@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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing helper: a non-null plain object (mirrors ./jsonl.ts `isObject`). Arrays and `null` are
|
|
3
|
+
* excluded so we can read string-keyed props safely.
|
|
4
|
+
*/
|
|
5
|
+
function isObject(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Default drift sink: log the record to STDERR via `console.error`. NEVER `console.log`/stdout —
|
|
10
|
+
* stdout is the ACP protocol channel (see ./index.ts, which redirects all `console.*` to stderr for
|
|
11
|
+
* exactly this reason). Used when the caller supplies no `onDrift`. Logging-only: billing-free and
|
|
12
|
+
* side-effect-free beyond the log line.
|
|
13
|
+
*/
|
|
14
|
+
function defaultDriftSink(record) {
|
|
15
|
+
console.error(`[jsonl-drift] ${JSON.stringify({
|
|
16
|
+
kind: record.kind,
|
|
17
|
+
type: record.type,
|
|
18
|
+
fields: record.fields,
|
|
19
|
+
version: record.version,
|
|
20
|
+
})}`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The documented top-level keys of {@link JsonlEvent} (the universal fields + per-type extras).
|
|
24
|
+
* Used ONLY for KEY-drift detection — naming top-level fields that are NOT in this set. This is a
|
|
25
|
+
* SEPARATE concern from the type-routing `switch`, whose arms stay explicit and individually
|
|
26
|
+
* removable; this Set is NOT a `KNOWN_TYPES.has(type)` membership shortcut and never gates routing.
|
|
27
|
+
*/
|
|
28
|
+
const KNOWN_EVENT_FIELDS = new Set([
|
|
29
|
+
"uuid",
|
|
30
|
+
"type",
|
|
31
|
+
"timestamp",
|
|
32
|
+
"sessionId",
|
|
33
|
+
"message",
|
|
34
|
+
"parentToolUseId",
|
|
35
|
+
"userType",
|
|
36
|
+
"parentUuid",
|
|
37
|
+
"cwd",
|
|
38
|
+
"gitBranch",
|
|
39
|
+
"version",
|
|
40
|
+
"isSidechain",
|
|
41
|
+
"entrypoint",
|
|
42
|
+
"requestId",
|
|
43
|
+
"promptId",
|
|
44
|
+
"toolUseResult",
|
|
45
|
+
"permissionMode",
|
|
46
|
+
]);
|
|
47
|
+
/**
|
|
48
|
+
* The event's top-level keys that are NOT in {@link KNOWN_EVENT_FIELDS} — the "unrecognised" fields.
|
|
49
|
+
* A non-object event yields `[]` (nothing to inspect).
|
|
50
|
+
*
|
|
51
|
+
* @param event the typed event (or any value — non-objects yield no fields).
|
|
52
|
+
* @returns the unrecognised top-level key names (empty when all keys are documented).
|
|
53
|
+
*/
|
|
54
|
+
function unrecognisedFields(event) {
|
|
55
|
+
if (!isObject(event))
|
|
56
|
+
return [];
|
|
57
|
+
return Object.keys(event).filter((key) => !KNOWN_EVENT_FIELDS.has(key));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Field-level drift for a KNOWN, routed type: if the event carries unrecognised top-level field(s),
|
|
61
|
+
* emit ONE `unknown-fields` drift record naming them. Routing is UNCHANGED — this is observational
|
|
62
|
+
* only. A fully-documented event emits nothing. Called from every documented arm (via {@link
|
|
63
|
+
* classifyContent} / {@link classifyLifecycle}) so the check covers all routed types in one place.
|
|
64
|
+
*
|
|
65
|
+
* @param event the routed typed event.
|
|
66
|
+
* @param onDrift the resolved drift sink.
|
|
67
|
+
*/
|
|
68
|
+
function reportFieldDrift(event, onDrift) {
|
|
69
|
+
const fields = unrecognisedFields(event);
|
|
70
|
+
if (fields.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
onDrift({ kind: "unknown-fields", type: event.type, fields, version: event.version, event });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Content normaliser for the content path: produce a UNIFORM block array so every downstream branch
|
|
76
|
+
* (and the §7 translators of story 018) can `.map`/index over content without ever tripping on the
|
|
77
|
+
* raw-string form documented in §6. Three shapes:
|
|
78
|
+
* - ARRAY → returned VERBATIM (same reference; arbitrary block fields preserved untouched, which
|
|
79
|
+
* Task 1.4's block typing relies on). We never mutate it.
|
|
80
|
+
* - STRING → wrapped as a single `[{ type: 'text', text: <string> }]` block (a FRESH array, so the
|
|
81
|
+
* input event is left untouched).
|
|
82
|
+
* - absent / anything else → `[]`.
|
|
83
|
+
* This is the SINGLE source for both the `assistant` and `user` content arms, applied BEFORE any
|
|
84
|
+
* block-type inspection so a string `message.content` can never throw an indexing/`.map` (Task 1.3).
|
|
85
|
+
*
|
|
86
|
+
* @param message the API message object (or any value — non-matching shapes yield `[]`).
|
|
87
|
+
* @returns the normalised {@link ContentBlock} array (string → one text block; non-array/non-string → `[]`).
|
|
88
|
+
*/
|
|
89
|
+
function normaliseContent(message) {
|
|
90
|
+
if (!isObject(message))
|
|
91
|
+
return [];
|
|
92
|
+
const content = message.content;
|
|
93
|
+
// The array passes through UNCHANGED (no mutation). `Array.isArray` only narrows to `unknown[]`,
|
|
94
|
+
// so we assert to `ContentBlock[]`: the open catch-all union member (`{type,...}`) makes any block
|
|
95
|
+
// structurally assignable, so this cast is sound and PRESERVES arbitrary extras (caller, tool_reference).
|
|
96
|
+
if (Array.isArray(content))
|
|
97
|
+
return content;
|
|
98
|
+
// A raw string becomes a single TextBlock (a FRESH array; the input event is left untouched).
|
|
99
|
+
if (typeof content === "string")
|
|
100
|
+
return [{ type: "text", text: content }];
|
|
101
|
+
return []; // absent / non-string / non-array
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build the content-path classification for a content-bearing event. Factored out so both the
|
|
105
|
+
* `assistant` and `user` arms route through one place and the extraction stays naive-but-isolated.
|
|
106
|
+
* Surfaces field-level drift (via {@link reportFieldDrift}) WITHOUT altering routing.
|
|
107
|
+
*/
|
|
108
|
+
function classifyContent(type, event, onDrift) {
|
|
109
|
+
// Field-drift is observational and must not change the result — report before building it.
|
|
110
|
+
reportFieldDrift(event, onDrift);
|
|
111
|
+
return {
|
|
112
|
+
class: "content",
|
|
113
|
+
type,
|
|
114
|
+
// For `assistant` / `user`, the message role equals the event type.
|
|
115
|
+
role: type,
|
|
116
|
+
content: normaliseContent(event.message),
|
|
117
|
+
event,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build the lifecycle-path classification for a recognised non-content event. Surfaces field-level
|
|
122
|
+
* drift (via {@link reportFieldDrift}) WITHOUT altering routing.
|
|
123
|
+
*/
|
|
124
|
+
function classifyLifecycle(event, onDrift) {
|
|
125
|
+
reportFieldDrift(event, onDrift);
|
|
126
|
+
return { class: "lifecycle", type: event.type, event };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Route a story-015 typed {@link JsonlEvent} by its `.type` to the appropriate classification.
|
|
130
|
+
*
|
|
131
|
+
* The two content-bearing types are dispatched to the content-block path consumed by the §7
|
|
132
|
+
* translators; the 13 documented lifecycle/metadata types (`system`, `result`, `started`, `mode`,
|
|
133
|
+
* `permission-mode`, `file-history-snapshot`, `attachment`, `queue-operation`, `last-prompt`,
|
|
134
|
+
* `ai-title`, `pr-link`, `agent-name`, and the post-`/compact` `summary`) are classified as
|
|
135
|
+
* recognised, NON-content events that neither crash nor reach a content translator. Every documented
|
|
136
|
+
* type has its OWN explicit `case` arm (no membership check) so drift against the DEFAULT branch is
|
|
137
|
+
* observable.
|
|
138
|
+
*
|
|
139
|
+
* No custom parsing is performed — the event already arrives typed from story 015's `projectEvent`;
|
|
140
|
+
* this function ONLY dispatches on `event.type`.
|
|
141
|
+
*
|
|
142
|
+
* Drift telemetry (R1.2, R3, R6): an UNRECOGNISED `.type` is a LOGGED, NON-FATAL drop — it emits an
|
|
143
|
+
* `unknown-type` record through `onDrift`, then returns a `drift` classification WITHOUT throwing and
|
|
144
|
+
* WITHOUT producing any ACP SessionUpdate. A KNOWN, routed type carrying unexpected top-level field(s)
|
|
145
|
+
* additionally emits an `unknown-fields` record (its routing is UNCHANGED). When `opts.onDrift` is
|
|
146
|
+
* omitted, drift is logged to STDERR via {@link defaultDriftSink}. Telemetry is billing-free and
|
|
147
|
+
* side-effect-free beyond logging, so the read path never assumes a fixed `.type` set.
|
|
148
|
+
*
|
|
149
|
+
* @param event the typed event projected by story 015's `projectEvent`.
|
|
150
|
+
* @param opts injectable `onDrift` seam; defaults to the stderr {@link defaultDriftSink}.
|
|
151
|
+
* @returns the {@link EventClassification} for the event.
|
|
152
|
+
*/
|
|
153
|
+
export function classifyEvent(event, opts = {}) {
|
|
154
|
+
// Resolve the drift sink once: caller-supplied seam, else the stderr default (never stdout).
|
|
155
|
+
const onDrift = opts.onDrift ?? defaultDriftSink;
|
|
156
|
+
switch (event.type) {
|
|
157
|
+
// --- Content-bearing types → the §7 translator content-block path -------------------------
|
|
158
|
+
case "assistant":
|
|
159
|
+
return classifyContent("assistant", event, onDrift);
|
|
160
|
+
case "user":
|
|
161
|
+
return classifyContent("user", event, onDrift);
|
|
162
|
+
// --- Lifecycle / metadata types → recognised, classified, NON-content ---------------------
|
|
163
|
+
// Each is an EXPLICIT, individually-removable `case` label (NOT a membership check) so that
|
|
164
|
+
// drift against the DEFAULT branch stays observable. The post-`/compact` `summary` is included.
|
|
165
|
+
case "system":
|
|
166
|
+
return classifyLifecycle(event, onDrift);
|
|
167
|
+
case "result":
|
|
168
|
+
return classifyLifecycle(event, onDrift);
|
|
169
|
+
case "started":
|
|
170
|
+
return classifyLifecycle(event, onDrift);
|
|
171
|
+
case "mode":
|
|
172
|
+
return classifyLifecycle(event, onDrift);
|
|
173
|
+
case "permission-mode":
|
|
174
|
+
return classifyLifecycle(event, onDrift);
|
|
175
|
+
case "file-history-snapshot":
|
|
176
|
+
return classifyLifecycle(event, onDrift);
|
|
177
|
+
case "attachment":
|
|
178
|
+
return classifyLifecycle(event, onDrift);
|
|
179
|
+
case "queue-operation":
|
|
180
|
+
return classifyLifecycle(event, onDrift);
|
|
181
|
+
case "last-prompt":
|
|
182
|
+
return classifyLifecycle(event, onDrift);
|
|
183
|
+
case "ai-title":
|
|
184
|
+
return classifyLifecycle(event, onDrift);
|
|
185
|
+
case "pr-link":
|
|
186
|
+
return classifyLifecycle(event, onDrift);
|
|
187
|
+
case "agent-name":
|
|
188
|
+
return classifyLifecycle(event, onDrift);
|
|
189
|
+
case "summary":
|
|
190
|
+
return classifyLifecycle(event, onDrift);
|
|
191
|
+
// --- DEFAULT (drift) ----------------------------------------------------------------------
|
|
192
|
+
// An UNRECOGNISED type: a type added in a future `claude` 2.1.x. Emit ONE `unknown-type` drift
|
|
193
|
+
// record (naming the new type + any unrecognised top-level fields + version), then return a
|
|
194
|
+
// `drift` classification WITHOUT throwing. A drift contributes nothing downstream — it is neither
|
|
195
|
+
// content nor lifecycle, so the §7 translators never see it and NO ACP SessionUpdate is produced.
|
|
196
|
+
default:
|
|
197
|
+
onDrift({
|
|
198
|
+
kind: "unknown-type",
|
|
199
|
+
type: event.type,
|
|
200
|
+
fields: unrecognisedFields(event),
|
|
201
|
+
version: event.version,
|
|
202
|
+
event,
|
|
203
|
+
});
|
|
204
|
+
return { class: "drift", type: event.type, event };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Bounded retry budget — re-select this many times before failing loudly (R1.4). */
|
|
2
|
+
export declare const DEFAULT_PORT_ATTEMPTS = 10;
|
|
3
|
+
/** Loopback host the gate hook server binds; the hook URL is `http://127.0.0.1:<port>` (story 007). */
|
|
4
|
+
export declare const LOOPBACK_HOST = "127.0.0.1";
|
|
5
|
+
/**
|
|
6
|
+
* Minimal `net`-server surface {@link findFreePort} needs. Factored out as an injectable seam (the
|
|
7
|
+
* same discipline as `AgentDeps.schedule` / `GuardHooks`) so a test can drive bind exhaustion
|
|
8
|
+
* deterministically WITHOUT monkey-patching the frozen `node:net` module namespace (which is
|
|
9
|
+
* read-only under ESM). Defaults to `node:net`'s real `createServer`.
|
|
10
|
+
*/
|
|
11
|
+
export interface PortServer {
|
|
12
|
+
listen(port: number, host: string): void;
|
|
13
|
+
close(cb?: () => void): void;
|
|
14
|
+
address(): {
|
|
15
|
+
port: number;
|
|
16
|
+
} | string | null;
|
|
17
|
+
once(event: "listening" | "error", cb: (...args: unknown[]) => void): void;
|
|
18
|
+
removeListener(event: "listening" | "error", cb: (...args: unknown[]) => void): void;
|
|
19
|
+
}
|
|
20
|
+
/** Factory for a fresh, unbound {@link PortServer}. Injected for tests; defaults to `node:net`. */
|
|
21
|
+
export type ServerFactory = () => PortServer;
|
|
22
|
+
/**
|
|
23
|
+
* Allocate an OS-assigned ephemeral port and PROVE it is actually free by binding `127.0.0.1:<port>`
|
|
24
|
+
* before returning it (global port-in-use rule; R1.1, R1.2). On a bind conflict
|
|
25
|
+
* (`EADDRINUSE`/`EACCES`) the candidate is discarded and another is selected (R1.3). After
|
|
26
|
+
* {@link DEFAULT_PORT_ATTEMPTS} exhausted attempts the promise REJECTS with a diagnostic naming the
|
|
27
|
+
* attempt count and the last bind error rather than returning an unverified port (R1.4).
|
|
28
|
+
*
|
|
29
|
+
* @param attempts bounded retry budget (default {@link DEFAULT_PORT_ATTEMPTS}); each attempt
|
|
30
|
+
* OS-assigns a fresh candidate and re-verifies it binds free.
|
|
31
|
+
* @param makeServer injectable `net`-server factory (defaults to `node:net`'s `createServer`);
|
|
32
|
+
* tests drive bind exhaustion through it without monkey-patching the frozen module.
|
|
33
|
+
* @returns a port number, in the ephemeral range, that bound free on `127.0.0.1` at adoption time.
|
|
34
|
+
* @throws {Error} if no candidate binds free within `attempts` tries — message names the count and
|
|
35
|
+
* the last bind error.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findFreePort(attempts?: number, makeServer?: ServerFactory): Promise<number>;
|
|
38
|
+
//# sourceMappingURL=port.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../src/gate/port.ts"],"names":[],"mappings":"AAsBA,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,uGAAuG;AACvG,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,KAAK,CAAC,EAAE,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC7B,OAAO,IAAI;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,GAAG,IAAI,CAAC;IAC5C,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3E,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;CACtF;AAED,mGAAmG;AACnG,MAAM,MAAM,aAAa,GAAG,MAAM,UAAU,CAAC;AA4C7C;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,YAAY,CAChC,QAAQ,GAAE,MAA8B,EACxC,UAAU,GAAE,aAAoC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAkDjB"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Story 032 / Task 1.1 — dynamic free-port allocator with 127.0.0.1 bind verification (R1).
|
|
2
|
+
//
|
|
3
|
+
// The Degrau-2 permission gate (HYBRID prototype, story 007 / GATE_FINDINGS.md) attaches a
|
|
4
|
+
// `PreToolUse` type:http hook to the real `claude` TUI over **TCP loopback**. That hook needs an
|
|
5
|
+
// endpoint port for the fork's local hook server. Per the global project rule on port-in-use, the
|
|
6
|
+
// port MUST be:
|
|
7
|
+
// - allocated DYNAMICALLY (OS-assigned / ephemeral) — NEVER a hard-coded constant; and
|
|
8
|
+
// - PROVEN free by actually binding `127.0.0.1:<port>` before it is adopted (a stale "looks free"
|
|
9
|
+
// check is not enough — a service could be on the box).
|
|
10
|
+
//
|
|
11
|
+
// STRATEGY (TOCTOU-aware): we OS-assign a candidate by listening on `127.0.0.1:0` (the kernel hands
|
|
12
|
+
// back a currently-free ephemeral port), read it, close that probe, then RE-BIND `127.0.0.1:<port>`
|
|
13
|
+
// to confirm it is still free at adoption time. A small race window remains between the two binds —
|
|
14
|
+
// that is inherent to any "pick then use" scheme — but if the re-bind races a `EADDRINUSE`/`EACCES`
|
|
15
|
+
// we simply re-select another candidate rather than returning an unverified port. After a bounded
|
|
16
|
+
// retry budget we REJECT loudly with the attempt count and the last bind error (R1.4) so the spawn
|
|
17
|
+
// path fails fast instead of hanging on an endless retry loop.
|
|
18
|
+
//
|
|
19
|
+
// OFFLINE: this module binds/closes loopback sockets only; it spawns NO `claude` and bills nothing.
|
|
20
|
+
import { createServer } from "node:net";
|
|
21
|
+
/** Bounded retry budget — re-select this many times before failing loudly (R1.4). */
|
|
22
|
+
export const DEFAULT_PORT_ATTEMPTS = 10;
|
|
23
|
+
/** Loopback host the gate hook server binds; the hook URL is `http://127.0.0.1:<port>` (story 007). */
|
|
24
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
25
|
+
const defaultServerFactory = () => createServer();
|
|
26
|
+
/**
|
|
27
|
+
* Listen on `host:port` and resolve once bound, or reject on the bind error. The caller decides
|
|
28
|
+
* whether to keep the server open (to read its assigned port) or close it. Every error path removes
|
|
29
|
+
* the `listening` handler implicitly via the one-shot promise, and the server is the caller's to
|
|
30
|
+
* close — `listenOnce` itself never leaks a half-open socket because a failed `listen` does not bind.
|
|
31
|
+
*/
|
|
32
|
+
function listenOnce(makeServer, host, port) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const server = makeServer();
|
|
35
|
+
const onError = (...args) => {
|
|
36
|
+
server.removeListener("listening", onListening);
|
|
37
|
+
// A server that failed to listen holds no bound socket; close defensively and surface the error.
|
|
38
|
+
try {
|
|
39
|
+
server.close();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore — nothing was bound
|
|
43
|
+
}
|
|
44
|
+
reject(args[0] ?? new Error("listen failed"));
|
|
45
|
+
};
|
|
46
|
+
const onListening = () => {
|
|
47
|
+
server.removeListener("error", onError);
|
|
48
|
+
resolve(server);
|
|
49
|
+
};
|
|
50
|
+
server.once("error", onError);
|
|
51
|
+
server.once("listening", onListening);
|
|
52
|
+
server.listen(port, host);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Close a probe server, swallowing any close error (a probe must never leak a socket — R1 note). */
|
|
56
|
+
function closeQuietly(server) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
try {
|
|
59
|
+
server.close(() => resolve());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
resolve();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Allocate an OS-assigned ephemeral port and PROVE it is actually free by binding `127.0.0.1:<port>`
|
|
68
|
+
* before returning it (global port-in-use rule; R1.1, R1.2). On a bind conflict
|
|
69
|
+
* (`EADDRINUSE`/`EACCES`) the candidate is discarded and another is selected (R1.3). After
|
|
70
|
+
* {@link DEFAULT_PORT_ATTEMPTS} exhausted attempts the promise REJECTS with a diagnostic naming the
|
|
71
|
+
* attempt count and the last bind error rather than returning an unverified port (R1.4).
|
|
72
|
+
*
|
|
73
|
+
* @param attempts bounded retry budget (default {@link DEFAULT_PORT_ATTEMPTS}); each attempt
|
|
74
|
+
* OS-assigns a fresh candidate and re-verifies it binds free.
|
|
75
|
+
* @param makeServer injectable `net`-server factory (defaults to `node:net`'s `createServer`);
|
|
76
|
+
* tests drive bind exhaustion through it without monkey-patching the frozen module.
|
|
77
|
+
* @returns a port number, in the ephemeral range, that bound free on `127.0.0.1` at adoption time.
|
|
78
|
+
* @throws {Error} if no candidate binds free within `attempts` tries — message names the count and
|
|
79
|
+
* the last bind error.
|
|
80
|
+
*/
|
|
81
|
+
export async function findFreePort(attempts = DEFAULT_PORT_ATTEMPTS, makeServer = defaultServerFactory) {
|
|
82
|
+
if (!Number.isInteger(attempts) || attempts < 1) {
|
|
83
|
+
throw new Error(`findFreePort: attempts must be a positive integer, got ${String(attempts)}`);
|
|
84
|
+
}
|
|
85
|
+
let lastError = null;
|
|
86
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
87
|
+
// 1) OS-assign a currently-free ephemeral port by listening on :0.
|
|
88
|
+
let candidate;
|
|
89
|
+
let probe;
|
|
90
|
+
try {
|
|
91
|
+
probe = await listenOnce(makeServer, LOOPBACK_HOST, 0);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
lastError = err;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const address = probe.address();
|
|
98
|
+
if (address === null || typeof address === "string") {
|
|
99
|
+
// Unexpected (a TCP server should yield an AddressInfo); discard and retry.
|
|
100
|
+
lastError = new Error("findFreePort: OS-assigned listener returned no numeric port");
|
|
101
|
+
await closeQuietly(probe);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
candidate = address.port;
|
|
105
|
+
await closeQuietly(probe);
|
|
106
|
+
// 2) Re-bind the SAME port to PROVE it is still free at adoption time (verification bind).
|
|
107
|
+
let verify;
|
|
108
|
+
try {
|
|
109
|
+
verify = await listenOnce(makeServer, LOOPBACK_HOST, candidate);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// EADDRINUSE / EACCES — the port got taken (or is privileged); re-select another candidate.
|
|
113
|
+
lastError = err;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
await closeQuietly(verify);
|
|
117
|
+
return candidate;
|
|
118
|
+
}
|
|
119
|
+
const detail = lastError instanceof Error
|
|
120
|
+
? `${lastError.message}`
|
|
121
|
+
: lastError === null
|
|
122
|
+
? "no candidate could be OS-assigned"
|
|
123
|
+
: String(lastError);
|
|
124
|
+
throw new Error(`findFreePort: could not obtain a verified-free 127.0.0.1 port after ${attempts} attempt(s); ` +
|
|
125
|
+
`last bind error: ${detail}. Refusing to return an unverified port (global port-in-use rule).`);
|
|
126
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Fixed fork-owned sentinel path segment embedded in the hook URL — the surgical-removal marker. */
|
|
2
|
+
export declare const FORK_HOOK_MARKER_PATH = "/__fork-acp-gate__";
|
|
3
|
+
/** Key stamped on the fork's matcher group for human-readability; the URL path is authoritative. */
|
|
4
|
+
export declare const FORK_HOOK_MARKER_KEY = "__forkAcpGate";
|
|
5
|
+
/** Default hook timeout in SECONDS (claude default is 600); kept a parameter so nothing is magic. */
|
|
6
|
+
export declare const DEFAULT_HOOK_TIMEOUT_SECONDS = 600;
|
|
7
|
+
/** Tool matcher for the gate hook; `"*"` matches all tools (the shape the story-007 probe fired). */
|
|
8
|
+
export declare const FORK_HOOK_MATCHER = "*";
|
|
9
|
+
/** A single http-hook entry as it appears inside a matcher group's `hooks` array (story-007 shape). */
|
|
10
|
+
export interface HttpHookEntry {
|
|
11
|
+
type: "http";
|
|
12
|
+
url: string;
|
|
13
|
+
timeout: number;
|
|
14
|
+
}
|
|
15
|
+
/** The `{ matcher, hooks:[…] }` group as it appears under `PreToolUse`, plus the fork sentinel key. */
|
|
16
|
+
export interface PreToolUseGroup {
|
|
17
|
+
matcher: string;
|
|
18
|
+
hooks: HttpHookEntry[];
|
|
19
|
+
/** Present (and `true`) only on the FORK's own group — never on a user's group. */
|
|
20
|
+
[FORK_HOOK_MARKER_KEY]?: true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the fork's `PreToolUse` `type:http` hook group, pointing at the 127.0.0.1 TCP-loopback
|
|
24
|
+
* endpoint for the dynamically allocated free port (R1 / R2.3). The URL carries the fork-owned
|
|
25
|
+
* sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group.
|
|
26
|
+
*
|
|
27
|
+
* @param port the verified-free port from {@link import("./port.js").findFreePort} — a positive integer.
|
|
28
|
+
* @param timeout hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
|
|
29
|
+
* @returns the `{ matcher, hooks:[{type:"http",…}], __forkAcpGate:true }` group.
|
|
30
|
+
* @throws {Error} if `port` is not a positive integer (a bad port would produce a malformed/ungated
|
|
31
|
+
* hook — fail loud rather than silently emit a hook claude ignores).
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildHookEntry(port: number, timeout?: number): PreToolUseGroup;
|
|
34
|
+
/** True iff `group` is the FORK's own injected hook group (sentinel key OR sentinel URL path). */
|
|
35
|
+
export declare function isForkHookGroup(group: unknown): boolean;
|
|
36
|
+
/** Minimal structural view of a settings document's hooks block (only what the merge touches). */
|
|
37
|
+
export interface SettingsLike {
|
|
38
|
+
hooks?: {
|
|
39
|
+
PreToolUse?: unknown[];
|
|
40
|
+
[event: string]: unknown;
|
|
41
|
+
};
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Merge the fork hook `group` into an existing-or-absent settings object, preserving EVERY
|
|
46
|
+
* pre-existing key/value AND the user's own hooks (R2.1, R2.2, R2.4). Never mutates `prior`.
|
|
47
|
+
*
|
|
48
|
+
* - `prior` null/absent → return a minimal object containing ONLY the fork hook (R2.2).
|
|
49
|
+
* - `prior` present → deep-clone it, then APPEND the fork group to `hooks.PreToolUse`
|
|
50
|
+
* without dropping/overwriting any existing entry (R2.4). An existing
|
|
51
|
+
* fork group (idempotent re-inject) is replaced in place, not duplicated.
|
|
52
|
+
*
|
|
53
|
+
* @param prior the parsed prior settings object, or `null` if the file was absent.
|
|
54
|
+
* @param group the fork hook group from {@link buildHookEntry}.
|
|
55
|
+
* @returns a NEW settings object — a superset of `prior` differing only by the added fork hook.
|
|
56
|
+
* @throws {Error} if `prior` is a non-object (array/scalar) — a corrupt root is surfaced loudly, not
|
|
57
|
+
* silently overwritten (R2.1: a corrupt user file is never clobbered blindly).
|
|
58
|
+
*/
|
|
59
|
+
export declare function mergeHook(prior: SettingsLike | null, group: PreToolUseGroup): SettingsLike;
|
|
60
|
+
/**
|
|
61
|
+
* Parse settings text tolerantly (JSONC — a user may have hand-edited comments/trailing commas) into
|
|
62
|
+
* a {@link SettingsLike} object. Reuses zed-register's `parseJsonc`. A parse failure is surfaced as a
|
|
63
|
+
* clear error (NOT silently swallowed) so a corrupt user file is never clobbered blindly (R2.1).
|
|
64
|
+
*
|
|
65
|
+
* @param text the raw on-disk file contents.
|
|
66
|
+
* @returns the parsed object.
|
|
67
|
+
* @throws {Error} wrapping the underlying parse error with a clear, attributable message.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parsePriorSettings(text: string): SettingsLike;
|
|
70
|
+
/**
|
|
71
|
+
* Captured prior on-disk state of `settings.local.json`, returned by {@link injectHook} and consumed
|
|
72
|
+
* by {@link restore}. Either the exact prior file bytes (`existed:true`) or a record of absence
|
|
73
|
+
* (`existed:false`) — enough to leave the user's config byte-equivalent on teardown (R4.1).
|
|
74
|
+
*/
|
|
75
|
+
export interface Backup {
|
|
76
|
+
/** Absolute path of the settings file this backup covers. */
|
|
77
|
+
settingsPath: string;
|
|
78
|
+
/** Whether the file existed on disk before inject. */
|
|
79
|
+
existed: boolean;
|
|
80
|
+
/** The exact prior file bytes (only when `existed` is true). */
|
|
81
|
+
priorBytes: Buffer | null;
|
|
82
|
+
}
|
|
83
|
+
/** Result of {@link restore}: which removal path ran (`'surgical'` preferred, `'backup'` fallback). */
|
|
84
|
+
export interface RestoreResult {
|
|
85
|
+
path: "surgical" | "backup";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Capture the prior on-disk state, compute the merged settings, and DURABLY write
|
|
89
|
+
* `settings.local.json` BEFORE the claude PTY is spawned (R3.1, R3.2, R4.1).
|
|
90
|
+
*
|
|
91
|
+
* CALLER CONTRACT (BLOCKER c — setup ordering): the spawn path MUST `await injectHook(...)` BEFORE it
|
|
92
|
+
* spawns the `claude` PTY, because `claude` reads hook config only at startup; a write that lands
|
|
93
|
+
* after spawn misses the first tool call. The engine-wiring that enforces this ordering is a separate
|
|
94
|
+
* Degrau-2 story (out of scope here — this module is the standalone seam).
|
|
95
|
+
* TODO(degrau-2 engine wiring): call `await injectHook(...)` in createSession BEFORE spawnPty, and
|
|
96
|
+
* `await restore(backup)` on session teardown. Tracked by the Degrau-2 engine-integration story.
|
|
97
|
+
*
|
|
98
|
+
* @param opts.settingsPath absolute path to the `settings.local.json` to mutate.
|
|
99
|
+
* @param opts.port the verified-free port from {@link import("./port.js").findFreePort} (R1).
|
|
100
|
+
* @param opts.timeout optional hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
|
|
101
|
+
* @returns a {@link Backup} handle carrying the exact prior bytes (or absence) for {@link restore}.
|
|
102
|
+
* @throws {Error} if the prior file is unreadable for a reason other than absence, or if the merged
|
|
103
|
+
* config cannot be written — on a write failure the prior bytes are restored (or the partial temp
|
|
104
|
+
* deleted) before rejecting, so a failed inject never leaves a file claude could read half-written.
|
|
105
|
+
*/
|
|
106
|
+
export declare function injectHook(opts: {
|
|
107
|
+
settingsPath: string;
|
|
108
|
+
port: number;
|
|
109
|
+
timeout?: number;
|
|
110
|
+
}): Promise<Backup>;
|
|
111
|
+
/**
|
|
112
|
+
* Teardown: remove the fork's injected hook, leaving ZERO residue (R4.2, R4.3).
|
|
113
|
+
*
|
|
114
|
+
* Preferred path (`'surgical'`): re-read the current on-disk file; if it parses, delete ONLY the
|
|
115
|
+
* group bearing the fork-owned marker ({@link isForkHookGroup}) and re-write the rest — preserving
|
|
116
|
+
* any edits the user made AFTER inject. If removing the fork group empties `hooks.PreToolUse`/`hooks`,
|
|
117
|
+
* and the backup recorded absence (the fork created the file), the file is deleted so no empty husk
|
|
118
|
+
* is left behind.
|
|
119
|
+
*
|
|
120
|
+
* Fallback path (`'backup'`): if the current file is unparseable, or surgical isolation of the fork
|
|
121
|
+
* entry is not possible, restore the captured prior state — delete the file if it did not previously
|
|
122
|
+
* exist, else rewrite the exact prior bytes.
|
|
123
|
+
*
|
|
124
|
+
* @param backup the handle returned by {@link injectHook}.
|
|
125
|
+
* @returns which path ran, so the caller can log it (R4.3).
|
|
126
|
+
* @throws {Error} only if BOTH the surgical path AND the backup fallback fail — the message names the
|
|
127
|
+
* underlying error so teardown never silently strands the fork hook.
|
|
128
|
+
*/
|
|
129
|
+
export declare function restore(backup: Backup): Promise<RestoreResult>;
|
|
130
|
+
//# sourceMappingURL=settings-writer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-writer.d.ts","sourceRoot":"","sources":["../../src/gate/settings-writer.ts"],"names":[],"mappings":"AA6BA,qGAAqG;AACrG,eAAO,MAAM,qBAAqB,uBAAuB,CAAC;AAE1D,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,qGAAqG;AACrG,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uGAAuG;AACvG,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,mFAAmF;IACnF,CAAC,oBAAoB,CAAC,CAAC,EAAE,IAAI,CAAC;CAC/B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,MAAqC,GAAG,eAAe,CAkB5G;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAuBvD;AAED,kGAAkG;AAClG,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE;QACN,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC;QACvB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,EAAE,KAAK,EAAE,eAAe,GAAG,YAAY,CAsB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAc7D;AAYD;;;;GAIG;AACH,MAAM,WAAW,MAAM;IACrB,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,OAAO,EAAE,OAAO,CAAC;IACjB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;CAC7B;AA2DD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,MAAM,CAAC,CAuClB;AAWD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgEpE"}
|