Skip to content

Commit 7adb0a2

Browse files
Merge pull request #1134 from corbitsdev/cl-8418-print-resume-command-on-console-exit-in-corbits-code
feat(tui): print resume command on session exit
2 parents 14c79e1 + 3911651 commit 7adb0a2

9 files changed

Lines changed: 295 additions & 4 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
394394
| _(no verb)_ | — | Interactive session; optional trailing task text |
395395
| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) |
396396
| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. |
397-
| `--resume` | — | Open the interactive session picker |
397+
| `--resume [<session-id>]` | — | Open the interactive session picker, or reopen a specific session when an id is given |
398398
| `resume <session-id>` | — | Reopen a specific session |
399399
| `resume --pick` / `--list` | — | Interactive session picker |
400400
| `--cwd <dir>` | `process.cwd()` | Working directory |

‎src/config.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,56 @@ describe("loadConfig", () => {
779779
}
780780
});
781781

782+
test("--resume <id> reopens a known session by id", async () => {
783+
const cwd = await emptyCwd();
784+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
785+
try {
786+
const globalPath = await writeGlobalSettings(cwd);
787+
const sessionId = generateSessionId();
788+
await initSessionDir(cwd, sessionId, home);
789+
await saveState(
790+
cwd,
791+
sessionId,
792+
{
793+
status: "done",
794+
turnsUsed: 2,
795+
task: "ship resume",
796+
startedAt: Date.now() - 1_000,
797+
finishedAt: Date.now(),
798+
},
799+
home,
800+
);
801+
// The exact argv form the exit hint prints (`corbits resume <id>`).
802+
const config = await loadConfig(["--resume", sessionId, "--cwd", cwd], {
803+
globalSettingsPath: globalPath,
804+
home,
805+
});
806+
assertConfigured(config);
807+
expect(config.command).toBe("tui");
808+
expect(config.resumeMode).toBe("id");
809+
expect(config.sessionId).toBe(sessionId);
810+
expect(config.skipInitialTask).toBe(true);
811+
expect(config.task).toBe("ship resume");
812+
} finally {
813+
await rm(cwd, { recursive: true, force: true });
814+
await rm(home, { recursive: true, force: true });
815+
}
816+
});
817+
818+
test("--resume with a non-session-id token errors instead of leaking into task text", async () => {
819+
const cwd = await emptyCwd();
820+
try {
821+
const globalPath = await writeGlobalSettings(cwd);
822+
await expect(
823+
loadConfig(["--resume", "not-a-session", "--cwd", cwd], {
824+
globalSettingsPath: globalPath,
825+
}),
826+
).rejects.toThrow("'not-a-session' is not a session id");
827+
} finally {
828+
await rm(cwd, { recursive: true, force: true });
829+
}
830+
});
831+
782832
test("plain corbits always creates fresh state even when a previous session exists", async () => {
783833
const cwd = await emptyCwd();
784834
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));

‎src/config/index.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ Usage:
681681
682682
Continue verbs (project-keyed to this checkout's git toplevel):
683683
resume / continue interactive session picker
684-
--resume interactive session picker
684+
--resume [<session-id>] interactive session picker, or reopen a specific session
685685
resume <session-id> reopen a specific session
686686
resume --pick / --list interactive session picker
687687
@@ -691,7 +691,7 @@ Flags:
691691
--provider <name> configured provider name
692692
--model <id> model for the active provider
693693
--profile <name> settings profile
694-
--resume interactive session picker
694+
--resume [<session-id>] interactive session picker, or reopen a specific session
695695
--director <id> exec-only: run as this director (default: skywalker)
696696
--dangerously-skip-permissions
697697
skip permission prompts for this run only;
@@ -888,7 +888,24 @@ export async function loadConfig(
888888
if (resumeMode === "id") {
889889
throw new Error("cannot combine a session id with --resume");
890890
}
891-
resumeMode = "pick";
891+
// Optional session id: `corbits --resume <uuid>` reopens that session
892+
// directly (an alias for the `corbits resume <uuid>` form the exit
893+
// hint prints); bare `--resume` opens the
894+
// picker. A non-flag token that is not a session id errors exactly like
895+
// the `resume` verb path instead of leaking into task text.
896+
const next = args[i + 1];
897+
if (next !== undefined && !isFlagToken(next)) {
898+
if (!isSessionId(next)) {
899+
throw new Error(
900+
`'${next}' is not a session id. Use a UUID session id or \`corbits resume\` to choose.`,
901+
);
902+
}
903+
resumeMode = "id";
904+
resumeSessionId = next;
905+
i++;
906+
} else {
907+
resumeMode = "pick";
908+
}
892909
continue;
893910
}
894911
if ((arg === "--pick" || arg === "--list") && resumeMode !== undefined) {

‎src/process-handlers.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type CrashKind,
55
} from "./crash/report.js";
66
import { getActiveRun, markCrashed } from "./session/active-run.js";
7+
import { printResumeHint } from "./session/resume-hint.js";
78
import { getActiveDisposeHost } from "./session/active-host.js";
89
import { saveCrashState } from "./session/state.js";
910
import { classifyErrorClass } from "./telemetry/classify.js";
@@ -232,6 +233,14 @@ export function installSignalHandlers(options?: ProcessHandlerOptions): void {
232233
const teardown = awaitActiveDisposeHost(`handling ${signal}`);
233234
markCrashed();
234235
await teardown;
236+
// Capture before finalizing: the terminal write clears the slot.
237+
// Print before the finalize await below so the yielded disk I/O
238+
// flushes this through the stderr pipe before process.exit — a write
239+
// immediately preceding process.exit can otherwise be dropped.
240+
// printResumeHint is exactly-once per process, so a signal racing
241+
// the normal finalize tail cannot double-print the line.
242+
const run = getActiveRun();
243+
if (run !== null) printResumeHint(run.sessionId);
235244
await finalizeActiveRunOnSignal(signal);
236245
process.exit(128 + SIGNAL_EXIT_NUMBER[signal]);
237246
})();

‎src/session/resume-hint.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, spyOn, test } from "bun:test";
2+
3+
import {
4+
formatResumeHint,
5+
printResumeHint,
6+
resetResumeHintForTests,
7+
} from "./resume-hint.js";
8+
9+
describe("resume hint", () => {
10+
test("formats the resume command with the exited session id", () => {
11+
expect(formatResumeHint("123e4567-e89b-12d3-a456-426614174000")).toBe(
12+
"Run corbits resume 123e4567-e89b-12d3-a456-426614174000",
13+
);
14+
});
15+
16+
test("prints the hint line to stderr, leaving stdout clean", () => {
17+
resetResumeHintForTests();
18+
const outWrites: string[] = [];
19+
const errWrites: string[] = [];
20+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(((
21+
chunk: unknown,
22+
) => {
23+
outWrites.push(String(chunk));
24+
return true;
25+
}) as typeof process.stdout.write);
26+
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(((
27+
chunk: unknown,
28+
) => {
29+
errWrites.push(String(chunk));
30+
return true;
31+
}) as typeof process.stderr.write);
32+
try {
33+
printResumeHint("123e4567-e89b-12d3-a456-426614174000");
34+
} finally {
35+
stdoutSpy.mockRestore();
36+
stderrSpy.mockRestore();
37+
}
38+
expect(errWrites).toEqual([
39+
"Run corbits resume 123e4567-e89b-12d3-a456-426614174000\n",
40+
]);
41+
expect(outWrites).toEqual([]);
42+
});
43+
44+
test("prints at most once per process (signal racing finalize)", () => {
45+
resetResumeHintForTests();
46+
const errWrites: string[] = [];
47+
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(((
48+
chunk: unknown,
49+
) => {
50+
errWrites.push(String(chunk));
51+
return true;
52+
}) as typeof process.stderr.write);
53+
try {
54+
// First call wins (e.g. the signal handler); the finalize tail's call
55+
// for the same session is a no-op so the line emits exactly once.
56+
printResumeHint("123e4567-e89b-12d3-a456-426614174000");
57+
printResumeHint("123e4567-e89b-12d3-a456-426614174000");
58+
} finally {
59+
stderrSpy.mockRestore();
60+
}
61+
expect(errWrites).toEqual([
62+
"Run corbits resume 123e4567-e89b-12d3-a456-426614174000\n",
63+
]);
64+
});
65+
});

‎src/session/resume-hint.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { COMMAND_NAME } from "../branding.js";
2+
3+
// Printed when an interactive session ends so the operator can get back to
4+
// the exact session they just left. Kept in its own module (branding only)
5+
// so the process-level signal handlers can use it without pulling the TUI
6+
// or config graph — see the import-cost comment in process-handlers.ts.
7+
export function formatResumeHint(sessionId: string): string {
8+
return `Run ${COMMAND_NAME} resume ${sessionId}`;
9+
}
10+
11+
// Exactly-once per process. The normal quit tail (finalizeTUIRun) and the
12+
// external-signal handler (installSignalHandlers) both funnel through
13+
// printResumeHint, and a signal arriving mid-finalize would otherwise print
14+
// the line twice: the process-level `terminating` guard covers
15+
// signal-vs-signal only, never signal-vs-finalize. The flag lives here —
16+
// the single choke point — so every current and future caller shares it.
17+
let printed = false;
18+
19+
export function resetResumeHintForTests(): void {
20+
printed = false;
21+
}
22+
23+
export function printResumeHint(sessionId: string): void {
24+
if (printed) return;
25+
printed = true;
26+
// stderr, not stdout: an exec run killed by a signal must not pollute piped
27+
// stdout (JSON consumers). Matches the crash-report/fatal convention, which
28+
// also reports on stderr; on a restored terminal both streams are visible.
29+
process.stderr.write(`${formatResumeHint(sessionId)}\n`);
30+
}

‎src/tui/runner/exit.test.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
} from "@intx/types/runtime";
1717
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
1818
import { defined } from "../../../tests/helpers/defined.js";
19+
import { withMockedHomedir } from "../../../tests/helpers/mock-module.js";
20+
import { createTempDirs } from "../../../tests/helpers/temporary-dirs.js";
1921
import {
2022
createDeliveryGeneration,
2123
createSessionOperationQueue,
@@ -30,6 +32,10 @@ import {
3032
COMPACTION_ABORTED_REASON,
3133
createCompactionLifecycle,
3234
} from "../../session/compaction-lifecycle.js";
35+
import {
36+
printResumeHint,
37+
resetResumeHintForTests,
38+
} from "../../session/resume-hint.js";
3339
import type { RunnerServices, RunnerState } from "./state.js";
3440

3541
function stubQuit(args: {
@@ -196,6 +202,104 @@ describe("finalizeTUIRun quit order", () => {
196202
});
197203
});
198204

205+
describe("finalizeTUIRun resume hint", () => {
206+
test("prints the resume command with the exited session id", async () => {
207+
resetResumeHintForTests();
208+
const dirs = createTempDirs(
209+
"corbits-resume-hint-cwd-",
210+
"corbits-resume-hint-home-",
211+
);
212+
const sessionId = "123e4567-e89b-12d3-a456-426614174000";
213+
const { state, services } = stubQuit({
214+
awaitTail: async () => undefined,
215+
shutdownRuntime: async () => undefined,
216+
});
217+
state.sessionId = sessionId;
218+
(
219+
services as unknown as { activatedToolNames: { list: () => string[] } }
220+
).activatedToolNames = { list: () => [] };
221+
(state.config as { cwd: string }).cwd = dirs.cwd;
222+
const outWrites: string[] = [];
223+
const errWrites: string[] = [];
224+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(((
225+
chunk: unknown,
226+
) => {
227+
outWrites.push(String(chunk));
228+
return true;
229+
}) as typeof process.stdout.write);
230+
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(((
231+
chunk: unknown,
232+
) => {
233+
errWrites.push(String(chunk));
234+
return true;
235+
}) as typeof process.stderr.write);
236+
try {
237+
const code = await withMockedHomedir(dirs.home, () =>
238+
finalizeTUIRun(state, services),
239+
);
240+
expect(code).toBe(0);
241+
} finally {
242+
stdoutSpy.mockRestore();
243+
stderrSpy.mockRestore();
244+
dirs.cleanup();
245+
}
246+
// stderr, not stdout: a piped stdout (JSON consumers) must stay clean.
247+
expect(
248+
errWrites.some((w) => w === `Run corbits resume ${sessionId}\n`),
249+
).toBe(true);
250+
expect(outWrites.some((w) => w.includes("resume"))).toBe(false);
251+
});
252+
253+
test("an external signal racing finalize prints the hint exactly once", async () => {
254+
resetResumeHintForTests();
255+
const dirs = createTempDirs(
256+
"corbits-resume-hint-race-cwd-",
257+
"corbits-resume-hint-race-home-",
258+
);
259+
const sessionId = "123e4567-e89b-12d3-a456-426614174000";
260+
let releaseTail: (() => void) | undefined;
261+
const gatedTail = new Promise<void>((resolve) => {
262+
releaseTail = resolve;
263+
});
264+
const { state, services } = stubQuit({
265+
awaitTail: () => gatedTail,
266+
shutdownRuntime: async () => undefined,
267+
});
268+
state.sessionId = sessionId;
269+
(
270+
services as unknown as { activatedToolNames: { list: () => string[] } }
271+
).activatedToolNames = { list: () => [] };
272+
(state.config as { cwd: string }).cwd = dirs.cwd;
273+
const errWrites: string[] = [];
274+
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(((
275+
chunk: unknown,
276+
) => {
277+
errWrites.push(String(chunk));
278+
return true;
279+
}) as typeof process.stderr.write);
280+
try {
281+
const pending = withMockedHomedir(dirs.home, () =>
282+
finalizeTUIRun(state, services),
283+
);
284+
// Simulate the signal handler firing mid-finalize: both paths funnel
285+
// through printResumeHint, so the shared once-flag keeps exactly one
286+
// line. (The process-level `terminating` guard covers signal-vs-signal
287+
// only — it cannot see the finalize tail already in flight.)
288+
await new Promise((resolve) => setTimeout(resolve, 50));
289+
printResumeHint(sessionId);
290+
defined(releaseTail, "releaseTail")();
291+
expect(await pending).toBe(0);
292+
} finally {
293+
stderrSpy.mockRestore();
294+
dirs.cleanup();
295+
}
296+
const hintLines = errWrites.filter(
297+
(w) => w === `Run corbits resume ${sessionId}\n`,
298+
);
299+
expect(hintLines).toHaveLength(1);
300+
});
301+
});
302+
199303
const liveSource: InferenceSource = {
200304
id: "codex/work",
201305
provider: "openai",

‎src/tui/runner/exit.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
resolveSessionLabel,
3939
truncateSessionLabel,
4040
} from "../../session/session-label.js";
41+
import { printResumeHint } from "../../session/resume-hint.js";
4142
import { clearActiveDisposeHost } from "../../session/active-host.js";
4243
import { syncRunStateHandle } from "../../session/active-run.js";
4344
import { startRunHeartbeat } from "../../session/run-liveness.js";
@@ -863,6 +864,11 @@ export async function finalizeTUIRun(
863864
});
864865
}
865866

867+
// Last word on the restored terminal: every normal quit (exit/quit command,
868+
// Ctrl+C, session end) funnels through here, so the exited session's id is
869+
// always the one printed.
870+
printResumeHint(state.sessionId);
871+
866872
return resolveExitCode({
867873
runError: state.runError,
868874
sinkError,

‎tests/integration/signal-finalize.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ describe("integration — signal finalizes run.json", () => {
4141

4242
expect(exitCode).toBe(expectedExitCode);
4343

44+
// The interrupted session must name itself so the operator can
45+
// resume exactly the session they just left. The hint goes to
46+
// stderr — never stdout — so piped stdout (JSON consumers) stays
47+
// clean. An external signal racing the finalize tail must still
48+
// emit the line exactly once (shared once-flag in resume-hint).
49+
const errText = await new Response(proc.stderr).text();
50+
const hint = `Run corbits resume ${sessionId}`;
51+
expect(errText).toContain(hint);
52+
expect(errText.split(hint).length - 1).toBe(1);
53+
4454
// The fixture parks two unawaited straggler "running" snapshot writes
4555
// behind setTestWriteGate and releases them only after markCrashed()
4656
// flips. Without that fence on the signal path, one of those renames

0 commit comments

Comments
 (0)