Skip to content

Commit 2337e35

Browse files
ralyodioclaude
andcommitted
tui: say so when a hand-off exits without running
"deepseek exited (code 0). back in the pit." is what the pit printed for a CLI that never ran a single line, and it reads as a clean session — so the bug looked like it was in the hand-off rather than in the engine. It is a real failure mode, not a hypothetical: @serjm/deepseek-code 0.5.0 decides whether it is the entrypoint by comparing resolve(process.argv[1]) against import.meta.url, and npm installs every global bin as a symlink, so the comparison never matches, the program falls off the end having done nothing, and exits 0. The child owns the terminal, so its output is not ours to read — but the clock is enough. An exit of 0 in under a second and a half did not host a session anybody used, and saying "this usually means a broken install" points at the right program without claiming to know why. Only for silent success: a non-zero exit already says something went wrong, and the engine has usually printed why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b1216fc commit 2337e35

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

src/tui.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,35 @@ let activeMirror = null;
464464
*/
465465
const childSink = () => (activeMirror ? (chunk) => activeMirror?.write(chunk) : undefined);
466466

467+
/**
468+
* How fast an exit has to be before it is worth remarking on.
469+
*
470+
* A person who opens an agent and immediately quits takes longer than this.
471+
* Nothing that actually started a session lands under it.
472+
*/
473+
const INSTANT_EXIT_MS = 1500;
474+
475+
/**
476+
* The note for a hand-off that ended the moment it began, or null.
477+
*
478+
* A CLI that exits 0 without doing anything is indistinguishable, from out
479+
* here, from one the operator opened and closed — both are "exited (code 0)",
480+
* which reads as success and sent somebody looking for the bug in the wrong
481+
* program. It happens for real: @serjm/deepseek-code 0.5.0 compares
482+
* `resolve(process.argv[1])` against `import.meta.url` to decide whether it is
483+
* the entrypoint, and npm installs every global bin as a symlink, so the
484+
* comparison fails and the whole CLI silently runs nothing.
485+
*
486+
* Timing is all we have — the child owns the terminal, so its output is not
487+
* ours to inspect — and it is enough to say "this looks wrong" without
488+
* claiming to know why.
489+
*/
490+
export function instantExitNote({ key, bin, code, ms }) {
491+
if (code !== 0 || ms >= INSTANT_EXIT_MS) return null;
492+
return `${key} exited instantly without running — that usually means a broken install, not a clean session.`
493+
+ ` check it directly with \`${bin} --version\`, and reinstall with /install ${key} if that prints nothing.`;
494+
}
495+
467496
async function openEngine(key, engine, args, { agentMode = false } = {}) {
468497
if (!engine.installed && !args.length) {
469498
console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
@@ -479,7 +508,9 @@ async function openEngine(key, engine, args, { agentMode = false } = {}) {
479508
console.log(info(`opening ${bone(key)}${agentMode ? " autonomously" : " raw"} — hand-off to its CLI, exit it to come back…`));
480509
console.log(hr());
481510
activeMirror?.setEngine(key);
511+
const startedAt = Date.now();
482512
const r = await openSession(engine, agentMode ? agentLaunchArgs(engine, args) : args, { onOutput: childSink() });
513+
const elapsed = Date.now() - startedAt;
483514
activeMirror?.setEngine(null);
484515
console.log(hr());
485516
if (!r.ok) {
@@ -488,6 +519,8 @@ async function openEngine(key, engine, args, { agentMode = false } = {}) {
488519
: err(`couldn't launch ${key}: ${r.error?.message || r.error}`));
489520
} else {
490521
console.log(info(`${key} exited${r.code != null ? ` (code ${r.code})` : ""}. back in the pit.`));
522+
const note = instantExitNote({ key, bin: engine.bin, code: r.code, ms: elapsed });
523+
if (note) console.log(warn(note));
491524
}
492525
}
493526

@@ -497,14 +530,18 @@ async function openWorkflowTool(key, tool, args) {
497530
}
498531
console.log(info(`opening ${bone(key)} — native CLI owns the terminal until it exits…`));
499532
console.log(hr());
533+
const toolStartedAt = Date.now();
500534
const result = await openTool(tool, args, { onOutput: childSink() });
535+
const toolElapsed = Date.now() - toolStartedAt;
501536
console.log(hr());
502537
if (!result.ok) {
503538
console.log(result.error?.code === "ENOENT"
504539
? err(`${key} isn't on PATH (\`${tool.bin}\`). install it with /install ${key}`)
505540
: err(`couldn't launch ${key}: ${result.error?.message || result.error}`));
506541
} else {
507542
console.log(info(`${key} exited${result.code != null ? ` (code ${result.code})` : result.signal ? ` (${result.signal})` : ""}. back in the pit.`));
543+
const note = instantExitNote({ key, bin: tool.bin, code: result.code, ms: toolElapsed });
544+
if (note) console.log(warn(note));
508545
}
509546
}
510547

test/tui.test.mjs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { join } from "node:path";
66
import test from "node:test";
77
import { fileURLToPath } from "node:url";
88

9-
import { splitCommandLine } from "../src/tui.mjs";
9+
import { instantExitNote, splitCommandLine } from "../src/tui.mjs";
1010

1111
const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
1212

@@ -233,3 +233,27 @@ test("TUI tightens a history file that was already world-readable", posixMode, a
233233
assert.equal(statSync(file).mode & 0o777, 0o600);
234234
assert.match(readFileSync(file, "utf8"), /Bearer sk-live/); // history itself survives
235235
});
236+
237+
test("an engine that exits instantly is called out, not reported as a clean session", () => {
238+
// @serjm/deepseek-code 0.5.0 compares resolve(process.argv[1]) against
239+
// import.meta.url to decide whether it is the entrypoint. npm installs every
240+
// global bin as a symlink, so that comparison fails and the CLI runs nothing
241+
// and exits 0 — which the pit printed as "exited (code 0). back in the pit."
242+
const note = instantExitNote({ key: "deepseek", bin: "deepseek-code", code: 0, ms: 40 });
243+
assert.match(note, /exited instantly/);
244+
assert.match(note, /deepseek-code --version/);
245+
assert.match(note, /\/install deepseek/);
246+
});
247+
248+
test("a session someone actually used says nothing extra", () => {
249+
assert.equal(instantExitNote({ key: "claude", bin: "claude", code: 0, ms: 90_000 }), null);
250+
// Right at the line: only faster than the threshold counts.
251+
assert.equal(instantExitNote({ key: "claude", bin: "claude", code: 0, ms: 1500 }), null);
252+
});
253+
254+
test("a real failure is left to the exit code — this note is only for silent success", () => {
255+
// A non-zero exit already says something went wrong, and the engine has
256+
// usually printed why. Adding "that was fast" on top would be noise.
257+
assert.equal(instantExitNote({ key: "codex", bin: "codex", code: 1, ms: 30 }), null);
258+
assert.equal(instantExitNote({ key: "codex", bin: "codex", code: null, ms: 30 }), null);
259+
});

0 commit comments

Comments
 (0)