@dungle-scrubs/harness-cli-normalizer 0.1.1
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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/execution/channel.d.ts +33 -0
- package/dist/execution/channel.d.ts.map +1 -0
- package/dist/execution/channel.js +86 -0
- package/dist/execution/channel.js.map +1 -0
- package/dist/execution/decode.d.ts +16 -0
- package/dist/execution/decode.d.ts.map +1 -0
- package/dist/execution/decode.js +60 -0
- package/dist/execution/decode.js.map +1 -0
- package/dist/execution/deps.d.ts +53 -0
- package/dist/execution/deps.d.ts.map +1 -0
- package/dist/execution/deps.js +8 -0
- package/dist/execution/deps.js.map +1 -0
- package/dist/execution/events.d.ts +54 -0
- package/dist/execution/events.d.ts.map +1 -0
- package/dist/execution/events.js +2 -0
- package/dist/execution/events.js.map +1 -0
- package/dist/execution/index.d.ts +19 -0
- package/dist/execution/index.d.ts.map +1 -0
- package/dist/execution/index.js +19 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/lines.d.ts +21 -0
- package/dist/execution/lines.d.ts.map +1 -0
- package/dist/execution/lines.js +70 -0
- package/dist/execution/lines.js.map +1 -0
- package/dist/execution/node-deps.d.ts +13 -0
- package/dist/execution/node-deps.d.ts.map +1 -0
- package/dist/execution/node-deps.js +164 -0
- package/dist/execution/node-deps.js.map +1 -0
- package/dist/execution/open-session.d.ts +31 -0
- package/dist/execution/open-session.d.ts.map +1 -0
- package/dist/execution/open-session.js +360 -0
- package/dist/execution/open-session.js.map +1 -0
- package/dist/execution/stream-turn.d.ts +43 -0
- package/dist/execution/stream-turn.d.ts.map +1 -0
- package/dist/execution/stream-turn.js +283 -0
- package/dist/execution/stream-turn.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/interpretation/argv.d.ts +35 -0
- package/dist/interpretation/argv.d.ts.map +1 -0
- package/dist/interpretation/argv.js +147 -0
- package/dist/interpretation/argv.js.map +1 -0
- package/dist/interpretation/capabilities.d.ts +19 -0
- package/dist/interpretation/capabilities.d.ts.map +1 -0
- package/dist/interpretation/capabilities.js +25 -0
- package/dist/interpretation/capabilities.js.map +1 -0
- package/dist/interpretation/content.d.ts +33 -0
- package/dist/interpretation/content.d.ts.map +1 -0
- package/dist/interpretation/content.js +169 -0
- package/dist/interpretation/content.js.map +1 -0
- package/dist/interpretation/context.d.ts +17 -0
- package/dist/interpretation/context.d.ts.map +1 -0
- package/dist/interpretation/context.js +16 -0
- package/dist/interpretation/context.js.map +1 -0
- package/dist/interpretation/dimensions.d.ts +13 -0
- package/dist/interpretation/dimensions.d.ts.map +1 -0
- package/dist/interpretation/dimensions.js +6 -0
- package/dist/interpretation/dimensions.js.map +1 -0
- package/dist/interpretation/identity.d.ts +32 -0
- package/dist/interpretation/identity.d.ts.map +1 -0
- package/dist/interpretation/identity.js +44 -0
- package/dist/interpretation/identity.js.map +1 -0
- package/dist/interpretation/index.d.ts +23 -0
- package/dist/interpretation/index.d.ts.map +1 -0
- package/dist/interpretation/index.js +23 -0
- package/dist/interpretation/index.js.map +1 -0
- package/dist/interpretation/limits.d.ts +20 -0
- package/dist/interpretation/limits.d.ts.map +1 -0
- package/dist/interpretation/limits.js +35 -0
- package/dist/interpretation/limits.js.map +1 -0
- package/dist/interpretation/parse-resume.d.ts +28 -0
- package/dist/interpretation/parse-resume.d.ts.map +1 -0
- package/dist/interpretation/parse-resume.js +91 -0
- package/dist/interpretation/parse-resume.js.map +1 -0
- package/dist/interpretation/presence.d.ts +16 -0
- package/dist/interpretation/presence.d.ts.map +1 -0
- package/dist/interpretation/presence.js +32 -0
- package/dist/interpretation/presence.js.map +1 -0
- package/dist/interpretation/resume-last.d.ts +32 -0
- package/dist/interpretation/resume-last.d.ts.map +1 -0
- package/dist/interpretation/resume-last.js +70 -0
- package/dist/interpretation/resume-last.js.map +1 -0
- package/dist/interpretation/session-id.d.ts +22 -0
- package/dist/interpretation/session-id.d.ts.map +1 -0
- package/dist/interpretation/session-id.js +29 -0
- package/dist/interpretation/session-id.js.map +1 -0
- package/dist/interpretation/session-input.d.ts +13 -0
- package/dist/interpretation/session-input.d.ts.map +1 -0
- package/dist/interpretation/session-input.js +32 -0
- package/dist/interpretation/session-input.js.map +1 -0
- package/dist/interpretation/shape.d.ts +17 -0
- package/dist/interpretation/shape.d.ts.map +1 -0
- package/dist/interpretation/shape.js +54 -0
- package/dist/interpretation/shape.js.map +1 -0
- package/dist/interpretation/store.d.ts +16 -0
- package/dist/interpretation/store.d.ts.map +1 -0
- package/dist/interpretation/store.js +27 -0
- package/dist/interpretation/store.js.map +1 -0
- package/dist/interpretation/versions.d.ts +16 -0
- package/dist/interpretation/versions.d.ts.map +1 -0
- package/dist/interpretation/versions.js +44 -0
- package/dist/interpretation/versions.js.map +1 -0
- package/dist/interpretation/vocabulary.d.ts +29 -0
- package/dist/interpretation/vocabulary.d.ts.map +1 -0
- package/dist/interpretation/vocabulary.js +55 -0
- package/dist/interpretation/vocabulary.js.map +1 -0
- package/dist/knowledge/claude-code.d.ts +8 -0
- package/dist/knowledge/claude-code.d.ts.map +1 -0
- package/dist/knowledge/claude-code.js +132 -0
- package/dist/knowledge/claude-code.js.map +1 -0
- package/dist/knowledge/codex.d.ts +9 -0
- package/dist/knowledge/codex.d.ts.map +1 -0
- package/dist/knowledge/codex.js +106 -0
- package/dist/knowledge/codex.js.map +1 -0
- package/dist/knowledge/descriptor.d.ts +211 -0
- package/dist/knowledge/descriptor.d.ts.map +1 -0
- package/dist/knowledge/descriptor.js +20 -0
- package/dist/knowledge/descriptor.js.map +1 -0
- package/dist/knowledge/index.d.ts +19 -0
- package/dist/knowledge/index.d.ts.map +1 -0
- package/dist/knowledge/index.js +19 -0
- package/dist/knowledge/index.js.map +1 -0
- package/dist/knowledge/matchers.d.ts +10 -0
- package/dist/knowledge/matchers.d.ts.map +1 -0
- package/dist/knowledge/matchers.js +11 -0
- package/dist/knowledge/matchers.js.map +1 -0
- package/dist/knowledge/muse.d.ts +12 -0
- package/dist/knowledge/muse.d.ts.map +1 -0
- package/dist/knowledge/muse.js +94 -0
- package/dist/knowledge/muse.js.map +1 -0
- package/dist/knowledge/overrides.d.ts +11 -0
- package/dist/knowledge/overrides.d.ts.map +1 -0
- package/dist/knowledge/overrides.js +166 -0
- package/dist/knowledge/overrides.js.map +1 -0
- package/dist/knowledge/pi.d.ts +11 -0
- package/dist/knowledge/pi.d.ts.map +1 -0
- package/dist/knowledge/pi.js +97 -0
- package/dist/knowledge/pi.js.map +1 -0
- package/package.json +69 -0
- package/src/execution/channel.ts +86 -0
- package/src/execution/decode.ts +79 -0
- package/src/execution/deps.ts +59 -0
- package/src/execution/events.ts +37 -0
- package/src/execution/index.ts +18 -0
- package/src/execution/lines.ts +71 -0
- package/src/execution/node-deps.ts +179 -0
- package/src/execution/open-session.ts +392 -0
- package/src/execution/stream-turn.ts +310 -0
- package/src/index.ts +7 -0
- package/src/interpretation/argv.ts +188 -0
- package/src/interpretation/capabilities.ts +51 -0
- package/src/interpretation/content.ts +191 -0
- package/src/interpretation/context.ts +28 -0
- package/src/interpretation/dimensions.ts +20 -0
- package/src/interpretation/identity.ts +81 -0
- package/src/interpretation/index.ts +22 -0
- package/src/interpretation/limits.ts +63 -0
- package/src/interpretation/parse-resume.ts +117 -0
- package/src/interpretation/presence.ts +49 -0
- package/src/interpretation/resume-last.ts +93 -0
- package/src/interpretation/session-id.ts +35 -0
- package/src/interpretation/session-input.ts +45 -0
- package/src/interpretation/shape.ts +55 -0
- package/src/interpretation/store.ts +43 -0
- package/src/interpretation/versions.ts +43 -0
- package/src/interpretation/vocabulary.ts +74 -0
- package/src/knowledge/claude-code.ts +132 -0
- package/src/knowledge/codex.ts +106 -0
- package/src/knowledge/descriptor.ts +217 -0
- package/src/knowledge/index.ts +23 -0
- package/src/knowledge/matchers.ts +19 -0
- package/src/knowledge/muse.ts +94 -0
- package/src/knowledge/overrides.ts +182 -0
- package/src/knowledge/pi.ts +97 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argv construction: pure functions turning a descriptor + launch options
|
|
3
|
+
* into the exact argv a spawner would exec. Ordering rules (positional
|
|
4
|
+
* prompt before tool grants) and the spawn-boundary refusals live here so
|
|
5
|
+
* no caller re-derives them.
|
|
6
|
+
*/
|
|
7
|
+
import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
|
|
8
|
+
import { assertUsableSessionId } from "./session-id.js";
|
|
9
|
+
import { validateModel } from "./vocabulary.js";
|
|
10
|
+
|
|
11
|
+
/** Raised when launch options would corrupt or subvert the spawned argv. */
|
|
12
|
+
export class ArgvRefusalError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
readonly issue: string,
|
|
15
|
+
message: string,
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ArgvRefusalError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One guard for every builder that places a positional prompt. Selector
|
|
23
|
+
* hygiene (session ids) lives in session-id.ts; model selectors go through
|
|
24
|
+
* validateModel - both refuse, never sanitize. */
|
|
25
|
+
const assertCleanPrompt = (h: HarnessDescriptor, prompt: string): void => {
|
|
26
|
+
if (prompt.startsWith("-")) {
|
|
27
|
+
throw new ArgvRefusalError(
|
|
28
|
+
"prompt-flag-injection",
|
|
29
|
+
`positional prompt may not start with '-'; it would be parsed as a flag by ${h.bin}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface TurnOptions {
|
|
35
|
+
readonly prompt: string;
|
|
36
|
+
readonly tools?: readonly string[];
|
|
37
|
+
readonly model?: string;
|
|
38
|
+
readonly autonomy?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ResumeOptions extends TurnOptions {
|
|
42
|
+
readonly sessionId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LaunchOptions = TurnOptions;
|
|
46
|
+
|
|
47
|
+
/** The shared tail of every headless-turn argv: prompt, stream flags, then
|
|
48
|
+
* validated selections, with the variadic tools flag LAST and fed exactly
|
|
49
|
+
* one joined token so nothing after it can be swallowed as a tool name. */
|
|
50
|
+
const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
|
|
51
|
+
assertCleanPrompt(h, opts.prompt);
|
|
52
|
+
const tail = [opts.prompt, ...h.launch.streamFlags];
|
|
53
|
+
if (opts.model !== undefined) {
|
|
54
|
+
const validated = validateModel(h, opts.model);
|
|
55
|
+
if (!validated.ok) throw new ArgvRefusalError("unknown-model", validated.reason);
|
|
56
|
+
tail.push(h.vocabulary.modelFlag, validated.id);
|
|
57
|
+
}
|
|
58
|
+
if (opts.autonomy === true) {
|
|
59
|
+
if (h.autonomy === null) {
|
|
60
|
+
throw new ArgvRefusalError("no-autonomy-mode", `${h.bin} has no unattended-run flag`);
|
|
61
|
+
}
|
|
62
|
+
tail.push(h.autonomy.flag);
|
|
63
|
+
}
|
|
64
|
+
if (opts.tools !== undefined && h.launch.toolsFlag !== null) {
|
|
65
|
+
if (opts.tools.length === 0 || opts.tools.some((t) => t.trim() === "" || t.includes(","))) {
|
|
66
|
+
throw new ArgvRefusalError(
|
|
67
|
+
"invalid-tool-grant",
|
|
68
|
+
`tool grant for ${h.bin} contains an empty entry or a comma; a blank ${h.launch.toolsFlag} value grants nothing detectable, and a comma inside one name silently splits the grant`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
tail.push(h.launch.toolsFlag, opts.tools.join(","));
|
|
72
|
+
}
|
|
73
|
+
return tail;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const buildLaunchArgv = (h: HarnessDescriptor, opts: LaunchOptions): string[] => [
|
|
77
|
+
h.bin,
|
|
78
|
+
...h.launch.baseFlags,
|
|
79
|
+
...turnTail(h, opts),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): string[] => {
|
|
83
|
+
assertUsableSessionId(opts.sessionId);
|
|
84
|
+
// Subcommands lead, then the resume token and id, then the flags the
|
|
85
|
+
// RESUME grammar accepts (never inherited launch flags - codex exec
|
|
86
|
+
// resume rejects --sandbox). One shape serves both styles:
|
|
87
|
+
// `claude --resume <id> -p <prompt>`, `codex exec resume <id> --json
|
|
88
|
+
// <prompt>`, `muse exec --session-id <id> <prompt>`.
|
|
89
|
+
return [
|
|
90
|
+
h.bin,
|
|
91
|
+
...h.launch.subcommands,
|
|
92
|
+
h.resume.flag,
|
|
93
|
+
opts.sessionId,
|
|
94
|
+
...h.resume.extraFlags,
|
|
95
|
+
...turnTail(h, opts),
|
|
96
|
+
];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export interface SessionOptions {
|
|
100
|
+
readonly sessionId: string;
|
|
101
|
+
readonly model?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
|
|
105
|
+
if (!h.sessionMode) {
|
|
106
|
+
throw new ArgvRefusalError(
|
|
107
|
+
"no-session-mode",
|
|
108
|
+
`${h.bin} declares no persistent headless session mode`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
assertUsableSessionId(opts.sessionId);
|
|
112
|
+
const argv = [
|
|
113
|
+
h.bin,
|
|
114
|
+
...h.launch.baseFlags,
|
|
115
|
+
...h.sessionMode.flags,
|
|
116
|
+
h.sessionMode.idFlag,
|
|
117
|
+
opts.sessionId,
|
|
118
|
+
];
|
|
119
|
+
if (opts.model !== undefined) {
|
|
120
|
+
const validated = validateModel(h, opts.model);
|
|
121
|
+
if (!validated.ok) throw new ArgvRefusalError("unknown-model", validated.reason);
|
|
122
|
+
argv.push(h.vocabulary.modelFlag, validated.id);
|
|
123
|
+
}
|
|
124
|
+
return argv;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Canonicalize an argv into a last-wins flag map: `--flag=value` splits,
|
|
128
|
+
* `--flag value` consumes the following non-flag token as its value, a flag
|
|
129
|
+
* followed by another flag is boolean. Positional tokens are skipped, so a
|
|
130
|
+
* value can never satisfy a boolean pin member. Aliases map onto canonical
|
|
131
|
+
* spellings. Known limitation: a crafted argv giving a pin member as the
|
|
132
|
+
* value of an unknown variadic flag can still false-positive - full CLI
|
|
133
|
+
* grammar is unknowable from outside. */
|
|
134
|
+
const flagMapOf = (h: HarnessDescriptor, argv: readonly string[]): Map<string, string | true> => {
|
|
135
|
+
const map = new Map<string, string | true>();
|
|
136
|
+
for (let i = 1; i < argv.length; i++) {
|
|
137
|
+
const token = argv[i];
|
|
138
|
+
if (token === undefined || !token.startsWith("-")) continue;
|
|
139
|
+
const eq = token.indexOf("=");
|
|
140
|
+
if (eq !== -1) {
|
|
141
|
+
const name = token.slice(0, eq);
|
|
142
|
+
map.set(h.output.flagAliases[name] ?? name, token.slice(eq + 1));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const name = h.output.flagAliases[token] ?? token;
|
|
146
|
+
const next = argv[i + 1];
|
|
147
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
148
|
+
map.set(name, next);
|
|
149
|
+
i++;
|
|
150
|
+
} else {
|
|
151
|
+
map.set(name, true);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const pinSatisfied = (
|
|
158
|
+
pin: readonly string[],
|
|
159
|
+
flags: ReadonlyMap<string, string | true>,
|
|
160
|
+
): boolean => {
|
|
161
|
+
for (let i = 0; i < pin.length; i++) {
|
|
162
|
+
const member = pin[i];
|
|
163
|
+
if (member === undefined || !member.startsWith("-")) continue;
|
|
164
|
+
const expected = pin[i + 1];
|
|
165
|
+
const actual = flags.get(member);
|
|
166
|
+
if (actual === undefined) return false;
|
|
167
|
+
if (expected !== undefined && !expected.startsWith("-") && actual !== expected) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** The granularity an invocation will actually emit: pins are checked in
|
|
175
|
+
* order, first fully-satisfied pin wins (every member present with its
|
|
176
|
+
* pinned value, last occurrence winning like the CLIs themselves); an argv
|
|
177
|
+
* satisfying no pin gets the floor. */
|
|
178
|
+
export const streamingGranularityOf = (
|
|
179
|
+
h: HarnessDescriptor,
|
|
180
|
+
argv: readonly string[],
|
|
181
|
+
): StreamingGranularity => {
|
|
182
|
+
if (h.output.pins.length === 0) return h.output.floor;
|
|
183
|
+
const flags = flagMapOf(h, argv);
|
|
184
|
+
for (const pin of h.output.pins) {
|
|
185
|
+
if (pinSatisfied(pin.flags, flags)) return pin.granularity;
|
|
186
|
+
}
|
|
187
|
+
return h.output.floor;
|
|
188
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability resolution: capabilitiesOf(h, model, mode) answers what this
|
|
3
|
+
* harness/model/mode combination can actually do, always with a source.
|
|
4
|
+
* This layer serves the CURATED baseline (or degrades to unknown for a
|
|
5
|
+
* model outside the vocabulary); runtime verification against a live
|
|
6
|
+
* harness/registry happens at attach time in the consumer, which upgrades
|
|
7
|
+
* source to runtime-verified. An LLM self-asserting capability is neither.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
HarnessDescriptor,
|
|
11
|
+
HarnessMode,
|
|
12
|
+
StreamingGranularity,
|
|
13
|
+
} from "../knowledge/descriptor.js";
|
|
14
|
+
import { resolveModel } from "./vocabulary.js";
|
|
15
|
+
|
|
16
|
+
export interface CapabilityResult {
|
|
17
|
+
readonly vision: boolean;
|
|
18
|
+
readonly images: boolean;
|
|
19
|
+
readonly streaming: StreamingGranularity;
|
|
20
|
+
readonly session: boolean;
|
|
21
|
+
readonly source: "runtime-verified" | "curated" | "unknown";
|
|
22
|
+
readonly confidence: "high" | "medium" | "none";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const capabilitiesOf = (
|
|
26
|
+
h: HarnessDescriptor,
|
|
27
|
+
model: string,
|
|
28
|
+
mode: HarnessMode,
|
|
29
|
+
): CapabilityResult => {
|
|
30
|
+
if (!resolveModel(h, model).curated) {
|
|
31
|
+
// Curated claims cover curated models only - an extensible registry's
|
|
32
|
+
// unknown model still degrades here until runtime verification.
|
|
33
|
+
// Degrade: no raw-image claims, no streaming claims - transcribe/hold.
|
|
34
|
+
return {
|
|
35
|
+
vision: false,
|
|
36
|
+
images: false,
|
|
37
|
+
streaming: "none",
|
|
38
|
+
session: false,
|
|
39
|
+
source: "unknown",
|
|
40
|
+
confidence: "none",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
vision: h.capabilities.vision,
|
|
45
|
+
images: h.capabilities.images,
|
|
46
|
+
streaming: h.capabilities.streamingByMode[mode],
|
|
47
|
+
session: h.capabilities.session,
|
|
48
|
+
source: "curated",
|
|
49
|
+
confidence: "medium",
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content decoding: extract assistant text, token deltas, tool calls, and
|
|
3
|
+
* progress from ONE parsed harness record. Each harness has an entirely
|
|
4
|
+
* different event vocabulary, so this dispatches by harness name to a pure
|
|
5
|
+
* per-harness reader. Identity and wall detection stay descriptor-driven
|
|
6
|
+
* elsewhere; this owns the message/token/tool/progress classes only.
|
|
7
|
+
*
|
|
8
|
+
* (Kept as functions rather than descriptor data because the shapes are
|
|
9
|
+
* structurally too varied - array-of-blocks text, nested delta events,
|
|
10
|
+
* payload-discriminated records - for the flat match/field spec that
|
|
11
|
+
* identity decoding uses.)
|
|
12
|
+
*/
|
|
13
|
+
import type { HarnessName } from "../knowledge/descriptor.js";
|
|
14
|
+
import { asRecord } from "./shape.js";
|
|
15
|
+
|
|
16
|
+
export type ContentEvent =
|
|
17
|
+
| { readonly kind: "token"; readonly text: string }
|
|
18
|
+
| { readonly kind: "message"; readonly role: string; readonly text: string }
|
|
19
|
+
| { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
|
|
20
|
+
| { readonly kind: "progress"; readonly label: string }
|
|
21
|
+
| { readonly kind: "error"; readonly message: string };
|
|
22
|
+
|
|
23
|
+
/** Text of an array of `{type:"text", text}` content blocks. */
|
|
24
|
+
const textOfBlocks = (content: unknown): string =>
|
|
25
|
+
Array.isArray(content)
|
|
26
|
+
? content
|
|
27
|
+
.map((b) => asRecord(b))
|
|
28
|
+
.filter((b): b is Record<string, unknown> => b !== null && b.type === "text")
|
|
29
|
+
.map((b) => (typeof b.text === "string" ? b.text : ""))
|
|
30
|
+
.join("")
|
|
31
|
+
: "";
|
|
32
|
+
|
|
33
|
+
const at = (record: Record<string, unknown>, path: string): unknown => {
|
|
34
|
+
let cursor: unknown = record;
|
|
35
|
+
for (const seg of path.split(".")) {
|
|
36
|
+
const inner = asRecord(cursor);
|
|
37
|
+
if (inner === null) return undefined;
|
|
38
|
+
cursor = inner[seg];
|
|
39
|
+
}
|
|
40
|
+
return cursor;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const claude = (r: Record<string, unknown>): ContentEvent[] => {
|
|
44
|
+
const events: ContentEvent[] = [];
|
|
45
|
+
if (r.type === "assistant") {
|
|
46
|
+
const content = at(r, "message.content");
|
|
47
|
+
if (Array.isArray(content)) {
|
|
48
|
+
for (const raw of content) {
|
|
49
|
+
const block = asRecord(raw);
|
|
50
|
+
if (block?.type === "tool_use" && typeof block.name === "string") {
|
|
51
|
+
events.push({ kind: "tool", name: block.name, input: block.input });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const text = textOfBlocks(content);
|
|
56
|
+
if (text !== "") events.push({ kind: "message", role: "assistant", text });
|
|
57
|
+
} else if (r.type === "stream_event") {
|
|
58
|
+
const delta = at(r, "event.delta");
|
|
59
|
+
const d = asRecord(delta);
|
|
60
|
+
if (d?.type === "text_delta" && typeof d.text === "string") {
|
|
61
|
+
events.push({ kind: "token", text: d.text });
|
|
62
|
+
}
|
|
63
|
+
} else if (r.type === "system" && r.subtype !== "init" && typeof r.subtype === "string") {
|
|
64
|
+
// Non-init system lines (hook_started etc.) surface as droppable
|
|
65
|
+
// progress, as the pre-refactor decoder did. The init line is the
|
|
66
|
+
// identity announcement, handled upstream.
|
|
67
|
+
events.push({ kind: "progress", label: r.subtype });
|
|
68
|
+
} else if (r.type === "result" && r.is_error === true) {
|
|
69
|
+
// A result line marked is_error is a failed turn (max-turns, execution
|
|
70
|
+
// error) - surface it so a streamTurn consumer sees the failure, not a
|
|
71
|
+
// clean turn. (openSession handles result boundaries itself.)
|
|
72
|
+
const sub = typeof r.subtype === "string" ? r.subtype : "result error";
|
|
73
|
+
events.push({ kind: "error", message: `turn failed: ${sub}` });
|
|
74
|
+
}
|
|
75
|
+
return events;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// codex item types that are NOT tool activity - everything else on
|
|
79
|
+
// item.started is a tool of some kind (command_execution, file_change,
|
|
80
|
+
// mcp_tool_call, web_search, ...), surfaced generically by item.type.
|
|
81
|
+
const CODEX_NON_TOOL = new Set(["agent_message", "error", "reasoning", "todo_list"]);
|
|
82
|
+
|
|
83
|
+
const codex = (r: Record<string, unknown>): ContentEvent[] => {
|
|
84
|
+
const item = asRecord(r.item);
|
|
85
|
+
if (item === null) return [];
|
|
86
|
+
// Tool activity is emitted once on start so it is not double-counted
|
|
87
|
+
// against the completion record. Any non-message/reasoning item is a tool;
|
|
88
|
+
// name it by its item.type, carry the most useful field as input.
|
|
89
|
+
if (
|
|
90
|
+
r.type === "item.started" &&
|
|
91
|
+
typeof item.type === "string" &&
|
|
92
|
+
!CODEX_NON_TOOL.has(item.type)
|
|
93
|
+
) {
|
|
94
|
+
const name = item.type === "command_execution" ? "shell" : item.type;
|
|
95
|
+
const input = item.command ?? item.changes ?? item.query ?? item.invocation ?? undefined;
|
|
96
|
+
return [{ kind: "tool", name, input }];
|
|
97
|
+
}
|
|
98
|
+
if (r.type !== "item.completed") return [];
|
|
99
|
+
if (item.type === "agent_message" && typeof item.text === "string") {
|
|
100
|
+
return [{ kind: "message", role: "assistant", text: item.text }];
|
|
101
|
+
}
|
|
102
|
+
if (item.type === "error" && typeof item.message === "string") {
|
|
103
|
+
return [{ kind: "error", message: item.message }];
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const pi = (r: Record<string, unknown>): ContentEvent[] => {
|
|
109
|
+
// One clean tool event per invocation (the toolcall_start/delta/end and
|
|
110
|
+
// tool_execution_update stream is noise; tool_execution_start fires once).
|
|
111
|
+
if (r.type === "tool_execution_start" && typeof r.toolName === "string") {
|
|
112
|
+
return [{ kind: "tool", name: r.toolName, input: r.args }];
|
|
113
|
+
}
|
|
114
|
+
if (r.type === "message_update") {
|
|
115
|
+
const ev = asRecord(r.assistantMessageEvent);
|
|
116
|
+
if (ev?.type === "text_delta" && typeof ev.delta === "string") {
|
|
117
|
+
return [{ kind: "token", text: ev.delta }];
|
|
118
|
+
}
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (r.type === "message_end") {
|
|
122
|
+
const message = asRecord(r.message);
|
|
123
|
+
if (message?.role === "assistant") {
|
|
124
|
+
// A turn that ends with stopReason "error" is a provider/auth/token
|
|
125
|
+
// failure pi does NOT print to stderr and exits 0 for (verified with
|
|
126
|
+
// an expired minimax token: empty content, stopReason error, clean
|
|
127
|
+
// exit). Without this the failure is invisible - a silent empty turn.
|
|
128
|
+
if (message.stopReason === "error") {
|
|
129
|
+
return [
|
|
130
|
+
{ kind: "error", message: "pi turn ended with stopReason error (provider/auth failure)" },
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
const text = textOfBlocks(message.content);
|
|
134
|
+
if (text !== "") return [{ kind: "message", role: "assistant", text }];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const muse = (r: Record<string, unknown>): ContentEvent[] => {
|
|
141
|
+
const payload = asRecord(r.payload);
|
|
142
|
+
if (payload === null) return [];
|
|
143
|
+
// Tool activity: muse emits a tool_result naming the tool in
|
|
144
|
+
// correlation_facts.tool_name, with the command inside the result text.
|
|
145
|
+
if (payload.kind === "tool_result") {
|
|
146
|
+
const facts = asRecord(payload.correlation_facts);
|
|
147
|
+
const name = typeof facts?.tool_name === "string" ? facts.tool_name : "tool";
|
|
148
|
+
// Shell tools carry a JSON result with a `command`; file tools carry
|
|
149
|
+
// plain prose. Surface the command when present, else leave input off
|
|
150
|
+
// (the tool name is the signal that matters).
|
|
151
|
+
let command: unknown;
|
|
152
|
+
if (typeof payload.text === "string") {
|
|
153
|
+
try {
|
|
154
|
+
command = (JSON.parse(payload.text) as Record<string, unknown>).command;
|
|
155
|
+
} catch {
|
|
156
|
+
command = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [{ kind: "tool", name, input: command }];
|
|
160
|
+
}
|
|
161
|
+
if (payload.kind === "run_output_delta" && typeof payload.text === "string") {
|
|
162
|
+
return [{ kind: "token", text: payload.text }];
|
|
163
|
+
}
|
|
164
|
+
if (payload.kind === "run_terminal") {
|
|
165
|
+
if (
|
|
166
|
+
payload.terminal === "completed" &&
|
|
167
|
+
typeof payload.text === "string" &&
|
|
168
|
+
payload.text !== ""
|
|
169
|
+
) {
|
|
170
|
+
return [{ kind: "message", role: "assistant", text: payload.text }];
|
|
171
|
+
}
|
|
172
|
+
if (payload.terminal === "failed") {
|
|
173
|
+
const reason = typeof payload.reason === "string" ? payload.reason : "run failed";
|
|
174
|
+
return [{ kind: "error", message: `muse run failed: ${reason}` }];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return [];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const READERS: Record<HarnessName, (r: Record<string, unknown>) => ContentEvent[]> = {
|
|
181
|
+
claude,
|
|
182
|
+
codex,
|
|
183
|
+
pi,
|
|
184
|
+
muse,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const contentEventsOf = (harness: HarnessName, raw: unknown): ContentEvent[] => {
|
|
188
|
+
const record = asRecord(raw);
|
|
189
|
+
if (record === null) return [];
|
|
190
|
+
return READERS[harness](record);
|
|
191
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-hook decoding: recognizes the harness's context-window usage
|
|
3
|
+
* payload and surfaces it as a `context` HarnessEvent, the droppable
|
|
4
|
+
* gauge-class event the chat layer may coalesce under pressure. For claude
|
|
5
|
+
* the payload arrives on the STATUSLINE channel, never on stream-json
|
|
6
|
+
* stdout - route this at the channel level, do not call it per stdout line.
|
|
7
|
+
* Values are sanitized (v1 sanitizeContext): non-finite is rejected,
|
|
8
|
+
* out-of-range is clamped, so callers report nothing rather than a broken
|
|
9
|
+
* gauge.
|
|
10
|
+
*/
|
|
11
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
12
|
+
import { asRecord } from "./shape.js";
|
|
13
|
+
|
|
14
|
+
export interface ContextEvent {
|
|
15
|
+
readonly kind: "context";
|
|
16
|
+
readonly usedPct: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const contextEventFrom = (h: HarnessDescriptor, raw: unknown): ContextEvent | null => {
|
|
20
|
+
if (h.contextHook === null) return null;
|
|
21
|
+
const record = asRecord(raw);
|
|
22
|
+
if (record === null) return null;
|
|
23
|
+
const outer = asRecord(record[h.contextHook.object]);
|
|
24
|
+
if (outer === null) return null;
|
|
25
|
+
const usedPct = outer[h.contextHook.usedPctField];
|
|
26
|
+
if (typeof usedPct !== "number" || !Number.isFinite(usedPct)) return null;
|
|
27
|
+
return { kind: "context", usedPct: Math.min(100, Math.max(0, usedPct)) };
|
|
28
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small dimension accessors: one interpretation function per PLAN 3.1
|
|
3
|
+
* dimension whose value is a direct descriptor lookup. They exist so every
|
|
4
|
+
* dimension has a single named owner in the interpretation layer - callers
|
|
5
|
+
* never reach into descriptor internals.
|
|
6
|
+
*/
|
|
7
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
8
|
+
|
|
9
|
+
export const stdinPolicyOf = (h: HarnessDescriptor): "inherit" | "close-required" => h.stdin;
|
|
10
|
+
|
|
11
|
+
export const providerFlagOf = (h: HarnessDescriptor): string | null =>
|
|
12
|
+
h.provider === null ? null : h.provider.flag;
|
|
13
|
+
|
|
14
|
+
export const toolsFlagOf = (h: HarnessDescriptor): string | null => h.launch.toolsFlag;
|
|
15
|
+
|
|
16
|
+
export const discoveryDisableFlagsOf = (h: HarnessDescriptor): readonly string[] =>
|
|
17
|
+
h.discoveryDisableFlags;
|
|
18
|
+
|
|
19
|
+
export const autonomyFlagOf = (h: HarnessDescriptor): string | null =>
|
|
20
|
+
h.autonomy === null ? null : h.autonomy.flag;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity decoding: pure recognition of a harness's identity announcement
|
|
3
|
+
* inside its output stream. D-022: claude re-emits `system/init` with the
|
|
4
|
+
* same session_id at every turn start, so announcements are deduped against
|
|
5
|
+
* the last seen id - an identity is only "news" on first sight or change.
|
|
6
|
+
* Under caller-assigned authority an announcement that differs from the
|
|
7
|
+
* REQUESTED id is a rotation anomaly (v1 HSI005): binding it would silently
|
|
8
|
+
* attach the conversation to a context that never saw it, so the outcome is
|
|
9
|
+
* surfaced for the runner to refuse, never papered over.
|
|
10
|
+
*/
|
|
11
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
12
|
+
import { isUsableSessionId } from "./session-id.js";
|
|
13
|
+
import { asRecord } from "./shape.js";
|
|
14
|
+
|
|
15
|
+
export type IdentityOutcome =
|
|
16
|
+
/** Not an identity announcement at all. */
|
|
17
|
+
| "none"
|
|
18
|
+
/** First sight (or a changed id under harness-minted authority). */
|
|
19
|
+
| "announced"
|
|
20
|
+
/** Same id as last seen - turn-start metadata, not news. */
|
|
21
|
+
| "duplicate"
|
|
22
|
+
/** Caller-assigned authority, but the harness announced a DIFFERENT id
|
|
23
|
+
* than the one requested - refuse to bind, do not paper over. */
|
|
24
|
+
| "rotated"
|
|
25
|
+
/** Announcement present but the id fails the shape rule - not believed. */
|
|
26
|
+
| "malformed";
|
|
27
|
+
|
|
28
|
+
export interface DecodedIdentity {
|
|
29
|
+
/** The id this raw event announces, whether or not it is news. */
|
|
30
|
+
readonly sessionId: string | null;
|
|
31
|
+
/** The id to surface as an identity HarnessEvent, or null when not news. */
|
|
32
|
+
readonly identity: string | null;
|
|
33
|
+
readonly outcome: IdentityOutcome;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NOT_ANNOUNCED: DecodedIdentity = { sessionId: null, identity: null, outcome: "none" };
|
|
37
|
+
|
|
38
|
+
export const decodeIdentity = (
|
|
39
|
+
h: HarnessDescriptor,
|
|
40
|
+
raw: unknown,
|
|
41
|
+
lastSeenId: string | null,
|
|
42
|
+
requestedId: string | null = null,
|
|
43
|
+
): DecodedIdentity => {
|
|
44
|
+
const record = asRecord(raw);
|
|
45
|
+
if (record === null) return NOT_ANNOUNCED;
|
|
46
|
+
const spec = h.identity.announce;
|
|
47
|
+
for (const [key, expected] of Object.entries(spec.match)) {
|
|
48
|
+
if (record[key] !== expected) return NOT_ANNOUNCED;
|
|
49
|
+
}
|
|
50
|
+
// idField is a dot-path: muse nests its id at stream.id.
|
|
51
|
+
let cursor: unknown = record;
|
|
52
|
+
for (const segment of spec.idField.split(".")) {
|
|
53
|
+
const inner = asRecord(cursor);
|
|
54
|
+
if (inner === null) return NOT_ANNOUNCED;
|
|
55
|
+
cursor = inner[segment];
|
|
56
|
+
}
|
|
57
|
+
const announced = cursor;
|
|
58
|
+
if (typeof announced !== "string" || !isUsableSessionId(announced)) {
|
|
59
|
+
// A record that matched a real discriminator (claude system/init) but
|
|
60
|
+
// carries a null/missing/garbage id is a MALFORMED announcement the
|
|
61
|
+
// runner must see - treating it as unrelated output leaves the runner
|
|
62
|
+
// waiting for an identity that already failed to arrive. Descriptors
|
|
63
|
+
// with an empty match (muse: any record) have no discriminator, so a
|
|
64
|
+
// record without the id path is ordinary output, not malformed.
|
|
65
|
+
const discriminated = Object.keys(spec.match).length > 0;
|
|
66
|
+
return discriminated
|
|
67
|
+
? { sessionId: null, identity: null, outcome: "malformed" }
|
|
68
|
+
: NOT_ANNOUNCED;
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
h.identity.authority === "caller-assigned" &&
|
|
72
|
+
requestedId !== null &&
|
|
73
|
+
announced !== requestedId
|
|
74
|
+
) {
|
|
75
|
+
return { sessionId: announced, identity: null, outcome: "rotated" };
|
|
76
|
+
}
|
|
77
|
+
if (announced === lastSeenId) {
|
|
78
|
+
return { sessionId: announced, identity: null, outcome: "duplicate" };
|
|
79
|
+
}
|
|
80
|
+
return { sessionId: announced, identity: announced, outcome: "announced" };
|
|
81
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interpretation layer: owns pure functions that operate over descriptors.
|
|
3
|
+
*
|
|
4
|
+
* This includes argv building, identity decoding, limit detection, and
|
|
5
|
+
* capability queries. Every function here is total and side-effect free: it
|
|
6
|
+
* performs no I/O and imports no side-effecting Node builtins - purity is
|
|
7
|
+
* test-enforced. It is NOT responsible for spawning processes or handling
|
|
8
|
+
* streams; that is the execution layer's job.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./argv.js";
|
|
11
|
+
export * from "./capabilities.js";
|
|
12
|
+
export * from "./context.js";
|
|
13
|
+
export * from "./dimensions.js";
|
|
14
|
+
export * from "./identity.js";
|
|
15
|
+
export * from "./limits.js";
|
|
16
|
+
export * from "./parse-resume.js";
|
|
17
|
+
export * from "./presence.js";
|
|
18
|
+
export * from "./resume-last.js";
|
|
19
|
+
export * from "./session-id.js";
|
|
20
|
+
export * from "./session-input.js";
|
|
21
|
+
export * from "./store.js";
|
|
22
|
+
export * from "./vocabulary.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall detection: pure classification of harness output against the
|
|
3
|
+
* descriptor's limit and auth matchers. Returns the CODE, never the matched
|
|
4
|
+
* line - the consumers of a detection are a retained record and a viewer
|
|
5
|
+
* warning, and anything the harness printed on that line (a prompt, a
|
|
6
|
+
* filename, a customer's name) must not ride along (v1 D-005: records carry
|
|
7
|
+
* identifiers and outcomes, never content).
|
|
8
|
+
*
|
|
9
|
+
* Feed these wall-eligible output only - stderr and the non-JSON tail of a
|
|
10
|
+
* dying turn - never assistant message content, where the model merely
|
|
11
|
+
* TALKING about limits would match.
|
|
12
|
+
*/
|
|
13
|
+
import type { AuthFailureKind, HarnessDescriptor, LimitCode } from "../knowledge/descriptor.js";
|
|
14
|
+
|
|
15
|
+
/** Bottom-up batch scans stop after this many non-empty lines: the wall is
|
|
16
|
+
* virtually always the last thing a dying turn printed, and an unbounded
|
|
17
|
+
* scan over an accumulating session buffer is O(turns x output). */
|
|
18
|
+
const BATCH_SCAN_MAX_LINES = 200;
|
|
19
|
+
|
|
20
|
+
const scanLine = <Code>(
|
|
21
|
+
line: string,
|
|
22
|
+
matchers: ReadonlyArray<readonly [RegExp, Code]>,
|
|
23
|
+
): Code | null => {
|
|
24
|
+
for (let i = 0; i < matchers.length; i++) {
|
|
25
|
+
const matcher = matchers[i];
|
|
26
|
+
if (matcher?.[0].test(line)) return matcher[1];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const scanTail = <Code>(
|
|
32
|
+
output: string,
|
|
33
|
+
matchers: ReadonlyArray<readonly [RegExp, Code]>,
|
|
34
|
+
): Code | null => {
|
|
35
|
+
let end = output.length;
|
|
36
|
+
let scanned = 0;
|
|
37
|
+
while (end > 0 && scanned < BATCH_SCAN_MAX_LINES) {
|
|
38
|
+
const start = output.lastIndexOf("\n", end - 1);
|
|
39
|
+
const line = output.slice(start + 1, end).trim();
|
|
40
|
+
end = start;
|
|
41
|
+
if (line === "") continue;
|
|
42
|
+
scanned++;
|
|
43
|
+
const code = scanLine(line, matchers);
|
|
44
|
+
if (code !== null) return code;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Per-line entry point for streaming readers: O(1) per line, no rescans. */
|
|
50
|
+
export const detectLimitInLine = (h: HarnessDescriptor, line: string): LimitCode | null =>
|
|
51
|
+
scanLine(line.trim(), h.limitMatchers);
|
|
52
|
+
|
|
53
|
+
/** Batch convenience over a turn's tail, bounded and bottom-up. */
|
|
54
|
+
export const detectLimit = (h: HarnessDescriptor, output: string): LimitCode | null =>
|
|
55
|
+
scanTail(output, h.limitMatchers);
|
|
56
|
+
|
|
57
|
+
export const detectAuthFailureInLine = (
|
|
58
|
+
h: HarnessDescriptor,
|
|
59
|
+
line: string,
|
|
60
|
+
): AuthFailureKind | null => scanLine(line.trim(), h.authMatchers);
|
|
61
|
+
|
|
62
|
+
export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
|
|
63
|
+
scanTail(output, h.authMatchers);
|