@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.
- package/dist/cli.js +14 -24
- package/dist/commands/agent.d.ts +4 -0
- package/dist/commands/agent.js +444 -243
- 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 +50 -10
- package/dist/commands/mode.d.ts +2 -0
- 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 +20 -4
- 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 +11 -11
- package/dist/core/__tests__/jobs.test.js +33 -1
- package/dist/core/__tests__/resolver.test.js +69 -1
- 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/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 +8 -2
- package/dist/core/jobs.js +109 -6
- package/dist/core/resolver.js +51 -1
- package/dist/core/spawn.d.ts +140 -23
- package/dist/core/spawn.js +392 -73
- package/dist/core/subagents.d.ts +18 -0
- package/dist/core/subagents.js +163 -0
- package/dist/prompts/agent.d.ts +10 -1
- package/dist/prompts/agent.js +34 -3
- package/dist/types.d.ts +21 -0
- package/dist/types.js +3 -0
- package/package.json +2 -2
package/dist/core/command.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
81
|
-
|
|
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 =
|
|
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;
|
package/dist/core/frontmatter.js
CHANGED
|
@@ -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:
|
|
10
|
+
return { data: toSkillFrontmatter(parseYamlRecord(raw)), body, raw };
|
|
11
11
|
}
|
|
12
|
-
|
|
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
|
-
|
|
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("'"))) {
|
package/dist/core/help.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
73
|
-
*
|
|
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
|
-
//
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|