atom-agent 1.1.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 (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. package/package.json +6 -2
@@ -243,6 +243,26 @@ function iterationTimeline(s) {
243
243
  });
244
244
  return `<div class="timeline">${rows.join("")}</div>`;
245
245
  }
246
+ // Goal fragment for one turn (ticket 09): rendered only when the trace
247
+ // carries a goal snapshot — absent or malformed reads as no-goal and renders
248
+ // nothing (never a fake claim). Counters show only when reported.
249
+ function goalFragment(t) {
250
+ const g = t.goal;
251
+ if (!g || typeof g.objective !== "string" || g.objective.length === 0)
252
+ return "";
253
+ const state = g.active === true ? "active" : "paused";
254
+ const bits = [];
255
+ if (typeof g.turns === "number")
256
+ bits.push(`${fmtCount(g.turns)} turn(s)`);
257
+ if (typeof g.requests === "number")
258
+ bits.push(`${fmtCount(g.requests)} request(s)`);
259
+ if (typeof g.tokens === "number")
260
+ bits.push(`${fmtCount(g.tokens)} tokens`);
261
+ if (typeof g.workMs === "number")
262
+ bits.push(fmtMs(g.workMs));
263
+ const counters = bits.length > 0 ? ` <span class="mute">(${bits.join(" · ")})</span>` : "";
264
+ return `<p><strong>Goal:</strong> <span class="mono">${escapeHtml(g.objective)}</span> <span class="pill">${state}</span>${counters}</p>`;
265
+ }
246
266
  function turnBlock(t) {
247
267
  const outcomeCls = t.outcome === "completed" ? "ok" : t.outcome === "failed" ? "err" : "warn";
248
268
  const total = turnTokensTotal(t);
@@ -272,6 +292,7 @@ function turnBlock(t) {
272
292
  <summary><span class="mono">${escapeHtml(t.id)}</span> · ${fmtTime(t.startedAt)} · ${escapeHtml(t.provider)} · ${escapeHtml(t.model)} · ${head}</summary>
273
293
  <div class="turnbody">
274
294
  <p><strong>Input:</strong> <span class="mono">${escapeHtml(t.inputPreview)}</span> <span class="mute">(${fmtCount(t.inputChars)} chars)</span></p>
295
+ ${goalFragment(t)}
275
296
  ${t.error ? `<p><strong>Error:</strong> <span class="err">${escapeHtml(t.error)}</span></p>` : ""}
276
297
  ${t.replyPreview ? `<p><strong>Reply:</strong> <span class="mono">${escapeHtml(t.replyPreview)}</span></p>` : ""}
277
298
  <h5>Timeline</h5>
@@ -361,6 +382,12 @@ export function buildDashboardHtml(sessions, opts = {}) {
361
382
  const successRate = agg.toolSuccessRate !== null
362
383
  ? `${(agg.toolSuccessRate * 100).toFixed(1)}%`
363
384
  : `<span class="na" title="No tool calls recorded — a rate over zero calls would be fake.">n/a</span>`;
385
+ // Goal overview (ticket 09): present only when at least one stored turn
386
+ // carried a goal snapshot — zero goal turns omit the card entirely (same
387
+ // conditional-render precedent as the corrupt-file note above).
388
+ const goalCard = agg.goalTurns > 0
389
+ ? `<div class="card"><div class="k">Goal turns</div><div class="v">${fmtCount(agg.goalTurns)}</div></div>`
390
+ : "";
364
391
  const refreshSeconds = typeof opts.refreshSeconds === "number" && Number.isFinite(opts.refreshSeconds)
365
392
  ? Math.floor(opts.refreshSeconds)
366
393
  : 0;
@@ -504,6 +531,7 @@ ${corruptNote}
504
531
  <div class="card"><div class="k">Completion tokens (reported)</div><div class="v">${fmtTokens(agg.usage.completion_tokens, agg.usageReported, "No model call reported completion_tokens.")}</div></div>
505
532
  <div class="card"><div class="k">Cache read / write (reported)</div><div class="v">${fmtTokens(agg.usage.cacheReadTokens, agg.usageReported, "No provider reported cache-read counters.")} / ${fmtTokens(agg.usage.cacheWriteTokens, agg.usageReported, "No provider reported cache-write counters.")}</div></div>
506
533
  <div class="card"><div class="k">Retries</div><div class="v">${fmtCount(agg.retries)}</div></div>
534
+ ${goalCard}
507
535
  <div class="card"><div class="k">Read-cache hits (local)</div><div class="v">${fmtCount(agg.cacheHits)}</div></div>
508
536
  <div class="card"><div class="k">Loop-guard hits</div><div class="v">${fmtCount(agg.repetitionHits)}</div></div>
509
537
  <div class="card"><div class="k">Avg model latency</div><div class="v">${fmtMs(agg.avgModelLatencyMs)}</div></div>
package/dist/telemetry.js CHANGED
@@ -111,6 +111,38 @@ export function cleanUsage(value) {
111
111
  ? out
112
112
  : undefined;
113
113
  }
114
+ // Keep only a well-formed goal snapshot (tolerant reader/writer pair with
115
+ // the dashboard: absent or malformed reads as no-goal, never a throw).
116
+ // Returns undefined when no goal was live. The objective is capped so a
117
+ // pasted paragraph cannot bloat the trace; counters copy finite values only.
118
+ export function cleanGoalSnapshot(value) {
119
+ if (typeof value !== "object" || value === null || Array.isArray(value))
120
+ return undefined;
121
+ const o = value;
122
+ const objective = o["objective"];
123
+ if (typeof objective !== "string" || objective.length === 0)
124
+ return undefined;
125
+ const snap = {
126
+ objective: objective.length > TELEMETRY_INPUT_PREVIEW_CHARS
127
+ ? objective.slice(0, TELEMETRY_INPUT_PREVIEW_CHARS)
128
+ : objective,
129
+ active: o["active"] === true,
130
+ };
131
+ const counter = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
132
+ const turns = counter(o["turns"]);
133
+ if (turns !== undefined)
134
+ snap.turns = turns;
135
+ const requests = counter(o["requests"]);
136
+ if (requests !== undefined)
137
+ snap.requests = requests;
138
+ const tokens = counter(o["tokens"]);
139
+ if (tokens !== undefined)
140
+ snap.tokens = tokens;
141
+ const workMs = counter(o["workMs"]);
142
+ if (workMs !== undefined)
143
+ snap.workMs = workMs;
144
+ return snap;
145
+ }
114
146
  export function addUsageInto(target, extra) {
115
147
  if (!extra)
116
148
  return false;
@@ -438,6 +470,7 @@ export function summarizeTelemetry(sessions) {
438
470
  retries: 0,
439
471
  cacheHits: 0,
440
472
  repetitionHits: 0,
473
+ goalTurns: 0,
441
474
  outcomes: emptyOutcomes(),
442
475
  byTool: [],
443
476
  avgModelLatencyMs: null,
@@ -462,6 +495,9 @@ export function summarizeTelemetry(sessions) {
462
495
  agg.turns += 1;
463
496
  if (t.outcome in agg.outcomes)
464
497
  agg.outcomes[t.outcome] += 1;
498
+ if (t.goal && typeof t.goal.objective === "string" && t.goal.objective.length > 0) {
499
+ agg.goalTurns += 1;
500
+ }
465
501
  if (t.usageReported) {
466
502
  if (addUsageInto(agg.usage, t.usage))
467
503
  agg.usageReported = true;
@@ -657,6 +693,9 @@ export class TelemetryRecorder {
657
693
  usageReported: false,
658
694
  retryCount: 0,
659
695
  };
696
+ const goalSnap = cleanGoalSnapshot(meta.goal);
697
+ if (goalSnap)
698
+ turn.goal = goalSnap;
660
699
  this.session.turns.push(turn);
661
700
  this.openTurns.set(id, turn);
662
701
  this.pendingRetries = [];
@@ -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
+ }
@@ -76,6 +76,13 @@ function toCwdRel(absDir, cwd, dirRel) {
76
76
  return dirRel;
77
77
  return `${prefix}/${dirRel}`;
78
78
  }
79
+ // Public for the ripgrep adapter: rg emits dir-relative paths, but the
80
+ // enumerated contract (and therefore outputs) is cwd-relative — which may
81
+ // climb out of the tree (`../../..`) when the search dir sits outside cwd.
82
+ // Exported so both paths share the one mapping (never duplicated logic).
83
+ export function rgRelToCwdRel(absDir, cwd, dirRel) {
84
+ return toCwdRel(absDir, cwd, dirRel);
85
+ }
79
86
  function filterSkipped(relPaths) {
80
87
  return relPaths.filter((rel) => {
81
88
  if (!rel)
@@ -7,7 +7,7 @@ import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js
7
7
  import { appendOverflow } from "./overflow.js";
8
8
  import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
9
9
  import { invalidateListingsForFile } from "./dir-cache.js";
10
- import { err, invalidCall, READ_CHAR_CAP, resolveSandbox } from "./shared.js";
10
+ import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
11
11
  // offset/limit are 1-based line numbers. Output capped at ~64KB.
12
12
  export async function readTool(args, cwd = process.cwd()) {
13
13
  try {
@@ -58,7 +58,8 @@ export async function readTool(args, cwd = process.cwd()) {
58
58
  let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
59
59
  if (out.length > READ_CHAR_CAP) {
60
60
  const full = out;
61
- out = appendOverflow(full.slice(0, READ_CHAR_CAP), "\n[truncated: output exceeded 64KB]", "file output", full);
61
+ const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
62
+ out = appendOverflow(t.head, t.note, "file output", full);
62
63
  }
63
64
  try {
64
65
  const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
@@ -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
+ }