atom-agent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/palette.js +1 -1
- package/dist/ui/status-bar.js +80 -5
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
|
@@ -0,0 +1,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
|
+
}
|