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.
- package/CHANGELOG.md +75 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/palette.js +1 -1
- package/dist/ui/status-bar.js +80 -5
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- 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
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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(
|
|
418
|
+
return readTool(args, cwd);
|
|
290
419
|
case "write":
|
|
291
|
-
return writeTool(
|
|
420
|
+
return writeTool(args, cwd);
|
|
292
421
|
case "glob":
|
|
293
|
-
return globTool(
|
|
422
|
+
return globTool(args, cwd);
|
|
294
423
|
case "grep":
|
|
295
|
-
return grepTool(
|
|
424
|
+
return grepTool(args, cwd);
|
|
296
425
|
case "edit":
|
|
297
|
-
return editTool(
|
|
426
|
+
return editTool(args, cwd);
|
|
298
427
|
case "bash":
|
|
299
|
-
return bashTool(
|
|
428
|
+
return bashTool(args, cwd);
|
|
300
429
|
case "bash_output":
|
|
301
|
-
return bashOutputTool(
|
|
430
|
+
return bashOutputTool(args);
|
|
302
431
|
case "webfetch":
|
|
303
|
-
return webfetchTool(
|
|
432
|
+
return webfetchTool(args);
|
|
304
433
|
case "websearch":
|
|
305
|
-
return websearchTool(
|
|
434
|
+
return websearchTool(args);
|
|
306
435
|
case "todowrite":
|
|
307
|
-
return todowriteTool(
|
|
436
|
+
return todowriteTool(args);
|
|
308
437
|
case "todo_get":
|
|
309
438
|
return todoGetTool();
|
|
310
439
|
case "todo_update":
|
|
311
|
-
return todoUpdateTool(
|
|
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
|
+
};
|
package/dist/ui/palette.js
CHANGED
|
@@ -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",
|