@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.
- package/README.md +2 -2
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
- package/dist/cli.js +16 -26
- package/dist/commands/__tests__/skill.test.js +24 -28
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +585 -0
- package/dist/commands/debug.d.ts +1 -1
- package/dist/commands/debug.js +20 -7
- package/dist/commands/human.js +51 -19
- package/dist/commands/job.d.ts +9 -0
- package/dist/commands/job.js +100 -385
- package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
- package/dist/commands/mode.js +231 -0
- package/dist/commands/pkg.js +5 -0
- package/dist/commands/plan.d.ts +1 -1
- package/dist/commands/plan.js +24 -11
- package/dist/commands/skill.js +130 -107
- package/dist/commands/spec.d.ts +1 -1
- package/dist/commands/spec.js +24 -11
- package/dist/commands/sys.js +5 -0
- package/dist/core/__tests__/job.test.js +38 -74
- package/dist/core/__tests__/jobs.test.d.ts +1 -0
- package/dist/core/__tests__/jobs.test.js +98 -0
- package/dist/core/__tests__/resolver.test.d.ts +1 -0
- package/dist/core/__tests__/resolver.test.js +181 -0
- package/dist/core/__tests__/spawn.test.d.ts +1 -0
- package/dist/core/__tests__/spawn.test.js +138 -0
- package/dist/core/__tests__/subagents.test.d.ts +1 -0
- package/dist/core/__tests__/subagents.test.js +75 -0
- package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
- package/dist/core/__tests__/unknown-path.test.js +52 -0
- package/dist/core/bootstrap.d.ts +2 -0
- package/dist/core/bootstrap.js +66 -0
- package/dist/core/command.d.ts +58 -2
- package/dist/core/command.js +62 -14
- package/dist/core/config.js +20 -2
- package/dist/core/frontmatter.d.ts +10 -0
- package/dist/core/frontmatter.js +24 -9
- package/dist/core/help.d.ts +39 -8
- package/dist/core/help.js +64 -32
- package/dist/core/jobs.d.ts +33 -13
- package/dist/core/jobs.js +259 -47
- package/dist/core/resolver.d.ts +1 -2
- package/dist/core/resolver.js +111 -47
- package/dist/core/spawn.d.ts +150 -10
- package/dist/core/spawn.js +493 -41
- package/dist/core/subagents.d.ts +18 -0
- package/dist/core/subagents.js +163 -0
- package/dist/prompts/agent.d.ts +12 -3
- package/dist/prompts/agent.js +51 -18
- package/dist/prompts/debug.js +14 -7
- package/dist/prompts/skill.js +16 -16
- package/dist/types.d.ts +22 -1
- package/dist/types.js +5 -2
- package/package.json +2 -2
- 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
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
for (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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));
|
package/dist/core/jobs.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
51
|
-
* If a pid is recorded, is not alive, and no result
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
169
|
-
const raw = readFileSync(
|
|
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
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
|
|
344
|
+
let timer;
|
|
345
|
+
let poll;
|
|
346
|
+
const finish = (response) => {
|
|
184
347
|
if (settled)
|
|
185
348
|
return;
|
|
186
349
|
settled = true;
|
|
187
|
-
|
|
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(
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
214
|
-
* If a pid is recorded, is not alive, and no result
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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'
|
|
480
|
+
if (state === 'live') {
|
|
481
|
+
const mdP = join(dir, 'result.md');
|
|
482
|
+
const jsP = join(dir, 'result.json');
|
|
277
483
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
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
|
}
|
package/dist/core/resolver.d.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|