@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.
Files changed (39) hide show
  1. package/README.md +111 -0
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +8 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/help.d.ts +2 -2
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +16 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/inspect.d.ts.map +1 -1
  10. package/dist/cli/inspect.js +21 -0
  11. package/dist/cli/inspect.js.map +1 -1
  12. package/dist/cli/refuse.d.ts +7 -1
  13. package/dist/cli/refuse.d.ts.map +1 -1
  14. package/dist/cli/refuse.js +16 -5
  15. package/dist/cli/refuse.js.map +1 -1
  16. package/dist/cli/session-json.d.ts +25 -0
  17. package/dist/cli/session-json.d.ts.map +1 -0
  18. package/dist/cli/session-json.js +195 -0
  19. package/dist/cli/session-json.js.map +1 -0
  20. package/dist/cli/session.d.ts.map +1 -1
  21. package/dist/cli/session.js +93 -11
  22. package/dist/cli/session.js.map +1 -1
  23. package/dist/execution/open-session.d.ts +24 -3
  24. package/dist/execution/open-session.d.ts.map +1 -1
  25. package/dist/execution/open-session.js +106 -19
  26. package/dist/execution/open-session.js.map +1 -1
  27. package/dist/interpretation/argv.d.ts +3 -0
  28. package/dist/interpretation/argv.d.ts.map +1 -1
  29. package/dist/interpretation/argv.js +5 -0
  30. package/dist/interpretation/argv.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/cli/args.ts +8 -0
  33. package/src/cli/help.ts +16 -0
  34. package/src/cli/inspect.ts +26 -0
  35. package/src/cli/refuse.ts +31 -12
  36. package/src/cli/session-json.ts +230 -0
  37. package/src/cli/session.ts +103 -12
  38. package/src/execution/open-session.ts +132 -23
  39. 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
+ };
@@ -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
- process.stderr.write(`${err.message}\n`);
27
- process.stderr.write(`supported: ${supported.join(", ")}\n`);
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 deps = nodeRunnerDeps();
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
- process.stderr.write(`${err.message}\n`);
106
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
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
- `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
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