@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 @@
|
|
|
1
|
+
{"version":3,"file":"permission-mode.d.ts","sourceRoot":"","sources":["../../src/permissions/permission-mode.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAc,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAElG,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,mBAAmB,CAAC;AAEpE;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ5E;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,kFAAkF;IAClF,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,0DAE5E;AAED,uGAAuG;AACvG,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,cAAc,GAAG,mBAAmB,CA2B9E"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Story 033 / Task 3.2 — the `--permission-mode acceptEdits`/`bypassPermissions` deny-only alternative,
|
|
2
|
+
// with billing UNCHANGED (R4.1, R4.2).
|
|
3
|
+
//
|
|
4
|
+
// THE ALTERNATIVE (IMPLEMENTACAO-FORK-ACP.md §9): spawn the TUI with `--permission-mode acceptEdits`
|
|
5
|
+
// (or `bypassPermissions`) so the TUI AUTO-APPROVES, paired with the PreToolUse hook acting as a
|
|
6
|
+
// DENY-ONLY gate: dangerous tools are denied; the rest auto-approve in the TUI with NO second prompt.
|
|
7
|
+
// This removes the #52822 double-prompt surface entirely on the allow path while keeping a deny safety
|
|
8
|
+
// net — a structurally simpler posture than the keystroke-injection mitigation (allow-inject.ts).
|
|
9
|
+
//
|
|
10
|
+
// BILLING IS SACRED (§10 / story 022): switching the permission MODE does NOT change billing.
|
|
11
|
+
// - the spawn argv stays interactive-only (no `-p`/`stream-json`), so the run still labels
|
|
12
|
+
// `entrypoint=='cli'` ORGANICALLY (the E1 keystone);
|
|
13
|
+
// - `CLAUDE_CODE_ENTRYPOINT` is NEVER spoofed (forcing it toward 'cli' would be evasion — the
|
|
14
|
+
// OpenClaw case);
|
|
15
|
+
// - the story-022 guard-rail is RE-ASSERTED on the first observed message: a `sdk-*`/`print` label
|
|
16
|
+
// ABORTS the session with the observed entrypoint named (we never proceed on a credit-class label).
|
|
17
|
+
//
|
|
18
|
+
// This module owns the flag/matcher selection + the guard-rail re-assertion; the actual spawn wiring
|
|
19
|
+
// in `startEngine` consumes {@link permissionModeFlag} and the deny-only matcher.
|
|
20
|
+
import { guardEvent } from "../billing/entrypoint-guard.js";
|
|
21
|
+
/**
|
|
22
|
+
* Build the `--permission-mode <mode>` spawn flag fragment for the alternative (R4.1). Returns the two
|
|
23
|
+
* argv tokens; the spawn path appends them to the interactive `claude` argv. NOTE: this flag changes
|
|
24
|
+
* only how the TUI PROMPTS — it does NOT add `-p`/`stream-json` and therefore does NOT change billing.
|
|
25
|
+
*
|
|
26
|
+
* @param mode `acceptEdits` (the safe default) or `bypassPermissions`.
|
|
27
|
+
* @returns the argv tokens `['--permission-mode', mode]`.
|
|
28
|
+
* @throws {Error} on an unrecognized mode (never silently fall through to a non-gating flag).
|
|
29
|
+
*/
|
|
30
|
+
export function permissionModeFlag(mode) {
|
|
31
|
+
if (mode !== "acceptEdits" && mode !== "bypassPermissions") {
|
|
32
|
+
throw new Error(`permissionModeFlag: unsupported alternative permission mode ${JSON.stringify(mode)} — ` +
|
|
33
|
+
`expected 'acceptEdits' or 'bypassPermissions'.`);
|
|
34
|
+
}
|
|
35
|
+
return ["--permission-mode", mode];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Decide whether the deny-only hook should DENY this tool (R4.1). Returns true iff the tool matches a
|
|
39
|
+
* deny matcher (exact name or `"*"`); otherwise the tool is allowed to auto-approve in the TUI.
|
|
40
|
+
*
|
|
41
|
+
* @param policy the deny-only policy (the dangerous-tool matchers).
|
|
42
|
+
* @param toolName the tool from the hook payload.
|
|
43
|
+
* @returns true → the hook denies this tool; false → auto-approve.
|
|
44
|
+
*/
|
|
45
|
+
export function denyOnlyShouldDeny(policy, toolName) {
|
|
46
|
+
return policy.denyMatchers.some((m) => m === "*" || m === toolName);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Re-assert the story-022 billing guard-rail (§10) for one observed message under the alternative mode
|
|
50
|
+
* (R4.2). This is the SAME guard-rail the read-only pump runs — re-asserted here so enabling
|
|
51
|
+
* auto-approve does NOT weaken billing. Delegates to {@link guardEvent} (it never re-derives the class
|
|
52
|
+
* and never mutates/spoofs `entrypoint`): on a `sdk-*`/`print` (or unknown) label it alerts + stops the
|
|
53
|
+
* session via the injected hooks; on `cli` it is a silent no-op.
|
|
54
|
+
*
|
|
55
|
+
* @param event one watched message event (the story-015 watcher shape).
|
|
56
|
+
* @param hooks the alert/stop sink (wired to the engine-lifecycle stop in production).
|
|
57
|
+
* @returns the guard decision (`allow`/`abort`/`skip`) so the caller can branch.
|
|
58
|
+
*/
|
|
59
|
+
export function reassertBillingGuard(event, hooks) {
|
|
60
|
+
return guardEvent(event, hooks);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convenience post-spawn assertion (R4.2): given the FIRST observed billing message's entrypoint
|
|
64
|
+
* self-label, decide whether the alternative-mode session may proceed. ABORTS (`ok:false`) on anything
|
|
65
|
+
* that is not the subscription `cli` label, naming the observed entrypoint in the reason — never
|
|
66
|
+
* proceeds on a `sdk-*`/`print` label and never spoofs the value toward `cli`.
|
|
67
|
+
*
|
|
68
|
+
* This is a thin, pure wrapper over {@link guardEvent} for the spawn path that wants a boolean rather
|
|
69
|
+
* than the hook side-effects; the side-effecting {@link reassertBillingGuard} is used by the live pump.
|
|
70
|
+
*
|
|
71
|
+
* @param event the first observed `assistant`/`user` event under the alternative mode.
|
|
72
|
+
* @returns `{ ok:true, entrypoint }` for `cli`; `{ ok:false, entrypoint, reason }` otherwise.
|
|
73
|
+
*/
|
|
74
|
+
export function assertEntrypointCli(event) {
|
|
75
|
+
const captured = [];
|
|
76
|
+
const hooks = {
|
|
77
|
+
alert() {
|
|
78
|
+
/* captured via stopSession below */
|
|
79
|
+
},
|
|
80
|
+
stopSession(info) {
|
|
81
|
+
captured.push({ entrypoint: info.entrypoint, entrypointClass: info.entrypointClass });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const decision = guardEvent(event, hooks);
|
|
85
|
+
const aborted = captured[0] ?? null;
|
|
86
|
+
if (decision.action === "abort" || aborted !== null) {
|
|
87
|
+
const ep = aborted?.entrypoint ?? (decision.action === "abort" ? decision.entrypoint : "");
|
|
88
|
+
const cls = aborted?.entrypointClass ?? (decision.action === "abort" ? decision.entrypointClass : "unknown");
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
entrypoint: ep,
|
|
92
|
+
reason: `[billing §10] alternative permission-mode session observed a ${cls}-class entrypoint ` +
|
|
93
|
+
`"${ep}" — aborting (the run would bill on API credit, not the subscription). The entrypoint ` +
|
|
94
|
+
`is NOT being rewritten toward 'cli'.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// 'allow' (subscription cli) — proceed. 'skip' (lightweight) is treated as not-yet-decided → proceed.
|
|
98
|
+
const entrypoint = decision.action === "allow" ? decision.entrypoint : "";
|
|
99
|
+
return { ok: true, entrypoint };
|
|
100
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** The kind hint on a permission option (ACP `PermissionOptionKind`). */
|
|
2
|
+
export type PermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
|
3
|
+
/** One permission option offered to the user (the ACP `PermissionOption` subset the gate emits). */
|
|
4
|
+
export interface PermissionOption {
|
|
5
|
+
optionId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
kind: PermissionOptionKind;
|
|
8
|
+
}
|
|
9
|
+
/** The tool-call detail carried in the ACP request (`RequestPermissionRequest.toolCall`). */
|
|
10
|
+
export interface ToolCallUpdateLike {
|
|
11
|
+
toolCallId: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
rawInput?: unknown;
|
|
14
|
+
}
|
|
15
|
+
/** The ACP `session/request_permission` params the gate sends. */
|
|
16
|
+
export interface RequestPermissionParams {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
toolCall: ToolCallUpdateLike;
|
|
19
|
+
options: PermissionOption[];
|
|
20
|
+
}
|
|
21
|
+
/** The ACP `RequestPermissionResponse` outcome union. */
|
|
22
|
+
export type RequestPermissionOutcome = {
|
|
23
|
+
outcome: "cancelled";
|
|
24
|
+
} | {
|
|
25
|
+
outcome: "selected";
|
|
26
|
+
optionId: string;
|
|
27
|
+
};
|
|
28
|
+
/** The ACP `RequestPermissionResponse` the client returns. */
|
|
29
|
+
export interface RequestPermissionResult {
|
|
30
|
+
outcome: RequestPermissionOutcome;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The minimal ACP client surface the bridge needs — structurally satisfied by the kept
|
|
34
|
+
* `AgentSideConnection` (`client.requestPermission(params)`). Injected so a unit test drives the bridge
|
|
35
|
+
* with a fake client OFFLINE (no AgentSideConnection, no Zed).
|
|
36
|
+
*/
|
|
37
|
+
export interface PermissionClient {
|
|
38
|
+
requestPermission(params: RequestPermissionParams): Promise<RequestPermissionResult>;
|
|
39
|
+
}
|
|
40
|
+
/** The forwarded tool call (hook payload subset) the bridge correlates + asks permission for. */
|
|
41
|
+
export interface PermissionToolCall {
|
|
42
|
+
/** The correlation key: hook `tool_use_id` == JSONL `tool_use.id`. */
|
|
43
|
+
toolUseId: string;
|
|
44
|
+
/** The tool name (`tool_use.name`) — shown in the Zed prompt title. */
|
|
45
|
+
toolName: string;
|
|
46
|
+
/** The tool input (`tool_use.input`) — forwarded as `rawInput` for the Zed prompt. */
|
|
47
|
+
toolInput?: unknown;
|
|
48
|
+
}
|
|
49
|
+
/** The two option ids the gate offers. Fixed so the response mapping is unambiguous. */
|
|
50
|
+
export declare const ALLOW_OPTION_ID = "allow";
|
|
51
|
+
export declare const DENY_OPTION_ID = "deny";
|
|
52
|
+
/** Build the fixed [allow, deny] option set the gate presents in Zed (R2.2). */
|
|
53
|
+
export declare function buildPermissionOptions(): PermissionOption[];
|
|
54
|
+
/**
|
|
55
|
+
* A correlation map keyed by `tool_use.id`: the JSONL `tool_use` events the story-015 watcher has
|
|
56
|
+
* observed, so a hook tool call can be matched to a REAL transcript tool_use before it is approved.
|
|
57
|
+
*
|
|
58
|
+
* `register(id)` is idempotency-aware: it records each id ONCE and flags a re-registration so a
|
|
59
|
+
* DUPLICATE id (id reuse / re-entrancy) can be detected and failed closed. `decide(id)` consumes the
|
|
60
|
+
* correlation: a missing OR duplicate id yields `deny`, a clean single match yields `allow-eligible`
|
|
61
|
+
* (the call may proceed to the ACP prompt). The map is per-session (tool_use ids are session-scoped).
|
|
62
|
+
*/
|
|
63
|
+
export declare class ToolUseCorrelator {
|
|
64
|
+
/** ids observed in the JSONL `tool_use` stream, with a count so duplicates are detectable. */
|
|
65
|
+
private readonly seen;
|
|
66
|
+
/** ids already consumed by a permission decision — a second consume is a duplicate (re-entrancy). */
|
|
67
|
+
private readonly consumed;
|
|
68
|
+
/**
|
|
69
|
+
* Record a JSONL `tool_use.id` from the story-015 watcher. Increments its count so a second
|
|
70
|
+
* observation marks it duplicate. Returns true on the FIRST registration, false on a duplicate.
|
|
71
|
+
*/
|
|
72
|
+
register(toolUseId: string): boolean;
|
|
73
|
+
/** True iff `toolUseId` was observed EXACTLY once in the JSONL and has not yet been consumed. */
|
|
74
|
+
isCleanMatch(toolUseId: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Consume the correlation for `toolUseId` and decide whether the call may proceed to the ACP prompt.
|
|
77
|
+
* FAIL CLOSED: a missing id (never seen in the JSONL), a DUPLICATE id (seen >1× → id reuse), or an
|
|
78
|
+
* already-consumed id (re-entrancy) all yield `'deny'`. A clean single, first-time match yields
|
|
79
|
+
* `'proceed'`. Marks the id consumed so a later re-ask is treated as a duplicate.
|
|
80
|
+
*/
|
|
81
|
+
decide(toolUseId: string): "proceed" | "deny";
|
|
82
|
+
}
|
|
83
|
+
/** Options for {@link requestPermission}. */
|
|
84
|
+
export interface RequestPermissionOptions {
|
|
85
|
+
client: PermissionClient;
|
|
86
|
+
sessionId: string;
|
|
87
|
+
toolCall: PermissionToolCall;
|
|
88
|
+
/** The per-session correlator seeded from the JSONL `tool_use` events (story 015). */
|
|
89
|
+
correlator: ToolUseCorrelator;
|
|
90
|
+
/** Optional sink for the fail-closed diagnostics (defaults to no-op; production wires the logger). */
|
|
91
|
+
onWarn?: (message: string) => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Correlate a forwarded tool call by `tool_use.id` and raise ACP `session/request_permission`,
|
|
95
|
+
* resolving to the enforced decision (R2.2). FAILS CLOSED to `'deny'` on a missing/duplicate id, an
|
|
96
|
+
* ACP transport error, or a `cancelled`/unrecognized outcome — never approves an uncorrelated call.
|
|
97
|
+
*
|
|
98
|
+
* @returns `'allow'` ONLY when the id is a clean single JSONL match AND Zed returns the allow option;
|
|
99
|
+
* `'deny'` otherwise.
|
|
100
|
+
*/
|
|
101
|
+
export declare function requestPermission(opts: RequestPermissionOptions): Promise<"allow" | "deny">;
|
|
102
|
+
//# sourceMappingURL=request-permission.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-permission.d.ts","sourceRoot":"","sources":["../../src/permissions/request-permission.ts"],"names":[],"mappings":"AAoBA,yEAAyE;AACzE,MAAM,MAAM,oBAAoB,GAAG,YAAY,GAAG,cAAc,GAAG,aAAa,GAAG,eAAe,CAAC;AAEnG,oGAAoG;AACpG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,oBAAoB,CAAC;CAC5B;AAED,6FAA6F;AAC7F,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,kEAAkE;AAClE,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B;AAED,yDAAyD;AACzD,MAAM,MAAM,wBAAwB,GAChC;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,GACxB;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C,8DAA8D;AAC9D,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,wBAAwB,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;CACtF;AAED,iGAAiG;AACjG,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAC;IACjB,sFAAsF;IACtF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,wFAAwF;AACxF,eAAO,MAAM,eAAe,UAAU,CAAC;AACvC,eAAO,MAAM,cAAc,SAAS,CAAC;AAErC,gFAAgF;AAChF,wBAAgB,sBAAsB,IAAI,gBAAgB,EAAE,CAK3D;AAED;;;;;;;;GAQG;AACH,qBAAa,iBAAiB;IAC5B,8FAA8F;IAC9F,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6B;IAClD,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAE9C;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAMpC,iGAAiG;IACjG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIxC;;;;;OAKG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM;CAS9C;AAED,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,gBAAgB,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,sFAAsF;IACtF,UAAU,EAAE,iBAAiB,CAAC;IAC9B,sGAAsG;IACtG,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAiDjG"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Story 033 / Task 2.2 — correlate by tool_use.id and raise ACP session/request_permission, failing
|
|
2
|
+
// CLOSED (deny) on a missing/duplicate id or an ACP transport error (R2.2).
|
|
3
|
+
//
|
|
4
|
+
// FLOW (IMPLEMENTACAO-FORK-ACP.md §9): the loopback hook server (hook-server.ts) forwards a tool call
|
|
5
|
+
// over IPC; the fork correlates it against the JSONL `tool_use` event by `tool_use.id` (the SAME id
|
|
6
|
+
// claude wrote into the transcript and into the hook payload — §9 "correlacionar por tool_use.id"),
|
|
7
|
+
// raises `session/request_permission` in Zed with options [allow, deny], awaits the user's
|
|
8
|
+
// `RequestPermissionResponse`, and maps the chosen option back to `'allow'`/`'deny'`.
|
|
9
|
+
//
|
|
10
|
+
// FAIL CLOSED (the load-bearing safety posture — design.md "Fail closed everywhere"):
|
|
11
|
+
// - a tool_use_id NOT seen in the JSONL correlation map → deny (an uncorrelated call is never
|
|
12
|
+
// approved — it might be a spoofed/stale id);
|
|
13
|
+
// - a DUPLICATE tool_use_id (already pending or already decided) → deny (re-entrancy / id reuse must
|
|
14
|
+
// not silently ride a prior approval);
|
|
15
|
+
// - an ACP transport error (client.requestPermission throws) → deny;
|
|
16
|
+
// - a `cancelled` outcome from Zed → deny (a cancel is NOT an approval).
|
|
17
|
+
//
|
|
18
|
+
// This module owns NO server and NO PTY; it is a pure correlation + ACP-bridge unit driven by an
|
|
19
|
+
// injectable client surface so a unit test runs it OFFLINE against a fake ACP client.
|
|
20
|
+
/** The two option ids the gate offers. Fixed so the response mapping is unambiguous. */
|
|
21
|
+
export const ALLOW_OPTION_ID = "allow";
|
|
22
|
+
export const DENY_OPTION_ID = "deny";
|
|
23
|
+
/** Build the fixed [allow, deny] option set the gate presents in Zed (R2.2). */
|
|
24
|
+
export function buildPermissionOptions() {
|
|
25
|
+
return [
|
|
26
|
+
{ optionId: ALLOW_OPTION_ID, name: "Allow", kind: "allow_once" },
|
|
27
|
+
{ optionId: DENY_OPTION_ID, name: "Deny", kind: "reject_once" },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A correlation map keyed by `tool_use.id`: the JSONL `tool_use` events the story-015 watcher has
|
|
32
|
+
* observed, so a hook tool call can be matched to a REAL transcript tool_use before it is approved.
|
|
33
|
+
*
|
|
34
|
+
* `register(id)` is idempotency-aware: it records each id ONCE and flags a re-registration so a
|
|
35
|
+
* DUPLICATE id (id reuse / re-entrancy) can be detected and failed closed. `decide(id)` consumes the
|
|
36
|
+
* correlation: a missing OR duplicate id yields `deny`, a clean single match yields `allow-eligible`
|
|
37
|
+
* (the call may proceed to the ACP prompt). The map is per-session (tool_use ids are session-scoped).
|
|
38
|
+
*/
|
|
39
|
+
export class ToolUseCorrelator {
|
|
40
|
+
constructor() {
|
|
41
|
+
/** ids observed in the JSONL `tool_use` stream, with a count so duplicates are detectable. */
|
|
42
|
+
this.seen = new Map();
|
|
43
|
+
/** ids already consumed by a permission decision — a second consume is a duplicate (re-entrancy). */
|
|
44
|
+
this.consumed = new Set();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Record a JSONL `tool_use.id` from the story-015 watcher. Increments its count so a second
|
|
48
|
+
* observation marks it duplicate. Returns true on the FIRST registration, false on a duplicate.
|
|
49
|
+
*/
|
|
50
|
+
register(toolUseId) {
|
|
51
|
+
const count = this.seen.get(toolUseId) ?? 0;
|
|
52
|
+
this.seen.set(toolUseId, count + 1);
|
|
53
|
+
return count === 0;
|
|
54
|
+
}
|
|
55
|
+
/** True iff `toolUseId` was observed EXACTLY once in the JSONL and has not yet been consumed. */
|
|
56
|
+
isCleanMatch(toolUseId) {
|
|
57
|
+
return this.seen.get(toolUseId) === 1 && !this.consumed.has(toolUseId);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Consume the correlation for `toolUseId` and decide whether the call may proceed to the ACP prompt.
|
|
61
|
+
* FAIL CLOSED: a missing id (never seen in the JSONL), a DUPLICATE id (seen >1× → id reuse), or an
|
|
62
|
+
* already-consumed id (re-entrancy) all yield `'deny'`. A clean single, first-time match yields
|
|
63
|
+
* `'proceed'`. Marks the id consumed so a later re-ask is treated as a duplicate.
|
|
64
|
+
*/
|
|
65
|
+
decide(toolUseId) {
|
|
66
|
+
const count = this.seen.get(toolUseId) ?? 0;
|
|
67
|
+
if (count !== 1 || this.consumed.has(toolUseId)) {
|
|
68
|
+
// Missing (0), duplicate (>1), or re-entrant (already consumed) → never approve.
|
|
69
|
+
return "deny";
|
|
70
|
+
}
|
|
71
|
+
this.consumed.add(toolUseId);
|
|
72
|
+
return "proceed";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Correlate a forwarded tool call by `tool_use.id` and raise ACP `session/request_permission`,
|
|
77
|
+
* resolving to the enforced decision (R2.2). FAILS CLOSED to `'deny'` on a missing/duplicate id, an
|
|
78
|
+
* ACP transport error, or a `cancelled`/unrecognized outcome — never approves an uncorrelated call.
|
|
79
|
+
*
|
|
80
|
+
* @returns `'allow'` ONLY when the id is a clean single JSONL match AND Zed returns the allow option;
|
|
81
|
+
* `'deny'` otherwise.
|
|
82
|
+
*/
|
|
83
|
+
export async function requestPermission(opts) {
|
|
84
|
+
const { client, sessionId, toolCall, correlator, onWarn } = opts;
|
|
85
|
+
// 1) Correlate by tool_use.id BEFORE asking — an uncorrelated/duplicate call is denied, not prompted.
|
|
86
|
+
const correlation = correlator.decide(toolCall.toolUseId);
|
|
87
|
+
if (correlation === "deny") {
|
|
88
|
+
onWarn?.(`[gate §9] FAIL CLOSED: tool_use ${toolCall.toolUseId} (tool "${toolCall.toolName}") is not a ` +
|
|
89
|
+
`clean single match in the JSONL correlation map (missing or duplicate) — denying rather than ` +
|
|
90
|
+
`approving an uncorrelated call.`);
|
|
91
|
+
return "deny";
|
|
92
|
+
}
|
|
93
|
+
// 2) Raise session/request_permission in Zed with [allow, deny]; await the user's choice.
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await client.requestPermission({
|
|
97
|
+
sessionId,
|
|
98
|
+
toolCall: {
|
|
99
|
+
toolCallId: toolCall.toolUseId,
|
|
100
|
+
title: toolCall.toolName,
|
|
101
|
+
rawInput: toolCall.toolInput,
|
|
102
|
+
},
|
|
103
|
+
options: buildPermissionOptions(),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
onWarn?.(`[gate §9] FAIL CLOSED: session/request_permission transport error for tool_use ` +
|
|
108
|
+
`${toolCall.toolUseId} (${err instanceof Error ? err.message : String(err)}) — denying.`);
|
|
109
|
+
return "deny";
|
|
110
|
+
}
|
|
111
|
+
// 3) Map the outcome back to the enforced decision. Only an explicit allow selection approves;
|
|
112
|
+
// a cancel or any unrecognized optionId fails closed.
|
|
113
|
+
const outcome = result.outcome;
|
|
114
|
+
if (outcome.outcome === "selected" && outcome.optionId === ALLOW_OPTION_ID) {
|
|
115
|
+
return "allow";
|
|
116
|
+
}
|
|
117
|
+
if (outcome.outcome === "selected" && outcome.optionId === DENY_OPTION_ID) {
|
|
118
|
+
return "deny";
|
|
119
|
+
}
|
|
120
|
+
// 'cancelled' or an unknown optionId → deny (a cancel is not an approval).
|
|
121
|
+
onWarn?.(`[gate §9] FAIL CLOSED: tool_use ${toolCall.toolUseId} resolved to ` +
|
|
122
|
+
`${outcome.outcome === "selected" ? `unknown option "${outcome.optionId}"` : "cancelled"} — denying.`);
|
|
123
|
+
return "deny";
|
|
124
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type Settings } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
export interface SettingsManagerOptions {
|
|
3
|
+
onChange?: () => void;
|
|
4
|
+
logger?: {
|
|
5
|
+
log: (...args: any[]) => void;
|
|
6
|
+
error: (...args: any[]) => void;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
|
11
|
+
* so the values we see match what `query()` would observe.
|
|
12
|
+
*
|
|
13
|
+
* Watches the user/project/local/managed settings files for changes and
|
|
14
|
+
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
|
15
|
+
* values from repo-committed sources are filtered out via
|
|
16
|
+
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
|
17
|
+
*/
|
|
18
|
+
export declare class SettingsManager {
|
|
19
|
+
private cwd;
|
|
20
|
+
private effective;
|
|
21
|
+
private watchers;
|
|
22
|
+
private onChange?;
|
|
23
|
+
private logger;
|
|
24
|
+
private initialized;
|
|
25
|
+
private disposed;
|
|
26
|
+
private debounceTimer;
|
|
27
|
+
private initPromise;
|
|
28
|
+
constructor(cwd: string, options?: SettingsManagerOptions);
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the settings manager by loading all settings and setting up file watchers
|
|
31
|
+
*/
|
|
32
|
+
initialize(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
|
35
|
+
* containing directories means we pick up file creation as well as edits.
|
|
36
|
+
*/
|
|
37
|
+
private getWatchedPaths;
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the effective settings via the SDK and applies the CLI's trust
|
|
40
|
+
* filter for escalating `permissions.defaultMode` values.
|
|
41
|
+
*/
|
|
42
|
+
private loadAllSettings;
|
|
43
|
+
/**
|
|
44
|
+
* Sets up file watchers for all settings files
|
|
45
|
+
*/
|
|
46
|
+
private setupWatchers;
|
|
47
|
+
/**
|
|
48
|
+
* Handles settings file changes with debouncing to avoid rapid reloads
|
|
49
|
+
*/
|
|
50
|
+
private handleSettingsChange;
|
|
51
|
+
/**
|
|
52
|
+
* Returns the current merged settings
|
|
53
|
+
*/
|
|
54
|
+
getSettings(): Settings;
|
|
55
|
+
/**
|
|
56
|
+
* Returns the current working directory
|
|
57
|
+
*/
|
|
58
|
+
getCwd(): string;
|
|
59
|
+
/**
|
|
60
|
+
* Updates the working directory and reloads project-specific settings
|
|
61
|
+
*/
|
|
62
|
+
setCwd(cwd: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Disposes of file watchers and cleans up resources
|
|
65
|
+
*/
|
|
66
|
+
dispose(): void;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,QAAQ,EACd,MAAM,gCAAgC,CAAC;AA0BxC,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE;QAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;QAAC,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;KAAE,CAAC;CAC7E;AAED;;;;;;;;GAQG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,QAAQ,CAAC,CAAa;IAC9B,OAAO,CAAC,MAAM,CAAqE;IACnF,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,aAAa,CAA8C;IACnE,OAAO,CAAC,WAAW,CAA8B;gBAErC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,sBAAsB;IAMzD;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAmBjC;;;OAGG;IACH,OAAO,CAAC,eAAe;IASvB;;;OAGG;YACW,eAAe;IAU7B;;OAEG;IACH,OAAO,CAAC,aAAa;IAyBrB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAqB5B;;OAEG;IACH,WAAW,IAAI,QAAQ;IAIvB;;OAEG;IACH,MAAM,IAAI,MAAM;IAIhB;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUxC;;OAEG;IACH,OAAO,IAAI,IAAI;CAehB"}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
|
|
4
|
+
// SDK; API may shift in a future release.
|
|
5
|
+
import { filterEscalatingDefaultMode, resolveSettings, } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
|
7
|
+
/**
|
|
8
|
+
* Permission rule format examples:
|
|
9
|
+
* - "Read" - matches all Read tool calls
|
|
10
|
+
* - "Read(./.env)" - matches specific path
|
|
11
|
+
* - "Read(./.env.*)" - glob pattern
|
|
12
|
+
* - "Read(./secrets/**)" - recursive glob
|
|
13
|
+
* - "Bash(npm run lint)" - exact command prefix
|
|
14
|
+
* - "Bash(npm run:*)" - command prefix with wildcard
|
|
15
|
+
*
|
|
16
|
+
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
|
17
|
+
*/
|
|
18
|
+
function getManagedSettingsPath() {
|
|
19
|
+
switch (process.platform) {
|
|
20
|
+
case "darwin":
|
|
21
|
+
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
22
|
+
case "win32":
|
|
23
|
+
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
24
|
+
default:
|
|
25
|
+
return "/etc/claude-code/managed-settings.json";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
|
30
|
+
* so the values we see match what `query()` would observe.
|
|
31
|
+
*
|
|
32
|
+
* Watches the user/project/local/managed settings files for changes and
|
|
33
|
+
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
|
34
|
+
* values from repo-committed sources are filtered out via
|
|
35
|
+
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
|
36
|
+
*/
|
|
37
|
+
export class SettingsManager {
|
|
38
|
+
constructor(cwd, options) {
|
|
39
|
+
this.effective = {};
|
|
40
|
+
this.watchers = [];
|
|
41
|
+
this.initialized = false;
|
|
42
|
+
this.disposed = false;
|
|
43
|
+
this.debounceTimer = null;
|
|
44
|
+
this.initPromise = null;
|
|
45
|
+
this.cwd = cwd;
|
|
46
|
+
this.onChange = options?.onChange;
|
|
47
|
+
this.logger = options?.logger ?? console;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Initialize the settings manager by loading all settings and setting up file watchers
|
|
51
|
+
*/
|
|
52
|
+
async initialize() {
|
|
53
|
+
if (this.initialized) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (this.initPromise) {
|
|
57
|
+
return this.initPromise;
|
|
58
|
+
}
|
|
59
|
+
this.disposed = false;
|
|
60
|
+
this.initPromise = this.loadAllSettings().then(() => {
|
|
61
|
+
if (!this.disposed) {
|
|
62
|
+
this.setupWatchers();
|
|
63
|
+
this.initialized = true;
|
|
64
|
+
}
|
|
65
|
+
this.initPromise = null;
|
|
66
|
+
});
|
|
67
|
+
return this.initPromise;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
|
71
|
+
* containing directories means we pick up file creation as well as edits.
|
|
72
|
+
*/
|
|
73
|
+
getWatchedPaths() {
|
|
74
|
+
return [
|
|
75
|
+
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
|
|
76
|
+
path.join(this.cwd, ".claude", "settings.json"),
|
|
77
|
+
path.join(this.cwd, ".claude", "settings.local.json"),
|
|
78
|
+
getManagedSettingsPath(),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the effective settings via the SDK and applies the CLI's trust
|
|
83
|
+
* filter for escalating `permissions.defaultMode` values.
|
|
84
|
+
*/
|
|
85
|
+
async loadAllSettings() {
|
|
86
|
+
try {
|
|
87
|
+
const resolved = await resolveSettings({ cwd: this.cwd });
|
|
88
|
+
this.effective = filterEscalatingDefaultMode(resolved);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.logger.error("Failed to resolve settings:", error);
|
|
92
|
+
this.effective = {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sets up file watchers for all settings files
|
|
97
|
+
*/
|
|
98
|
+
setupWatchers() {
|
|
99
|
+
for (const filePath of this.getWatchedPaths()) {
|
|
100
|
+
try {
|
|
101
|
+
const dir = path.dirname(filePath);
|
|
102
|
+
const filename = path.basename(filePath);
|
|
103
|
+
if (fs.existsSync(dir)) {
|
|
104
|
+
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
|
105
|
+
if (changedFilename === filename) {
|
|
106
|
+
this.handleSettingsChange();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
watcher.on("error", (error) => {
|
|
110
|
+
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
|
111
|
+
});
|
|
112
|
+
this.watchers.push(watcher);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Handles settings file changes with debouncing to avoid rapid reloads
|
|
122
|
+
*/
|
|
123
|
+
handleSettingsChange() {
|
|
124
|
+
if (this.debounceTimer) {
|
|
125
|
+
clearTimeout(this.debounceTimer);
|
|
126
|
+
}
|
|
127
|
+
this.debounceTimer = setTimeout(async () => {
|
|
128
|
+
this.debounceTimer = null;
|
|
129
|
+
if (this.disposed) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await this.loadAllSettings();
|
|
134
|
+
if (!this.disposed) {
|
|
135
|
+
this.onChange?.();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
this.logger.error("Failed to reload settings:", error);
|
|
140
|
+
}
|
|
141
|
+
}, 100);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Returns the current merged settings
|
|
145
|
+
*/
|
|
146
|
+
getSettings() {
|
|
147
|
+
return this.effective;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Returns the current working directory
|
|
151
|
+
*/
|
|
152
|
+
getCwd() {
|
|
153
|
+
return this.cwd;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Updates the working directory and reloads project-specific settings
|
|
157
|
+
*/
|
|
158
|
+
async setCwd(cwd) {
|
|
159
|
+
if (this.cwd === cwd) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.dispose();
|
|
163
|
+
this.cwd = cwd;
|
|
164
|
+
await this.initialize();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Disposes of file watchers and cleans up resources
|
|
168
|
+
*/
|
|
169
|
+
dispose() {
|
|
170
|
+
this.disposed = true;
|
|
171
|
+
this.initialized = false;
|
|
172
|
+
this.initPromise = null;
|
|
173
|
+
if (this.debounceTimer) {
|
|
174
|
+
clearTimeout(this.debounceTimer);
|
|
175
|
+
this.debounceTimer = null;
|
|
176
|
+
}
|
|
177
|
+
for (const watcher of this.watchers) {
|
|
178
|
+
watcher.close();
|
|
179
|
+
}
|
|
180
|
+
this.watchers = [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StopReason } from "@agentclientprotocol/sdk";
|
|
2
|
+
/** Telemetry sink for an unmapped stop_reason (drift). Structural — the acp-agent logger satisfies it. */
|
|
3
|
+
export interface StopReasonLogger {
|
|
4
|
+
error: (...args: unknown[]) => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Map a raw `message.stop_reason` to the ACP {@link StopReason} (R3.1–R3.3):
|
|
8
|
+
* - `end_turn` / `stop_sequence` → `"end_turn"`
|
|
9
|
+
* - `max_tokens` → `"max_tokens"`
|
|
10
|
+
* - `refusal` → `"refusal"`
|
|
11
|
+
* - any unrecognized NON-NULL value → the defined fallback `"end_turn"`, AND the raw value is logged
|
|
12
|
+
* for drift telemetry (never silently dropped).
|
|
13
|
+
*
|
|
14
|
+
* Total: never throws. `null`/`undefined` (absence, not drift) return the fallback without logging.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mapStopReason(raw: unknown, logger?: StopReasonLogger): StopReason;
|
|
17
|
+
//# sourceMappingURL=stop-reason-map.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stop-reason-map.d.ts","sourceRoot":"","sources":["../src/stop-reason-map.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,0GAA0G;AAC1G,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACrC;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,gBAAgB,GAAG,UAAU,CAgBjF"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// === Story 024 / Task 4.1 — map raw JSONL `message.stop_reason` to the ACP stopReason taxonomy ====
|
|
2
|
+
//
|
|
3
|
+
// §7 translation JSONL→ACP. The model's terminal `stop_reason` is mapped to the ACP `StopReason`
|
|
4
|
+
// the rewritten prompt() loop answers with (§5, §17 acp-agent.ts PromptResponse). The map is TOTAL:
|
|
5
|
+
// it never throws, and an unrecognized non-null variant is surfaced (logged) and defaulted rather
|
|
6
|
+
// than silently dropped, so model/stop-reason drift is observable for telemetry (R3.3).
|
|
7
|
+
/**
|
|
8
|
+
* Map a raw `message.stop_reason` to the ACP {@link StopReason} (R3.1–R3.3):
|
|
9
|
+
* - `end_turn` / `stop_sequence` → `"end_turn"`
|
|
10
|
+
* - `max_tokens` → `"max_tokens"`
|
|
11
|
+
* - `refusal` → `"refusal"`
|
|
12
|
+
* - any unrecognized NON-NULL value → the defined fallback `"end_turn"`, AND the raw value is logged
|
|
13
|
+
* for drift telemetry (never silently dropped).
|
|
14
|
+
*
|
|
15
|
+
* Total: never throws. `null`/`undefined` (absence, not drift) return the fallback without logging.
|
|
16
|
+
*/
|
|
17
|
+
export function mapStopReason(raw, logger) {
|
|
18
|
+
switch (raw) {
|
|
19
|
+
case "end_turn":
|
|
20
|
+
case "stop_sequence":
|
|
21
|
+
return "end_turn";
|
|
22
|
+
case "max_tokens":
|
|
23
|
+
return "max_tokens";
|
|
24
|
+
case "refusal":
|
|
25
|
+
return "refusal";
|
|
26
|
+
default:
|
|
27
|
+
if (raw !== null && raw !== undefined) {
|
|
28
|
+
// Drift: a non-null stop_reason outside the known set. Surface it (R3.3) — don't drop it.
|
|
29
|
+
logger?.error("[stop-reason-map] unmapped stop_reason, defaulting to end_turn:", raw);
|
|
30
|
+
}
|
|
31
|
+
return "end_turn";
|
|
32
|
+
}
|
|
33
|
+
}
|