@lucascouts/claude-agent-tui 0.1.0
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 +191 -0
- package/NOTICE +14 -0
- package/README.md +50 -0
- package/dist/acp-agent.d.ts +594 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +2139 -0
- package/dist/ansi-mirror.d.ts +42 -0
- package/dist/ansi-mirror.d.ts.map +1 -0
- package/dist/ansi-mirror.js +61 -0
- package/dist/besteffort.d.ts +44 -0
- package/dist/besteffort.d.ts.map +1 -0
- package/dist/besteffort.js +100 -0
- package/dist/billing/entrypoint-guard.d.ts +97 -0
- package/dist/billing/entrypoint-guard.d.ts.map +1 -0
- package/dist/billing/entrypoint-guard.js +166 -0
- package/dist/claude-path.d.ts +12 -0
- package/dist/claude-path.d.ts.map +1 -0
- package/dist/claude-path.js +61 -0
- package/dist/diff-enriched-reader.d.ts +41 -0
- package/dist/diff-enriched-reader.d.ts.map +1 -0
- package/dist/diff-enriched-reader.js +106 -0
- package/dist/diff-source.d.ts +104 -0
- package/dist/diff-source.d.ts.map +1 -0
- package/dist/diff-source.js +164 -0
- package/dist/end-of-turn.d.ts +172 -0
- package/dist/end-of-turn.d.ts.map +1 -0
- package/dist/end-of-turn.js +415 -0
- package/dist/engine-lifecycle.d.ts +222 -0
- package/dist/engine-lifecycle.d.ts.map +1 -0
- package/dist/engine-lifecycle.js +236 -0
- package/dist/engine-pty.d.ts +143 -0
- package/dist/engine-pty.d.ts.map +1 -0
- package/dist/engine-pty.js +222 -0
- package/dist/engine-watcher.d.ts +83 -0
- package/dist/engine-watcher.d.ts.map +1 -0
- package/dist/engine-watcher.js +173 -0
- package/dist/engine.d.ts +30 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +34 -0
- package/dist/event-switch.d.ts +164 -0
- package/dist/event-switch.d.ts.map +1 -0
- package/dist/event-switch.js +206 -0
- package/dist/gate/port.d.ts +38 -0
- package/dist/gate/port.d.ts.map +1 -0
- package/dist/gate/port.js +126 -0
- package/dist/gate/settings-writer.d.ts +130 -0
- package/dist/gate/settings-writer.d.ts.map +1 -0
- package/dist/gate/settings-writer.js +349 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +106 -0
- package/dist/jsonl.d.ts +267 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +527 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +5 -0
- package/dist/linearize.d.ts +219 -0
- package/dist/linearize.d.ts.map +1 -0
- package/dist/linearize.js +444 -0
- package/dist/live-diff-env.d.ts +7 -0
- package/dist/live-diff-env.d.ts.map +1 -0
- package/dist/live-diff-env.js +18 -0
- package/dist/live-subagent-env.d.ts +7 -0
- package/dist/live-subagent-env.d.ts.map +1 -0
- package/dist/live-subagent-env.js +19 -0
- package/dist/permissions/allow-inject.d.ts +67 -0
- package/dist/permissions/allow-inject.d.ts.map +1 -0
- package/dist/permissions/allow-inject.js +85 -0
- package/dist/permissions/deny.d.ts +60 -0
- package/dist/permissions/deny.d.ts.map +1 -0
- package/dist/permissions/deny.js +81 -0
- package/dist/permissions/gate-wiring.d.ts +112 -0
- package/dist/permissions/gate-wiring.d.ts.map +1 -0
- package/dist/permissions/gate-wiring.js +350 -0
- package/dist/permissions/hook-server.d.ts +72 -0
- package/dist/permissions/hook-server.d.ts.map +1 -0
- package/dist/permissions/hook-server.js +179 -0
- package/dist/permissions/permission-mode.d.ts +67 -0
- package/dist/permissions/permission-mode.d.ts.map +1 -0
- package/dist/permissions/permission-mode.js +100 -0
- package/dist/permissions/request-permission.d.ts +102 -0
- package/dist/permissions/request-permission.d.ts.map +1 -0
- package/dist/permissions/request-permission.js +124 -0
- package/dist/settings.d.ts +68 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +182 -0
- package/dist/stop-reason-map.d.ts +17 -0
- package/dist/stop-reason-map.d.ts.map +1 -0
- package/dist/stop-reason-map.js +33 -0
- package/dist/subagent-source.d.ts +63 -0
- package/dist/subagent-source.d.ts.map +1 -0
- package/dist/subagent-source.js +132 -0
- package/dist/subagent-watcher.d.ts +40 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +108 -0
- package/dist/tools.d.ts +119 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +729 -0
- package/dist/usage-env.d.ts +7 -0
- package/dist/usage-env.d.ts.map +1 -0
- package/dist/usage-env.js +16 -0
- package/dist/usage.d.ts +54 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +53 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +83 -0
- package/dist/zed-register.d.ts +26 -0
- package/dist/zed-register.d.ts.map +1 -0
- package/dist/zed-register.js +106 -0
- package/package.json +79 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IPty, IDisposable } from "node-pty";
|
|
2
|
+
/**
|
|
3
|
+
* The live-view sink: receives each raw PTY chunk verbatim. This is the OUTPUT-ONLY surface a
|
|
4
|
+
* host may pipe to a terminal view for §9 "is the prompt alive?" gating visibility. It is a sink,
|
|
5
|
+
* not a source — there is deliberately NO return channel by which a consumer could write back into
|
|
6
|
+
* engine/session state. Consumers MUST NOT parse this stream back into session state (§2): state
|
|
7
|
+
* comes only from the JSONL tail.
|
|
8
|
+
*/
|
|
9
|
+
export type AnsiChunkSink = (chunk: string) => void;
|
|
10
|
+
/** Options for {@link attachAnsiMirror}. */
|
|
11
|
+
export interface AnsiMirrorOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Master flag — OFF by default (§5/§9). When false/absent, NO `p.onData` listener is attached
|
|
14
|
+
* and the engine path is byte-for-byte the read-only Degrau-1 behaviour. Only when true AND a
|
|
15
|
+
* sink is supplied is the cosmetic tap subscribed.
|
|
16
|
+
*/
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The live-view sink that receives each raw chunk verbatim. The tap is a no-op (and no listener
|
|
20
|
+
* is attached) when this is absent, so the mirror cannot accidentally subscribe with nowhere to
|
|
21
|
+
* forward to.
|
|
22
|
+
*/
|
|
23
|
+
onAnsiChunk?: AnsiChunkSink;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Conditionally tap the PTY's `p.onData` stream for the cosmetic live ANSI mirror (§5).
|
|
27
|
+
*
|
|
28
|
+
* When the mirror is ENABLED (flag on AND a sink supplied), subscribes a stateless `p.onData`
|
|
29
|
+
* listener that forwards each raw chunk VERBATIM to the sink — no parsing, no ANSI decoding, no
|
|
30
|
+
* state accumulation, no `SessionUpdate`, no billing — and returns the `IDisposable` so the caller
|
|
31
|
+
* can detach it on teardown.
|
|
32
|
+
*
|
|
33
|
+
* When the mirror is DISABLED (the default — flag off OR no sink), `p.onData` is NEVER touched and
|
|
34
|
+
* the function returns `undefined`. This is the load-bearing guarantee that the OFF path is
|
|
35
|
+
* byte-for-byte the read-only Degrau-1 engine path: a disabled mirror attaches no listener at all.
|
|
36
|
+
*
|
|
37
|
+
* The mirror is structurally subordinate to the JSONL tail (§2): it is one-way (bytes → sink) and
|
|
38
|
+
* feeds NOTHING into the structural path. Toggling it on/off does not change the structural event
|
|
39
|
+
* sequence the JSONL tail produces.
|
|
40
|
+
*/
|
|
41
|
+
export declare function attachAnsiMirror(p: Pick<IPty, "onData">, opts?: AnsiMirrorOptions): IDisposable | undefined;
|
|
42
|
+
//# sourceMappingURL=ansi-mirror.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ansi-mirror.d.ts","sourceRoot":"","sources":["../src/ansi-mirror.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;AAEpD,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,aAAa,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,EACvB,IAAI,GAAE,iBAAsB,GAC3B,WAAW,GAAG,SAAS,CAgBzB"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// === §5 Espelho ANSI ao vivo (opcional) — cosmetic live PTY mirror (story 035) ===
|
|
2
|
+
//
|
|
3
|
+
// Optional, flag-gated tap on the story-013/014 PTY's `p.onData(chunk => …)` stream that
|
|
4
|
+
// forwards the RAW TUI bytes to a "live-view" sink, for the §5 "ao vivo" effect ONLY. Its sole
|
|
5
|
+
// purpose is the §9 v1 gating-visibility surface: a human can render this stream to confirm the
|
|
6
|
+
// real `claude` prompt is alive and drawing in-TUI, WITHOUT it ever becoming a structural input.
|
|
7
|
+
//
|
|
8
|
+
// ── tail-as-source-of-truth fence (§2 ordering) ──────────────────────────────────────────────
|
|
9
|
+
// This mirror is STRICTLY subordinate to the JSONL tail (stories 015/016/017). It:
|
|
10
|
+
// • holds NO parsed state — it is stateless and one-way (bytes → sink, nothing read back);
|
|
11
|
+
// • emits NO ACP `SessionUpdate` — it never calls the tail watcher, the event switch, the
|
|
12
|
+
// translators, or any `SessionUpdate` producer (structure/state come ONLY from the JSONL);
|
|
13
|
+
// • does NOT decode/parse ANSI — the byte stream is interleaved/partial across chunks, so any
|
|
14
|
+
// parse attempt would corrupt state; it forwards the chunk verbatim and nothing else;
|
|
15
|
+
// • touches NO billing and has NO IO side-effect beyond the single sink call.
|
|
16
|
+
// ANY future code that derives session state from `onData` bytes is a §2 violation by construction.
|
|
17
|
+
//
|
|
18
|
+
// ── OFF is byte-for-byte the read-only Degrau-1 path (story 023) ─────────────────────────────
|
|
19
|
+
// The flag is OFF by default. When disabled, {@link attachAnsiMirror} returns WITHOUT calling
|
|
20
|
+
// `p.onData` at all — no listener is ever attached — so the engine path is identical to the
|
|
21
|
+
// read-only Degrau-1 behaviour. Enabling/disabling the mirror changes NOTHING in the structural
|
|
22
|
+
// output: the JSONL tail produces the identical event sequence with the mirror on or off.
|
|
23
|
+
//
|
|
24
|
+
// ── §7 caveat: streamEventToAcpNotifications stays UNWIRED in v1 ──────────────────────────────
|
|
25
|
+
// `streamEventToAcpNotifications` (`content_block_delta` deltas) is left without a source in the
|
|
26
|
+
// JSONL (which is per-block granular, §7); it would ONLY be viable layered ON TOP of this cosmetic
|
|
27
|
+
// mirror. But this mirror has NO ordering or structural guarantees (structure still comes from the
|
|
28
|
+
// JSONL tail per §2), so wiring deltas onto it is DELIBERATELY left undone in v1. This module
|
|
29
|
+
// implements NO delta-to-`SessionUpdate` translation, and no such wiring exists in this path.
|
|
30
|
+
/**
|
|
31
|
+
* Conditionally tap the PTY's `p.onData` stream for the cosmetic live ANSI mirror (§5).
|
|
32
|
+
*
|
|
33
|
+
* When the mirror is ENABLED (flag on AND a sink supplied), subscribes a stateless `p.onData`
|
|
34
|
+
* listener that forwards each raw chunk VERBATIM to the sink — no parsing, no ANSI decoding, no
|
|
35
|
+
* state accumulation, no `SessionUpdate`, no billing — and returns the `IDisposable` so the caller
|
|
36
|
+
* can detach it on teardown.
|
|
37
|
+
*
|
|
38
|
+
* When the mirror is DISABLED (the default — flag off OR no sink), `p.onData` is NEVER touched and
|
|
39
|
+
* the function returns `undefined`. This is the load-bearing guarantee that the OFF path is
|
|
40
|
+
* byte-for-byte the read-only Degrau-1 engine path: a disabled mirror attaches no listener at all.
|
|
41
|
+
*
|
|
42
|
+
* The mirror is structurally subordinate to the JSONL tail (§2): it is one-way (bytes → sink) and
|
|
43
|
+
* feeds NOTHING into the structural path. Toggling it on/off does not change the structural event
|
|
44
|
+
* sequence the JSONL tail produces.
|
|
45
|
+
*/
|
|
46
|
+
export function attachAnsiMirror(p, opts = {}) {
|
|
47
|
+
// OFF path (default): do NOT touch p.onData. No listener is attached → byte-for-byte the
|
|
48
|
+
// read-only Degrau-1 path. The `onAnsiChunk` guard means an enabled-but-sinkless config is also
|
|
49
|
+
// a no-op (it would have nowhere to forward to), never a dangling subscription.
|
|
50
|
+
if (!opts.enabled || !opts.onAnsiChunk) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const sink = opts.onAnsiChunk;
|
|
54
|
+
// ON path: forward the raw chunk verbatim. STATELESS and one-way — bytes → sink ONLY. This
|
|
55
|
+
// handler deliberately does NOT call the tail watcher (015), the event switch (016), the
|
|
56
|
+
// translators, or any `SessionUpdate` producer (§2). Deriving any state from `chunk` here would
|
|
57
|
+
// be a §2 violation: structure/state come solely from the JSONL tail.
|
|
58
|
+
return p.onData((chunk) => {
|
|
59
|
+
sink(chunk);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type AgentSideConnection, type SessionNotification } from "@agentclientprotocol/sdk";
|
|
2
|
+
import { type ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
3
|
+
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
|
4
|
+
import { toAcpNotifications, type Logger, type ToolUseCache } from "./acp-agent.js";
|
|
5
|
+
/**
|
|
6
|
+
* Per-surface enable flags for the convenience translation. Each surface is gated independently so
|
|
7
|
+
* the orchestrator can, e.g., keep `plan` while suppressing `image` (or disable both entirely).
|
|
8
|
+
*/
|
|
9
|
+
export interface ConvenienceFlags {
|
|
10
|
+
/** Emit `plan` updates derived from `TodoWrite` tool_use blocks when true. */
|
|
11
|
+
plan: boolean;
|
|
12
|
+
/** Emit `image` content updates derived from `image` blocks when true. */
|
|
13
|
+
image: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A CONVENIENCE block is one whose translation produces a best-effort surface (NOT part of the core
|
|
17
|
+
* text/tool thread): an `image` content block, OR a `TodoWrite` tool_use (which routes to `plan`).
|
|
18
|
+
* Everything else (text, thinking, other tool_use, tool_result, …) is CORE.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isConvenienceBlock(block: any): boolean;
|
|
21
|
+
type ContentBlock = ContentBlockParam | BetaContentBlock | BetaRawContentBlockDelta;
|
|
22
|
+
type TranslateFn = typeof toAcpNotifications;
|
|
23
|
+
/**
|
|
24
|
+
* Translate `content` with the plan/image convenience surface gated behind a best-effort flag.
|
|
25
|
+
*
|
|
26
|
+
* CORE pass (always, never gated): the non-convenience blocks are translated via the REAL
|
|
27
|
+
* `toAcpNotifications` (`registerHooks: false` to stay synchronous, mirroring the §7 seam tests). A
|
|
28
|
+
* convenience failure can NEVER touch these updates — they are produced before the gated pass runs.
|
|
29
|
+
*
|
|
30
|
+
* CONVENIENCE pass (flag-gated + try/catch): only when at least one surface flag is on AND there is
|
|
31
|
+
* at least one convenience block, the convenience subset is translated (via the injectable
|
|
32
|
+
* `opts.translate`, default the real translator) inside a try/catch. Surviving updates are filtered
|
|
33
|
+
* to the enabled surfaces and appended after the core updates. On a thrown error OR an unusable
|
|
34
|
+
* (non-array) result, a drift note is logged via `logger.error(...)` and that surface is SKIPPED
|
|
35
|
+
* (nothing appended) — the error is NEVER rethrown, so the live thread is never broken.
|
|
36
|
+
*
|
|
37
|
+
* Signature mirrors `toAcpNotifications`'s real parameter list/types (acp-agent.ts:2866), with the
|
|
38
|
+
* required `flags` and the optional `opts.translate` test seam appended.
|
|
39
|
+
*/
|
|
40
|
+
export declare function translateBestEffort(content: string | ContentBlock[], role: "assistant" | "user", sessionId: string, cache: ToolUseCache, client: AgentSideConnection, logger: Logger, flags: ConvenienceFlags, opts?: {
|
|
41
|
+
translate?: TranslateFn;
|
|
42
|
+
}): SessionNotification[];
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=besteffort.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"besteffort.d.ts","sourceRoot":"","sources":["../src/besteffort.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,KAAK,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EACL,gBAAgB,EAChB,wBAAwB,EACzB,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEpF;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,8EAA8E;IAC9E,IAAI,EAAE,OAAO,CAAC;IACd,0EAA0E;IAC1E,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAKtD;AAED,KAAK,YAAY,GAAG,iBAAiB,GAAG,gBAAgB,GAAG,wBAAwB,CAAC;AACpF,KAAK,WAAW,GAAG,OAAO,kBAAkB,CAAC;AA8C7C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,GAAG,YAAY,EAAE,EAChC,IAAI,EAAE,WAAW,GAAG,MAAM,EAC1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,YAAY,EACnB,MAAM,EAAE,mBAAmB,EAC3B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,gBAAgB,EACvB,IAAI,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,WAAW,CAAA;CAAE,GACjC,mBAAmB,EAAE,CA0CvB"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { toAcpNotifications } from "./acp-agent.js";
|
|
2
|
+
/**
|
|
3
|
+
* A CONVENIENCE block is one whose translation produces a best-effort surface (NOT part of the core
|
|
4
|
+
* text/tool thread): an `image` content block, OR a `TodoWrite` tool_use (which routes to `plan`).
|
|
5
|
+
* Everything else (text, thinking, other tool_use, tool_result, …) is CORE.
|
|
6
|
+
*/
|
|
7
|
+
export function isConvenienceBlock(block) {
|
|
8
|
+
return (block?.type === "image" ||
|
|
9
|
+
(block?.type === "tool_use" && block?.name === "TodoWrite"));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Split `content` into [core, convenience] block subsets using `isConvenienceBlock`. A non-array /
|
|
13
|
+
* string `content` (the bare-string `toAcpNotifications` fast-path) has no per-block convenience
|
|
14
|
+
* surface, so it is treated wholly as CORE (returned via the core subset, empty convenience subset).
|
|
15
|
+
*/
|
|
16
|
+
function partitionBlocks(content) {
|
|
17
|
+
if (typeof content === "string" || !Array.isArray(content)) {
|
|
18
|
+
return { core: content, convenience: [] };
|
|
19
|
+
}
|
|
20
|
+
const core = [];
|
|
21
|
+
const convenience = [];
|
|
22
|
+
for (const block of content) {
|
|
23
|
+
if (isConvenienceBlock(block)) {
|
|
24
|
+
convenience.push(block);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
core.push(block);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { core, convenience };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Keep only the convenience updates whose surface flag is on. A `plan` update survives iff
|
|
34
|
+
* `flags.plan`; an `image`-content update survives iff `flags.image`. Anything else the convenience
|
|
35
|
+
* pass might emit is dropped (the convenience subset only ever contains image/TodoWrite blocks).
|
|
36
|
+
*/
|
|
37
|
+
function filterConvenienceUpdates(updates, flags) {
|
|
38
|
+
return updates.filter((n) => {
|
|
39
|
+
const update = n?.update;
|
|
40
|
+
if (update?.sessionUpdate === "plan") {
|
|
41
|
+
return flags.plan;
|
|
42
|
+
}
|
|
43
|
+
if (update?.content?.type === "image") {
|
|
44
|
+
return flags.image;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Translate `content` with the plan/image convenience surface gated behind a best-effort flag.
|
|
51
|
+
*
|
|
52
|
+
* CORE pass (always, never gated): the non-convenience blocks are translated via the REAL
|
|
53
|
+
* `toAcpNotifications` (`registerHooks: false` to stay synchronous, mirroring the §7 seam tests). A
|
|
54
|
+
* convenience failure can NEVER touch these updates — they are produced before the gated pass runs.
|
|
55
|
+
*
|
|
56
|
+
* CONVENIENCE pass (flag-gated + try/catch): only when at least one surface flag is on AND there is
|
|
57
|
+
* at least one convenience block, the convenience subset is translated (via the injectable
|
|
58
|
+
* `opts.translate`, default the real translator) inside a try/catch. Surviving updates are filtered
|
|
59
|
+
* to the enabled surfaces and appended after the core updates. On a thrown error OR an unusable
|
|
60
|
+
* (non-array) result, a drift note is logged via `logger.error(...)` and that surface is SKIPPED
|
|
61
|
+
* (nothing appended) — the error is NEVER rethrown, so the live thread is never broken.
|
|
62
|
+
*
|
|
63
|
+
* Signature mirrors `toAcpNotifications`'s real parameter list/types (acp-agent.ts:2866), with the
|
|
64
|
+
* required `flags` and the optional `opts.translate` test seam appended.
|
|
65
|
+
*/
|
|
66
|
+
export function translateBestEffort(content, role, sessionId, cache, client, logger, flags, opts) {
|
|
67
|
+
const { core, convenience } = partitionBlocks(content);
|
|
68
|
+
// CORE pass — ALWAYS the real translator, ALWAYS emitted. Outside any try/catch so a convenience
|
|
69
|
+
// failure provably cannot affect it.
|
|
70
|
+
const coreUpdates = toAcpNotifications(core, role, sessionId, cache, client, logger, {
|
|
71
|
+
registerHooks: false,
|
|
72
|
+
});
|
|
73
|
+
// CONVENIENCE pass — gated by the flags AND the presence of convenience blocks. Skipped entirely
|
|
74
|
+
// when both surfaces are off or there is nothing convenient to translate.
|
|
75
|
+
const anyFlagOn = flags.plan || flags.image;
|
|
76
|
+
if (!anyFlagOn || convenience.length === 0) {
|
|
77
|
+
return coreUpdates;
|
|
78
|
+
}
|
|
79
|
+
const translate = opts?.translate ?? toAcpNotifications;
|
|
80
|
+
try {
|
|
81
|
+
const raw = translate(convenience, role, sessionId, cache, client, logger, {
|
|
82
|
+
registerHooks: false,
|
|
83
|
+
});
|
|
84
|
+
// Guard against an unusable result (e.g. a stub returning undefined/non-array): treat as a
|
|
85
|
+
// best-effort miss rather than letting a downstream `.filter` throw.
|
|
86
|
+
if (!Array.isArray(raw)) {
|
|
87
|
+
logger.error(`[claude-agent-acp] best-effort convenience translation yielded an unusable (non-array) result for session ${sessionId}; skipping the plan/image surface`);
|
|
88
|
+
return coreUpdates;
|
|
89
|
+
}
|
|
90
|
+
const convenienceUpdates = filterConvenienceUpdates(raw, flags);
|
|
91
|
+
// Core first, then the enabled convenience surfaces appended (ordering trade-off above).
|
|
92
|
+
return [...coreUpdates, ...convenienceUpdates];
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// CONTAINMENT: a thrown convenience translation is caught here, logged as a drift note, and
|
|
96
|
+
// swallowed. The core stream (already computed) is returned intact — the turn/thread survives.
|
|
97
|
+
logger.error(`[claude-agent-acp] best-effort convenience translation threw for session ${sessionId}; skipping the plan/image surface (thread preserved): ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
return coreUpdates;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §10 SUBSCRIPTION set (OK) — client labels that bill on the Claude subscription.
|
|
3
|
+
* Source: doc §10, `claude` binary 2.1.159.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SUBSCRIPTION_ENTRYPOINTS: readonly ["cli", "claude-vscode", "vscode", "jetbrains", "firstParty", "local-agent"];
|
|
6
|
+
/**
|
|
7
|
+
* §10 CREDIT set (ABORT) — client labels that bill on API credit (Agent SDK / headless `-p`).
|
|
8
|
+
* Source: doc §10, `claude` binary 2.1.159.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CREDIT_ENTRYPOINTS: readonly ["sdk-ts", "sdk-py", "sdk-cli", "print"];
|
|
11
|
+
export type EntrypointClass = "subscription" | "credit" | "unknown";
|
|
12
|
+
/**
|
|
13
|
+
* Classify a client-emitted `entrypoint` self-label into its §10 billing class.
|
|
14
|
+
*
|
|
15
|
+
* @param entrypoint the raw `entrypoint` value read from a message event.
|
|
16
|
+
* @returns `'subscription'` for any label in {@link SUBSCRIPTION_ENTRYPOINTS}; `'credit'` for any
|
|
17
|
+
* label in {@link CREDIT_ENTRYPOINTS}; `'unknown'` for any other label (conservative — NEVER
|
|
18
|
+
* classifies an unrecognized label as `'subscription'`).
|
|
19
|
+
*/
|
|
20
|
+
export declare function classifyEntrypoint(entrypoint: string): EntrypointClass;
|
|
21
|
+
/**
|
|
22
|
+
* Structural shape of one watched message event. Mirrors the story-015 watcher's
|
|
23
|
+
* `SessionMessage` (`{ uuid?: string; [k: string]: unknown }`) plus the two fields this guard
|
|
24
|
+
* reads. Kept LOCAL & structural on purpose — importing `engine-watcher` would pull in node-pty
|
|
25
|
+
* and friends, and this module must stay light and pure.
|
|
26
|
+
*/
|
|
27
|
+
export interface WatchedMessage {
|
|
28
|
+
type?: string;
|
|
29
|
+
entrypoint?: string;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
[k: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The decision {@link inspectEvent} returns for one watched event:
|
|
35
|
+
* - `skip` — a lightweight (non-message) type; nothing to classify.
|
|
36
|
+
* - `allow` — an `assistant`/`user` event whose entrypoint is in the §10 SUBSCRIPTION set.
|
|
37
|
+
* - `abort` — an `assistant`/`user` event whose entrypoint is credit OR unknown (incl. missing).
|
|
38
|
+
*/
|
|
39
|
+
export type GuardDecision = {
|
|
40
|
+
action: "skip";
|
|
41
|
+
} | {
|
|
42
|
+
action: "allow";
|
|
43
|
+
entrypoint: string;
|
|
44
|
+
entrypointClass: "subscription";
|
|
45
|
+
} | {
|
|
46
|
+
action: "abort";
|
|
47
|
+
entrypoint: string;
|
|
48
|
+
entrypointClass: "credit" | "unknown";
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Pure decision for one watched message event (see module note). Does not mutate `event` and has
|
|
52
|
+
* no side effects.
|
|
53
|
+
*
|
|
54
|
+
* @param event one event delivered by the story-015 watcher.
|
|
55
|
+
* @returns `{ action: 'skip' }` for non-message (lightweight) types; otherwise an `allow`/`abort`
|
|
56
|
+
* decision carrying the read `entrypoint` and its §10 class. A missing/empty `.entrypoint` on an
|
|
57
|
+
* `assistant`/`user` event classifies as `'unknown'` → `abort` (never allowed).
|
|
58
|
+
*/
|
|
59
|
+
export declare function inspectEvent(event: WatchedMessage): GuardDecision;
|
|
60
|
+
/**
|
|
61
|
+
* Side-effect sink injected into {@link guardEvent}. Kept as an interface (not real I/O) so the guard
|
|
62
|
+
* is pure w.r.t. globals and testable; story 023 wires these to the engine-lifecycle stop.
|
|
63
|
+
*/
|
|
64
|
+
export interface GuardHooks {
|
|
65
|
+
/** Loud, unambiguous alert sink. The message MUST carry the offending entrypoint value AND session id. */
|
|
66
|
+
alert(message: string): void;
|
|
67
|
+
/** Stop the session (wired to engine-lifecycle stop in story 023). Invoked once per violation. */
|
|
68
|
+
stopSession(info: {
|
|
69
|
+
entrypoint: string;
|
|
70
|
+
sessionId?: string;
|
|
71
|
+
entrypointClass: "credit" | "unknown";
|
|
72
|
+
}): void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Apply {@link inspectEvent}'s decision for one watched event against the injected {@link GuardHooks}.
|
|
76
|
+
*
|
|
77
|
+
* @param event one event delivered by the story-015 watcher. NEVER mutated.
|
|
78
|
+
* @param hooks the alert/stop sink (the ONLY channel for side effects).
|
|
79
|
+
* @returns the same {@link GuardDecision} `inspectEvent` produced. On `'abort'` (credit OR unknown),
|
|
80
|
+
* first {@link GuardHooks.alert | alerts} with a loud message carrying the offending entrypoint value
|
|
81
|
+
* AND session id, then {@link GuardHooks.stopSession | stops the session}; on `'allow'`/`'skip'` it
|
|
82
|
+
* returns with no side effect. Does NOT mutate, default, or overwrite `event.entrypoint` (§10).
|
|
83
|
+
*/
|
|
84
|
+
export declare function guardEvent(event: WatchedMessage, hooks: GuardHooks): GuardDecision;
|
|
85
|
+
/**
|
|
86
|
+
* Fail-fast assertion that NO billing-sensitive env key survived into the env about to be passed to
|
|
87
|
+
* spawn (§10 post-condition for {@link buildSanitizedEnv}). Read-only: never deletes or mutates `env`.
|
|
88
|
+
*
|
|
89
|
+
* @param env the spawn env (process-env shape — values may be `undefined`).
|
|
90
|
+
* @throws {Error} if any key is present (value not `undefined`) that is exactly
|
|
91
|
+
* `CLAUDE_CODE_ENTRYPOINT` OR starts with `CLAUDE_AGENT_SDK_`. The message NAMES every leaked key
|
|
92
|
+
* and states the spawn must be refused (these would silently bill the run as API credit — R1, §10).
|
|
93
|
+
* A key set to `undefined` is treated as absent (not a leak). `CLAUDECODE` is NOT checked here (it
|
|
94
|
+
* is the spawn helper's anti-recusa concern, distinct from this billing check — §10).
|
|
95
|
+
*/
|
|
96
|
+
export declare function assertCleanBillingEnv(env: Record<string, string | undefined>): void;
|
|
97
|
+
//# sourceMappingURL=entrypoint-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entrypoint-guard.d.ts","sourceRoot":"","sources":["../../src/billing/entrypoint-guard.ts"],"names":[],"mappings":"AAsCA;;;GAGG;AACH,eAAO,MAAM,wBAAwB,uFAO3B,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,kBAAkB,mDAKrB,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEpE;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAQtE;AAeD;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,cAAc,CAAA;CAAE,GACxE;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,QAAQ,GAAG,SAAS,CAAA;CAAE,CAAC;AAEnF;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,aAAa,CAUjE;AAkBD;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,0GAA0G;IAC1G,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,kGAAkG;IAClG,WAAW,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,QAAQ,GAAG,SAAS,CAAA;KAAE,GAAG,IAAI,CAAC;CAC5G;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,GAAG,aAAa,CAkBlF;AAuBD;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,IAAI,CAenF"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Story 022 — entrypoint billing guard-rail.
|
|
2
|
+
//
|
|
3
|
+
// ── GOOD-FAITH LIMITS (this guard-rail is a SIGNAL, never a lever) ─────────────────────────────────
|
|
4
|
+
// Sources: doc §10 "Auditoria de billing — entrypoint"; §0 TL;DR point 3; Appendix row
|
|
5
|
+
// "`entrypoint` como prova de billing"; experiments/DEGRAU0-RESULTS.md (E1).
|
|
6
|
+
//
|
|
7
|
+
// (a) `entrypoint` is a CLIENT SELF-LABEL, written verbatim from an env var without checking the
|
|
8
|
+
// real mode. It is a GOOD-FAITH SIGNAL — NOT contractual proof that the run billed on the Claude
|
|
9
|
+
// subscription, and NOT a contract published by Anthropic. Billing is decided server-side by the
|
|
10
|
+
// headers the client sends; we do not capture TLS, so this field corroborates, it does not prove.
|
|
11
|
+
// (b) This code NEVER forces or spoofs `entrypoint=cli`. It only READS and REACTS — rewriting the
|
|
12
|
+
// value toward 'cli' would be spoofing-with-enforcement (the OpenClaw case) = evasion. `guardEvent`
|
|
13
|
+
// leaves the observed value untouched and aborts on a credit-class label instead.
|
|
14
|
+
// (c) The subscription GUARANTEE comes from RUNNING THE REAL TUI with a sanitized env — the E1
|
|
15
|
+
// keystone (experiments/DEGRAU0-RESULTS.md: a clean-env PTY writes `entrypoint=='cli'`
|
|
16
|
+
// ORGANICALLY), with billing decided server-side. This guard-rail only DETECTS a drift into
|
|
17
|
+
// credit (sdk-*/print) after the fact; it must never manufacture the subscription posture.
|
|
18
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// Story 022 / Task 1.1 — subscription/credit taxonomy + classifier for the runtime
|
|
21
|
+
// entrypoint billing guard-rail (doc §10 "Auditoria de billing — entrypoint").
|
|
22
|
+
//
|
|
23
|
+
// WHAT THIS IS: the centralized §10 taxonomy (two frozen label sets) plus `classifyEntrypoint`,
|
|
24
|
+
// a PURE function mapping a client-emitted `entrypoint` self-label to a billing class:
|
|
25
|
+
// - SUBSCRIPTION (OK): the TUI / first-party clients that bill on the Claude subscription.
|
|
26
|
+
// - CREDIT (ABORT): the Agent SDK / headless `-p` clients that bill on API credit.
|
|
27
|
+
// - 'unknown': any label in NEITHER set. It MUST be treated conservatively by the caller
|
|
28
|
+
// (task 2.2) and MUST NEVER fall through to 'subscription' — an unrecognized or newly
|
|
29
|
+
// introduced label is not proof of subscription billing.
|
|
30
|
+
//
|
|
31
|
+
// WHY a centralized, version-noted constant (doc §10): the taxonomy was reverse-engineered from
|
|
32
|
+
// the `claude` binary 2.1.159 and the `entrypoint` field is the client's own self-label (written
|
|
33
|
+
// verbatim from an env var, not a published contract). It may drift between 2.1.x versions, so the
|
|
34
|
+
// sets live in ONE place with the source version cited below — a binary bump shows up in review.
|
|
35
|
+
//
|
|
36
|
+
// SOURCE: doc §10 "Auditoria de billing — entrypoint", reverse-engineered from `claude` binary
|
|
37
|
+
// 2.1.159. If the binary version changes, re-audit these two sets against the new binary.
|
|
38
|
+
/**
|
|
39
|
+
* §10 SUBSCRIPTION set (OK) — client labels that bill on the Claude subscription.
|
|
40
|
+
* Source: doc §10, `claude` binary 2.1.159.
|
|
41
|
+
*/
|
|
42
|
+
export const SUBSCRIPTION_ENTRYPOINTS = [
|
|
43
|
+
"cli",
|
|
44
|
+
"claude-vscode",
|
|
45
|
+
"vscode",
|
|
46
|
+
"jetbrains",
|
|
47
|
+
"firstParty",
|
|
48
|
+
"local-agent",
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* §10 CREDIT set (ABORT) — client labels that bill on API credit (Agent SDK / headless `-p`).
|
|
52
|
+
* Source: doc §10, `claude` binary 2.1.159.
|
|
53
|
+
*/
|
|
54
|
+
export const CREDIT_ENTRYPOINTS = [
|
|
55
|
+
"sdk-ts",
|
|
56
|
+
"sdk-py",
|
|
57
|
+
"sdk-cli",
|
|
58
|
+
"print",
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* Classify a client-emitted `entrypoint` self-label into its §10 billing class.
|
|
62
|
+
*
|
|
63
|
+
* @param entrypoint the raw `entrypoint` value read from a message event.
|
|
64
|
+
* @returns `'subscription'` for any label in {@link SUBSCRIPTION_ENTRYPOINTS}; `'credit'` for any
|
|
65
|
+
* label in {@link CREDIT_ENTRYPOINTS}; `'unknown'` for any other label (conservative — NEVER
|
|
66
|
+
* classifies an unrecognized label as `'subscription'`).
|
|
67
|
+
*/
|
|
68
|
+
export function classifyEntrypoint(entrypoint) {
|
|
69
|
+
if (SUBSCRIPTION_ENTRYPOINTS.includes(entrypoint)) {
|
|
70
|
+
return "subscription";
|
|
71
|
+
}
|
|
72
|
+
if (CREDIT_ENTRYPOINTS.includes(entrypoint)) {
|
|
73
|
+
return "credit";
|
|
74
|
+
}
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Pure decision for one watched message event (see module note). Does not mutate `event` and has
|
|
79
|
+
* no side effects.
|
|
80
|
+
*
|
|
81
|
+
* @param event one event delivered by the story-015 watcher.
|
|
82
|
+
* @returns `{ action: 'skip' }` for non-message (lightweight) types; otherwise an `allow`/`abort`
|
|
83
|
+
* decision carrying the read `entrypoint` and its §10 class. A missing/empty `.entrypoint` on an
|
|
84
|
+
* `assistant`/`user` event classifies as `'unknown'` → `abort` (never allowed).
|
|
85
|
+
*/
|
|
86
|
+
export function inspectEvent(event) {
|
|
87
|
+
if (event.type !== "assistant" && event.type !== "user") {
|
|
88
|
+
return { action: "skip" };
|
|
89
|
+
}
|
|
90
|
+
const entrypoint = event.entrypoint ?? "";
|
|
91
|
+
const entrypointClass = classifyEntrypoint(entrypoint);
|
|
92
|
+
if (entrypointClass === "subscription") {
|
|
93
|
+
return { action: "allow", entrypoint, entrypointClass };
|
|
94
|
+
}
|
|
95
|
+
return { action: "abort", entrypoint, entrypointClass };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Apply {@link inspectEvent}'s decision for one watched event against the injected {@link GuardHooks}.
|
|
99
|
+
*
|
|
100
|
+
* @param event one event delivered by the story-015 watcher. NEVER mutated.
|
|
101
|
+
* @param hooks the alert/stop sink (the ONLY channel for side effects).
|
|
102
|
+
* @returns the same {@link GuardDecision} `inspectEvent` produced. On `'abort'` (credit OR unknown),
|
|
103
|
+
* first {@link GuardHooks.alert | alerts} with a loud message carrying the offending entrypoint value
|
|
104
|
+
* AND session id, then {@link GuardHooks.stopSession | stops the session}; on `'allow'`/`'skip'` it
|
|
105
|
+
* returns with no side effect. Does NOT mutate, default, or overwrite `event.entrypoint` (§10).
|
|
106
|
+
*/
|
|
107
|
+
export function guardEvent(event, hooks) {
|
|
108
|
+
const decision = inspectEvent(event);
|
|
109
|
+
if (decision.action !== "abort") {
|
|
110
|
+
// 'allow' (subscription) and 'skip' (lightweight) continue with no side effect.
|
|
111
|
+
return decision;
|
|
112
|
+
}
|
|
113
|
+
const message = `[billing §10] BLOCKED ${decision.entrypointClass}-class entrypoint "${decision.entrypoint}" ` +
|
|
114
|
+
`on session "${event.sessionId ?? "(unknown)"}": this client bills on API credit, not the ` +
|
|
115
|
+
`Claude subscription. Stopping the session to avoid silent credit billing — the entrypoint is ` +
|
|
116
|
+
`NOT being rewritten (forcing it to 'cli' would be evasion).`;
|
|
117
|
+
hooks.alert(message);
|
|
118
|
+
hooks.stopSession({
|
|
119
|
+
entrypoint: decision.entrypoint,
|
|
120
|
+
sessionId: event.sessionId,
|
|
121
|
+
entrypointClass: decision.entrypointClass,
|
|
122
|
+
});
|
|
123
|
+
return decision;
|
|
124
|
+
}
|
|
125
|
+
// Task 3.1 — startup self-check: fail fast on leaked SDK env BEFORE spawn.
|
|
126
|
+
//
|
|
127
|
+
// `assertCleanBillingEnv` is the POST-CONDITION assertion for the spawn helper's env-sanitize
|
|
128
|
+
// (`buildSanitizedEnv` in engine-pty.ts, which DELETES the billing-sensitive keys). It does NOT
|
|
129
|
+
// delete-and-continue — deletion is the helper's job; this is the read-only check that it worked.
|
|
130
|
+
// Inheriting any of these keys from the parent Node/ACP process would silently bill the spawned
|
|
131
|
+
// TUI as API credit (R1, §10) BEFORE any message event is produced, so this must run BEFORE spawn.
|
|
132
|
+
//
|
|
133
|
+
// SCOPE (the billing subset of engine-pty's FORBIDDEN_BILLING_VARS): the exact key
|
|
134
|
+
// `CLAUDE_CODE_ENTRYPOINT` plus the whole `CLAUDE_AGENT_SDK_*` family. The family is matched by
|
|
135
|
+
// PREFIX (not an enumerated list) on purpose: it covers today's `CLAUDE_AGENT_SDK_VERSION` /
|
|
136
|
+
// `CLAUDE_AGENT_SDK_CLIENT_APP` AND catches any future `CLAUDE_AGENT_SDK_*` sibling the helper's
|
|
137
|
+
// fixed deletion list might miss.
|
|
138
|
+
//
|
|
139
|
+
// `CLAUDECODE` is deliberately NOT checked here. It IS one of engine-pty's FORBIDDEN_BILLING_VARS,
|
|
140
|
+
// but it is the spawn helper's ANTI-RECUSA (anti-nesting) concern — it makes the nested `claude` TUI
|
|
141
|
+
// refuse to start — which is distinct from this BILLING check (§10). Folding it in here would
|
|
142
|
+
// conflate the two guard-rails, so it stays out of the collection logic.
|
|
143
|
+
//
|
|
144
|
+
// PURITY: read-only. Does NOT delete keys and does NOT mutate `env`.
|
|
145
|
+
/**
|
|
146
|
+
* Fail-fast assertion that NO billing-sensitive env key survived into the env about to be passed to
|
|
147
|
+
* spawn (§10 post-condition for {@link buildSanitizedEnv}). Read-only: never deletes or mutates `env`.
|
|
148
|
+
*
|
|
149
|
+
* @param env the spawn env (process-env shape — values may be `undefined`).
|
|
150
|
+
* @throws {Error} if any key is present (value not `undefined`) that is exactly
|
|
151
|
+
* `CLAUDE_CODE_ENTRYPOINT` OR starts with `CLAUDE_AGENT_SDK_`. The message NAMES every leaked key
|
|
152
|
+
* and states the spawn must be refused (these would silently bill the run as API credit — R1, §10).
|
|
153
|
+
* A key set to `undefined` is treated as absent (not a leak). `CLAUDECODE` is NOT checked here (it
|
|
154
|
+
* is the spawn helper's anti-recusa concern, distinct from this billing check — §10).
|
|
155
|
+
*/
|
|
156
|
+
export function assertCleanBillingEnv(env) {
|
|
157
|
+
const leaked = Object.keys(env).filter((key) => env[key] !== undefined &&
|
|
158
|
+
(key === "CLAUDE_CODE_ENTRYPOINT" || key.startsWith("CLAUDE_AGENT_SDK_")));
|
|
159
|
+
if (leaked.length === 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`[billing §10] REFUSING TO SPAWN: ${leaked.length} billing-sensitive env key(s) leaked into the ` +
|
|
163
|
+
`spawn env: ${leaked.join(", ")}. These would silently bill the spawned TUI as API credit ` +
|
|
164
|
+
`(R1) instead of the Claude subscription. The spawn-env sanitize must strip them BEFORE spawn — ` +
|
|
165
|
+
`aborting rather than launch a credit-billed run.`);
|
|
166
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an absolute path to an executable subscription `claude` binary.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. `claude` found on `process.env.PATH` (which-style scan).
|
|
6
|
+
* 2. The documented native-binary path under the vscode extension.
|
|
7
|
+
*
|
|
8
|
+
* @returns absolute path to an executable `claude`.
|
|
9
|
+
* @throws Error naming both attempted locations if neither is executable.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveClaudePath(): string;
|
|
12
|
+
//# sourceMappingURL=claude-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claude-path.d.ts","sourceRoot":"","sources":["../src/claude-path.ts"],"names":[],"mappings":"AAsCA;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAyB1C"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Resolves the subscription `claude` binary from PATH (story 012, R1/R1.2).
|
|
2
|
+
//
|
|
3
|
+
// The fork must drive the user's *subscription* claude (resolved from PATH),
|
|
4
|
+
// NOT the binary embedded in @anthropic-ai/claude-agent-sdk — the SDK-embedded
|
|
5
|
+
// path bills as credit, while the PATH `claude` bills as signature
|
|
6
|
+
// (`entrypoint == 'cli'`, the E1 keystone — experiments/DEGRAU0-RESULTS.md).
|
|
7
|
+
// This helper is ported 1:1 from the E1-validated experiments/lib/claude-path.ts,
|
|
8
|
+
// preserving its resolution order: PATH first, then the documented native-binary
|
|
9
|
+
// fallback (IMPLEMENTACAO-FORK-ACP §17), else throw naming BOTH attempted
|
|
10
|
+
// locations (R1.3).
|
|
11
|
+
//
|
|
12
|
+
// Dependency-free on purpose: only node: builtins (FORK.md pins node-pty + the
|
|
13
|
+
// SDK as the only runtime deps; this adds none).
|
|
14
|
+
import { accessSync, constants } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { delimiter, join } from "node:path";
|
|
17
|
+
// Documented native-binary fallback (IMPLEMENTACAO-FORK-ACP §17), for claude
|
|
18
|
+
// 2.1.159 (frozen base doc anchor, §3). `~` is expanded via os.homedir.
|
|
19
|
+
const FALLBACK_RELATIVE_PATH = join(".vscode", "extensions", "anthropic.claude-code-2.1.159-linux-x64", "resources", "native-binary", "claude");
|
|
20
|
+
/** True if `p` exists and is executable by the current process. */
|
|
21
|
+
function isExecutable(p) {
|
|
22
|
+
try {
|
|
23
|
+
accessSync(p, constants.X_OK);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve an absolute path to an executable subscription `claude` binary.
|
|
32
|
+
*
|
|
33
|
+
* Resolution order:
|
|
34
|
+
* 1. `claude` found on `process.env.PATH` (which-style scan).
|
|
35
|
+
* 2. The documented native-binary path under the vscode extension.
|
|
36
|
+
*
|
|
37
|
+
* @returns absolute path to an executable `claude`.
|
|
38
|
+
* @throws Error naming both attempted locations if neither is executable.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveClaudePath() {
|
|
41
|
+
// 1. PATH lookup: scan each PATH entry for an executable `claude`.
|
|
42
|
+
const pathEnv = process.env.PATH ?? "";
|
|
43
|
+
const pathEntries = pathEnv.split(delimiter).filter((entry) => entry.length > 0);
|
|
44
|
+
for (const entry of pathEntries) {
|
|
45
|
+
const candidate = join(entry, "claude");
|
|
46
|
+
if (isExecutable(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 2. Documented native-binary fallback (~ expanded via os.homedir).
|
|
51
|
+
const fallback = join(homedir(), FALLBACK_RELATIVE_PATH);
|
|
52
|
+
if (isExecutable(fallback)) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
// 3. Neither exists: throw naming BOTH attempted locations.
|
|
56
|
+
throw new Error(`Could not resolve an executable "claude" binary. Tried: ` +
|
|
57
|
+
`(1) PATH lookup over ${pathEntries.length} entr${pathEntries.length === 1 ? "y" : "ies"} ` +
|
|
58
|
+
`("${pathEnv}"), and ` +
|
|
59
|
+
`(2) the documented native-binary fallback "${fallback}". ` +
|
|
60
|
+
`Install the Claude Code subscription CLI or ensure "claude" is on PATH.`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GetMessages } from "./engine-watcher.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build a `uuid → toolUseResult` index from raw JSONL transcript lines.
|
|
4
|
+
*
|
|
5
|
+
* Only records that ARE plain objects carrying BOTH a string `uuid` AND a defined `toolUseResult`
|
|
6
|
+
* enter the map — a record without a `toolUseResult` is intentionally absent so the matching message is
|
|
7
|
+
* returned unchanged (R1.2). Empty/whitespace lines and malformed JSON are skipped silently (robustness:
|
|
8
|
+
* a torn or partially-written final line must never throw and abort the hydration).
|
|
9
|
+
*
|
|
10
|
+
* @param rawLines the transcript's newline-split lines (READ-ONLY; never mutated).
|
|
11
|
+
* @returns a map from message uuid to its raw `toolUseResult` value (possibly empty).
|
|
12
|
+
*/
|
|
13
|
+
export declare function toolUseResultMap(rawLines: string[]): Map<string, unknown>;
|
|
14
|
+
/** Construction options for {@link createDiffEnrichedReader} — both seams injectable for tests. */
|
|
15
|
+
export interface DiffEnrichedReaderOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Locate the session's transcript file(s). Defaults to the in-fork {@link defaultFindTranscript}
|
|
18
|
+
* (a billing-free recursive glob of `<sessionId>.jsonl` under `~/.claude/projects`). First match.
|
|
19
|
+
*/
|
|
20
|
+
findTranscript?: (sessionId: string) => string[];
|
|
21
|
+
/**
|
|
22
|
+
* Read a transcript file into its newline-split lines. Defaults to a synchronous
|
|
23
|
+
* `readFileSync(path,'utf8').split('\n')`. Injected so tests can stub the raw read (and simulate an
|
|
24
|
+
* unreadable transcript for the R1.3 fallback) without touching the filesystem.
|
|
25
|
+
*/
|
|
26
|
+
readRawLines?: (path: string) => string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wrap a base `getSessionMessages`-shaped reader so each returned message gains its `toolUseResult`
|
|
30
|
+
* from the raw transcript, matched by `uuid`. This is PURE hydration: ordering and identity come from
|
|
31
|
+
* `base`; only the top-level `toolUseResult` key is added (exactly where the diff block reads it,
|
|
32
|
+
* `turn.message.toolUseResult`). Billing-free — the only extra cost is one `fs` read of the transcript
|
|
33
|
+
* located from the `sessionId` (R4.2). On ANY locate/read/parse failure, or when no record carries a
|
|
34
|
+
* `toolUseResult`, the plain reduced messages are returned unchanged (R1.3) and NO patch is fabricated.
|
|
35
|
+
*
|
|
36
|
+
* @param base the underlying reader (the SDK `getSessionMessages`, or an injected stub).
|
|
37
|
+
* @param opts injectable {@link DiffEnrichedReaderOptions} seams (defaults are production behaviour).
|
|
38
|
+
* @returns a {@link GetMessages} that yields the base messages with `toolUseResult` hydrated by uuid.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createDiffEnrichedReader(base: GetMessages, opts?: DiffEnrichedReaderOptions): GetMessages;
|
|
41
|
+
//# sourceMappingURL=diff-enriched-reader.d.ts.map
|