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,224 @@
1
+ // Provider request/response hooks (ticket 08): extension hooks over every
2
+ // live model POST (redaction, routing, logging, header injection).
3
+ //
4
+ // Dependency-free like intercept.ts (type-only imports) so the extension
5
+ // host, the transports (zen.ts), and the tests can all share it with no
6
+ // cycle: extensions register here, the transports apply here, nobody imports
7
+ // the other.
8
+ //
9
+ // Semantics (fail-open vs fail-closed per hook):
10
+ // - Context transform (fail OPEN): handlers run in registration order over
11
+ // the outgoing messages; each sees the previous handler's output and may
12
+ // return a replacement array (void/null/undefined passes through). A
13
+ // throwing handler — or a non-array / malformed-array return — degrades to
14
+ // the untransformed value so far, never a broken request. Fail-open (not
15
+ // fail-closed like before-tool-call) because there is no unsafe execution
16
+ // to prevent here — only request shaping — and a buggy redactor must not
17
+ // hold every model round hostage (same rationale as the before_switch
18
+ // fail-open gate). Extensions needing guaranteed redaction keep handlers
19
+ // total (try/catch inside the handler).
20
+ // - Pre-request (fail OPEN): handlers run in registration order over the
21
+ // assembled payload + headers; each sees the previous handler's output and
22
+ // may return { payload } to replace the body wholesale and/or { headers }
23
+ // to set per-key values. A throwing handler is skipped (its change
24
+ // dropped, the chain continues) so one buggy header injection never fails
25
+ // the turn. A non-record payload replacement is ignored — the replacement
26
+ // still flows through the downstream JSON/fetch handling, never bypassing
27
+ // it. Header merge is per-key: a string sets/overwrites, null/undefined
28
+ // DELETES the key, anything else is ignored (never silently stringified).
29
+ // - Post-response (fail OPEN, observe-only): handlers run in registration
30
+ // order with a { provider, model, url, status, ok, headers } snapshot after
31
+ // every resolved POST (ok and HTTP-error alike; network throws have no
32
+ // response to observe and never fire). Return values are ignored; a
33
+ // throwing handler is dropped and the turn continues untouched.
34
+ //
35
+ // Coverage: the three transports in zen.ts (openai-chat via chatCompletion,
36
+ // anthropic-messages via chatCompletionAnthropic, gemini-generate via
37
+ // chatCompletionGemini) each apply all three hooks per POST, so every
38
+ // provider kind — zen, kilo, openai, deepseek, mistral, openai-compatible,
39
+ // local runtimes, anthropic, google-gemini — is covered through the single
40
+ // chatCompletionForProvider dispatcher (which delegates to those three).
41
+ // The loop transcript itself is never mutated: hooks transform per-POST
42
+ // copies only.
43
+ function isRecord(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+ const VALID_ROLES = new Set(["system", "user", "assistant", "tool"]);
47
+ function isMessages(value) {
48
+ if (!Array.isArray(value))
49
+ return false;
50
+ for (const m of value) {
51
+ if (!isRecord(m))
52
+ return false;
53
+ if (typeof m["role"] !== "string" || !VALID_ROLES.has(m["role"]))
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+ const contextHandlers = [];
59
+ const beforeRequestHandlers = [];
60
+ const afterResponseHandlers = [];
61
+ function registerOwned(store, handler, owner, what) {
62
+ if (typeof handler !== "function") {
63
+ throw new Error(`${what} handler must be a function`);
64
+ }
65
+ const record = { owner, handler };
66
+ store.push(record);
67
+ let live = true;
68
+ return () => {
69
+ if (!live)
70
+ return;
71
+ live = false;
72
+ const idx = store.indexOf(record);
73
+ if (idx >= 0)
74
+ store.splice(idx, 1);
75
+ };
76
+ }
77
+ /** Register an outgoing-context transformer. Returns an unregister function. */
78
+ export function registerContextTransform(handler, owner = "(unknown)") {
79
+ return registerOwned(contextHandlers, handler, owner, "context-transform");
80
+ }
81
+ /** Register a pre-request payload/header hook. Returns an unregister function. */
82
+ export function registerBeforeRequest(handler, owner = "(unknown)") {
83
+ return registerOwned(beforeRequestHandlers, handler, owner, "before-request");
84
+ }
85
+ /** Register a post-response observer. Returns an unregister function. */
86
+ export function registerAfterResponse(handler, owner = "(unknown)") {
87
+ return registerOwned(afterResponseHandlers, handler, owner, "after-response");
88
+ }
89
+ /** Snapshot of live context handlers in registration order (deterministic composition). */
90
+ export function contextTransformers() {
91
+ return [...contextHandlers];
92
+ }
93
+ /** Snapshot of live pre-request handlers in registration order. */
94
+ export function beforeRequestInterceptors() {
95
+ return [...beforeRequestHandlers];
96
+ }
97
+ /** Snapshot of live post-response observers in registration order. */
98
+ export function afterResponseObservers() {
99
+ return [...afterResponseHandlers];
100
+ }
101
+ /** Test seam: drop every provider hook. */
102
+ export function clearProviderHooks() {
103
+ contextHandlers.length = 0;
104
+ beforeRequestHandlers.length = 0;
105
+ afterResponseHandlers.length = 0;
106
+ }
107
+ // Apply context handlers sequentially in registration order. Never throws:
108
+ // a throwing (or malformed-returning) handler degrades to the value so far.
109
+ export async function applyContextTransform(handlers, messages) {
110
+ // The first handler receives a deep copy, never the loop's live array:
111
+ // in-place mutation by a handler must not corrupt the transcript (the
112
+ // zen.ts call-site contract). structuredClone covers plain chat payloads;
113
+ // the JSON fallback covers exotic values; live is the last resort.
114
+ let current;
115
+ try {
116
+ current = structuredClone(messages);
117
+ }
118
+ catch {
119
+ try {
120
+ current = JSON.parse(JSON.stringify(messages));
121
+ }
122
+ catch {
123
+ current = messages;
124
+ }
125
+ }
126
+ for (const record of handlers) {
127
+ let next;
128
+ try {
129
+ next = await record.handler(current);
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (next === undefined || next === null)
135
+ continue;
136
+ if (!isMessages(next))
137
+ continue;
138
+ current = next;
139
+ }
140
+ return current;
141
+ }
142
+ // Apply pre-request handlers sequentially; each sees the previous output.
143
+ // Never throws: a throwing handler is skipped, a non-record payload is
144
+ // ignored, and header values merge per-key (string sets, null/undefined
145
+ // deletes, anything else ignored).
146
+ export async function applyBeforeRequest(handlers, input) {
147
+ let payload = input.payload;
148
+ let headers = { ...input.headers };
149
+ for (const record of handlers) {
150
+ let decision;
151
+ try {
152
+ decision = await record.handler({
153
+ provider: input.provider,
154
+ model: input.model,
155
+ url: input.url,
156
+ payload,
157
+ headers: { ...headers },
158
+ });
159
+ }
160
+ catch {
161
+ continue;
162
+ }
163
+ if (!isRecord(decision))
164
+ continue;
165
+ const rep = decision["payload"];
166
+ if (rep !== undefined && isRecord(rep))
167
+ payload = rep;
168
+ const hm = decision["headers"];
169
+ if (isRecord(hm)) {
170
+ for (const [k, v] of Object.entries(hm)) {
171
+ if (typeof v === "string")
172
+ headers[k] = v;
173
+ else if (v === null || v === undefined)
174
+ delete headers[k];
175
+ }
176
+ }
177
+ }
178
+ return { payload, headers };
179
+ }
180
+ // Notify post-response observers sequentially. Never throws and never
181
+ // rejects: observer failures are dropped, the turn continues untouched.
182
+ export async function notifyAfterResponse(handlers, input) {
183
+ for (const record of handlers) {
184
+ try {
185
+ await record.handler(input);
186
+ }
187
+ catch {
188
+ // observe-only: failures never break the turn
189
+ }
190
+ }
191
+ }
192
+ /**
193
+ * Snapshot response headers into a plain record. Never throws: unknown
194
+ * shapes (mocks with a bare .get, missing headers) yield {}.
195
+ */
196
+ export function snapshotResponseHeaders(res) {
197
+ const out = {};
198
+ try {
199
+ const headers = res?.headers;
200
+ if (headers === null || headers === undefined)
201
+ return out;
202
+ const forEach = headers.forEach;
203
+ if (typeof forEach === "function") {
204
+ forEach.call(headers, (v, k) => {
205
+ if (typeof k === "string" && typeof v === "string")
206
+ out[k.toLowerCase()] = v;
207
+ });
208
+ return out;
209
+ }
210
+ const iter = headers[Symbol.iterator];
211
+ if (typeof iter === "function") {
212
+ for (const entry of headers) {
213
+ if (Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "string") {
214
+ out[entry[0].toLowerCase()] = entry[1];
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+ }
220
+ catch {
221
+ // fall through to {}
222
+ }
223
+ return out;
224
+ }
@@ -7,6 +7,14 @@ import { editTool, readTool, writeTool, } from "./filesystem.js";
7
7
  import { GREP_OUTPUT_MODES, globTool, grepTool } from "./search.js";
8
8
  import { bashOutputTool, bashTool, } from "./shell.js";
9
9
  import { err, invalidCall } from "./shared.js";
10
+ import { clearCustomTools, customToolNames, getCustomTool, isCustomTool, listCustomTools, registerCustomTool, unregisterCustomTool, validateCustomToolArgs, validateExtensionToolDef, } from "./custom.js";
11
+ export { validateExtensionToolDef } from "./custom.js";
12
+ import { getToolOverride, isToolOverridden, registerToolOverride, unregisterToolOverride, validateExtensionToolOverrideDef, } from "./overrides.js";
13
+ // NOTE: the override/prompt-hint store functions (registerToolOverride,
14
+ // getToolOverride, getExtensionPromptHints, ...) are intentionally NOT
15
+ // re-exported here as values: the tools barrel star-exports both this module
16
+ // and overrides.js, so a value re-export would make those names ambiguous.
17
+ // Import them from "./tools/overrides.js" (or "../src/tools.js") directly.
10
18
  import { todoGetTool, todoUpdateTool, todowriteTool, } from "./todo.js";
11
19
  import { webfetchTool, websearchTool, } from "./web.js";
12
20
  export const MAX_TOOL_STEPS = 30;
@@ -23,13 +31,49 @@ export const MAX_TOOL_STEPS = 30;
23
31
  export const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "webfetch", "websearch", "bash_output", "todowrite", "todo_get", "todo_update"]);
24
32
  export const APPROVAL_TOOLS = new Set(["write", "edit", "bash"]);
25
33
  export function needsApproval(name) {
26
- return APPROVAL_TOOLS.has(name);
34
+ if (APPROVAL_TOOLS.has(name))
35
+ return true;
36
+ // Approval policy for extension tools: custom tools REQUIRE approval by
37
+ // default. Extension code runs with full user privileges and its footprint
38
+ // is invisible to the scheduler, so fail-closed is the only safe default.
39
+ // An extension may opt out per tool with requireApproval:false, reserved
40
+ // for pure side-effect-free helpers — the opt-out is explicit at
41
+ // registration, never ambient.
42
+ const custom = getCustomTool(name);
43
+ if (custom)
44
+ return custom.requireApproval;
45
+ return false;
27
46
  }
28
- // Known tool names (single source: TOOL_DEFINITIONS, defined below). The
29
- // validator + loop build "Available: ..." lists from this so the message
30
- // can never drift from the schema.
47
+ // Known tool names (single source: builtin TOOL_DEFINITIONS plus
48
+ // extension-registered custom tools, defined below). The validator + loop
49
+ // build "Available: ..." lists from this so the message can never drift
50
+ // from the schema.
31
51
  export function toolNames() {
32
- return TOOL_DEFINITIONS.map((t) => t.function.name);
52
+ return [...TOOL_DEFINITIONS.map((t) => t.function.name), ...customToolNames()];
53
+ }
54
+ // Every definition the model sees: builtins plus extension tools. The raw
55
+ // TOOL_DEFINITIONS export stays builtin-only (tests pin its 13 entries);
56
+ // chat payloads must use this so custom tools are discoverable. A builtin
57
+ // shadowed by an extension override (ticket 06) keeps its name, schema, and
58
+ // position, but its description carries an audit-visible override marker so
59
+ // the shadowing is never silent.
60
+ export function allToolDefinitions() {
61
+ return [
62
+ ...TOOL_DEFINITIONS.map((t) => isToolOverridden(t.function.name)
63
+ ? {
64
+ type: "function",
65
+ function: {
66
+ name: t.function.name,
67
+ description: `${t.function.description}\n[overridden by extension "${getToolOverride(t.function.name).owner}"]`,
68
+ parameters: t.function.parameters,
69
+ },
70
+ }
71
+ : t),
72
+ ...listCustomTools().map((c) => ({
73
+ type: "function",
74
+ function: { name: c.name, description: c.description, parameters: c.parameters },
75
+ })),
76
+ ];
33
77
  }
34
78
  function typeLabel(v) {
35
79
  if (v === null)
@@ -81,6 +125,10 @@ export function validateToolArgs(name, args) {
81
125
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
82
126
  return `arguments for tool "${name}" must be an object. Expected ${expectedShape(name)}`;
83
127
  }
128
+ // Extension tools validate against their own parameters schema (same
129
+ // invalidCall framing as builtins: the tool never runs on bad args).
130
+ if (isCustomTool(name))
131
+ return validateCustomToolArgs(name, args);
84
132
  const a = args;
85
133
  const exp = expectedShape(name);
86
134
  switch (name) {
@@ -270,6 +318,65 @@ export async function executeTool(name, args, cwd = process.cwd()) {
270
318
  if (!known.has(name)) {
271
319
  return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
272
320
  }
321
+ // Extension tools run through the same validate-then-execute gate as
322
+ // builtins: bad args are inline model-visible errors, and a throwing
323
+ // implementation degrades to an `Error:` result string — never a crash, so
324
+ // call pairing in the loop stays valid.
325
+ if (isCustomTool(name)) {
326
+ const custom = getCustomTool(name);
327
+ if (!custom) {
328
+ return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
329
+ }
330
+ const detail = validateToolArgs(name, a);
331
+ if (detail)
332
+ return invalidCall(detail);
333
+ try {
334
+ const out = await custom.execute(a, { cwd });
335
+ if (typeof out === "string")
336
+ return out;
337
+ try {
338
+ return JSON.stringify(out) ?? String(out);
339
+ }
340
+ catch {
341
+ return String(out);
342
+ }
343
+ }
344
+ catch (e) {
345
+ return e instanceof Error ? `Error: ${e.message}` : `Error: ${String(e)}`;
346
+ }
347
+ }
348
+ // Extension override (ticket 06): an explicitly shadowed builtin routes
349
+ // through the override, which decides per call — deny a subset with a
350
+ // reason (throw, or return an `Error:` result) or pass the rest through
351
+ // via ctx.passthrough (the default). Builtin arg validation runs first on
352
+ // the received args, exactly as without the override, so model mistakes
353
+ // never reach extension code; the passthrough re-validates whatever it is
354
+ // given, so the override can never smuggle unvalidated args into the
355
+ // pristine builtin. A throwing override degrades to an `Error:` result
356
+ // string — never a crash, so call pairing in the loop stays valid.
357
+ const override = getToolOverride(name);
358
+ if (override) {
359
+ const overrideDetail = validateToolArgs(name, a);
360
+ if (overrideDetail)
361
+ return invalidCall(overrideDetail);
362
+ try {
363
+ const out = await override.execute(a, {
364
+ cwd,
365
+ passthrough: (passthroughArgs = a) => executeBuiltinTool(name, passthroughArgs, cwd),
366
+ });
367
+ if (typeof out === "string")
368
+ return out;
369
+ try {
370
+ return JSON.stringify(out) ?? String(out);
371
+ }
372
+ catch {
373
+ return String(out);
374
+ }
375
+ }
376
+ catch (e) {
377
+ return e instanceof Error ? `Error: ${e.message}` : `Error: ${String(e)}`;
378
+ }
379
+ }
273
380
  // ask_question keeps its dedicated hook-missing path, but validation
274
381
  // still comes first (validateAskQuestionArgs already uses invalidCall).
275
382
  if (name === "ask_question") {
@@ -282,40 +389,70 @@ export async function executeTool(name, args, cwd = process.cwd()) {
282
389
  return err("ask_question has no UI hook");
283
390
  }
284
391
  const detail = validateToolArgs(name, a);
392
+ if (detail)
393
+ return invalidCall(detail);
394
+ return executeBuiltinTool(name, a, cwd);
395
+ }
396
+ // Pristine builtin execution (ticket 06: the override passthrough target).
397
+ // Validation first, then the builtin executor — exactly the path above, so
398
+ // pass-through behavior is byte-identical to no override. Never consults the
399
+ // override store, so recursion is impossible by construction. A throwing
400
+ // executor degrades to an `Error:` result string, never a crash.
401
+ async function executeBuiltinTool(name, args, cwd) {
402
+ // ask_question keeps its dedicated hook-missing path, but validation
403
+ // still comes first (validateAskQuestionArgs already uses invalidCall).
404
+ if (name === "ask_question") {
405
+ // No UI hook at this layer: the agentic loop intercepts ask_question
406
+ // and serves it via its askUser hook. Direct calls validate, then
407
+ // report the missing hook as a result string (never throw).
408
+ const invalid = validateAskQuestionArgs(args);
409
+ if (invalid)
410
+ return invalid;
411
+ return err("ask_question has no UI hook");
412
+ }
413
+ const detail = validateToolArgs(name, args);
285
414
  if (detail)
286
415
  return invalidCall(detail);
287
416
  switch (name) {
288
417
  case "read":
289
- return readTool(a, cwd);
418
+ return readTool(args, cwd);
290
419
  case "write":
291
- return writeTool(a, cwd);
420
+ return writeTool(args, cwd);
292
421
  case "glob":
293
- return globTool(a, cwd);
422
+ return globTool(args, cwd);
294
423
  case "grep":
295
- return grepTool(a, cwd);
424
+ return grepTool(args, cwd);
296
425
  case "edit":
297
- return editTool(a, cwd);
426
+ return editTool(args, cwd);
298
427
  case "bash":
299
- return bashTool(a, cwd);
428
+ return bashTool(args, cwd);
300
429
  case "bash_output":
301
- return bashOutputTool(a);
430
+ return bashOutputTool(args);
302
431
  case "webfetch":
303
- return webfetchTool(a);
432
+ return webfetchTool(args);
304
433
  case "websearch":
305
- return websearchTool(a);
434
+ return websearchTool(args);
306
435
  case "todowrite":
307
- return todowriteTool(a);
436
+ return todowriteTool(args);
308
437
  case "todo_get":
309
438
  return todoGetTool();
310
439
  case "todo_update":
311
- return todoUpdateTool(a);
440
+ return todoUpdateTool(args);
312
441
  default:
313
442
  // Unreachable: unknown names return above with the Available list.
314
443
  return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
315
444
  }
316
445
  }
317
- // One-line TUI label for a tool call, e.g. "⚙ read src/zen.ts".
446
+ // One-line TUI label for a tool call, e.g. "⚙ read src/zen.ts". A call to
447
+ // a shadowed builtin (ticket 06) carries an audit-visible override suffix
448
+ // so the shadowing is visible in the activity line too — pristine builtins
449
+ // render byte-identically to before.
318
450
  export function describeToolCall(name, args) {
451
+ const label = describeToolCallBase(name, args);
452
+ const over = getToolOverride(name);
453
+ return over ? `${label} (override: ${over.owner})` : label;
454
+ }
455
+ function describeToolCallBase(name, args) {
319
456
  const a = (args ?? {});
320
457
  const str = (v) => (typeof v === "string" ? v : "");
321
458
  const describePath = (p) => {
@@ -801,3 +938,95 @@ export const TOOL_ONE_LINERS = {
801
938
  todo_get: "Read the session task checklist.",
802
939
  todo_update: "Check off or edit one session task.",
803
940
  };
941
+ // Extension-tool registration seam (ticket 02): the ExtensionAPI calls
942
+ // this, never the custom store directly, so builtin collisions are rejected
943
+ // here where the builtin names are known. Throws on bad shapes, builtin
944
+ // collisions, and duplicate custom names — a loud error, never a silent
945
+ // shadow. Returns an unregister function for hot-reload style removal.
946
+ export function registerExtensionTool(def) {
947
+ validateExtensionToolDef(def);
948
+ if (TOOL_DEFINITIONS.some((t) => t.function.name === def.name)) {
949
+ throw new Error(`extension tool "${def.name}" collides with a builtin tool`);
950
+ }
951
+ const unregister = registerCustomTool(def);
952
+ const oneLiner = getCustomTool(def.name)?.oneLiner;
953
+ if (oneLiner)
954
+ TOOL_ONE_LINERS[def.name] = oneLiner;
955
+ let live = true;
956
+ return () => {
957
+ if (!live)
958
+ return;
959
+ live = false;
960
+ unregister();
961
+ delete TOOL_ONE_LINERS[def.name];
962
+ };
963
+ }
964
+ export function unregisterExtensionTool(name) {
965
+ const wasCustom = isCustomTool(name);
966
+ const removed = unregisterCustomTool(name);
967
+ // Only custom one-liners are ever added above: builtin entries stay intact.
968
+ if (wasCustom)
969
+ delete TOOL_ONE_LINERS[name];
970
+ return removed;
971
+ }
972
+ /** Test seam: drop every extension tool and its /tools one-liner. */
973
+ export function clearExtensionTools() {
974
+ for (const name of customToolNames())
975
+ delete TOOL_ONE_LINERS[name];
976
+ clearCustomTools();
977
+ }
978
+ // Extension-tool override seam (ticket 06): the ExtensionAPI calls this,
979
+ // never the override store directly, so non-builtin names are rejected here
980
+ // where the builtin names are known. Shadowing is explicit and audited: the
981
+ // override replaces the builtin everywhere (definitions carry an
982
+ // `[overridden by extension "X"]` marker, the activity label a
983
+ // `(override: X)` suffix, the /tools one-liner an `(override: X)` suffix),
984
+ // and removing it restores the pristine builtin with no residue. Throws on
985
+ // bad shapes, non-builtin names, and duplicate overrides — a loud error,
986
+ // never a silent shadow. Returns an unregister function.
987
+ export function registerExtensionToolOverride(def, owner = "(unknown)") {
988
+ validateExtensionToolOverrideDef(def);
989
+ if (!TOOL_DEFINITIONS.some((t) => t.function.name === def.name)) {
990
+ throw new Error(`extension tool override "${def.name}" is not a builtin tool (only builtins can be overridden)`);
991
+ }
992
+ const unregister = registerToolOverride(def, owner);
993
+ const pristineOneLiner = TOOL_ONE_LINERS[def.name];
994
+ if (pristineOneLiner !== undefined) {
995
+ TOOL_ONE_LINERS[def.name] = `${pristineOneLiner} (override: ${owner})`;
996
+ }
997
+ let live = true;
998
+ return () => {
999
+ if (!live)
1000
+ return;
1001
+ live = false;
1002
+ unregister();
1003
+ // Restore the pristine one-liner byte-identically (no residue): only the
1004
+ // marked value we set above is ever replaced; a foreign value is left
1005
+ // alone so a concurrent edit can never be clobbered.
1006
+ if (pristineOneLiner !== undefined && TOOL_ONE_LINERS[def.name] === `${pristineOneLiner} (override: ${owner})`) {
1007
+ TOOL_ONE_LINERS[def.name] = pristineOneLiner;
1008
+ }
1009
+ };
1010
+ }
1011
+ export function unregisterExtensionToolOverride(name) {
1012
+ const over = getToolOverride(name);
1013
+ const removed = unregisterToolOverride(name);
1014
+ // Restore the pristine one-liner only when it still carries our marker.
1015
+ if (over && removed && typeof TOOL_ONE_LINERS[name] === "string") {
1016
+ const marker = ` (override: ${over.owner})`;
1017
+ if (TOOL_ONE_LINERS[name].endsWith(marker)) {
1018
+ TOOL_ONE_LINERS[name] = TOOL_ONE_LINERS[name].slice(0, -marker.length);
1019
+ }
1020
+ }
1021
+ return removed;
1022
+ }
1023
+ // Scheduling hint for one tool name (ticket 06): the override's mode wins
1024
+ // for shadowed builtins, else the custom tool's mode, else undefined
1025
+ // (builtins carry no hint — the effect table drives them). "sequential"
1026
+ // forces the whole sibling batch one-at-a-time; "parallel" is advisory.
1027
+ export function toolExecutionMode(name) {
1028
+ const over = getToolOverride(name);
1029
+ if (over?.executionMode !== undefined)
1030
+ return over.executionMode;
1031
+ return getCustomTool(name)?.executionMode;
1032
+ }
package/dist/tools.js CHANGED
@@ -8,6 +8,11 @@
8
8
  // executors, and the registry that owns names, validation, and dispatch).
9
9
  // This file is a pure barrel so every existing `from "./tools.js"` /
10
10
  // `"../src/tools.js"` import keeps working untouched.
11
+ export * from "./tools/custom.js";
12
+ export * from "./tools/compaction-hooks.js";
13
+ export * from "./tools/intercept.js";
14
+ export * from "./tools/overrides.js";
15
+ export * from "./tools/provider-hooks.js";
11
16
  export * from "./tools/filesystem.js";
12
17
  export * from "./tools/fingerprints.js";
13
18
  export * from "./tools/dir-cache.js";
@@ -20,3 +25,42 @@ export * from "./tools/shared.js";
20
25
  export * from "./tools/shell.js";
21
26
  export * from "./tools/todo.js";
22
27
  export * from "./tools/web.js";
28
+ export const UPDATE_GOAL_TOOL_DEFINITION = {
29
+ type: "function",
30
+ function: {
31
+ name: "update_goal",
32
+ description: "Report this goal turn's outcome (goal-scoped: only available during an active goal turn). " +
33
+ "WHEN to use: at the end of each goal turn — status \"continue\" with the next action, " +
34
+ "or \"complete\"/\"blocked\" with a reason. " +
35
+ "A \"complete\" lands only on genuinely finished work: verified checks and resolved todos. " +
36
+ "Checks you could not run go in \"unverified\" (recorded openly in the closing summary, never a gate). " +
37
+ "WHEN NOT to use: never outside a goal turn (it records nothing there); " +
38
+ "a turn with no report continues the goal.",
39
+ parameters: {
40
+ type: "object",
41
+ properties: {
42
+ status: {
43
+ type: "string",
44
+ enum: ["continue", "complete", "blocked"],
45
+ description: "Turn outcome: \"continue\" (keep working), \"complete\" (goal done), \"blocked\" (cannot proceed).",
46
+ },
47
+ next: {
48
+ type: "string",
49
+ description: "Next action (only with status \"continue\"; omit otherwise).",
50
+ },
51
+ reason: {
52
+ type: "string",
53
+ description: "Why the goal is done or stuck (required with \"complete\"/\"blocked\"; omit otherwise).",
54
+ },
55
+ unverified: {
56
+ type: "array",
57
+ items: { type: "string" },
58
+ description: "Checks that could not be run (only with status \"complete\"; omit otherwise). " +
59
+ "Recorded openly in the closing summary; at most 10 non-empty items of 200 characters each.",
60
+ },
61
+ },
62
+ required: ["status"],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ };
@@ -9,7 +9,7 @@ import { Box, Text } from "ink";
9
9
  import { PickerMoreAbove, PickerMoreBelow, pickerWindow } from "./pickers.js";
10
10
  import { theme } from "./theme.js";
11
11
  export const PALETTE_WINDOW = 12;
12
- export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Help"];
12
+ export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Extensions", "Help"];
13
13
  const PALETTE_CATEGORIES = {
14
14
  "/model": "Model",
15
15
  "/provider": "Model",