@intentic/extension-api 1.248.0 → 1.250.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/background.ts CHANGED
@@ -2,106 +2,39 @@ import type { Ref } from "vue";
2
2
  import type { Disposable, IntenticApi } from "./api.js";
3
3
  import { sandboxRef, sandboxScopeGuard } from "./scope.js";
4
4
 
5
- /* WHAT AN EXTENSION DOES WHILE NONE OF IT IS ON SCREEN, the two pieces every surface that badges a rail tile
6
- * turned out to need, and had been writing out by hand.
7
- *
8
- * A tile has to be able to say something before it is opened. That rules out the view's own query, which stops
9
- * when the component unmounts, and it rules out the file-change push AS AN INVALIDATION, because evicting a
10
- * cache entry only reaches a query something is observing. So the state lives at module scope (scope.ts) and
11
- * something refreshes it.
12
- *
13
- * THAT SOMETHING IS THE WRITE ITSELF WHEREVER THE ANSWER LIVES IN A FILE, and the timer is the backstop. The
14
- * push was already arriving and already naming this extension's paths, it was simply spent entirely on the cache
15
- * (`api.workspace.onDidChangeFiles`), so every badge in the workspace was as fresh as its own interval and no
16
- * fresher: an approvals queue the owner had just emptied went on claiming six items for a minute, and the slowest
17
- * tile here is ten minutes behind the file it describes. A count that is wrong for minutes at a time is worse
18
- * than no count, because the owner learns to distrust the one they cannot check without clicking.
19
- *
20
- * What the interval is still FOR, and why it did not simply go away: a source that is not a file at all (a CI
21
- * provider, a Komodo deployment) has nothing to push, so for those it remains the only feed; and for the rest it
22
- * covers the gap no push can close, a watcher that dropped an event, an exclusion nobody noticed. Where a file
23
- * binding carries the news, `everyMs` is honestly a slow safety net and the extensions here say so.
24
- *
25
- * Seven modules across six extensions arrived at the identical shape for that timer, and it carries five rules
26
- * that are each invisible until they are broken:
27
- *
28
- * never reject it runs detached, so a throw is an unhandled rejection with no caller to report to, and
29
- * that includes reading the host handle, which throws before activate() has bound one
30
- * skip when down an unreachable daemon is not news; asking it is a failed request per tick, forever
31
- * guard the await a read issued before a sandbox switch must not write its answer into the box after it
32
- * keep the last a transient failure is not evidence that nothing is waiting, so a failed read changes
33
- * nothing rather than blanking the tile
34
- * stop the clock the interval is disposed with the extension, or a switched-off extension keeps polling
35
- *
36
- * Six of the seven copies got the third one wrong, which is what this pair exists to make impossible. What is
37
- * NOT here is what each tile SAYS: the count, the tone and the wording are the whole point of each surface and
38
- * differ deliberately, so `badge()` stays with the extension that owns the judgement. */
5
+ // Sandbox-scoped background polling for a badge that needs an answer before its tile opens: state lives at module
6
+ // scope, refreshed by a coalesced interval and a file-change wake. A failed read keeps the last value; a read is
7
+ // dropped if the sandbox switched while it was in flight.
39
8
 
40
9
  export interface SandboxPoll<T> {
41
- /* The value, as sandbox-scoped module state (scope.ts). Read it from `badge()` or `detect()` and the host's
42
- * own computed repaints when it moves; write to it directly for the local fold a "mark as seen" does, which
43
- * is what clears a tile on the spot instead of at the next tick. */
10
+ // Sandbox-scoped state (scope.ts); write directly to fold in a local change like "mark as seen".
44
11
  readonly state: Ref<T>;
45
- /* Begin reading: on the extension's own `contributes.files` being written, and on the interval. Push the
46
- * Disposable onto `context.subscriptions` and both stop with the extension.
47
- *
48
- * The file wake needs nothing declared here. An extension that named the paths its views derive from has
49
- * already said which writes change this answer, so subscribing is unconditional and an extension that
50
- * declared none is simply never woken.
51
- */
12
+ // Begins reading on the interval and on the extension's own file writes; push onto context.subscriptions.
52
13
  start(): Disposable;
53
- // Read now, off-cycle, for the moments that change the answer and should not wait out the interval: a
54
- // connection appearing, a draft published, a run discarded.
14
+ // Reads immediately, off-cycle, for a moment that should not wait for the interval.
55
15
  refresh(): void;
56
16
  }
57
17
 
58
18
  export interface SandboxPollOptions<T> {
59
- // The extension's own host handle (hostSlot). A function, not the api itself, because this is constructed
60
- // at module load and nothing is bound until activate() runs.
19
+ // A function, not the api itself: nothing is bound until activate() runs.
61
20
  readonly host: () => IntenticApi;
62
- /* How often, in milliseconds. There is no default on purpose: the right interval is a claim about how fast
63
- * the answer actually changes, and a surface that has not thought about it will inherit whatever number
64
- * happened to be chosen here. A badge is glanced at, so the honest range is minutes, not seconds.
65
- *
66
- * Read it against `start()`'s file wake. If the answer lives in a path your manifest declares, the write
67
- * already refreshes this and the interval is a BACKSTOP for the frame nobody delivered, so the honest number
68
- * there is slow. If it lives behind somebody else's API, nothing pushes and this is the only feed. */
21
+ // No default: choose an interval that reflects how fast the answer actually changes.
69
22
  readonly everyMs: number;
70
23
  // The value before anything has been read, rebuilt on every sandbox switch (sandboxRef).
71
24
  readonly initial: () => T;
72
- /* The read. Gets the api and the value currently held, the second for a poll that ACCUMULATES rather than
73
- * replaces, where one failed source must leave its own last answer standing beside the others. Throwing is
74
- * fine and means "nothing changed": the value in hand is kept.
75
- */
25
+ // Gets the api and the value held (for an accumulating poll); throwing means nothing changed, value kept.
76
26
  readonly read: (api: IntenticApi, previous: T) => Promise<T>;
77
- /* Whether `start()` reads immediately as well as on the interval. Default true, because a tile that only
78
- * badges a minute after login is a tile nobody trusts. Set false when the poll has nothing to ask until
79
- * something else tells it what to ask about, deployments learns its connections from `detect()`. */
27
+ // Defaults to reading immediately too; set false when there's nothing to ask until told (detect()).
80
28
  readonly immediate?: boolean;
81
- // For a value that owns something the garbage collector will not take back; see sandboxRef.
29
+ // For a value that owns something the garbage collector will not take back.
82
30
  readonly dispose?: (previous: T) => void;
83
31
  }
84
32
 
85
- /* HOW LONG A BURST OF WRITES IS ALLOWED TO COALESCE before the wake reads. The daemon already batches its
86
- * watcher at 250ms, but one logical event is often several batches, an acceptance run writing a result file per
87
- * story, a publish rewriting a staging tree, so without this the widest scan in the workspace would be re-run
88
- * per frame. Trailing rather than leading: the last write in a burst is the one whose answer is true.
89
- *
90
- * Short enough that the badge still moves while the owner is looking at the screen that caused the write, which
91
- * is the entire point of not waiting for the interval. */
33
+ // How long a burst of writes may coalesce before the wake reads; trailing, since the last write wins.
92
34
  const WAKE_MS = 400;
93
35
 
94
- /* ONE LANE FOR EVERY BACKGROUND READ IN THIS WINDOW.
95
- *
96
- * Extensions activate together, so their minute clocks also fire together. Letting each timer issue its own
97
- * request turned one harmless cadence into a burst across the daemon: the widest poll could still be walking
98
- * the workspace while CI, deployments and chores all started beside it. A reconnect was worse, because every
99
- * file-backed poll was woken in the same frame.
100
- *
101
- * Per-poll coalescing matters as much as the global lane. A wake that lands while its read is QUEUED adds no
102
- * information; a wake that lands while it is RUNNING may describe a change the in-flight read missed, so it
103
- * earns exactly one trailing pass. Re-queueing that pass at the back keeps one noisy poll from starving the
104
- * others. */
36
+ // One shared read lane for the whole window, so simultaneous per-extension timers don't burst the daemon at once.
37
+ // Per-poll: a wake mid-queue is redundant, a wake mid-run earns exactly one trailing pass, requeued at the back.
105
38
  interface ScheduledRead {
106
39
  queued: boolean;
107
40
  running: boolean;
@@ -154,12 +87,8 @@ const scheduleRead = (scheduled: ScheduledRead): void => {
154
87
  void drainScheduledReads();
155
88
  };
156
89
 
157
- /* Re-read when the extension's own declared files are written.
158
- *
159
- * Contained in a try/catch because BOTH of its failure modes are ordinary rather than exceptional: `host()`
160
- * throws until activate() has bound a handle, and an older host has no `onDidChangeFiles` at all (this arrived in
161
- * api 2.10.0, and an extension may declare `engines.intentic` wider than that). Either way the poll falls back to
162
- * exactly the timer it had before, which is a slower badge and never a broken one. */
90
+ // Re-reads when the extension's declared files change. Wrapped in try/catch: host() throws before activate(), and an
91
+ // older host may lack onDidChangeFiles; either way it falls back to the timer alone.
163
92
  const wakeOnFiles = (host: () => IntenticApi, read: () => void): Disposable => {
164
93
  let pending: ReturnType<typeof setTimeout> | undefined;
165
94
  let subscription: Disposable | undefined;
@@ -200,8 +129,7 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
200
129
  }
201
130
  state.value = next;
202
131
  } catch {
203
- // Whatever went wrong, an unbound host, a refused route, a daemon mid-boot, the answer is the
204
- // same: leave the last value standing. "We could not ask" is not "there is nothing there".
132
+ // Leave the last value standing; "could not ask" is not "nothing there".
205
133
  }
206
134
  };
207
135
  const scheduled: ScheduledRead = { queued: false, running: false, trailing: false, read: once };
@@ -225,41 +153,14 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
225
153
  };
226
154
  };
227
155
 
228
- /* WHAT THE OWNER HAS ALREADY SEEN, as a file in the workspace.
229
- *
230
- * The rail's bar is that a badge means "something happened here that you do not already know about". Meeting it
231
- * needs somewhere to record what they DO know, and three extensions independently chose the same home: a JSON
232
- * object under `.intentic`, keyed by whatever identifies the thing. That is the right home, it survives a
233
- * reload, it is shared across the owner's browsers, and it needs no setting nobody would ever type, but each
234
- * of them then hand-wrote the same tolerant reader and the same careful write.
235
- *
236
- * KEY → MARK, where the mark is what makes the entry STALE. That is the whole vocabulary, and it covers both
237
- * the ledgers that compare (a chore's evidence digest, a story's verdict, the same key with a different mark
238
- * is news again) and the ones that only ask whether a key is present at all (a document set reviewed once).
239
- * A presence-only ledger writes the acknowledgement time as its mark, which nothing reads and a human opening
240
- * the file is glad of.
241
- *
242
- * The file is written by agents and editable by hand, so a missing, truncated or hand-mangled one reads as
243
- * "nothing acknowledged". That direction is deliberate: bad bookkeeping may light a badge that should have been
244
- * quiet, and must never hide one that should have been lit.
245
- *
246
- * BOTH WRITES ANSWER "did this take effect", which is the question the caller's NEXT line depends on. Marking
247
- * something seen is almost always followed by folding it out of the badge locally, so the tile clears on the
248
- * spot rather than at the next poll, and that fold is a write into sandbox-scoped state, so it must not happen
249
- * when the acknowledgement itself was abandoned because the owner switched sandbox mid-operation. It would
250
- * silence the NEW box's badge for a fact about the old one. `false` means only that: the scope moved. A ledger
251
- * that already said what you asked it to say answers `true`, because it does. */
156
+ // Key → mark map of what the owner has already seen, stored as JSON under `.intentic`. Missing or unparseable reads as
157
+ // nothing acknowledged; a write's `false` return means only that the sandbox scope moved mid-write.
252
158
  export interface SandboxLedger {
253
159
  // Everything acknowledged so far. Absent, unparseable or not-an-object all read as nothing.
254
160
  read(): Promise<Readonly<Record<string, string>>>;
255
- /* Record these, leaving every other entry alone, the ordinary acknowledgement. No write happens when
256
- * nothing moved: the file push would otherwise cost every connected browser a refetch for a file whose
257
- * content is identical. */
161
+ // Records these, leaving every other entry alone; no write happens when nothing moved.
258
162
  mark(entries: Readonly<Record<string, string>>): Promise<boolean>;
259
- /* Make these the WHOLE ledger, dropping anything not named. For a ledger whose keys go out of scope, a
260
- * run that has scrolled past the scan window can never be seen again, and merging forever would grow the
261
- * file without bound. Same no-op-when-unchanged rule as `mark`.
262
- */
163
+ // Makes these the whole ledger, dropping anything not named; same no-op-when-unchanged rule as mark.
263
164
  replace(entries: Readonly<Record<string, string>>): Promise<boolean>;
264
165
  }
265
166
 
@@ -269,20 +170,17 @@ const sameEntries = (left: Readonly<Record<string, string>>, right: Readonly<Rec
269
170
  export const sandboxLedger = (host: () => IntenticApi, path: string): SandboxLedger => {
270
171
  const read = async (): Promise<Readonly<Record<string, string>>> => {
271
172
  const parsed = await host().workspace.readJson<Record<string, unknown>>(path);
272
- // Non-string values are dropped rather than coerced: a mark is a string, and anything else is somebody
273
- // else's idea of what this file is for.
173
+ // Non-string values are dropped, not coerced: a mark is always a string.
274
174
  return Object.fromEntries(Object.entries(parsed ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === `string`));
275
175
  };
276
176
 
277
- /* One writer for both verbs, and the scope guard lives HERE rather than at the call site, this is the
278
- * only thing in an extension's background work that damages state on DISK when a sandbox switch lands
279
- * mid-operation. Reading one workspace's acknowledgements and writing them into the tree of the workspace
280
- * the owner has just moved to is bookkeeping filed in the wrong place, which no later poll corrects. */
177
+ // One writer for both mark and replace; the scope guard lives here, not at the call site, since a sandbox switch
178
+ // mid-write would file the acknowledgement into the workspace the owner just left.
281
179
  const settle = async (next: (seen: Readonly<Record<string, string>>) => Readonly<Record<string, string>>): Promise<boolean> => {
282
180
  const current = sandboxScopeGuard();
283
181
  const seen = await read();
284
182
  const wanted = next(seen);
285
- // Already saying it. Nothing to write, and the caller's local fold is still right.
183
+ // Already saying it: nothing to write, and the caller's fold stays correct.
286
184
  if (sameEntries(seen, wanted)) {
287
185
  return true;
288
186
  }
package/src/diff.ts CHANGED
@@ -1,73 +1,42 @@
1
1
  import type { PartialFileDiff } from "@intentic/sandbox-contract";
2
2
 
3
- /* WHAT AN EXTENSION HANDS THE HOST TO OPEN A DIFF, the argument to `api.workspace.openDiff`.
4
- *
5
- * A diff belongs in the editor area beside the files it is about, in the same tab strip as everything else the
6
- * user has open. That strip is the host's, so an extension that has computed a before/after pair has nowhere to
7
- * put it: a view renders inside its own frame, and a document provider inside its own tab. This is the way out,
8
- * and it is the whole shape of the contribution, the extension says what changed, the host owns the tab, the
9
- * viewer, the close orchestration and the dirty-buffer bookkeeping.
10
- *
11
- * It lives in the PUBLIC api package rather than in the app because the app is downstream of it: the workspace's
12
- * own review surfaces build the identical payload, and having two spellings of it is how the two would drift. */
3
+ // The argument to `api.workspace.openDiff`: the extension says what changed, the host owns the tab, viewer and
4
+ // dirty-buffer bookkeeping. Lives in the public api package because the app's own review surfaces build the identical
5
+ // payload.
13
6
 
14
- // Git's vocabulary for what happened to a file. "conflicted" is git's unmerged state (`U`) and is not a kind of
15
- // modification, there is no stage 0 for such a path, so nothing a commit could record. The host renders each of
16
- // these as its own letter and colour.
7
+ // Git's vocabulary for what happened to a file; the host renders each as its own letter and colour. "conflicted" is
8
+ // git's unmerged state (`U`), not a kind of modification.
17
9
  export type ChangeStatus = "added" | "modified" | "deleted" | "renamed" | "type-changed" | "conflicted";
18
10
 
19
- /* A binary diff ships no text, so its two sides ride the payload as daemon URLs its BYTES are fetched from
20
- * rather than as content. An absent side means that side does not exist, an added file has no before, a deleted
21
- * one no after, and the viewer then gives the side that does exist the whole pane instead of drawing an empty
22
- * half beside it. */
11
+ // A binary diff ships its two sides as daemon URLs to fetch bytes from, not as content. An absent side means that side
12
+ // does not exist; the viewer gives the pane entirely to the side that does.
23
13
  export interface DiffRawSides {
24
14
  readonly beforeRaw?: string;
25
15
  readonly afterRaw?: string;
26
16
  }
27
17
 
28
18
  export interface DiffPayload extends DiffRawSides {
29
- /* The diff SOURCE's identity, a commit sha, a snapshot id, `working:<repo>`. Together with `scope` and
30
- * `path` it is the tab's identity, so re-opening the same file at the same commit focuses the tab that is
31
- * already open rather than stacking a second copy of it. Pick something stable and collision-free; prefixing
32
- * with the extension's own id is the safe habit. */
19
+ // Identity of the diff source (commit sha, snapshot id, `working:<repo>`); with `scope`/`path` it is the tab's
20
+ // identity, so a reopen focuses it.
33
21
  readonly key: string;
34
22
  // Which repo (or snapshot scope) the path is relative to, the other half of the tab identity.
35
23
  readonly scope: string;
36
- // The tab's label. Short: the strip is narrow, and "file.ts @ a1b2c3d" reads better than a full path.
24
+ // The tab's label; keep it short (e.g. "file.ts @ a1b2c3d"), the strip is narrow.
37
25
  readonly label: string;
38
26
  readonly status: ChangeStatus;
39
27
  readonly path: string;
40
- // The two sides as text. Absent where the side does not exist, or where the content is binary/oversized,
41
- // `binary` and `partial` are what the viewer renders instead of an empty pane.
28
+ // The two sides as text; absent when a side doesn't exist or is binary/oversized (see `binary`/`partial`).
42
29
  readonly before?: string;
43
30
  readonly after?: string;
44
31
  readonly binary?: boolean;
45
- /* A file too big to hand over whole, described by its changed regions instead (a unified patch) plus the
46
- * sizes of the two sides. The host's viewer rebuilds a real diff out of it, with the file's own line
47
- * numbers, so an extension that has computed a huge before/after pair has something better to offer than
48
- * a refusal: set this and leave `before`/`after` off. See PartialFileDiff for how the patch is read. */
32
+ // A big file's changed regions (a unified patch) plus both sizes; set this instead of before/after.
49
33
  readonly partial?: PartialFileDiff;
50
- // What the row that opened this diff already knew about its size, carried onto the tab's toolbar. Absent
51
- // where the source has no numstat to give (a binary file, a change list without line counts), the toolbar
52
- // then renders nothing rather than a zero.
34
+ // Size already known by the row that opened this, shown on the tab's toolbar; absent renders nothing, not zero.
53
35
  readonly additions?: number;
54
36
  readonly deletions?: number;
55
- /* THE CONTENT IS STILL COMING, open the tab now and fill it in when it lands (`workspace.fillDiff`).
56
- *
57
- * Reading a file the source has to compute or fetch is a wait, and a wait that has nowhere to be drawn gets
58
- * drawn on the click instead: the row is clicked, nothing on screen changes, and the reader clicks it again.
59
- * Everything the tab needs to exist, its identity, its label, the status letter and the line counts, is
60
- * known before the content is, so the tab opens on the click and the panes fill underneath it.
61
- *
62
- * Absent means the payload IS the content, which is what an extension handing over an already-computed
63
- * before/after pair should send. */
37
+ // Opens the tab immediately and fills content later via `workspace.fillDiff`; absent means the payload is the
38
+ // content.
64
39
  readonly pending?: boolean;
65
- /* THIS IS A LOOK, NOT AN OPEN: the tab takes the strip's single transient slot (the italic tab), and the
66
- * next peek replaces it in place.
67
- *
68
- * It is the difference between a list being READ and a file being CHOSEN. An extension whose document lists
69
- * changed files (a commit's, a run's) has a reader who will click ten of them in a row, and ten pinned tabs
70
- * is what the slot exists to prevent, so those clicks are peeks and a double-click, which should send the
71
- * payload without this, is what keeps one. Absent means keep, which is right for a single deliberate open. */
40
+ // A peek, not an open: reuses the strip's one transient tab slot instead of pinning a new one; absent keeps it.
72
41
  readonly preview?: boolean;
73
42
  }
package/src/facts.ts CHANGED
@@ -1,17 +1,9 @@
1
- /* The stable DETECTION facts, the subset of the daemon's wire schemas an extension's `detect()` reads to decide
2
- * when to activate a view. This is deliberately narrow (evidence over identity) so activation logic doesn't
3
- * couple to the daemon's fuller summaries. It is NOT the data plane: an activated extension reads real data over
4
- * `api.sandbox.request/json` against the `@intentic/sandbox-contract` schemas, the first-party wire contract,
5
- * gated per-route by the manifest's `permissions.sandbox` allowlist. (Because every first-party extension is
6
- * in-repo and compiled together, a wire change there is caught by the compiler and fixed atomically, so there is
7
- * no separate "stable data API" to promote, only detection is version-stable.) The daemon's own summaries
8
- * (PanelSummary, CapabilitySummary) are structural supersets and flow into `detect()` unmapped, but only THESE
9
- * fields are guaranteed to it. Optional fields carry `| undefined` so zod-inferred wire types assign under
10
- * exactOptionalPropertyTypes. */
1
+ // Stable detection facts `detect()` reads to decide when to activate a view: narrow and evidence-based, not the data
2
+ // plane (real data comes via `api.sandbox.request/json` against sandbox-contract schemas). Optional fields carry `|
3
+ // undefined` for exactOptionalPropertyTypes.
11
4
 
12
- // Daemon-computed content facts for one discovered repository under /work (`repo` is its root-relative dir),
13
- // evidence over identity: a repo is served because of what it CONTAINS (deploy.config.ts, pnpm-workspace.yaml
14
- // + turbo.json, .intentic/ui), not what it happens to be named.
5
+ // Daemon-computed content facts for one repo under /work (`repo` is its root-relative dir); evidence over identity,
6
+ // based on what the repo contains, not its name.
15
7
  export interface RepoFacts {
16
8
  readonly repo: string;
17
9
  // The workspace role this repo dir occupies; absent for extra clones.
@@ -23,19 +15,14 @@ export interface RepoFacts {
23
15
  readonly directoryUi: boolean;
24
16
  readonly monorepo: boolean;
25
17
  readonly vitest: boolean;
26
- // Whether the repo describes its features as user stories (a docs/user-stories directory), the one fact
27
- // here that is language-agnostic, and the evidence an acceptance-testing surface activates on.
18
+ // Whether the repo has a docs/user-stories directory; what an acceptance-testing surface activates on.
28
19
  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. */
20
+ // Whether the repo has a docs/architecture directory.
34
21
  readonly docs: boolean;
35
22
  }
36
23
 
37
- // One connected capability's secret-free echo, `kind` is an open string: new kinds appear without an API bump,
38
- // and matching on one couples the extension to that kind's continued existence.
24
+ // One connected capability's secret-free echo. `kind` is an open string; matching on one couples the extension to it
25
+ // continuing to exist.
39
26
  export interface CapabilityFacts {
40
27
  readonly id: string;
41
28
  readonly kind: string;
package/src/host.ts CHANGED
@@ -1,18 +1,8 @@
1
1
  import type { IntenticApi } from "./api.js";
2
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. */
3
+ // Per-extension host handle, ambient like `vscode.*`: `activate(api)` binds it once via `bindHost`, `host()` reads it.
4
+ // A factory, not a module-level slot: the web shell publishes one instance of this module to every extension's bundle,
5
+ // so a shared slot would be overwritten by whichever activates last.
16
6
  export const hostSlot = (extension: string): { bindHost: (api: IntenticApi) => void; host: () => IntenticApi } => {
17
7
  let current: IntenticApi | undefined;
18
8
  return {
package/src/protocol.ts CHANGED
@@ -1,18 +1,6 @@
1
- /* THE HALF OF THIS PACKAGE THAT RUNS WITHOUT A BROWSER, the protocol version and the matcher that compares a
2
- * manifest's `engines.intentic` against it.
3
- *
4
- * The root barrel is the EXTENSION-facing surface, and an extension runs in the app, so the barrel is free to
5
- * reach for vue: `scope.ts` imports `ref` as a value, not a type. The daemon is the other consumer, and it
6
- * needs exactly these two names, to refuse an incompatible extension (backend-supervisor), to stamp the
7
- * version it provides (backend-host-main), to write an engines range into a scaffold. It has no vue and no
8
- * reason to grow one, but `export *` loads every re-exported module eagerly, so importing the barrel for
9
- * `extensionApiVersion` alone dragged `vue` into a Node process that could not resolve it, the daemon crashed
10
- * on boot and the sandbox never became healthy.
11
- *
12
- * Hence this entry point. `@intentic/extension-api/protocol` is what host-side Node code imports; the root
13
- * barrel keeps exporting both names too, so nothing about the published extension surface changes. Type-only
14
- * imports from the root (`ExtensionServerApi` and friends) stay where they are, those are erased at compile
15
- * time and never reach the loader. */
1
+ // Node-safe entry point for `satisfiesEngines`/`extensionApiVersion`: the root barrel's `export *` eagerly loads every
2
+ // module including vue, which a host-side Node process (the daemon) cannot resolve. The root barrel still re-exports
3
+ // both names too.
16
4
 
17
5
  export { satisfiesEngines } from "./engines.js";
18
6
  export { extensionApiVersion } from "./version.js";
package/src/route.ts CHANGED
@@ -1,17 +1,11 @@
1
- /* The URL-query rules behind `api.route`, as pure functions, the same split `permissions.ts` uses: the host owns
2
- * the router, this owns the rule, and the rule is testable without one.
3
- *
4
- * A view's internal navigation lives in the QUERY because the path is already spoken for: `/ext/:ext/:key?` has
5
- * exactly one free segment and it means "which activation". So several views can be addressing the same query
6
- * string at once, and the one rule that matters is that none of them may clobber another's key. */
1
+ // Pure functions behind `api.route`'s URL-query rules; the host owns the router, this owns the rule. Navigation lives
2
+ // in the query since `/ext/:ext/:key?` has only one free segment (the activation); no view may clobber another's key.
7
3
 
8
4
  // What the router hands back for a query: vue-router yields `string | null` per key, or an array when a key repeats.
9
5
  export type RawQuery = Readonly<Record<string, string | readonly (string | null)[] | null | undefined>>;
10
6
 
11
- /* Flatten a router query to the scalar record extensions read. A repeated key takes its FIRST value rather than
12
- * its last: a view's state is singular, and the first occurrence is the one a hand-written or shared link means.
13
- * A valueless key (`?draft`) reads as the empty string, which is falsy-ish for the caller to test but never
14
- * `undefined`, absent and present-but-empty are different answers. */
7
+ // Flattens a router query to a scalar record. A repeated key keeps its first value (what a hand-written or shared link
8
+ // means); a valueless key (`?draft`) becomes `""`, never `undefined`.
15
9
  export const flattenQuery = (query: RawQuery): Record<string, string> => {
16
10
  const out: Record<string, string> = {};
17
11
  for (const [key, value] of Object.entries(query)) {
@@ -25,10 +19,8 @@ export const flattenQuery = (query: RawQuery): Record<string, string> => {
25
19
  return out;
26
20
  };
27
21
 
28
- /* Merge a patch into the live query. `undefined` REMOVES its key, that is how a view says "I am no longer on a
29
- * page" without leaving `?doc=` behind, so the tidy URL is the one you get by default rather than one you have to
30
- * construct. Every key the patch does not mention is carried through untouched, which is the whole invariant:
31
- * a documentation view setting `doc` must not drop the terminal's or another view's parameters. */
22
+ // Merges a patch into the live query; `undefined` removes that key rather than leaving `?doc=` behind. Every key the
23
+ // patch doesn't mention passes through untouched.
32
24
  export const mergeQuery = (current: RawQuery, patch: Readonly<Record<string, string | undefined>>): Record<string, string> => {
33
25
  const next = flattenQuery(current);
34
26
  for (const [key, value] of Object.entries(patch)) {
package/src/scope.ts CHANGED
@@ -1,27 +1,7 @@
1
1
  import { ref, type Ref } from "vue";
2
2
 
3
- /* STATE THAT BELONGS TO ONE SANDBOX, and the reason an extension cannot be left to remember that itself.
4
- *
5
- * Three tiers of client state exist in this app, and each needs a different answer to "what happens on a
6
- * switch". Cached server state is keyed by `api.sandbox.key(...)`, so it is answered by construction. State
7
- * inside a mounted component dies with the component. THIS is the third tier: module state owned by
8
- * `activate()`, the badge counts, the document-presence maps, the poll results that must survive the view
9
- * being unmounted, because a badge you only see once you have already navigated to the view is pointless.
10
- *
11
- * Nothing owned it. A rail tile filled by a ten-minute timer therefore kept the PREVIOUS sandbox's number for
12
- * up to ten minutes after a switch, under the new sandbox's name, a badge is a claim addressed to the reader,
13
- * and one describing a workspace they are no longer looking at is worse than no badge at all. It was not one
14
- * extension's mistake either: every extension that badges had the identical shape, which is the signature of a
15
- * missing primitive rather than of carelessness.
16
- *
17
- * So the host owns it. Declare the state through `sandboxRef` and the host empties it on every switch; there
18
- * is no subscription to remember and no teardown to write.
19
- *
20
- * A MODULE-LEVEL REGISTRY IS CORRECT HERE, and that is worth saying because `hostSlot` in this same package
21
- * warns against exactly that. The shell publishes ONE instance of this module to every bundle
22
- * (extension-host/hostModules.ts), so a slot held here is shared by all of them, which made it wrong for a
23
- * per-extension host handle and makes it right for this: one switch empties every extension's scope, and no
24
- * extension can be missed. */
3
+ // Module state scoped to one sandbox (badge counts, poll results) that must survive a component unmount. Declare it
4
+ // through `sandboxRef`; the host clears it on every sandbox switch, so there is no subscription or teardown to write.
25
5
 
26
6
  interface Registered {
27
7
  readonly clear: () => void;
@@ -29,22 +9,11 @@ interface Registered {
29
9
 
30
10
  const registered: Registered[] = [];
31
11
 
32
- /* Which scope the extensions are in, counted rather than named, this module cannot see the sandbox id, and
33
- * does not need to. All any caller asks is "is this still the scope I started in", and a counter answers that
34
- * without this package having to know what a sandbox is. */
12
+ // Sandbox scope counter; this package can't see sandbox ids, only whether the scope changed since a value was captured.
35
13
  let generation = 0;
36
14
 
37
- /* MODULE STATE FOR ONE SANDBOX. `initial` is a factory, not a value, so each scope starts from a fresh object
38
- * rather than sharing (and slowly mutating) one literal written at import time.
39
- *
40
- * const unseen = sandboxRef<readonly ChoreVerdict[]>(() => []);
41
- *
42
- * It is an ordinary `Ref` in every other respect: read it in a `badge()` or a `detect()` and the host's own
43
- * computed re-renders the tile when it changes, exactly as before. READ, not write, see `sandboxValue`.
44
- *
45
- * `dispose` is for state that owns something the garbage collector will not take back, an object URL, a
46
- * subscription. It is handed the value being dropped, once, at the moment the scope closes. Most callers need
47
- * none: a list of verdicts is released by being replaced. */
15
+ // Module state scoped to one sandbox: resets to a fresh `initial()` on every switch. `dispose`, if given, runs once on
16
+ // the outgoing value, for state that owns something the garbage collector won't reclaim.
48
17
  export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void): Ref<T> => {
49
18
  const state = ref(initial()) as Ref<T>;
50
19
  registered.push({
@@ -56,30 +25,14 @@ export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void)
56
25
  return state;
57
26
  };
58
27
 
59
- // A sandbox-scoped box that nothing observes. Same shape as a `Ref` on purpose, `.value`, read and written,
60
- // so moving state between the two is one word at the declaration and nothing at the call sites.
28
+ // A sandbox-scoped box that nothing observes; same `.value` shape as a `Ref` so state can move between the two without
29
+ // changing call sites.
61
30
  export interface SandboxValue<T> {
62
31
  value: T;
63
32
  }
64
33
 
65
- /* MODULE STATE FOR ONE SANDBOX THAT NOTHING RENDERS, `sandboxRef`'s lifetime without its reactivity, for the
66
- * bookkeeping a background poll keeps for ITSELF: which connections to ask about next round, the cursor a
67
- * fetch resumes from, the id a retry belongs to.
68
- *
69
- * It exists because of where `detect()` and `badge()` are called from. Both run INSIDE the host's render
70
- * computed, that is the whole mechanism by which a tile repaints when a poll lands, and a `Ref` WRITTEN from
71
- * inside a computed is that computed mutating its own dependency. Vue re-runs it, the write happens again, and
72
- * the rail recurses until Vue abandons the flush mid-frame. What the reader sees then is not one broken tile:
73
- * every update queued behind the rail is dropped with the flush, so the whole window stops answering, and the
74
- * console fills with a recursion error naming a component that is merely where the loop was noticed.
75
- *
76
- * So the division is by AUDIENCE, not by lifetime: `sandboxRef` for what a tile SHOWS, `sandboxValue` for what
77
- * a poll REMEMBERS. Both are emptied on a switch by the same door, and writing this one from a render callback
78
- * is safe precisely because there is nothing to invalidate. When a poll's own bookkeeping later turns out to
79
- * be worth showing, promoting it is a one-word change, and the promotion is the moment to check that nothing
80
- * writes it from `detect()`.
81
- *
82
- * `dispose` behaves exactly as it does on `sandboxRef`. */
34
+ // Like `sandboxRef` but not reactive: safe to write from inside `detect()`/`badge()`, which run inside the host's own
35
+ // render computed, where writing a `Ref` would re-trigger it recursively.
83
36
  export const sandboxValue = <T>(initial: () => T, dispose?: (previous: T) => void): SandboxValue<T> => {
84
37
  const box: SandboxValue<T> = { value: initial() };
85
38
  registered.push({
@@ -91,28 +44,14 @@ export const sandboxValue = <T>(initial: () => T, dispose?: (previous: T) => voi
91
44
  return box;
92
45
  };
93
46
 
94
- /* THE GUARD FOR WORK THAT WAS ALREADY IN FLIGHT WHEN THE SWITCH HAPPENED.
95
- *
96
- * Emptying the refs is not enough on its own. A poll that issued its request under the old sandbox resolves a
97
- * moment after the switch, and writes the old box's answer into the fresh scope, the same wrong badge, just
98
- * harder to reproduce. There is no way for this module to cancel that request, so it offers the one thing the
99
- * caller needs instead: a way to ask, after the await, whether the answer is still wanted.
100
- *
101
- * Take it BEFORE the await, ask it AFTER:
102
- *
103
- * const current = sandboxScopeGuard();
104
- * const report = await api.sandbox.fetch(query);
105
- * if (!current()) return;
106
- * unseen.value = assess(report);
107
- *
108
- * Two lines, and the failure it prevents is invisible without them. */
47
+ // Snapshot the current scope before an await; call the result after to check the scope hasn't switched meanwhile.
48
+ // Needed because emptying the refs doesn't stop an in-flight request from writing its answer into the new scope.
109
49
  export const sandboxScopeGuard = (): (() => boolean) => {
110
50
  const taken = generation;
111
51
  return () => taken === generation;
112
52
  };
113
53
 
114
- /* THE HOST'S DOOR, called by the shell when the active sandbox changes, not by extensions, which have nothing
115
- * to reset and no business resetting each other's. */
54
+ // Called by the shell whenever the active sandbox changes; extensions never call this, they have nothing to reset.
116
55
  export const resetSandboxScope = (): void => {
117
56
  generation += 1;
118
57
  for (const entry of registered) {