atom-agent 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
@@ -0,0 +1,1571 @@
1
+ // Extension host (tickets 01-07): discovery, loading, lifecycle, trust gate.
2
+ //
3
+ // An extension is a local .ts/.js file exporting a factory function that
4
+ // receives an ExtensionAPI and registers subscriptions. Loading is
5
+ // same-process dynamic import via jiti (TypeScript-capable, no sandbox):
6
+ // extension code runs with full user privileges, exactly like app code.
7
+ // Trust posture (ticket 07): global-scope extensions are user-owned and
8
+ // implicitly trusted (like the user's own config); project-scope + explicit
9
+ // paths never execute until the project is trusted (LoadOptions.projectTrusted,
10
+ // driven by the App's one-time trust prompt + the project-trust store) or
11
+ // are skipped by the lockdown / enable-disable filters. Skipped extensions
12
+ // are never imported — their factories never run — and are reported on
13
+ // runtime.skipped so the host can show what was left inert and why.
14
+ //
15
+ // Generations (stale-use rule): every session replacement (switch, /new,
16
+ // /resume) invalidates the runtime, bumping a generation counter. API
17
+ // objects captured before the bump throw on any further use; event handlers
18
+ // always receive a fresh, current-generation API. This makes
19
+ // use-after-replacement a loud error instead of a silent wrong-session bug.
20
+ //
21
+ // This module never throws out of loadExtensions: per-extension failures are
22
+ // recorded on the runtime. It never touches React, the TUI, or LLM clients.
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import * as path from "node:path";
25
+ import { createJiti } from "jiti";
26
+ import { atomDir } from "./auth.js";
27
+ import { TOOL_DEFINITIONS, registerExtensionTool, registerExtensionToolOverride, validateExtensionToolDef, } from "./tools/registry.js";
28
+ import { registerExtensionPromptHint, validateExtensionPromptHint, validateExtensionToolOverrideDef, } from "./tools/overrides.js";
29
+ import { registerAfterToolCall, registerBeforeToolCall, } from "./tools/intercept.js";
30
+ import { registerAfterResponse, registerBeforeRequest, registerContextTransform, } from "./tools/provider-hooks.js";
31
+ import { registerBeforeCompact, } from "./tools/compaction-hooks.js";
32
+ import { registerExtensionCommand as registerCommandInStore, validateExtensionCommandDef, } from "./extension-commands.js";
33
+ import { validateDialogDef, validateNotifyMessage, validateStatusSegment, validateWidgetDef, } from "./extension-ui.js";
34
+ import { getSession as readSessionRecord, updateSession as writeSessionRecord, } from "./sessions.js";
35
+ import { matchGlob } from "./permissions.js";
36
+ export const EXTENSIONS_DIRNAME = "extensions";
37
+ // Staged extension notices are drained into the transcript on render. Cap the
38
+ // queue drop-oldest so a chatty extension cannot grow memory between drains.
39
+ export const EXT_NOTICE_CAP = 100;
40
+ // Extra extension paths from the environment (path.delimiter-separated).
41
+ // Explicit configuration; honored alongside the two discovered scopes.
42
+ export const EXTENSIONS_ENV = "ATOM_EXTENSIONS";
43
+ // Per-session extension state lives namespaced here: record metadata
44
+ // `{ ..., extensions: { [extName]: state } }` in the sessions store — the
45
+ // existing durable per-session record, so state survives resume and reload
46
+ // with the session itself and no parallel store is ever introduced.
47
+ const EXT_STATE_KEY = "extensions";
48
+ function assertJsonSerializable(value, what) {
49
+ let text;
50
+ try {
51
+ text = JSON.stringify(value);
52
+ }
53
+ catch (e) {
54
+ throw new Error(`${what} must be JSON-serializable: ${errorText(e)}`);
55
+ }
56
+ if (text === undefined) {
57
+ throw new Error(`${what} must be JSON-serializable (functions and undefined do not persist)`);
58
+ }
59
+ }
60
+ // Cancel interpretation (single rule for requestSwitch): only an explicit
61
+ // veto cancels — true (default reason), a string (that reason, empty falls
62
+ // back to the default), or { cancel: true | "reason" }. Everything else
63
+ // (void/null/false/{cancel:false}/foreign shapes) allows.
64
+ function cancelReasonOf(decision, extName) {
65
+ if (decision === true)
66
+ return `extension "${extName}" cancelled the session switch`;
67
+ if (typeof decision === "string") {
68
+ return decision.length > 0
69
+ ? decision
70
+ : `extension "${extName}" cancelled the session switch`;
71
+ }
72
+ if (isRecord(decision)) {
73
+ const cancel = decision.cancel;
74
+ if (cancel === true)
75
+ return `extension "${extName}" cancelled the session switch`;
76
+ if (typeof cancel === "string") {
77
+ return cancel.length > 0
78
+ ? cancel
79
+ : `extension "${extName}" cancelled the session switch`;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ // Fresh parse per call (the store re-reads the file), so callers can never
85
+ // alias persisted state — but mutating the result still persists nothing.
86
+ function readExtensionState(home, sessionId, extName) {
87
+ if (sessionId === null)
88
+ return undefined;
89
+ const record = readSessionRecord(sessionId, home);
90
+ if (!record)
91
+ return undefined;
92
+ const metadata = record.metadata;
93
+ if (!isRecord(metadata))
94
+ return undefined;
95
+ const bag = metadata[EXT_STATE_KEY];
96
+ if (!isRecord(bag))
97
+ return undefined;
98
+ return bag[extName];
99
+ }
100
+ function writeExtensionState(home, sessionId, extName, value) {
101
+ if (sessionId === null) {
102
+ throw new Error(`extension "${extName}": no active session to persist state into`);
103
+ }
104
+ // undefined clears the slot; anything else must survive a JSON round-trip
105
+ // (the store persists via JSON.stringify, which would throw there — fail
106
+ // here instead, loudly and before touching the record).
107
+ if (value !== undefined)
108
+ assertJsonSerializable(value, `extension "${extName}" session state`);
109
+ const record = readSessionRecord(sessionId, home);
110
+ if (!record) {
111
+ throw new Error(`extension "${extName}": session "${sessionId}" is unavailable`);
112
+ }
113
+ const metadata = isRecord(record.metadata) ? { ...record.metadata } : {};
114
+ const rawBag = metadata[EXT_STATE_KEY];
115
+ const bag = isRecord(rawBag) ? { ...rawBag } : {};
116
+ if (value === undefined) {
117
+ delete bag[extName];
118
+ }
119
+ else {
120
+ bag[extName] = value;
121
+ }
122
+ if (Object.keys(bag).length === 0) {
123
+ delete metadata[EXT_STATE_KEY];
124
+ }
125
+ else {
126
+ metadata[EXT_STATE_KEY] = bag;
127
+ }
128
+ const updated = writeSessionRecord(sessionId, { metadata }, home);
129
+ if (!updated) {
130
+ throw new Error(`extension "${extName}": could not persist session state`);
131
+ }
132
+ }
133
+ function isRecord(value) {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }
136
+ export function globalExtensionsDir(home) {
137
+ return path.join(atomDir(home), EXTENSIONS_DIRNAME);
138
+ }
139
+ export function projectExtensionsDir(cwd) {
140
+ const base = typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
141
+ return path.join(base, ".atom", EXTENSIONS_DIRNAME);
142
+ }
143
+ const ENTRY_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".cjs"]);
144
+ const INDEX_BASENAMES = ["index.ts", "index.js", "index.mjs", "index.cjs"];
145
+ function isEntryFile(name) {
146
+ return ENTRY_EXTENSIONS.has(path.extname(name).toLowerCase());
147
+ }
148
+ /** Manifest field for extension-package subdirectories (mirrors the Pi `pi` field convention). */
149
+ function readAtomManifest(dir) {
150
+ let raw;
151
+ try {
152
+ raw = readFileSync(path.join(dir, "package.json"), "utf8");
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ let data;
158
+ try {
159
+ data = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ if (!isRecord(data))
165
+ return null;
166
+ const atom = data["atom"];
167
+ if (!isRecord(atom))
168
+ return null;
169
+ const extensions = atom["extensions"];
170
+ if (!Array.isArray(extensions))
171
+ return null;
172
+ const out = [];
173
+ for (const e of extensions) {
174
+ if (typeof e === "string" && e.length > 0) {
175
+ out.push(path.resolve(dir, e));
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+ /** Resolve one discovered path to loadable entry files (no recursion beyond one level). */
181
+ function resolveEntries(entryPath) {
182
+ let stat;
183
+ try {
184
+ stat = statSync(entryPath);
185
+ }
186
+ catch {
187
+ return [];
188
+ }
189
+ if (stat.isFile()) {
190
+ return isEntryFile(path.basename(entryPath)) ? [entryPath] : [];
191
+ }
192
+ if (!stat.isDirectory())
193
+ return [];
194
+ const manifest = readAtomManifest(entryPath);
195
+ if (manifest)
196
+ return manifest;
197
+ for (const base of INDEX_BASENAMES) {
198
+ const candidate = path.join(entryPath, base);
199
+ try {
200
+ if (statSync(candidate).isFile())
201
+ return [candidate];
202
+ }
203
+ catch {
204
+ // try next basename
205
+ }
206
+ }
207
+ return [];
208
+ }
209
+ function discoverInDir(dir) {
210
+ let names;
211
+ try {
212
+ names = readdirSync(dir);
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ const out = [];
218
+ for (const name of [...names].sort()) {
219
+ if (name.startsWith("."))
220
+ continue;
221
+ out.push(...resolveEntries(path.join(dir, name)));
222
+ }
223
+ return out;
224
+ }
225
+ /**
226
+ * Ordered, deduplicated entries with scope labels: project scope, global
227
+ * scope, then explicit paths (env ATOM_EXTENSIONS + extraPaths). Missing
228
+ * scopes are silently skipped — never throws.
229
+ */
230
+ export function discoverExtensionEntries(opts = {}) {
231
+ const seen = new Set();
232
+ const out = [];
233
+ const push = (p, scope) => {
234
+ const abs = path.resolve(p);
235
+ if (!seen.has(abs)) {
236
+ seen.add(abs);
237
+ out.push({ path: abs, scope });
238
+ }
239
+ };
240
+ for (const p of discoverInDir(projectExtensionsDir(opts.cwd)))
241
+ push(p, "project");
242
+ for (const p of discoverInDir(globalExtensionsDir(opts.home)))
243
+ push(p, "global");
244
+ const envRaw = process.env[EXTENSIONS_ENV];
245
+ if (typeof envRaw === "string" && envRaw.length > 0) {
246
+ for (const p of envRaw.split(path.delimiter)) {
247
+ const trimmed = p.trim();
248
+ if (trimmed.length === 0)
249
+ continue;
250
+ for (const e of resolveEntries(path.resolve(trimmed)))
251
+ push(e, "explicit");
252
+ }
253
+ }
254
+ for (const p of opts.extraPaths ?? []) {
255
+ for (const e of resolveEntries(path.resolve(p)))
256
+ push(e, "explicit");
257
+ }
258
+ return out;
259
+ }
260
+ /**
261
+ * Ordered, deduplicated entry files: project scope, global scope, then
262
+ * explicit paths (env ATOM_EXTENSIONS + extraPaths). Missing scopes are
263
+ * silently skipped — never throws.
264
+ */
265
+ export function discoverExtensionPaths(opts = {}) {
266
+ return discoverExtensionEntries(opts).map((e) => e.path);
267
+ }
268
+ // Scope for a pre-resolved entry file (the LoadOptions.entryPaths seam):
269
+ // under the project dir → project, under the global dir → global,
270
+ // anywhere else → explicit (gated like project scope).
271
+ export function classifyExtensionScope(entryPath, opts = {}) {
272
+ const abs = path.resolve(entryPath);
273
+ const proj = path.resolve(projectExtensionsDir(opts.cwd));
274
+ if (abs === proj || abs.startsWith(proj + path.sep))
275
+ return "project";
276
+ const glob = path.resolve(globalExtensionsDir(opts.home));
277
+ if (abs === glob || abs.startsWith(glob + path.sep))
278
+ return "global";
279
+ return "explicit";
280
+ }
281
+ export function parseExtensionFlags(argv) {
282
+ const out = { lockdown: false, enable: [], disable: [] };
283
+ const takeValue = (args, i, name) => {
284
+ const a = args[i];
285
+ const eqPrefix = `${name}=`;
286
+ if (a.startsWith(eqPrefix))
287
+ return { value: a.slice(eqPrefix.length), next: i };
288
+ if (a === name) {
289
+ const next = args[i + 1];
290
+ // Same convention as cli.tsx --port: a following `-flag` is another
291
+ // flag, not this flag's value.
292
+ if (typeof next === "string" && !next.startsWith("-"))
293
+ return { value: next, next: i + 1 };
294
+ }
295
+ return { next: i };
296
+ };
297
+ const pushList = (raw, target) => {
298
+ if (raw === undefined)
299
+ return;
300
+ for (const part of raw.split(",")) {
301
+ const trimmed = part.trim();
302
+ if (trimmed.length > 0)
303
+ target.push(trimmed);
304
+ }
305
+ };
306
+ for (let i = 0; i < argv.length; i++) {
307
+ const a = argv[i];
308
+ if (a === "--no-extensions" || a === "--lockdown") {
309
+ out.lockdown = true;
310
+ continue;
311
+ }
312
+ if (a === "--enable-extension" || a.startsWith("--enable-extension=")) {
313
+ const r = takeValue(argv, i, "--enable-extension");
314
+ i = r.next;
315
+ pushList(r.value, out.enable);
316
+ continue;
317
+ }
318
+ if (a === "--disable-extension" || a.startsWith("--disable-extension=")) {
319
+ const r = takeValue(argv, i, "--disable-extension");
320
+ i = r.next;
321
+ pushList(r.value, out.disable);
322
+ continue;
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+ export function resolveExtensionName(entryPath) {
328
+ const base = path.basename(entryPath);
329
+ const stem = base.replace(/\.(ts|js|mjs|cjs)$/i, "");
330
+ if (stem.toLowerCase() !== "index")
331
+ return stem;
332
+ const parent = path.basename(path.dirname(entryPath));
333
+ return parent.length > 0 ? parent : stem;
334
+ }
335
+ function errorText(e) {
336
+ return e instanceof Error ? e.message : String(e ?? "unknown error");
337
+ }
338
+ let jitiInstance = null;
339
+ function jiti() {
340
+ if (!jitiInstance) {
341
+ jitiInstance = createJiti(import.meta.url);
342
+ }
343
+ return jitiInstance;
344
+ }
345
+ function resolveFactory(mod) {
346
+ if (typeof mod === "function")
347
+ return mod;
348
+ if (isRecord(mod)) {
349
+ const def = mod.default;
350
+ if (typeof def === "function")
351
+ return def;
352
+ }
353
+ return null;
354
+ }
355
+ /**
356
+ * Load every discovered extension. Never throws: each failure is recorded
357
+ * in runtime.errors with its path, and loading continues with the rest.
358
+ */
359
+ export async function loadExtensions(opts = {}) {
360
+ // Ticket-07 gate inputs (precedence: lockdown > untrusted-project >
361
+ // disabledPatterns > enabledPatterns > load; disabled wins over enabled).
362
+ // projectTrusted defaults true for direct programmatic callers (tests,
363
+ // tooling); the App always passes the explicit boot trust value, so
364
+ // production project scope stays gated.
365
+ const lockdown = opts.lockdown === true;
366
+ const trusted = opts.projectTrusted !== false;
367
+ const enabled = opts.enabledPatterns ?? [];
368
+ const disabled = opts.disabledPatterns ?? [];
369
+ // Scoped entries: discovery already labels scope; pre-resolved entryPaths
370
+ // are classified by location (project dir → project, global dir → global,
371
+ // anywhere else → explicit). Entry order is preserved either way.
372
+ const scoped = opts.entryPaths !== undefined
373
+ ? opts.entryPaths.map((p) => ({ path: path.resolve(p), scope: classifyExtensionScope(p, opts) }))
374
+ : discoverExtensionEntries(opts);
375
+ // The gate: skipped entries are never imported (factory never runs — fully
376
+ // inert), only recorded with the reason so the host stays visible.
377
+ const skipped = [];
378
+ const entryPaths = [];
379
+ for (const e of scoped) {
380
+ const name = resolveExtensionName(e.path);
381
+ if (lockdown) {
382
+ skipped.push({ path: e.path, name, reason: "lockdown" });
383
+ continue;
384
+ }
385
+ // Global-scope extensions are user-owned (implicitly trusted, like the
386
+ // user's own config); the gate applies to project-scope + explicit paths.
387
+ if (e.scope !== "global" && !trusted) {
388
+ skipped.push({ path: e.path, name, reason: "untrusted-project" });
389
+ continue;
390
+ }
391
+ if (disabled.some((pattern) => matchGlob(pattern, name))) {
392
+ skipped.push({ path: e.path, name, reason: "disabled" });
393
+ continue;
394
+ }
395
+ if (enabled.length > 0 && !enabled.some((pattern) => matchGlob(pattern, name))) {
396
+ skipped.push({ path: e.path, name, reason: "not-enabled" });
397
+ continue;
398
+ }
399
+ entryPaths.push(e.path);
400
+ }
401
+ const handlers = new Map();
402
+ // Cancellable switch gate (ticket 05): runtime-local like event handlers
403
+ // (never a global store — a replaced runtime must not leak vetoes).
404
+ const beforeSwitchHandlers = [];
405
+ const loaded = [];
406
+ const errors = [];
407
+ let generation = 0;
408
+ let staleMessage = null;
409
+ // Session scoping for get/setSessionState (ticket 05): the store base is
410
+ // the load home; the host re-binds the id on every session boundary.
411
+ const sessionHome = opts.home;
412
+ let currentSessionId = typeof opts.sessionId === "string" && opts.sessionId.length > 0 ? opts.sessionId : null;
413
+ // Ticket-10 UI surface: runtime-local like beforeSwitchHandlers (a
414
+ // replaced runtime never leaks segments into another lineage, and
415
+ // parallel runtimes in tests never observe each other). Segments and
416
+ // widgets persist across session replacement keyed by owner — the fresh
417
+ // session_start API updates the same slots — and clear only on unload
418
+ // (unregister) or teardown (disposeUI). Only the pending dialog is
419
+ // transient: invalidate rejects it so it never hangs across a switch.
420
+ const interactive = opts.interactive === true;
421
+ const statusSegments = new Map();
422
+ const widgets = new Map();
423
+ const notifications = [];
424
+ let pendingDialog = null;
425
+ let dialogSeq = 0;
426
+ const uiListeners = new Set();
427
+ const emitUI = () => {
428
+ for (const fn of [...uiListeners]) {
429
+ try {
430
+ fn();
431
+ }
432
+ catch {
433
+ // Listener errors never break the host (same best-effort rule as emit).
434
+ }
435
+ }
436
+ };
437
+ const widgetKey = (owner, id) => `${owner}∅${id}`;
438
+ const addBeforeSwitchHandler = (extensionPath, handler) => {
439
+ const record = { extensionPath, handler };
440
+ beforeSwitchHandlers.push(record);
441
+ let live = true;
442
+ return () => {
443
+ if (!live)
444
+ return;
445
+ live = false;
446
+ const idx = beforeSwitchHandlers.indexOf(record);
447
+ if (idx >= 0)
448
+ beforeSwitchHandlers.splice(idx, 1);
449
+ };
450
+ };
451
+ // Builtin slash names (slash-prefixed) extension commands must not
452
+ // shadow — checked after shape validation whenever a command registers.
453
+ const builtinSlash = new Set((opts.builtinSlashCommands ?? []).map((n) => (n.startsWith("/") ? n : `/${n}`)));
454
+ const assertCommandNameFree = (def) => {
455
+ if (builtinSlash.has(`/${def.name}`)) {
456
+ throw new Error(`extension command "/${def.name}" collides with a builtin command (builtins always win)`);
457
+ }
458
+ };
459
+ const makeApi = (name, extensionPath) => {
460
+ // Generation captured at creation: any use after a later invalidate
461
+ // throws, while APIs minted fresh at emit time stay live.
462
+ const apiGeneration = generation;
463
+ const staleCheck = () => {
464
+ if (apiGeneration !== generation) {
465
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
466
+ }
467
+ };
468
+ return {
469
+ name,
470
+ on: (event, handler) => {
471
+ staleCheck();
472
+ if (typeof handler !== "function") {
473
+ throw new Error(`extension "${name}": handler for "${event}" must be a function`);
474
+ }
475
+ let list = handlers.get(event);
476
+ if (!list) {
477
+ list = [];
478
+ handlers.set(event, list);
479
+ }
480
+ const record = { extensionPath, handler };
481
+ list.push(record);
482
+ return () => {
483
+ const current = handlers.get(event);
484
+ if (!current)
485
+ return;
486
+ const idx = current.indexOf(record);
487
+ if (idx >= 0)
488
+ current.splice(idx, 1);
489
+ };
490
+ },
491
+ registerTool: (def) => {
492
+ staleCheck();
493
+ const apiGenerationAtCall = apiGeneration;
494
+ const unregister = registerExtensionTool(def);
495
+ let live = true;
496
+ return () => {
497
+ if (apiGenerationAtCall !== generation) {
498
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
499
+ }
500
+ if (!live)
501
+ return;
502
+ live = false;
503
+ unregister();
504
+ };
505
+ },
506
+ overrideTool: (def) => {
507
+ staleCheck();
508
+ const apiGenerationAtCall = apiGeneration;
509
+ const unregister = registerExtensionToolOverride(def, name);
510
+ let live = true;
511
+ return () => {
512
+ if (apiGenerationAtCall !== generation) {
513
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
514
+ }
515
+ if (!live)
516
+ return;
517
+ live = false;
518
+ unregister();
519
+ };
520
+ },
521
+ addPromptHint: (hint) => {
522
+ staleCheck();
523
+ const apiGenerationAtCall = apiGeneration;
524
+ const unregister = registerExtensionPromptHint(hint, name);
525
+ let live = true;
526
+ return () => {
527
+ if (apiGenerationAtCall !== generation) {
528
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
529
+ }
530
+ if (!live)
531
+ return;
532
+ live = false;
533
+ unregister();
534
+ };
535
+ },
536
+ registerCommand: (def) => {
537
+ staleCheck();
538
+ validateExtensionCommandDef(def);
539
+ assertCommandNameFree(def);
540
+ const apiGenerationAtCall = apiGeneration;
541
+ const unregister = registerCommandInStore(def, name);
542
+ let live = true;
543
+ return () => {
544
+ if (apiGenerationAtCall !== generation) {
545
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
546
+ }
547
+ if (!live)
548
+ return;
549
+ live = false;
550
+ unregister();
551
+ };
552
+ },
553
+ onBeforeToolCall: (handler) => {
554
+ staleCheck();
555
+ if (typeof handler !== "function") {
556
+ throw new Error(`extension "${name}": before-tool-call handler must be a function`);
557
+ }
558
+ const apiGenerationAtCall = apiGeneration;
559
+ const unregister = registerBeforeToolCall(handler, name);
560
+ let live = true;
561
+ return () => {
562
+ if (apiGenerationAtCall !== generation) {
563
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
564
+ }
565
+ if (!live)
566
+ return;
567
+ live = false;
568
+ unregister();
569
+ };
570
+ },
571
+ onAfterToolCall: (handler) => {
572
+ staleCheck();
573
+ if (typeof handler !== "function") {
574
+ throw new Error(`extension "${name}": after-tool-call handler must be a function`);
575
+ }
576
+ const apiGenerationAtCall = apiGeneration;
577
+ const unregister = registerAfterToolCall(handler, name);
578
+ let live = true;
579
+ return () => {
580
+ if (apiGenerationAtCall !== generation) {
581
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
582
+ }
583
+ if (!live)
584
+ return;
585
+ live = false;
586
+ unregister();
587
+ };
588
+ },
589
+ onBeforeSwitch: (handler) => {
590
+ staleCheck();
591
+ if (typeof handler !== "function") {
592
+ throw new Error(`extension "${name}": before-switch handler must be a function`);
593
+ }
594
+ const apiGenerationAtCall = apiGeneration;
595
+ const unregister = addBeforeSwitchHandler(extensionPath, handler);
596
+ let live = true;
597
+ return () => {
598
+ if (apiGenerationAtCall !== generation) {
599
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
600
+ }
601
+ if (!live)
602
+ return;
603
+ live = false;
604
+ unregister();
605
+ };
606
+ },
607
+ onTransformContext: (handler) => {
608
+ staleCheck();
609
+ if (typeof handler !== "function") {
610
+ throw new Error(`extension "${name}": context-transform handler must be a function`);
611
+ }
612
+ const apiGenerationAtCall = apiGeneration;
613
+ const unregister = registerContextTransform(handler, name);
614
+ let live = true;
615
+ return () => {
616
+ if (apiGenerationAtCall !== generation) {
617
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
618
+ }
619
+ if (!live)
620
+ return;
621
+ live = false;
622
+ unregister();
623
+ };
624
+ },
625
+ onBeforeRequest: (handler) => {
626
+ staleCheck();
627
+ if (typeof handler !== "function") {
628
+ throw new Error(`extension "${name}": before-request handler must be a function`);
629
+ }
630
+ const apiGenerationAtCall = apiGeneration;
631
+ const unregister = registerBeforeRequest(handler, name);
632
+ let live = true;
633
+ return () => {
634
+ if (apiGenerationAtCall !== generation) {
635
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
636
+ }
637
+ if (!live)
638
+ return;
639
+ live = false;
640
+ unregister();
641
+ };
642
+ },
643
+ onAfterResponse: (handler) => {
644
+ staleCheck();
645
+ if (typeof handler !== "function") {
646
+ throw new Error(`extension "${name}": after-response handler must be a function`);
647
+ }
648
+ const apiGenerationAtCall = apiGeneration;
649
+ const unregister = registerAfterResponse(handler, name);
650
+ let live = true;
651
+ return () => {
652
+ if (apiGenerationAtCall !== generation) {
653
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
654
+ }
655
+ if (!live)
656
+ return;
657
+ live = false;
658
+ unregister();
659
+ };
660
+ },
661
+ onBeforeCompact: (handler) => {
662
+ staleCheck();
663
+ if (typeof handler !== "function") {
664
+ throw new Error(`extension "${name}": before-compact handler must be a function`);
665
+ }
666
+ const apiGenerationAtCall = apiGeneration;
667
+ const unregister = registerBeforeCompact(handler, name);
668
+ let live = true;
669
+ return () => {
670
+ if (apiGenerationAtCall !== generation) {
671
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
672
+ }
673
+ if (!live)
674
+ return;
675
+ live = false;
676
+ unregister();
677
+ };
678
+ },
679
+ getSessionState: () => {
680
+ staleCheck();
681
+ return readExtensionState(sessionHome, currentSessionId, name);
682
+ },
683
+ setSessionState: (value) => {
684
+ staleCheck();
685
+ writeExtensionState(sessionHome, currentSessionId, name, value);
686
+ },
687
+ isProjectTrusted: () => {
688
+ staleCheck();
689
+ return trusted;
690
+ },
691
+ setStatusSegment: (text) => {
692
+ staleCheck();
693
+ // Eager shape check: a bad segment fails loudly (fail-closed), the
694
+ // slot upserts by owner so calling again updates live across turns
695
+ // (each call returns its own unregister; any of them removes the
696
+ // whole slot, so the latest handle is the one to keep).
697
+ const clean = validateStatusSegment(name, text);
698
+ const apiGenerationAtCall = apiGeneration;
699
+ statusSegments.set(name, clean);
700
+ emitUI();
701
+ let live = true;
702
+ return () => {
703
+ if (apiGenerationAtCall !== generation) {
704
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
705
+ }
706
+ if (!live)
707
+ return;
708
+ live = false;
709
+ statusSegments.delete(name);
710
+ emitUI();
711
+ };
712
+ },
713
+ setWidget: (def) => {
714
+ staleCheck();
715
+ // Eager shape + placement check: a bad widget fails loudly
716
+ // (fail-closed); same-id calls upsert in place for live updates.
717
+ const clean = validateWidgetDef(name, def);
718
+ const apiGenerationAtCall = apiGeneration;
719
+ const key = widgetKey(name, clean.id);
720
+ widgets.set(key, { owner: name, ...clean });
721
+ emitUI();
722
+ let live = true;
723
+ return () => {
724
+ if (apiGenerationAtCall !== generation) {
725
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
726
+ }
727
+ if (!live)
728
+ return;
729
+ live = false;
730
+ widgets.delete(key);
731
+ emitUI();
732
+ };
733
+ },
734
+ notify: (message) => {
735
+ staleCheck();
736
+ // Transient by design: staged for the host to drain into the
737
+ // transcript, dropped on teardown — nothing to unregister.
738
+ // Bounded (drop-oldest past EXT_NOTICE_CAP) so a chatty extension
739
+ // cannot grow memory between render drains.
740
+ notifications.push({ owner: name, message: validateNotifyMessage(name, message) });
741
+ if (notifications.length > EXT_NOTICE_CAP) {
742
+ notifications.splice(0, notifications.length - EXT_NOTICE_CAP);
743
+ }
744
+ emitUI();
745
+ },
746
+ promptUser: (question, options, allowCustom) => {
747
+ staleCheck();
748
+ const clean = validateDialogDef(name, { question, options, allowCustom });
749
+ if (!interactive) {
750
+ return Promise.reject(new Error(`extension "${name}": dialogs are unavailable in non-interactive mode (no prompt was shown)`));
751
+ }
752
+ if (pendingDialog) {
753
+ return Promise.reject(new Error("(an extension dialog is already open — wait for it to resolve)"));
754
+ }
755
+ const apiGenerationAtCall = apiGeneration;
756
+ const id = (dialogSeq += 1);
757
+ return new Promise((resolve, reject) => {
758
+ pendingDialog = {
759
+ id,
760
+ owner: name,
761
+ question: clean.question,
762
+ options: clean.options,
763
+ allowCustom: clean.allowCustom,
764
+ generation: apiGenerationAtCall,
765
+ resolve: (answer) => {
766
+ if (pendingDialog?.id !== id)
767
+ return;
768
+ pendingDialog = null;
769
+ emitUI();
770
+ resolve(answer);
771
+ },
772
+ reject: (err) => {
773
+ if (pendingDialog?.id !== id)
774
+ return;
775
+ pendingDialog = null;
776
+ emitUI();
777
+ reject(err);
778
+ },
779
+ };
780
+ emitUI();
781
+ });
782
+ },
783
+ };
784
+ };
785
+ const runtime = {
786
+ loaded,
787
+ errors,
788
+ skipped,
789
+ get generation() {
790
+ return generation;
791
+ },
792
+ invalidate(message) {
793
+ staleMessage = message;
794
+ generation += 1;
795
+ // A dialog awaiting input across a session switch resolves safely:
796
+ // reject with the stale message (never hangs, never fulfills into
797
+ // the wrong session). Visible segments/widgets persist keyed by
798
+ // owner — the fresh session_start API updates the same slots.
799
+ const cur = pendingDialog;
800
+ if (cur)
801
+ cur.reject(new Error(message));
802
+ },
803
+ async emit(event, info) {
804
+ const list = handlers.get(event) ?? [];
805
+ for (const record of [...list]) {
806
+ const api = makeApi(resolveExtensionName(record.extensionPath), record.extensionPath);
807
+ try {
808
+ await record.handler(api, info);
809
+ }
810
+ catch (e) {
811
+ errors.push({ path: record.extensionPath, error: `${event} handler failed: ${errorText(e)}` });
812
+ }
813
+ }
814
+ },
815
+ setSessionId(id) {
816
+ currentSessionId = typeof id === "string" && id.length > 0 ? id : null;
817
+ },
818
+ // Ticket-10 host reads (all copies — callers can never alias live
819
+ // store state) and host-side dialog settlement. Never throw except
820
+ // subscribeUI's shape check (the on() convention).
821
+ getStatusSegments() {
822
+ return [...statusSegments].map(([owner, text]) => ({ owner, text }));
823
+ },
824
+ getWidgets() {
825
+ return [...widgets.values()].map((w) => ({ ...w }));
826
+ },
827
+ drainNotifications() {
828
+ const out = notifications.map((n) => ({ ...n }));
829
+ notifications.length = 0;
830
+ return out;
831
+ },
832
+ getPendingDialog() {
833
+ if (!pendingDialog)
834
+ return null;
835
+ return {
836
+ id: pendingDialog.id,
837
+ owner: pendingDialog.owner,
838
+ question: pendingDialog.question,
839
+ options: [...pendingDialog.options],
840
+ allowCustom: pendingDialog.allowCustom,
841
+ };
842
+ },
843
+ subscribeUI(listener) {
844
+ if (typeof listener !== "function") {
845
+ throw new Error("extension UI listener must be a function");
846
+ }
847
+ uiListeners.add(listener);
848
+ let live = true;
849
+ return () => {
850
+ if (!live)
851
+ return;
852
+ live = false;
853
+ uiListeners.delete(listener);
854
+ };
855
+ },
856
+ resolvePendingDialog(answer) {
857
+ const cur = pendingDialog;
858
+ if (!cur)
859
+ return false;
860
+ if (typeof answer !== "string" || answer.length === 0)
861
+ return false;
862
+ cur.resolve(answer);
863
+ return true;
864
+ },
865
+ cancelPendingDialog(reason) {
866
+ const cur = pendingDialog;
867
+ if (!cur)
868
+ return false;
869
+ cur.reject(new Error(typeof reason === "string" && reason.length > 0
870
+ ? reason
871
+ : `extension "${cur.owner}" dialog was cancelled`));
872
+ return true;
873
+ },
874
+ disposeUI() {
875
+ statusSegments.clear();
876
+ widgets.clear();
877
+ notifications.length = 0;
878
+ const cur = pendingDialog;
879
+ if (cur) {
880
+ cur.reject(new Error(`extension "${cur.owner}" dialog was cancelled (session teardown)`));
881
+ }
882
+ else {
883
+ emitUI();
884
+ }
885
+ },
886
+ // The sanctioned pre-replacement gate: the host awaits this BEFORE any
887
+ // snapshot/persist/mutate step. Handlers observe fresh APIs; the first
888
+ // explicit cancel wins (later handlers never run); throws fail open.
889
+ // The sanctioned post-replacement continuation is the fresh API passed
890
+ // to session_start handlers by emit — captured pre-replacement handles
891
+ // throw via the generation check above (reused, never reimplemented).
892
+ async requestSwitch(info) {
893
+ for (const record of [...beforeSwitchHandlers]) {
894
+ const extName = resolveExtensionName(record.extensionPath);
895
+ const api = makeApi(extName, record.extensionPath);
896
+ let decision;
897
+ try {
898
+ decision = await record.handler(api, info);
899
+ }
900
+ catch (e) {
901
+ errors.push({ path: record.extensionPath, error: `before_switch handler failed: ${errorText(e)}` });
902
+ continue;
903
+ }
904
+ const reason = cancelReasonOf(decision, extName);
905
+ if (reason !== null)
906
+ return { cancelled: true, reason };
907
+ }
908
+ return { cancelled: false };
909
+ },
910
+ };
911
+ const importer = jiti();
912
+ for (const entryPath of entryPaths) {
913
+ const name = resolveExtensionName(entryPath);
914
+ let mod;
915
+ try {
916
+ mod = await importer.import(entryPath, { default: true });
917
+ }
918
+ catch (e) {
919
+ errors.push({ path: entryPath, error: `import failed: ${errorText(e)}` });
920
+ continue;
921
+ }
922
+ const factory = resolveFactory(mod);
923
+ if (!factory) {
924
+ errors.push({ path: entryPath, error: "does not export a factory function (default export must be a function)" });
925
+ continue;
926
+ }
927
+ // A factory that throws during activation fails alone: activation runs
928
+ // before commit, so nothing from this extension is ever registered.
929
+ const pendingHandlers = [];
930
+ const pendingTools = [];
931
+ const pendingOverrides = [];
932
+ const pendingHints = [];
933
+ const pendingCommands = [];
934
+ const pendingBefore = [];
935
+ const pendingAfter = [];
936
+ // Staged switch gate (same atomicity as interceptors): validated eagerly
937
+ // so a non-function fails activation loudly, committed only after the
938
+ // factory succeeds — a throwing factory leaves no veto behind.
939
+ const pendingSwitch = [];
940
+ // Staged provider hooks (ticket 08; same atomicity as interceptors): a
941
+ // factory that throws during activation leaves no provider hook behind.
942
+ // Committed after the tool/command rounds below — hook registration
943
+ // cannot fail, so a tool-commit failure still rolls back to zero.
944
+ const pendingProviderContext = [];
945
+ const pendingProviderPre = [];
946
+ const pendingProviderPost = [];
947
+ // Staged compaction hooks (ticket 09; same atomicity as provider hooks):
948
+ // a factory that throws during activation leaves no compaction hook
949
+ // behind. Committed with the rounds below — hook registration cannot
950
+ // fail, so a tool/command failure still rolls back to zero.
951
+ const pendingCompact = [];
952
+ // Staged UI surface (ticket 10; same atomicity as hints): validated
953
+ // eagerly so a bad shape fails activation loudly, committed only after
954
+ // the factory succeeds — a throwing factory leaves zero UI residue.
955
+ // (Boxed: the slot is assigned only inside unregister closures, and a
956
+ // plain `let` would flow-narrow to its null initializer at commit.)
957
+ const stagedStatusBox = { current: null };
958
+ const pendingWidgets = [];
959
+ const pendingNotices = [];
960
+ const activationGeneration = generation;
961
+ const activationApi = {
962
+ name,
963
+ on: (event, handler) => {
964
+ if (activationGeneration !== generation) {
965
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
966
+ }
967
+ if (typeof handler !== "function") {
968
+ throw new Error(`extension "${name}": handler for "${event}" must be a function`);
969
+ }
970
+ pendingHandlers.push({ event, handler });
971
+ return () => {
972
+ const idx = pendingHandlers.findIndex((p) => p.event === event && p.handler === handler);
973
+ if (idx >= 0)
974
+ pendingHandlers.splice(idx, 1);
975
+ };
976
+ },
977
+ registerTool: (def) => {
978
+ if (activationGeneration !== generation) {
979
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
980
+ }
981
+ // Eager shape check: a malformed definition fails this extension's
982
+ // activation before anything is committed (cross-extension name
983
+ // races still surface at commit below, failing alone there too).
984
+ validateExtensionToolDef(def);
985
+ if (pendingTools.some((p) => p.def.name === def.name)) {
986
+ throw new Error(`extension "${name}": tool "${def.name}" is already registered`);
987
+ }
988
+ const entry = { def, committedUnregister: null };
989
+ pendingTools.push(entry);
990
+ let alive = true;
991
+ return () => {
992
+ if (activationGeneration !== generation) {
993
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
994
+ }
995
+ if (!alive)
996
+ return;
997
+ alive = false;
998
+ if (entry.committedUnregister) {
999
+ const undo = entry.committedUnregister;
1000
+ entry.committedUnregister = null;
1001
+ undo();
1002
+ }
1003
+ else {
1004
+ const idx = pendingTools.indexOf(entry);
1005
+ if (idx >= 0)
1006
+ pendingTools.splice(idx, 1);
1007
+ }
1008
+ };
1009
+ },
1010
+ overrideTool: (def) => {
1011
+ if (activationGeneration !== generation) {
1012
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1013
+ }
1014
+ // Eager shape + builtin checks: a malformed definition or a
1015
+ // non-builtin name fails this extension's activation before anything
1016
+ // is committed (cross-extension override races still surface at
1017
+ // commit below, failing alone there too).
1018
+ validateExtensionToolOverrideDef(def);
1019
+ if (!TOOL_DEFINITIONS.some((t) => t.function.name === def.name)) {
1020
+ throw new Error(`extension tool override "${def.name}" is not a builtin tool (only builtins can be overridden)`);
1021
+ }
1022
+ if (pendingOverrides.some((p) => p.def.name === def.name)) {
1023
+ throw new Error(`extension "${name}": tool override "${def.name}" is already registered`);
1024
+ }
1025
+ const entry = { def, committedUnregister: null };
1026
+ pendingOverrides.push(entry);
1027
+ let alive = true;
1028
+ return () => {
1029
+ if (activationGeneration !== generation) {
1030
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1031
+ }
1032
+ if (!alive)
1033
+ return;
1034
+ alive = false;
1035
+ if (entry.committedUnregister) {
1036
+ const undo = entry.committedUnregister;
1037
+ entry.committedUnregister = null;
1038
+ undo();
1039
+ }
1040
+ else {
1041
+ const idx = pendingOverrides.indexOf(entry);
1042
+ if (idx >= 0)
1043
+ pendingOverrides.splice(idx, 1);
1044
+ }
1045
+ };
1046
+ },
1047
+ addPromptHint: (hint) => {
1048
+ if (activationGeneration !== generation) {
1049
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1050
+ }
1051
+ // Eager shape check: an empty/oversize hint fails this extension's
1052
+ // activation before anything is committed.
1053
+ validateExtensionPromptHint(hint);
1054
+ const entry = { hint, committedUnregister: null };
1055
+ pendingHints.push(entry);
1056
+ let alive = true;
1057
+ return () => {
1058
+ if (activationGeneration !== generation) {
1059
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1060
+ }
1061
+ if (!alive)
1062
+ return;
1063
+ alive = false;
1064
+ if (entry.committedUnregister) {
1065
+ const undo = entry.committedUnregister;
1066
+ entry.committedUnregister = null;
1067
+ undo();
1068
+ }
1069
+ else {
1070
+ const idx = pendingHints.indexOf(entry);
1071
+ if (idx >= 0)
1072
+ pendingHints.splice(idx, 1);
1073
+ }
1074
+ };
1075
+ },
1076
+ registerCommand: (def) => {
1077
+ if (activationGeneration !== generation) {
1078
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1079
+ }
1080
+ // Eager shape + builtin checks: a malformed or colliding definition
1081
+ // fails this extension's activation before anything is committed
1082
+ // (cross-extension name races still surface at commit below).
1083
+ validateExtensionCommandDef(def);
1084
+ assertCommandNameFree(def);
1085
+ if (pendingCommands.some((p) => p.def.name === def.name)) {
1086
+ throw new Error(`extension "${name}": command "/${def.name}" is already registered`);
1087
+ }
1088
+ const entry = { def, committedUnregister: null };
1089
+ pendingCommands.push(entry);
1090
+ let alive = true;
1091
+ return () => {
1092
+ if (activationGeneration !== generation) {
1093
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1094
+ }
1095
+ if (!alive)
1096
+ return;
1097
+ alive = false;
1098
+ if (entry.committedUnregister) {
1099
+ const undo = entry.committedUnregister;
1100
+ entry.committedUnregister = null;
1101
+ undo();
1102
+ }
1103
+ else {
1104
+ const idx = pendingCommands.indexOf(entry);
1105
+ if (idx >= 0)
1106
+ pendingCommands.splice(idx, 1);
1107
+ }
1108
+ };
1109
+ },
1110
+ onBeforeToolCall: (handler) => {
1111
+ if (activationGeneration !== generation) {
1112
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1113
+ }
1114
+ if (typeof handler !== "function") {
1115
+ throw new Error(`extension "${name}": before-tool-call handler must be a function`);
1116
+ }
1117
+ const entry = { handler, committedUnregister: null };
1118
+ pendingBefore.push(entry);
1119
+ let alive = true;
1120
+ return () => {
1121
+ if (activationGeneration !== generation) {
1122
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1123
+ }
1124
+ if (!alive)
1125
+ return;
1126
+ alive = false;
1127
+ if (entry.committedUnregister) {
1128
+ const undo = entry.committedUnregister;
1129
+ entry.committedUnregister = null;
1130
+ undo();
1131
+ }
1132
+ else {
1133
+ const idx = pendingBefore.indexOf(entry);
1134
+ if (idx >= 0)
1135
+ pendingBefore.splice(idx, 1);
1136
+ }
1137
+ };
1138
+ },
1139
+ onAfterToolCall: (handler) => {
1140
+ if (activationGeneration !== generation) {
1141
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1142
+ }
1143
+ if (typeof handler !== "function") {
1144
+ throw new Error(`extension "${name}": after-tool-call handler must be a function`);
1145
+ }
1146
+ const entry = { handler, committedUnregister: null };
1147
+ pendingAfter.push(entry);
1148
+ let alive = true;
1149
+ return () => {
1150
+ if (activationGeneration !== generation) {
1151
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1152
+ }
1153
+ if (!alive)
1154
+ return;
1155
+ alive = false;
1156
+ if (entry.committedUnregister) {
1157
+ const undo = entry.committedUnregister;
1158
+ entry.committedUnregister = null;
1159
+ undo();
1160
+ }
1161
+ else {
1162
+ const idx = pendingAfter.indexOf(entry);
1163
+ if (idx >= 0)
1164
+ pendingAfter.splice(idx, 1);
1165
+ }
1166
+ };
1167
+ },
1168
+ onTransformContext: (handler) => {
1169
+ if (activationGeneration !== generation) {
1170
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1171
+ }
1172
+ if (typeof handler !== "function") {
1173
+ throw new Error(`extension "${name}": context-transform handler must be a function`);
1174
+ }
1175
+ const entry = { handler, committedUnregister: null };
1176
+ pendingProviderContext.push(entry);
1177
+ let alive = true;
1178
+ return () => {
1179
+ if (activationGeneration !== generation) {
1180
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1181
+ }
1182
+ if (!alive)
1183
+ return;
1184
+ alive = false;
1185
+ if (entry.committedUnregister) {
1186
+ const undo = entry.committedUnregister;
1187
+ entry.committedUnregister = null;
1188
+ undo();
1189
+ }
1190
+ else {
1191
+ const idx = pendingProviderContext.indexOf(entry);
1192
+ if (idx >= 0)
1193
+ pendingProviderContext.splice(idx, 1);
1194
+ }
1195
+ };
1196
+ },
1197
+ onBeforeRequest: (handler) => {
1198
+ if (activationGeneration !== generation) {
1199
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1200
+ }
1201
+ if (typeof handler !== "function") {
1202
+ throw new Error(`extension "${name}": before-request handler must be a function`);
1203
+ }
1204
+ const entry = { handler, committedUnregister: null };
1205
+ pendingProviderPre.push(entry);
1206
+ let alive = true;
1207
+ return () => {
1208
+ if (activationGeneration !== generation) {
1209
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1210
+ }
1211
+ if (!alive)
1212
+ return;
1213
+ alive = false;
1214
+ if (entry.committedUnregister) {
1215
+ const undo = entry.committedUnregister;
1216
+ entry.committedUnregister = null;
1217
+ undo();
1218
+ }
1219
+ else {
1220
+ const idx = pendingProviderPre.indexOf(entry);
1221
+ if (idx >= 0)
1222
+ pendingProviderPre.splice(idx, 1);
1223
+ }
1224
+ };
1225
+ },
1226
+ onAfterResponse: (handler) => {
1227
+ if (activationGeneration !== generation) {
1228
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1229
+ }
1230
+ if (typeof handler !== "function") {
1231
+ throw new Error(`extension "${name}": after-response handler must be a function`);
1232
+ }
1233
+ const entry = { handler, committedUnregister: null };
1234
+ pendingProviderPost.push(entry);
1235
+ let alive = true;
1236
+ return () => {
1237
+ if (activationGeneration !== generation) {
1238
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1239
+ }
1240
+ if (!alive)
1241
+ return;
1242
+ alive = false;
1243
+ if (entry.committedUnregister) {
1244
+ const undo = entry.committedUnregister;
1245
+ entry.committedUnregister = null;
1246
+ undo();
1247
+ }
1248
+ else {
1249
+ const idx = pendingProviderPost.indexOf(entry);
1250
+ if (idx >= 0)
1251
+ pendingProviderPost.splice(idx, 1);
1252
+ }
1253
+ };
1254
+ },
1255
+ onBeforeCompact: (handler) => {
1256
+ if (activationGeneration !== generation) {
1257
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1258
+ }
1259
+ if (typeof handler !== "function") {
1260
+ throw new Error(`extension "${name}": before-compact handler must be a function`);
1261
+ }
1262
+ const entry = { handler, committedUnregister: null };
1263
+ pendingCompact.push(entry);
1264
+ let alive = true;
1265
+ return () => {
1266
+ if (activationGeneration !== generation) {
1267
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1268
+ }
1269
+ if (!alive)
1270
+ return;
1271
+ alive = false;
1272
+ if (entry.committedUnregister) {
1273
+ const undo = entry.committedUnregister;
1274
+ entry.committedUnregister = null;
1275
+ undo();
1276
+ }
1277
+ else {
1278
+ const idx = pendingCompact.indexOf(entry);
1279
+ if (idx >= 0)
1280
+ pendingCompact.splice(idx, 1);
1281
+ }
1282
+ };
1283
+ },
1284
+ onBeforeSwitch: (handler) => {
1285
+ if (activationGeneration !== generation) {
1286
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1287
+ }
1288
+ if (typeof handler !== "function") {
1289
+ throw new Error(`extension "${name}": before-switch handler must be a function`);
1290
+ }
1291
+ const entry = { handler, committedUnregister: null };
1292
+ pendingSwitch.push(entry);
1293
+ let alive = true;
1294
+ return () => {
1295
+ if (activationGeneration !== generation) {
1296
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1297
+ }
1298
+ if (!alive)
1299
+ return;
1300
+ alive = false;
1301
+ if (entry.committedUnregister) {
1302
+ const undo = entry.committedUnregister;
1303
+ entry.committedUnregister = null;
1304
+ undo();
1305
+ }
1306
+ else {
1307
+ const idx = pendingSwitch.indexOf(entry);
1308
+ if (idx >= 0)
1309
+ pendingSwitch.splice(idx, 1);
1310
+ }
1311
+ };
1312
+ },
1313
+ getSessionState: () => {
1314
+ if (activationGeneration !== generation) {
1315
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1316
+ }
1317
+ return readExtensionState(sessionHome, currentSessionId, name);
1318
+ },
1319
+ setSessionState: (value) => {
1320
+ if (activationGeneration !== generation) {
1321
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1322
+ }
1323
+ writeExtensionState(sessionHome, currentSessionId, name, value);
1324
+ },
1325
+ isProjectTrusted: () => {
1326
+ if (activationGeneration !== generation) {
1327
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1328
+ }
1329
+ return trusted;
1330
+ },
1331
+ setStatusSegment: (text) => {
1332
+ if (activationGeneration !== generation) {
1333
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1334
+ }
1335
+ // Eager shape check + single-slot staging: a later set overwrites
1336
+ // the earlier (live-update upsert), a throw rolls back to null.
1337
+ const entry = { text: validateStatusSegment(name, text), committed: false };
1338
+ stagedStatusBox.current = entry;
1339
+ let alive = true;
1340
+ return () => {
1341
+ if (activationGeneration !== generation) {
1342
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1343
+ }
1344
+ if (!alive)
1345
+ return;
1346
+ alive = false;
1347
+ if (entry.committed) {
1348
+ statusSegments.delete(name);
1349
+ emitUI();
1350
+ }
1351
+ else if (stagedStatusBox.current === entry) {
1352
+ stagedStatusBox.current = null;
1353
+ }
1354
+ };
1355
+ },
1356
+ setWidget: (def) => {
1357
+ if (activationGeneration !== generation) {
1358
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1359
+ }
1360
+ // Eager shape + placement check: same-id sets upsert the pending
1361
+ // entry (live-update staging), a throw drops the whole list.
1362
+ const clean = validateWidgetDef(name, def);
1363
+ const key = widgetKey(name, clean.id);
1364
+ let entry = pendingWidgets.find((p) => widgetKey(name, p.def.id) === key);
1365
+ if (!entry) {
1366
+ entry = { def: clean, committed: false };
1367
+ pendingWidgets.push(entry);
1368
+ }
1369
+ else {
1370
+ entry.def = clean;
1371
+ }
1372
+ const staged = entry;
1373
+ let alive = true;
1374
+ return () => {
1375
+ if (activationGeneration !== generation) {
1376
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1377
+ }
1378
+ if (!alive)
1379
+ return;
1380
+ alive = false;
1381
+ if (staged.committed) {
1382
+ widgets.delete(key);
1383
+ emitUI();
1384
+ }
1385
+ else {
1386
+ const idx = pendingWidgets.indexOf(staged);
1387
+ if (idx >= 0)
1388
+ pendingWidgets.splice(idx, 1);
1389
+ }
1390
+ };
1391
+ },
1392
+ notify: (message) => {
1393
+ if (activationGeneration !== generation) {
1394
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1395
+ }
1396
+ // Eager shape check: staged with the other UI, committed only on
1397
+ // success — a throwing factory posts nothing.
1398
+ pendingNotices.push(validateNotifyMessage(name, message));
1399
+ },
1400
+ promptUser: () => {
1401
+ if (activationGeneration !== generation) {
1402
+ throw new Error(staleMessage ?? "extension context is stale after a session replacement");
1403
+ }
1404
+ // Activation runs before the host assigns the runtime, so no modal
1405
+ // could ever fulfill this — reject instead of hanging forever.
1406
+ return Promise.reject(new Error(`extension "${name}": dialogs cannot open during activation — prompt from a command or event handler instead (nothing was shown)`));
1407
+ },
1408
+ };
1409
+ try {
1410
+ await factory(activationApi);
1411
+ }
1412
+ catch (e) {
1413
+ errors.push({ path: entryPath, error: `activation failed: ${errorText(e)}` });
1414
+ continue;
1415
+ }
1416
+ // Commit: activation succeeded, registrations go live atomically. A
1417
+ // duplicate/builtin-colliding tool name fails this extension alone:
1418
+ // tools committed earlier in this round roll back and handlers never
1419
+ // go live.
1420
+ const committedToolUndos = [];
1421
+ try {
1422
+ for (const t of pendingTools) {
1423
+ const undo = registerExtensionTool(t.def);
1424
+ t.committedUnregister = undo;
1425
+ committedToolUndos.push(undo);
1426
+ }
1427
+ }
1428
+ catch (e) {
1429
+ for (const undo of committedToolUndos) {
1430
+ try {
1431
+ undo();
1432
+ }
1433
+ catch {
1434
+ // rollback is best-effort; the error entry below carries the cause
1435
+ }
1436
+ }
1437
+ for (const t of pendingTools)
1438
+ t.committedUnregister = null;
1439
+ errors.push({ path: entryPath, error: `tool registration failed: ${errorText(e)}` });
1440
+ continue;
1441
+ }
1442
+ // Override commit (same atomic round): a duplicate override (a
1443
+ // cross-extension race — same-extension duplicates fail eagerly above)
1444
+ // fails this extension alone: overrides committed earlier in this round
1445
+ // roll back ALONGSIDE the tools above, and later rounds never go live.
1446
+ const committedOverrideUndos = [];
1447
+ try {
1448
+ for (const o of pendingOverrides) {
1449
+ const undo = registerExtensionToolOverride(o.def, name);
1450
+ o.committedUnregister = undo;
1451
+ committedOverrideUndos.push(undo);
1452
+ }
1453
+ }
1454
+ catch (e) {
1455
+ for (const undo of [...committedOverrideUndos, ...committedToolUndos]) {
1456
+ try {
1457
+ undo();
1458
+ }
1459
+ catch {
1460
+ // rollback is best-effort; the error entry below carries the cause
1461
+ }
1462
+ }
1463
+ for (const t of pendingTools)
1464
+ t.committedUnregister = null;
1465
+ for (const o of pendingOverrides)
1466
+ o.committedUnregister = null;
1467
+ errors.push({ path: entryPath, error: `tool override registration failed: ${errorText(e)}` });
1468
+ continue;
1469
+ }
1470
+ // Command commit (same atomic round): a duplicate/colliding name fails
1471
+ // this extension alone — commands committed earlier in this round roll
1472
+ // back ALONGSIDE the tools and overrides above, and handlers/interceptors
1473
+ // never go live, so a failed activation always leaves zero registrations
1474
+ // behind.
1475
+ const committedCommandUndos = [];
1476
+ try {
1477
+ for (const c of pendingCommands) {
1478
+ const undo = registerCommandInStore(c.def, name);
1479
+ c.committedUnregister = undo;
1480
+ committedCommandUndos.push(undo);
1481
+ }
1482
+ }
1483
+ catch (e) {
1484
+ for (const undo of [...committedCommandUndos, ...committedOverrideUndos, ...committedToolUndos]) {
1485
+ try {
1486
+ undo();
1487
+ }
1488
+ catch {
1489
+ // rollback is best-effort; the error entry below carries the cause
1490
+ }
1491
+ }
1492
+ for (const t of pendingTools)
1493
+ t.committedUnregister = null;
1494
+ for (const o of pendingOverrides)
1495
+ o.committedUnregister = null;
1496
+ for (const c of pendingCommands)
1497
+ c.committedUnregister = null;
1498
+ errors.push({ path: entryPath, error: `command registration failed: ${errorText(e)}` });
1499
+ continue;
1500
+ }
1501
+ // Hint commit (same atomic round): validated eagerly, cannot fail — a
1502
+ // tool/override/command failure above skips this entirely via continue,
1503
+ // so a failed activation never leaves a hint behind either.
1504
+ for (const h of pendingHints) {
1505
+ h.committedUnregister = registerExtensionPromptHint(h.hint, name);
1506
+ }
1507
+ // UI-surface commit (same atomic round): validated eagerly, cannot
1508
+ // fail — a tool/override/command failure above skips this entirely via
1509
+ // continue, so a failed activation never leaves a segment, widget, or
1510
+ // notice behind either.
1511
+ const stagedStatus = stagedStatusBox.current;
1512
+ if (stagedStatus) {
1513
+ stagedStatus.committed = true;
1514
+ statusSegments.set(name, stagedStatus.text);
1515
+ }
1516
+ for (const w of pendingWidgets) {
1517
+ w.committed = true;
1518
+ widgets.set(widgetKey(name, w.def.id), { owner: name, ...w.def });
1519
+ }
1520
+ for (const message of pendingNotices) {
1521
+ notifications.push({ owner: name, message });
1522
+ if (notifications.length > EXT_NOTICE_CAP) {
1523
+ notifications.splice(0, notifications.length - EXT_NOTICE_CAP);
1524
+ }
1525
+ }
1526
+ if (stagedStatus || pendingWidgets.length > 0 || pendingNotices.length > 0)
1527
+ emitUI();
1528
+ for (const e of pendingBefore) {
1529
+ e.committedUnregister = registerBeforeToolCall(e.handler, name);
1530
+ }
1531
+ for (const e of pendingAfter) {
1532
+ e.committedUnregister = registerAfterToolCall(e.handler, name);
1533
+ }
1534
+ // Provider-hook commit (same atomic round): runtime-global like the
1535
+ // interceptors above, cannot fail — a tool/command failure skips this
1536
+ // entirely via continue, so a failed activation never leaves a provider
1537
+ // hook behind either.
1538
+ for (const e of pendingProviderContext) {
1539
+ e.committedUnregister = registerContextTransform(e.handler, name);
1540
+ }
1541
+ for (const e of pendingProviderPre) {
1542
+ e.committedUnregister = registerBeforeRequest(e.handler, name);
1543
+ }
1544
+ for (const e of pendingProviderPost) {
1545
+ e.committedUnregister = registerAfterResponse(e.handler, name);
1546
+ }
1547
+ // Compaction-hook commit (same atomic round): runtime-global like the
1548
+ // provider hooks above, cannot fail — a tool/command failure skips this
1549
+ // entirely via continue, so a failed activation never leaves a
1550
+ // compaction hook behind either.
1551
+ for (const e of pendingCompact) {
1552
+ e.committedUnregister = registerBeforeCompact(e.handler, name);
1553
+ }
1554
+ // Switch-gate commit (same atomic round): runtime-local, cannot fail —
1555
+ // a tool/command failure above skips this entirely via continue, so a
1556
+ // failed activation never leaves a veto behind either.
1557
+ for (const e of pendingSwitch) {
1558
+ e.committedUnregister = addBeforeSwitchHandler(entryPath, e.handler);
1559
+ }
1560
+ for (const p of pendingHandlers) {
1561
+ let list = handlers.get(p.event);
1562
+ if (!list) {
1563
+ list = [];
1564
+ handlers.set(p.event, list);
1565
+ }
1566
+ list.push({ extensionPath: entryPath, handler: p.handler });
1567
+ }
1568
+ loaded.push({ path: entryPath, name });
1569
+ }
1570
+ return runtime;
1571
+ }