@dungle-scrubs/harness-cli-normalizer 0.6.2 → 0.6.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 (114) hide show
  1. package/README.md +50 -1
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +4 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/check.d.ts +5 -1
  6. package/dist/cli/check.d.ts.map +1 -1
  7. package/dist/cli/check.js +47 -8
  8. package/dist/cli/check.js.map +1 -1
  9. package/dist/cli/help.d.ts +1 -1
  10. package/dist/cli/help.d.ts.map +1 -1
  11. package/dist/cli/help.js +23 -1
  12. package/dist/cli/help.js.map +1 -1
  13. package/dist/cli/inspect-context.d.ts +5 -0
  14. package/dist/cli/inspect-context.d.ts.map +1 -0
  15. package/dist/cli/inspect-context.js +86 -0
  16. package/dist/cli/inspect-context.js.map +1 -0
  17. package/dist/cli/inspect-session.d.ts +4 -0
  18. package/dist/cli/inspect-session.d.ts.map +1 -0
  19. package/dist/cli/inspect-session.js +71 -0
  20. package/dist/cli/inspect-session.js.map +1 -0
  21. package/dist/cli/inspect.d.ts.map +1 -1
  22. package/dist/cli/inspect.js +53 -24
  23. package/dist/cli/inspect.js.map +1 -1
  24. package/dist/cli/plan-turn.js +2 -2
  25. package/dist/cli/plan-turn.js.map +1 -1
  26. package/dist/cli/runtime-compatibility.d.ts +17 -0
  27. package/dist/cli/runtime-compatibility.d.ts.map +1 -0
  28. package/dist/cli/runtime-compatibility.js +46 -0
  29. package/dist/cli/runtime-compatibility.js.map +1 -0
  30. package/dist/execution/context-inspection.d.ts +20 -0
  31. package/dist/execution/context-inspection.d.ts.map +1 -0
  32. package/dist/execution/context-inspection.js +174 -0
  33. package/dist/execution/context-inspection.js.map +1 -0
  34. package/dist/execution/deps.d.ts +3 -0
  35. package/dist/execution/deps.d.ts.map +1 -1
  36. package/dist/execution/environment.d.ts +3 -0
  37. package/dist/execution/environment.d.ts.map +1 -0
  38. package/dist/execution/environment.js +16 -0
  39. package/dist/execution/environment.js.map +1 -0
  40. package/dist/execution/lines.d.ts +3 -1
  41. package/dist/execution/lines.d.ts.map +1 -1
  42. package/dist/execution/lines.js +26 -23
  43. package/dist/execution/lines.js.map +1 -1
  44. package/dist/execution/node-deps.d.ts.map +1 -1
  45. package/dist/execution/node-deps.js +14 -19
  46. package/dist/execution/node-deps.js.map +1 -1
  47. package/dist/execution/stream-turn.d.ts +1 -1
  48. package/dist/execution/stream-turn.d.ts.map +1 -1
  49. package/dist/execution/stream-turn.js +29 -5
  50. package/dist/execution/stream-turn.js.map +1 -1
  51. package/dist/execution/supervisor.d.ts +5 -0
  52. package/dist/execution/supervisor.d.ts.map +1 -1
  53. package/dist/execution/supervisor.js +26 -19
  54. package/dist/execution/supervisor.js.map +1 -1
  55. package/dist/interpretation/argv.d.ts +2 -0
  56. package/dist/interpretation/argv.d.ts.map +1 -1
  57. package/dist/interpretation/argv.js +26 -4
  58. package/dist/interpretation/argv.js.map +1 -1
  59. package/dist/interpretation/context-inspection.d.ts +41 -0
  60. package/dist/interpretation/context-inspection.d.ts.map +1 -0
  61. package/dist/interpretation/context-inspection.js +114 -0
  62. package/dist/interpretation/context-inspection.js.map +1 -0
  63. package/dist/interpretation/refusal.d.ts +1 -1
  64. package/dist/interpretation/refusal.d.ts.map +1 -1
  65. package/dist/interpretation/refusal.js.map +1 -1
  66. package/dist/interpretation/resolve-options.d.ts +1 -0
  67. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  68. package/dist/interpretation/resolve-options.js +21 -28
  69. package/dist/interpretation/resolve-options.js.map +1 -1
  70. package/dist/interpretation/turn-options.d.ts +1 -1
  71. package/dist/interpretation/turn-options.d.ts.map +1 -1
  72. package/dist/interpretation/turn-options.js +9 -1
  73. package/dist/interpretation/turn-options.js.map +1 -1
  74. package/dist/knowledge/claude-code.d.ts +1 -1
  75. package/dist/knowledge/claude-code.d.ts.map +1 -1
  76. package/dist/knowledge/claude-code.js +22 -7
  77. package/dist/knowledge/claude-code.js.map +1 -1
  78. package/dist/knowledge/codex.d.ts.map +1 -1
  79. package/dist/knowledge/codex.js +1 -0
  80. package/dist/knowledge/codex.js.map +1 -1
  81. package/dist/knowledge/descriptor.d.ts +12 -0
  82. package/dist/knowledge/descriptor.d.ts.map +1 -1
  83. package/dist/knowledge/muse.d.ts.map +1 -1
  84. package/dist/knowledge/muse.js +1 -0
  85. package/dist/knowledge/muse.js.map +1 -1
  86. package/dist/knowledge/pi.d.ts.map +1 -1
  87. package/dist/knowledge/pi.js +1 -0
  88. package/dist/knowledge/pi.js.map +1 -1
  89. package/package.json +2 -2
  90. package/src/cli/args.ts +4 -0
  91. package/src/cli/check.ts +56 -11
  92. package/src/cli/help.ts +23 -1
  93. package/src/cli/inspect-context.ts +96 -0
  94. package/src/cli/inspect-session.ts +85 -0
  95. package/src/cli/inspect.ts +65 -28
  96. package/src/cli/plan-turn.ts +2 -2
  97. package/src/cli/runtime-compatibility.ts +55 -0
  98. package/src/execution/context-inspection.ts +177 -0
  99. package/src/execution/deps.ts +3 -0
  100. package/src/execution/environment.ts +15 -0
  101. package/src/execution/lines.ts +22 -23
  102. package/src/execution/node-deps.ts +14 -17
  103. package/src/execution/stream-turn.ts +31 -4
  104. package/src/execution/supervisor.ts +27 -17
  105. package/src/interpretation/argv.ts +24 -4
  106. package/src/interpretation/context-inspection.ts +169 -0
  107. package/src/interpretation/refusal.ts +1 -0
  108. package/src/interpretation/resolve-options.ts +29 -30
  109. package/src/interpretation/turn-options.ts +8 -0
  110. package/src/knowledge/claude-code.ts +23 -7
  111. package/src/knowledge/codex.ts +1 -0
  112. package/src/knowledge/descriptor.ts +9 -0
  113. package/src/knowledge/muse.ts +1 -0
  114. package/src/knowledge/pi.ts +1 -0
@@ -2,18 +2,33 @@ import { capabilitiesOf } from "../interpretation/capabilities.js";
2
2
  import { canonicalTable, mergeToolMaps } from "../interpretation/tool-vocabulary.js";
3
3
  import { HARNESS_MODES, type HarnessMode } from "../knowledge/descriptor.js";
4
4
  import { defaultDescriptors } from "../knowledge/overrides.js";
5
- import { splitPassthrough } from "./args.js";
5
+ import { parseCommonFlags } from "./args.js";
6
6
  import { ConfigError, loadProjectConfig, loadUserConfig } from "./config.js";
7
7
  import { EXIT_REFUSAL } from "./exit-codes.js";
8
+ import { inspectContextCommand } from "./inspect-context.js";
9
+ import { inspectSessionRuntime } from "./inspect-session.js";
8
10
  import { planTurn, writePlanDiagnostics } from "./plan-turn.js";
9
11
  import { refuse } from "./refuse.js";
10
12
  import { resolveHarness } from "./resolve-harness.js";
13
+ import { runtimeCompatibility } from "./runtime-compatibility.js";
11
14
 
12
15
  export const inspect = async (harnessName: string, rawArgs: string[]): Promise<void> => {
13
16
  const h = resolveHarness(harnessName);
14
- const { normalized } = splitPassthrough(rawArgs);
17
+ let parsed: ReturnType<typeof parseCommonFlags>;
18
+ try {
19
+ parsed = parseCommonFlags(rawArgs);
20
+ } catch (err) {
21
+ // Preserve the shared prompt-injection and native-spelling diagnostics.
22
+ const outcome = await planTurn(h, rawArgs, { command: "inspect" });
23
+ if (outcome.kind === "refusal") {
24
+ refuse(outcome.refusal, outcome.wantJson && rawArgs.includes("--context"));
25
+ return;
26
+ }
27
+ throw err;
28
+ }
29
+ const values = parsed.values as Record<string, unknown>;
15
30
 
16
- if (normalized.includes("--help") || normalized.includes("-h")) {
31
+ if (values.help === true) {
17
32
  const { INSPECT_HELP } = await import("./help.js");
18
33
  process.stdout.write(INSPECT_HELP);
19
34
  return;
@@ -22,41 +37,61 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
22
37
  // --argv: the preview is the plan the run command would spawn from, so
23
38
  // the two agree by construction (RFC-02 change 10). Refusals go through
24
39
  // the shared refuse path like every other command's.
25
- if (normalized.includes("--argv")) {
26
- if (normalized.includes("--capabilities")) {
27
- process.stderr.write(`--capabilities and --argv are mutually exclusive; pick one\n`);
28
- process.exitCode = EXIT_REFUSAL;
29
- return;
30
- }
40
+ if (
41
+ (values.capabilities && (values.argv || values.runtime || values.context)) ||
42
+ (values.context && (values.argv || values.runtime))
43
+ ) {
44
+ refuse(
45
+ {
46
+ issue: "invalid-option-value",
47
+ message: "--capabilities and --context are mutually exclusive with other inspection modes",
48
+ },
49
+ values.context === true && values.json === true,
50
+ );
51
+ return;
52
+ }
53
+ if (values.context && values.mode !== undefined && values.mode !== "headless-turn") {
54
+ refuse(
55
+ { issue: "invalid-option-value", message: "--context supports headless-turn only" },
56
+ values.json === true,
57
+ );
58
+ return;
59
+ }
60
+ if (values.context === true) {
61
+ await inspectContextCommand(h, rawArgs);
62
+ return;
63
+ }
64
+ if (values.argv === true || values.runtime === true) {
65
+ if (values.runtime === true && (await inspectSessionRuntime(h, rawArgs))) return;
31
66
  const outcome = await planTurn(h, rawArgs, { command: "inspect" });
32
67
  if (outcome.kind === "refusal") {
33
68
  refuse(outcome.refusal, false);
34
69
  return;
35
70
  }
36
71
  writePlanDiagnostics(h, outcome.plan, "argv");
72
+ if (values.runtime === true) {
73
+ const mode = parseCommonFlags(rawArgs).values.mode ?? "headless-turn";
74
+ if (mode !== "headless-turn" && mode !== "headless-session") {
75
+ refuse(
76
+ {
77
+ message: `invalid runtime mode: ${String(mode)}`,
78
+ issue: "invalid-option-value",
79
+ supported: ["headless-turn", "headless-session"],
80
+ },
81
+ false,
82
+ );
83
+ return;
84
+ }
85
+ const argv = outcome.plan.redactedArgv;
86
+ process.stdout.write(
87
+ `${JSON.stringify({ v: 1, argvKind: "redacted-preview", argv, ...(await runtimeCompatibility(h, outcome.plan.options)) })}\n`,
88
+ );
89
+ return;
90
+ }
37
91
  process.stdout.write(`${JSON.stringify(outcome.plan.redactedArgv)}\n`);
38
92
  return;
39
93
  }
40
94
 
41
- // --capabilities path: pure capability record, no spawn, no config, no prompt
42
- const { parseCommonFlags } = await import("./args.js");
43
- let parsed: ReturnType<typeof parseCommonFlags>;
44
- try {
45
- parsed = parseCommonFlags(rawArgs);
46
- } catch (err) {
47
- const message = err instanceof Error ? err.message : String(err);
48
- refuse(
49
- {
50
- message: `unknown flag: ${message}`,
51
- issue: "invalid-option-value",
52
- trailer: ["Run 'hcn inspect --help' for usage."],
53
- },
54
- false,
55
- );
56
- return;
57
- }
58
- const values = parsed.values as Record<string, unknown>;
59
-
60
95
  if (values.capabilities === true) {
61
96
  const mode = values.mode === undefined ? "headless-turn" : String(values.mode);
62
97
  if (!(HARNESS_MODES as readonly string[]).includes(mode)) {
@@ -118,11 +153,13 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
118
153
  baseFlags: h.launch.baseFlags,
119
154
  subcommands: h.launch.subcommands,
120
155
  streamFlags: h.launch.streamFlags,
156
+ stdinPrompt: h.launch.stdinPrompt ?? null,
121
157
  promptStyle: h.launch.promptStyle,
122
158
  toolsFlag: h.tools.includeFlag,
123
159
  idFlag: h.launch.idFlag,
124
160
  },
125
161
  resume: h.resume,
162
+ contextInspection: h.contextInspection ?? null,
126
163
  sessionMode: h.sessionMode,
127
164
  vocabulary: {
128
165
  models: h.vocabulary.models,
@@ -7,7 +7,7 @@
7
7
  * preview agree by construction, skill tokens and passthrough included.
8
8
  */
9
9
  import { redactArgv, type TurnRunOptions } from "../execution/stream-turn.js";
10
- import { buildSpawnArgv, promptTextOf } from "../interpretation/argv.js";
10
+ import { buildSpawnArgv, promptTextOf, stdinPromptOf } from "../interpretation/argv.js";
11
11
  import { assertIsolationCombination } from "../interpretation/isolation.js";
12
12
  import { composeEscalatedPrompt } from "../interpretation/question.js";
13
13
  import { ArgvRefusalError } from "../interpretation/refusal.js";
@@ -320,7 +320,7 @@ export const planTurn = async (
320
320
  wantJson,
321
321
  options,
322
322
  argv,
323
- redactedArgv: redactArgv(argv, promptTextOf(options)),
323
+ redactedArgv: redactArgv(argv, promptTextOf(options), stdinPromptOf(h, options) !== null),
324
324
  provenance,
325
325
  unrenderable,
326
326
  behavior,
@@ -0,0 +1,55 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { delimiter, isAbsolute, resolve } from "node:path";
3
+ import { mergeEnvironment } from "../execution/environment.js";
4
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
5
+ import { installedVersion } from "./check.js";
6
+
7
+ function executablePath(bin: string, cwd: string, searchPath: string): string | null {
8
+ const candidates =
9
+ isAbsolute(bin) || bin.includes("/")
10
+ ? [resolve(cwd, bin)]
11
+ : searchPath.split(delimiter).map((dir) => resolve(cwd, dir, bin));
12
+ for (const candidate of candidates) {
13
+ try {
14
+ accessSync(candidate, constants.X_OK);
15
+ if (statSync(candidate).isFile()) return realpathSync(candidate);
16
+ } catch {
17
+ // PATH lookup continues when an earlier directory has no executable.
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+
23
+ /** Exact verified versions establish support. Newer is not evidence of compatibility. */
24
+ export async function runtimeCompatibility(
25
+ harness: HarnessDescriptor,
26
+ options: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>> } = {},
27
+ ): Promise<{
28
+ readonly executable: { readonly path: string | null; readonly version: string | null };
29
+ readonly resume: { readonly status: "supported" | "unknown"; readonly reason: string | null };
30
+ readonly verifiedAgainst: string;
31
+ }> {
32
+ const { cwd = process.cwd(), env: environment } = options;
33
+ const inheritedEnvironment = process.env;
34
+ const effectiveEnvironment = mergeEnvironment(inheritedEnvironment, environment);
35
+ // Node's Unix lookup uses this default when PATH has been removed.
36
+ const searchPath = effectiveEnvironment.PATH ?? "/usr/bin:/bin";
37
+ const path = executablePath(harness.bin, cwd, searchPath);
38
+ const version =
39
+ path === null ? null : await installedVersion(path, { cwd, env: effectiveEnvironment });
40
+ const supported = version !== null && version === harness.verifiedAgainst;
41
+ return {
42
+ executable: { path, version },
43
+ resume: {
44
+ status: supported ? "supported" : "unknown",
45
+ reason: supported
46
+ ? null
47
+ : path === null
48
+ ? "The selected executable could not be resolved."
49
+ : version === null
50
+ ? "The selected executable did not report a usable version."
51
+ : "The selected executable version does not match the verified adapter. Verify this version before native takeover.",
52
+ },
53
+ verifiedAgainst: harness.verifiedAgainst,
54
+ };
55
+ }
@@ -0,0 +1,177 @@
1
+ import type { ContextInspection } from "../interpretation/context-inspection.js";
2
+ import { createContextProbe } from "../interpretation/context-inspection.js";
3
+ import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
4
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
5
+
6
+ export type { ContextInspection } from "../interpretation/context-inspection.js";
7
+
8
+ import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
9
+ import { LineBuffer } from "./lines.js";
10
+ import { KILL_GRACE_MS, superviseTermination } from "./supervisor.js";
11
+
12
+ export interface ContextInspectionOptions {
13
+ /** Already validated, version-bound ephemeral argv. The CLI adapter owns
14
+ * no-persistence and fork-on-resume enforcement before this layer runs. */
15
+ readonly argv: readonly string[];
16
+ readonly cwd?: string;
17
+ readonly env?: Readonly<Record<string, string>>;
18
+ readonly harness: HarnessDescriptor;
19
+ readonly inputId: string;
20
+ readonly prompt: string;
21
+ readonly signal?: AbortSignal;
22
+ }
23
+
24
+ // Bounds serialized transport, not tokens. Native replay echoes the complete
25
+ // user message; the ordinary event line bound would silently drop its ack.
26
+ export const CONTEXT_TRANSPORT_MAX = 8 * 1024 * 1024;
27
+ const INSPECTION_TIMEOUT_MS = 30_000;
28
+ function deferred<TValue>(): {
29
+ readonly promise: Promise<TValue>;
30
+ readonly resolve: (value: TValue) => void;
31
+ } {
32
+ let resolve!: (value: TValue) => void;
33
+ const promise = new Promise<TValue>((done) => {
34
+ resolve = done;
35
+ });
36
+ return { promise, resolve };
37
+ }
38
+ /** One disposable native control channel. shouldQuery:false stages the
39
+ * pending task without asking the assistant to execute it. */
40
+ export async function inspectContext(
41
+ options: ContextInspectionOptions,
42
+ deps: RunnerDeps,
43
+ ): Promise<ContextInspection> {
44
+ if (options.signal?.aborted) return { status: "unavailable", reason: "cancelled" };
45
+ const probe = createContextProbe(options.inputId, options.prompt);
46
+ if (new TextEncoder().encode(probe.staged).byteLength > CONTEXT_TRANSPORT_MAX) {
47
+ return { status: "unavailable", reason: "transport-limit" };
48
+ }
49
+ let child: SpawnedProcess;
50
+ try {
51
+ child = deps.spawn(options.argv, { cwd: options.cwd, env: options.env, stdin: "pipe" });
52
+ } catch {
53
+ return { status: "unavailable", reason: "transport" };
54
+ }
55
+ let finished = false;
56
+ let exited = false;
57
+ let exitCode: number | null = null;
58
+ let nativeFailure: "auth" | "limit" | null = null;
59
+ let disposalTimer: TimerHandle | undefined;
60
+ const result = deferred<ContextInspection>();
61
+ const stopped = deferred<boolean>();
62
+ const signal = (kind: "SIGTERM" | "SIGKILL"): void => {
63
+ try {
64
+ deps.signal(child, kind);
65
+ } catch {
66
+ /* Exit races are handled by exited. */
67
+ }
68
+ };
69
+ const termination = superviseTermination(deps.clock, signal);
70
+ const finish = (value: ContextInspection): void => {
71
+ if (finished) return;
72
+ finished = true;
73
+ result.resolve(value);
74
+ try {
75
+ child.stdin?.end();
76
+ } catch {
77
+ /* A broken pipe still needs cleanup. */
78
+ }
79
+ if (exited) {
80
+ stopped.resolve(true);
81
+ return;
82
+ }
83
+ termination.escalate();
84
+ disposalTimer = deps.clock.setTimeout(() => {
85
+ child.disposeOutput();
86
+ termination.settle();
87
+ stopped.resolve(false);
88
+ }, 2 * KILL_GRACE_MS);
89
+ };
90
+ const write = (line: string): void => {
91
+ try {
92
+ if (child.stdin === undefined) {
93
+ finish({ status: "unavailable", reason: "transport" });
94
+ return;
95
+ }
96
+ child.stdin.write(`${line}\n`);
97
+ } catch {
98
+ finish({ status: "unavailable", reason: "transport" });
99
+ }
100
+ };
101
+ const onLine = (line: string): void => {
102
+ if (finished) return;
103
+ const action = probe.accept(line);
104
+ if (action?.kind === "send") write(action.line);
105
+ else if (action?.kind === "complete") finish(action.accounting);
106
+ };
107
+ const deadline = deps.clock.setTimeout(
108
+ () => finish({ status: "unavailable", reason: "timeout" }),
109
+ deps.turnTimeoutMs && deps.turnTimeoutMs > 0 ? deps.turnTimeoutMs : INSPECTION_TIMEOUT_MS,
110
+ );
111
+ const abort = (): void => finish({ status: "unavailable", reason: "cancelled" });
112
+ options.signal?.addEventListener("abort", abort, { once: true });
113
+ const stdout = (async (): Promise<void> => {
114
+ const lines = new LineBuffer(CONTEXT_TRANSPORT_MAX + 65_536);
115
+ try {
116
+ for await (const chunk of child.stdout) {
117
+ if (!finished) for (const line of lines.push(chunk)) onLine(line);
118
+ }
119
+ const tail = lines.flush();
120
+ if (tail !== null) onLine(tail);
121
+ finish({ status: "unavailable", reason: "transport" });
122
+ } catch {
123
+ finish({ status: "unavailable", reason: "transport" });
124
+ }
125
+ })();
126
+ const stderr = (async (): Promise<void> => {
127
+ const lines = new LineBuffer();
128
+ const classify = (line: string): void => {
129
+ if (detectAuthFailureInLine(options.harness, line) !== null) nativeFailure = "auth";
130
+ else if (nativeFailure === null && detectLimitInLine(options.harness, line) !== null)
131
+ nativeFailure = "limit";
132
+ };
133
+ try {
134
+ for await (const chunk of child.stderr) for (const line of lines.push(chunk)) classify(line);
135
+ const tail = lines.flush();
136
+ if (tail !== null) classify(tail);
137
+ } catch {
138
+ finish({ status: "unavailable", reason: "transport" });
139
+ }
140
+ })();
141
+ void child.inputError?.then(() => finish({ status: "unavailable", reason: "transport" }));
142
+ void child.exited.then(async (code) => {
143
+ exited = true;
144
+ exitCode = code;
145
+ if (code !== null && code !== 0) finish({ status: "unavailable", reason: "transport" });
146
+ termination.settle();
147
+ if (disposalTimer !== undefined) deps.clock.clearTimeout(disposalTimer);
148
+ // Exit can precede the final bytes reaching the runtime's read buffer.
149
+ // Drain naturally first; inherited pipes get a bounded disposal fallback.
150
+ let drainTimer: TimerHandle | undefined;
151
+ await Promise.race([
152
+ Promise.all([stdout, stderr]),
153
+ new Promise<void>((resolve) => {
154
+ drainTimer = deps.clock.setTimeout(resolve, KILL_GRACE_MS);
155
+ }),
156
+ ]);
157
+ if (drainTimer !== undefined) deps.clock.clearTimeout(drainTimer);
158
+ child.disposeOutput();
159
+ stopped.resolve(true);
160
+ });
161
+ write(probe.initial);
162
+ // Abort can arrive during spawn or listener registration.
163
+ if (options.signal?.aborted) abort();
164
+ const value = await result.promise;
165
+ deps.clock.clearTimeout(deadline);
166
+ options.signal?.removeEventListener("abort", abort);
167
+ const clean = await stopped.promise;
168
+ if (clean) await Promise.all([stdout, stderr]);
169
+ if (!clean) return { status: "unavailable", reason: "cleanup" };
170
+ if (value.status === "unavailable" && value.reason === "transport") {
171
+ return {
172
+ status: "unavailable",
173
+ reason: nativeFailure ?? (exitCode !== null && exitCode !== 0 ? "native-exit" : "transport"),
174
+ };
175
+ }
176
+ return value;
177
+ }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  export interface SpawnedProcess {
9
+ /** Resolves on asynchronous stdin failure (for example EPIPE). The
10
+ * adapter always handles the native error event, even without a caller. */
11
+ readonly inputError?: Promise<void>;
9
12
  readonly stdout: AsyncIterable<string | Uint8Array>;
10
13
  readonly stderr: AsyncIterable<string | Uint8Array>;
11
14
  /** Idempotently terminate stdout and stderr reads without signalling the
@@ -0,0 +1,15 @@
1
+ /** Apply the spawn contract: an empty override removes the inherited key. */
2
+ export function mergeEnvironment(
3
+ inherited: Readonly<Record<string, string | undefined>>,
4
+ overrides: Readonly<Record<string, string>> = {},
5
+ ): Record<string, string> {
6
+ const result: Record<string, string> = {};
7
+ for (const [key, value] of Object.entries(inherited)) {
8
+ if (value !== undefined) result[key] = value;
9
+ }
10
+ for (const [key, value] of Object.entries(overrides)) {
11
+ if (value === "") delete result[key];
12
+ else result[key] = value;
13
+ }
14
+ return result;
15
+ }
@@ -11,11 +11,11 @@
11
11
  export const LINE_MAX = 65_536;
12
12
 
13
13
  export class LineBuffer {
14
+ constructor(private readonly limit = LINE_MAX) {}
14
15
  // Per-instance and stateful ({stream:true}): a shared decoder would carry
15
16
  // partial-sequence state across two streams and corrupt both.
16
17
  private readonly decoder = new TextDecoder();
17
18
  private pending = "";
18
- private scanFrom = 0;
19
19
  private discarding = false;
20
20
 
21
21
  push(chunk: string | Uint8Array): string[] {
@@ -24,39 +24,38 @@ export class LineBuffer {
24
24
  }
25
25
 
26
26
  private ingest(text: string): string[] {
27
- this.pending += text;
28
27
  const lines: string[] = [];
28
+ let start = 0;
29
29
  while (true) {
30
- const at = this.pending.indexOf("\n", this.scanFrom);
31
- if (at === -1) break;
32
- const line = this.pending.slice(0, at);
33
- this.pending = this.pending.slice(at + 1);
34
- this.scanFrom = 0;
35
- if (this.discarding) {
36
- // The truncated head of an over-long line ends here; drop it whole.
37
- this.discarding = false;
38
- continue;
39
- }
40
- // The bound applies to COMPLETE lines too - an oversized line whose
41
- // newline arrived in the same chunk must not bypass it.
42
- if (line.length <= LINE_MAX && !isBlank(line)) lines.push(line);
30
+ // Search only new bytes. Searching the accumulated rope on each pipe
31
+ // chunk repeatedly flattens large echoed prompts.
32
+ const at = text.indexOf("\n", start);
33
+ this.append(text.slice(start, at === -1 ? text.length : at));
34
+ if (at === -1) return lines;
35
+ if (!this.discarding && !isBlank(this.pending)) lines.push(this.pending);
36
+ this.pending = "";
37
+ this.discarding = false;
38
+ start = at + 1;
43
39
  }
44
- this.scanFrom = this.pending.length;
45
- if (this.pending.length > LINE_MAX) {
40
+ }
41
+
42
+ private append(text: string): void {
43
+ if (this.discarding) return;
44
+ if (this.pending.length + text.length > this.limit) {
46
45
  this.pending = "";
47
- this.scanFrom = 0;
48
46
  this.discarding = true;
49
- }
50
- return lines;
47
+ } else this.pending += text;
51
48
  }
52
49
 
53
50
  /** The final partial line at stream close, if any. */
54
51
  flush(): string | null {
55
52
  const tail = this.decoder.decode();
56
- if (tail !== "") this.pending += tail;
57
- const rest = this.discarding || isBlank(this.pending) ? null : this.pending;
53
+ if (tail !== "") this.append(tail);
54
+ const rest =
55
+ this.discarding || this.pending.length > this.limit || isBlank(this.pending)
56
+ ? null
57
+ : this.pending;
58
58
  this.pending = "";
59
- this.scanFrom = 0;
60
59
  this.discarding = false;
61
60
  return rest;
62
61
  }
@@ -9,6 +9,7 @@
9
9
  import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
10
10
  import type { Readable } from "node:stream";
11
11
  import type { Clock, RunnerDeps, SpawnedProcess, SpawnOptions } from "./deps.js";
12
+ import { mergeEnvironment } from "./environment.js";
12
13
 
13
14
  const children = new WeakMap<SpawnedProcess, ChildProcess>();
14
15
 
@@ -91,22 +92,7 @@ export const disposableOutputStream = (
91
92
  const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess => {
92
93
  const [bin, ...args] = argv;
93
94
  if (bin === undefined) throw new Error("empty argv");
94
- const env =
95
- opts.env === undefined
96
- ? undefined
97
- : (() => {
98
- const merged: Record<string, string | undefined> = { ...process.env };
99
- for (const [k, v] of Object.entries(opts.env)) {
100
- if (v === "") delete merged[k];
101
- else merged[k] = v;
102
- }
103
- // Filter out undefined to satisfy Node's env type (string -> string)
104
- const out: Record<string, string> = {};
105
- for (const [k, v] of Object.entries(merged)) {
106
- if (v !== undefined) out[k] = v;
107
- }
108
- return out;
109
- })();
95
+ const env = mergeEnvironment(process.env, opts.env);
110
96
  const child = nodeSpawn(bin, args, {
111
97
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
112
98
  ...(env !== undefined ? { env } : {}),
@@ -116,7 +102,14 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
116
102
  "pipe",
117
103
  ],
118
104
  });
105
+ const inputError = new Promise<void>((resolve) => {
106
+ child.stdin?.once("error", () => resolve());
107
+ });
119
108
  let outputDisposed = false;
109
+ let outputDisposedResolve!: () => void;
110
+ const outputDisposal = new Promise<void>((resolve) => {
111
+ outputDisposedResolve = resolve;
112
+ });
120
113
  const stdoutDisposalCause = new Error("stdout disposed by runner");
121
114
  const stderrDisposalCause = new Error("stderr disposed by runner");
122
115
  let spawnError: Error | null = null;
@@ -150,10 +143,13 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
150
143
  // immediately while an async ENOENT remains the single spawn-error signal.
151
144
  const stderrWithError = async function* (): AsyncIterable<string | Uint8Array> {
152
145
  if (stderrOutput !== null) yield* stderrOutput.stream;
153
- await exited;
146
+ // Terminal output disposal must also settle this tail when a child has
147
+ // closed stderr but has not exited. The caller owns signalling it.
148
+ await Promise.race([exited, outputDisposal]);
154
149
  if (spawnError !== null) yield `spawn failed: ${(spawnError as Error).message}\n`;
155
150
  };
156
151
  const proc: SpawnedProcess = {
152
+ inputError,
157
153
  stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream,
158
154
  stderr: stderrWithError(),
159
155
  exited,
@@ -161,6 +157,7 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
161
157
  disposeOutput: (): void => {
162
158
  if (outputDisposed) return;
163
159
  outputDisposed = true;
160
+ outputDisposedResolve();
164
161
  stdoutOutput?.dispose();
165
162
  stderrOutput?.dispose();
166
163
  },
@@ -19,6 +19,7 @@ import {
19
19
  buildSpawnArgv,
20
20
  type LaunchOptions,
21
21
  promptTextOf,
22
+ stdinPromptOf,
22
23
  streamingGranularityOf,
23
24
  withPromptText,
24
25
  } from "../interpretation/argv.js";
@@ -72,14 +73,21 @@ const SECRETISH = /(sk-[A-Za-z0-9_-]{8,}|(?:token|key|secret|password)=\S+)/i;
72
73
  * other token is kept unless it is secret-shaped. Only the prompt's
73
74
  * positional slot is masked, so a one-word prompt that equals a flag
74
75
  * value does not cause that flag value to be masked. */
75
- export const redactArgv = (argv: readonly string[], prompt?: string): string[] => {
76
+ export const redactArgv = (
77
+ argv: readonly string[],
78
+ prompt?: string,
79
+ viaStdin = false,
80
+ ): string[] => {
76
81
  const promptIndex = prompt !== undefined ? argv.lastIndexOf(prompt) : -1;
77
82
  const promptLabel = prompt !== undefined ? `[prompt:${prompt.length}ch]` : "";
78
- return argv.map((token, index) => {
83
+ const redacted = argv.map((token, index) => {
79
84
  if (index === promptIndex) return promptLabel;
80
85
  if (SECRETISH.test(token)) return "[redacted]";
81
86
  return token;
82
87
  });
88
+ return viaStdin && prompt !== undefined
89
+ ? [...redacted, `[stdin prompt:${prompt.length}ch]`]
90
+ : redacted;
83
91
  };
84
92
 
85
93
  export interface TurnRunOptions extends LaunchOptions {
@@ -119,6 +127,7 @@ export async function* streamTurn(
119
127
  prompt: withPromptText(opts.prompt, composeEscalatedPrompt(promptTextOf(opts), questionMode)),
120
128
  };
121
129
  const promptText = promptTextOf(effective);
130
+ const stdinPrompt = stdinPromptOf(h, effective);
122
131
  let asked = false;
123
132
  let escalationDetection: EscalationDetection = "none";
124
133
 
@@ -209,7 +218,7 @@ export async function* streamTurn(
209
218
  event: "spawn",
210
219
  turnId,
211
220
  harness: h.name,
212
- argv: redactArgv(argv, promptText),
221
+ argv: redactArgv(argv, promptText, stdinPrompt !== null),
213
222
  granularity,
214
223
  ...(matcherOverrides ? { matcherOverrides } : {}),
215
224
  ...(envKeys?.length ? { envKeys } : {}),
@@ -226,7 +235,7 @@ export async function* streamTurn(
226
235
  let proc: SpawnedProcess;
227
236
  try {
228
237
  proc = deps.spawn(argv, {
229
- stdin: h.stdin === "close-required" ? "close" : "inherit",
238
+ stdin: stdinPrompt !== null ? "pipe" : h.stdin === "close-required" ? "close" : "inherit",
230
239
  ...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
231
240
  ...(effective.env !== undefined ? { env: effective.env } : {}),
232
241
  });
@@ -367,6 +376,24 @@ export async function* streamTurn(
367
376
  }, PIPE_GRACE_MS);
368
377
  });
369
378
 
379
+ if (stdinPrompt !== null) {
380
+ let inputFailed = false;
381
+ const failInput = (): void => {
382
+ if (inputFailed || cancelled || exited) return;
383
+ inputFailed = true;
384
+ void pushFailure(failureFromTransport("native prompt stdin failed"));
385
+ sup.escalate();
386
+ };
387
+ void proc.inputError?.then(failInput);
388
+ try {
389
+ if (!proc.stdin) throw new Error("native prompt requires stdin");
390
+ proc.stdin.write(stdinPrompt);
391
+ proc.stdin.end();
392
+ } catch {
393
+ failInput();
394
+ }
395
+ }
396
+
370
397
  const pumpStdout = async (): Promise<void> => {
371
398
  const lines = new LineBuffer();
372
399
  let identitySeen = false;