@dungle-scrubs/harness-cli-normalizer 0.4.4 → 0.4.5
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 +14 -5
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +8 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +4 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +7 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +57 -24
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +125 -65
- package/dist/cli/run.js.map +1 -1
- package/dist/execution/decode.d.ts +3 -3
- package/dist/execution/decode.d.ts.map +1 -1
- package/dist/execution/decode.js +44 -12
- package/dist/execution/decode.js.map +1 -1
- package/dist/execution/deps.d.ts +3 -0
- package/dist/execution/deps.d.ts.map +1 -1
- package/dist/execution/events.d.ts +7 -2
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.js.map +1 -1
- package/dist/execution/node-deps.d.ts.map +1 -1
- package/dist/execution/node-deps.js +1 -0
- package/dist/execution/node-deps.js.map +1 -1
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +36 -5
- package/dist/execution/open-session.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +4 -0
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +135 -29
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +7 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +24 -4
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/capabilities.d.ts.map +1 -1
- package/dist/interpretation/capabilities.js +4 -1
- package/dist/interpretation/capabilities.js.map +1 -1
- package/dist/interpretation/content.d.ts +9 -1
- package/dist/interpretation/content.d.ts.map +1 -1
- package/dist/interpretation/content.js +13 -4
- package/dist/interpretation/content.js.map +1 -1
- package/dist/interpretation/hints.d.ts.map +1 -1
- package/dist/interpretation/hints.js +5 -0
- package/dist/interpretation/hints.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts.map +1 -1
- package/dist/interpretation/resolve-options.js +16 -0
- package/dist/interpretation/resolve-options.js.map +1 -1
- package/dist/interpretation/tool-selection.d.ts.map +1 -1
- package/dist/interpretation/tool-selection.js +9 -0
- package/dist/interpretation/tool-selection.js.map +1 -1
- package/dist/interpretation/turn-options.d.ts.map +1 -1
- package/dist/interpretation/turn-options.js +35 -3
- package/dist/interpretation/turn-options.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +21 -0
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +9 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +21 -10
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js +6 -0
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/matchers.d.ts.map +1 -1
- package/dist/knowledge/matchers.js +8 -1
- package/dist/knowledge/matchers.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +20 -0
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +7 -0
- package/src/cli/config.ts +4 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/inspect.ts +54 -22
- package/src/cli/run.ts +166 -67
- package/src/execution/decode.ts +44 -11
- package/src/execution/deps.ts +3 -0
- package/src/execution/events.ts +5 -2
- package/src/execution/node-deps.ts +1 -0
- package/src/execution/open-session.ts +34 -3
- package/src/execution/stream-turn.ts +134 -27
- package/src/interpretation/argv.ts +30 -4
- package/src/interpretation/capabilities.ts +4 -1
- package/src/interpretation/content.ts +18 -5
- package/src/interpretation/hints.ts +8 -0
- package/src/interpretation/resolve-options.ts +17 -0
- package/src/interpretation/tool-selection.ts +9 -0
- package/src/interpretation/turn-options.ts +31 -3
- package/src/knowledge/claude-code.ts +21 -0
- package/src/knowledge/codex.ts +9 -0
- package/src/knowledge/descriptor.ts +28 -10
- package/src/knowledge/matchers.ts +8 -1
- package/src/knowledge/pi.ts +20 -0
package/src/cli/run.ts
CHANGED
|
@@ -1,16 +1,72 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import type { HarnessEvent } from "../execution/events.js";
|
|
3
|
+
import { failureFromRejected } from "../execution/failure.js";
|
|
2
4
|
import { nodeRunnerDeps } from "../execution/node-deps.js";
|
|
3
5
|
import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
|
|
4
6
|
import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
|
|
5
7
|
import { composeEscalatedPrompt } from "../interpretation/question.js";
|
|
8
|
+
import type { RefusalIssue } from "../interpretation/refusal.js";
|
|
6
9
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
7
10
|
import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
|
|
11
|
+
import { storePath } from "../interpretation/store.js";
|
|
8
12
|
import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
|
|
9
13
|
import { defaultDescriptors } from "../knowledge/overrides.js";
|
|
10
14
|
import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
|
|
11
15
|
import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
|
|
12
16
|
import { resolveHarness } from "./resolve-harness.js";
|
|
13
17
|
|
|
18
|
+
/** One shape for every hcn-side refusal. Stderr keeps the prose (message,
|
|
19
|
+
* hint, support lists, usage pointer); under --json the same fields also
|
|
20
|
+
* ride stdout as a `failure` event plus a `done`, so a stream reader never
|
|
21
|
+
* meets an empty stream on exit 2. */
|
|
22
|
+
interface Refusal {
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly issue: RefusalIssue;
|
|
25
|
+
readonly option?: ArgvRefusalError["option"];
|
|
26
|
+
readonly facet?: ArgvRefusalError["facet"];
|
|
27
|
+
readonly supported?: readonly string[];
|
|
28
|
+
readonly supportedBy?: ArgvRefusalError["supportedBy"];
|
|
29
|
+
readonly hint?: string;
|
|
30
|
+
/** Plain stderr lines written after the structured ones. */
|
|
31
|
+
readonly trailer?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const refusalOf = (err: ArgvRefusalError): Refusal => ({
|
|
35
|
+
message: err.message,
|
|
36
|
+
issue: err.issue,
|
|
37
|
+
option: err.option,
|
|
38
|
+
facet: err.facet,
|
|
39
|
+
supported: err.supported,
|
|
40
|
+
supportedBy: err.supportedBy,
|
|
41
|
+
hint: err.hint,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const refuse = (r: Refusal, json: boolean): void => {
|
|
45
|
+
process.stderr.write(`${r.message}\n`);
|
|
46
|
+
if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
|
|
47
|
+
if (r.supportedBy?.length) {
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
`supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
|
|
53
|
+
for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
|
|
54
|
+
if (json) {
|
|
55
|
+
const summary = failureFromRejected({
|
|
56
|
+
issue: r.issue,
|
|
57
|
+
option: r.option,
|
|
58
|
+
facet: r.facet,
|
|
59
|
+
supported: r.supported,
|
|
60
|
+
supportedBy: r.supportedBy,
|
|
61
|
+
hint: r.hint,
|
|
62
|
+
detail: r.message,
|
|
63
|
+
});
|
|
64
|
+
writeEventNdjson({ kind: "failure", ...summary });
|
|
65
|
+
writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
|
|
66
|
+
}
|
|
67
|
+
process.exitCode = 2;
|
|
68
|
+
};
|
|
69
|
+
|
|
14
70
|
export const run = async (harnessName: string, rawArgs: string[]): Promise<void> => {
|
|
15
71
|
const h = resolveHarness(harnessName);
|
|
16
72
|
|
|
@@ -23,7 +79,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
23
79
|
const { parseCommonFlags, detectPositionalPromptInjection, splitPassthrough } = await import(
|
|
24
80
|
"./args.js"
|
|
25
81
|
);
|
|
26
|
-
const { passthrough } = splitPassthrough(rawArgs);
|
|
82
|
+
const { normalized, passthrough } = splitPassthrough(rawArgs);
|
|
83
|
+
// Decided before any refusal can fire: a refused --json run still owes
|
|
84
|
+
// the stream a failure and a done.
|
|
85
|
+
const wantJson = normalized.includes("--json");
|
|
27
86
|
const injection = detectPositionalPromptInjection(rawArgs);
|
|
28
87
|
if (injection) {
|
|
29
88
|
const err = new ArgvRefusalError({
|
|
@@ -32,9 +91,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
32
91
|
supported: ["prompt must not start with '-'"],
|
|
33
92
|
detail: `it would be parsed as a flag by ${h.bin}`,
|
|
34
93
|
});
|
|
35
|
-
|
|
36
|
-
process.stderr.write(`supported: prompt must not start with '-'\n`);
|
|
37
|
-
process.exitCode = 2;
|
|
94
|
+
refuse(refusalOf(err), wantJson);
|
|
38
95
|
return;
|
|
39
96
|
}
|
|
40
97
|
let parsed: ReturnType<typeof parseCommonFlags>;
|
|
@@ -77,19 +134,25 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
77
134
|
: native.option.startsWith("discovery.")
|
|
78
135
|
? `--no-${native.option.split(".")[1] === "instructionFiles" ? "instruction-files" : native.option.split(".")[1]}`
|
|
79
136
|
: `--${native.option}`;
|
|
80
|
-
|
|
81
|
-
|
|
137
|
+
refuse(
|
|
138
|
+
{
|
|
139
|
+
message: `unknown flag: ${rawFlag} is a native spelling (used by ${native.entries.map((e) => e.harness).join(", ")}) - use the normalized ${normalizedSpelling} flag instead`,
|
|
140
|
+
issue: "invalid-option-value",
|
|
141
|
+
supportedBy: by,
|
|
142
|
+
trailer: ["Run 'hcn run --help' for usage."],
|
|
143
|
+
},
|
|
144
|
+
wantJson,
|
|
82
145
|
);
|
|
83
|
-
|
|
84
|
-
process.stderr.write(
|
|
85
|
-
`supported on: ${by.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
process.stderr.write(`unknown flag: ${message}\n`);
|
|
146
|
+
return;
|
|
90
147
|
}
|
|
91
|
-
|
|
92
|
-
|
|
148
|
+
refuse(
|
|
149
|
+
{
|
|
150
|
+
message: `unknown flag: ${message}`,
|
|
151
|
+
issue: "invalid-option-value",
|
|
152
|
+
trailer: ["Run 'hcn run --help' for usage."],
|
|
153
|
+
},
|
|
154
|
+
wantJson,
|
|
155
|
+
);
|
|
93
156
|
return;
|
|
94
157
|
}
|
|
95
158
|
|
|
@@ -98,18 +161,26 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
98
161
|
// positionals may contain prompt if not using flag; harness already consumed so first positional is prompt
|
|
99
162
|
const positionalPrompt = positionals.length > 0 ? positionals[0] : undefined;
|
|
100
163
|
if (positionals.length > 1) {
|
|
101
|
-
|
|
102
|
-
|
|
164
|
+
refuse(
|
|
165
|
+
{
|
|
166
|
+
message: "too many positionals for run; expected one prompt",
|
|
167
|
+
issue: "invalid-option-value",
|
|
168
|
+
},
|
|
169
|
+
wantJson,
|
|
170
|
+
);
|
|
103
171
|
return;
|
|
104
172
|
}
|
|
105
173
|
if (passthrough.length === 0 && rawArgs.includes("--")) {
|
|
106
|
-
|
|
107
|
-
|
|
174
|
+
refuse(
|
|
175
|
+
{
|
|
176
|
+
message: "-- separator given but no passthrough tokens followed it",
|
|
177
|
+
issue: "invalid-option-value",
|
|
178
|
+
},
|
|
179
|
+
wantJson,
|
|
180
|
+
);
|
|
108
181
|
return;
|
|
109
182
|
}
|
|
110
183
|
|
|
111
|
-
const wantJson = values.json === true;
|
|
112
|
-
|
|
113
184
|
// Resolve prompt (async for --prompt-file -)
|
|
114
185
|
let prompt: string;
|
|
115
186
|
let promptSource: string;
|
|
@@ -123,20 +194,14 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
123
194
|
promptSource = resolved.source;
|
|
124
195
|
} catch (err) {
|
|
125
196
|
if (err instanceof ArgvRefusalError) {
|
|
126
|
-
|
|
127
|
-
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
128
|
-
if (err.supportedBy?.length) {
|
|
129
|
-
process.stderr.write(
|
|
130
|
-
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
134
|
-
process.exitCode = 2;
|
|
197
|
+
refuse(refusalOf(err), wantJson);
|
|
135
198
|
return;
|
|
136
199
|
}
|
|
137
200
|
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
refuse(
|
|
202
|
+
{ message: `prompt file not found: ${err.message}`, issue: "invalid-option-value" },
|
|
203
|
+
wantJson,
|
|
204
|
+
);
|
|
140
205
|
return;
|
|
141
206
|
}
|
|
142
207
|
throw err;
|
|
@@ -148,15 +213,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
148
213
|
turnOpts = parseTurnOptions(values);
|
|
149
214
|
} catch (err) {
|
|
150
215
|
if (err instanceof ArgvRefusalError) {
|
|
151
|
-
|
|
152
|
-
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
153
|
-
if (err.supportedBy?.length) {
|
|
154
|
-
process.stderr.write(
|
|
155
|
-
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
159
|
-
process.exitCode = 2;
|
|
216
|
+
refuse(refusalOf(err), wantJson);
|
|
160
217
|
return;
|
|
161
218
|
}
|
|
162
219
|
throw err;
|
|
@@ -167,15 +224,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
167
224
|
extra = parseRunExtra(values);
|
|
168
225
|
} catch (err) {
|
|
169
226
|
if (err instanceof ArgvRefusalError) {
|
|
170
|
-
|
|
171
|
-
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
172
|
-
if (err.supportedBy?.length) {
|
|
173
|
-
process.stderr.write(
|
|
174
|
-
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
178
|
-
process.exitCode = 2;
|
|
227
|
+
refuse(refusalOf(err), wantJson);
|
|
179
228
|
return;
|
|
180
229
|
}
|
|
181
230
|
throw err;
|
|
@@ -199,9 +248,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
199
248
|
(turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
|
|
200
249
|
} catch (err) {
|
|
201
250
|
if (err instanceof ArgvRefusalError) {
|
|
202
|
-
|
|
203
|
-
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
204
|
-
process.exitCode = 2;
|
|
251
|
+
refuse(refusalOf(err), wantJson);
|
|
205
252
|
return;
|
|
206
253
|
}
|
|
207
254
|
throw err;
|
|
@@ -230,8 +277,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
230
277
|
if (proj !== null) tiers.project = proj.config;
|
|
231
278
|
} catch (configErr) {
|
|
232
279
|
if (configErr instanceof ConfigError) {
|
|
233
|
-
|
|
234
|
-
|
|
280
|
+
refuse(
|
|
281
|
+
{ message: `config error: ${configErr.message}`, issue: "invalid-option-value" },
|
|
282
|
+
wantJson,
|
|
283
|
+
);
|
|
235
284
|
return;
|
|
236
285
|
}
|
|
237
286
|
throw configErr;
|
|
@@ -243,8 +292,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
243
292
|
resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
|
|
244
293
|
} catch (resErr) {
|
|
245
294
|
if (resErr instanceof FloorExceededError) {
|
|
246
|
-
|
|
247
|
-
process.exitCode = 2;
|
|
295
|
+
refuse({ message: resErr.message, issue: "invalid-tool-grant" }, wantJson);
|
|
248
296
|
return;
|
|
249
297
|
}
|
|
250
298
|
throw resErr;
|
|
@@ -265,6 +313,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
265
313
|
`divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
|
|
266
314
|
);
|
|
267
315
|
}
|
|
316
|
+
// issue #48 (ratified): when claude's instruction-files isolation
|
|
317
|
+
// rides (the --setting-sources project spelling), surface its known
|
|
318
|
+
// cost as a divergence line - legal to use, priced in stderr.
|
|
319
|
+
if (
|
|
320
|
+
h.name === "claude" &&
|
|
321
|
+
(resolved.options as { discovery?: { instructionFiles?: boolean } }).discovery
|
|
322
|
+
?.instructionFiles === false
|
|
323
|
+
) {
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
"divergence: setting-sources isolation also skips hooks, LSP and keychain reads on claude - weighed, not refused\n",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
268
328
|
}
|
|
269
329
|
}
|
|
270
330
|
|
|
@@ -342,15 +402,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
342
402
|
_validated = true;
|
|
343
403
|
} catch (err) {
|
|
344
404
|
if (err instanceof ArgvRefusalError) {
|
|
345
|
-
|
|
346
|
-
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
347
|
-
if (err.supportedBy?.length) {
|
|
348
|
-
process.stderr.write(
|
|
349
|
-
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
353
|
-
process.exitCode = 2;
|
|
405
|
+
refuse(refusalOf(err), wantJson);
|
|
354
406
|
return;
|
|
355
407
|
}
|
|
356
408
|
throw err;
|
|
@@ -372,6 +424,51 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
372
424
|
process.stderr.write(
|
|
373
425
|
`provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
|
|
374
426
|
);
|
|
427
|
+
// On a harness whose include flag is not a strict allowlist (claude),
|
|
428
|
+
// a name outside the curated set passes through ungated; say which
|
|
429
|
+
// ones so a wrong-case name is visible. A grant with no known name at
|
|
430
|
+
// all refuses inside buildLaunchArgv below, so a throw here is left
|
|
431
|
+
// to that path.
|
|
432
|
+
const grant = (fullOpts.resume ? turnOpts : effectiveTurnOpts).tools;
|
|
433
|
+
if (grant !== undefined && grant.length > 0 && !h.tools.includeIsStrictAllowlist) {
|
|
434
|
+
try {
|
|
435
|
+
const { renderToolSelection } = await import("../interpretation/tool-selection.js");
|
|
436
|
+
const { unmapped } = renderToolSelection(h, { include: [...grant] });
|
|
437
|
+
if (unmapped.length > 0) {
|
|
438
|
+
process.stderr.write(`provenance: unmapped tools = ${JSON.stringify(unmapped)}\n`);
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
// refused below with the structured message
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// A harness that creates a session when the id is unknown (pi, muse)
|
|
447
|
+
// would turn a stale --resume into a silent blank session. Refuse when
|
|
448
|
+
// the session store path does not exist; where the path cannot be
|
|
449
|
+
// computed, the runner's pre-spawn warning is the only guard.
|
|
450
|
+
if (fullOpts.resume !== undefined && h.resume.onMissing === "create") {
|
|
451
|
+
let path: string | null = null;
|
|
452
|
+
try {
|
|
453
|
+
path = storePath(h, {
|
|
454
|
+
home: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
455
|
+
cwd: extra.cwd ?? process.cwd(),
|
|
456
|
+
sessionId: fullOpts.resume,
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
path = null;
|
|
460
|
+
}
|
|
461
|
+
if (path !== null && !existsSync(path)) {
|
|
462
|
+
refuse(
|
|
463
|
+
{
|
|
464
|
+
message: `no ${h.name} session ${fullOpts.resume} found at ${path}`,
|
|
465
|
+
issue: "invalid-option-value",
|
|
466
|
+
supported: [`a session id that exists in ${h.name}'s store`],
|
|
467
|
+
},
|
|
468
|
+
wantJson,
|
|
469
|
+
);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
375
472
|
}
|
|
376
473
|
|
|
377
474
|
// Delete HERDR_ENV before spawn
|
|
@@ -410,10 +507,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
410
507
|
},
|
|
411
508
|
};
|
|
412
509
|
|
|
510
|
+
const abortController = new AbortController();
|
|
413
511
|
let interrupted = false;
|
|
414
512
|
const onSig = async () => {
|
|
415
513
|
if (interrupted) return;
|
|
416
514
|
interrupted = true;
|
|
515
|
+
abortController.abort();
|
|
417
516
|
if (lastProc) {
|
|
418
517
|
try {
|
|
419
518
|
wrappedDeps.signal(lastProc, "SIGTERM");
|
|
@@ -439,7 +538,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
439
538
|
|
|
440
539
|
try {
|
|
441
540
|
// streamTurn handles both launch and resume via TurnRunOptions
|
|
442
|
-
const events = streamTurn(h, fullOpts, wrappedDeps);
|
|
541
|
+
const events = streamTurn(h, { ...fullOpts, signal: abortController.signal }, wrappedDeps);
|
|
443
542
|
for await (const event of events) {
|
|
444
543
|
lastEvent = event;
|
|
445
544
|
if (wantJson) {
|
package/src/execution/decode.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* caller threads (identity dedupe per D-022). Unparseable lines on a
|
|
5
5
|
* structured stream are tolerated - scanned for walls, never fatal.
|
|
6
6
|
*/
|
|
7
|
-
import { capabilitiesOf } from "../interpretation/capabilities.js";
|
|
7
|
+
import { type CapabilityResult, capabilitiesOf } from "../interpretation/capabilities.js";
|
|
8
8
|
import { contentEventsOf } from "../interpretation/content.js";
|
|
9
9
|
import { decodeIdentity } from "../interpretation/identity.js";
|
|
10
10
|
import { detectLimitInLine } from "../interpretation/limits.js";
|
|
11
|
-
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
11
|
+
import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
|
|
12
12
|
import type { HarnessEvent } from "./events.js";
|
|
13
|
+
import { failureFromBudget } from "./failure.js";
|
|
13
14
|
|
|
14
15
|
export interface DecodeState {
|
|
15
16
|
lastSeenId: string | null;
|
|
@@ -30,6 +31,7 @@ export const decodeLine = (
|
|
|
30
31
|
line: string,
|
|
31
32
|
state: DecodeState,
|
|
32
33
|
model: string,
|
|
34
|
+
streaming?: StreamingGranularity,
|
|
33
35
|
): HarnessEvent[] => {
|
|
34
36
|
let raw: unknown;
|
|
35
37
|
try {
|
|
@@ -43,7 +45,7 @@ export const decodeLine = (
|
|
|
43
45
|
}
|
|
44
46
|
return [];
|
|
45
47
|
}
|
|
46
|
-
return decodeParsed(h, raw, state, model);
|
|
48
|
+
return decodeParsed(h, raw, state, model, streaming);
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
/** The parsed-record half of decodeLine, for pumps that already parsed the
|
|
@@ -54,8 +56,16 @@ export const decodeParsed = (
|
|
|
54
56
|
raw: unknown,
|
|
55
57
|
state: DecodeState,
|
|
56
58
|
model: string,
|
|
59
|
+
streaming?: StreamingGranularity,
|
|
57
60
|
): HarnessEvent[] => {
|
|
58
61
|
const events: HarnessEvent[] = [];
|
|
62
|
+
// Streaming is a property of the spawned argv, not of the model, so the
|
|
63
|
+
// runner passes what streamingGranularityOf computed for it; the curated
|
|
64
|
+
// baseline supplies the rest. An unknown model stays unknown throughout.
|
|
65
|
+
const capabilities = (): CapabilityResult => {
|
|
66
|
+
const base = capabilitiesOf(h, model, "headless-turn");
|
|
67
|
+
return streaming !== undefined && base.source !== "unknown" ? { ...base, streaming } : base;
|
|
68
|
+
};
|
|
59
69
|
const decoded = decodeIdentity(h, raw, state.lastSeenId, state.requestedId);
|
|
60
70
|
if (decoded.sessionId !== null) state.lastSeenId = decoded.sessionId;
|
|
61
71
|
if (decoded.identity !== null) {
|
|
@@ -63,18 +73,41 @@ export const decodeParsed = (
|
|
|
63
73
|
kind: "identity",
|
|
64
74
|
sessionId: decoded.identity,
|
|
65
75
|
authority: h.identity.authority,
|
|
66
|
-
capabilities:
|
|
76
|
+
capabilities: capabilities(),
|
|
67
77
|
});
|
|
68
78
|
} else if (decoded.outcome === "malformed" || decoded.outcome === "rotated") {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if (decoded.outcome === "rotated") {
|
|
80
|
+
const requested = state.requestedId ?? "unknown";
|
|
81
|
+
const announced = decoded.sessionId ?? "unknown";
|
|
82
|
+
events.push({
|
|
83
|
+
kind: "error",
|
|
84
|
+
message: `identity rotated: requested ${requested} but announced ${announced}`,
|
|
85
|
+
});
|
|
86
|
+
// The harness answered under a different id: hand the consumer the
|
|
87
|
+
// id it can actually resume, marked as minted by the harness.
|
|
88
|
+
if (decoded.sessionId !== null) {
|
|
89
|
+
events.push({
|
|
90
|
+
kind: "identity",
|
|
91
|
+
sessionId: decoded.sessionId,
|
|
92
|
+
authority: "harness-minted",
|
|
93
|
+
capabilities: capabilities(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
events.push({ kind: "error", message: `identity ${decoded.outcome}` });
|
|
98
|
+
}
|
|
73
99
|
}
|
|
74
100
|
|
|
75
|
-
// Content (message/token/tool/error) is per-harness; identity
|
|
76
|
-
// descriptor-driven. contentEventsOf dispatches by harness
|
|
77
|
-
|
|
101
|
+
// Content (message/token/tool/error/budget) is per-harness; identity
|
|
102
|
+
// above is descriptor-driven. contentEventsOf dispatches by harness
|
|
103
|
+
// name. budget is not a HarnessEvent kind and must not leak out.
|
|
104
|
+
for (const content of contentEventsOf(h.name, raw)) {
|
|
105
|
+
if (content.kind === "budget") {
|
|
106
|
+
events.push({ kind: "failure", ...failureFromBudget(content.detail) });
|
|
107
|
+
} else {
|
|
108
|
+
events.push(content);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
78
111
|
|
|
79
112
|
// claude's rate_limit_event: only non-"allowed" statuses are failures.
|
|
80
113
|
// overageStatus is deliberately not classified - it is a separate
|
package/src/execution/deps.ts
CHANGED
|
@@ -16,6 +16,9 @@ export interface SpawnedProcess {
|
|
|
16
16
|
/** Resolves with the exit code, or null when the process died to a
|
|
17
17
|
* signal without one. */
|
|
18
18
|
readonly exited: Promise<number | null>;
|
|
19
|
+
/** After `exited` settles, the spawn-time error message when the child
|
|
20
|
+
* never started (async ENOENT and the like), else null. */
|
|
21
|
+
readonly startupError?: () => string | null;
|
|
19
22
|
/** Present when the spawner opened a writable stdin (session mode needs
|
|
20
23
|
* it; one-shot turns close or inherit stdin instead). */
|
|
21
24
|
readonly stdin?: {
|
package/src/execution/events.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* concatenate both.
|
|
16
16
|
*/
|
|
17
17
|
import type { CapabilityResult } from "../interpretation/capabilities.js";
|
|
18
|
+
import type { LimitCode } from "../knowledge/descriptor.js";
|
|
18
19
|
import type { FailureSummary } from "./failure.js";
|
|
19
20
|
|
|
20
21
|
export type ExitCause =
|
|
@@ -49,8 +50,10 @@ export type HarnessEvent =
|
|
|
49
50
|
readonly options: readonly string[];
|
|
50
51
|
readonly recommended?: string;
|
|
51
52
|
}
|
|
52
|
-
| { readonly kind: "limit"; readonly code:
|
|
53
|
-
|
|
53
|
+
| { readonly kind: "limit"; readonly code: LimitCode; readonly message: string }
|
|
54
|
+
/** `terminal: true` marks an error that ended the turn; the matching
|
|
55
|
+
* `failure` (class task) follows it. */
|
|
56
|
+
| { readonly kind: "error"; readonly message: string; readonly terminal?: boolean }
|
|
54
57
|
| ({ readonly kind: "failure" } & FailureSummary)
|
|
55
58
|
| {
|
|
56
59
|
readonly kind: "done";
|
|
@@ -157,6 +157,7 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
|
|
|
157
157
|
stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream,
|
|
158
158
|
stderr: stderrWithError(),
|
|
159
159
|
exited,
|
|
160
|
+
startupError: () => spawnError?.message ?? null,
|
|
160
161
|
disposeOutput: (): void => {
|
|
161
162
|
if (outputDisposed) return;
|
|
162
163
|
outputDisposed = true;
|
|
@@ -27,6 +27,8 @@ import { AsyncChannel } from "./channel.js";
|
|
|
27
27
|
import { decodeParsed, freshDecodeState } from "./decode.js";
|
|
28
28
|
import type { RunnerDeps, SpawnedProcess } from "./deps.js";
|
|
29
29
|
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
30
|
+
import type { FailureSummary } from "./failure.js";
|
|
31
|
+
import { failureFromAuth, failureFromLimit, reduceFailures } from "./failure.js";
|
|
30
32
|
import { LineBuffer } from "./lines.js";
|
|
31
33
|
import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
|
|
32
34
|
|
|
@@ -145,6 +147,7 @@ export const openSession = (
|
|
|
145
147
|
let exitCode: number | null = null;
|
|
146
148
|
let resultError = false;
|
|
147
149
|
let turnLimitSeen = false;
|
|
150
|
+
let turnFailures: FailureSummary[] = [];
|
|
148
151
|
let pumpError: unknown = null;
|
|
149
152
|
// issue #44: the active turn's last assistant message (where the
|
|
150
153
|
// hcn-question block lives) and whether the turn ended by asking.
|
|
@@ -175,14 +178,24 @@ export const openSession = (
|
|
|
175
178
|
}
|
|
176
179
|
};
|
|
177
180
|
|
|
181
|
+
/** A decoded failure event minus its kind: what done.failure carries. */
|
|
182
|
+
const summaryOf = (event: HarnessEvent & { kind: "failure" }): FailureSummary => {
|
|
183
|
+
const { kind: _kind, ...summary } = event;
|
|
184
|
+
return summary;
|
|
185
|
+
};
|
|
186
|
+
|
|
178
187
|
const startTurn = (): void => {
|
|
179
188
|
turnLimitSeen = false;
|
|
189
|
+
turnFailures = [];
|
|
180
190
|
turnAsked = false;
|
|
181
191
|
lastAssistantText = null;
|
|
182
192
|
activeTurn = new AsyncChannel<HarnessEvent>();
|
|
183
193
|
activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
|
|
184
194
|
log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
|
|
185
|
-
for (const held of preTurnEvents.splice(0))
|
|
195
|
+
for (const held of preTurnEvents.splice(0)) {
|
|
196
|
+
if (held.kind === "failure") turnFailures.push(summaryOf(held));
|
|
197
|
+
activeTurn.push(held);
|
|
198
|
+
}
|
|
186
199
|
turnsChannel.push(activeTurn);
|
|
187
200
|
};
|
|
188
201
|
|
|
@@ -224,6 +237,10 @@ export const openSession = (
|
|
|
224
237
|
// in sessions) and the caller answers with the next send().
|
|
225
238
|
emitQuestionIfAsked();
|
|
226
239
|
if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
|
|
240
|
+
// Every failure was already emitted as an event through pushFailure;
|
|
241
|
+
// the turn's done carries the reduced summary, as streamTurn's does.
|
|
242
|
+
const reduced = reduceFailures(turnFailures);
|
|
243
|
+
if (reduced !== undefined) done = { ...done, failure: reduced };
|
|
227
244
|
activeTurn.push(done);
|
|
228
245
|
activeTurn.close();
|
|
229
246
|
log({
|
|
@@ -265,6 +282,11 @@ export const openSession = (
|
|
|
265
282
|
return Promise.resolve();
|
|
266
283
|
};
|
|
267
284
|
|
|
285
|
+
const pushFailure = (f: FailureSummary): Promise<void> => {
|
|
286
|
+
turnFailures.push(f);
|
|
287
|
+
return routeEvent({ kind: "failure", ...f });
|
|
288
|
+
};
|
|
289
|
+
|
|
268
290
|
const pumpStdout = async (): Promise<void> => {
|
|
269
291
|
const lines = new LineBuffer();
|
|
270
292
|
const matches = (
|
|
@@ -297,6 +319,7 @@ export const openSession = (
|
|
|
297
319
|
const code = detectLimitInLine(h, line);
|
|
298
320
|
if (code !== null) {
|
|
299
321
|
await routeEvent({ kind: "limit", code, message: `limit wall detected (${code})` });
|
|
322
|
+
await pushFailure(failureFromLimit(code));
|
|
300
323
|
}
|
|
301
324
|
return;
|
|
302
325
|
}
|
|
@@ -367,7 +390,10 @@ export const openSession = (
|
|
|
367
390
|
// decodeParsed already surfaces the is_error case as an error event
|
|
368
391
|
// (content.ts claude reader); routing the events is enough - we only
|
|
369
392
|
// still track resultError here to classify the done cause.
|
|
370
|
-
for (const event of events)
|
|
393
|
+
for (const event of events) {
|
|
394
|
+
if (event.kind === "failure") await pushFailure(summaryOf(event));
|
|
395
|
+
else await routeEvent(event);
|
|
396
|
+
}
|
|
371
397
|
if (parsed.is_error === true) resultError = true;
|
|
372
398
|
endTurn({
|
|
373
399
|
kind: "done",
|
|
@@ -376,7 +402,10 @@ export const openSession = (
|
|
|
376
402
|
});
|
|
377
403
|
return;
|
|
378
404
|
}
|
|
379
|
-
for (const event of events)
|
|
405
|
+
for (const event of events) {
|
|
406
|
+
if (event.kind === "failure") await pushFailure(summaryOf(event));
|
|
407
|
+
else await routeEvent(event);
|
|
408
|
+
}
|
|
380
409
|
};
|
|
381
410
|
for await (const chunk of proc.stdout) {
|
|
382
411
|
for (const line of lines.push(chunk)) await handleLine(line);
|
|
@@ -396,10 +425,12 @@ export const openSession = (
|
|
|
396
425
|
code: limit,
|
|
397
426
|
message: `limit wall detected (${limit})`,
|
|
398
427
|
});
|
|
428
|
+
await pushFailure(failureFromLimit(limit));
|
|
399
429
|
continue;
|
|
400
430
|
}
|
|
401
431
|
const auth = detectAuthFailureInLine(h, line);
|
|
402
432
|
if (auth !== null) {
|
|
433
|
+
await pushFailure(failureFromAuth(auth));
|
|
403
434
|
await routeEvent({ kind: "error", message: `auth wall: ${auth}` });
|
|
404
435
|
continue;
|
|
405
436
|
}
|