@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/session.ts
CHANGED
|
@@ -57,45 +57,96 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const values = parsed.values as Record<string, unknown>;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// --resume and --session-id are aliases for one concept: resume an existing
|
|
61
|
+
// session. Passing both is refused, matching hcn run's parseRunExtra shape
|
|
62
|
+
// (src/cli/args.ts) rather than inventing a second parser.
|
|
63
|
+
if (values.resume !== undefined && values["session-id"] !== undefined) {
|
|
64
|
+
const { ArgvRefusalError: AliasError } = await import("../interpretation/refusal.js");
|
|
65
|
+
const err = new AliasError({
|
|
66
|
+
issue: "mutually-exclusive-options",
|
|
67
|
+
harness: h.name,
|
|
68
|
+
supported: ["--resume or --session-id, not both (--session-id is an alias for --resume)"],
|
|
69
|
+
detail: "both --resume and --session-id given",
|
|
70
|
+
});
|
|
71
|
+
const { refusalOf: aliasRefusalOf, refuse: aliasRefuse } = await import("./refuse.js");
|
|
72
|
+
aliasRefuse(aliasRefusalOf(err), jsonMode, "closed");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const resumeId =
|
|
76
|
+
(values.resume as string | undefined) ?? (values["session-id"] as string | undefined);
|
|
77
|
+
// The two flags are aliases for "use this session". Whether that means
|
|
78
|
+
// RESUME is decided by the store, not by which spelling was typed: an id
|
|
79
|
+
// that exists is resumed (resumeFlag rendered), an id that does not exist
|
|
80
|
+
// names a fresh session (idFlag rendered). --resume on an id that does not
|
|
81
|
+
// exist is the one combination refused, below - a caller who said "resume"
|
|
82
|
+
// and would silently get a fresh conversation is issue #86 exactly.
|
|
83
|
+
const explicitResume = values.resume !== undefined;
|
|
84
|
+
// A caller-supplied id must be the shape this harness's store files
|
|
85
|
+
// sessions under (issue #95). hcn run already holds this line through
|
|
86
|
+
// parse-resume; session only checked the safe-filename rule, so "bogus"
|
|
87
|
+
// reached the harness, rode the session and identity events, and on a
|
|
88
|
+
// create-on-unknown harness became a real file nobody can find again.
|
|
89
|
+
if (resumeId !== undefined && !h.resume.idShape.test(resumeId)) {
|
|
90
|
+
const flag = explicitResume ? "--resume" : "--session-id";
|
|
91
|
+
const { refuse: shapeRefuse } = await import("./refuse.js");
|
|
92
|
+
shapeRefuse(
|
|
93
|
+
{
|
|
94
|
+
message: `${flag} ${JSON.stringify(resumeId.slice(0, 64))} is not a ${h.name} session id`,
|
|
95
|
+
issue: "invalid-option-value",
|
|
96
|
+
supported: [`a session id matching ${String(h.resume.idShape)}`],
|
|
97
|
+
},
|
|
98
|
+
jsonMode,
|
|
99
|
+
"closed",
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const sessionId = resumeId ?? randomUUID();
|
|
104
|
+
let isResume = false;
|
|
64
105
|
const model = values.model as string | undefined;
|
|
65
106
|
const cwd = values.cwd as string | undefined;
|
|
66
107
|
const provider = values.provider as string | undefined;
|
|
67
108
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
// question mode precedence arg > project > user > default (ask)
|
|
110
|
+
const rawArgMode = values.questions !== undefined ? String(values.questions) : undefined;
|
|
111
|
+
if (rawArgMode !== undefined && !["ask", "assume", "none"].includes(rawArgMode)) {
|
|
112
|
+
const { refuse, refusalOf } = await import("./refuse.js");
|
|
113
|
+
const { ArgvRefusalError } = await import("../interpretation/refusal.js");
|
|
114
|
+
refuse(
|
|
115
|
+
refusalOf(
|
|
116
|
+
new ArgvRefusalError({
|
|
117
|
+
issue: "invalid-option-value",
|
|
118
|
+
harness: h.name,
|
|
119
|
+
option: "questions",
|
|
120
|
+
supported: ["ask", "assume", "none"],
|
|
121
|
+
detail: rawArgMode,
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
jsonMode,
|
|
125
|
+
"closed",
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let questionMode: import("../interpretation/question.js").QuestionMode;
|
|
130
|
+
let questionTier: "arg" | "project-config" | "user-config" | "default";
|
|
79
131
|
try {
|
|
80
132
|
const { loadUserConfig, loadProjectConfig } = await import("./config.js");
|
|
81
|
-
const user = loadUserConfig()?.config as {
|
|
82
|
-
const project = loadProjectConfig()?.config as {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
: "default";
|
|
133
|
+
const user = loadUserConfig()?.config as { questions?: string } | undefined;
|
|
134
|
+
const project = loadProjectConfig()?.config as { questions?: string } | undefined;
|
|
135
|
+
const userMode = user?.questions;
|
|
136
|
+
const projectMode = project?.questions;
|
|
137
|
+
if (rawArgMode !== undefined) {
|
|
138
|
+
questionMode = rawArgMode as import("../interpretation/question.js").QuestionMode;
|
|
139
|
+
questionTier = "arg";
|
|
140
|
+
} else if (projectMode !== undefined) {
|
|
141
|
+
questionMode = projectMode as import("../interpretation/question.js").QuestionMode;
|
|
142
|
+
questionTier = "project-config";
|
|
143
|
+
} else if (userMode !== undefined) {
|
|
144
|
+
questionMode = userMode as import("../interpretation/question.js").QuestionMode;
|
|
145
|
+
questionTier = "user-config";
|
|
146
|
+
} else {
|
|
147
|
+
questionMode = "ask";
|
|
148
|
+
questionTier = "default";
|
|
149
|
+
}
|
|
99
150
|
} catch (configErr) {
|
|
100
151
|
process.stderr.write(`config error: ${(configErr as Error).message}\n`);
|
|
101
152
|
if (jsonMode) {
|
|
@@ -112,9 +163,38 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
112
163
|
process.exitCode = 2;
|
|
113
164
|
return;
|
|
114
165
|
}
|
|
115
|
-
process.stderr.write(`provenance:
|
|
166
|
+
process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
|
|
116
167
|
|
|
117
168
|
// Validate sessionId shape? let openSession handle via assertUsableSessionId
|
|
169
|
+
// Unknown-id refusal reuses the run resume guard (src/cli/resume-guard.ts):
|
|
170
|
+
// harnesses that create on unknown (pi, muse with onMissing === "create")
|
|
171
|
+
// are refused before spawn with the same message shape hcn run uses.
|
|
172
|
+
// claude (onMissing === "error") refuses on its own; no store check.
|
|
173
|
+
if (resumeId !== undefined) {
|
|
174
|
+
const { resumeStore: checkResumeStore } = await import("./resume-guard.js");
|
|
175
|
+
const { path: storePath, exists } = checkResumeStore(h, {
|
|
176
|
+
home: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
177
|
+
cwd: cwd ?? process.cwd(),
|
|
178
|
+
sessionId,
|
|
179
|
+
});
|
|
180
|
+
// Exists in the store: resume it, whichever alias was typed. Absent and the
|
|
181
|
+
// caller only named an id: a fresh session under that id, as before.
|
|
182
|
+
isResume = storePath !== null && exists;
|
|
183
|
+
if (explicitResume && storePath !== null && !exists) {
|
|
184
|
+
const { refuse: guardRefuse } = await import("./refuse.js");
|
|
185
|
+
guardRefuse(
|
|
186
|
+
{
|
|
187
|
+
message: `no ${h.name} session ${sessionId} found at ${storePath}`,
|
|
188
|
+
issue: "invalid-option-value",
|
|
189
|
+
supported: [`a session id that exists in ${h.name}'s store`],
|
|
190
|
+
},
|
|
191
|
+
jsonMode,
|
|
192
|
+
"closed",
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
118
198
|
delete (process.env as Record<string, string | undefined>).HERDR_ENV;
|
|
119
199
|
|
|
120
200
|
const wantJson = values.json === true;
|
|
@@ -164,7 +244,11 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
164
244
|
|
|
165
245
|
let handle: ReturnType<typeof openSession>;
|
|
166
246
|
try {
|
|
167
|
-
handle = openSession(
|
|
247
|
+
handle = openSession(
|
|
248
|
+
h,
|
|
249
|
+
{ sessionId, model, cwd, questions: questionMode, provider, isResume },
|
|
250
|
+
deps,
|
|
251
|
+
);
|
|
168
252
|
} catch (err) {
|
|
169
253
|
if (err instanceof ArgvRefusalError) {
|
|
170
254
|
const { refusalOf, refuse } = await import("./refuse.js");
|
|
@@ -196,7 +280,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
196
280
|
sessionId,
|
|
197
281
|
harness: h.name,
|
|
198
282
|
hcnVersion: getVersion(),
|
|
199
|
-
|
|
283
|
+
questions: questionMode,
|
|
284
|
+
origin: isResume ? "resumed" : "fresh",
|
|
200
285
|
getCloseInfo: () => closeInfo,
|
|
201
286
|
getDroppedIds: () => droppedIds,
|
|
202
287
|
});
|
|
@@ -258,10 +343,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
258
343
|
const trimmed = line.trim();
|
|
259
344
|
if (trimmed === "" || trimmed === "exit") break;
|
|
260
345
|
|
|
261
|
-
|
|
262
|
-
if (result.disposition === "queued") {
|
|
263
|
-
process.stderr.write(`disposition: queued (turn in progress)\n`);
|
|
264
|
-
}
|
|
346
|
+
handle.send({ id: `you-${++sendCount}`, text: line });
|
|
265
347
|
|
|
266
348
|
const turn = (await turns.next()).value as
|
|
267
349
|
| AsyncIterable<import("../execution/events.js").HarnessEvent>
|
package/src/execution/events.ts
CHANGED
|
@@ -25,10 +25,17 @@ export type ExitCause =
|
|
|
25
25
|
| "stall"
|
|
26
26
|
| "killed"
|
|
27
27
|
| "failed"
|
|
28
|
-
/** issue #41: the turn ended by asking (
|
|
28
|
+
/** issue #41: the turn ended by asking (questions ask) - a
|
|
29
29
|
* SUCCESSFUL turn (process exit 0); the caller resumes with the answer. */
|
|
30
30
|
| "awaiting-input";
|
|
31
31
|
|
|
32
|
+
export type EscalationMode = "ask" | "assume" | "none";
|
|
33
|
+
export type EscalationDetection = "block" | "malformed" | "none";
|
|
34
|
+
export interface EscalationRecord {
|
|
35
|
+
readonly mode: EscalationMode;
|
|
36
|
+
readonly detection: EscalationDetection;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export type HarnessEvent =
|
|
33
40
|
| {
|
|
34
41
|
readonly kind: "identity";
|
|
@@ -60,6 +67,7 @@ export type HarnessEvent =
|
|
|
60
67
|
readonly exitCode: number | null;
|
|
61
68
|
readonly cause: ExitCause;
|
|
62
69
|
readonly failure?: FailureSummary;
|
|
70
|
+
readonly escalation: EscalationRecord;
|
|
63
71
|
};
|
|
64
72
|
|
|
65
73
|
export const DROPPABLE_KINDS = new Set(["token", "progress", "context"]);
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* openSession: the persistent headless session runner - ONE process, many
|
|
3
|
-
* turns (A-001). `send`
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* context unpredictably - the harness itself queues, so we mirror its
|
|
7
|
-
* disposition). `result` lines delimit turns; identity dedupe (D-022)
|
|
3
|
+
* turns (A-001). `send` writes a descriptor-encoded user record to stdin and starts a
|
|
4
|
+
* turn when idle or hands the text to the harness when a turn is live
|
|
5
|
+
* (the harness queues it). `result` lines delimit turns; identity dedupe (D-022)
|
|
8
6
|
* spans the whole session. Lifecycle is bounded end to end: close() ends
|
|
9
7
|
* stdin, escalates SIGTERM->SIGKILL if the child ignores EOF, and pipes
|
|
10
8
|
* held open past exit close out after grace - a session can always be
|
|
11
|
-
* ended.
|
|
9
|
+
* ended. Pending sends that die with the session are surfaced, never
|
|
12
10
|
* silently dropped. Structured lifecycle events (session open/close, turn
|
|
13
11
|
* start/end, send dispositions, drops) are always-on evidence with
|
|
14
12
|
* sessionId + turnId correlation.
|
|
15
13
|
*/
|
|
16
14
|
import { buildSessionArgv } from "../interpretation/argv.js";
|
|
17
15
|
import { capabilitiesOf } from "../interpretation/capabilities.js";
|
|
16
|
+
import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
|
|
18
17
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "../interpretation/
|
|
23
|
-
import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
|
|
18
|
+
composeEscalatedPrompt,
|
|
19
|
+
detectQuestionBlock,
|
|
20
|
+
type QuestionMode,
|
|
21
|
+
} from "../interpretation/question.js";
|
|
24
22
|
import {
|
|
25
23
|
encodeSessionInput,
|
|
26
24
|
resolveSessionInput,
|
|
@@ -30,7 +28,7 @@ import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descr
|
|
|
30
28
|
import { AsyncChannel } from "./channel.js";
|
|
31
29
|
import { decodeParsed, freshDecodeState } from "./decode.js";
|
|
32
30
|
import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
|
|
33
|
-
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
31
|
+
import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
|
|
34
32
|
import type { FailureSummary } from "./failure.js";
|
|
35
33
|
import {
|
|
36
34
|
failureFromAuth,
|
|
@@ -50,7 +48,7 @@ export const CLOSE_GRACE_MS = 5_000;
|
|
|
50
48
|
const PRETURN_MAX = 256;
|
|
51
49
|
|
|
52
50
|
export interface SessionSendResult {
|
|
53
|
-
readonly disposition: "started" | "
|
|
51
|
+
readonly disposition: "started" | "rejected";
|
|
54
52
|
/** Present when rejected. `write-failed` is a broken stdin pipe, which is
|
|
55
53
|
* a different remedy from a session the caller already closed - the two
|
|
56
54
|
* must stay distinguishable. */
|
|
@@ -88,13 +86,13 @@ export interface OpenSessionOptions {
|
|
|
88
86
|
readonly model?: string;
|
|
89
87
|
/** Working directory for the spawned harness. */
|
|
90
88
|
readonly cwd?: string;
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
* every send and arms block detection at turn end; false composes the
|
|
94
|
-
* no-ask instruction and disarms detection. */
|
|
95
|
-
readonly escalateQuestions?: boolean;
|
|
89
|
+
/** question mode for session (ask/assume/none), default "ask" */
|
|
90
|
+
readonly questions?: QuestionMode;
|
|
96
91
|
/** Provider selector (pi); refused on a harness without one. */
|
|
97
92
|
readonly provider?: string;
|
|
93
|
+
/** True when resuming an existing conversation; controls which flag
|
|
94
|
+
* (resumeFlag vs idFlag) buildSessionArgv renders. */
|
|
95
|
+
readonly isResume?: boolean;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
export class SessionClosedError extends Error {
|
|
@@ -123,6 +121,7 @@ export const openSession = (
|
|
|
123
121
|
sessionId: opts.sessionId,
|
|
124
122
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
125
123
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
124
|
+
...(opts.isResume !== undefined ? { isResume: opts.isResume } : {}),
|
|
126
125
|
});
|
|
127
126
|
let sessionInput: SessionInputContract;
|
|
128
127
|
try {
|
|
@@ -168,13 +167,21 @@ export const openSession = (
|
|
|
168
167
|
|
|
169
168
|
const turnsChannel = new AsyncChannel<SessionTurn>();
|
|
170
169
|
const state = freshDecodeState(opts.sessionId);
|
|
171
|
-
const
|
|
170
|
+
const questionMode: QuestionMode = opts.questions ?? "ask";
|
|
172
171
|
const sessionInputMode = h.sessionMode;
|
|
173
172
|
const stderrTail = new StderrTail();
|
|
174
173
|
let turnCounter = 0;
|
|
175
174
|
let activeTurn: AsyncChannel<HarnessEvent> | null = null;
|
|
176
175
|
let activeTurnId = "";
|
|
177
|
-
const
|
|
176
|
+
const pendingIds: string[] = [];
|
|
177
|
+
const pendingLengths: number[] = [];
|
|
178
|
+
// close() waits here while a turn is open. Ending the child's stdin
|
|
179
|
+
// mid-turn is fatal on pi: rpc treats EOF as "finish up and exit", so the
|
|
180
|
+
// prompt it has buffered never runs and the turn ends clean with no
|
|
181
|
+
// output (issue #99). claude happens to drain a queued turn after EOF,
|
|
182
|
+
// which is why this was invisible there. The README promises that a close
|
|
183
|
+
// after a send lets the turn finish; this is what keeps that promise.
|
|
184
|
+
let turnSettled: (() => void) | null = null;
|
|
178
185
|
const preTurnEvents: HarnessEvent[] = [];
|
|
179
186
|
let dead = false;
|
|
180
187
|
let closing = false;
|
|
@@ -188,6 +195,7 @@ export const openSession = (
|
|
|
188
195
|
// hcn-question block lives) and whether the turn ended by asking.
|
|
189
196
|
let lastAssistantText: string | null = null;
|
|
190
197
|
let turnAsked = false;
|
|
198
|
+
let turnEscalationDetection: EscalationDetection = "none";
|
|
191
199
|
let identityAnnounced = false;
|
|
192
200
|
|
|
193
201
|
const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
|
|
@@ -237,10 +245,7 @@ export const openSession = (
|
|
|
237
245
|
const writeUser = (text: string): boolean => {
|
|
238
246
|
try {
|
|
239
247
|
stdin.write(
|
|
240
|
-
encodeSessionInput(
|
|
241
|
-
sessionInput,
|
|
242
|
-
composeEscalatedPrompt(text, escalateQuestions, "session"),
|
|
243
|
-
),
|
|
248
|
+
encodeSessionInput(sessionInput, composeEscalatedPrompt(text, questionMode, "session")),
|
|
244
249
|
);
|
|
245
250
|
return true;
|
|
246
251
|
} catch {
|
|
@@ -265,11 +270,12 @@ export const openSession = (
|
|
|
265
270
|
turnLimitSeen = false;
|
|
266
271
|
turnFailures = [];
|
|
267
272
|
turnAsked = false;
|
|
273
|
+
turnEscalationDetection = "none";
|
|
268
274
|
lastAssistantText = null;
|
|
269
275
|
activeTurn = new AsyncChannel<HarnessEvent>();
|
|
270
276
|
activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
|
|
271
277
|
// Tag the turn with the id of the send that opened it, so the consumer
|
|
272
|
-
// correlates
|
|
278
|
+
// correlates an input to its turn by reading the tag, not by
|
|
273
279
|
// shadowing the runner's delivery order.
|
|
274
280
|
(activeTurn as { inputId?: string; turnId?: string }).inputId = inputId;
|
|
275
281
|
(activeTurn as { inputId?: string; turnId?: string }).turnId = activeTurnId;
|
|
@@ -288,16 +294,24 @@ export const openSession = (
|
|
|
288
294
|
* turn stream right before its done; a malformed block surfaces as an
|
|
289
295
|
* error event, never a silent no-op. */
|
|
290
296
|
const emitQuestionIfAsked = (): void => {
|
|
291
|
-
if (
|
|
297
|
+
if (questionMode !== "ask" || lastAssistantText === null) {
|
|
298
|
+
turnEscalationDetection = "none";
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
292
301
|
const detection = detectQuestionBlock(lastAssistantText);
|
|
293
|
-
if (detection === null)
|
|
302
|
+
if (detection === null) {
|
|
303
|
+
turnEscalationDetection = "none";
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
294
306
|
if ("malformed" in detection) {
|
|
307
|
+
turnEscalationDetection = "malformed";
|
|
295
308
|
activeTurn?.push({ kind: "error", message: detection.malformed });
|
|
296
309
|
const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
|
|
297
310
|
turnFailures.push(failure);
|
|
298
311
|
void activeTurn?.push({ kind: "failure", ...failure });
|
|
299
312
|
return;
|
|
300
313
|
}
|
|
314
|
+
turnEscalationDetection = "block";
|
|
301
315
|
turnAsked = true;
|
|
302
316
|
log({
|
|
303
317
|
event: "question",
|
|
@@ -316,7 +330,7 @@ export const openSession = (
|
|
|
316
330
|
});
|
|
317
331
|
};
|
|
318
332
|
|
|
319
|
-
const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
|
|
333
|
+
const endTurn = (done: Omit<HarnessEvent & { kind: "done" }, "escalation">): void => {
|
|
320
334
|
if (activeTurn === null) return;
|
|
321
335
|
disarmStall();
|
|
322
336
|
// Asking is a successful turn: the session semantic is "blocked on
|
|
@@ -324,41 +338,40 @@ export const openSession = (
|
|
|
324
338
|
// in sessions) and the caller answers with the next send().
|
|
325
339
|
emitQuestionIfAsked();
|
|
326
340
|
if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
|
|
341
|
+
// RFC-01 every turn end carries the escalation record
|
|
342
|
+
let fullDone: HarnessEvent & { kind: "done" } = {
|
|
343
|
+
...done,
|
|
344
|
+
escalation: { mode: questionMode, detection: turnEscalationDetection },
|
|
345
|
+
} as HarnessEvent & { kind: "done" };
|
|
327
346
|
// Every failure was already emitted as an event through pushFailure;
|
|
328
347
|
// the turn's done carries the reduced summary, as streamTurn's does.
|
|
329
348
|
const reduced = reduceFailures(turnFailures);
|
|
330
349
|
if (reduced !== undefined) {
|
|
331
|
-
if (
|
|
332
|
-
else
|
|
350
|
+
if (fullDone.cause === "clean") fullDone = { ...fullDone, cause: "failed", failure: reduced };
|
|
351
|
+
else fullDone = { ...fullDone, failure: reduced };
|
|
333
352
|
}
|
|
334
|
-
activeTurn.push(
|
|
353
|
+
activeTurn.push(fullDone);
|
|
335
354
|
activeTurn.close();
|
|
336
355
|
log({
|
|
337
356
|
event: "turn_end",
|
|
338
357
|
sessionId: opts.sessionId,
|
|
339
358
|
turnId: activeTurnId,
|
|
340
|
-
cause:
|
|
359
|
+
cause: fullDone.cause,
|
|
341
360
|
});
|
|
342
361
|
activeTurn = null;
|
|
343
362
|
resultError = false;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (writeUser(next.text)) {
|
|
349
|
-
startTurn(next.id);
|
|
350
|
-
return;
|
|
363
|
+
if (turnSettled !== null) {
|
|
364
|
+
const release = turnSettled;
|
|
365
|
+
turnSettled = null;
|
|
366
|
+
release();
|
|
351
367
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
reason: "write-failed",
|
|
360
|
-
lengths: [next.text.length],
|
|
361
|
-
});
|
|
368
|
+
if (dead || closing) return;
|
|
369
|
+
const nextId = pendingIds.shift();
|
|
370
|
+
if (nextId === undefined) return;
|
|
371
|
+
pendingLengths.shift();
|
|
372
|
+
// The text was already written to the harness when the send arrived;
|
|
373
|
+
// the id was held to correlate the next turn.
|
|
374
|
+
startTurn(nextId);
|
|
362
375
|
};
|
|
363
376
|
|
|
364
377
|
const routeEvent = (event: HarnessEvent): Promise<void> => {
|
|
@@ -366,7 +379,7 @@ export const openSession = (
|
|
|
366
379
|
state.limitSeen = true;
|
|
367
380
|
turnLimitSeen = true;
|
|
368
381
|
}
|
|
369
|
-
if (
|
|
382
|
+
if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
|
|
370
383
|
lastAssistantText = event.text;
|
|
371
384
|
}
|
|
372
385
|
if (activeTurn !== null) {
|
|
@@ -581,22 +594,22 @@ export const openSession = (
|
|
|
581
594
|
if (pumpError !== null) {
|
|
582
595
|
void routeEvent({ kind: "error", message: `session pump failed: ${String(pumpError)}` });
|
|
583
596
|
}
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const droppedIds = pendingSends.map((s) => s.id);
|
|
597
|
+
if (pendingIds.length > 0) {
|
|
598
|
+
const droppedIds = [...pendingIds];
|
|
599
|
+
const droppedLengths = [...pendingLengths];
|
|
588
600
|
void routeEvent({
|
|
589
601
|
kind: "error",
|
|
590
|
-
message: `${
|
|
602
|
+
message: `${pendingIds.length} pending send(s) died with the session: ${droppedIds.join(", ")}`,
|
|
591
603
|
});
|
|
592
604
|
log({
|
|
593
605
|
event: "sends_dropped",
|
|
594
606
|
sessionId: opts.sessionId,
|
|
595
|
-
count:
|
|
607
|
+
count: pendingIds.length,
|
|
596
608
|
ids: droppedIds,
|
|
597
|
-
lengths:
|
|
609
|
+
lengths: droppedLengths,
|
|
598
610
|
});
|
|
599
|
-
|
|
611
|
+
pendingIds.length = 0;
|
|
612
|
+
pendingLengths.length = 0;
|
|
600
613
|
}
|
|
601
614
|
endTurn({ kind: "done", exitCode, cause });
|
|
602
615
|
if (preTurnEvents.some((e) => e.kind !== "token" && e.kind !== "progress")) {
|
|
@@ -621,6 +634,12 @@ export const openSession = (
|
|
|
621
634
|
void proc.exited.then((code) => {
|
|
622
635
|
dead = true;
|
|
623
636
|
exitCode = code;
|
|
637
|
+
// A close() waiting on an open turn must not outlive the child.
|
|
638
|
+
if (turnSettled !== null) {
|
|
639
|
+
const release = turnSettled;
|
|
640
|
+
turnSettled = null;
|
|
641
|
+
release();
|
|
642
|
+
}
|
|
624
643
|
// The process is gone: a later fire would flip a finished turn to stall.
|
|
625
644
|
disarmStall();
|
|
626
645
|
// Pipes held open past exit (a grandchild) must not hang the session.
|
|
@@ -636,9 +655,22 @@ export const openSession = (
|
|
|
636
655
|
});
|
|
637
656
|
});
|
|
638
657
|
|
|
639
|
-
|
|
658
|
+
// Two intents share this path and must not be conflated. A CLOSE is the
|
|
659
|
+
// consumer asking politely: an open turn gets to finish first, because
|
|
660
|
+
// the consumer is still there to receive it. ABANDONMENT is the consumer
|
|
661
|
+
// walking away from the turns iterable: nobody is left to receive a turn,
|
|
662
|
+
// so stdin ends at once and the child is reaped, not drained.
|
|
663
|
+
const close = async (drain = true): Promise<void> => {
|
|
640
664
|
if (closing) return shutdown;
|
|
641
665
|
closing = true;
|
|
666
|
+
// Let an open turn reach its end record before stdin goes away. The
|
|
667
|
+
// wait is bounded twice over: the stall watchdog ends a silent turn,
|
|
668
|
+
// and the grace below still escalates a child that never exits.
|
|
669
|
+
if (drain && activeTurn !== null && !dead) {
|
|
670
|
+
await new Promise<void>((resolve) => {
|
|
671
|
+
turnSettled = resolve;
|
|
672
|
+
});
|
|
673
|
+
}
|
|
642
674
|
try {
|
|
643
675
|
stdin.end();
|
|
644
676
|
} catch {
|
|
@@ -658,22 +690,12 @@ export const openSession = (
|
|
|
658
690
|
try {
|
|
659
691
|
for await (const turn of turnsChannel) yield turn;
|
|
660
692
|
} finally {
|
|
661
|
-
if (!closing && !dead) void close();
|
|
693
|
+
if (!closing && !dead) void close(false);
|
|
662
694
|
}
|
|
663
695
|
})(),
|
|
664
696
|
send(input: SessionInput): SessionSendResult {
|
|
665
697
|
if (dead || closing) throw new SessionClosedError();
|
|
666
|
-
|
|
667
|
-
pendingSends.push(input);
|
|
668
|
-
log({
|
|
669
|
-
event: "send",
|
|
670
|
-
sessionId: opts.sessionId,
|
|
671
|
-
turnId: activeTurnId,
|
|
672
|
-
inputId: input.id,
|
|
673
|
-
disposition: "queued",
|
|
674
|
-
});
|
|
675
|
-
return { disposition: "queued" };
|
|
676
|
-
}
|
|
698
|
+
const wasBusy = activeTurn !== null;
|
|
677
699
|
if (!writeUser(input.text)) {
|
|
678
700
|
log({
|
|
679
701
|
event: "send",
|
|
@@ -685,7 +707,12 @@ export const openSession = (
|
|
|
685
707
|
});
|
|
686
708
|
return { disposition: "rejected", reason: "write-failed" };
|
|
687
709
|
}
|
|
688
|
-
|
|
710
|
+
if (wasBusy) {
|
|
711
|
+
pendingIds.push(input.id);
|
|
712
|
+
pendingLengths.push(input.text.length);
|
|
713
|
+
} else {
|
|
714
|
+
startTurn(input.id);
|
|
715
|
+
}
|
|
689
716
|
log({
|
|
690
717
|
event: "send",
|
|
691
718
|
sessionId: opts.sessionId,
|