Skip to content

Commit abbc4b1

Browse files
authored
Merge pull request #39 from sunnyzhouy/master
feat: session resume, xterm.js 6.0 upgrade, and resize fix
2 parents a14e47e + 754a966 commit abbc4b1

File tree

17 files changed

+812
-236
lines changed

17 files changed

+812
-236
lines changed

package-lock.json

Lines changed: 39 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
"@fastify/compress": "^8.3.1",
5252
"@fastify/cookie": "^11.0.2",
5353
"@fastify/static": "^8.0.0",
54+
"@xterm/addon-fit": "^0.11.0",
55+
"@xterm/addon-unicode11": "^0.9.0",
56+
"@xterm/addon-webgl": "^0.19.0",
57+
"@xterm/xterm": "^6.0.0",
5458
"chalk": "^5.3.0",
5559
"chokidar": "^3.6.0",
5660
"commander": "^12.1.0",
@@ -59,10 +63,6 @@
5963
"qrcode": "^1.5.4",
6064
"uuid": "^10.0.0",
6165
"web-push": "^3.6.7",
62-
"xterm": "^5.3.0",
63-
"xterm-addon-fit": "^0.8.0",
64-
"xterm-addon-unicode11": "^0.6.0",
65-
"xterm-addon-webgl": "^0.16.0",
6666
"zod": "^4.3.6"
6767
},
6868
"devDependencies": {

scripts/build.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
3535
run('copy web assets', 'cp -r src/web/public dist/web/');
3636
run('copy template', 'cp src/templates/case-template.md dist/templates/');
3737

38-
// 3. Vendor xterm bundles
39-
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
40-
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
41-
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
42-
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
43-
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
38+
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
39+
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
40+
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
41+
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
42+
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
43+
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
4444
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
4545

4646
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`

scripts/postinstall.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,10 @@ if (isGlobalInstall) {
250250
} else {
251251
try {
252252
const require = createRequire(import.meta.url);
253-
const xtermDir = join(require.resolve('xterm'), '..', '..');
254-
const fitDir = join(require.resolve('xterm-addon-fit'), '..', '..');
255-
const webglDir = join(require.resolve('xterm-addon-webgl'), '..', '..');
256-
const unicode11Dir = join(require.resolve('xterm-addon-unicode11'), '..', '..');
253+
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
254+
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
255+
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
256+
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
257257
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
258258

259259
const { mkdirSync, copyFileSync } = await import('fs');
@@ -263,19 +263,19 @@ if (isGlobalInstall) {
263263
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
264264
try {
265265
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
266-
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
267-
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
266+
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
267+
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
268268
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
269269
} catch {
270270
// Fallback: copy unminified
271271
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
272-
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
273-
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
272+
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
273+
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
274274
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
275275
}
276276

277277
// WebGL addon: copy unminified (matches build script behavior)
278-
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
278+
copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
279279

280280
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
281281
try {

src/mux-interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface CreateSessionOptions {
6161
claudeMode?: ClaudeMode;
6262
allowedTools?: string;
6363
openCodeConfig?: OpenCodeConfig;
64+
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
65+
resumeSessionId?: string;
6466
}
6567

6668
/** Options for respawning a dead pane. */
@@ -73,6 +75,8 @@ export interface RespawnPaneOptions {
7375
claudeMode?: ClaudeMode;
7476
allowedTools?: string;
7577
openCodeConfig?: OpenCodeConfig;
78+
/** Resume a previous Claude conversation when respawning */
79+
resumeSessionId?: string;
7680
}
7781

7882
/**

src/session.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export class Session extends EventEmitter {
324324

325325
// OpenCode configuration (only for mode === 'opencode')
326326
private _openCodeConfig: OpenCodeConfig | undefined;
327+
private _resumeSessionId: string | undefined;
327328

328329
// Session color for visual differentiation
329330
private _color: import('./types.js').SessionColor = 'default';
@@ -382,6 +383,8 @@ export class Session extends EventEmitter {
382383
allowedTools?: string;
383384
/** OpenCode configuration (only for mode === 'opencode') */
384385
openCodeConfig?: OpenCodeConfig;
386+
/** Resume a previous Claude conversation (used after server reboot) */
387+
resumeSessionId?: string;
385388
}
386389
) {
387390
super();
@@ -398,12 +401,10 @@ export class Session extends EventEmitter {
398401
this.createdAt = config.createdAt || Date.now();
399402
this.mode = config.mode || 'claude';
400403
this._name = config.name || '';
404+
this._resumeSessionId = config.resumeSessionId;
401405
this._lastActivityAt = this.createdAt;
402-
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id}
403-
// to Claude CLI, so the Claude session ID always matches the Codeman session ID.
404-
// This ensures subagent matching works even for recovered sessions (where
405-
// startInteractive() hasn't been called yet).
406-
this._claudeSessionId = this.id;
406+
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
407+
this._claudeSessionId = config.resumeSessionId || this.id;
407408
this._mux = config.mux || null;
408409
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
409410
this._muxSession = config.muxSession || null;
@@ -792,6 +793,7 @@ export class Session extends EventEmitter {
792793
cliAccountType: this._cliAccountType || undefined,
793794
cliLatestVersion: this._cliLatestVersion || undefined,
794795
openCodeConfig: this._openCodeConfig,
796+
resumeSessionId: this._resumeSessionId,
795797
};
796798
}
797799

@@ -910,6 +912,7 @@ export class Session extends EventEmitter {
910912
claudeMode: this._claudeMode,
911913
allowedTools: this._allowedTools,
912914
openCodeConfig: this._openCodeConfig,
915+
resumeSessionId: this._resumeSessionId,
913916
});
914917
if (!newPid) {
915918
console.error('[Session] Failed to respawn pane, will create new session');
@@ -936,6 +939,7 @@ export class Session extends EventEmitter {
936939
claudeMode: this._claudeMode,
937940
allowedTools: this._allowedTools,
938941
openCodeConfig: this._openCodeConfig,
942+
resumeSessionId: this._resumeSessionId,
939943
});
940944
console.log('[Session] Created mux session:', this._muxSession.muxName);
941945
// No extra sleep — createSession() already waits for tmux readiness

src/tmux-manager.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,24 @@ function buildSpawnCommand(options: {
200200
claudeMode?: ClaudeMode;
201201
allowedTools?: string;
202202
openCodeConfig?: OpenCodeConfig;
203+
resumeSessionId?: string;
203204
}): string {
204205
if (options.mode === 'claude') {
205206
// Validate model to prevent command injection
206207
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
207208
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
208-
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
209+
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
210+
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
211+
// fall back to a new session with --session-id so the pane doesn't die.
212+
const safeResumeId =
213+
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
214+
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
215+
if (safeResumeId) {
216+
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
217+
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
218+
return `${resumeCmd} || ${fallbackCmd}`;
219+
}
220+
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
209221
}
210222
if (options.mode === 'opencode') {
211223
return buildOpenCodeCommand(options.openCodeConfig);
@@ -370,7 +382,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
370382
* In test mode: creates an in-memory session only (no real tmux session).
371383
*/
372384
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
373-
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
385+
const {
386+
sessionId,
387+
workingDir,
388+
mode,
389+
name,
390+
niceConfig,
391+
model,
392+
claudeMode,
393+
allowedTools,
394+
openCodeConfig,
395+
resumeSessionId,
396+
} = options;
374397
const muxName = `codeman-${sessionId.slice(0, 8)}`;
375398

376399
if (!isValidMuxName(muxName)) {
@@ -433,6 +456,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
433456
claudeMode,
434457
allowedTools,
435458
openCodeConfig,
459+
resumeSessionId,
436460
});
437461

438462
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -605,7 +629,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
605629
* preserving the session and its scrollback buffer.
606630
*/
607631
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
608-
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
632+
const {
633+
sessionId,
634+
workingDir,
635+
mode,
636+
niceConfig,
637+
model,
638+
claudeMode,
639+
allowedTools,
640+
openCodeConfig,
641+
resumeSessionId,
642+
} = options;
609643
const session = this.sessions.get(sessionId);
610644
if (!session) return null;
611645
const muxName = session.muxName;
@@ -641,6 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
641675
claudeMode,
642676
allowedTools,
643677
openCodeConfig,
678+
resumeSessionId,
644679
});
645680
const config = niceConfig || DEFAULT_NICE_CONFIG;
646681
const cmd = wrapWithNice(baseCmd, config);

src/types/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ export interface SessionState {
143143
cliLatestVersion?: string;
144144
/** OpenCode-specific configuration (only for mode === 'opencode') */
145145
openCodeConfig?: OpenCodeConfig;
146+
/** Claude conversation session ID to resume after reboot (set by restore script) */
147+
resumeSessionId?: string;
146148
}
147149

148150
/**

0 commit comments

Comments
 (0)