Skip to content

Commit 8e679a2

Browse files
authored
Merge pull request #55 from TeigenZhang/fix/auto-attach-on-restart
fix: auto-attach PTY on server restart
2 parents 53b4737 + 28a6247 commit 8e679a2

File tree

8 files changed

+970
-73
lines changed

8 files changed

+970
-73
lines changed

dist/state-store.js

Lines changed: 822 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
# Claudeman launchd wrapper
3+
#
4+
# 根因: Node 25 被 launchd 直接拉起时 V8 bootstrapper 概率性死锁
5+
# (进程存在、端口不监听、日志空白、sample 显示卡在 LoadEnvironment)
6+
# 手动 nohup 同样环境则正常。通过 bash wrapper + exec 绕过此问题。
7+
#
8+
# 额外加固:
9+
# - 启动前清理占 3000 端口的野进程
10+
# - 写启动日志到 stderr(被 launchd 重定向到 StandardErrorPath)
11+
12+
set -euo pipefail
13+
14+
PORT=3000
15+
CLAUDEMAN_DIR="/Users/teigen/Documents/Workspace/AI_project/Claudeman"
16+
17+
export HOME=/Users/teigen
18+
export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
19+
20+
echo "[wrapper] $(date '+%Y-%m-%d %H:%M:%S') starting claudeman web" >&2
21+
22+
# 清理占端口的野进程(非本进程树的残留 node)
23+
STALE_PIDS=$(/usr/sbin/lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t 2>/dev/null || true)
24+
if [[ -n "$STALE_PIDS" ]]; then
25+
echo "[wrapper] clearing stale processes on port ${PORT}: ${STALE_PIDS}" >&2
26+
for pid in $STALE_PIDS; do
27+
kill "$pid" 2>/dev/null || true
28+
done
29+
sleep 2
30+
fi
31+
32+
cd "$CLAUDEMAN_DIR"
33+
exec /opt/homebrew/bin/node dist/index.js web --https -p "$PORT"

src/state-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export class StateStore {
309309

310310
this.ensureDir();
311311

312-
const tempPath = this.filePath + '.tmp';
312+
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
313313
const backupPath = this.filePath + '.bak';
314314

315315
// Step 1: Serialize state (validates it's JSON-safe)
@@ -373,7 +373,7 @@ export class StateStore {
373373

374374
this.ensureDir();
375375

376-
const tempPath = this.filePath + '.tmp';
376+
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
377377
const backupPath = this.filePath + '.bak';
378378

379379
const json = this.serializeState();

src/web/public/app.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,8 +2105,9 @@ class CodemanApp {
21052105

21062106
// Track working directory for path normalization in Project Insights
21072107
this.currentSessionWorkingDir = session?.workingDir || null;
2108-
if (session && session.pid === null && session.status === 'idle') {
2109-
// This is a restored session - attach to the existing screen/shell
2108+
if (session && session.pid === null && !session._ended) {
2109+
// Session has no PTY attached — either restored after server restart
2110+
// or detached for some other reason. Re-attach regardless of status.
21102111
try {
21112112
const endpoint = session.mode === 'shell'
21122113
? `/api/sessions/${sessionId}/shell`

src/web/public/keyboard-accessory.js

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Defines two exports:
55
*
66
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
7-
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
7+
* keyboard on mobile: Esc, arrow up/down, Tab, Shift+Tab, Ctrl+O, /init, /clear, /compact, paste, and dismiss.
88
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
99
* Commands are sent as text + Enter separately for Ink compatibility.
1010
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
@@ -53,17 +53,32 @@ const KeyboardAccessoryBar = {
5353
<path d="M19 9l-7 7-7-7"/>
5454
</svg>
5555
</button>
56-
<button class="accessory-btn" data-action="init" title="/init">/init</button>
57-
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
58-
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
56+
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
57+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
58+
<path d="M15 19l-7-7 7-7"/>
59+
</svg>
60+
</button>
61+
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
62+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
63+
<path d="M9 5l7 7-7 7"/>
64+
</svg>
65+
</button>
5966
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
6067
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
6168
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
6269
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
6370
</svg>
6471
</button>
65-
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
66-
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
72+
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
73+
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
74+
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
75+
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
76+
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
77+
<button class="accessory-btn" data-action="init" title="/init">/init</button>
78+
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
79+
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
80+
<button class="accessory-btn accessory-btn-arrow" data-action="dismiss" title="Dismiss keyboard">
81+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
6782
<path d="M19 9l-7 7-7-7"/>
6883
</svg>
6984
</button>
@@ -80,7 +95,7 @@ const KeyboardAccessoryBar = {
8095
this.handleAction(action, btn);
8196

8297
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
83-
if ((action === 'scroll-up' || action === 'scroll-down') ||
98+
if ((action === 'scroll-up' || action === 'scroll-down' || action === 'arrow-left' || action === 'arrow-right' || action === 'tab' || action === 'shift-tab' || action === 'ctrl-o' || action === 'opt-enter' || action === 'esc') ||
8499
((action === 'clear' || action === 'compact') && this._confirmAction)) {
85100
if (typeof app !== 'undefined' && app.terminal) {
86101
app.terminal.focus();
@@ -109,6 +124,27 @@ const KeyboardAccessoryBar = {
109124
case 'scroll-down':
110125
this.sendKey('\x1b[B');
111126
break;
127+
case 'arrow-left':
128+
this.sendKey('\x1b[D');
129+
break;
130+
case 'arrow-right':
131+
this.sendKey('\x1b[C');
132+
break;
133+
case 'esc':
134+
this.sendKey('\x1b');
135+
break;
136+
case 'opt-enter':
137+
this.sendKey('\x1b\r');
138+
break;
139+
case 'tab':
140+
this.sendKey('\t');
141+
break;
142+
case 'shift-tab':
143+
this.sendKey('\x1b[Z');
144+
break;
145+
case 'ctrl-o':
146+
this.sendKey('\x0f');
147+
break;
112148
case 'init':
113149
this.sendCommand('/init');
114150
break;

src/web/public/mobile.css

Lines changed: 15 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -886,7 +886,8 @@ html.mobile-init .file-browser-panel {
886886
padding-right: calc(8px + var(--safe-area-right));
887887
gap: 8px;
888888
align-items: center;
889-
justify-content: center;
889+
overflow-x: auto;
890+
-webkit-overflow-scrolling: touch;
890891
z-index: 51;
891892
transition: transform 0.15s ease-out;
892893
will-change: transform;
@@ -896,10 +897,16 @@ html.mobile-init .file-browser-panel {
896897
display: flex;
897898
}
898899

900+
/* 隐藏滚动条但保留滑动能力 */
901+
.keyboard-accessory-bar::-webkit-scrollbar {
902+
display: none;
903+
}
904+
899905
.accessory-btn {
900906
display: inline-flex;
901907
align-items: center;
902908
justify-content: center;
909+
flex-shrink: 0;
903910
gap: 4px;
904911
padding: 6px 12px;
905912
background: #2a2a2a;
@@ -939,24 +946,6 @@ html.mobile-init .file-browser-panel {
939946
background: #2563eb;
940947
}
941948

942-
.accessory-btn-dismiss {
943-
padding: 8px 14px;
944-
background: #2a2a2a;
945-
border: 1.5px solid rgba(255, 255, 255, 0.25);
946-
border-radius: 6px;
947-
color: #e5e5e5;
948-
}
949-
950-
.accessory-btn-dismiss svg {
951-
width: 22px;
952-
height: 22px;
953-
stroke-width: 3;
954-
}
955-
956-
.accessory-btn-dismiss:active {
957-
background: #3a3a3a;
958-
}
959-
960949
/* Voice preview — positioned above accessory bar on mobile */
961950
.voice-preview {
962951
bottom: calc(var(--safe-area-bottom) + 94px);
@@ -2068,18 +2057,24 @@ html.mobile-init .file-browser-panel {
20682057
padding: 6px 8px;
20692058
gap: 8px;
20702059
align-items: center;
2071-
justify-content: center;
2060+
overflow-x: auto;
2061+
-webkit-overflow-scrolling: touch;
20722062
z-index: 51;
20732063
}
20742064

20752065
.keyboard-accessory-bar.visible {
20762066
display: flex;
20772067
}
20782068

2069+
.keyboard-accessory-bar::-webkit-scrollbar {
2070+
display: none;
2071+
}
2072+
20792073
.accessory-btn {
20802074
display: inline-flex;
20812075
align-items: center;
20822076
justify-content: center;
2077+
flex-shrink: 0;
20832078
gap: 4px;
20842079
padding: 6px 12px;
20852080
background: #2a2a2a;
@@ -2118,24 +2113,6 @@ html.mobile-init .file-browser-panel {
21182113
background: #2563eb;
21192114
}
21202115

2121-
.accessory-btn-dismiss {
2122-
padding: 8px 14px;
2123-
background: #2a2a2a;
2124-
border: 1.5px solid rgba(255, 255, 255, 0.25);
2125-
border-radius: 6px;
2126-
color: #e5e5e5;
2127-
}
2128-
2129-
.accessory-btn-dismiss svg {
2130-
width: 22px;
2131-
height: 22px;
2132-
stroke-width: 3;
2133-
}
2134-
2135-
.accessory-btn-dismiss:active {
2136-
background: #3a3a3a;
2137-
}
2138-
21392116
/* ============================================================================
21402117
iOS Safari Specific Fixes
21412118
============================================================================ */

src/web/public/terminal-ui.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ Object.assign(CodemanApp.prototype, {
179179
// Accumulate sub-line pixel deltas so slow swipes still scroll
180180
let pixelAccum = 0;
181181

182+
let didScroll = false; // track whether touchmove fired (tap vs scroll)
182183
container.addEventListener(
183184
'touchstart',
184185
(ev) => {
@@ -187,6 +188,7 @@ Object.assign(CodemanApp.prototype, {
187188
velocity = 0;
188189
pixelAccum = 0;
189190
isTouching = true;
191+
didScroll = false;
190192
lastTime = 0;
191193
if (scrollFrame) {
192194
cancelAnimationFrame(scrollFrame);
@@ -201,6 +203,7 @@ Object.assign(CodemanApp.prototype, {
201203
'touchmove',
202204
(ev) => {
203205
if (ev.touches.length === 1 && isTouching) {
206+
didScroll = true;
204207
const touchY = ev.touches[0].clientY;
205208
const delta = touchLastY - touchY; // positive = scroll down
206209
pixelAccum += delta;
@@ -225,6 +228,12 @@ Object.assign(CodemanApp.prototype, {
225228
if (!scrollFrame && Math.abs(velocity) > 0.3) {
226229
scrollFrame = requestAnimationFrame(scrollLoop);
227230
}
231+
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
232+
// routes back to the terminal. Without this, a tap on the terminal area
233+
// consumes the touch event but xterm's textarea never regains focus.
234+
if (!didScroll && this.terminal) {
235+
this.terminal.focus();
236+
}
228237
},
229238
{ passive: true }
230239
);
@@ -284,22 +293,6 @@ Object.assign(CodemanApp.prototype, {
284293
}
285294
this.flushFlickerBuffer();
286295
}
287-
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
288-
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
289-
// pushing overflow into scrollback. Ink's cursor-up count is based on the
290-
// pre-reflow line count, so ghost renders accumulate in scrollback.
291-
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
292-
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
293-
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
294-
if (
295-
activeResizeSession &&
296-
activeResizeSession.mode !== 'shell' &&
297-
!activeResizeSession._ended &&
298-
this.terminal &&
299-
this.isTerminalAtBottom()
300-
) {
301-
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
302-
}
303296
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
304297
// causes Ink to re-render at the new row count, garbling terminal output.
305298
// Local fit() still runs so xterm knows the viewport size for scrolling.
@@ -311,6 +304,24 @@ Object.assign(CodemanApp.prototype, {
311304
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
312305
// Only send resize if dimensions actually changed
313306
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
307+
// Clear viewport + scrollback ONLY when dimensions actually change.
308+
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
309+
// pushing overflow into scrollback. Ink's cursor-up count is based on the
310+
// pre-reflow line count, so ghost renders accumulate in scrollback.
311+
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
312+
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
313+
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
314+
// Clearing without a subsequent Ink redraw leaves the terminal blank.
315+
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
316+
if (
317+
activeResizeSession &&
318+
activeResizeSession.mode !== 'shell' &&
319+
!activeResizeSession._ended &&
320+
this.terminal &&
321+
this.isTerminalAtBottom()
322+
) {
323+
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
324+
}
314325
this._lastResizeDims = { cols, rows };
315326
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
316327
method: 'POST',
@@ -1348,6 +1359,10 @@ Object.assign(CodemanApp.prototype, {
13481359
if (this.fitAddon) this.fitAddon.fit();
13491360
const dims = this.getTerminalDimensions();
13501361
if (!dims) return;
1362+
// Update _lastResizeDims so the throttledResize handler won't redundantly
1363+
// clear the terminal for the same dimensions (which would blank the screen
1364+
// without a subsequent Ink redraw to repaint it).
1365+
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
13511366
// Fast path: WebSocket resize
13521367
if (this._wsReady && this._wsSessionId === sessionId) {
13531368
try {

src/web/server.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,15 +1767,28 @@ export class WebServer extends EventEmitter {
17671767

17681768
this.sessions.set(session.id, session);
17691769
await this.setupSessionListeners(session);
1770-
this.persistSessionState(session);
17711770

1772-
// Mark it as restored (not started yet - user needs to attach)
1773-
getLifecycleLog().log({
1774-
event: 'recovered',
1775-
sessionId: session.id,
1776-
name: session.name,
1777-
});
1778-
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
1771+
// Auto-attach PTY to the surviving tmux session immediately.
1772+
// This ensures ALL sessions resume capturing output right away,
1773+
// not just the one the client happens to select first.
1774+
try {
1775+
await session.startInteractive();
1776+
getLifecycleLog().log({
1777+
event: 'recovered',
1778+
sessionId: session.id,
1779+
name: session.name,
1780+
});
1781+
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
1782+
} catch (attachErr) {
1783+
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
1784+
getLifecycleLog().log({
1785+
event: 'recovered',
1786+
sessionId: session.id,
1787+
name: session.name,
1788+
});
1789+
}
1790+
1791+
this.persistSessionState(session);
17791792
}
17801793
}
17811794

0 commit comments

Comments
 (0)