atom-agent 1.2.0 → 1.3.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
@@ -0,0 +1,165 @@
1
+ // Before-compaction hooks (ticket 09): extension interception over automatic
2
+ // and manual compaction (cancel or custom summary).
3
+ //
4
+ // Dependency-free like intercept.ts and provider-hooks.ts (type-only imports)
5
+ // so the extension host, the compaction caller (App.tsx), and the tests can
6
+ // all share it with no cycle: extensions register here, doCompact applies
7
+ // here, nobody imports the other.
8
+ //
9
+ // Semantics (fail-open vs fail-closed):
10
+ // - Cancel (fail CLOSED on explicit veto, fail OPEN on throws): handlers run
11
+ // in registration order BEFORE any snapshot/persist/mutate step with the
12
+ // reason and the pending head/tail split. Only an explicit cancel vetoes —
13
+ // true (default reason naming the extension), a non-empty string (that
14
+ // reason), or { cancel: true | "reason" }. Everything else
15
+ // (void/null/false/{cancel:false}/foreign shapes) allows. The first cancel
16
+ // wins: later handlers never run. A throwing handler is recorded and fails
17
+ // OPEN (degrades to the builtin summary with a visible error) — a buggy
18
+ // extension must never hold compaction hostage or half-compact a session
19
+ // (same rationale as the before_switch fail-open gate).
20
+ // - Custom summary (fail OPEN): a handler may return { summary: "text" } to
21
+ // replace the builtin summarizer output. The text must be a non-empty
22
+ // string after trimming — anything else is ignored. The first valid
23
+ // summary wins: later handlers never run. The winning text enters the SAME
24
+ // post-processing as builtin output (touched-files append/fit, boundary
25
+ // marker, atomic swap, save, snapshot clearing) at the single injection
26
+ // point in doCompact — never a parallel pipeline. A bare string return is
27
+ // a cancel reason (the before_switch convention), never a summary, so the
28
+ // two decisions can never be confused.
29
+ // - Read-only split: handlers observe deep copies (per-handler fresh clones
30
+ // of a pristine snapshot), never the live split arrays — an in-place
31
+ // mutation by a handler must not corrupt planning (the provider-hooks
32
+ // deep-copy precedent). The apply entry point never mutates its inputs.
33
+ //
34
+ // Coverage: doCompact in App.tsx is the single compaction funnel (manual
35
+ // /compact via runCompactCommand, pending drains, and auto via
36
+ // maybeAutoCompact all route through it), so one gate covers every reason —
37
+ // auto, manual, and the overflow domain the type reserves for future callers.
38
+ function isRecord(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ function errorText(e) {
42
+ return e instanceof Error ? e.message : String(e ?? "unknown error");
43
+ }
44
+ function cloneMessages(messages) {
45
+ try {
46
+ return structuredClone(messages);
47
+ }
48
+ catch {
49
+ try {
50
+ return JSON.parse(JSON.stringify(messages));
51
+ }
52
+ catch {
53
+ return messages.map((m) => ({ ...m }));
54
+ }
55
+ }
56
+ }
57
+ // Cancel interpretation (single rule, mirrors the before_switch gate): only
58
+ // an explicit veto cancels — true (default reason), a string (that reason,
59
+ // empty falls back to the default), or { cancel: true | "reason" }.
60
+ // Everything else (void/null/false/{cancel:false}/foreign shapes) allows.
61
+ function cancelReasonOf(decision, owner) {
62
+ if (decision === true)
63
+ return `extension "${owner}" cancelled compaction`;
64
+ if (typeof decision === "string") {
65
+ return decision.length > 0 ? decision : `extension "${owner}" cancelled compaction`;
66
+ }
67
+ if (isRecord(decision)) {
68
+ const cancel = decision.cancel;
69
+ if (cancel === true)
70
+ return `extension "${owner}" cancelled compaction`;
71
+ if (typeof cancel === "string") {
72
+ return cancel.length > 0 ? cancel : `extension "${owner}" cancelled compaction`;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ // Custom-summary interpretation (single rule): only { summary } with a
78
+ // non-empty-after-trim string supplies — void/null/false/bare strings (a
79
+ // bare string is a cancel reason above, never a summary)/foreign shapes pass
80
+ // through to the builtin summarizer.
81
+ function summaryOf(decision) {
82
+ if (!isRecord(decision))
83
+ return null;
84
+ const summary = decision.summary;
85
+ if (typeof summary !== "string" || summary.trim().length === 0)
86
+ return null;
87
+ return summary;
88
+ }
89
+ const beforeCompactHandlers = [];
90
+ /** Register a before-compaction hook. Returns an unregister function. */
91
+ export function registerBeforeCompact(handler, owner = "(unknown)") {
92
+ if (typeof handler !== "function") {
93
+ throw new Error("before-compact handler must be a function");
94
+ }
95
+ const record = { owner, handler };
96
+ beforeCompactHandlers.push(record);
97
+ let live = true;
98
+ return () => {
99
+ if (!live)
100
+ return;
101
+ live = false;
102
+ const idx = beforeCompactHandlers.indexOf(record);
103
+ if (idx >= 0)
104
+ beforeCompactHandlers.splice(idx, 1);
105
+ };
106
+ }
107
+ /** Snapshot of live before-compaction handlers in registration order (deterministic composition). */
108
+ export function beforeCompactInterceptors() {
109
+ return [...beforeCompactHandlers];
110
+ }
111
+ /** Test seam: drop every compaction hook. */
112
+ export function clearCompactionHooks() {
113
+ beforeCompactHandlers.length = 0;
114
+ }
115
+ // Apply before-compaction handlers sequentially in registration order. Never
116
+ // throws and never mutates its inputs: each handler receives fresh deep
117
+ // copies of a pristine snapshot, a throwing handler is recorded fail-open
118
+ // (its veto/summary dropped, the chain continues), and the first explicit
119
+ // cancel — or the first valid custom summary — wins with later handlers
120
+ // never running (the before_switch first-wins precedent).
121
+ export async function applyBeforeCompact(handlers, info) {
122
+ const out = {
123
+ cancelled: false,
124
+ cancelReason: null,
125
+ cancelOwner: null,
126
+ summary: null,
127
+ summaryOwner: null,
128
+ errors: [],
129
+ };
130
+ // Pristine snapshot first (never the caller's live arrays): per-handler
131
+ // clones below mean a mutating hook corrupts neither planning nor the
132
+ // next handler's view.
133
+ const pristineHead = cloneMessages(info.head);
134
+ const pristineTail = cloneMessages(info.tail);
135
+ for (const record of handlers) {
136
+ let decision;
137
+ try {
138
+ decision = await record.handler({
139
+ reason: info.reason,
140
+ focusText: info.focusText,
141
+ head: cloneMessages(pristineHead),
142
+ tail: cloneMessages(pristineTail),
143
+ olderTurnCount: info.olderTurnCount,
144
+ });
145
+ }
146
+ catch (e) {
147
+ out.errors.push(`${record.owner}: ${errorText(e)}`);
148
+ continue;
149
+ }
150
+ const reason = cancelReasonOf(decision, record.owner);
151
+ if (reason !== null) {
152
+ out.cancelled = true;
153
+ out.cancelReason = reason;
154
+ out.cancelOwner = record.owner;
155
+ return out;
156
+ }
157
+ const summary = summaryOf(decision);
158
+ if (summary !== null) {
159
+ out.summary = summary;
160
+ out.summaryOwner = record.owner;
161
+ return out;
162
+ }
163
+ }
164
+ return out;
165
+ }
@@ -0,0 +1,189 @@
1
+ // Extension-registered model-callable tools (ticket 02): the runtime store
2
+ // behind ExtensionAPI.registerTool. This module stays dependency-free apart
3
+ // from the sibling overrides store (which itself imports nothing), so the
4
+ // registry and the scheduler can still consult it without a runtime cycle.
5
+ import { isToolExecutionMode } from "./overrides.js";
6
+ const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
7
+ const store = new Map();
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function shapeSummary(parameters) {
12
+ // Compact `{field: type}` hint for error details; falls back to raw JSON.
13
+ try {
14
+ const props = parameters["properties"];
15
+ if (isRecord(props)) {
16
+ const parts = [];
17
+ for (const [k, v] of Object.entries(props)) {
18
+ const t = isRecord(v) && typeof v["type"] === "string" ? v["type"] : "any";
19
+ parts.push(`"${k}": ${t}`);
20
+ }
21
+ const required = Array.isArray(parameters["required"])
22
+ ? ` (required: ${parameters["required"].map((r) => JSON.stringify(r)).join(", ")})`
23
+ : "";
24
+ return `{${parts.join(", ")}}${required}`;
25
+ }
26
+ const raw = JSON.stringify(parameters);
27
+ return raw.length > 200 ? `${raw.slice(0, 200)}…` : raw;
28
+ }
29
+ catch {
30
+ return "{}";
31
+ }
32
+ }
33
+ /**
34
+ * Validate a registration shape. Throws Error on any problem (bad name,
35
+ * empty description, non-object schema, non-function execute). Duplicate
36
+ * and builtin-collision checks live in the registry wrapper
37
+ * (registerExtensionTool), which knows the builtin names.
38
+ */
39
+ export function validateExtensionToolDef(def) {
40
+ if (!isRecord(def))
41
+ throw new Error("extension tool definition must be an object");
42
+ if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
43
+ throw new Error(`extension tool has an invalid name ${JSON.stringify(def.name)} (want 1-64 chars of A-Za-z0-9_-)`);
44
+ }
45
+ if (typeof def.description !== "string" || def.description.trim().length === 0) {
46
+ throw new Error(`extension tool "${def.name}" needs a non-empty description`);
47
+ }
48
+ if (!isRecord(def.parameters) || def.parameters["type"] !== "object") {
49
+ throw new Error(`extension tool "${def.name}" needs a parameters schema object with type "object"`);
50
+ }
51
+ if (typeof def.execute !== "function") {
52
+ throw new Error(`extension tool "${def.name}" needs an execute function`);
53
+ }
54
+ if (def.requireApproval !== undefined && typeof def.requireApproval !== "boolean") {
55
+ throw new Error(`extension tool "${def.name}" field "requireApproval" must be a boolean`);
56
+ }
57
+ if (def.oneLiner !== undefined && typeof def.oneLiner !== "string") {
58
+ throw new Error(`extension tool "${def.name}" field "oneLiner" must be a string`);
59
+ }
60
+ if (def.executionMode !== undefined && !isToolExecutionMode(def.executionMode)) {
61
+ throw new Error(`extension tool "${def.name}" field "executionMode" must be "sequential" or "parallel"`);
62
+ }
63
+ }
64
+ function typeLabel(v) {
65
+ if (v === null)
66
+ return "null";
67
+ if (Array.isArray(v))
68
+ return "array";
69
+ return typeof v;
70
+ }
71
+ function schemaTypeMatches(schema, value) {
72
+ const t = schema["type"];
73
+ if (typeof t !== "string")
74
+ return true; // untyped property: anything goes
75
+ switch (t) {
76
+ case "string":
77
+ return typeof value === "string";
78
+ case "number":
79
+ return typeof value === "number" && Number.isFinite(value);
80
+ case "integer":
81
+ return typeof value === "number" && Number.isInteger(value);
82
+ case "boolean":
83
+ return typeof value === "boolean";
84
+ case "array":
85
+ return Array.isArray(value);
86
+ case "object":
87
+ return isRecord(value);
88
+ case "null":
89
+ return value === null;
90
+ default:
91
+ return true; // unknown type keyword: do not reject
92
+ }
93
+ }
94
+ // Validate parsed args against the tool's parameters schema. Returns a
95
+ // detail string (without prefix) when malformed, or null when valid — the
96
+ // caller frames it with invalidCall so failures surface as inline
97
+ // model-visible errors and the tool never runs.
98
+ export function validateCustomToolArgs(name, args) {
99
+ const rec = store.get(name);
100
+ if (!rec)
101
+ return null;
102
+ if (!isRecord(args)) {
103
+ return `arguments for tool "${name}" must be an object. Expected ${shapeSummary(rec.parameters)}`;
104
+ }
105
+ const exp = shapeSummary(rec.parameters);
106
+ const schema = rec.parameters;
107
+ const required = schema["required"];
108
+ if (Array.isArray(required)) {
109
+ for (const key of required) {
110
+ if (typeof key !== "string")
111
+ continue;
112
+ if (args[key] === undefined) {
113
+ return `missing required field "${key}" for tool "${name}". Expected ${exp}`;
114
+ }
115
+ }
116
+ }
117
+ const props = schema["properties"];
118
+ if (isRecord(props)) {
119
+ for (const [key, propSchema] of Object.entries(props)) {
120
+ const value = args[key];
121
+ if (value === undefined)
122
+ continue;
123
+ if (!isRecord(propSchema))
124
+ continue;
125
+ if (!schemaTypeMatches(propSchema, value)) {
126
+ const want = typeof propSchema["type"] === "string" ? propSchema["type"] : "matching value";
127
+ return `field "${key}" for tool "${name}" must be a ${want} (got ${typeLabel(value)}). Expected ${exp}`;
128
+ }
129
+ const en = propSchema["enum"];
130
+ if (Array.isArray(en) && !en.includes(value)) {
131
+ return `field "${key}" for tool "${name}" must be one of ${JSON.stringify(en)} (got ${JSON.stringify(value)}). Expected ${exp}`;
132
+ }
133
+ }
134
+ }
135
+ if (schema["additionalProperties"] === false && isRecord(props)) {
136
+ for (const key of Object.keys(args)) {
137
+ if (!(key in props)) {
138
+ return `unknown field "${key}" for tool "${name}". Expected ${exp}`;
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ /** Register a validated custom tool. Throws on duplicate names. */
145
+ export function registerCustomTool(def) {
146
+ validateExtensionToolDef(def);
147
+ if (store.has(def.name)) {
148
+ throw new Error(`extension tool "${def.name}" is already registered`);
149
+ }
150
+ const rec = {
151
+ name: def.name,
152
+ description: def.description,
153
+ parameters: def.parameters,
154
+ execute: def.execute,
155
+ requireApproval: def.requireApproval ?? true,
156
+ executionMode: def.executionMode,
157
+ oneLiner: typeof def.oneLiner === "string" && def.oneLiner.length > 0
158
+ ? def.oneLiner
159
+ : def.description.split("\n")[0].slice(0, 120),
160
+ };
161
+ store.set(def.name, rec);
162
+ let live = true;
163
+ return () => {
164
+ if (!live)
165
+ return;
166
+ live = false;
167
+ if (store.get(def.name) === rec)
168
+ store.delete(def.name);
169
+ };
170
+ }
171
+ export function unregisterCustomTool(name) {
172
+ return store.delete(name);
173
+ }
174
+ export function getCustomTool(name) {
175
+ return store.get(name);
176
+ }
177
+ export function isCustomTool(name) {
178
+ return store.has(name);
179
+ }
180
+ export function customToolNames() {
181
+ return [...store.keys()];
182
+ }
183
+ export function listCustomTools() {
184
+ return [...store.values()];
185
+ }
186
+ /** Test seam: drop every custom tool (callers restore one-liners separately). */
187
+ export function clearCustomTools() {
188
+ store.clear();
189
+ }
@@ -0,0 +1,145 @@
1
+ // Tool-call interception store (ticket 03): extension pre/post hooks over
2
+ // every loop-executed tool call (builtins and custom tools alike).
3
+ //
4
+ // Dependency-free like custom.ts (no imports) so the extension host, the
5
+ // registry barrel, and the agentic loop can all share it with no cycle:
6
+ // extensions register here, the loop applies here, nobody imports the other.
7
+ //
8
+ // Semantics:
9
+ // - Before handlers run in registration order and see each call pre-
10
+ // validation and pre-approval. Each may return { args } to rewrite the
11
+ // arguments (later handlers see the rewrite) or { block: reason } / a
12
+ // reason string / { block: true } to veto the execution. The first block
13
+ // wins: later handlers never run for a blocked call, approval is skipped
14
+ // entirely, and the reason commits as a normal model-visible result.
15
+ // - A throwing (or rejecting) before handler fails CLOSED: the call is
16
+ // blocked with a handler-failed reason and the turn continues. Unknown
17
+ // behavior never executes blindly.
18
+ // - After handlers run in registration order inside the commit funnel, so
19
+ // they observe every committed result (executions, blocks, denials,
20
+ // validation errors). Each may return a string or { content } to patch
21
+ // what the model sees. A throwing after handler fails OPEN to the
22
+ // original result — a patch must never break the turn or the
23
+ // tool_call_id re-pairing/commit order around it.
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function errorText(e) {
28
+ return e instanceof Error ? e.message : String(e ?? "unknown error");
29
+ }
30
+ const beforeHandlers = [];
31
+ const afterHandlers = [];
32
+ /** Register a pre-execution interceptor. Returns an unregister function. */
33
+ export function registerBeforeToolCall(handler, owner = "(unknown)") {
34
+ if (typeof handler !== "function") {
35
+ throw new Error("before-tool-call handler must be a function");
36
+ }
37
+ const record = { owner, handler };
38
+ beforeHandlers.push(record);
39
+ let live = true;
40
+ return () => {
41
+ if (!live)
42
+ return;
43
+ live = false;
44
+ const idx = beforeHandlers.indexOf(record);
45
+ if (idx >= 0)
46
+ beforeHandlers.splice(idx, 1);
47
+ };
48
+ }
49
+ /** Register a post-execution result patcher. Returns an unregister function. */
50
+ export function registerAfterToolCall(handler, owner = "(unknown)") {
51
+ if (typeof handler !== "function") {
52
+ throw new Error("after-tool-call handler must be a function");
53
+ }
54
+ const record = { owner, handler };
55
+ afterHandlers.push(record);
56
+ let live = true;
57
+ return () => {
58
+ if (!live)
59
+ return;
60
+ live = false;
61
+ const idx = afterHandlers.indexOf(record);
62
+ if (idx >= 0)
63
+ afterHandlers.splice(idx, 1);
64
+ };
65
+ }
66
+ /** Snapshot of live before handlers in registration order (deterministic composition). */
67
+ export function beforeToolInterceptors() {
68
+ return [...beforeHandlers];
69
+ }
70
+ /** Snapshot of live after handlers in registration order. */
71
+ export function afterToolInterceptors() {
72
+ return [...afterHandlers];
73
+ }
74
+ /** Test seam: drop every interceptor. */
75
+ export function clearToolInterceptors() {
76
+ beforeHandlers.length = 0;
77
+ afterHandlers.length = 0;
78
+ }
79
+ /** Model-visible result for a blocked call (an `Error:` result, committed normally — the turn continues). */
80
+ export function blockedToolResult(name, owner, reason) {
81
+ const trimmed = reason.trim();
82
+ const by = owner.length > 0 ? `extension "${owner}"` : "extension";
83
+ return trimmed.length > 0
84
+ ? `Error: blocked by ${by}: ${trimmed}`
85
+ : `Error: blocked by ${by}: tool "${name}" was blocked`;
86
+ }
87
+ // Apply before handlers sequentially in registration order. Never throws:
88
+ // a throwing handler fails closed to a blocked outcome carrying the cause.
89
+ export async function applyBeforeInterceptors(handlers, name, args) {
90
+ let current = args;
91
+ for (const record of handlers) {
92
+ let decision;
93
+ try {
94
+ decision = await record.handler({ name, args: current });
95
+ }
96
+ catch (e) {
97
+ return {
98
+ args: current,
99
+ blocked: blockedToolResult(name, record.owner, `handler failed: ${errorText(e)}`),
100
+ };
101
+ }
102
+ if (typeof decision === "string") {
103
+ // Convenience form: a returned string is a block reason.
104
+ return { args: current, blocked: blockedToolResult(name, record.owner, decision) };
105
+ }
106
+ if (!isRecord(decision))
107
+ continue;
108
+ const next = decision;
109
+ if (isRecord(next["args"]))
110
+ current = next["args"];
111
+ const block = next["block"];
112
+ if (block === true) {
113
+ return { args: current, blocked: blockedToolResult(name, record.owner, "") };
114
+ }
115
+ if (typeof block === "string" && block.trim().length > 0) {
116
+ return { args: current, blocked: blockedToolResult(name, record.owner, block) };
117
+ }
118
+ }
119
+ return { args: current, blocked: null };
120
+ }
121
+ // Apply after handlers sequentially; each sees the previous patch. Never
122
+ // throws: a throwing handler fails open to the content so far.
123
+ export async function applyAfterInterceptors(handlers, input) {
124
+ let content = input.result;
125
+ const isError = input.isError;
126
+ for (const record of handlers) {
127
+ let decision;
128
+ try {
129
+ decision = await record.handler({ name: input.name, args: input.args, result: content, isError });
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (typeof decision === "string") {
135
+ content = decision;
136
+ continue;
137
+ }
138
+ if (!isRecord(decision))
139
+ continue;
140
+ const patch = decision["content"];
141
+ if (typeof patch === "string")
142
+ content = patch;
143
+ }
144
+ return { content, isError };
145
+ }
@@ -0,0 +1,105 @@
1
+ export function isToolExecutionMode(value) {
2
+ return value === "sequential" || value === "parallel";
3
+ }
4
+ const overrides = new Map();
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
9
+ /**
10
+ * Validate an override shape. Throws Error on any problem (bad name,
11
+ * non-function execute, bad executionMode). Builtin-existence and duplicate
12
+ * checks live in the registry wrapper (registerExtensionToolOverride), which
13
+ * knows the builtin names.
14
+ */
15
+ export function validateExtensionToolOverrideDef(def) {
16
+ if (!isRecord(def))
17
+ throw new Error("extension tool override definition must be an object");
18
+ if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
19
+ throw new Error(`extension tool override has an invalid name ${JSON.stringify(def.name)} (want 1-64 chars of A-Za-z0-9_-)`);
20
+ }
21
+ if (typeof def.execute !== "function") {
22
+ throw new Error(`extension tool override "${def.name}" needs an execute function`);
23
+ }
24
+ if (def.executionMode !== undefined && !isToolExecutionMode(def.executionMode)) {
25
+ throw new Error(`extension tool override "${def.name}" field "executionMode" must be "sequential" or "parallel"`);
26
+ }
27
+ }
28
+ /** Register a validated override. Throws on duplicate names. */
29
+ export function registerToolOverride(def, owner = "(unknown)") {
30
+ validateExtensionToolOverrideDef(def);
31
+ if (overrides.has(def.name)) {
32
+ throw new Error(`extension tool override "${def.name}" is already registered`);
33
+ }
34
+ const rec = {
35
+ name: def.name,
36
+ execute: def.execute,
37
+ executionMode: def.executionMode,
38
+ owner,
39
+ };
40
+ overrides.set(def.name, rec);
41
+ let live = true;
42
+ return () => {
43
+ if (!live)
44
+ return;
45
+ live = false;
46
+ if (overrides.get(def.name) === rec)
47
+ overrides.delete(def.name);
48
+ };
49
+ }
50
+ export function unregisterToolOverride(name) {
51
+ return overrides.delete(name);
52
+ }
53
+ export function getToolOverride(name) {
54
+ return overrides.get(name);
55
+ }
56
+ export function isToolOverridden(name) {
57
+ return overrides.has(name);
58
+ }
59
+ /** Snapshot of live overrides in registration order (audit surface). */
60
+ export function listToolOverrides() {
61
+ return [...overrides.values()];
62
+ }
63
+ /** Test seam: drop every tool override. */
64
+ export function clearToolOverrides() {
65
+ overrides.clear();
66
+ }
67
+ // ---- Extension prompt hints (same ticket, same audit posture) ----
68
+ // Short model-facing guidance strings ("prefer X with tool Y") contributed by
69
+ // extensions. They reach the model only through the existing prompt assembly
70
+ // (zen buildSystemPrompt appends them under an "Extension hints" section) —
71
+ // no parallel prompt pipeline is ever introduced. Registration order is kept.
72
+ export const MAX_PROMPT_HINT_CHARS = 2000;
73
+ const promptHints = [];
74
+ /** Validate a prompt hint shape. Throws Error on any problem. */
75
+ export function validateExtensionPromptHint(hint) {
76
+ if (typeof hint !== "string" || hint.trim().length === 0) {
77
+ throw new Error("extension prompt hint must be a non-empty string");
78
+ }
79
+ if (hint.length > MAX_PROMPT_HINT_CHARS) {
80
+ throw new Error(`extension prompt hint exceeds ${MAX_PROMPT_HINT_CHARS} chars (got ${hint.length})`);
81
+ }
82
+ }
83
+ /** Register a prompt hint. Returns an unregister function. */
84
+ export function registerExtensionPromptHint(hint, owner = "(unknown)") {
85
+ validateExtensionPromptHint(hint);
86
+ const record = { text: hint, owner };
87
+ promptHints.push(record);
88
+ let live = true;
89
+ return () => {
90
+ if (!live)
91
+ return;
92
+ live = false;
93
+ const idx = promptHints.indexOf(record);
94
+ if (idx >= 0)
95
+ promptHints.splice(idx, 1);
96
+ };
97
+ }
98
+ /** Hint texts in registration order (what the prompt assembly appends). */
99
+ export function getExtensionPromptHints() {
100
+ return promptHints.map((h) => h.text);
101
+ }
102
+ /** Test seam: drop every prompt hint. */
103
+ export function clearExtensionPromptHints() {
104
+ promptHints.length = 0;
105
+ }