@dungle-scrubs/harness-cli-normalizer 0.5.4 → 0.5.6
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 +3 -2
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +16 -8
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +11 -4
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/help.d.ts +3 -3
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +32 -23
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +10 -7
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/render.d.ts +15 -1
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +18 -2
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resume-guard.js +29 -2
- package/dist/cli/resume-guard.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +35 -34
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/session-json.d.ts +5 -2
- package/dist/cli/session-json.d.ts.map +1 -1
- package/dist/cli/session-json.js +36 -5
- package/dist/cli/session-json.js.map +1 -1
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +103 -36
- package/dist/cli/session.js.map +1 -1
- package/dist/execution/events.d.ts +8 -1
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.js.map +1 -1
- package/dist/execution/open-session.d.ts +7 -6
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +88 -59
- package/dist/execution/open-session.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +4 -7
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +43 -22
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +9 -5
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +2 -7
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/capabilities.d.ts +20 -0
- package/dist/interpretation/capabilities.d.ts.map +1 -1
- package/dist/interpretation/capabilities.js +27 -0
- package/dist/interpretation/capabilities.js.map +1 -1
- package/dist/interpretation/hints.js +1 -1
- package/dist/interpretation/hints.js.map +1 -1
- package/dist/interpretation/question.d.ts +6 -2
- package/dist/interpretation/question.d.ts.map +1 -1
- package/dist/interpretation/question.js +13 -4
- package/dist/interpretation/question.js.map +1 -1
- package/dist/interpretation/refusal.d.ts +1 -1
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js +1 -1
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/skills-selection.d.ts +18 -1
- package/dist/interpretation/skills-selection.d.ts.map +1 -1
- package/dist/interpretation/skills-selection.js +33 -6
- package/dist/interpretation/skills-selection.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 +16 -6
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +39 -12
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js +1 -0
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +7 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +23 -4
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/args.ts +16 -6
- package/src/cli/config.ts +13 -4
- package/src/cli/help.ts +32 -23
- package/src/cli/inspect.ts +8 -6
- package/src/cli/render.ts +18 -1
- package/src/cli/resume-guard.ts +26 -2
- package/src/cli/run.ts +36 -38
- package/src/cli/session-json.ts +45 -7
- package/src/cli/session.ts +122 -40
- package/src/execution/events.ts +9 -1
- package/src/execution/open-session.ts +99 -72
- package/src/execution/stream-turn.ts +53 -30
- package/src/interpretation/argv.ts +11 -12
- package/src/interpretation/capabilities.ts +50 -0
- package/src/interpretation/hints.ts +1 -1
- package/src/interpretation/question.ts +17 -7
- package/src/interpretation/refusal.ts +2 -1
- package/src/interpretation/skills-selection.ts +46 -8
- package/src/knowledge/claude-code.ts +21 -0
- package/src/knowledge/codex.ts +16 -6
- package/src/knowledge/descriptor.ts +40 -12
- package/src/knowledge/muse.ts +7 -0
- package/src/knowledge/pi.ts +23 -4
package/src/cli/config.ts
CHANGED
|
@@ -76,9 +76,8 @@ const KNOWN_KEYS = new Set([
|
|
|
76
76
|
"maxSteps",
|
|
77
77
|
"toolsets",
|
|
78
78
|
"timeout",
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
"escalateQuestions",
|
|
79
|
+
// question mode: ask|assume|none (behavior instruction, not a turn option)
|
|
80
|
+
"questions",
|
|
82
81
|
// issue #48: payload-stripping dimensions (opt-in-only, no profile
|
|
83
82
|
// entry by ratification).
|
|
84
83
|
"systemPrompt",
|
|
@@ -88,7 +87,8 @@ const KNOWN_KEYS = new Set([
|
|
|
88
87
|
]);
|
|
89
88
|
|
|
90
89
|
const LIST_KEYS = new Set(["tools", "excludeTools"]);
|
|
91
|
-
const BOOL_KEYS = new Set(["autonomy", "write", "shell"
|
|
90
|
+
const BOOL_KEYS = new Set(["autonomy", "write", "shell"]);
|
|
91
|
+
const QUESTIONS_VALUES = new Set(["ask", "assume", "none"]);
|
|
92
92
|
|
|
93
93
|
/** Parse + validate config text. Throws ConfigError with the offending key
|
|
94
94
|
* named on any violation - never warns and continues. */
|
|
@@ -227,6 +227,15 @@ export const parseUserConfig = (text: string): Partial<TurnOptions> => {
|
|
|
227
227
|
(out as Record<string, unknown>)[key] = value;
|
|
228
228
|
continue;
|
|
229
229
|
}
|
|
230
|
+
if (key === "questions") {
|
|
231
|
+
if (typeof value !== "string" || !QUESTIONS_VALUES.has(value)) {
|
|
232
|
+
throw new ConfigError(
|
|
233
|
+
`config key ${JSON.stringify(key)} must be one of ${[...QUESTIONS_VALUES].join(", ")}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
(out as Record<string, unknown>).questions = value;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
230
239
|
if (BOOL_KEYS.has(key)) {
|
|
231
240
|
if (typeof value !== "boolean") {
|
|
232
241
|
throw new ConfigError(`config key ${JSON.stringify(key)} must be a boolean`);
|
package/src/cli/help.ts
CHANGED
|
@@ -48,12 +48,12 @@ Options:
|
|
|
48
48
|
--no-write Disable write
|
|
49
49
|
--shell Enable shell (muse)
|
|
50
50
|
--no-shell Disable shell
|
|
51
|
-
--
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
done cause "awaiting-input", exit 0)
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
--questions <ask|assume|none>
|
|
52
|
+
Which preamble to inject: ask = escalation
|
|
53
|
+
protocol (DEFAULT, question event +
|
|
54
|
+
done cause "awaiting-input", exit 0), assume =
|
|
55
|
+
never ask, state assumption, none = inject
|
|
56
|
+
nothing
|
|
57
57
|
--system-prompt <text> Replace the built-in system prompt (claude, pi:
|
|
58
58
|
flag; codex: -c instructions=<text-or-path>;
|
|
59
59
|
muse refuses. claude pairs the dynamic-section
|
|
@@ -78,12 +78,15 @@ Options:
|
|
|
78
78
|
--no-skills Disable skills discovery facet
|
|
79
79
|
--cwd <path> Working directory for spawn
|
|
80
80
|
--env KEY=VAL Environment (repeatable; KEY= deletes)
|
|
81
|
-
--resume <uuid> Resume session id (UUID).
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
--resume <uuid> Resume session id (UUID). Continues the
|
|
82
|
+
conversation where it left off. The answer
|
|
83
|
+
path for question escalation: resume with the
|
|
84
|
+
chosen answer as the prompt; id continuity per
|
|
84
85
|
harness (claude stable, pi/muse caller-assigned,
|
|
85
86
|
codex minted via identity event)
|
|
86
|
-
Note:
|
|
87
|
+
Note: hcn refuses an unknown id before spawn
|
|
88
|
+
(exit 2) for harnesses that would otherwise
|
|
89
|
+
create a fresh session silently (pi, muse)
|
|
87
90
|
--session-id <uuid> Alias for --resume (mutually exclusive with --resume)
|
|
88
91
|
-- Passthrough: native harness args verbatim
|
|
89
92
|
(failures surface as labeled native errors)
|
|
@@ -102,9 +105,8 @@ Defaults with no flags:
|
|
|
102
105
|
profile). toolMap is config-only (no flag) - canonical -> native
|
|
103
106
|
mapping per harness. A bare pi run renders no --tools list: pi's list
|
|
104
107
|
is a strict allowlist and would drop extension and MCP tools; name
|
|
105
|
-
them through toolMap or native:<name> when you grant. Question
|
|
106
|
-
|
|
107
|
-
"escalateQuestions"; it is a prompt preamble, never a harness flag).
|
|
108
|
+
them through toolMap or native:<name> when you grant. Question mode defaults to ask (config keys
|
|
109
|
+
"questions"; prompt preamble, never a harness flag).
|
|
108
110
|
Provenance prints to stderr on every run; see
|
|
109
111
|
'hcn inspect <harness>' for the resolved argv of a bare run.
|
|
110
112
|
`;
|
|
@@ -120,8 +122,9 @@ Options:
|
|
|
120
122
|
--json Machine surface: NDJSON events on stdout, NDJSON
|
|
121
123
|
commands on stdin ({"op":"send","id":..,"text":..},
|
|
122
124
|
"answer", "close"). Every send is answered with one
|
|
123
|
-
disposition (started |
|
|
124
|
-
|
|
125
|
+
disposition (started | rejected); every send is
|
|
126
|
+
handed to the harness, and the turn that consumes it
|
|
127
|
+
carries its id. The
|
|
125
128
|
stream opens with a session event and ends with a
|
|
126
129
|
closed event. Exit 0 clean, 1 otherwise, 2 refusal.
|
|
127
130
|
--stall <seconds> Per-turn inactivity budget; the turn ends and the
|
|
@@ -129,12 +132,18 @@ Options:
|
|
|
129
132
|
(default: no limit)
|
|
130
133
|
--provider <value> Provider (pi only)
|
|
131
134
|
--model <id> Model for the session
|
|
132
|
-
--
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
--resume <uuid> Resume session id (UUID). Continues the
|
|
136
|
+
conversation where it left off. --session-id
|
|
137
|
+
is an alias (mutually exclusive with --resume).
|
|
138
|
+
If no id is given, a fresh session is started
|
|
139
|
+
with a new id. hcn refuses an unknown id before
|
|
140
|
+
spawn (exit 2) for harnesses that would otherwise
|
|
141
|
+
create a fresh session silently (pi)
|
|
142
|
+
--session-id <uuid> Alias for --resume (mutually exclusive with --resume)
|
|
143
|
+
--questions <ask|assume|none>
|
|
144
|
+
Which preamble to inject: ask = worker may
|
|
145
|
+
ask (DEFAULT, pickable menu), assume = never
|
|
146
|
+
ask, none = inject nothing
|
|
138
147
|
--cwd <path> Working directory
|
|
139
148
|
--help Show help
|
|
140
149
|
--version Show version
|
|
@@ -167,11 +176,11 @@ Options:
|
|
|
167
176
|
--autonomy / --no-autonomy
|
|
168
177
|
--write / --no-write
|
|
169
178
|
--shell / --no-shell
|
|
170
|
-
--
|
|
179
|
+
--questions <ask|assume|none>
|
|
171
180
|
(accepted; renders nothing - rides the run prompt)
|
|
172
181
|
--max-steps <n>
|
|
173
182
|
--no-tools, --no-instruction-files, --no-extensions, --no-skills
|
|
174
|
-
--
|
|
183
|
+
--questions <ask|assume|none>
|
|
175
184
|
Accepted; renders nothing in argv (the mode
|
|
176
185
|
rides the run prompt, not a harness flag)
|
|
177
186
|
--cwd <path>
|
package/src/cli/inspect.ts
CHANGED
|
@@ -210,13 +210,16 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
210
210
|
try {
|
|
211
211
|
const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
|
|
212
212
|
const resolvedSkills = resolveSkillNames(rawSkills, h.name);
|
|
213
|
-
const
|
|
213
|
+
const skillTokens: string[] = [];
|
|
214
214
|
if (h.name === "claude") {
|
|
215
215
|
const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
|
|
216
|
-
|
|
216
|
+
skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
|
|
217
|
+
} else if (h.name === "codex") {
|
|
218
|
+
const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
|
|
219
|
+
skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
|
|
217
220
|
}
|
|
218
221
|
(turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
|
|
219
|
-
(turnOpts as unknown as Record<string, unknown>).
|
|
222
|
+
(turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
|
|
220
223
|
} catch (err) {
|
|
221
224
|
if (err instanceof ArgvRefusalError) {
|
|
222
225
|
process.stderr.write(`${err.message}\n`);
|
|
@@ -301,9 +304,8 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
301
304
|
let argv: string[];
|
|
302
305
|
try {
|
|
303
306
|
argv = buildLaunchArgv(h, fullOpts);
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
if (claudeTokens !== undefined && claudeTokens.length > 0) argv.push(...claudeTokens);
|
|
307
|
+
const skillTokens = (effectiveRest as unknown as { __skillTokens?: string[] }).__skillTokens;
|
|
308
|
+
if (skillTokens !== undefined && skillTokens.length > 0) argv.push(...skillTokens);
|
|
307
309
|
} catch (err) {
|
|
308
310
|
if (err instanceof ArgvRefusalError) {
|
|
309
311
|
refuse(refusalOf(err as ArgvRefusalError), false);
|
package/src/cli/render.ts
CHANGED
|
@@ -81,6 +81,23 @@ export const renderEvent = (event: HarnessEvent, state: RenderState): void => {
|
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
/** Write one NDJSON event, reporting whether stdout took it immediately.
|
|
85
|
+
*
|
|
86
|
+
* `false` means the kernel buffer is full and the bytes are queued in the
|
|
87
|
+
* process. A caller streaming a turn MUST wait for `drain` before writing
|
|
88
|
+
* again (see `writeEventNdjsonAsync`); a caller writing one terminal pair on
|
|
89
|
+
* the way out may ignore it, because Node flushes pending stdout writes
|
|
90
|
+
* before it exits. */
|
|
91
|
+
export const writeEventNdjson = (event: HarnessEvent): boolean =>
|
|
85
92
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
93
|
+
|
|
94
|
+
/** The streaming form: resolves once stdout has taken the line.
|
|
95
|
+
*
|
|
96
|
+
* Without this a slow reader is absorbed by the process rather than pushed
|
|
97
|
+
* back on: hcn keeps pulling from the harness and buffering, so memory grows
|
|
98
|
+
* with the turn instead of the harness being stalled. RFC-01 rule 8 names
|
|
99
|
+
* both hops; this is the hcn-to-consumer one. */
|
|
100
|
+
export const writeEventNdjsonAsync = async (event: HarnessEvent): Promise<void> => {
|
|
101
|
+
if (writeEventNdjson(event)) return;
|
|
102
|
+
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
|
|
86
103
|
};
|
package/src/cli/resume-guard.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { storePath } from "../interpretation/store.js";
|
|
3
3
|
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
4
4
|
|
|
@@ -31,5 +31,29 @@ export const resumeStore = (
|
|
|
31
31
|
} catch {
|
|
32
32
|
path = null;
|
|
33
33
|
}
|
|
34
|
-
return { path, exists: path !== null &&
|
|
34
|
+
return { path, exists: path !== null && sessionExistsAt(path, opts.sessionId) };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A store template that resolves to a FILE names the session directly and
|
|
38
|
+
* existence is the file's. One that resolves to a DIRECTORY names where the
|
|
39
|
+
* harness files every session for this cwd, and the session is one entry in
|
|
40
|
+
* it whose name carries the id somewhere - pi writes `<timestamp>_<id>.jsonl`,
|
|
41
|
+
* so the id's position is not a template. Checking only that the directory
|
|
42
|
+
* exists reported every id as present once any session had ever run in the
|
|
43
|
+
* cwd, which made the guard pass unknown ids on pi and, once `origin` rode
|
|
44
|
+
* on it, reported a brand-new session as resumed. */
|
|
45
|
+
const sessionExistsAt = (path: string, sessionId: string): boolean => {
|
|
46
|
+
if (!existsSync(path)) return false;
|
|
47
|
+
let isDir = false;
|
|
48
|
+
try {
|
|
49
|
+
isDir = statSync(path).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (!isDir) return true;
|
|
54
|
+
try {
|
|
55
|
+
return readdirSync(path).some((name) => name.includes(sessionId));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
35
59
|
};
|
package/src/cli/run.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.
|
|
|
9
9
|
import { defaultDescriptors } from "../knowledge/overrides.js";
|
|
10
10
|
import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
|
|
11
11
|
import { refusalOf, refuse } from "./refuse.js";
|
|
12
|
-
import { createRenderState, renderEvent,
|
|
12
|
+
import { createRenderState, renderEvent, writeEventNdjsonAsync } from "./render.js";
|
|
13
13
|
import { resolveHarness } from "./resolve-harness.js";
|
|
14
14
|
import { resumeStore } from "./resume-guard.js";
|
|
15
15
|
|
|
@@ -185,13 +185,16 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
185
185
|
try {
|
|
186
186
|
const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
|
|
187
187
|
const resolvedSkills = resolveSkillNames(rawSkills, h.name);
|
|
188
|
-
const
|
|
188
|
+
const skillTokens: string[] = [];
|
|
189
189
|
if (h.name === "claude") {
|
|
190
190
|
const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
|
|
191
|
-
|
|
191
|
+
skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
|
|
192
|
+
} else if (h.name === "codex") {
|
|
193
|
+
const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
|
|
194
|
+
skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
|
|
192
195
|
}
|
|
193
196
|
(turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
|
|
194
|
-
(turnOpts as unknown as Record<string, unknown>).
|
|
197
|
+
(turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
|
|
195
198
|
} catch (err) {
|
|
196
199
|
if (err instanceof ArgvRefusalError) {
|
|
197
200
|
refuse(refusalOf(err), wantJson);
|
|
@@ -209,9 +212,9 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
209
212
|
project?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
210
213
|
} = {};
|
|
211
214
|
// Config files load on EVERY run, launch or resume: the tiers feed the
|
|
212
|
-
// defaults profile on launch, and issue #41's
|
|
215
|
+
// defaults profile on launch, and issue #41's questions (a
|
|
213
216
|
// behavior instruction, not a turn option) resolves from them on resume
|
|
214
|
-
// too - otherwise a no-
|
|
217
|
+
// too - otherwise a no-ask session would flip its preamble on the
|
|
215
218
|
// answer turn. Resolution of TURN options stays launch-only.
|
|
216
219
|
const tiers = resolvedTiers;
|
|
217
220
|
{
|
|
@@ -256,39 +259,34 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
256
259
|
writeProvenance(h.name, provenance, unrenderable);
|
|
257
260
|
}
|
|
258
261
|
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
: userEscalate !== undefined
|
|
274
|
-
? userEscalate
|
|
275
|
-
: true;
|
|
276
|
-
const escalateTier =
|
|
277
|
-
turnOpts.escalateQuestions !== undefined
|
|
262
|
+
// question mode precedence arg > project > user > default (ask)
|
|
263
|
+
const projectQuestions = (resolvedTiers.project as { questions?: string } | undefined)?.questions;
|
|
264
|
+
const userQuestions = (resolvedTiers.user as { questions?: string } | undefined)?.questions;
|
|
265
|
+
const rawMode = (turnOpts as { questions?: string }).questions;
|
|
266
|
+
const questionMode =
|
|
267
|
+
rawMode !== undefined
|
|
268
|
+
? (rawMode as import("../interpretation/question.js").QuestionMode)
|
|
269
|
+
: projectQuestions !== undefined
|
|
270
|
+
? (projectQuestions as import("../interpretation/question.js").QuestionMode)
|
|
271
|
+
: userQuestions !== undefined
|
|
272
|
+
? (userQuestions as import("../interpretation/question.js").QuestionMode)
|
|
273
|
+
: ("ask" as const);
|
|
274
|
+
const questionTier =
|
|
275
|
+
rawMode !== undefined
|
|
278
276
|
? "arg"
|
|
279
|
-
:
|
|
277
|
+
: projectQuestions !== undefined
|
|
280
278
|
? "project-config"
|
|
281
|
-
:
|
|
279
|
+
: userQuestions !== undefined
|
|
282
280
|
? "user-config"
|
|
283
281
|
: "default";
|
|
284
282
|
|
|
285
283
|
const fullOpts = {
|
|
286
284
|
...effectiveTurnOpts,
|
|
287
|
-
prompt: composeEscalatedPrompt(prompt,
|
|
285
|
+
prompt: composeEscalatedPrompt(prompt, questionMode),
|
|
288
286
|
cwd: extra.cwd,
|
|
289
287
|
env: extra.env,
|
|
290
288
|
resume: extra.resume,
|
|
291
|
-
|
|
289
|
+
questions: questionMode,
|
|
292
290
|
...(passthrough.length > 0 ? { passthrough } : {}),
|
|
293
291
|
...(isExplicit ? { __explicitPrompt: true as const } : {}),
|
|
294
292
|
} as Parameters<typeof streamTurn>[1] & {
|
|
@@ -304,7 +302,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
304
302
|
if (fullOpts.resume) {
|
|
305
303
|
// Resume never carries TURN-option profile resolution (launch-only
|
|
306
304
|
// rule), so it builds from the raw turn options; hcn-owned behavior
|
|
307
|
-
// (
|
|
305
|
+
// (questions preamble, timeout budget) still applies.
|
|
308
306
|
preArgv = buildResumeArgv(h, {
|
|
309
307
|
...(turnOpts as object),
|
|
310
308
|
prompt: fullOpts.prompt,
|
|
@@ -321,10 +319,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
321
319
|
prompt: fullOpts.prompt,
|
|
322
320
|
__explicitPrompt: isExplicit,
|
|
323
321
|
} as never);
|
|
324
|
-
const
|
|
325
|
-
.
|
|
326
|
-
if (
|
|
327
|
-
preArgv.push(...
|
|
322
|
+
const skillTokens = (effectiveTurnOpts as unknown as { __skillTokens?: string[] })
|
|
323
|
+
.__skillTokens;
|
|
324
|
+
if (skillTokens !== undefined && skillTokens.length > 0) {
|
|
325
|
+
preArgv.push(...skillTokens);
|
|
328
326
|
}
|
|
329
327
|
}
|
|
330
328
|
_validated = true;
|
|
@@ -349,9 +347,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
349
347
|
}
|
|
350
348
|
// issue #41: the escalation mode rides stderr as provenance, like
|
|
351
349
|
// every other resolution the turn depends on.
|
|
352
|
-
process.stderr.write(
|
|
353
|
-
`provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
|
|
354
|
-
);
|
|
350
|
+
process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
|
|
355
351
|
// On a harness whose include flag is not a strict allowlist (claude),
|
|
356
352
|
// a name outside the curated set passes through ungated; say which
|
|
357
353
|
// ones so a wrong-case name is visible. A grant with no known name at
|
|
@@ -465,7 +461,9 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
465
461
|
for await (const event of events) {
|
|
466
462
|
lastEvent = event;
|
|
467
463
|
if (wantJson) {
|
|
468
|
-
|
|
464
|
+
// Await the write: a consumer that stops reading must stall the
|
|
465
|
+
// harness, not be absorbed into this process's memory.
|
|
466
|
+
await writeEventNdjsonAsync(event);
|
|
469
467
|
} else {
|
|
470
468
|
renderEvent(event, state);
|
|
471
469
|
}
|
package/src/cli/session-json.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* with the control events (`session`, `turn`, `disposition`, `closed`) that
|
|
5
5
|
* a program needs to drive a session it does not own the timing of. RFC-01.
|
|
6
6
|
*
|
|
7
|
-
* This owns the wire framing only. The turn lifecycle
|
|
7
|
+
* This owns the wire framing only. The turn lifecycle and the id
|
|
8
8
|
* correlation live in `openSession`; this reads the id off the yielded turn
|
|
9
9
|
* rather than shadowing the runner's delivery order.
|
|
10
10
|
*/
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type SessionHandle,
|
|
17
17
|
type SessionSendResult,
|
|
18
18
|
} from "../execution/open-session.js";
|
|
19
|
+
import type { QuestionMode } from "../interpretation/question.js";
|
|
19
20
|
|
|
20
21
|
/** What the CLI reads back after a close, captured from the runner's
|
|
21
22
|
* `session_close` boundary log. */
|
|
@@ -24,15 +25,18 @@ export interface CloseInfo {
|
|
|
24
25
|
cause: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export type SessionOrigin = "fresh" | "resumed";
|
|
29
|
+
|
|
27
30
|
export interface JsonSessionArgs {
|
|
28
31
|
readonly handle: SessionHandle;
|
|
29
32
|
readonly sessionId: string;
|
|
30
33
|
readonly harness: string;
|
|
31
34
|
readonly hcnVersion: string;
|
|
32
|
-
readonly
|
|
35
|
+
readonly questions?: QuestionMode;
|
|
36
|
+
readonly origin: SessionOrigin;
|
|
33
37
|
/** Read after close - the runner's final exitCode and cause. */
|
|
34
38
|
readonly getCloseInfo: () => CloseInfo;
|
|
35
|
-
/** Read after close - ids the runner accepted
|
|
39
|
+
/** Read after close - ids the runner accepted and never
|
|
36
40
|
* delivered. Each owes the consumer a rejection (RFC S003). */
|
|
37
41
|
readonly getDroppedIds?: () => readonly string[];
|
|
38
42
|
/** Injected for tests; defaults to process.stdin / process.stdout. */
|
|
@@ -116,12 +120,14 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
|
116
120
|
return chain;
|
|
117
121
|
};
|
|
118
122
|
|
|
123
|
+
const qMode: QuestionMode = a.questions ?? "ask";
|
|
119
124
|
await emit({
|
|
120
125
|
kind: "session",
|
|
121
126
|
sessionId: a.sessionId,
|
|
122
127
|
harness: a.harness,
|
|
123
128
|
hcn: a.hcnVersion,
|
|
124
|
-
|
|
129
|
+
questions: qMode,
|
|
130
|
+
origin: a.origin,
|
|
125
131
|
});
|
|
126
132
|
|
|
127
133
|
// Shared between the pumps: the last question asked, whether the last turn
|
|
@@ -154,6 +160,17 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
|
154
160
|
})();
|
|
155
161
|
|
|
156
162
|
const rl = createInterface({ input: a.input ?? process.stdin });
|
|
163
|
+
// Ids accepted from stdin but not yet answered with a disposition. If the
|
|
164
|
+
// command stream itself fails, each is owed one: silence would leave the
|
|
165
|
+
// consumer waiting on an answer that can no longer arrive.
|
|
166
|
+
const unanswered = new Set<string>();
|
|
167
|
+
let stdinError: string | null = null;
|
|
168
|
+
const commandStream = (a.input ?? process.stdin) as {
|
|
169
|
+
on?: (event: string, listener: (err: NodeJS.ErrnoException) => void) => unknown;
|
|
170
|
+
};
|
|
171
|
+
commandStream.on?.("error", (err) => {
|
|
172
|
+
stdinError = err.code ?? err.message;
|
|
173
|
+
});
|
|
157
174
|
const stdinPump = (async () => {
|
|
158
175
|
for await (const line of rl) {
|
|
159
176
|
if (line.trim() === "") continue;
|
|
@@ -177,12 +194,14 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
|
177
194
|
text = composeAnswer(lastQuestion, cmd.text);
|
|
178
195
|
}
|
|
179
196
|
let sent: SessionSendResult;
|
|
197
|
+
unanswered.add(cmd.id);
|
|
180
198
|
try {
|
|
181
199
|
sent = a.handle.send({ id: cmd.id, text });
|
|
182
200
|
} catch (err) {
|
|
183
201
|
// A session the caller already closed, or one already dead: a
|
|
184
202
|
// different remedy from a broken pipe, so a different reason.
|
|
185
203
|
if (err instanceof SessionClosedError) {
|
|
204
|
+
unanswered.delete(cmd.id);
|
|
186
205
|
await emit({
|
|
187
206
|
kind: "disposition",
|
|
188
207
|
id: cmd.id,
|
|
@@ -191,8 +210,10 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
|
191
210
|
});
|
|
192
211
|
continue;
|
|
193
212
|
}
|
|
213
|
+
unanswered.delete(cmd.id);
|
|
194
214
|
throw err;
|
|
195
215
|
}
|
|
216
|
+
unanswered.delete(cmd.id);
|
|
196
217
|
await emit({
|
|
197
218
|
kind: "disposition",
|
|
198
219
|
id: cmd.id,
|
|
@@ -201,16 +222,33 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
|
201
222
|
});
|
|
202
223
|
}
|
|
203
224
|
})();
|
|
225
|
+
// A command stream that breaks is not a clean end of input, but it is also
|
|
226
|
+
// not a crash of this process: record it and close the session in order.
|
|
227
|
+
// The caught form is what the race waits on - the raw pump would reject
|
|
228
|
+
// straight out of runJsonSession.
|
|
229
|
+
const stdinDone = stdinPump.catch((err: unknown) => {
|
|
230
|
+
stdinError = err instanceof Error ? err.message : String(err);
|
|
231
|
+
});
|
|
204
232
|
|
|
205
233
|
// Whichever ends first drives the close: a close/EOF from stdin, or the
|
|
206
234
|
// session dying (the turns iterable ends). close() is idempotent; closing
|
|
207
235
|
// the readline unblocks the stdin pump if the session died first.
|
|
208
|
-
await Promise.race([
|
|
236
|
+
await Promise.race([stdinDone, stdoutPump]);
|
|
209
237
|
await a.handle.close();
|
|
210
238
|
rl.close();
|
|
211
|
-
await Promise.allSettled([
|
|
239
|
+
await Promise.allSettled([stdinDone, stdoutPump]);
|
|
240
|
+
|
|
241
|
+
// A broken command stream is a fact the consumer needs, and every send it
|
|
242
|
+
// swallowed is owed its answer.
|
|
243
|
+
if (stdinError !== null) {
|
|
244
|
+
await emit({ kind: "error", message: `command stream failed: ${stdinError}` });
|
|
245
|
+
}
|
|
246
|
+
for (const id of unanswered) {
|
|
247
|
+
await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });
|
|
248
|
+
}
|
|
249
|
+
unanswered.clear();
|
|
212
250
|
|
|
213
|
-
// Every input the runner accepted
|
|
251
|
+
// Every input the runner accepted and then lost gets its own
|
|
214
252
|
// rejection, before the terminal line (RFC S003).
|
|
215
253
|
for (const id of a.getDroppedIds?.() ?? []) {
|
|
216
254
|
await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });
|