@dungle-scrubs/harness-cli-normalizer 0.5.3 → 0.5.4
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 +111 -0
- 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/help.d.ts +2 -2
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +16 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +21 -0
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/refuse.d.ts +7 -1
- package/dist/cli/refuse.d.ts.map +1 -1
- package/dist/cli/refuse.js +16 -5
- package/dist/cli/refuse.js.map +1 -1
- package/dist/cli/session-json.d.ts +25 -0
- package/dist/cli/session-json.d.ts.map +1 -0
- package/dist/cli/session-json.js +195 -0
- package/dist/cli/session-json.js.map +1 -0
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +93 -11
- package/dist/cli/session.js.map +1 -1
- package/dist/execution/open-session.d.ts +24 -3
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +106 -19
- package/dist/execution/open-session.js.map +1 -1
- package/dist/interpretation/argv.d.ts +3 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +5 -0
- package/dist/interpretation/argv.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +8 -0
- package/src/cli/help.ts +16 -0
- package/src/cli/inspect.ts +26 -0
- package/src/cli/refuse.ts +31 -12
- package/src/cli/session-json.ts +230 -0
- package/src/cli/session.ts +103 -12
- package/src/execution/open-session.ts +132 -23
- package/src/interpretation/argv.ts +8 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The machine session surface: `hcn session <h> --json`. Two pumps over one
|
|
3
|
+
* persistent session - stdin NDJSON commands in, stdout NDJSON events out -
|
|
4
|
+
* with the control events (`session`, `turn`, `disposition`, `closed`) that
|
|
5
|
+
* a program needs to drive a session it does not own the timing of. RFC-01.
|
|
6
|
+
*
|
|
7
|
+
* This owns the wire framing only. The turn lifecycle, the queue, and the id
|
|
8
|
+
* correlation live in `openSession`; this reads the id off the yielded turn
|
|
9
|
+
* rather than shadowing the runner's delivery order.
|
|
10
|
+
*/
|
|
11
|
+
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import type { HarnessEvent } from "../execution/events.js";
|
|
13
|
+
import type { FailureSummary } from "../execution/failure.js";
|
|
14
|
+
import {
|
|
15
|
+
SessionClosedError,
|
|
16
|
+
type SessionHandle,
|
|
17
|
+
type SessionSendResult,
|
|
18
|
+
} from "../execution/open-session.js";
|
|
19
|
+
|
|
20
|
+
/** What the CLI reads back after a close, captured from the runner's
|
|
21
|
+
* `session_close` boundary log. */
|
|
22
|
+
export interface CloseInfo {
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
cause: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JsonSessionArgs {
|
|
28
|
+
readonly handle: SessionHandle;
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
readonly harness: string;
|
|
31
|
+
readonly hcnVersion: string;
|
|
32
|
+
readonly escalateQuestions: boolean;
|
|
33
|
+
/** Read after close - the runner's final exitCode and cause. */
|
|
34
|
+
readonly getCloseInfo: () => CloseInfo;
|
|
35
|
+
/** Read after close - ids the runner accepted as queued and never
|
|
36
|
+
* delivered. Each owes the consumer a rejection (RFC S003). */
|
|
37
|
+
readonly getDroppedIds?: () => readonly string[];
|
|
38
|
+
/** Injected for tests; defaults to process.stdin / process.stdout. */
|
|
39
|
+
readonly input?: NodeJS.ReadableStream;
|
|
40
|
+
readonly write?: (line: string) => boolean;
|
|
41
|
+
readonly onDrain?: (fn: () => void) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ParsedCommand =
|
|
45
|
+
| { readonly op: "send" | "answer"; readonly id: string; readonly text: string }
|
|
46
|
+
| { readonly op: "close" }
|
|
47
|
+
| { readonly malformed: string };
|
|
48
|
+
|
|
49
|
+
const parseCommand = (line: string): ParsedCommand => {
|
|
50
|
+
let obj: unknown;
|
|
51
|
+
try {
|
|
52
|
+
obj = JSON.parse(line);
|
|
53
|
+
} catch {
|
|
54
|
+
return { malformed: "not JSON" };
|
|
55
|
+
}
|
|
56
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
57
|
+
return { malformed: "not an object" };
|
|
58
|
+
}
|
|
59
|
+
const rec = obj as Record<string, unknown>;
|
|
60
|
+
if (rec.op === "close") return { op: "close" };
|
|
61
|
+
if (rec.op === "send" || rec.op === "answer") {
|
|
62
|
+
if (typeof rec.id !== "string" || rec.id === "") return { malformed: "missing or empty id" };
|
|
63
|
+
if (typeof rec.text !== "string") return { malformed: "text must be a string" };
|
|
64
|
+
return { op: rec.op, id: rec.id, text: rec.text };
|
|
65
|
+
}
|
|
66
|
+
return { malformed: `unknown op ${JSON.stringify(rec.op)}` };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The answer wrapper hcn composes so a consumer never re-derives it
|
|
70
|
+
* (RFC-01: the preamble is normalizer knowledge). */
|
|
71
|
+
const composeAnswer = (question: string, answer: string): string =>
|
|
72
|
+
`The user answered the question: "${question}" with: ${answer}. Continue accordingly.`;
|
|
73
|
+
|
|
74
|
+
export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
|
|
75
|
+
const rawWrite = a.write ?? ((line: string) => process.stdout.write(line));
|
|
76
|
+
const onDrain = a.onDrain ?? ((fn: () => void) => process.stdout.once("drain", fn));
|
|
77
|
+
|
|
78
|
+
// The consumer's read end can close mid-session (it died, or it stopped
|
|
79
|
+
// reading). The process-wide EPIPE guard in index.ts exits 0 immediately,
|
|
80
|
+
// which would strand the harness child with nobody to end it. For a
|
|
81
|
+
// machine session the right answer is to close the session - grace, then
|
|
82
|
+
// signal - and exit 1. Replacing the listener is deliberate: the global
|
|
83
|
+
// one runs first otherwise and the process is gone before we act.
|
|
84
|
+
let consumerGone = false;
|
|
85
|
+
if (a.write === undefined) {
|
|
86
|
+
process.stdout.removeAllListeners("error");
|
|
87
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
88
|
+
if (err.code !== "EPIPE") return;
|
|
89
|
+
consumerGone = true;
|
|
90
|
+
void a.handle.close();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One serialized, drain-aware writer for both pumps: stdout never
|
|
95
|
+
// interleaves two events and never buffers past the OS pipe.
|
|
96
|
+
let chain: Promise<void> = Promise.resolve();
|
|
97
|
+
const emit = (event: unknown): Promise<void> => {
|
|
98
|
+
chain = chain.then(
|
|
99
|
+
() =>
|
|
100
|
+
new Promise<void>((resolve) => {
|
|
101
|
+
// A broken stdout never drains. Writing into it would park this
|
|
102
|
+
// chain forever and the session would hang instead of closing.
|
|
103
|
+
if (consumerGone) {
|
|
104
|
+
resolve();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
if (rawWrite(`${JSON.stringify(event)}\n`)) resolve();
|
|
109
|
+
else onDrain(resolve);
|
|
110
|
+
} catch {
|
|
111
|
+
consumerGone = true;
|
|
112
|
+
resolve();
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
return chain;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await emit({
|
|
120
|
+
kind: "session",
|
|
121
|
+
sessionId: a.sessionId,
|
|
122
|
+
harness: a.harness,
|
|
123
|
+
hcn: a.hcnVersion,
|
|
124
|
+
escalateQuestions: a.escalateQuestions,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Shared between the pumps: the last question asked, whether the last turn
|
|
128
|
+
// ended awaiting an answer, and the last failure seen (for closed.failure).
|
|
129
|
+
let lastQuestion = "";
|
|
130
|
+
let awaitingAnswer = false;
|
|
131
|
+
let lastFailure: FailureSummary | undefined;
|
|
132
|
+
|
|
133
|
+
const stdoutPump = (async () => {
|
|
134
|
+
for await (const turn of a.handle.turns) {
|
|
135
|
+
awaitingAnswer = false;
|
|
136
|
+
await emit({
|
|
137
|
+
kind: "turn",
|
|
138
|
+
turnId: turn.turnId,
|
|
139
|
+
...(turn.inputId !== undefined ? { id: turn.inputId } : {}),
|
|
140
|
+
});
|
|
141
|
+
for await (const ev of turn as AsyncIterable<HarnessEvent>) {
|
|
142
|
+
// Update the answer state BEFORE the event reaches the consumer. A
|
|
143
|
+
// consumer answers the moment it reads `done`, so setting this after
|
|
144
|
+
// the emit leaves a window where a valid answer is refused.
|
|
145
|
+
if (ev.kind === "question") lastQuestion = ev.question;
|
|
146
|
+
if (ev.kind === "done") {
|
|
147
|
+
awaitingAnswer = ev.cause === "awaiting-input";
|
|
148
|
+
if (ev.failure !== undefined) lastFailure = ev.failure;
|
|
149
|
+
}
|
|
150
|
+
if (ev.kind === "failure") lastFailure = ev;
|
|
151
|
+
await emit(ev);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
const rl = createInterface({ input: a.input ?? process.stdin });
|
|
157
|
+
const stdinPump = (async () => {
|
|
158
|
+
for await (const line of rl) {
|
|
159
|
+
if (line.trim() === "") continue;
|
|
160
|
+
const cmd = parseCommand(line);
|
|
161
|
+
if ("malformed" in cmd) {
|
|
162
|
+
await emit({ kind: "error", message: `malformed command: ${cmd.malformed}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (cmd.op === "close") return;
|
|
166
|
+
let text = cmd.text;
|
|
167
|
+
if (cmd.op === "answer") {
|
|
168
|
+
if (!awaitingAnswer) {
|
|
169
|
+
await emit({
|
|
170
|
+
kind: "disposition",
|
|
171
|
+
id: cmd.id,
|
|
172
|
+
disposition: "rejected",
|
|
173
|
+
reason: "no-open-question",
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
text = composeAnswer(lastQuestion, cmd.text);
|
|
178
|
+
}
|
|
179
|
+
let sent: SessionSendResult;
|
|
180
|
+
try {
|
|
181
|
+
sent = a.handle.send({ id: cmd.id, text });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// A session the caller already closed, or one already dead: a
|
|
184
|
+
// different remedy from a broken pipe, so a different reason.
|
|
185
|
+
if (err instanceof SessionClosedError) {
|
|
186
|
+
await emit({
|
|
187
|
+
kind: "disposition",
|
|
188
|
+
id: cmd.id,
|
|
189
|
+
disposition: "rejected",
|
|
190
|
+
reason: "closed",
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
await emit({
|
|
197
|
+
kind: "disposition",
|
|
198
|
+
id: cmd.id,
|
|
199
|
+
disposition: sent.disposition,
|
|
200
|
+
...(sent.reason !== undefined ? { reason: sent.reason } : {}),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
|
|
205
|
+
// Whichever ends first drives the close: a close/EOF from stdin, or the
|
|
206
|
+
// session dying (the turns iterable ends). close() is idempotent; closing
|
|
207
|
+
// the readline unblocks the stdin pump if the session died first.
|
|
208
|
+
await Promise.race([stdinPump, stdoutPump]);
|
|
209
|
+
await a.handle.close();
|
|
210
|
+
rl.close();
|
|
211
|
+
await Promise.allSettled([stdinPump, stdoutPump]);
|
|
212
|
+
|
|
213
|
+
// Every input the runner accepted as queued and then lost gets its own
|
|
214
|
+
// rejection, before the terminal line (RFC S003).
|
|
215
|
+
for (const id of a.getDroppedIds?.() ?? []) {
|
|
216
|
+
await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const info = a.getCloseInfo();
|
|
220
|
+
await emit({
|
|
221
|
+
kind: "closed",
|
|
222
|
+
exitCode: info.exitCode,
|
|
223
|
+
cause: info.cause,
|
|
224
|
+
...(info.cause !== "clean" && lastFailure !== undefined ? { failure: lastFailure } : {}),
|
|
225
|
+
});
|
|
226
|
+
// A consumer that stopped reading gets exit 1 even on a clean harness exit:
|
|
227
|
+
// the session did not end the way the consumer asked for.
|
|
228
|
+
if (consumerGone) return 1;
|
|
229
|
+
return info.cause === "clean" ? 0 : 1;
|
|
230
|
+
};
|
package/src/cli/session.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { createRenderState, renderEvent } from "./render.js";
|
|
|
9
9
|
import { resolveHarness } from "./resolve-harness.js";
|
|
10
10
|
|
|
11
11
|
export const session = async (harnessName: string, rawArgs: string[]): Promise<void> => {
|
|
12
|
+
// Decided before any refusal can fire: a refused --json session still owes
|
|
13
|
+
// the stream a failure and a terminal `closed` (RFC-01 rule 3).
|
|
14
|
+
const jsonMode = rawArgs.includes("--json");
|
|
12
15
|
// issue #44: the gate is the descriptor's sessionMode (claude stream-json,
|
|
13
16
|
// pi --mode rpc), not a hardcoded name list - a harness that grows a
|
|
14
17
|
// session mode is available the moment its descriptor declares one.
|
|
@@ -23,9 +26,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
23
26
|
supported,
|
|
24
27
|
detail: `session mode is available on ${supported.join(", ")}; ${harnessName} declares no persistent headless session`,
|
|
25
28
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
process.exitCode = 2;
|
|
29
|
+
const { refusalOf, refuse } = await import("./refuse.js");
|
|
30
|
+
refuse(refusalOf(err), jsonMode, "closed");
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -42,6 +44,14 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
42
44
|
} catch (err) {
|
|
43
45
|
const message = err instanceof Error ? err.message : String(err);
|
|
44
46
|
process.stderr.write(`unknown flag: ${message}\n`);
|
|
47
|
+
if (jsonMode) {
|
|
48
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
49
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
50
|
+
writeFailurePair(
|
|
51
|
+
failureFromRejected({ issue: "invalid-option-value", detail: `unknown flag: ${message}` }),
|
|
52
|
+
"closed",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
45
55
|
process.exitCode = 2;
|
|
46
56
|
return;
|
|
47
57
|
}
|
|
@@ -53,6 +63,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
53
63
|
randomUUID();
|
|
54
64
|
const model = values.model as string | undefined;
|
|
55
65
|
const cwd = values.cwd as string | undefined;
|
|
66
|
+
const provider = values.provider as string | undefined;
|
|
56
67
|
|
|
57
68
|
// issue #44: same precedence as hcn run - arg > project > user >
|
|
58
69
|
// default-true. A behavior instruction, so it rides every send's
|
|
@@ -87,6 +98,17 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
87
98
|
: "default";
|
|
88
99
|
} catch (configErr) {
|
|
89
100
|
process.stderr.write(`config error: ${(configErr as Error).message}\n`);
|
|
101
|
+
if (jsonMode) {
|
|
102
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
103
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
104
|
+
writeFailurePair(
|
|
105
|
+
failureFromRejected({
|
|
106
|
+
issue: "invalid-option-value",
|
|
107
|
+
detail: `config error: ${(configErr as Error).message}`,
|
|
108
|
+
}),
|
|
109
|
+
"closed",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
90
112
|
process.exitCode = 2;
|
|
91
113
|
return;
|
|
92
114
|
}
|
|
@@ -95,25 +117,92 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
95
117
|
// Validate sessionId shape? let openSession handle via assertUsableSessionId
|
|
96
118
|
delete (process.env as Record<string, string | undefined>).HERDR_ENV;
|
|
97
119
|
|
|
98
|
-
const
|
|
120
|
+
const wantJson = values.json === true;
|
|
121
|
+
// Opt-in per-turn inactivity budget. 0 disables; no default. A session turn
|
|
122
|
+
// can hang with the process alive, which no exit code reports.
|
|
123
|
+
const rawStall = values.stall as string | undefined;
|
|
124
|
+
let stallMs: number | undefined;
|
|
125
|
+
if (rawStall !== undefined) {
|
|
126
|
+
const seconds = Number(rawStall);
|
|
127
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
128
|
+
process.stderr.write(`invalid --stall ${JSON.stringify(rawStall)}; expected seconds >= 0\n`);
|
|
129
|
+
if (jsonMode) {
|
|
130
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
131
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
132
|
+
writeFailurePair(
|
|
133
|
+
failureFromRejected({
|
|
134
|
+
issue: "invalid-option-value",
|
|
135
|
+
detail: `invalid --stall ${JSON.stringify(rawStall)}`,
|
|
136
|
+
}),
|
|
137
|
+
"closed",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
process.exitCode = 2;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (seconds > 0) stallMs = seconds * 1000;
|
|
144
|
+
}
|
|
145
|
+
const baseDeps = stallMs === undefined ? nodeRunnerDeps() : nodeRunnerDeps({ stallMs });
|
|
146
|
+
// Capture the runner's final exitCode/cause for the --json `closed` event.
|
|
147
|
+
const closeInfo = { exitCode: null as number | null, cause: "clean" };
|
|
148
|
+
const droppedIds: string[] = [];
|
|
149
|
+
const deps = wantJson
|
|
150
|
+
? {
|
|
151
|
+
...baseDeps,
|
|
152
|
+
log: (e: Record<string, unknown>) => {
|
|
153
|
+
if (e.event === "session_close") {
|
|
154
|
+
closeInfo.exitCode = (e.exitCode as number | null) ?? null;
|
|
155
|
+
closeInfo.cause = (e.cause as string) ?? "clean";
|
|
156
|
+
}
|
|
157
|
+
if (e.event === "sends_dropped" && Array.isArray(e.ids)) {
|
|
158
|
+
for (const id of e.ids as unknown[]) if (typeof id === "string") droppedIds.push(id);
|
|
159
|
+
}
|
|
160
|
+
baseDeps.log?.(e);
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: baseDeps;
|
|
99
164
|
|
|
100
165
|
let handle: ReturnType<typeof openSession>;
|
|
101
166
|
try {
|
|
102
|
-
handle = openSession(h, { sessionId, model, cwd, escalateQuestions }, deps);
|
|
167
|
+
handle = openSession(h, { sessionId, model, cwd, escalateQuestions, provider }, deps);
|
|
103
168
|
} catch (err) {
|
|
104
169
|
if (err instanceof ArgvRefusalError) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
process.exitCode = 2;
|
|
170
|
+
const { refusalOf, refuse } = await import("./refuse.js");
|
|
171
|
+
refuse(refusalOf(err), jsonMode, "closed");
|
|
108
172
|
return;
|
|
109
173
|
}
|
|
174
|
+
// S002: the harness binary is missing or would not start. A transport
|
|
175
|
+
// failure, not a refusal - exit 1, and the stream is still owed its pair.
|
|
110
176
|
process.stderr.write(
|
|
111
177
|
`could not open session: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
112
178
|
);
|
|
179
|
+
if (jsonMode) {
|
|
180
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
181
|
+
const { failureFromTransport } = await import("../execution/failure.js");
|
|
182
|
+
writeFailurePair(
|
|
183
|
+
failureFromTransport(err instanceof Error ? err.message : String(err)),
|
|
184
|
+
"closed",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
113
187
|
process.exitCode = 1;
|
|
114
188
|
return;
|
|
115
189
|
}
|
|
116
190
|
|
|
191
|
+
if (wantJson) {
|
|
192
|
+
const { runJsonSession } = await import("./session-json.js");
|
|
193
|
+
const { getVersion } = await import("./version.js");
|
|
194
|
+
process.exitCode = await runJsonSession({
|
|
195
|
+
handle,
|
|
196
|
+
sessionId,
|
|
197
|
+
harness: h.name,
|
|
198
|
+
hcnVersion: getVersion(),
|
|
199
|
+
escalateQuestions,
|
|
200
|
+
getCloseInfo: () => closeInfo,
|
|
201
|
+
getDroppedIds: () => droppedIds,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
117
206
|
process.stdout.write(
|
|
118
207
|
`interactive ${h.name} session ${sessionId}\n(empty line or "exit" to quit)\n`,
|
|
119
208
|
);
|
|
@@ -136,6 +225,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
136
225
|
|
|
137
226
|
// Handle SIGINT to close session cleanly
|
|
138
227
|
let closing = false;
|
|
228
|
+
let sendCount = 0;
|
|
139
229
|
const doClose = async () => {
|
|
140
230
|
if (closing) return;
|
|
141
231
|
closing = true;
|
|
@@ -168,7 +258,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
168
258
|
const trimmed = line.trim();
|
|
169
259
|
if (trimmed === "" || trimmed === "exit") break;
|
|
170
260
|
|
|
171
|
-
const result = handle.send(line);
|
|
261
|
+
const result = handle.send({ id: `you-${++sendCount}`, text: line });
|
|
172
262
|
if (result.disposition === "queued") {
|
|
173
263
|
process.stderr.write(`disposition: queued (turn in progress)\n`);
|
|
174
264
|
}
|
|
@@ -209,9 +299,10 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
209
299
|
answer = a;
|
|
210
300
|
}
|
|
211
301
|
}
|
|
212
|
-
handle.send(
|
|
213
|
-
|
|
214
|
-
|
|
302
|
+
handle.send({
|
|
303
|
+
id: `you-${++sendCount}`,
|
|
304
|
+
text: `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
|
|
305
|
+
});
|
|
215
306
|
// Drain the answer turn BEFORE prompting again - the pump's
|
|
216
307
|
// backpressure stalls the harness until the turn iterable is
|
|
217
308
|
// consumed (verified live: menu answered, you-prompt rendered, no
|