@crouton-kit/crouter 0.3.8 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli.js +14 -24
  2. package/dist/commands/agent.d.ts +4 -0
  3. package/dist/commands/agent.js +444 -243
  4. package/dist/commands/debug.d.ts +1 -1
  5. package/dist/commands/debug.js +20 -7
  6. package/dist/commands/human.js +51 -19
  7. package/dist/commands/job.d.ts +9 -0
  8. package/dist/commands/job.js +50 -10
  9. package/dist/commands/mode.d.ts +2 -0
  10. package/dist/commands/mode.js +231 -0
  11. package/dist/commands/pkg.js +5 -0
  12. package/dist/commands/plan.d.ts +1 -1
  13. package/dist/commands/plan.js +24 -11
  14. package/dist/commands/skill.js +20 -4
  15. package/dist/commands/spec.d.ts +1 -1
  16. package/dist/commands/spec.js +24 -11
  17. package/dist/commands/sys.js +5 -0
  18. package/dist/core/__tests__/job.test.js +11 -11
  19. package/dist/core/__tests__/jobs.test.js +33 -1
  20. package/dist/core/__tests__/resolver.test.js +69 -1
  21. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  22. package/dist/core/__tests__/spawn.test.js +138 -0
  23. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  24. package/dist/core/__tests__/subagents.test.js +75 -0
  25. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  26. package/dist/core/__tests__/unknown-path.test.js +52 -0
  27. package/dist/core/bootstrap.d.ts +2 -0
  28. package/dist/core/bootstrap.js +66 -0
  29. package/dist/core/command.d.ts +58 -2
  30. package/dist/core/command.js +62 -14
  31. package/dist/core/frontmatter.d.ts +10 -0
  32. package/dist/core/frontmatter.js +24 -9
  33. package/dist/core/help.d.ts +39 -8
  34. package/dist/core/help.js +64 -32
  35. package/dist/core/jobs.d.ts +8 -2
  36. package/dist/core/jobs.js +109 -6
  37. package/dist/core/resolver.js +51 -1
  38. package/dist/core/spawn.d.ts +140 -23
  39. package/dist/core/spawn.js +392 -73
  40. package/dist/core/subagents.d.ts +18 -0
  41. package/dist/core/subagents.js +163 -0
  42. package/dist/prompts/agent.d.ts +10 -1
  43. package/dist/prompts/agent.js +34 -3
  44. package/dist/types.d.ts +21 -0
  45. package/dist/types.js +3 -0
  46. package/package.json +2 -2
@@ -17,14 +17,60 @@ export function defineLeaf(opts) {
17
17
  kind: 'leaf',
18
18
  name: opts.name,
19
19
  help: opts.help,
20
+ slash: opts.slash,
20
21
  run: opts.run,
21
22
  };
22
23
  }
23
24
  export function defineBranch(opts) {
24
- return { kind: 'branch', name: opts.name, help: opts.help, children: opts.children };
25
+ return {
26
+ kind: 'branch',
27
+ name: opts.name,
28
+ help: opts.help,
29
+ rootEntry: opts.rootEntry,
30
+ slash: opts.slash,
31
+ children: opts.children,
32
+ };
25
33
  }
34
+ /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
35
+ * by the bootstrap to discover which commands opted into slash exposure. */
36
+ export function collectSlashSpecs(root) {
37
+ const out = [];
38
+ const visit = (node) => {
39
+ if (node.slash !== undefined)
40
+ out.push(node.slash);
41
+ if (node.kind === 'branch')
42
+ for (const c of node.children)
43
+ visit(c);
44
+ };
45
+ for (const s of root.subtrees)
46
+ visit(s);
47
+ return out;
48
+ }
49
+ /** Assemble root -h from the subtrees themselves. Root owns only the tagline
50
+ * and globals; every subtree's concept line, selection rubric, and dynamic
51
+ * block come from its own RootEntry. A subtree without a rootEntry does not
52
+ * appear in root -h — declaring the parent-level representation is how a
53
+ * subtree opts into being listed. */
26
54
  export function defineRoot(opts) {
27
- return { kind: 'root', help: opts.help, subtrees: opts.subtrees };
55
+ // Each listed subtree becomes one <name> block at root, assembled straight
56
+ // from its RootEntry. Root composes nothing and hardcodes nothing: add a
57
+ // subtree with a rootEntry and it surfaces; its concept, rubric, state tag,
58
+ // and dynamic block all travel with it.
59
+ const commands = opts.subtrees
60
+ .filter((s) => s.rootEntry !== undefined)
61
+ .map((s) => ({
62
+ name: s.name,
63
+ concept: s.rootEntry.concept,
64
+ desc: s.rootEntry.desc,
65
+ useWhen: s.rootEntry.useWhen,
66
+ dynamicState: s.rootEntry.dynamicState,
67
+ }));
68
+ const help = {
69
+ tagline: opts.tagline,
70
+ commands,
71
+ globals: opts.globals,
72
+ };
73
+ return { kind: 'root', help, subtrees: opts.subtrees };
28
74
  }
29
75
  /** Validate and return child names for an unknown-path error. */
30
76
  function childNames(node) {
@@ -35,10 +81,12 @@ function childNames(node) {
35
81
  return [];
36
82
  }
37
83
  /** Walk argv tokens to the deepest matched node.
38
- * Returns { node, remaining } where remaining are unconsumed tokens.
84
+ * Returns { node, path, remaining } where path is the sequence of matched node
85
+ * names from root (excluding root itself) and remaining are unconsumed tokens.
39
86
  * -h / --help tokens are NOT consumed here — the caller checks for them. */
40
- function walk(root, tokens) {
87
+ export function walk(root, tokens) {
41
88
  let current = root;
89
+ const path = [];
42
90
  let i = 0;
43
91
  while (i < tokens.length) {
44
92
  const token = tokens[i];
@@ -50,6 +98,7 @@ function walk(root, tokens) {
50
98
  if (nextNode === undefined)
51
99
  break;
52
100
  current = nextNode;
101
+ path.push(nextNode.name);
53
102
  i++;
54
103
  }
55
104
  else if (current.kind === 'branch') {
@@ -57,6 +106,7 @@ function walk(root, tokens) {
57
106
  if (nextNode === undefined)
58
107
  break;
59
108
  current = nextNode;
109
+ path.push(nextNode.name);
60
110
  i++;
61
111
  }
62
112
  else {
@@ -64,7 +114,7 @@ function walk(root, tokens) {
64
114
  break;
65
115
  }
66
116
  }
67
- return { node: current, remaining: tokens.slice(i) };
117
+ return { node: current, path, remaining: tokens.slice(i) };
68
118
  }
69
119
  function renderNode(node) {
70
120
  if (node.kind === 'root')
@@ -77,15 +127,13 @@ function helpRequested(remaining) {
77
127
  return remaining.some((t) => t === '-h' || t === '--help');
78
128
  }
79
129
  /** Build a structured unknown-path error. Names valid children of the deepest
80
- * matched node and names the entry command per the spec. No fuzzy matching. */
81
- function unknownPathError(node, bad) {
130
+ * matched node and names the entry command per the spec. The entry command is
131
+ * the full path to the matched node (not just its local name), so the recovery
132
+ * hint is a command that actually exists. No fuzzy matching. */
133
+ export function unknownPathError(node, path, bad) {
82
134
  const valid = childNames(node);
83
135
  const validStr = valid.length > 0 ? valid.join(', ') : '(none)';
84
- const entryCmd = node.kind === 'root'
85
- ? 'crtr -h'
86
- : node.kind === 'branch'
87
- ? `crtr ${node.name} -h`
88
- : 'crtr -h';
136
+ const entryCmd = path.length > 0 ? `crtr ${path.join(' ')} -h` : 'crtr -h';
89
137
  return new CrtrError('unknown_path', `unknown subcommand: ${bad}`, ExitCode.USAGE, {
90
138
  received: bad,
91
139
  next: `Valid children: ${validStr}. Run \`${entryCmd}\` for the full list.`,
@@ -257,7 +305,7 @@ export async function runCli(root, argv) {
257
305
  process.stdout.write(renderRoot(root.help) + '\n');
258
306
  process.exit(ExitCode.SUCCESS);
259
307
  }
260
- const { node, remaining } = walk(root, tokens);
308
+ const { node, path, remaining } = walk(root, tokens);
261
309
  try {
262
310
  // Help anywhere in remaining tokens → print node help and exit
263
311
  if (helpRequested(remaining)) {
@@ -267,7 +315,7 @@ export async function runCli(root, argv) {
267
315
  // Bare branch or bare root (no -h, but no leaf selected) → help surface
268
316
  if (node.kind === 'root' || node.kind === 'branch') {
269
317
  if (remaining.length > 0) {
270
- throw unknownPathError(node, remaining[0]);
318
+ throw unknownPathError(node, path, remaining[0]);
271
319
  }
272
320
  process.stdout.write(renderNode(node) + '\n');
273
321
  process.exit(ExitCode.SUCCESS);
@@ -5,4 +5,14 @@ export interface ParsedFrontmatter {
5
5
  raw: string;
6
6
  }
7
7
  export declare function parseFrontmatter(source: string): ParsedFrontmatter;
8
+ export interface ParsedFrontmatterGeneric {
9
+ /** Raw, uncoerced key/value record from the YAML block (null when absent). */
10
+ data: Record<string, unknown> | null;
11
+ body: string;
12
+ raw: string;
13
+ }
14
+ /** Like parseFrontmatter but returns the raw key/value record instead of
15
+ * coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
16
+ * fields skills don't declare, such as `tools` and `model`. */
17
+ export declare function parseFrontmatterGeneric(source: string): ParsedFrontmatterGeneric;
8
18
  export declare function serializeFrontmatter(data: SkillFrontmatter): string;
@@ -7,9 +7,30 @@ export function parseFrontmatter(source) {
7
7
  }
8
8
  const raw = match[1];
9
9
  const body = source.slice(match[0].length);
10
- return { data: parseSimpleYaml(raw), body, raw };
10
+ return { data: toSkillFrontmatter(parseYamlRecord(raw)), body, raw };
11
11
  }
12
- function parseSimpleYaml(yaml) {
12
+ /** Like parseFrontmatter but returns the raw key/value record instead of
13
+ * coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
14
+ * fields skills don't declare, such as `tools` and `model`. */
15
+ export function parseFrontmatterGeneric(source) {
16
+ const match = source.match(FRONTMATTER_RE);
17
+ if (!match) {
18
+ return { data: null, body: source, raw: '' };
19
+ }
20
+ const raw = match[1];
21
+ const body = source.slice(match[0].length);
22
+ return { data: parseYamlRecord(raw), body, raw };
23
+ }
24
+ function toSkillFrontmatter(out) {
25
+ const fm = {
26
+ name: typeof out.name === 'string' ? out.name : '',
27
+ description: typeof out.description === 'string' ? out.description : undefined,
28
+ keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
29
+ type: isSkillType(out.type) ? out.type : undefined,
30
+ };
31
+ return fm;
32
+ }
33
+ function parseYamlRecord(yaml) {
13
34
  const lines = yaml.split(/\r?\n/);
14
35
  const out = {};
15
36
  let i = 0;
@@ -123,13 +144,7 @@ function parseSimpleYaml(yaml) {
123
144
  out[key] = stripQuotes(rest);
124
145
  i++;
125
146
  }
126
- const fm = {
127
- name: typeof out.name === 'string' ? out.name : '',
128
- description: typeof out.description === 'string' ? out.description : undefined,
129
- keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
130
- type: isSkillType(out.type) ? out.type : undefined,
131
- };
132
- return fm;
147
+ return out;
133
148
  }
134
149
  function stripQuotes(s) {
135
150
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
@@ -47,17 +47,40 @@ export interface ContextFileParam {
47
47
  shape?: string;
48
48
  }
49
49
  export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
50
+ /** A subtree's self-description at the parent (root) level. Each subtree owns
51
+ * the content that represents it one level up: its vocabulary line, its
52
+ * selection rubric, and any bounded block it contributes to the parent's -h.
53
+ * defineRoot assembles the root help from these — root never hardcodes a
54
+ * subtree's representation. See cli-design "Each node owns its parent-level
55
+ * representation". */
56
+ export interface RootEntry {
57
+ /** One-line vocabulary desc — what this subtree is. Rendered first in the
58
+ * subtree's <name> block at root. */
59
+ concept: string;
60
+ /** Operations summary (verb list). Carried for completeness; the root block
61
+ * leads with concept + rubric, so this is available but not rendered. */
62
+ desc: string;
63
+ /** The selection rubric — `use when X` in the subtree's <name> block. */
64
+ useWhen: string;
65
+ /** Optional bounded block this subtree contributes to its <name> block at
66
+ * root. Returns a complete self-named state element (build it with
67
+ * stateBlock), e.g. `<skills count="42">…</skills>`. Aggregate, never an
68
+ * unbounded enumeration on a cold path. Soft-fails to omission on
69
+ * null/throw. */
70
+ dynamicState?: () => string | null;
71
+ }
50
72
  export interface RootHelp {
51
73
  tagline: string;
52
- /** Vocabulary block rendered before subtrees. */
53
- concepts: {
54
- name: string;
55
- desc: string;
56
- }[];
57
- subtrees: {
74
+ /** One entry per listed subtree. Each renders as its own <name> XML block at
75
+ * root, carrying the subtree's concept, selection rubric, and any nested
76
+ * runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
77
+ * root hardcodes none of it. */
78
+ commands: {
58
79
  name: string;
80
+ concept: string;
59
81
  desc: string;
60
82
  useWhen: string;
83
+ dynamicState?: () => string | null;
61
84
  }[];
62
85
  globals: {
63
86
  name: string;
@@ -69,8 +92,9 @@ export interface BranchHelp {
69
92
  summary: string;
70
93
  /** Local lifecycle/model line that extends the parent definition. */
71
94
  model?: string;
72
- /** Bounded runtime aggregate, e.g. "Current: 2 draft, 1 active".
73
- * Renderer soft-fails to omission if this returns null or throws. */
95
+ /** Bounded runtime aggregate as a complete self-named state element (build
96
+ * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
97
+ * soft-fails to omission if this returns null or throws. */
74
98
  dynamicState?: () => string | null;
75
99
  children: {
76
100
  name: string;
@@ -93,6 +117,13 @@ export interface LeafHelp {
93
117
  * leaves use exactly: ["None. Read-only."] */
94
118
  effects: string[];
95
119
  }
120
+ /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
121
+ * subtree that owns the state authors it through this, so the tag name and any
122
+ * scalar metadata (e.g. a count) travel with the data and render identically
123
+ * at every level the block appears. The tag name carries the label, so the
124
+ * body never repeats it. Attribute values are controlled (counts, short
125
+ * tokens) and not escaped. */
126
+ export declare function stateBlock(tag: string, attrs: Record<string, string | number>, body: string): string;
96
127
  export declare function renderRoot(h: RootHelp): string;
97
128
  export declare function renderBranch(h: BranchHelp): string;
98
129
  export declare function renderLeafArgv(h: LeafHelp): string;
package/dist/core/help.js CHANGED
@@ -5,6 +5,30 @@
5
5
  // ---------------------------------------------------------------------------
6
6
  // Internal helpers
7
7
  // ---------------------------------------------------------------------------
8
+ /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
9
+ * subtree that owns the state authors it through this, so the tag name and any
10
+ * scalar metadata (e.g. a count) travel with the data and render identically
11
+ * at every level the block appears. The tag name carries the label, so the
12
+ * body never repeats it. Attribute values are controlled (counts, short
13
+ * tokens) and not escaped. */
14
+ export function stateBlock(tag, attrs, body) {
15
+ const a = Object.entries(attrs)
16
+ .map(([k, v]) => ` ${k}="${v}"`)
17
+ .join('');
18
+ return `<${tag}${a}>\n${body}\n</${tag}>`;
19
+ }
20
+ /** Evaluate a dynamicState hook, soft-failing to null on throw or empty. */
21
+ function evalDynamic(fn) {
22
+ if (fn === undefined)
23
+ return null;
24
+ try {
25
+ const s = fn();
26
+ return s !== null && s !== '' ? s : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
8
32
  /** Return the longest string length in an array of names. */
9
33
  function maxLen(names) {
10
34
  let max = 0;
@@ -23,27 +47,38 @@ function pad(s, width) {
23
47
  // ---------------------------------------------------------------------------
24
48
  const IO_CONTRACT = 'I/O contract: flags and positional args on input, JSON on stdout (JSONL for streams).\n' +
25
49
  'Exit 0 on success, non-zero on failure. Schemas appear at leaf -h.';
50
+ // Behavioral instruction (not a schema) — engrained in the appended system
51
+ // prompt so the model treats unfamiliar capabilities as a cue to discover the
52
+ // contract, never to guess. Lives in the root guide, outside any leaf -h.
53
+ const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
54
+ 'do not guess or assume it is unsupported — run `-h` on the relevant command ' +
55
+ '(append it anywhere along the path) to read the contract before acting.';
26
56
  export function renderRoot(h) {
27
57
  const lines = [];
28
58
  lines.push(`${h.tagline}`);
29
59
  lines.push('');
30
- // Concepts block
31
- lines.push('Concepts');
32
- const cNameW = maxLen(h.concepts.map((c) => c.name));
33
- for (const c of h.concepts) {
34
- lines.push(` ${pad(c.name, cNameW)} ${c.desc}`);
35
- }
36
- lines.push('');
37
- // Subtrees block
38
- lines.push('Subtrees');
39
- const sNameW = maxLen(h.subtrees.map((s) => s.name));
40
- // Align desc column so "| use when X" starts at a consistent offset
41
- const sDescW = maxLen(h.subtrees.map((s) => s.desc));
42
- for (const s of h.subtrees) {
43
- lines.push(` ${pad(s.name, sNameW)} ${pad(s.desc, sDescW)} | use when ${s.useWhen}`);
60
+ // Each subtree is one <command name="…"> block. The uniform wrapper states
61
+ // "this is a command you invoke as `crtr <name>`" — so the model reads them
62
+ // by one rule, and a nested state element (which is never a <command>) can't
63
+ // be mistaken for a sibling command. Inside: the concept (what it is), the
64
+ // selection rubric (when to pick it), then any self-named state element
65
+ // grouped with the command it belongs to. Once injected into a system prompt,
66
+ // each block reads as one self-contained concern domain. Header (tagline) and
67
+ // footer (Globals + I/O contract + capability-discovery rule) are the only
68
+ // non-command areas. Two levels of nesting: <command> → <state>.
69
+ for (const c of h.commands) {
70
+ lines.push(`<command name="${c.name}">`);
71
+ lines.push(c.concept);
72
+ lines.push(`use when ${c.useWhen}`);
73
+ // dynamicState returns a complete self-named element (e.g.
74
+ // <skills count="42">…</skills>) — emit it as-is, nested in the command.
75
+ const state = evalDynamic(c.dynamicState);
76
+ if (state !== null)
77
+ lines.push(state);
78
+ lines.push('</command>');
79
+ lines.push('');
44
80
  }
45
- lines.push('');
46
- // Globals block
81
+ // Globals block (footer)
47
82
  lines.push('Globals');
48
83
  const gNameW = maxLen(h.globals.map((g) => g.name));
49
84
  for (const g of h.globals) {
@@ -51,6 +86,8 @@ export function renderRoot(h) {
51
86
  }
52
87
  lines.push('');
53
88
  lines.push(IO_CONTRACT);
89
+ lines.push('');
90
+ lines.push(CAPABILITY_DISCOVERY);
54
91
  return lines.join('\n');
55
92
  }
56
93
  // ---------------------------------------------------------------------------
@@ -59,25 +96,20 @@ export function renderRoot(h) {
59
96
  export function renderBranch(h) {
60
97
  const lines = [];
61
98
  lines.push(`${h.name}: ${h.summary}.`);
99
+ // Dynamic content leads — the live aggregate (e.g. the <skills> catalog)
100
+ // renders right after the name, before the hardcoded model prose, so current
101
+ // state is read first. The subtree authors the whole element, so the same
102
+ // self-named block appears identically at root and at `skill -h`.
103
+ const branchState = evalDynamic(h.dynamicState);
104
+ if (branchState !== null) {
105
+ // dynamicState returns a complete self-named element — emit as-is.
106
+ lines.push('');
107
+ lines.push(branchState);
108
+ }
62
109
  if (h.model !== undefined) {
110
+ lines.push('');
63
111
  lines.push(h.model);
64
112
  }
65
- // Dynamic state — soft-fail to omission. Rendered as its own block,
66
- // blank-line separated from the summary, so a multi-line runtime
67
- // aggregate (e.g. the loaded-skills catalog) reads cleanly.
68
- if (h.dynamicState !== undefined) {
69
- let state = null;
70
- try {
71
- state = h.dynamicState();
72
- }
73
- catch {
74
- // soft-fail: omit the block
75
- }
76
- if (state !== null && state !== '') {
77
- lines.push('');
78
- lines.push(state);
79
- }
80
- }
81
113
  lines.push('');
82
114
  lines.push('Branches');
83
115
  const nameW = maxLen(h.children.map((c) => c.name));
@@ -1,4 +1,4 @@
1
- type TerminalStatus = 'done' | 'failed' | 'canceled';
1
+ type TerminalStatus = 'done' | 'failed' | 'canceled' | 'closed';
2
2
  type JobState = 'live' | TerminalStatus;
3
3
  type LogLevel = 'debug' | 'info' | 'warn' | 'error';
4
4
  /**
@@ -16,6 +16,11 @@ export declare function createJob(kind: string, opts: {
16
16
  * Record the tmux pane hosting a detached worker so `cancelJob` can kill it.
17
17
  */
18
18
  export declare function recordJobPane(jobId: string, paneId: string): void;
19
+ /**
20
+ * Record the pid of a detached worker (e.g. a headless background agent) so
21
+ * jobStatus can mark the job failed if the process dies without a result.
22
+ */
23
+ export declare function recordJobPid(jobId: string, pid: number): void;
19
24
  /**
20
25
  * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
21
26
  * a crashed writer should not further corrupt state; use a guard at the call site.
@@ -50,9 +55,10 @@ export declare function writeMarkdownResult(jobId: string, body: string, termina
50
55
  * - JSON path: { status, result: object }
51
56
  * - Markdown path: { status, result_md: string, reason?: string }
52
57
  * - Timeout: { status: 'timeout' }
58
+ * - Closed: pane vanished with no result → status 'closed'
53
59
  */
54
60
  export interface ReadResultResponse {
55
- status: 'done' | 'failed' | 'canceled' | 'timeout';
61
+ status: 'done' | 'failed' | 'canceled' | 'closed' | 'timeout';
56
62
  result?: object;
57
63
  result_md?: string;
58
64
  reason?: string;
package/dist/core/jobs.js CHANGED
@@ -9,6 +9,13 @@
9
9
  // result.md — agent submissions (markdown body + YAML frontmatter). Written atomically.
10
10
  // result.json — programmatic submissions (structured object). Written atomically.
11
11
  // Either result file's APPEARANCE is the completion signal. Exactly one is written per job.
12
+ //
13
+ // A worker is not required to submit. Besides an explicit submit, a job becomes
14
+ // terminal when (a) the wrapper shell's `crtr job _fail` runs on a clean exit,
15
+ // or (b) the hosting tmux pane is closed — which sends SIGHUP so (a) never runs.
16
+ // Case (b) is reaped here: when a live job's recorded pane is gone and no result
17
+ // exists, we write a `closed` result (terminal, but distinct from `failed`) so
18
+ // the job stops being a zombie without claiming an outcome we can't know.
12
19
  import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
13
20
  import { watch } from 'node:fs';
14
21
  import { spawnSync } from 'node:child_process';
@@ -87,6 +94,56 @@ function pidAlive(pid) {
87
94
  return false;
88
95
  }
89
96
  }
97
+ /**
98
+ * Set of every tmux pane id across all sessions on the running server. Empty
99
+ * when no server is running (→ every recorded pane is treated as gone).
100
+ *
101
+ * This bridges tmux's pane lifecycle to the job registry. A worker whose pane
102
+ * is closed/killed receives SIGHUP, so the wrapper shell's `crtr job _fail`
103
+ * never runs and the job would otherwise stay `live` forever (a zombie). We
104
+ * detect the vanished pane and reap the job instead.
105
+ */
106
+ function allTmuxPaneIds() {
107
+ const set = new Set();
108
+ const r = spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
109
+ if (r.status !== 0 || typeof r.stdout !== 'string')
110
+ return set;
111
+ for (const line of r.stdout.split('\n')) {
112
+ const t = line.trim();
113
+ if (t !== '')
114
+ set.add(t);
115
+ }
116
+ return set;
117
+ }
118
+ /**
119
+ * Reap a job whose hosting tmux pane has disappeared. Acts only when the job is
120
+ * still `live`, has a recorded pane, and has produced no result file. Writes a
121
+ * terminal `closed` result so the job stops being a zombie and every reader
122
+ * (status, list, result --wait) agrees. `closed` is distinct from `failed`: we
123
+ * don't know the outcome, only that the pane is gone. Returns true if it reaped.
124
+ *
125
+ * `panes` lets a caller reuse a single tmux query across many jobs (listJobs).
126
+ */
127
+ function reapIfPaneDead(meta, panes) {
128
+ if (meta.status !== 'live')
129
+ return false;
130
+ if (meta.pane_id === undefined || meta.pane_id === '')
131
+ return false;
132
+ if (existingResultPath(meta.job_id) !== null)
133
+ return false;
134
+ const set = panes ?? allTmuxPaneIds();
135
+ if (set.has(meta.pane_id))
136
+ return false;
137
+ try {
138
+ writeMarkdownResult(meta.job_id, '', 'closed', 'worker pane closed before submitting a result');
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ return true;
144
+ }
145
+ /** Poll cadence (ms) for detecting a closed worker pane during result --wait. */
146
+ const PANE_POLL_MS = 2000;
90
147
  const LEVEL_RANK = {
91
148
  debug: 0,
92
149
  info: 1,
@@ -126,6 +183,15 @@ export function recordJobPane(jobId, paneId) {
126
183
  meta.pane_id = paneId;
127
184
  writeMeta(jobId, meta);
128
185
  }
186
+ /**
187
+ * Record the pid of a detached worker (e.g. a headless background agent) so
188
+ * jobStatus can mark the job failed if the process dies without a result.
189
+ */
190
+ export function recordJobPid(jobId, pid) {
191
+ const meta = readMeta(jobId);
192
+ meta.pid = pid;
193
+ writeMeta(jobId, meta);
194
+ }
129
195
  /**
130
196
  * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
131
197
  * a crashed writer should not further corrupt state; use a guard at the call site.
@@ -275,11 +341,16 @@ export function readResult(jobId, opts = {}) {
275
341
  }
276
342
  return new Promise((resolve) => {
277
343
  let settled = false;
344
+ let timer;
345
+ let poll;
278
346
  const finish = (response) => {
279
347
  if (settled)
280
348
  return;
281
349
  settled = true;
282
- clearTimeout(timer);
350
+ if (timer !== undefined)
351
+ clearTimeout(timer);
352
+ if (poll !== undefined)
353
+ clearInterval(poll);
283
354
  try {
284
355
  watcher.close();
285
356
  }
@@ -299,9 +370,33 @@ export function readResult(jobId, opts = {}) {
299
370
  finish(parseAt(path));
300
371
  return;
301
372
  }
302
- const timer = setTimeout(() => {
303
- finish({ status: 'timeout' });
304
- }, opts.waitMs);
373
+ // fs.watch only fires on result files. A pane that closes without a submit
374
+ // produces no such event, so poll to reap it instead of hanging until the
375
+ // full timeout budget elapses.
376
+ poll = setInterval(() => {
377
+ const found = existingResultPath(jobId);
378
+ if (found !== null) {
379
+ finish(parseAt(found));
380
+ return;
381
+ }
382
+ try {
383
+ if (reapIfPaneDead(readMeta(jobId))) {
384
+ const reaped = existingResultPath(jobId);
385
+ if (reaped !== null)
386
+ finish(parseAt(reaped));
387
+ }
388
+ }
389
+ catch { /* noop */ }
390
+ }, PANE_POLL_MS);
391
+ // A non-finite budget (Infinity) means block until a result appears or the
392
+ // worker pane dies — used by `human review`, where the human may take an
393
+ // unbounded amount of time. The poll above still reaps a dead pane, so this
394
+ // never hangs forever on a closed pane.
395
+ if (Number.isFinite(opts.waitMs)) {
396
+ timer = setTimeout(() => {
397
+ finish({ status: 'timeout' });
398
+ }, opts.waitMs);
399
+ }
305
400
  });
306
401
  }
307
402
  /**
@@ -309,7 +404,10 @@ export function readResult(jobId, opts = {}) {
309
404
  * If a pid is recorded, is not alive, and no result file exists → 'failed'.
310
405
  */
311
406
  export function jobStatus(jobId) {
312
- const meta = readMeta(jobId);
407
+ let meta = readMeta(jobId);
408
+ if (reapIfPaneDead(meta)) {
409
+ meta = readMeta(jobId);
410
+ }
313
411
  const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
314
412
  let state = meta.status;
315
413
  if (state === 'live') {
@@ -363,6 +461,8 @@ export function listJobs() {
363
461
  return [];
364
462
  const entries = readdirSync(root);
365
463
  const jobs = [];
464
+ // One tmux query, reused to reap every job whose pane has vanished.
465
+ const panes = allTmuxPaneIds();
366
466
  for (const entry of entries) {
367
467
  const dir = join(root, entry);
368
468
  try {
@@ -371,7 +471,10 @@ export function listJobs() {
371
471
  const mp = join(dir, 'meta.json');
372
472
  if (!existsSync(mp))
373
473
  continue;
374
- const meta = JSON.parse(readFileSync(mp, 'utf8'));
474
+ let meta = JSON.parse(readFileSync(mp, 'utf8'));
475
+ if (reapIfPaneDead(meta, panes)) {
476
+ meta = JSON.parse(readFileSync(mp, 'utf8'));
477
+ }
375
478
  // Derive effective state (result file beats meta.status for live jobs).
376
479
  let state = meta.status;
377
480
  if (state === 'live') {