@crouton-kit/crouter 0.3.3 → 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 (57) hide show
  1. package/README.md +2 -2
  2. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
  3. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
  4. package/dist/cli.js +16 -26
  5. package/dist/commands/__tests__/skill.test.js +24 -28
  6. package/dist/commands/agent.d.ts +6 -0
  7. package/dist/commands/agent.js +585 -0
  8. package/dist/commands/debug.d.ts +1 -1
  9. package/dist/commands/debug.js +20 -7
  10. package/dist/commands/human.js +51 -19
  11. package/dist/commands/job.d.ts +9 -0
  12. package/dist/commands/job.js +100 -385
  13. package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
  14. package/dist/commands/mode.js +231 -0
  15. package/dist/commands/pkg.js +5 -0
  16. package/dist/commands/plan.d.ts +1 -1
  17. package/dist/commands/plan.js +24 -11
  18. package/dist/commands/skill.js +130 -107
  19. package/dist/commands/spec.d.ts +1 -1
  20. package/dist/commands/spec.js +24 -11
  21. package/dist/commands/sys.js +5 -0
  22. package/dist/core/__tests__/job.test.js +38 -74
  23. package/dist/core/__tests__/jobs.test.d.ts +1 -0
  24. package/dist/core/__tests__/jobs.test.js +98 -0
  25. package/dist/core/__tests__/resolver.test.d.ts +1 -0
  26. package/dist/core/__tests__/resolver.test.js +181 -0
  27. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  28. package/dist/core/__tests__/spawn.test.js +138 -0
  29. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  30. package/dist/core/__tests__/subagents.test.js +75 -0
  31. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  32. package/dist/core/__tests__/unknown-path.test.js +52 -0
  33. package/dist/core/bootstrap.d.ts +2 -0
  34. package/dist/core/bootstrap.js +66 -0
  35. package/dist/core/command.d.ts +58 -2
  36. package/dist/core/command.js +62 -14
  37. package/dist/core/config.js +20 -2
  38. package/dist/core/frontmatter.d.ts +10 -0
  39. package/dist/core/frontmatter.js +24 -9
  40. package/dist/core/help.d.ts +39 -8
  41. package/dist/core/help.js +64 -32
  42. package/dist/core/jobs.d.ts +33 -13
  43. package/dist/core/jobs.js +259 -47
  44. package/dist/core/resolver.d.ts +1 -2
  45. package/dist/core/resolver.js +111 -47
  46. package/dist/core/spawn.d.ts +150 -10
  47. package/dist/core/spawn.js +493 -41
  48. package/dist/core/subagents.d.ts +18 -0
  49. package/dist/core/subagents.js +163 -0
  50. package/dist/prompts/agent.d.ts +12 -3
  51. package/dist/prompts/agent.js +51 -18
  52. package/dist/prompts/debug.js +14 -7
  53. package/dist/prompts/skill.js +16 -16
  54. package/dist/types.d.ts +22 -1
  55. package/dist/types.js +5 -2
  56. package/package.json +2 -2
  57. package/dist/commands/flow.js +0 -24
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.
@@ -27,28 +32,43 @@ export declare function appendEvent(jobId: string, event: {
27
32
  data?: object;
28
33
  }): void;
29
34
  /**
30
- * Atomically write result.json and update meta.json status.
31
- * result.json's appearance is the ONLY completion signal never inferred from
32
- * log content.
35
+ * Atomically write result.json (structured object) and update meta.json status.
36
+ * Used by programmatic callers (human, sys) that produce object results.
37
+ * The result file's appearance is the completion signal — never inferred from log content.
33
38
  */
34
39
  export declare function writeResult(jobId: string, result: object, terminalStatus: TerminalStatus): void;
35
40
  /**
36
- * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
37
- * until result.json appears or the timeout elapses.
41
+ * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
42
+ * Used by `crtr job submit` for agent-driven markdown results.
43
+ */
44
+ export declare function writeMarkdownResult(jobId: string, body: string, terminalStatus: TerminalStatus, reason?: string): void;
45
+ /**
46
+ * Read whichever result file exists (result.md or result.json). If neither
47
+ * exists and waitMs is given, block via fs.watch until one appears or the
48
+ * timeout elapses.
38
49
  *
39
- * Race safety: registers the watcher THEN re-stats. If result.json appeared
50
+ * Race safety: registers the watcher THEN re-stats. If a result file appeared
40
51
  * between the first stat and the watch registration, the re-stat catches it
41
52
  * before the watcher has a chance to miss it.
53
+ *
54
+ * Returns shape:
55
+ * - JSON path: { status, result: object }
56
+ * - Markdown path: { status, result_md: string, reason?: string }
57
+ * - Timeout: { status: 'timeout' }
58
+ * - Closed: pane vanished with no result → status 'closed'
42
59
  */
60
+ export interface ReadResultResponse {
61
+ status: 'done' | 'failed' | 'canceled' | 'closed' | 'timeout';
62
+ result?: object;
63
+ result_md?: string;
64
+ reason?: string;
65
+ }
43
66
  export declare function readResult(jobId: string, opts?: {
44
67
  waitMs?: number;
45
- }): Promise<{
46
- status: 'done' | 'failed' | 'canceled' | 'timeout';
47
- result?: object;
48
- }>;
68
+ }): Promise<ReadResultResponse>;
49
69
  /**
50
- * Derive job state from meta.json, result.json, and the tail of log.jsonl.
51
- * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
70
+ * Derive job state from meta.json, the result file, and the tail of log.jsonl.
71
+ * If a pid is recorded, is not alive, and no result file exists → 'failed'.
52
72
  */
53
73
  export declare function jobStatus(jobId: string): {
54
74
  state: JobState;
package/dist/core/jobs.js CHANGED
@@ -6,7 +6,16 @@
6
6
  // Layout: ${XDG_STATE_HOME or ~/.local/state}/crtr/jobs/<job_id>/
7
7
  // meta.json — written atomically on create; updated atomically on terminal transition.
8
8
  // log.jsonl — append-only event log.
9
- // result.json written atomically; its APPEARANCE is the only completion signal.
9
+ // result.md agent submissions (markdown body + YAML frontmatter). Written atomically.
10
+ // result.json — programmatic submissions (structured object). Written atomically.
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.
10
19
  import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
11
20
  import { watch } from 'node:fs';
12
21
  import { spawnSync } from 'node:child_process';
@@ -31,9 +40,22 @@ function metaPath(jobId) {
31
40
  function logPath(jobId) {
32
41
  return join(jobDir(jobId), 'log.jsonl');
33
42
  }
34
- function resultPath(jobId) {
43
+ function resultJsonPath(jobId) {
35
44
  return join(jobDir(jobId), 'result.json');
36
45
  }
46
+ function resultMdPath(jobId) {
47
+ return join(jobDir(jobId), 'result.md');
48
+ }
49
+ /** Path of whichever result file currently exists, or null if neither does. */
50
+ function existingResultPath(jobId) {
51
+ const md = resultMdPath(jobId);
52
+ if (existsSync(md))
53
+ return md;
54
+ const js = resultJsonPath(jobId);
55
+ if (existsSync(js))
56
+ return js;
57
+ return null;
58
+ }
37
59
  // ---------------------------------------------------------------------------
38
60
  // Internal helpers
39
61
  // ---------------------------------------------------------------------------
@@ -72,6 +94,56 @@ function pidAlive(pid) {
72
94
  return false;
73
95
  }
74
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;
75
147
  const LEVEL_RANK = {
76
148
  debug: 0,
77
149
  info: 1,
@@ -111,6 +183,15 @@ export function recordJobPane(jobId, paneId) {
111
183
  meta.pane_id = paneId;
112
184
  writeMeta(jobId, meta);
113
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
+ }
114
195
  /**
115
196
  * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
116
197
  * a crashed writer should not further corrupt state; use a guard at the call site.
@@ -129,9 +210,9 @@ export function appendEvent(jobId, event) {
129
210
  appendFileSync(p, JSON.stringify(line) + '\n', 'utf8');
130
211
  }
131
212
  /**
132
- * Atomically write result.json and update meta.json status.
133
- * result.json's appearance is the ONLY completion signal never inferred from
134
- * log content.
213
+ * Atomically write result.json (structured object) and update meta.json status.
214
+ * Used by programmatic callers (human, sys) that produce object results.
215
+ * The result file's appearance is the completion signal — never inferred from log content.
135
216
  */
136
217
  export function writeResult(jobId, result, terminalStatus) {
137
218
  const dir = jobDir(jobId);
@@ -143,87 +224,205 @@ export function writeResult(jobId, result, terminalStatus) {
143
224
  result,
144
225
  written_at: new Date().toISOString(),
145
226
  };
146
- // Atomic write: tmp + rename within same directory (same fs, rename is atomic).
147
227
  const tmp = join(dir, '.result.tmp');
148
228
  writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
149
- renameSync(tmp, resultPath(jobId));
150
- // Update meta status.
229
+ renameSync(tmp, resultJsonPath(jobId));
151
230
  const meta = readMeta(jobId);
152
231
  meta.status = terminalStatus;
153
232
  writeMeta(jobId, meta);
154
233
  }
155
234
  /**
156
- * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
157
- * until result.json appears or the timeout elapses.
158
- *
159
- * Race safety: registers the watcher THEN re-stats. If result.json appeared
160
- * between the first stat and the watch registration, the re-stat catches it
161
- * before the watcher has a chance to miss it.
235
+ * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
236
+ * Used by `crtr job submit` for agent-driven markdown results.
237
+ */
238
+ export function writeMarkdownResult(jobId, body, terminalStatus, reason) {
239
+ const dir = jobDir(jobId);
240
+ if (!existsSync(dir)) {
241
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
242
+ }
243
+ const fm = {
244
+ status: terminalStatus,
245
+ written_at: new Date().toISOString(),
246
+ };
247
+ if (reason !== undefined && reason !== '') {
248
+ fm.reason = reason;
249
+ }
250
+ const content = `${renderFrontmatter(fm)}${body}`;
251
+ const tmp = join(dir, '.result.tmp');
252
+ writeFileSync(tmp, content, 'utf8');
253
+ renameSync(tmp, resultMdPath(jobId));
254
+ const meta = readMeta(jobId);
255
+ meta.status = terminalStatus;
256
+ writeMeta(jobId, meta);
257
+ }
258
+ /**
259
+ * Render a small fixed-shape frontmatter block. We control writer and reader,
260
+ * so a 3-key hand-rolled emitter is plenty — no YAML dep, no escaping surprises.
261
+ * Values are plain strings; we double-quote `reason` to survive newlines/colons.
162
262
  */
263
+ function renderFrontmatter(fm) {
264
+ const lines = ['---', `status: ${fm.status}`, `written_at: ${fm.written_at}`];
265
+ if (fm.reason !== undefined) {
266
+ const escaped = fm.reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
267
+ lines.push(`reason: "${escaped}"`);
268
+ }
269
+ lines.push('---', '');
270
+ return lines.join('\n');
271
+ }
272
+ /**
273
+ * Parse the small fixed-shape frontmatter we emit. Tolerant of trailing
274
+ * whitespace; returns `{ frontmatter, body }`. Throws if the document does not
275
+ * start with `---\n` or no closing `---` is found.
276
+ */
277
+ function parseMarkdownResult(raw) {
278
+ if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
279
+ throw new Error('result.md missing opening --- delimiter');
280
+ }
281
+ const afterOpen = raw.indexOf('\n') + 1;
282
+ const closeIdx = raw.indexOf('\n---', afterOpen);
283
+ if (closeIdx === -1) {
284
+ throw new Error('result.md missing closing --- delimiter');
285
+ }
286
+ const fmBlock = raw.slice(afterOpen, closeIdx);
287
+ // Body starts after the closing `---` line.
288
+ const afterCloseLine = raw.indexOf('\n', closeIdx + 1);
289
+ const body = afterCloseLine === -1 ? '' : raw.slice(afterCloseLine + 1);
290
+ const fm = {};
291
+ for (const line of fmBlock.split('\n')) {
292
+ const m = line.match(/^([a-z_]+):\s*(.*)$/);
293
+ if (m === null)
294
+ continue;
295
+ const key = m[1];
296
+ if (m[2] === undefined)
297
+ continue;
298
+ let val = m[2];
299
+ if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
300
+ val = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
301
+ }
302
+ if (key === 'status') {
303
+ fm.status = val;
304
+ }
305
+ else if (key === 'written_at') {
306
+ fm.written_at = val;
307
+ }
308
+ else if (key === 'reason') {
309
+ fm.reason = val;
310
+ }
311
+ }
312
+ if (fm.status === undefined || fm.written_at === undefined) {
313
+ throw new Error('result.md frontmatter missing status or written_at');
314
+ }
315
+ return { frontmatter: fm, body };
316
+ }
163
317
  export function readResult(jobId, opts = {}) {
164
318
  const dir = jobDir(jobId);
165
319
  if (!existsSync(dir)) {
166
320
  throw notFound(`job not found: ${jobId}`, { job_id: jobId });
167
321
  }
168
- function parseResult() {
169
- const raw = readFileSync(resultPath(jobId), 'utf8');
322
+ function parseAt(path) {
323
+ const raw = readFileSync(path, 'utf8');
324
+ if (path.endsWith('.md')) {
325
+ const { frontmatter, body } = parseMarkdownResult(raw);
326
+ const out = { status: frontmatter.status, result_md: body };
327
+ if (frontmatter.reason !== undefined) {
328
+ out.reason = frontmatter.reason;
329
+ }
330
+ return out;
331
+ }
170
332
  const parsed = JSON.parse(raw);
171
333
  return { status: parsed.status, result: parsed.result };
172
334
  }
173
- // Fast path: result already present.
174
- if (existsSync(resultPath(jobId))) {
175
- const r = parseResult();
176
- return Promise.resolve({ status: r.status, result: r.result });
335
+ const existing = existingResultPath(jobId);
336
+ if (existing !== null) {
337
+ return Promise.resolve(parseAt(existing));
177
338
  }
178
339
  if (opts.waitMs === undefined || opts.waitMs <= 0) {
179
340
  return Promise.resolve({ status: 'timeout' });
180
341
  }
181
342
  return new Promise((resolve) => {
182
343
  let settled = false;
183
- const finish = (status, result) => {
344
+ let timer;
345
+ let poll;
346
+ const finish = (response) => {
184
347
  if (settled)
185
348
  return;
186
349
  settled = true;
187
- clearTimeout(timer);
350
+ if (timer !== undefined)
351
+ clearTimeout(timer);
352
+ if (poll !== undefined)
353
+ clearInterval(poll);
188
354
  try {
189
355
  watcher.close();
190
356
  }
191
357
  catch { /* noop */ }
192
- resolve({ status, result });
358
+ resolve(response);
193
359
  };
194
- // Register watcher first, then re-stat (race safety).
195
360
  const watcher = watch(dir, (_event, name) => {
196
- if (name === 'result.json' && existsSync(resultPath(jobId))) {
197
- const r = parseResult();
198
- finish(r.status, r.result);
361
+ if (name !== 'result.md' && name !== 'result.json')
362
+ return;
363
+ const path = existingResultPath(jobId);
364
+ if (path !== null) {
365
+ finish(parseAt(path));
199
366
  }
200
367
  });
201
- // Re-stat after watcher is registered to close the race window.
202
- if (existsSync(resultPath(jobId))) {
203
- const r = parseResult();
204
- finish(r.status, r.result);
368
+ const path = existingResultPath(jobId);
369
+ if (path !== null) {
370
+ finish(parseAt(path));
205
371
  return;
206
372
  }
207
- const timer = setTimeout(() => {
208
- finish('timeout');
209
- }, 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
+ }
210
400
  });
211
401
  }
212
402
  /**
213
- * Derive job state from meta.json, result.json, and the tail of log.jsonl.
214
- * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
403
+ * Derive job state from meta.json, the result file, and the tail of log.jsonl.
404
+ * If a pid is recorded, is not alive, and no result file exists → 'failed'.
215
405
  */
216
406
  export function jobStatus(jobId) {
217
- const meta = readMeta(jobId);
407
+ let meta = readMeta(jobId);
408
+ if (reapIfPaneDead(meta)) {
409
+ meta = readMeta(jobId);
410
+ }
218
411
  const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
219
- // Derive effective state.
220
412
  let state = meta.status;
221
413
  if (state === 'live') {
222
- if (existsSync(resultPath(jobId))) {
223
- // result.json present but meta not yet updated (rare); trust the file.
414
+ const existing = existingResultPath(jobId);
415
+ if (existing !== null) {
416
+ // Result file present but meta not yet updated (rare); trust the file.
224
417
  try {
225
- const r = JSON.parse(readFileSync(resultPath(jobId), 'utf8'));
226
- state = r.status;
418
+ if (existing.endsWith('.md')) {
419
+ const { frontmatter } = parseMarkdownResult(readFileSync(existing, 'utf8'));
420
+ state = frontmatter.status;
421
+ }
422
+ else {
423
+ const r = JSON.parse(readFileSync(existing, 'utf8'));
424
+ state = r.status;
425
+ }
227
426
  }
228
427
  catch { /* leave as live */ }
229
428
  }
@@ -262,6 +461,8 @@ export function listJobs() {
262
461
  return [];
263
462
  const entries = readdirSync(root);
264
463
  const jobs = [];
464
+ // One tmux query, reused to reap every job whose pane has vanished.
465
+ const panes = allTmuxPaneIds();
265
466
  for (const entry of entries) {
266
467
  const dir = join(root, entry);
267
468
  try {
@@ -270,13 +471,24 @@ export function listJobs() {
270
471
  const mp = join(dir, 'meta.json');
271
472
  if (!existsSync(mp))
272
473
  continue;
273
- const meta = JSON.parse(readFileSync(mp, 'utf8'));
274
- // Derive effective state (result.json beats meta.status for live jobs).
474
+ let meta = JSON.parse(readFileSync(mp, 'utf8'));
475
+ if (reapIfPaneDead(meta, panes)) {
476
+ meta = JSON.parse(readFileSync(mp, 'utf8'));
477
+ }
478
+ // Derive effective state (result file beats meta.status for live jobs).
275
479
  let state = meta.status;
276
- if (state === 'live' && existsSync(join(dir, 'result.json'))) {
480
+ if (state === 'live') {
481
+ const mdP = join(dir, 'result.md');
482
+ const jsP = join(dir, 'result.json');
277
483
  try {
278
- const r = JSON.parse(readFileSync(join(dir, 'result.json'), 'utf8'));
279
- state = r.status;
484
+ if (existsSync(mdP)) {
485
+ const { frontmatter } = parseMarkdownResult(readFileSync(mdP, 'utf8'));
486
+ state = frontmatter.status;
487
+ }
488
+ else if (existsSync(jsP)) {
489
+ const r = JSON.parse(readFileSync(jsP, 'utf8'));
490
+ state = r.status;
491
+ }
280
492
  }
281
493
  catch { /* leave as live */ }
282
494
  }
@@ -22,8 +22,7 @@ export interface SkillResolutionOpts {
22
22
  export declare function resolveSkill(rawName: string, opts?: SkillResolutionOpts): Skill;
23
23
  export interface ParsedSkillQualifier {
24
24
  scope?: Scope;
25
- plugin?: string;
26
- name: string;
25
+ segments: string[];
27
26
  }
28
27
  export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
29
28
  export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];