@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,349 @@
|
|
|
1
|
+
// Story 032 — safe settings.local.json hook writer (Degrau-2 §9 BLOCKER c).
|
|
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. This module is the
|
|
5
|
+
// REVERSIBLE, NON-DESTRUCTIVE config-mutation seam that injects that hook into `settings.local.json`
|
|
6
|
+
// BEFORE the PTY spawns and restores the user's prior config on teardown — it owns NO server, no
|
|
7
|
+
// deny/allow logic, and no engine wiring (those are separate Degrau-2 stories).
|
|
8
|
+
//
|
|
9
|
+
// HOOK SHAPE (authoritative — empirically FIRED against real claude 2.1.161; see GATE_FINDINGS.md
|
|
10
|
+
// keystone + experiments/e-gate.ts `buildScratchSettings`):
|
|
11
|
+
// { hooks: { PreToolUse: [ { matcher, hooks: [ { type:"http", url, timeout } ] } ] } }
|
|
12
|
+
// `timeout` is in SECONDS (claude default 600). The endpoint is a 127.0.0.1 TCP-loopback URL
|
|
13
|
+
// carrying the dynamically allocated free port (story 032 Task 1 / R1) — a unix-socket URL is
|
|
14
|
+
// silently ignored by claude (blocker b), so loopback TCP is mandatory.
|
|
15
|
+
//
|
|
16
|
+
// FORK-OWNED MARKER (so teardown can surgically remove ONLY our hook — Task 3.2 / R4.3): the hook's
|
|
17
|
+
// `url` carries a fixed sentinel PATH segment ({@link FORK_HOOK_MARKER_PATH}). The URL is opaque to
|
|
18
|
+
// claude (passed through verbatim), so this is a safe, parse-surviving identifier that never
|
|
19
|
+
// collides with a user's own hook entry. We also stamp the matcher group with the same sentinel on a
|
|
20
|
+
// dedicated `__forkAcpGate` key for human-readability; the URL path is the AUTHORITATIVE marker.
|
|
21
|
+
//
|
|
22
|
+
// Tasks 2.1 (buildHookEntry) + 2.2 (mergeHook) live here; the inject/restore seam (Task 3) is added
|
|
23
|
+
// below them. The read side reuses `parseJsonc` from zed-register.ts (tolerant of a user's
|
|
24
|
+
// hand-edited JSONC settings) rather than a parallel parser.
|
|
25
|
+
import * as fs from "node:fs/promises";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { parseJsonc } from "../zed-register.js";
|
|
28
|
+
/** Fixed fork-owned sentinel path segment embedded in the hook URL — the surgical-removal marker. */
|
|
29
|
+
export const FORK_HOOK_MARKER_PATH = "/__fork-acp-gate__";
|
|
30
|
+
/** Key stamped on the fork's matcher group for human-readability; the URL path is authoritative. */
|
|
31
|
+
export const FORK_HOOK_MARKER_KEY = "__forkAcpGate";
|
|
32
|
+
/** Default hook timeout in SECONDS (claude default is 600); kept a parameter so nothing is magic. */
|
|
33
|
+
export const DEFAULT_HOOK_TIMEOUT_SECONDS = 600;
|
|
34
|
+
/** Tool matcher for the gate hook; `"*"` matches all tools (the shape the story-007 probe fired). */
|
|
35
|
+
export const FORK_HOOK_MATCHER = "*";
|
|
36
|
+
/**
|
|
37
|
+
* Build the fork's `PreToolUse` `type:http` hook group, pointing at the 127.0.0.1 TCP-loopback
|
|
38
|
+
* endpoint for the dynamically allocated free port (R1 / R2.3). The URL carries the fork-owned
|
|
39
|
+
* sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group.
|
|
40
|
+
*
|
|
41
|
+
* @param port the verified-free port from {@link import("./port.js").findFreePort} — a positive integer.
|
|
42
|
+
* @param timeout hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
|
|
43
|
+
* @returns the `{ matcher, hooks:[{type:"http",…}], __forkAcpGate:true }` group.
|
|
44
|
+
* @throws {Error} if `port` is not a positive integer (a bad port would produce a malformed/ungated
|
|
45
|
+
* hook — fail loud rather than silently emit a hook claude ignores).
|
|
46
|
+
*/
|
|
47
|
+
export function buildHookEntry(port, timeout = DEFAULT_HOOK_TIMEOUT_SECONDS) {
|
|
48
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
49
|
+
throw new Error(`buildHookEntry: port must be a positive integer in 1..65535, got ${String(port)} — refusing ` +
|
|
50
|
+
`to build a malformed hook (claude would ignore it and the tool would run ungated).`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
matcher: FORK_HOOK_MATCHER,
|
|
54
|
+
hooks: [
|
|
55
|
+
{
|
|
56
|
+
type: "http",
|
|
57
|
+
url: `http://127.0.0.1:${port}${FORK_HOOK_MARKER_PATH}`,
|
|
58
|
+
timeout,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
[FORK_HOOK_MARKER_KEY]: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** True iff `group` is the FORK's own injected hook group (sentinel key OR sentinel URL path). */
|
|
65
|
+
export function isForkHookGroup(group) {
|
|
66
|
+
if (typeof group !== "object" || group === null) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const g = group;
|
|
70
|
+
if (g[FORK_HOOK_MARKER_KEY] === true) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// Fall back to the authoritative URL-path marker (survives even if the sentinel key were stripped).
|
|
74
|
+
const hooks = g.hooks;
|
|
75
|
+
if (Array.isArray(hooks)) {
|
|
76
|
+
for (const h of hooks) {
|
|
77
|
+
if (h !== null &&
|
|
78
|
+
typeof h === "object" &&
|
|
79
|
+
typeof h.url === "string" &&
|
|
80
|
+
(h.url.includes(FORK_HOOK_MARKER_PATH))) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Merge the fork hook `group` into an existing-or-absent settings object, preserving EVERY
|
|
89
|
+
* pre-existing key/value AND the user's own hooks (R2.1, R2.2, R2.4). Never mutates `prior`.
|
|
90
|
+
*
|
|
91
|
+
* - `prior` null/absent → return a minimal object containing ONLY the fork hook (R2.2).
|
|
92
|
+
* - `prior` present → deep-clone it, then APPEND the fork group to `hooks.PreToolUse`
|
|
93
|
+
* without dropping/overwriting any existing entry (R2.4). An existing
|
|
94
|
+
* fork group (idempotent re-inject) is replaced in place, not duplicated.
|
|
95
|
+
*
|
|
96
|
+
* @param prior the parsed prior settings object, or `null` if the file was absent.
|
|
97
|
+
* @param group the fork hook group from {@link buildHookEntry}.
|
|
98
|
+
* @returns a NEW settings object — a superset of `prior` differing only by the added fork hook.
|
|
99
|
+
* @throws {Error} if `prior` is a non-object (array/scalar) — a corrupt root is surfaced loudly, not
|
|
100
|
+
* silently overwritten (R2.1: a corrupt user file is never clobbered blindly).
|
|
101
|
+
*/
|
|
102
|
+
export function mergeHook(prior, group) {
|
|
103
|
+
if (prior === null || prior === undefined) {
|
|
104
|
+
return { hooks: { PreToolUse: [structuredClone(group)] } };
|
|
105
|
+
}
|
|
106
|
+
if (typeof prior !== "object" || Array.isArray(prior)) {
|
|
107
|
+
throw new Error(`mergeHook: prior settings root must be a JSON object, got ${Array.isArray(prior) ? "array" : typeof prior}. ` +
|
|
108
|
+
`Refusing to overwrite a malformed settings.local.json (the user's config is never clobbered blindly).`);
|
|
109
|
+
}
|
|
110
|
+
const merged = structuredClone(prior);
|
|
111
|
+
if (typeof merged.hooks !== "object" || merged.hooks === null || Array.isArray(merged.hooks)) {
|
|
112
|
+
merged.hooks = {};
|
|
113
|
+
}
|
|
114
|
+
const hooks = merged.hooks;
|
|
115
|
+
if (!Array.isArray(hooks.PreToolUse)) {
|
|
116
|
+
hooks.PreToolUse = [];
|
|
117
|
+
}
|
|
118
|
+
// Drop any prior fork group (idempotent re-inject) but keep every user group, then append ours.
|
|
119
|
+
hooks.PreToolUse = hooks.PreToolUse.filter((g) => !isForkHookGroup(g));
|
|
120
|
+
hooks.PreToolUse.push(structuredClone(group));
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse settings text tolerantly (JSONC — a user may have hand-edited comments/trailing commas) into
|
|
125
|
+
* a {@link SettingsLike} object. Reuses zed-register's `parseJsonc`. A parse failure is surfaced as a
|
|
126
|
+
* clear error (NOT silently swallowed) so a corrupt user file is never clobbered blindly (R2.1).
|
|
127
|
+
*
|
|
128
|
+
* @param text the raw on-disk file contents.
|
|
129
|
+
* @returns the parsed object.
|
|
130
|
+
* @throws {Error} wrapping the underlying parse error with a clear, attributable message.
|
|
131
|
+
*/
|
|
132
|
+
export function parsePriorSettings(text) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = parseJsonc(text);
|
|
135
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
136
|
+
throw new Error(`parsed root is ${Array.isArray(parsed) ? "an array" : typeof parsed}, expected an object`);
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
142
|
+
throw new Error(`parsePriorSettings: settings.local.json is not parseable JSON/JSONC (${reason}). Refusing to ` +
|
|
143
|
+
`clobber it blindly — the inject path must fall back to a safe handling of a corrupt file.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Durably write `text` to `filePath` via a temp-file + fsync + atomic `rename` so a reader (claude on
|
|
148
|
+
* startup) never observes a partial file (R3.2). On any failure the partial temp is removed before
|
|
149
|
+
* the error propagates, so a failed write leaves no half-written file behind.
|
|
150
|
+
*/
|
|
151
|
+
async function durableWrite(filePath, text) {
|
|
152
|
+
const dir = path.dirname(filePath);
|
|
153
|
+
await fs.mkdir(dir, { recursive: true });
|
|
154
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.fork-tmp-${process.pid}-${Date.now()}`);
|
|
155
|
+
let handle = null;
|
|
156
|
+
try {
|
|
157
|
+
handle = await fs.open(tmp, "w");
|
|
158
|
+
await handle.writeFile(text, "utf8");
|
|
159
|
+
await handle.sync(); // flush the file's own bytes to disk before the rename
|
|
160
|
+
await handle.close();
|
|
161
|
+
handle = null;
|
|
162
|
+
await fs.rename(tmp, filePath); // atomic swap into place
|
|
163
|
+
// Best-effort fsync of the directory entry so the rename itself is durable.
|
|
164
|
+
try {
|
|
165
|
+
const dirHandle = await fs.open(dir, "r");
|
|
166
|
+
try {
|
|
167
|
+
await dirHandle.sync();
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await dirHandle.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Some platforms reject O_RDONLY dir fsync; the file fsync above is the load-bearing one.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (handle !== null) {
|
|
179
|
+
try {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// ignore
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await fs.rm(tmp, { force: true });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// ignore — nothing durable to clean
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Read the prior file bytes, or `null` if it does not exist. Non-ENOENT errors propagate. */
|
|
196
|
+
async function readPriorBytes(settingsPath) {
|
|
197
|
+
try {
|
|
198
|
+
return await fs.readFile(settingsPath);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code === "ENOENT") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Capture the prior on-disk state, compute the merged settings, and DURABLY write
|
|
209
|
+
* `settings.local.json` BEFORE the claude PTY is spawned (R3.1, R3.2, R4.1).
|
|
210
|
+
*
|
|
211
|
+
* CALLER CONTRACT (BLOCKER c — setup ordering): the spawn path MUST `await injectHook(...)` BEFORE it
|
|
212
|
+
* spawns the `claude` PTY, because `claude` reads hook config only at startup; a write that lands
|
|
213
|
+
* after spawn misses the first tool call. The engine-wiring that enforces this ordering is a separate
|
|
214
|
+
* Degrau-2 story (out of scope here — this module is the standalone seam).
|
|
215
|
+
* TODO(degrau-2 engine wiring): call `await injectHook(...)` in createSession BEFORE spawnPty, and
|
|
216
|
+
* `await restore(backup)` on session teardown. Tracked by the Degrau-2 engine-integration story.
|
|
217
|
+
*
|
|
218
|
+
* @param opts.settingsPath absolute path to the `settings.local.json` to mutate.
|
|
219
|
+
* @param opts.port the verified-free port from {@link import("./port.js").findFreePort} (R1).
|
|
220
|
+
* @param opts.timeout optional hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
|
|
221
|
+
* @returns a {@link Backup} handle carrying the exact prior bytes (or absence) for {@link restore}.
|
|
222
|
+
* @throws {Error} if the prior file is unreadable for a reason other than absence, or if the merged
|
|
223
|
+
* config cannot be written — on a write failure the prior bytes are restored (or the partial temp
|
|
224
|
+
* deleted) before rejecting, so a failed inject never leaves a file claude could read half-written.
|
|
225
|
+
*/
|
|
226
|
+
export async function injectHook(opts) {
|
|
227
|
+
const { settingsPath, port, timeout } = opts;
|
|
228
|
+
// 1) Snapshot the prior state (bytes or absence) — captured BEFORE any mutation (R4.1).
|
|
229
|
+
const priorBytes = await readPriorBytes(settingsPath);
|
|
230
|
+
const backup = { settingsPath, existed: priorBytes !== null, priorBytes };
|
|
231
|
+
// 2) Compute the merged object from the prior (parsed tolerantly) + the fork hook.
|
|
232
|
+
const group = buildHookEntry(port, timeout);
|
|
233
|
+
let prior = null;
|
|
234
|
+
if (priorBytes !== null) {
|
|
235
|
+
const text = priorBytes.toString("utf8").trim();
|
|
236
|
+
// An empty existing file is treated as "no prior settings" rather than a parse error.
|
|
237
|
+
prior = text.length === 0 ? null : parsePriorSettings(text);
|
|
238
|
+
}
|
|
239
|
+
const merged = mergeHook(prior, group);
|
|
240
|
+
const out = `${JSON.stringify(merged, null, 2)}\n`;
|
|
241
|
+
// 3) Durably write BEFORE returning control to the spawn path (R3.1, R3.2). On failure, roll back
|
|
242
|
+
// to the prior bytes (or delete a file we just created) so claude never reads a half-written file.
|
|
243
|
+
try {
|
|
244
|
+
await durableWrite(settingsPath, out);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
try {
|
|
248
|
+
if (backup.existed && backup.priorBytes !== null) {
|
|
249
|
+
await durableWrite(settingsPath, backup.priorBytes.toString("utf8"));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
await fs.rm(settingsPath, { force: true });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// best-effort rollback; surface the ORIGINAL write error below regardless
|
|
257
|
+
}
|
|
258
|
+
throw new Error(`injectHook: failed to write ${settingsPath} (${err instanceof Error ? err.message : String(err)}). ` +
|
|
259
|
+
`Rolled back to the prior on-disk state; refusing to leave a half-written settings.local.json.`);
|
|
260
|
+
}
|
|
261
|
+
return backup;
|
|
262
|
+
}
|
|
263
|
+
/** Rewrite the captured prior bytes, or delete the file if the backup recorded absence (R4.2). */
|
|
264
|
+
async function applyBackup(backup) {
|
|
265
|
+
if (backup.existed && backup.priorBytes !== null) {
|
|
266
|
+
await durableWrite(backup.settingsPath, backup.priorBytes.toString("utf8"));
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
await fs.rm(backup.settingsPath, { force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Teardown: remove the fork's injected hook, leaving ZERO residue (R4.2, R4.3).
|
|
274
|
+
*
|
|
275
|
+
* Preferred path (`'surgical'`): re-read the current on-disk file; if it parses, delete ONLY the
|
|
276
|
+
* group bearing the fork-owned marker ({@link isForkHookGroup}) and re-write the rest — preserving
|
|
277
|
+
* any edits the user made AFTER inject. If removing the fork group empties `hooks.PreToolUse`/`hooks`,
|
|
278
|
+
* and the backup recorded absence (the fork created the file), the file is deleted so no empty husk
|
|
279
|
+
* is left behind.
|
|
280
|
+
*
|
|
281
|
+
* Fallback path (`'backup'`): if the current file is unparseable, or surgical isolation of the fork
|
|
282
|
+
* entry is not possible, restore the captured prior state — delete the file if it did not previously
|
|
283
|
+
* exist, else rewrite the exact prior bytes.
|
|
284
|
+
*
|
|
285
|
+
* @param backup the handle returned by {@link injectHook}.
|
|
286
|
+
* @returns which path ran, so the caller can log it (R4.3).
|
|
287
|
+
* @throws {Error} only if BOTH the surgical path AND the backup fallback fail — the message names the
|
|
288
|
+
* underlying error so teardown never silently strands the fork hook.
|
|
289
|
+
*/
|
|
290
|
+
export async function restore(backup) {
|
|
291
|
+
// Re-read the CURRENT on-disk file (it may carry post-inject user edits).
|
|
292
|
+
let currentBytes;
|
|
293
|
+
try {
|
|
294
|
+
currentBytes = await readPriorBytes(backup.settingsPath);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
currentBytes = null; // unreadable for a non-ENOENT reason → fall back to the backup
|
|
298
|
+
}
|
|
299
|
+
// If the file is gone, the desired end-state is already the backup's "absence" or prior bytes.
|
|
300
|
+
if (currentBytes === null) {
|
|
301
|
+
try {
|
|
302
|
+
await applyBackup(backup);
|
|
303
|
+
return { path: "backup" };
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
throw new Error(`restore: file missing and backup re-apply failed (${err instanceof Error ? err.message : String(err)}).`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Try the SURGICAL path: parse, drop only the fork group, re-write the rest.
|
|
310
|
+
try {
|
|
311
|
+
const text = currentBytes.toString("utf8").trim();
|
|
312
|
+
const parsed = text.length === 0 ? {} : parsePriorSettings(text);
|
|
313
|
+
const next = structuredClone(parsed);
|
|
314
|
+
const hooks = next.hooks;
|
|
315
|
+
let removedSomething = false;
|
|
316
|
+
if (hooks && Array.isArray(hooks.PreToolUse)) {
|
|
317
|
+
const before = hooks.PreToolUse.length;
|
|
318
|
+
hooks.PreToolUse = hooks.PreToolUse.filter((g) => !isForkHookGroup(g));
|
|
319
|
+
removedSomething = hooks.PreToolUse.length < before;
|
|
320
|
+
if (hooks.PreToolUse.length === 0) {
|
|
321
|
+
delete hooks.PreToolUse;
|
|
322
|
+
}
|
|
323
|
+
if (Object.keys(hooks).length === 0) {
|
|
324
|
+
delete next.hooks;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// If, after removing the fork hook, the document is empty AND the file did not exist before
|
|
328
|
+
// inject (the fork created it), delete it so no empty `{}` husk is left as residue (R4.2).
|
|
329
|
+
if (!backup.existed && Object.keys(next).length === 0) {
|
|
330
|
+
await fs.rm(backup.settingsPath, { force: true });
|
|
331
|
+
return { path: "surgical" };
|
|
332
|
+
}
|
|
333
|
+
void removedSomething; // a no-op removal is still a valid surgical outcome (idempotent teardown)
|
|
334
|
+
await durableWrite(backup.settingsPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
335
|
+
return { path: "surgical" };
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Surgical isolation impossible (unparseable / corrupt post-inject file) → backup fallback (R4.3).
|
|
339
|
+
try {
|
|
340
|
+
await applyBackup(backup);
|
|
341
|
+
return { path: "backup" };
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
throw new Error(`restore: surgical removal failed AND backup fallback failed ` +
|
|
345
|
+
`(${err instanceof Error ? err.message : String(err)}). The fork hook may be stranded — manual ` +
|
|
346
|
+
`inspection of ${backup.settingsPath} is required.`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { runAcp } from "./acp-agent.js";
|
|
4
|
+
import { resolveClaudePath } from "./claude-path.js";
|
|
5
|
+
import { usageUpdateEnabled } from "./usage-env.js";
|
|
6
|
+
import { liveDiffEnabled } from "./live-diff-env.js";
|
|
7
|
+
import { liveSubagentWatchEnabled } from "./live-subagent-env.js";
|
|
8
|
+
if (process.argv.includes("--cli")) {
|
|
9
|
+
const { spawn } = await import("node:child_process");
|
|
10
|
+
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
|
11
|
+
const child = spawn(process.env.CLAUDE_CODE_EXECUTABLE ?? resolveClaudePath(), args, {
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
});
|
|
14
|
+
const signals = process.platform === "win32"
|
|
15
|
+
? ["SIGINT", "SIGTERM"]
|
|
16
|
+
: ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
17
|
+
for (const sig of signals) {
|
|
18
|
+
process.on(sig, () => {
|
|
19
|
+
if (!child.killed)
|
|
20
|
+
child.kill(sig);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
child.on("exit", (code, signal) => {
|
|
24
|
+
if (signal && process.platform !== "win32") {
|
|
25
|
+
// Remove our listener so re-raising actually terminates instead of
|
|
26
|
+
// re-entering the no-op handler, which would let us exit with code 0
|
|
27
|
+
// instead of the signal's conventional 128+N.
|
|
28
|
+
process.removeAllListeners(signal);
|
|
29
|
+
process.kill(process.pid, signal);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
process.exit(code ?? 1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
child.on("error", (err) => {
|
|
36
|
+
console.error(err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Apply env vars from the managed-policy tier before any SDK call so the
|
|
42
|
+
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
|
43
|
+
// read of managed-settings.json) also picks up MDM sources on macOS and
|
|
44
|
+
// HKLM/HKCU on Windows.
|
|
45
|
+
const policy = await resolveSettings({ settingSources: [] });
|
|
46
|
+
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
|
47
|
+
process.env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
// stdout is used to send messages to the client
|
|
50
|
+
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
|
51
|
+
console.log = console.error;
|
|
52
|
+
console.info = console.error;
|
|
53
|
+
console.warn = console.error;
|
|
54
|
+
console.debug = console.error;
|
|
55
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
56
|
+
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
57
|
+
});
|
|
58
|
+
// Story 042 (R1.1): the UNSTABLE usage_update notification now defaults ON — unset/empty (or any
|
|
59
|
+
// value other than the opt-out) → ON; opt OUT only via USAGE_UPDATE=0 / USAGE_UPDATE=false. This
|
|
60
|
+
// flips the story-038 default-OFF posture now that story 039's ZED-CLIENT-STUDY confirmed the
|
|
61
|
+
// user's Zed ACCEPTS+RENDERS usage_update by code; the per-session R8 reject latch still guards a
|
|
62
|
+
// client that rejects it. Parsed by the pure `usageUpdateEnabled` (src/usage-env.ts) so the truth
|
|
63
|
+
// table is unit-checkable; threaded through runAcp → AgentDeps → the agent.
|
|
64
|
+
const usageUpdate = usageUpdateEnabled();
|
|
65
|
+
// Story 034 (§9 / R3.3): the HYBRID permission gate is the v1 policy (PERMISSIONS.md §1), so the
|
|
66
|
+
// production bootstrap resolves it ON by default — `FORK_GATE=off` is the documented diagnostic
|
|
67
|
+
// escape hatch (and the R5 v1-no-gate fallback switch). With the gate OFF, createSession starts
|
|
68
|
+
// NO hook server, writes NO scratch settings, and the spawn argv carries no `--settings` — the
|
|
69
|
+
// ungated spawn is byte-for-byte pre-034. Any other value (absent, "1", "on", even "0") stays ON:
|
|
70
|
+
// for a safety gate the conservative direction is gated, so only the exact documented escape
|
|
71
|
+
// hatch disables it. Threaded through runAcp → AgentDeps → the agent (the usageUpdate pattern).
|
|
72
|
+
const gate = process.env.FORK_GATE !== "off";
|
|
73
|
+
// Story 043 (R2.2): the live Edit/Write diff now defaults ON — unset/empty (or any value other
|
|
74
|
+
// than the opt-out) → ON; opt OUT only via LIVE_DIFF=0 / LIVE_DIFF=false. With the PTY engine the
|
|
75
|
+
// PostToolUse hook that once produced diffs is gone, so the reduced-shape JSONL renders no diff
|
|
76
|
+
// until `toolUseResult` is hydrated by uuid (diff-enriched-reader.ts, FORK.md 4.2); defaulting ON
|
|
77
|
+
// restores the story-021 diff on BOTH the live pump and the session/load replay. OFF → byte-for-byte
|
|
78
|
+
// the pre-043 reduced reader. Parsed by the pure `liveDiffEnabled` (src/live-diff-env.ts) so the
|
|
79
|
+
// truth table is unit-checkable; threaded through runAcp → AgentDeps → the agent.
|
|
80
|
+
const liveDiff = liveDiffEnabled();
|
|
81
|
+
// Story 044 (R4.1): the live sub-agent watcher now defaults ON — unset/empty (or any value other
|
|
82
|
+
// than the opt-out) → ON; opt OUT only via FORK_LIVE_SUBAGENT_WATCH=0 / =false. It arms the
|
|
83
|
+
// Option-B 2nd watcher (a POLL of the SDK sidechain readers while a turn is in flight) so a
|
|
84
|
+
// long-running sub-agent — whose rows land in subagents/*.jsonl while the MAIN transcript stays
|
|
85
|
+
// silent — feeds the turn detector's noteActivity() seam and renders incrementally instead of
|
|
86
|
+
// false-stalling the story-024 watchdog (the story-041 R4.2 live gap). OFF → byte-for-byte today's
|
|
87
|
+
// pull-only path: NO 2nd watcher armed (R4.2). Parsed by the pure `liveSubagentWatchEnabled`
|
|
88
|
+
// (src/live-subagent-env.ts) so the truth table is unit-checkable; threaded through
|
|
89
|
+
// runAcp → AgentDeps → the agent.
|
|
90
|
+
const liveSubagentWatch = liveSubagentWatchEnabled();
|
|
91
|
+
const { connection, agent } = runAcp({ usageUpdate, gate, liveDiff, liveSubagentWatch });
|
|
92
|
+
async function shutdown() {
|
|
93
|
+
await agent.dispose().catch((err) => {
|
|
94
|
+
console.error("Error during cleanup:", err);
|
|
95
|
+
});
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
|
99
|
+
// error). Without this, `process.stdin.resume()` keeps the event loop
|
|
100
|
+
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
|
101
|
+
connection.closed.then(shutdown);
|
|
102
|
+
process.on("SIGTERM", shutdown);
|
|
103
|
+
process.on("SIGINT", shutdown);
|
|
104
|
+
// Keep process alive while connection is open
|
|
105
|
+
process.stdin.resume();
|
|
106
|
+
}
|