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
@@ -2,7 +2,6 @@
2
2
  // - How much context is available? (budget())
3
3
  // - How much is currently used? (usage())
4
4
  // - Should we compact? (needsCompaction())
5
- // - What messages should be sent? (trimForSend())
6
5
  //
7
6
  // Budget derivation (no fixed-200K assumption): the history allowance comes
8
7
  // from the model's ACTUAL verified context window:
@@ -12,20 +11,18 @@
12
11
  //
13
12
  // measured in tokens via the shared 4ch/token estimator. A model with a 1M
14
13
  // window therefore gets ~1M of usable history instead of ~50K tokens.
14
+ // Models with NO verified window report no allowance (a window is never
15
+ // invented; auto-compact stays off for them).
15
16
  //
16
- // Hard safety ceiling (configurable, never primary): env ATOM_MAX_HISTORY_*
17
- // and atom.json maxHistory* still resolve through historyCharSource /
18
- // historyMessageSource. The ceiling only ever CAPS the derived budget with
19
- // nothing configured it never binds for known-window models. Models with NO
20
- // verified window keep the legacy 200K-char / 100-message behavior exactly
21
- // (a window is never invented; auto-compact stays off for them).
17
+ // History itself is NEVER truncated: there are no message/char caps.
18
+ // Compaction (manual /compact, auto at ~83% of the verified window) is the
19
+ // only pressure valvePi-style.
22
20
  //
23
21
  // Layering: this module owns measurement + budget math. It imports
24
- // context-windows (metadata) and config (file fallback) at runtime, and
25
- // zen.js types ONLY (no runtime cycle — zen.ts imports this module for its
26
- // loop trim). The agent loop, compaction mechanics, and providers are
27
- // untouched: truncateHistory/shouldAutoCompact/compactPct keep working via
28
- // re-exports from their original modules.
22
+ // context-windows (metadata) and config (file fallback for compactPct) at
23
+ // runtime, and zen.js types ONLY (no runtime cycle — zen.ts imports this
24
+ // module for context math). The agent loop, compaction mechanics, and
25
+ // providers are untouched.
29
26
  //
30
27
  // Prompt-caching foundation (NOT implemented): all inputs here are explicit
31
28
  // values (system/tools/history split, measured sizes, stable options), so a
@@ -72,8 +69,10 @@ export function historyChars(history) {
72
69
  total += messageChars(m);
73
70
  return total;
74
71
  }
75
- // Load = last POST's reported prompt_tokens when available, else the
76
- // 4ch/token estimate of the sent history chars.
72
+ // Load = last POST's reported input-side tokens (prompt_tokens, normalized at
73
+ // parse time to include exclusive prefix-cache counters like Anthropic's
74
+ // cache_read/_creation) when available, else the 4ch/token estimate of the
75
+ // sent history chars.
77
76
  export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
78
77
  if (typeof lastPromptTokens === "number" &&
79
78
  Number.isFinite(lastPromptTokens) &&
@@ -82,51 +81,6 @@ export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
82
81
  }
83
82
  return estimateTokensForChars(sentHistoryChars);
84
83
  }
85
- // ---- Safety-ceiling sources (env > atom.json > compiled default) ----
86
- export const MAX_HISTORY_MESSAGES = 100;
87
- export const MAX_HISTORY_CHARS = 200_000;
88
- function clampInt(n, min, max) {
89
- return Math.min(Math.max(Math.floor(n), min), max);
90
- }
91
- function envInt(raw) {
92
- if (raw === undefined)
93
- return undefined;
94
- const text = raw.trim();
95
- if (!/^\d+$/.test(text))
96
- return undefined;
97
- const n = Number(text);
98
- return Number.isFinite(n) ? Math.floor(n) : undefined;
99
- }
100
- // Message-count ceiling source. `explicit` tells whether a human configured
101
- // it (env or file) as opposed to the compiled default.
102
- export function historyMessageSource() {
103
- const env = envInt(process.env.ATOM_MAX_HISTORY_MESSAGES);
104
- if (env !== undefined)
105
- return { value: clampInt(env, 10, 1000), explicit: true };
106
- const file = loadAtomConfig().config.maxHistoryMessages;
107
- if (file !== undefined)
108
- return { value: file, explicit: true };
109
- return { value: MAX_HISTORY_MESSAGES, explicit: false };
110
- }
111
- // Char-count safety ceiling source. Same explicit contract.
112
- export function historyCharSource() {
113
- const env = envInt(process.env.ATOM_MAX_HISTORY_CHARS);
114
- if (env !== undefined)
115
- return { value: clampInt(env, 10_000, 2_000_000), explicit: true };
116
- const file = loadAtomConfig().config.maxHistoryChars;
117
- if (file !== undefined)
118
- return { value: file, explicit: true };
119
- return { value: MAX_HISTORY_CHARS, explicit: false };
120
- }
121
- // Legacy accessors (env → file → default). Kept for the loop's legacy path
122
- // and existing callers; the manager uses the sources above so it can tell
123
- // configured ceilings apart from defaults.
124
- export function historyMessageBudget() {
125
- return historyMessageSource().value;
126
- }
127
- export function historyCharBudget() {
128
- return historyCharSource().value;
129
- }
130
84
  // ---- Compaction threshold (moved here: the manager owns "should compact") ----
131
85
  export const COMPACT_PCT_DEFAULT = 0.83;
132
86
  function clampPctPercent(n) {
@@ -159,135 +113,20 @@ export function shouldAutoCompact(load, model, pctOverride) {
159
113
  : compactPct();
160
114
  return load / window >= pct;
161
115
  }
162
- // Searchable text for todo matching: message content plus the assistant's
163
- // tool_calls payload (todowrite CALLS carry the list, tool RESULTS echo it).
164
- // Tool call ids are NOT searched — they are pairing keys, not goal text, so
165
- // a todo that reads like an id can never false-pin a turn.
166
- function todoHaystack(m) {
167
- let hay = "";
168
- const content = m.content;
169
- if (typeof content === "string")
170
- hay += content;
171
- if (m.role === "assistant" && m.tool_calls !== undefined) {
172
- try {
173
- hay += JSON.stringify(m.tool_calls);
174
- }
175
- catch {
176
- // unstringifiable payload pins nothing
177
- }
178
- }
179
- return hay;
180
- }
181
- function turnMentionsTodo(history, start, end, needles) {
182
- for (let i = start; i < end; i++) {
183
- const hay = todoHaystack(history[i]);
184
- if (hay.length === 0)
185
- continue;
186
- for (const n of needles) {
187
- if (n.length > 0 && hay.includes(n))
188
- return true;
189
- }
190
- }
191
- return false;
192
- }
193
- // Drop oldest user-turns until history fits BOTH caps (message count AND
194
- // total chars, each plus the caller's `reserve` headroom for a message it is
195
- // about to push). A user turn = the `user` message plus all following
196
- // messages up to (excluding) the next `user` message, so assistant
197
- // tool_calls always stay paired with their tool results across all three
198
- // wire formats. NEVER drops history[0] (system prompt), the first user turn
199
- // (the task prompt — the goal a long run must never forget), any turn that
200
- // still quotes a CURRENT open todo, or the latest turn (the one being
201
- // sent/built). Budget-aware edge: when the pinned content alone (first turn
202
- // + todo turns + latest) already exceeds a cap, there is nothing left to
203
- // drop — stop and still send (same never-drop-the-live-turn principle).
204
- // Mutates `history` in place via splice (so caller indices captured after
205
- // this call stay valid) and, when at least one turn dropped, fires ONE
206
- // `notify` (the caller surfaces it dim in the TUI); silence otherwise.
207
- // Returns what was dropped.
208
- export function truncateHistoryWithCaps(history, caps, opts) {
209
- const result = { droppedTurns: 0, droppedMessages: 0 };
210
- if (history.length <= 1)
211
- return result;
212
- const maxMessages = caps.maxMessages;
213
- const maxChars = caps.maxChars;
214
- const reserve = opts?.reserve;
215
- const needles = opts?.todoNeedles ?? [];
216
- const roomMessages = reserve?.messages !== undefined && Number.isFinite(reserve.messages)
217
- ? Math.max(0, Math.floor(reserve.messages))
218
- : 0;
219
- const roomChars = reserve?.chars !== undefined && Number.isFinite(reserve.chars)
220
- ? Math.max(0, reserve.chars)
221
- : 0;
222
- for (;;) {
223
- const over = history.length + roomMessages > maxMessages ||
224
- historyChars(history) + roomChars > maxChars;
225
- if (!over)
226
- break;
227
- // Turn boundaries over history[1..]: each turn starts at a `user`
228
- // message (the oldest slice starts at 1 even when it isn't one, matching
229
- // the pre-pin drop unit). Whole-turn drops keep assistant/tool pairing.
230
- const starts = [1];
231
- for (let i = 2; i < history.length; i++) {
232
- if (history[i]?.role === "user")
233
- starts.push(i);
234
- }
235
- // Oldest NON-pinned, non-latest turn goes first: the first turn (task
236
- // prompt) and any turn still quoting a current open todo stay, and the
237
- // latest turn is never dropped. No candidate means pinned content alone
238
- // is over budget — stop and send it as-is (see edge above).
239
- let drop = -1;
240
- for (let t = 0; t < starts.length; t++) {
241
- if (t === starts.length - 1)
242
- continue; // latest turn
243
- if (t === 0)
244
- continue; // task prompt
245
- const end = t + 1 < starts.length ? starts[t + 1] : history.length;
246
- if (needles.length > 0 && turnMentionsTodo(history, starts[t], end, needles))
247
- continue;
248
- drop = t;
249
- break;
250
- }
251
- if (drop === -1)
252
- break;
253
- const end = drop + 1 < starts.length ? starts[drop + 1] : history.length;
254
- const removed = history.splice(starts[drop], end - starts[drop]);
255
- result.droppedTurns += 1;
256
- result.droppedMessages += removed.length;
257
- }
258
- if (result.droppedTurns > 0) {
259
- try {
260
- opts?.notify?.(`(history truncated: dropped ${result.droppedTurns} oldest turn(s))`);
261
- }
262
- catch {
263
- // observer errors never break the loop
264
- }
265
- }
266
- return result;
267
- }
268
116
  // ---- Budget derivation ----
269
117
  // Expected completion/output reserve: one full summary-sized generation must
270
118
  // always fit alongside history (mirrors the compaction output cap).
271
119
  export const OUTPUT_RESERVE_TOKENS = 4096;
272
- // Safety headroom below the raw window: the trim cap never plans to use the
120
+ // Safety headroom below the raw window: the budget never plans to use the
273
121
  // last 5% (auto-compact at ~83% fires long before this matters — the margin
274
- // is the last defense, not the trigger).
122
+ // is informational, not a trigger).
275
123
  export const SAFETY_MARGIN_PCT = 0.05;
276
- // Default safety ceiling when nothing is configured AND no window is known
277
- // (the legacy 200K-char budget, preserved byte-for-byte as fallback).
278
- export const HARD_CEILING_FLOOR_CHARS = 200_000;
279
124
  export function createContextManager(opts) {
280
125
  const model = opts.model;
281
126
  const toolsChars = opts.toolsChars ?? 0;
282
127
  const reserveTokens = opts.outputReserveTokens ?? OUTPUT_RESERVE_TOKENS;
283
128
  const marginPct = opts.safetyMarginPct ?? SAFETY_MARGIN_PCT;
284
129
  const pct = opts.compactPct ?? compactPct();
285
- function ceilingChars() {
286
- if (opts.hardCeilingChars !== undefined) {
287
- return { value: opts.hardCeilingChars, explicit: opts.hardCeilingExplicit ?? true };
288
- }
289
- return historyCharSource();
290
- }
291
130
  function budget(history) {
292
131
  const window = contextWindowFor(model);
293
132
  const stats = ledgerStats(history);
@@ -298,18 +137,6 @@ export function createContextManager(opts) {
298
137
  ? Math.max(0, window - systemTokens - toolsTokens - reserveTokens - margin)
299
138
  : undefined;
300
139
  const historyCharsCap = historyTokens !== undefined ? historyTokens * CHARS_PER_TOKEN : undefined;
301
- const ceil = ceilingChars();
302
- const ceilMsgs = opts.hardCeilingMessages ?? historyMessageSource().value;
303
- // The ceiling only ever CAPS: with nothing configured the derived budget
304
- // rules (large windows stay usable); an explicit ceiling still binds as
305
- // the safety net it is. Unknown windows fall back to the legacy floor.
306
- const effectiveMaxChars = historyCharsCap !== undefined
307
- ? ceil.explicit
308
- ? Math.min(historyCharsCap, ceil.value)
309
- : historyCharsCap
310
- : ceil.explicit
311
- ? ceil.value
312
- : HARD_CEILING_FLOOR_CHARS;
313
140
  return {
314
141
  windowTokens: window,
315
142
  systemTokens,
@@ -318,11 +145,6 @@ export function createContextManager(opts) {
318
145
  safetyMarginTokens: margin,
319
146
  historyTokens,
320
147
  historyChars: historyCharsCap,
321
- hardCeilingChars: ceil.value,
322
- hardCeilingExplicit: ceil.explicit,
323
- hardCeilingMessages: ceilMsgs,
324
- effectiveMaxChars,
325
- effectiveMaxMessages: ceilMsgs,
326
148
  };
327
149
  }
328
150
  function usage(history, lastPromptTokens) {
@@ -340,11 +162,7 @@ export function createContextManager(opts) {
340
162
  function needsCompaction(loadTokens) {
341
163
  return shouldAutoCompact(loadTokens, model, pct);
342
164
  }
343
- function trimForSend(history, notify, reserve, todoNeedles = []) {
344
- const b = budget(history);
345
- return truncateHistoryWithCaps(history, { maxMessages: b.effectiveMaxMessages, maxChars: b.effectiveMaxChars }, { notify, reserve, todoNeedles });
346
- }
347
- return { model, budget, usage, needsCompaction, trimForSend };
165
+ return { model, budget, usage, needsCompaction };
348
166
  }
349
167
  const ledgerByRaw = new WeakMap();
350
168
  const ledgerByProxy = new WeakMap();
@@ -77,7 +77,9 @@ export function contextWindowFor(model) {
77
77
  return CONTEXT_WINDOWS[model];
78
78
  }
79
79
  // Total session tokens: prefer usage.total_tokens when present, else
80
- // prompt_tokens + completion_tokens (missing keys count as 0).
80
+ // prompt_tokens + completion_tokens (missing keys count as 0). prompt_tokens
81
+ // is normalized at parse time to total input-side tokens (exclusive
82
+ // prefix-cache counters folded in), so both paths count cached context.
81
83
  export function totalTokens(usage) {
82
84
  if (typeof usage.total_tokens === "number") {
83
85
  return Math.max(0, Math.floor(usage.total_tokens));
@@ -90,7 +92,7 @@ export function totalTokens(usage) {
90
92
  // - no usage reported yet: `token: n/a` (never estimated)
91
93
  // - known window: `token: (P%) NK` (NK = round(total/1024) + "K" from the
92
94
  // CUMULATIVE session spend; P = round(100*load/window) from the CURRENT
93
- // context load — prompt_tokens of the last POST, else the 4ch/token
95
+ // context load — input-side tokens of the last POST, else the 4ch/token
94
96
  // estimate. Cumulative spend keeps growing after compaction, so it must
95
97
  // NOT drive P; load does. Pass load explicitly; when omitted it falls
96
98
  // back to the cumulative total for backward compat.)
package/dist/env-block.js CHANGED
@@ -3,17 +3,18 @@
3
3
  // timestamp.
4
4
  //
5
5
  // Placement: pinned to the SYSTEM message only (suffix to history[0]'s
6
- // content via withEnvBlock), NEVER into user content. history[0] is the only
7
- // slot truncateHistory never drops, so the block survives budget trimming.
8
- // Caching: the App refreshes history[0] once per turn in submit() (before the
9
- // budget check, so truncation accounts for it) the loop's up-to-30 POSTs
10
- // reuse the same history[0], so git is shelled at most once per turn.
6
+ // content via withEnvBlock), NEVER into user content. history[0] is the
7
+ // system prompt, so the block survives every turn.
8
+ // Caching: the App refreshes history[0] once per turn in submit() the
9
+ // loop's POSTs reuse the same history[0], so git is shelled at most once
10
+ // per turn.
11
11
  // Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
12
12
  // (cwd + node + time only), never throws, never blocks the turn. No new
13
13
  // dependencies; one cheap `git status` invocation with a short timeout, and
14
14
  // zero shell-outs when `.git` is absent.
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { existsSync } from "node:fs";
17
+ import * as os from "node:os";
17
18
  import * as path from "node:path";
18
19
  // Cap for the block itself (~500 chars per the task). The base system prompt
19
20
  // (SYSTEM_PROMPT + AGENTS.md overlay) is untouched by this cap.
@@ -30,8 +31,9 @@ function shortCwd(cwd) {
30
31
  return `…${cwd.slice(cwd.length - (CWD_DISPLAY_CAP - 1))}`;
31
32
  }
32
33
  // Pure formatter (no I/O): always `cwd + node + time`, plus
33
- // `branch + status` only when git reported them. Capped to
34
- // ENV_BLOCK_CHAR_CAP (cwd is pre-truncated so time/node survive the cap).
34
+ // `branch + status` only when git reported them, plus the operating context
35
+ // (`os + shell + user`) when provided. Capped to ENV_BLOCK_CHAR_CAP (cwd is
36
+ // pre-truncated so time/node survive the cap).
35
37
  export function buildEnvBlock(parts) {
36
38
  const cwd = shortCwd(parts.cwd);
37
39
  const git = parts.branch !== undefined &&
@@ -39,7 +41,10 @@ export function buildEnvBlock(parts) {
39
41
  parts.branch.length > 0
40
42
  ? ` branch=${parts.branch} status=${parts.status ?? "unknown"}`
41
43
  : "";
42
- const block = `[env cwd=${cwd}${git} node=${parts.nodeVersion} time=${parts.timestamp}]`;
44
+ const machine = parts.os !== undefined && parts.os !== null && parts.os.length > 0
45
+ ? ` os=${parts.os} shell=${parts.shell ?? "unknown"} user=${parts.user ?? "unknown"}`
46
+ : "";
47
+ const block = `[env cwd=${cwd}${git}${machine} node=${parts.nodeVersion} time=${parts.timestamp}]`;
43
48
  return block.length > ENV_BLOCK_CHAR_CAP
44
49
  ? `${block.slice(0, ENV_BLOCK_CHAR_CAP - 1)}]`
45
50
  : block;
@@ -117,12 +122,45 @@ export function getEnvBlock(cwd = process.cwd()) {
117
122
  catch {
118
123
  git = null;
119
124
  }
125
+ // Operating context (best-effort, never throws): tells the model which
126
+ // shell runs its commands and who/where it is, so it stops probing with
127
+ // whoami/hostname and guessing `ls` vs `dir` (measured: 2–4 wasted bash
128
+ // calls per task before this existed).
129
+ let osName = "unknown";
130
+ try {
131
+ if (typeof process.platform === "string" && process.platform.length > 0) {
132
+ osName = process.platform;
133
+ }
134
+ }
135
+ catch {
136
+ // keep fallback
137
+ }
138
+ let shell = "unknown";
139
+ try {
140
+ shell = process.platform === "win32" ? "cmd.exe" : "sh";
141
+ }
142
+ catch {
143
+ // keep fallback
144
+ }
145
+ let user = "unknown";
146
+ try {
147
+ const name = os.userInfo?.().username;
148
+ if (typeof name === "string" && name.length > 0) {
149
+ user = name;
150
+ }
151
+ }
152
+ catch {
153
+ // keep fallback (sandboxed runtimes may forbid userInfo)
154
+ }
120
155
  return buildEnvBlock({
121
156
  cwd: dir,
122
157
  branch: git?.branch ?? null,
123
158
  status: git?.status ?? null,
124
159
  nodeVersion,
125
160
  timestamp,
161
+ os: osName,
162
+ shell,
163
+ user,
126
164
  });
127
165
  }
128
166
  catch {
@@ -0,0 +1,196 @@
1
+ // Extension slash commands (ticket 04): the runtime store behind
2
+ // ExtensionAPI.registerCommand. Dependency-free (no imports) like
3
+ // tools/custom.ts and tools/intercept.ts, so the extension host and the App
4
+ // slash dispatch share it with no cycle: extensions register here, the App
5
+ // lists/parses/runs here, nobody imports the other.
6
+ //
7
+ // A command is a real slash command (`/name args...`): it appears in the
8
+ // command palette and the "/" menu, takes typed free-text arguments, and
9
+ // runs extension code OUTSIDE the model turn loop with a generation-bound
10
+ // context (prompt the user, read a session snapshot, post messages).
11
+ //
12
+ // Semantics:
13
+ // - Names are bare in the definition ("deploy") and slash-prefixed at the
14
+ // seam ("/deploy"). Lowercase [a-z0-9_-] only, mirroring builtin style so
15
+ // menu/palette matching stays exact and case-free.
16
+ // - Collision decision: builtins always win. The host rejects a colliding
17
+ // name at activation (loud error, nothing commits) and App dispatch
18
+ // routes builtins first as backstop — a builtin is never shadowed, and
19
+ // there is no renamed form to discover. Duplicate extension names throw
20
+ // the same way (first registration wins, deterministically by load order).
21
+ // - Handlers never touch model history: say() stages messages and the
22
+ // runner commits them only on success. A throwing handler drops the
23
+ // stage and surfaces a clean `Error:` string — the session is untouched.
24
+ // - The context is generation-bound: every ctx call runs checkStale first,
25
+ // so use after a session replacement throws loudly instead of acting on
26
+ // the wrong session (same rule as the event API).
27
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
28
+ const store = new Map();
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ function errorText(e) {
33
+ return e instanceof Error ? e.message : String(e ?? "unknown error");
34
+ }
35
+ /**
36
+ * Validate a registration shape. Throws Error on any problem (bad name,
37
+ * empty description, non-function handler). Duplicate and builtin-
38
+ * collision checks live with the callers that own those names (the store
39
+ * owns extension names; the host owns the builtin set).
40
+ */
41
+ export function validateExtensionCommandDef(def) {
42
+ if (!isRecord(def))
43
+ throw new Error("extension command definition must be an object");
44
+ if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
45
+ throw new Error(`extension command has an invalid name ${JSON.stringify(def.name)} (want a bare 1-64 char a-z0-9_- name, no leading slash)`);
46
+ }
47
+ if (typeof def.description !== "string" || def.description.trim().length === 0) {
48
+ throw new Error(`extension command "/${def.name}" needs a non-empty description`);
49
+ }
50
+ if (typeof def.handler !== "function") {
51
+ throw new Error(`extension command "/${def.name}" needs a handler function`);
52
+ }
53
+ }
54
+ /** Register a validated command. Throws on duplicate names. Returns an unregister function. */
55
+ export function registerExtensionCommand(def, owner = "(unknown)") {
56
+ validateExtensionCommandDef(def);
57
+ if (store.has(def.name)) {
58
+ throw new Error(`extension command "/${def.name}" is already registered`);
59
+ }
60
+ const rec = {
61
+ name: def.name,
62
+ description: def.description,
63
+ handler: def.handler,
64
+ owner,
65
+ };
66
+ store.set(def.name, rec);
67
+ let live = true;
68
+ return () => {
69
+ if (!live)
70
+ return;
71
+ live = false;
72
+ if (store.get(def.name) === rec)
73
+ store.delete(def.name);
74
+ };
75
+ }
76
+ export function unregisterExtensionCommand(name) {
77
+ return store.delete(name);
78
+ }
79
+ export function getExtensionCommand(name) {
80
+ return store.get(name);
81
+ }
82
+ /** Live commands in registration order (deterministic menu/palette order). */
83
+ export function listExtensionCommands() {
84
+ return [...store.values()];
85
+ }
86
+ /** Test seam: drop every extension command. */
87
+ export function clearExtensionCommands() {
88
+ store.clear();
89
+ }
90
+ // Split "/name args..." into its bare name and raw args. Returns null for
91
+ // anything that is not a single-line slash invocation (plain text, a bare
92
+ // "/", multiline, namespaced skill forms like "/skill:dep" — the name must
93
+ // be followed by whitespace or end, so skill routing is never disturbed).
94
+ export function parseExtensionCommandInput(text) {
95
+ if (!text.startsWith("/") || text.length < 2)
96
+ return null;
97
+ if (/[\r\n]/.test(text))
98
+ return null;
99
+ const match = /^\/([A-Za-z0-9_-]+)(?:[ \t]+([\s\S]*))?$/.exec(text);
100
+ if (!match)
101
+ return null;
102
+ return { name: match[1], args: (match[2] ?? "").trim() };
103
+ }
104
+ // Whitespace tokenizer for ctx.argv: double/single quotes group words, the
105
+ // quotes are stripped, no escape processing (documented, not shell).
106
+ export function splitCommandArgs(args) {
107
+ const out = [];
108
+ let cur = "";
109
+ let quote = null;
110
+ let has = false;
111
+ for (let i = 0; i < args.length; i++) {
112
+ const ch = args[i];
113
+ if (quote) {
114
+ if (ch === quote) {
115
+ quote = null;
116
+ }
117
+ else {
118
+ cur += ch;
119
+ }
120
+ has = true;
121
+ continue;
122
+ }
123
+ if (ch === '"' || ch === "'") {
124
+ quote = ch;
125
+ has = true;
126
+ continue;
127
+ }
128
+ if (ch === " " || ch === "\t") {
129
+ if (has) {
130
+ out.push(cur);
131
+ cur = "";
132
+ has = false;
133
+ }
134
+ continue;
135
+ }
136
+ cur += ch;
137
+ has = true;
138
+ }
139
+ if (has)
140
+ out.push(cur);
141
+ return out;
142
+ }
143
+ // Run a registered command outside the model turn loop. Never throws for
144
+ // handler failures: unknown names and throwing handlers return a clean
145
+ // `Error:` result. say() output stages in a buffer and commits through
146
+ // deps.say only on success, so a throwing handler leaves the session
147
+ // untouched. Every context call runs checkStale first — stale use throws
148
+ // into the same clean-error path instead of acting on the wrong session.
149
+ export async function runExtensionCommand(name, rawArgs, deps) {
150
+ const rec = store.get(name);
151
+ if (!rec) {
152
+ return { ok: false, error: `Error: unknown extension command "/${name}" (not registered)` };
153
+ }
154
+ const stale = deps.checkStale ?? (() => undefined);
155
+ const staged = [];
156
+ const ctx = {
157
+ name,
158
+ args: rawArgs,
159
+ argv: splitCommandArgs(rawArgs),
160
+ cwd: deps.cwd ?? process.cwd(),
161
+ askUser: async (question, options, allowCustom) => {
162
+ stale();
163
+ return deps.askUser(question, options, allowCustom);
164
+ },
165
+ getSession: () => {
166
+ stale();
167
+ return deps.getSession();
168
+ },
169
+ say: (message) => {
170
+ stale();
171
+ if (typeof message !== "string") {
172
+ throw new Error(`extension command "/${name}" say() needs a string`);
173
+ }
174
+ if (message.length === 0)
175
+ return;
176
+ staged.push(message);
177
+ },
178
+ };
179
+ let returned;
180
+ try {
181
+ returned = await rec.handler(ctx);
182
+ }
183
+ catch (e) {
184
+ return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
185
+ }
186
+ if (typeof returned === "string" && returned.length > 0)
187
+ staged.push(returned);
188
+ try {
189
+ for (const message of staged)
190
+ deps.say(message);
191
+ }
192
+ catch (e) {
193
+ return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
194
+ }
195
+ return { ok: true, posted: staged.length };
196
+ }