@intentic/extension-api 1.325.0 → 1.327.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/src/runtime.ts ADDED
@@ -0,0 +1,535 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { createBackoff, sleep } from "@intentic/base/async";
5
+ import { errorMessage, undefinedIfMissing } from "@intentic/base/errors";
6
+ import { queueOnFile, writeFileAtomic } from "@intentic/base/fs";
7
+ import { sandboxRouteAllowed } from "@intentic/extension-manifest/permissions";
8
+ import type * as SandboxContract from "@intentic/sandbox-contract";
9
+ import { readDocument } from "@intentic/sandbox-contract/documents";
10
+ import {
11
+ EXTENSION_EVENT_KINDS,
12
+ EXTENSION_EVENTS_HEARTBEAT_MS,
13
+ EXTENSION_PROCESS_ENV,
14
+ type ExtensionEventKind,
15
+ ExtensionEventSchema,
16
+ extensionEventsUrl,
17
+ ExtensionHealthSchema,
18
+ ExtensionOwnSettingsSchema,
19
+ ExtensionPermissionsSchema,
20
+ extensionSettingsUrl,
21
+ } from "@intentic/sandbox-contract/extension-protocol";
22
+ import { EXTENSION_TOKEN_HEADER } from "@intentic/sandbox-contract/headers";
23
+ import type * as OrpcClient from "@orpc/client";
24
+ import type * as OpenApiFetch from "@orpc/openapi-client/fetch";
25
+ import type { Disposable } from "./disposable.js";
26
+ import type {
27
+ BackendRouteHandler,
28
+ ExtensionHealth,
29
+ ExtensionProcessApi,
30
+ ExtensionServerApi,
31
+ ExtensionServerContext,
32
+ ExtensionServerModule,
33
+ ServerActivation,
34
+ ServerDocument,
35
+ ServerDocumentOptions,
36
+ StoredJson,
37
+ ToolCard,
38
+ ToolDefinition,
39
+ } from "./server.js";
40
+ import { extensionApiVersion } from "./version.js";
41
+
42
+ // The backend half's types, from here as well as from the package root: the root also carries the browser half, whose
43
+ // types name Vue's, which a gateway's program has no business loading.
44
+ export type {
45
+ BackendRouteHandler,
46
+ ExtensionHealth,
47
+ ExtensionHealthState,
48
+ ExtensionProcessApi,
49
+ ExtensionServerApi,
50
+ ExtensionServerContext,
51
+ ExtensionServerModule,
52
+ ExtensionSettingValues,
53
+ ServerActivation,
54
+ ServerDocument,
55
+ ServerDocumentOptions,
56
+ StoredJson,
57
+ ToolCallContext,
58
+ ToolCard,
59
+ ToolContent,
60
+ ToolDefinition,
61
+ ToolEffect,
62
+ ToolResult,
63
+ } from "./server.js";
64
+
65
+ // The backend api, built once for every place an extension's Node code runs: the daemon's backend host, which runs every
66
+ // `server` bundle in one process, and each declared process (`contributes.processes`), which connects itself from the
67
+ // environment the daemon started it with. One implementation, so a gateway and a server bundle reach the daemon, read
68
+ // their settings, hear about the workspace and keep their files the same way. Node only, and free of Vue, unlike the
69
+ // package's root: a gateway that imports this loads none of the browser half.
70
+
71
+ type DaemonApi = ExtensionServerApi["daemon"];
72
+ type Rpc = DaemonApi["rpc"];
73
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
74
+
75
+ // What one `api.tools.serve` registration answers with, per card.
76
+ export type ToolSource = (card: ToolCard | undefined) => readonly ToolDefinition[] | Promise<readonly ToolDefinition[]>;
77
+
78
+ export interface DaemonApiOptions {
79
+ // The daemon's loopback origin.
80
+ readonly url: string;
81
+ // The extension's minted token, which the daemon judges every request by.
82
+ readonly token: string;
83
+ // The extension's routing id, for the sentence a refusal says.
84
+ readonly id: string;
85
+ // The manifest's `permissions.daemon`: a typed call outside it is refused before anything is sent.
86
+ readonly permissions: readonly string[];
87
+ // Where requests go; the global `fetch` when absent, read at call time so a test can stand in for it.
88
+ readonly fetch?: Fetch | undefined;
89
+ }
90
+
91
+ // Loads a module synchronously, which Node 22+ and Bun both do for ESM; used only for what a process should not pay for
92
+ // until it is asked for.
93
+ const load = createRequire(import.meta.url);
94
+
95
+ // The typed client over the daemon's contract, gated by the extension's declared reach and parsing every answer by the
96
+ // procedure's own schema.
97
+ const typedClient = (options: DaemonApiOptions, send: Fetch): Rpc => {
98
+ // SAFETY: each name is the package the type beside it is taken from, loaded by its own export map.
99
+ const contract = load("@intentic/sandbox-contract") as typeof SandboxContract;
100
+ // SAFETY: as above.
101
+ const { createORPCClient } = load("@orpc/client") as typeof OrpcClient;
102
+ // SAFETY: as above.
103
+ const { OpenAPILink } = load("@orpc/openapi-client/fetch") as typeof OpenApiFetch;
104
+ return createORPCClient<Rpc>(
105
+ new OpenAPILink(contract.sandboxContract, {
106
+ url: options.url,
107
+ headers: { [EXTENSION_TOKEN_HEADER]: options.token },
108
+ fetch: (request, init) => send(request, init),
109
+ interceptors: [
110
+ // A procedure this build's contract does not declare is refused too: reaching one means a hand-built path.
111
+ ({ path, input, next }) => {
112
+ const request = contract.sandboxRequestFor(path, input);
113
+ if (request === undefined) {
114
+ throw new Error(`extension "${options.id}" called daemon procedure ${path.join(".")}, which this build's contract does not declare`);
115
+ }
116
+ if (!sandboxRouteAllowed(options.permissions, request.method, request.path)) {
117
+ throw new Error(
118
+ `extension "${options.id}" called undeclared daemon route ${request.method} ${request.path}: declare it in permissions.daemon in the manifest`,
119
+ );
120
+ }
121
+ return next();
122
+ },
123
+ async ({ path, next }) => {
124
+ const answer = await next();
125
+ const schema = contract.sandboxAnswerSchema(path);
126
+ return schema === undefined ? answer : schema.parse(answer);
127
+ },
128
+ ],
129
+ }),
130
+ );
131
+ };
132
+
133
+ // One extension's `api.daemon`, every door presenting its minted token, which the daemon judges each request by. The
134
+ // typed client is also judged here, before anything is sent: on the method and path a call resolves to, against the same
135
+ // `permissions.daemon` list, so a refusal names the missing declaration instead of arriving as the daemon's 403. It is
136
+ // built on first use: the contract it is built from holds every schema the daemon has, tens of megabytes a gateway that
137
+ // never makes a typed call should not pay for at start.
138
+ export const createDaemonApi = (options: DaemonApiOptions): DaemonApi => {
139
+ const send: Fetch = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
140
+ const granted = (init?: RequestInit): Headers => {
141
+ const headers = new Headers(init?.headers);
142
+ headers.set(EXTENSION_TOKEN_HEADER, options.token);
143
+ return headers;
144
+ };
145
+ let client: Rpc | undefined;
146
+ return {
147
+ get rpc(): Rpc {
148
+ client ??= typedClient(options, send);
149
+ return client;
150
+ },
151
+ request: (path, init) => send(`${options.url}${path}`, { ...init, headers: granted(init) }),
152
+ json: async <T>(path: string, init?: RequestInit): Promise<T> => {
153
+ const headers = granted(init);
154
+ if (init?.body !== undefined && !headers.has("content-type")) {
155
+ headers.set("content-type", "application/json");
156
+ }
157
+ const response = await send(`${options.url}${path}`, { ...init, headers });
158
+ if (!response.ok) {
159
+ throw new Error(`daemon answered ${response.status} for ${init?.method ?? "GET"} ${path}: ${await response.text()}`);
160
+ }
161
+ // SAFETY: the caller names the shape the route answers; `api.daemon.rpc` is the door that checks it.
162
+ return (await response.json()) as T;
163
+ },
164
+ };
165
+ };
166
+
167
+ type Listener = (values: readonly string[]) => void;
168
+
169
+ // How long a connection may go without a frame before it is taken for dead: three missed heartbeats.
170
+ const SILENCE_MS = EXTENSION_EVENTS_HEARTBEAT_MS * 3;
171
+
172
+ // Every complete line in `buffer`, and what is left after the last one.
173
+ const splitLines = (buffer: string) => {
174
+ const parts = buffer.split("\n");
175
+ const rest = parts.pop() ?? "";
176
+ return { lines: parts.map((line) => line.trim()).filter((line) => line !== ""), rest };
177
+ };
178
+
179
+ // The values a frame carries for its listeners, or undefined for a line this version cannot read (not JSON, or a kind a
180
+ // newer daemon sends), which is skipped rather than fatal.
181
+ const frameOf = (line: string): { readonly kind: ExtensionEventKind; readonly values: readonly string[] } | undefined => {
182
+ let raw: unknown;
183
+ try {
184
+ raw = JSON.parse(line);
185
+ } catch {
186
+ // allow(silent-catch): a line that is not JSON is a frame this version cannot read, skipped like an unknown kind
187
+ return undefined;
188
+ }
189
+ const parsed = ExtensionEventSchema.safeParse(raw);
190
+ if (!parsed.success) {
191
+ return undefined;
192
+ }
193
+ const event = parsed.data;
194
+ switch (event.kind) {
195
+ case "heartbeat":
196
+ return undefined;
197
+ case "files":
198
+ return { kind: event.kind, values: event.paths };
199
+ case "settings":
200
+ return { kind: event.kind, values: event.keys };
201
+ default:
202
+ return { kind: event.kind, values: event.repos };
203
+ }
204
+ };
205
+
206
+ // The extension's event stream: opened by the first listener, closed with the last, reconnected with backoff while
207
+ // anything listens. A drop means frames were missed, so a reconnect tells every listener "anything may have changed" with
208
+ // an empty list, which is what a reader of an empty batch already does.
209
+ const createEventHub = (open: (signal: AbortSignal) => Promise<Response>, log: (message: string) => void) => {
210
+ const listeners = new Map<ExtensionEventKind, Set<Listener>>(EXTENSION_EVENT_KINDS.map((kind) => [kind, new Set<Listener>()]));
211
+ let running: AbortController | undefined;
212
+ const count = (): number => [...listeners.values()].reduce((sum, set) => sum + set.size, 0);
213
+
214
+ const deliver = (kind: ExtensionEventKind, values: readonly string[]): void => {
215
+ for (const listener of listeners.get(kind) ?? []) {
216
+ try {
217
+ listener(values);
218
+ } catch (error) {
219
+ // A throwing listener is its own bug; the stream and every other listener carry on.
220
+ log(`a ${kind} listener threw: ${errorMessage(error)}`);
221
+ }
222
+ }
223
+ };
224
+
225
+ // One connection, read to its end; resolves when the daemon ends it or it falls silent.
226
+ const connect = async (outer: AbortSignal): Promise<void> => {
227
+ const connection = new AbortController();
228
+ const abort = (): void => connection.abort();
229
+ outer.addEventListener("abort", abort, { once: true });
230
+ let silence = setTimeout(abort, SILENCE_MS);
231
+ try {
232
+ const response = await open(connection.signal);
233
+ if (!response.ok || response.body === null) {
234
+ throw new Error(`the daemon answered ${response.status}`);
235
+ }
236
+ const reader = response.body.getReader();
237
+ const decoder = new TextDecoder();
238
+ let buffer = "";
239
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
240
+ clearTimeout(silence);
241
+ silence = setTimeout(abort, SILENCE_MS);
242
+ const split = splitLines(buffer + decoder.decode(chunk.value, { stream: true }));
243
+ buffer = split.rest;
244
+ for (const line of split.lines) {
245
+ const frame = frameOf(line);
246
+ if (frame !== undefined) {
247
+ deliver(frame.kind, frame.values);
248
+ }
249
+ }
250
+ }
251
+ } finally {
252
+ clearTimeout(silence);
253
+ outer.removeEventListener("abort", abort);
254
+ }
255
+ };
256
+
257
+ // Every connection after the first follows a gap, which a listener hears about as an empty batch.
258
+ const run = async (signal: AbortSignal): Promise<void> => {
259
+ let connections = 0;
260
+ const backoff = createBackoff({ floorMs: 1_000, capMs: 30_000, stableMs: SILENCE_MS });
261
+ while (!signal.aborted) {
262
+ if (connections > 0) {
263
+ for (const kind of EXTENSION_EVENT_KINDS) {
264
+ deliver(kind, []);
265
+ }
266
+ }
267
+ connections += 1;
268
+ const opened = Date.now();
269
+ try {
270
+ await connect(signal);
271
+ } catch (error) {
272
+ if (!signal.aborted) {
273
+ log(`the event stream dropped (${errorMessage(error)}); reconnecting`);
274
+ }
275
+ }
276
+ if (signal.aborted) {
277
+ return;
278
+ }
279
+ await sleep(backoff.next(Date.now() - opened), { signal, unref: true });
280
+ }
281
+ };
282
+
283
+ const subscribe = (kind: ExtensionEventKind, listener: Listener): Disposable => {
284
+ listeners.get(kind)?.add(listener);
285
+ if (running === undefined) {
286
+ running = new AbortController();
287
+ void run(running.signal);
288
+ }
289
+ return {
290
+ dispose: () => {
291
+ listeners.get(kind)?.delete(listener);
292
+ if (count() === 0) {
293
+ running?.abort();
294
+ running = undefined;
295
+ }
296
+ },
297
+ };
298
+ };
299
+
300
+ return {
301
+ subscribe,
302
+ close: (): void => {
303
+ for (const set of listeners.values()) {
304
+ set.clear();
305
+ }
306
+ running?.abort();
307
+ running = undefined;
308
+ },
309
+ };
310
+ };
311
+
312
+ // A JSON file under the extension's own state directory, read through the contract's one document reading.
313
+ const openDocument = <T>(stateDir: string, path: string, options: ServerDocumentOptions<T>): ServerDocument<T> => {
314
+ const file = resolve(stateDir, path);
315
+ const inside = relative(stateDir, file);
316
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
317
+ throw new Error(`document "${path}" must name a file inside the extension's state directory`);
318
+ }
319
+ const evolution = { history: options.history ?? [], granularity: options.granularity ?? "object" } as const;
320
+ const readAs = async () => {
321
+ const text = await readFile(file, "utf8").catch(undefinedIfMissing);
322
+ // SAFETY: readDocument hands its parse what JSON.parse produced and the conversions rewrote, which is JSON.
323
+ return text === undefined ? undefined : readDocument<T>(text, evolution, { kind: "whole", parse: (raw) => options.parse(raw as StoredJson) });
324
+ };
325
+ return {
326
+ read: async () => (await readAs())?.value ?? options.fallback(),
327
+ update: (change) =>
328
+ queueOnFile(file, async () => {
329
+ const stored = await readAs();
330
+ if (stored !== undefined && stored.value === undefined) {
331
+ throw new Error(`${path} holds what this version of the extension cannot read; it is left as it is`, { cause: stored.problems[0] });
332
+ }
333
+ const before = stored?.value ?? options.fallback();
334
+ const after = change(before);
335
+ if (after === before) {
336
+ return before;
337
+ }
338
+ const written = stored === undefined ? after : stored.carry(after);
339
+ await writeFileAtomic(file, `${JSON.stringify(written, undefined, 2)}\n`);
340
+ return after;
341
+ }),
342
+ };
343
+ };
344
+
345
+ export interface ExtensionRuntimeOptions {
346
+ // The extension's routing id: its /x/<id> namespace, the install id.
347
+ readonly id: string;
348
+ readonly daemonUrl: string;
349
+ readonly token: string;
350
+ // The manifest's `permissions.daemon`.
351
+ readonly permissions: readonly string[];
352
+ readonly workspaceRoot: string;
353
+ readonly extensionDir: string;
354
+ readonly stateDir: string;
355
+ readonly cacheDir: string;
356
+ // Where a log line goes; stdout with the id as a prefix when absent, which is what reaches the daemon's log.
357
+ readonly log?: ((message: string) => void) | undefined;
358
+ readonly fetch?: Fetch | undefined;
359
+ }
360
+
361
+ export interface ExtensionRuntime {
362
+ readonly api: ExtensionProcessApi;
363
+ // Ends what the runtime holds open on the extension's behalf (the event stream). The host calls it as it lets go of
364
+ // an activation; a process may leave it to its own exit.
365
+ close(): void;
366
+ }
367
+
368
+ export const createExtensionRuntime = (options: ExtensionRuntimeOptions): ExtensionRuntime => {
369
+ const log = options.log ?? ((message: string) => void process.stdout.write(`[${options.id}] ${message}\n`));
370
+ const daemon = createDaemonApi({ url: options.daemonUrl, token: options.token, id: options.id, permissions: options.permissions, fetch: options.fetch });
371
+ const events = createEventHub((signal) => daemon.request(extensionEventsUrl(), { signal }), log);
372
+ return {
373
+ api: {
374
+ apiVersion: extensionApiVersion,
375
+ workspaceRoot: options.workspaceRoot,
376
+ extensionDir: options.extensionDir,
377
+ stateDir: options.stateDir,
378
+ cacheDir: options.cacheDir,
379
+ log,
380
+ daemon,
381
+ document: (path, documentOptions) => openDocument(options.stateDir, path, documentOptions),
382
+ settings: {
383
+ get: async () => ExtensionOwnSettingsSchema.parse(await (await daemon.request(extensionSettingsUrl())).json()).settings,
384
+ onDidChange: (listener) => events.subscribe("settings", listener),
385
+ },
386
+ workspace: {
387
+ onDidChangeFiles: (listener) => events.subscribe("files", listener),
388
+ onDidChangeRefs: (listener) => events.subscribe("refs", listener),
389
+ onDidChangeRepos: (listener) => events.subscribe("repos", listener),
390
+ },
391
+ },
392
+ close: events.close,
393
+ };
394
+ };
395
+
396
+ // The backend api a `server` bundle is handed: the runtime plus the two slots only the host serves, its route namespace
397
+ // and its tools. A second `mount` or `serve` replaces the first, per the api's contract.
398
+ export interface ServerApiSlots {
399
+ readonly api: ExtensionServerApi;
400
+ readonly handler: () => BackendRouteHandler | undefined;
401
+ readonly tools: () => ToolSource | undefined;
402
+ close(): void;
403
+ }
404
+
405
+ export const createServerApi = (options: ExtensionRuntimeOptions): ServerApiSlots => {
406
+ const runtime = createExtensionRuntime(options);
407
+ let mounted: BackendRouteHandler | undefined;
408
+ let tools: ToolSource | undefined;
409
+ return {
410
+ api: {
411
+ ...runtime.api,
412
+ routes: {
413
+ mount: (handler) => {
414
+ mounted = handler;
415
+ },
416
+ },
417
+ tools: {
418
+ serve: (source) => {
419
+ tools = source;
420
+ },
421
+ },
422
+ },
423
+ handler: () => mounted,
424
+ tools: () => tools,
425
+ close: runtime.close,
426
+ };
427
+ };
428
+
429
+ // A server bundle's exports: `activateServer` as a default export's member or a named export, matching the web loader's
430
+ // tolerance for UI bundles.
431
+ export type ServerBundleExports = Partial<ExtensionServerModule> & { readonly default?: ExtensionServerModule };
432
+
433
+ // Resolves activateServer and activates it. What it hands back is kept whole; nothing handed back reads as an activation
434
+ // with neither half.
435
+ export const activateServerModule = async (imported: ServerBundleExports, api: ExtensionServerApi, context: ExtensionServerContext): Promise<ServerActivation> => {
436
+ const resolved = imported.default ?? imported;
437
+ if (resolved.activateServer === undefined) {
438
+ throw new Error("the server bundle exports no activateServer(api, context)");
439
+ }
440
+ return (await resolved.activateServer(api, context)) ?? {};
441
+ };
442
+
443
+ // How long one extension's `deactivate` and `health` may take before the host goes on without them.
444
+ export const DEACTIVATE_DEADLINE_MS = 2_500;
445
+ export const HEALTH_DEADLINE_MS = 2_000;
446
+
447
+ // `work`'s answer, or `late` once `ms` passes, whichever comes first; the timer never holds the process open.
448
+ const within = async <T, L>(work: () => T | Promise<T>, ms: number, late: L): Promise<T | L> => {
449
+ let timer: NodeJS.Timeout | undefined;
450
+ const lateness = new Promise<L>((settle) => {
451
+ timer = setTimeout(() => settle(late), ms);
452
+ timer.unref();
453
+ });
454
+ try {
455
+ return await Promise.race([Promise.resolve().then(work), lateness]);
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ };
460
+
461
+ // Runs an activation's `deactivate` under the deadline. A throw or a timeout is said, never raised: the code is being
462
+ // replaced either way, and the caller's next step must not wait on it.
463
+ export const deactivateWithin = async (activation: ServerActivation, log: (message: string) => void): Promise<void> => {
464
+ const deactivate = activation.deactivate;
465
+ if (deactivate === undefined) {
466
+ return;
467
+ }
468
+ try {
469
+ const outcome = await within(async () => {
470
+ await deactivate();
471
+ return "done" as const;
472
+ }, DEACTIVATE_DEADLINE_MS, "late" as const);
473
+ if (outcome === "late") {
474
+ log(`deactivate did not finish within ${DEACTIVATE_DEADLINE_MS} ms; its code is replaced anyway`);
475
+ }
476
+ } catch (error) {
477
+ log(`deactivate threw: ${errorMessage(error)}`);
478
+ }
479
+ };
480
+
481
+ // An activation's own account of itself, read under the deadline and held to the closed vocabulary: no `health` reads as
482
+ // `ok`, and one that throws, does not answer in time or answers outside the vocabulary reads as `degraded`, saying so.
483
+ export const healthWithin = async (activation: ServerActivation): Promise<ExtensionHealth> => {
484
+ const health = activation.health;
485
+ if (health === undefined) {
486
+ return { state: "ok" };
487
+ }
488
+ const late: ExtensionHealth = { state: "degraded", detail: `its health check did not answer within ${HEALTH_DEADLINE_MS} ms` };
489
+ try {
490
+ const answer = await within(health, HEALTH_DEADLINE_MS, late);
491
+ const parsed = ExtensionHealthSchema.safeParse(answer);
492
+ if (!parsed.success) {
493
+ return { state: "degraded", detail: "its health check answered something other than ok, starting, degraded or failed" };
494
+ }
495
+ return parsed.data.detail === undefined ? { state: parsed.data.state } : { state: parsed.data.state, detail: parsed.data.detail };
496
+ } catch (error) {
497
+ return { state: "degraded", detail: `its health check threw: ${errorMessage(error)}` };
498
+ }
499
+ };
500
+
501
+ // Reads the variable or says which one is missing: only the daemon starts an extension's process, and a process started
502
+ // any other way has no token to reach it with.
503
+ const required = (env: NodeJS.ProcessEnv, name: string): string => {
504
+ const value = env[name];
505
+ if (value === undefined || value === "") {
506
+ throw new Error(`missing ${name}: an extension's process is started by the sandbox daemon, which sets it`);
507
+ }
508
+ return value;
509
+ };
510
+
511
+ const permissionsOf = (raw: string | undefined): readonly string[] => {
512
+ if (raw === undefined || raw === "") {
513
+ return [];
514
+ }
515
+ const parsed = ExtensionPermissionsSchema.safeParse(JSON.parse(raw));
516
+ if (!parsed.success) {
517
+ throw new Error(`${EXTENSION_PROCESS_ENV.permissions} is not a JSON array of permissions`);
518
+ }
519
+ return parsed.data;
520
+ };
521
+
522
+ // A declared process's api, from the environment the daemon started it with (EXTENSION_PROCESS_ENV). Throws, naming the
523
+ // variable, when one it needs is missing.
524
+ export const connectExtensionProcess = (env: NodeJS.ProcessEnv = process.env, options: { readonly log?: (message: string) => void } = {}): ExtensionRuntime =>
525
+ createExtensionRuntime({
526
+ id: required(env, EXTENSION_PROCESS_ENV.id),
527
+ daemonUrl: required(env, EXTENSION_PROCESS_ENV.daemon),
528
+ token: required(env, EXTENSION_PROCESS_ENV.token),
529
+ permissions: permissionsOf(env[EXTENSION_PROCESS_ENV.permissions]),
530
+ workspaceRoot: required(env, EXTENSION_PROCESS_ENV.workspace),
531
+ extensionDir: env[EXTENSION_PROCESS_ENV.dir] ?? process.cwd(),
532
+ stateDir: required(env, EXTENSION_PROCESS_ENV.state),
533
+ cacheDir: required(env, EXTENSION_PROCESS_ENV.cache),
534
+ log: options.log,
535
+ });
package/src/server.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { sandboxContract } from "@intentic/sandbox-contract";
2
+ import type { Conversion, Granularity } from "@intentic/sandbox-contract/documents";
2
3
  import type { ToolEffect } from "@intentic/sandbox-contract/peer-mcp-server";
3
4
  import type { ContractRouterClient } from "@orpc/contract";
5
+ import type { Disposable } from "./disposable.js";
4
6
 
5
7
  // The effect vocabulary the daemon's own tools and every device's tools declare, re-exported so a tool here says what it
6
8
  // does in the same words.
@@ -34,7 +36,7 @@ export interface ToolCallContext {
34
36
  // Aborted when the agent's client gives up on the call, or the host's deadline for it passes.
35
37
  readonly signal: AbortSignal;
36
38
  // The conversation the calling turn belongs to, when it has one.
37
- readonly conversationId?: string;
39
+ readonly conversationId?: string | undefined;
38
40
  }
39
41
 
40
42
  export interface ToolDefinition {
@@ -55,6 +57,57 @@ export interface ToolDefinition {
55
57
  // the host answers 404.
56
58
  export type BackendRouteHandler = (request: Request) => Promise<Response | undefined>;
57
59
 
60
+ // An extension's own setting values, keyed as its manifest's `contributes.settings` declares them.
61
+ export type ExtensionSettingValues = Readonly<Record<string, string | number | boolean>>;
62
+
63
+ // A file the extension keeps in its own state directory, read through the conversions its shape has had and written
64
+ // with what a newer version of the extension put in it kept in place: the evolution `sandboxDocument` gives the browser
65
+ // half, and the daemon's own stores have. A file that exists but this version cannot read is left alone: `update`
66
+ // rejects rather than writing the fallback over it.
67
+ export interface ServerDocument<T> {
68
+ // Today's value: the file converted and parsed; the fallback when it is absent or this version cannot read it.
69
+ read(): Promise<T>;
70
+ // Read-change-write, serialized against every other handle on the same file, written whole by a rename so a reader
71
+ // never sees half of it. Answers what is stored afterwards; returning `current` unchanged writes nothing.
72
+ update(change: (current: T) => T): Promise<T>;
73
+ }
74
+
75
+ // What a stored document holds before its parse: JSON, after the conversions its shape has had.
76
+ export type StoredJson = string | number | boolean | null | readonly StoredJson[] | { readonly [key: string]: StoredJson };
77
+
78
+ export interface ServerDocumentOptions<T> {
79
+ // Today's shape, or undefined for a file this version cannot read.
80
+ readonly parse: (raw: StoredJson) => T | undefined;
81
+ readonly fallback: () => T;
82
+ // Append-only: a conversion is never edited or removed once shipped, only followed by another.
83
+ readonly history?: readonly Conversion[];
84
+ // Where the conversions apply: the document, each entry of a top-level array, or each value of an object keyed by id.
85
+ readonly granularity?: Granularity;
86
+ }
87
+
88
+ // How a backend is doing, by its own account (`ServerActivation.health`), shown on its row in the Extensions list.
89
+ // `starting`: activated, still getting ready. `degraded`: serving, but something it relies on is not (a connection
90
+ // down, a download that failed), which `detail` says. `failed`: not serving at all.
91
+ export type ExtensionHealthState = "ok" | "starting" | "degraded" | "failed";
92
+
93
+ export interface ExtensionHealth {
94
+ readonly state: ExtensionHealthState;
95
+ // One sentence for the owner, required in spirit for anything but `ok`: a state with no stated reason is an opinion.
96
+ readonly detail?: string;
97
+ }
98
+
99
+ // What `activateServer` may hand back: the two things only the extension can do for itself.
100
+ export interface ServerActivation {
101
+ // Release what activation opened (listeners, timers, child processes, connections), before the host replaces this
102
+ // extension's code or stops. With one, a change that touches only this extension (a rebuild, an update, a toggle)
103
+ // reloads it alone and leaves every other backend running; without one, the whole host restarts. Bounded by the
104
+ // host's deadline, after which the code is replaced anyway.
105
+ readonly deactivate?: () => void | Promise<void>;
106
+ // Asked on the host's health sweep and answered within its deadline; one that throws or does not answer in time
107
+ // reads as `degraded`. Absent: the backend reads as `ok` while its activation stands.
108
+ readonly health?: () => ExtensionHealth | Promise<ExtensionHealth>;
109
+ }
110
+
58
111
  export interface ExtensionServerApi {
59
112
  // The host's @intentic/extension-api version, checked against `engines.intentic`.
60
113
  readonly apiVersion: string;
@@ -62,8 +115,36 @@ export interface ExtensionServerApi {
62
115
  readonly workspaceRoot: string;
63
116
  // This extension's own checkout (absolute), where its bundled assets sit.
64
117
  readonly extensionDir: string;
118
+ // This extension's own state directory (absolute), created before activation and keyed by `publisher.name`, so it
119
+ // survives an update, a re-install and dev mode alike. Never versioned, never backed up, not carried to another
120
+ // sandbox: resume marks, cached tokens, a listener's last port. Removing the extension deletes it.
121
+ readonly stateDir: string;
122
+ // This extension's own cache directory (absolute): what can be fetched or built again (a downloaded engine, an
123
+ // index). Created before activation, ignored by the workspace watcher, deleted with the extension.
124
+ readonly cacheDir: string;
65
125
  // A line in the daemon's log, attributed to this extension.
66
126
  readonly log: (message: string) => void;
127
+ // A JSON file of the extension's own under `stateDir` (`path` is relative to it), evolved by its conversions.
128
+ readonly document: <T>(path: string, options: ServerDocumentOptions<T>) => ServerDocument<T>;
129
+ // The extension's own settings (`contributes.settings`), secret values included, as the owner set them now. Reached
130
+ // without declaring anything in `permissions.daemon`: the daemon answers only about the extension asking.
131
+ readonly settings: {
132
+ get(): Promise<ExtensionSettingValues>;
133
+ // Fires with the keys whose value changed, however they changed: the Extensions tab, an agent, a hand edit.
134
+ onDidChange(listener: (keys: readonly string[]) => void): Disposable;
135
+ };
136
+ // Changes in the workspace as the daemon sees them, over one stream the host opens on the first listener and closes
137
+ // with the last. The browser half's `onDidChangeFiles` is narrowed to the extension's own `contributes.files`; this
138
+ // one is every change, since a backend reads the whole workspace anyway.
139
+ readonly workspace: {
140
+ // Workspace-relative paths written, created or deleted, in batches; an empty batch is a write the watcher
141
+ // could not name (a build under a pruned directory), which a reader treats as "anything may have changed".
142
+ onDidChangeFiles(listener: (paths: readonly string[]) => void): Disposable;
143
+ // Repositories whose refs moved: a commit, a checkout, a branch or tag, a rebase.
144
+ onDidChangeRefs(listener: (repos: readonly string[]) => void): Disposable;
145
+ // The repository set itself moved (a clone, a scaffold, a delete); answers the whole set.
146
+ onDidChangeRepos(listener: (repos: readonly string[]) => void): Disposable;
147
+ };
67
148
  readonly routes: {
68
149
  // Serves this extension's route namespace; the daemon proxies /x/<id>/* here through its ordinary auth. A
69
150
  // second mount replaces the first.
@@ -93,8 +174,14 @@ export interface ExtensionServerContext {
93
174
  readonly extensionId: string;
94
175
  }
95
176
 
96
- // Shape of the manifest `server` bundle's default (or named) export; `activateServer` runs once per backend-host start.
97
- // No deactivate: retirement is the host process ending.
177
+ // What a declared process (`contributes.processes`) gets, from `connectExtensionProcess` in
178
+ // `@intentic/extension-api/runtime`: the backend's api without the two halves only the backend host can serve. A
179
+ // process answers on its own port, so it mounts no routes and serves tools there (`contributes.tools.process`).
180
+ export type ExtensionProcessApi = Omit<ExtensionServerApi, "routes" | "tools">;
181
+
182
+ // Shape of the manifest `server` bundle's default (or named) export; `activateServer` runs once per activation, which
183
+ // is once per backend-host start unless the extension returns a `deactivate`, after which a change to it alone
184
+ // activates it again in place.
98
185
  export interface ExtensionServerModule {
99
- activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | Promise<void>;
186
+ activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | ServerActivation | Promise<void | ServerActivation>;
100
187
  }