@intentic/extension-api 1.176.3 → 1.209.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +63 -13
  2. package/dist/api.d.ts +48 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/diff.d.ts +20 -0
  5. package/dist/diff.d.ts.map +1 -0
  6. package/dist/diff.js +2 -0
  7. package/dist/diff.js.map +1 -0
  8. package/dist/engines.d.ts +2 -0
  9. package/dist/engines.d.ts.map +1 -0
  10. package/dist/engines.js +26 -0
  11. package/dist/engines.js.map +1 -0
  12. package/dist/facts.d.ts +1 -0
  13. package/dist/facts.d.ts.map +1 -1
  14. package/dist/host.d.ts +6 -0
  15. package/dist/host.d.ts.map +1 -0
  16. package/dist/host.js +15 -0
  17. package/dist/host.js.map +1 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/server.d.ts +21 -0
  23. package/dist/server.d.ts.map +1 -0
  24. package/dist/server.js +2 -0
  25. package/dist/server.js.map +1 -0
  26. package/dist/version.d.ts +1 -1
  27. package/dist/version.d.ts.map +1 -1
  28. package/dist/version.js +1 -1
  29. package/dist/version.js.map +1 -1
  30. package/package.json +5 -4
  31. package/src/api.ts +270 -5
  32. package/src/diff.ts +59 -0
  33. package/src/engines.ts +31 -0
  34. package/src/facts.ts +6 -0
  35. package/src/host.ts +29 -0
  36. package/src/index.ts +4 -2
  37. package/src/server.ts +65 -0
  38. package/src/surface.json +280 -0
  39. package/src/version.ts +33 -1
  40. package/dist/manifest.d.ts +0 -260
  41. package/dist/manifest.d.ts.map +0 -1
  42. package/dist/manifest.js +0 -136
  43. package/dist/manifest.js.map +0 -1
  44. package/dist/permissions.d.ts +0 -2
  45. package/dist/permissions.d.ts.map +0 -1
  46. package/dist/permissions.js +0 -21
  47. package/dist/permissions.js.map +0 -1
  48. package/src/manifest.ts +0 -275
  49. package/src/permissions.ts +0 -36
package/src/api.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import type { sandboxContract } from "@intentic/sandbox-contract";
2
+ import type { ContractRouterClient } from "@orpc/contract";
1
3
  import type { Component } from "vue";
4
+ import type { DiffPayload } from "./diff.js";
2
5
  import type { CapabilityFacts, RepoFacts } from "./facts.js";
3
6
 
4
7
  /* The host API an extension programs against. There is no ambient global: the implementation arrives as the
@@ -39,16 +42,44 @@ export interface ViewBadge {
39
42
  // be sent" is the whole message, and a number beside it would be read in the unit `count` established —
40
43
  // so the amount goes in the tooltip and the glyph carries the kind. A badge with neither renders nothing.
41
44
  readonly mark?: string | undefined;
42
- // `info` is the resting tone every core count uses (unread agents, uncommitted changes, live terminals);
43
- // `warning` marks a risk the user is carrying (an exposed port); `danger` means something is BROKEN.
44
- // Reach for danger sparingly — its whole value is that it is rare enough to still mean something.
45
- readonly tone?: "info" | "warning" | "danger" | undefined;
45
+ // THE FIRST QUESTION A BADGE ANSWERS IS "DO I OWE THIS ANYTHING?", and until `neutral` existed there was no
46
+ // way to say no. A count that means "three things are alive in here" (open browsers, running services) was
47
+ // drawn exactly like one that means "three things are waiting on you" — same pill, same tint, same digit —
48
+ // so a reader who chased one and found an inventory learned that badges do not repay being chased. That is
49
+ // the failure this vocabulary exists to prevent, arriving through the door it left open.
50
+ //
51
+ // `neutral` is an INVENTORY: true most of the day, nothing owed, quiet ink. `info` is the resting tone for
52
+ // work the user can act on (unread agents, uncommitted changes); `warning` marks a risk they are carrying
53
+ // (an exposed port); `danger` means something is BROKEN — reach for it sparingly, its whole value is that it
54
+ // is rare enough to still mean something. Absent still means `info`, so a badge that says nothing about its
55
+ // tone is still assumed to be asking for something.
56
+ readonly tone?: "neutral" | "info" | "warning" | "danger" | undefined;
46
57
  // Say what happened and how much, not just the number the user can already see. The host renders it
47
58
  // AFTER the view's own name — "Agents · 3 need you" on the rail, the chip's text in the mobile menu — so
48
59
  // phrase it as the continuation of a label, not as a standalone sentence that repeats the view.
49
60
  readonly tooltip?: string | undefined;
50
61
  }
51
62
 
63
+ /* ONE CACHED READ, described rather than performed — the currency both `ViewRegistration.warm` and
64
+ * `api.sandbox.fetch` deal in.
65
+ *
66
+ * It is deliberately the shape a vue-query `useQuery` already takes, because that is the point: the entry a
67
+ * view warms, the entry its badge fills from a timer and the entry the view's own `useQuery` observes are ONE
68
+ * entry, and they are one entry because all three name it the same way. Two of the three used to be separate
69
+ * reads of the same route in this app's own extensions — the tile fetched the report every ten minutes and kept
70
+ * it privately, and the view it badged for started from nothing every time it was opened.
71
+ *
72
+ * The caching terms are optional and yours: `staleTime` is how long an answer stays believable without a
73
+ * refetch (Infinity for something only an invalidation can make wrong), `gcTime` how long it survives with
74
+ * nothing observing it. Both default to the host's. */
75
+ export interface HostQuery<T = unknown> {
76
+ // MUST be scoped by api.sandbox.key(...), so nothing bleeds across a sandbox switch.
77
+ readonly queryKey: readonly unknown[];
78
+ readonly queryFn: () => Promise<T>;
79
+ readonly staleTime?: number | undefined;
80
+ readonly gcTime?: number | undefined;
81
+ }
82
+
52
83
  // A view's runtime registration — for third-party extensions, `id`, `label` and `surface` must match a
53
84
  // `contributes.views` entry in the approved manifest or the host refuses the registration.
54
85
  export interface ViewRegistration {
@@ -75,6 +106,25 @@ export interface ViewRegistration {
75
106
  // The source has to stay alive while the view is UNMOUNTED (a badge you only see once you have already
76
107
  // navigated to the view is pointless), so it belongs in module state owned by activate(), not in the view.
77
108
  readonly badge?: ((activation: Activation) => ViewBadge | undefined) | undefined;
109
+ /* WHAT THIS VIEW WOULD LIKE IN HAND BEFORE ANYONE OPENS IT — read ahead by the host's background loader in
110
+ * the gaps between what the user is already doing, so the tile opens with content instead of a skeleton.
111
+ *
112
+ * A rail tile is at the far end of the loader's priority order (the user is not there, they might GO
113
+ * there), so this is a wish and never a guarantee: on a workspace busy enough that nothing is spare, none
114
+ * of it is read and the view costs exactly what it cost before. Nothing here is user-visible, nothing
115
+ * retries, and a failed warm is simply a warm that did not happen.
116
+ *
117
+ * DECLARE THE QUERY, not a function that fetches it — that is the whole reason this takes a HostQuery. The
118
+ * host's own wishes used to carry a cache key and a separate "how to read it" callback, and for most of
119
+ * them the callback fetched the data and returned it to its caller without ever filing it under that key.
120
+ * The wish could then never be satisfied, and since the loader always takes the first unsatisfied wish, one
121
+ * of them was enough to park it for the whole session. Handing over the query makes the two halves the same
122
+ * object. Use the SAME key your view's `useQuery` reads (api.sandbox.key(...)), or you are warming an entry
123
+ * nothing will look in.
124
+ *
125
+ * Called on every beat of the loader, so it must be cheap and pure — derive from state you already keep,
126
+ * never fetch. A throwing warm contributes nothing that beat. */
127
+ readonly warm?: (() => readonly HostQuery[]) | undefined;
78
128
  // A fallback view's activations are dropped for repos already claimed by a non-fallback one.
79
129
  readonly fallback?: true | undefined;
80
130
  // An AUXILIARY view adds a surface BESIDE whatever else serves the repo instead of replacing it — a test
@@ -105,6 +155,15 @@ export interface DocumentOffer {
105
155
  readonly tooltip: string;
106
156
  // The tab's label. Short: the strip already shows the directory's own name beside it.
107
157
  readonly title: string;
158
+ /* Whether the row keeps this icon when the pointer is elsewhere. A tree row's icons are revealed on hover,
159
+ * because a permanent column of them is what stops the eye reading names — but that rule assumes an icon is
160
+ * an ACTION you already know you want. An offer that is EVIDENCE is the opposite case: "there is a page about
161
+ * this package" is a fact nobody can act on until they see it, and finding it by sweeping fifty-five rows with
162
+ * the mouse is not finding it. Such an offer sets this, and the row carries it dimmed until hover.
163
+ *
164
+ * Left off (the default) by an offer every directory of its kind gets — a repo's git history is always there,
165
+ * so a permanent glyph states nothing and costs the same attention. */
166
+ readonly evidence?: boolean;
108
167
  }
109
168
 
110
169
  /* A DOCUMENT PROVIDER — an extension's answer to "there is something to READ about this directory".
@@ -131,6 +190,38 @@ export interface DocumentProviderRegistration {
131
190
  readonly view: () => Promise<Component>;
132
191
  }
133
192
 
193
+ /* EVERYTHING THAT DECIDES WHO SERVES A TURN, as one value. The label is here because a view that shows a chosen
194
+ * model without showing the list would otherwise have to keep a catalog of its own — which is exactly the
195
+ * duplication `api.models` exists to end.
196
+ *
197
+ * The last two are optional because they are pins, and the unpinned state is the one most callers want: absent
198
+ * means "whatever the daemon resolves", which is what keeps a saved choice working after an account is
199
+ * disconnected or a harness gains a provider. A caller that only cares which model runs can ignore both and
200
+ * lose nothing. */
201
+ export interface PickedModel {
202
+ // An `AgentProvider` — `claude`, `codex`, a configured model endpoint's id, an installed ACP agent's id.
203
+ // Open on purpose: the set grows with what the sandbox has connected, and an extension only carries it.
204
+ readonly provider: string;
205
+ readonly model: string;
206
+ readonly label: string;
207
+ /* WHICH CONNECTED ACCOUNT of that provider runs the turn, by its daemon-minted id — absent ⇒ whichever comes
208
+ * first. It is on the pick rather than left to the daemon because the surfaces that start UNATTENDED runs are
209
+ * the ones that need it: nobody is watching at 6am, so a first account that has run out of headroom (or whose
210
+ * organization switched the plan off) is a run that errors every time until someone reads the row. */
211
+ readonly account?: string | undefined;
212
+ /* What the shell calls that account — the sign-in identity, which is the only part of it the owner
213
+ * recognises ("Claude" is what three unrenamed accounts are all called).
214
+ *
215
+ * Absent means the shell cannot name it, which covers BOTH a pin whose credential has been disconnected and
216
+ * an account list this sandbox has not been read for yet. Do not render the first from the second: they are
217
+ * the same absence, and a view that reads it as "this automation is broken" says so about every row while the
218
+ * daemon is merely still starting. Show the name when there is one, and nothing when there isn't. */
219
+ readonly accountLabel?: string | undefined;
220
+ // `native` or `claude-code` — the agentic loop, an axis of its own since codex/grok run the same subscription
221
+ // model ids under either. Absent ⇒ native, which for every other provider is the only answer there is.
222
+ readonly harness?: string | undefined;
223
+ }
224
+
134
225
  export type SettingValue = string | number | boolean;
135
226
 
136
227
  export interface ProcessStatus {
@@ -155,6 +246,17 @@ export interface IntenticApi {
155
246
  // renders one; the host draws the tree's affordance and owns the tab. See DocumentProviderRegistration.
156
247
  readonly documents: {
157
248
  register(provider: DocumentProviderRegistration): Disposable;
249
+ /* Open one of THIS extension's documents for a directory, as if its row icon had been clicked.
250
+ *
251
+ * The row is the ordinary way in, so this is for the directories that have no row: the workspace root,
252
+ * which the tree renders the contents of rather than a line for. Without it a command contributed
253
+ * alongside a document provider — "Show Git History" in the palette — has nothing it can actually open.
254
+ *
255
+ * `id` must be one of this extension's registered providers, and the provider must have an offer for
256
+ * `path` (the same `detect()` the tree asks); a provider that has nothing to say about the directory
257
+ * opens nothing rather than an empty tab. The title and glyph come from that offer, so the tab reads
258
+ * exactly as it would have from the row. */
259
+ open(id: string, path: string): void;
158
260
  };
159
261
  readonly commands: {
160
262
  // `command` must match a `contributes.commands` entry in the approved manifest.
@@ -168,11 +270,36 @@ export interface IntenticApi {
168
270
  onDidChange(listener: (key: string) => void): Disposable;
169
271
  };
170
272
  // The authenticated transport to the sandbox daemon's routes — auth is injected host-side; an extension
171
- // never sees tokens. Reach is scoped: request/json are gated by the manifest's `permissions.sandbox`
273
+ // never sees tokens. Reach is scoped: every door here is gated by the manifest's `permissions.sandbox`
172
274
  // allowlist, so a call to an undeclared method+path throws rather than reaching the whole daemon.
173
275
  readonly sandbox: {
276
+ /* THE DAEMON, TYPED — the same contract the daemon implements, so a call names a procedure instead of
277
+ * building a URL. `rpc.git.stashApply({ repo, ref, pop })` carries the declared input shape and answers
278
+ * the declared output shape, both checked at build time.
279
+ *
280
+ * This is the door to reach for. `request`/`json` below take a path string, which means every caller
281
+ * re-derives what this already knows: the method, the escaping, the query encoding, and the shape of the
282
+ * answer — the last of those as an unchecked assertion that keeps compiling long after the daemon's reply
283
+ * has changed underneath it. Thirteen extensions between them hand-wrote a hundred such calls and
284
+ * re-validated half the responses against the very schemas the contract had already declared.
285
+ *
286
+ * Gated identically, and on the same evidence: the host resolves the procedure to its method and concrete
287
+ * path and checks THAT against `permissions.sandbox`, so a manifest neither gains nor loses reach by an
288
+ * extension switching doors, and the usage record stays comparable across both. */
289
+ readonly rpc: ContractRouterClient<typeof sandboxContract>;
174
290
  request(path: string, init?: RequestInit): Promise<Response>;
175
291
  json<T>(path: string, init?: RequestInit): Promise<T>;
292
+ /* READ THROUGH THE HOST'S CACHE, from outside a component — the door for the module-level timers that
293
+ * badge a rail tile, which is where `useQuery` cannot reach.
294
+ *
295
+ * Concurrent callers of one key share a single request, and a caller inside `staleTime` is answered
296
+ * from cache with no round trip at all. Which is what makes this worth using over `json`: a badge that
297
+ * polls a route on its own timer and hands the answer only to itself makes the view it badges for pay
298
+ * for the same read again on open. Through here, the badge's poll IS the view's first paint.
299
+ *
300
+ * The route is gated exactly as `json` is — `queryFn` is your function, and whatever it calls carries
301
+ * its own manifest check. */
302
+ fetch<T>(query: HostQuery<T>): Promise<T>;
176
303
  // Whether the active sandbox is currently reachable — reactive when read inside a computed, so it
177
304
  // drives host-provided vue-query `enabled` options.
178
305
  reachable(): boolean;
@@ -183,11 +310,76 @@ export interface IntenticApi {
183
310
  // endpoints. Undefined until the sandbox has registered its address. Not needed for `request`/`json`
184
311
  // (those take a path and inject auth) — only when the raw origin must be shown to the user.
185
312
  origin(): string | undefined;
313
+ /* THE SIGNED-IN USER'S TRUST TIER on the active sandbox — `owner`, `maintainer`, `collaborator` or
314
+ * `viewer` — reactive when read inside a computed, like `reachable`. For AFFORDANCES ONLY: every route
315
+ * is independently floored by the daemon, so what this gates is whether an Approve button renders, never
316
+ * whether the call would succeed. A view that shows a viewer buttons the daemon will refuse teaches them
317
+ * that buttons lie; this is how a view says less instead.
318
+ *
319
+ * The first consumer is the drafts queue (approve/reject are maintainer-and-up), and it existed as a
320
+ * private composable before it was public API — which is the pattern this package's history warns about:
321
+ * a surface only its own app needs is a surface nobody else can build the same feature on. */
322
+ role(): "owner" | "maintainer" | "collaborator" | "viewer";
186
323
  };
187
324
  readonly workspace: {
188
325
  repos(): readonly RepoFacts[];
189
326
  capabilities(): readonly CapabilityFacts[];
190
327
  onDidChange(listener: () => void): Disposable;
328
+ /* A REF MOVED IN ONE OF THESE REPOS — a commit, a branch, a checkout, a rebase, an aborted merge.
329
+ *
330
+ * Separate from `contributes.files` because no file contribution could ever carry it: the daemon's
331
+ * watcher descent-ignores `.git`, so a changed ref produces no `workspaceChanged` path to match a prefix
332
+ * against. The daemon diffs the git dirs itself and pushes the repos that moved, exactly as it does for
333
+ * the repo SET (which the same watcher cannot see either, and for the same reason).
334
+ *
335
+ * This matters most for work the user did not do: an agent commits, rebases or lands out-of-band, with no
336
+ * HTTP mutation in this browser to hang an invalidate on. Without this a git surface is only ever as fresh
337
+ * as the last thing the user clicked. `repos` are root-relative ids ("root" is the workspace repo itself).
338
+ */
339
+ onDidChangeRefs(listener: (repos: readonly string[]) => void): Disposable;
340
+ /* OPEN A DIFF IN THE EDITOR AREA — the host's tab strip, beside the files the diff is about.
341
+ *
342
+ * The shell owns the strip, the viewer, the close orchestration and the edit-buffer bookkeeping; the
343
+ * extension owns only the question of what changed. Re-opening the same `key`+`scope`+`path` focuses the
344
+ * tab that is already open rather than stacking a second copy — see DiffPayload for how that identity is
345
+ * built. On mobile, where there is no strip, the host navigates to the diff instead.
346
+ */
347
+ openDiff(payload: DiffPayload): void;
348
+ /* FILL A DIFF OPENED WITH `pending` — the second half of opening a tab before its content exists.
349
+ *
350
+ * Refreshes, never opens: a tab the user has since closed or replaced takes nothing, and the content is
351
+ * simply dropped. That is what makes the pending open safe to use for a slow source — a reader who has
352
+ * moved on to another file is never yanked back to this one, and never has it appear under them.
353
+ */
354
+ fillDiff(payload: DiffPayload): void;
355
+ /* READING AND WRITING WORKSPACE FILES — the daemon's file routes, without the encoding.
356
+ *
357
+ * Extensions keep their durable state in the workspace rather than in settings: an acceptance run's
358
+ * reports, a documentation set's staging tree, the "what has the rail badge already shown" file each of
359
+ * them keeps. That is the right home — it survives a reload, it is shared across the owner's browsers,
360
+ * and the agent writing into it out-of-band is the whole point — but it left every extension spelling
361
+ * `sandbox.json(\`/workspace/file?path=${encodeURIComponent(path)}\`)` and then parsing the envelope out
362
+ * of the answer. Three extensions had five byte-identical copies of that one function.
363
+ *
364
+ * Gated exactly as `sandbox.request`/`sandbox.json` are: these go through the same permission check, so
365
+ * an extension still declares `GET /workspace/file` and `POST /workspace/upload` in its manifest and one
366
+ * that doesn't is still refused. This removes the encoding, not the grant. */
367
+ // The file's text, or undefined when it is not there. Absent is the ordinary FIRST state for most of what
368
+ // extensions keep — nothing has been acknowledged because nothing has been seen — so it is a value here,
369
+ // not a throw every caller would have to wrap. The daemon reports it the same way (a 200 that says the
370
+ // path holds nothing), so a poll over files that do not exist yet is silent rather than a page of failed
371
+ // requests in the owner's console.
372
+ file(path: string): Promise<string | undefined>;
373
+ /* The file parsed as a JSON object, or undefined when it is absent, truncated, or not an object at all.
374
+ *
375
+ * One tolerant reader rather than one per caller. These files are written by agents and editable by
376
+ * hand, so a half-written or hand-mangled one is a case that WILL happen, and "skip it" is the right
377
+ * answer everywhere: one bad file must never blank the surface that reads it. Arrays answer undefined
378
+ * too — every caller of this wants a record. */
379
+ readJson<T>(path: string): Promise<T | undefined>;
380
+ // Create or replace a workspace file. Throws on failure, unlike the reads: a write that silently did
381
+ // nothing would lose the thing the caller was told was saved.
382
+ write(path: string, body: string): Promise<void>;
191
383
  };
192
384
  // The extension's OWN declared background processes — names outside the manifest are refused.
193
385
  readonly processes: {
@@ -210,6 +402,79 @@ export interface IntenticApi {
210
402
  // Open (or focus) the tab for a stored runtime session id — the same path the History menu and the fleet
211
403
  // board take. A session the daemon no longer holds opens an empty tab rather than failing.
212
404
  openSession(sessionId: string): void;
405
+ /* AIM A NEW CHAT AT A WORKFLOW: the host opens a session exactly as "New agent" does, with the
406
+ * composer's workflow badge set to this design — so the next message the user types becomes that run's
407
+ * request instead of a turn on the chat.
408
+ *
409
+ * It hands over the START of the work rather than performing it, and that is the point. An extension
410
+ * with a Run button used to have two bad options: start the run itself behind its own dialog (a second
411
+ * way to begin agent work, with its own box that looks like nothing else in the product), or navigate
412
+ * to a page about the run. This is the third: the extension names the design, and the user starts it
413
+ * where they start everything else.
414
+ *
415
+ * A workflow id, not a run id, and the id is all it can be: sandbox-contract imports THIS package, so
416
+ * nothing here can name a `Workflow` type.
417
+ */
418
+ composeWorkflow(workflowId: string): void;
419
+ /* AIM A NEW CHAT AT A SAVED LOOP — the same handover as `composeWorkflow` above, for the other kind of
420
+ * design: the host opens a session with the composer's loop badge set, so the next message the user
421
+ * types becomes the loop's GOAL and Send starts it running.
422
+ *
423
+ * It is a separate call rather than a flag on that one because the two badges are separate picks that
424
+ * cannot both be armed, and a single "compose with this id" would have had to guess which kind an id
425
+ * was. A loop id, not a running loop's — nothing has started, and nothing is spent until the send.
426
+ */
427
+ composeLoop(loopId: string): void;
428
+ };
429
+ /* WHICH MODEL A RUN THIS EXTENSION STARTS WILL SPEND, the way `terminal` is the shell's one terminal panel:
430
+ * the extension names the choice it is holding, the host owns the picker.
431
+ *
432
+ * It is an API rather than a kit component because the picker is not a widget — it is a live read of every
433
+ * connected provider's catalog, which credentials the sandbox actually holds, and what each model can do.
434
+ * An extension that rendered its own control could only ever offer a worse list: the acceptance view's did,
435
+ * fetching one provider's models behind a second dropdown for the provider itself, and so it happily
436
+ * offered models the sandbox had no credential for — a run that fails on a credential error minutes later.
437
+ *
438
+ * IT COVERS THE WHOLE CHOICE — provider, account, harness, model — because covering three quarters of it is
439
+ * what produced the copy this API exists to prevent. The automations form asked all four questions, found an
440
+ * API that answered three, and hand-rolled ROWS OF CHIPS for every one of them to keep its own fields
441
+ * consistent with each other: a static provider list that offered providers the sandbox had no credential for
442
+ * and could not offer a model endpoint or an ACP agent at all, and a model row eleven chips wide that grew
443
+ * with every release. Partial coverage does not buy partial reuse; it buys none. */
444
+ readonly models: {
445
+ // What a run opens on when nobody has chosen: the sandbox's Agent-runs model (Sandbox ▸ Agent ▸ Models),
446
+ // falling back to whatever the owner's own chat is set to. Reactive when read inside a computed.
447
+ agentRun(): PickedModel;
448
+ /* NAME A SELECTION THE EXTENSION ALREADY HOLDS — a pin read back from disk, which arrives as bare ids and
449
+ * has to be rendered before anyone opens the picker. This is what keeps `label` honest for the surfaces
450
+ * that SAVE a choice rather than spend it immediately: without it every one of them would keep a catalog
451
+ * to pretty-print its own stored ids, which is the duplication this API exists to end, and it would go
452
+ * stale the day a provider renames a model or the owner disconnects an account.
453
+ *
454
+ * Reactive when read inside a computed — a model that lands in the catalog, or an account that stops being
455
+ * connected, changes what a stored pin should say about itself. */
456
+ describe(selection: {
457
+ readonly provider: string;
458
+ readonly model: string;
459
+ readonly account?: string | undefined;
460
+ readonly harness?: string | undefined;
461
+ }): PickedModel;
462
+ /* Open the picker over `anchor` — a popover on desktop, a sheet on mobile — starting on the selection the
463
+ * caller is holding. Resolves with the pick, or undefined if it was dismissed. A second call supersedes
464
+ * the first, resolving it as a dismissal.
465
+ *
466
+ * EVERY ROW SETTLES IT, including an account and a harness row: each click is one complete answer, so a
467
+ * caller never has to reconcile a half-changed selection, and the picker never has to hold state that
468
+ * disagrees with what the caller is showing. Picking a model under a DIFFERENT provider clears the
469
+ * account with it — an account id is one provider's store key, so carrying it across would pin the run to
470
+ * an account that provider does not have. */
471
+ pick(options: {
472
+ readonly anchor: HTMLElement;
473
+ readonly provider: string;
474
+ readonly model: string;
475
+ readonly account?: string | undefined;
476
+ readonly harness?: string | undefined;
477
+ }): Promise<PickedModel | undefined>;
213
478
  };
214
479
  // Navigate the shell to an app path (e.g. "/capabilities", "/ext/<view>/<key>").
215
480
  readonly navigate: (path: string) => void;
package/src/diff.ts ADDED
@@ -0,0 +1,59 @@
1
+ /* WHAT AN EXTENSION HANDS THE HOST TO OPEN A DIFF — the argument to `api.workspace.openDiff`.
2
+ *
3
+ * A diff belongs in the editor area beside the files it is about, in the same tab strip as everything else the
4
+ * user has open. That strip is the host's, so an extension that has computed a before/after pair has nowhere to
5
+ * put it: a view renders inside its own frame, and a document provider inside its own tab. This is the way out,
6
+ * and it is the whole shape of the contribution — the extension says what changed, the host owns the tab, the
7
+ * viewer, the close orchestration and the dirty-buffer bookkeeping.
8
+ *
9
+ * It lives in the PUBLIC api package rather than in the app because the app is downstream of it: the workspace's
10
+ * own review surfaces build the identical payload, and having two spellings of it is how the two would drift. */
11
+
12
+ // Git's vocabulary for what happened to a file. "conflicted" is git's unmerged state (`U`) and is not a kind of
13
+ // modification — there is no stage 0 for such a path, so nothing a commit could record. The host renders each of
14
+ // these as its own letter and colour.
15
+ export type ChangeStatus = "added" | "modified" | "deleted" | "renamed" | "type-changed" | "conflicted";
16
+
17
+ /* A binary diff ships no text, so its two sides ride the payload as daemon URLs its BYTES are fetched from
18
+ * rather than as content. An absent side means that side does not exist — an added file has no before, a deleted
19
+ * one no after — and the viewer then gives the side that does exist the whole pane instead of drawing an empty
20
+ * half beside it. */
21
+ export interface DiffRawSides {
22
+ readonly beforeRaw?: string;
23
+ readonly afterRaw?: string;
24
+ }
25
+
26
+ export interface DiffPayload extends DiffRawSides {
27
+ /* The diff SOURCE's identity — a commit sha, a snapshot id, `working:<repo>`. Together with `scope` and
28
+ * `path` it is the tab's identity, so re-opening the same file at the same commit focuses the tab that is
29
+ * already open rather than stacking a second copy of it. Pick something stable and collision-free; prefixing
30
+ * with the extension's own id is the safe habit. */
31
+ readonly key: string;
32
+ // Which repo (or snapshot scope) the path is relative to — the other half of the tab identity.
33
+ readonly scope: string;
34
+ // The tab's label. Short: the strip is narrow, and "file.ts @ a1b2c3d" reads better than a full path.
35
+ readonly label: string;
36
+ readonly status: ChangeStatus;
37
+ readonly path: string;
38
+ // The two sides as text. Absent where the side does not exist, or where the content is binary/oversized —
39
+ // `binary` and `truncated` are what the viewer renders instead of an empty pane.
40
+ readonly before?: string;
41
+ readonly after?: string;
42
+ readonly binary?: boolean;
43
+ readonly truncated?: boolean;
44
+ // What the row that opened this diff already knew about its size, carried onto the tab's toolbar. Absent
45
+ // where the source has no numstat to give (a binary file, a change list without line counts) — the toolbar
46
+ // then renders nothing rather than a zero.
47
+ readonly additions?: number;
48
+ readonly deletions?: number;
49
+ /* THE CONTENT IS STILL COMING — open the tab now and fill it in when it lands (`workspace.fillDiff`).
50
+ *
51
+ * Reading a file the source has to compute or fetch is a wait, and a wait that has nowhere to be drawn gets
52
+ * drawn on the click instead: the row is clicked, nothing on screen changes, and the reader clicks it again.
53
+ * Everything the tab needs to exist — its identity, its label, the status letter and the line counts — is
54
+ * known before the content is, so the tab opens on the click and the panes fill underneath it.
55
+ *
56
+ * Absent means the payload IS the content, which is what an extension handing over an already-computed
57
+ * before/after pair should send. */
58
+ readonly pending?: boolean;
59
+ }
package/src/engines.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Minimal matcher for a manifest's `engines.intentic` range against the host's extension API version: an exact
2
+ // version ("0.1.0") or a caret range ("^0.1", "^1", "^1.2.3"). Caret follows semver — same major at or above
3
+ // the floor, and while the major is 0 the minor is breaking too. Anything unparseable fails closed: the loader
4
+ // reports the extension incompatible rather than activating on a guess.
5
+
6
+ const parse = (value: string): [number, number, number] | undefined => {
7
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim());
8
+ if (match === null) {
9
+ return undefined;
10
+ }
11
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
12
+ };
13
+
14
+ export const satisfiesEngines = (range: string, version: string): boolean => {
15
+ const host = parse(version);
16
+ const caret = range.trim().startsWith(`^`);
17
+ const floor = parse(caret ? range.trim().slice(1) : range);
18
+ if (host === undefined || floor === undefined) {
19
+ return false;
20
+ }
21
+ if (!caret) {
22
+ return host[0] === floor[0] && host[1] === floor[1] && host[2] === floor[2];
23
+ }
24
+ if (host[0] !== floor[0] || (floor[0] === 0 && host[1] !== floor[1])) {
25
+ return false;
26
+ }
27
+ if (host[1] !== floor[1]) {
28
+ return host[1] > floor[1];
29
+ }
30
+ return host[2] >= floor[2];
31
+ };
package/src/facts.ts CHANGED
@@ -26,6 +26,12 @@ export interface RepoFacts {
26
26
  // Whether the repo describes its features as user stories (a docs/user-stories directory) — the one fact
27
27
  // here that is language-agnostic, and the evidence an acceptance-testing surface activates on.
28
28
  readonly userStories: boolean;
29
+ /* Whether the repo carries architecture documentation (a docs/architecture directory).
30
+ *
31
+ * Here so that a surface which READS documentation can tell, without asking the file routes, which repos
32
+ * have any. The alternative was a read per repo on a poll — an answer the daemon already has from the same
33
+ * one-pass scan that produces every other fact on this interface. */
34
+ readonly docs: boolean;
29
35
  }
30
36
 
31
37
  // One connected capability's secret-free echo — `kind` is an open string: new kinds appear without an API bump,
package/src/host.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { IntenticApi } from "./api.js";
2
+
3
+ /* THE AMBIENT HOST HANDLE, one slot per extension. `activate(api)` binds it once, before any view renders, so
4
+ * the extension's composables reach the authenticated daemon transport, cache scoping and workspace facts
5
+ * through `host()` — the way `vscode.*` is ambient to a VSCode extension. Not app internals: everything flows
6
+ * through the public IntenticApi.
7
+ *
8
+ * A FACTORY rather than a module-level slot here, and that is the whole reason this lives in the API package
9
+ * instead of being one shared `host()`. The web shell publishes ONE instance of this module to every bundle
10
+ * (extension-host/hostModules.ts), so a slot held at module scope would be a single global that the last
11
+ * extension to activate silently takes over. Each extension calls this once and keeps its own closure:
12
+ *
13
+ * export const { bindHost, host } = hostSlot(`ext-activity`);
14
+ *
15
+ * The names come back already spelled the way call sites use them, so nothing downstream renames anything. */
16
+ export const hostSlot = (extension: string): { bindHost: (api: IntenticApi) => void; host: () => IntenticApi } => {
17
+ let current: IntenticApi | undefined;
18
+ return {
19
+ bindHost: (api) => {
20
+ current = api;
21
+ },
22
+ host: () => {
23
+ if (current === undefined) {
24
+ throw new Error(`${extension}: host() called before activate()`);
25
+ }
26
+ return current;
27
+ },
28
+ };
29
+ };
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from "./api.js";
2
+ export * from "./diff.js";
3
+ export * from "./engines.js";
2
4
  export * from "./facts.js";
3
- export * from "./manifest.js";
4
- export * from "./permissions.js";
5
+ export * from "./host.js";
5
6
  export * from "./route.js";
7
+ export * from "./server.js";
6
8
  export * from "./stream.js";
7
9
  export * from "./version.js";
package/src/server.ts ADDED
@@ -0,0 +1,65 @@
1
+ /* THE SERVER HALF an extension programs against — the daemon-side twin of IntenticApi (api.ts).
2
+ *
3
+ * A manifest `server` bundle exports `activateServer(api, context)`, and the daemon's BACKEND HOST — one
4
+ * separate supervised node process shared by every enabled extension with a backend — imports the bundle and
5
+ * calls it. A separate process rather than the daemon itself because loaded code can never be unloaded: the
6
+ * off switch, an upgrade to a new sha and a live-edited workspace extension all require the process holding
7
+ * the old code to die, and that process must never be the daemon (chat, terminals and file sync live there).
8
+ * A shared process rather than one per extension because the trust model is full trust (install is owner-only
9
+ * and sha-pinned) — isolation between extensions would buy robustness nobody is billed for.
10
+ *
11
+ * Full trust is also why this surface is deliberately small. The backend runs in the sandbox container as the
12
+ * same user the daemon does, so the workspace is reachable with plain `node:fs` — the api hands over PATHS,
13
+ * not a file service. What it does mediate is the two things a path cannot carry: the extension's route
14
+ * namespace (mount), and its reach into the daemon's own routes (daemon.*, gated by the manifest's
15
+ * `permissions.daemon` — the daemon refuses undeclared routes, same grammar and same honesty rule as the UI
16
+ * half's `permissions.sandbox`). */
17
+
18
+ // One request into this extension's namespace. The host strips the `/x/<id>` prefix before dispatch, so the
19
+ // handler sees the extension's OWN paths — the same paths its contract declares and its UI half calls.
20
+ // `undefined` means "not mine": the host answers 404 without the extension having to speak HTTP for it.
21
+ export type BackendRouteHandler = (request: Request) => Promise<Response | undefined>;
22
+
23
+ export interface ExtensionServerApi {
24
+ // The host's @intentic/extension-api version — what `engines.intentic` was checked against.
25
+ readonly apiVersion: string;
26
+ // The workspace root (absolute). The backend reads and writes under it with node's own fs — full trust
27
+ // means no file service in between. Durable state belongs in workspace files (the same rule the UI half
28
+ // lives by): it survives restarts, is shared across browsers, and the agent editing it out-of-band is the
29
+ // product.
30
+ readonly workspaceRoot: string;
31
+ // This extension's own checkout (absolute) — where its bundled assets sit.
32
+ readonly extensionDir: string;
33
+ // A line in the daemon's log, attributed to this extension.
34
+ readonly log: (message: string) => void;
35
+ readonly routes: {
36
+ /* Serve this extension's route namespace. The daemon proxies /x/<id>/* here — through its ordinary
37
+ * auth (an owner's browser, a member at the route's role floor), so a backend never sees an
38
+ * unauthenticated request and never sees a credential. One handler per extension: the extension owns
39
+ * its whole namespace, and how it routes inside it (an oRPC handler over its own contract, a plain
40
+ * switch) is its own business. A second mount replaces the first. */
41
+ mount(handler: BackendRouteHandler): void;
42
+ };
43
+ /* The authenticated transport to the daemon's own routes — the backend's `api.sandbox`. Auth is a minted
44
+ * per-extension token injected here; the daemon's gate checks every call against the manifest's
45
+ * `permissions.daemon` allowlist, so a backend's reach into the core is declared, reviewable and refusable
46
+ * exactly like the UI half's. The extension's own namespace needs no declaration — but there is also no
47
+ * reason to dial yourself over HTTP. */
48
+ readonly daemon: {
49
+ request(path: string, init?: RequestInit): Promise<Response>;
50
+ json<T>(path: string, init?: RequestInit): Promise<T>;
51
+ };
52
+ }
53
+
54
+ export interface ExtensionServerContext {
55
+ // The extension's routing id — its /x/<id> namespace segment (the capability entry id for a git-installed
56
+ // extension, publisher.name otherwise; the same id the UI half sees as ExtensionSummary.id).
57
+ readonly extensionId: string;
58
+ }
59
+
60
+ // The shape of the manifest `server` bundle's default export (or its named exports): `activateServer` runs
61
+ // once per backend-host start, after the engines check. There is no deactivate — retirement IS the host
62
+ // process ending, which is the one teardown that cannot leak.
63
+ export interface ExtensionServerModule {
64
+ activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | Promise<void>;
65
+ }