@intentic/extension-api 1.223.0 → 1.224.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/dist/api.d.ts +1 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -2
- package/src/api.ts +91 -81
- package/src/background.ts +13 -13
- package/src/diff.ts +11 -11
- package/src/engines.ts +1 -1
- package/src/facts.ts +7 -7
- package/src/host.ts +1 -1
- package/src/protocol.ts +4 -4
- package/src/route.ts +3 -3
- package/src/scope.ts +12 -12
- package/src/server.ts +15 -15
- package/src/stream.ts +3 -3
- package/src/surface.json +66 -0
- package/src/version.ts +19 -13
package/src/background.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
|
5
|
+
/* WHAT AN EXTENSION DOES WHILE NONE OF IT IS ON SCREEN, the two pieces every surface that badges a rail tile
|
|
6
6
|
* turned out to need, and had been writing out by hand.
|
|
7
7
|
*
|
|
8
8
|
* A tile has to be able to say something before it is opened. That rules out the view's own query, which stops
|
|
@@ -12,7 +12,7 @@ import { sandboxRef, sandboxScopeGuard } from "./scope.js";
|
|
|
12
12
|
* Seven modules across six extensions arrived at the identical shape for that timer, and it carries five rules
|
|
13
13
|
* that are each invisible until they are broken:
|
|
14
14
|
*
|
|
15
|
-
* never reject it runs detached, so a throw is an unhandled rejection with no caller to report to
|
|
15
|
+
* never reject it runs detached, so a throw is an unhandled rejection with no caller to report to, and
|
|
16
16
|
* that includes reading the host handle, which throws before activate() has bound one
|
|
17
17
|
* skip when down an unreachable daemon is not news; asking it is a failed request per tick, forever
|
|
18
18
|
* guard the await a read issued before a sandbox switch must not write its answer into the box after it
|
|
@@ -31,7 +31,7 @@ export interface SandboxPoll<T> {
|
|
|
31
31
|
readonly state: Ref<T>;
|
|
32
32
|
// Begin polling. Push the Disposable onto `context.subscriptions` and the clock stops with the extension.
|
|
33
33
|
start(): Disposable;
|
|
34
|
-
// Read now, off-cycle
|
|
34
|
+
// Read now, off-cycle, for the moments that change the answer and should not wait out the interval: a
|
|
35
35
|
// connection appearing, a draft published, a run discarded.
|
|
36
36
|
refresh(): void;
|
|
37
37
|
}
|
|
@@ -46,14 +46,14 @@ export interface SandboxPollOptions<T> {
|
|
|
46
46
|
readonly everyMs: number;
|
|
47
47
|
// The value before anything has been read, rebuilt on every sandbox switch (sandboxRef).
|
|
48
48
|
readonly initial: () => T;
|
|
49
|
-
/* The read. Gets the api and the value currently held
|
|
49
|
+
/* The read. Gets the api and the value currently held, the second for a poll that ACCUMULATES rather than
|
|
50
50
|
* replaces, where one failed source must leave its own last answer standing beside the others. Throwing is
|
|
51
51
|
* fine and means "nothing changed": the value in hand is kept.
|
|
52
52
|
*/
|
|
53
53
|
readonly read: (api: IntenticApi, previous: T) => Promise<T>;
|
|
54
54
|
/* Whether `start()` reads immediately as well as on the interval. Default true, because a tile that only
|
|
55
55
|
* badges a minute after login is a tile nobody trusts. Set false when the poll has nothing to ask until
|
|
56
|
-
* something else tells it what to ask about
|
|
56
|
+
* something else tells it what to ask about, deployments learns its connections from `detect()`. */
|
|
57
57
|
readonly immediate?: boolean;
|
|
58
58
|
// For a value that owns something the garbage collector will not take back; see sandboxRef.
|
|
59
59
|
readonly dispose?: (previous: T) => void;
|
|
@@ -75,7 +75,7 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
|
|
|
75
75
|
}
|
|
76
76
|
state.value = next;
|
|
77
77
|
} catch {
|
|
78
|
-
// Whatever went wrong
|
|
78
|
+
// Whatever went wrong, an unbound host, a refused route, a daemon mid-boot, the answer is the
|
|
79
79
|
// same: leave the last value standing. "We could not ask" is not "there is nothing there".
|
|
80
80
|
}
|
|
81
81
|
};
|
|
@@ -97,12 +97,12 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
|
|
|
97
97
|
*
|
|
98
98
|
* The rail's bar is that a badge means "something happened here that you do not already know about". Meeting it
|
|
99
99
|
* needs somewhere to record what they DO know, and three extensions independently chose the same home: a JSON
|
|
100
|
-
* object under `.intentic`, keyed by whatever identifies the thing. That is the right home
|
|
101
|
-
* reload, it is shared across the owner's browsers, and it needs no setting nobody would ever type
|
|
100
|
+
* object under `.intentic`, keyed by whatever identifies the thing. That is the right home, it survives a
|
|
101
|
+
* reload, it is shared across the owner's browsers, and it needs no setting nobody would ever type, but each
|
|
102
102
|
* of them then hand-wrote the same tolerant reader and the same careful write.
|
|
103
103
|
*
|
|
104
104
|
* KEY → MARK, where the mark is what makes the entry STALE. That is the whole vocabulary, and it covers both
|
|
105
|
-
* the ledgers that compare (a chore's evidence digest, a story's verdict
|
|
105
|
+
* the ledgers that compare (a chore's evidence digest, a story's verdict, the same key with a different mark
|
|
106
106
|
* is news again) and the ones that only ask whether a key is present at all (a document set reviewed once).
|
|
107
107
|
* A presence-only ledger writes the acknowledgement time as its mark, which nothing reads and a human opening
|
|
108
108
|
* the file is glad of.
|
|
@@ -113,18 +113,18 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
|
|
|
113
113
|
*
|
|
114
114
|
* BOTH WRITES ANSWER "did this take effect", which is the question the caller's NEXT line depends on. Marking
|
|
115
115
|
* something seen is almost always followed by folding it out of the badge locally, so the tile clears on the
|
|
116
|
-
* spot rather than at the next poll
|
|
116
|
+
* spot rather than at the next poll, and that fold is a write into sandbox-scoped state, so it must not happen
|
|
117
117
|
* when the acknowledgement itself was abandoned because the owner switched sandbox mid-operation. It would
|
|
118
118
|
* silence the NEW box's badge for a fact about the old one. `false` means only that: the scope moved. A ledger
|
|
119
119
|
* that already said what you asked it to say answers `true`, because it does. */
|
|
120
120
|
export interface SandboxLedger {
|
|
121
121
|
// Everything acknowledged so far. Absent, unparseable or not-an-object all read as nothing.
|
|
122
122
|
read(): Promise<Readonly<Record<string, string>>>;
|
|
123
|
-
/* Record these, leaving every other entry alone
|
|
123
|
+
/* Record these, leaving every other entry alone, the ordinary acknowledgement. No write happens when
|
|
124
124
|
* nothing moved: the file push would otherwise cost every connected browser a refetch for a file whose
|
|
125
125
|
* content is identical. */
|
|
126
126
|
mark(entries: Readonly<Record<string, string>>): Promise<boolean>;
|
|
127
|
-
/* Make these the WHOLE ledger, dropping anything not named. For a ledger whose keys go out of scope
|
|
127
|
+
/* Make these the WHOLE ledger, dropping anything not named. For a ledger whose keys go out of scope, a
|
|
128
128
|
* run that has scrolled past the scan window can never be seen again, and merging forever would grow the
|
|
129
129
|
* file without bound. Same no-op-when-unchanged rule as `mark`.
|
|
130
130
|
*/
|
|
@@ -142,7 +142,7 @@ export const sandboxLedger = (host: () => IntenticApi, path: string): SandboxLed
|
|
|
142
142
|
return Object.fromEntries(Object.entries(parsed ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === `string`));
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
-
/* One writer for both verbs, and the scope guard lives HERE rather than at the call site
|
|
145
|
+
/* One writer for both verbs, and the scope guard lives HERE rather than at the call site, this is the
|
|
146
146
|
* only thing in an extension's background work that damages state on DISK when a sandbox switch lands
|
|
147
147
|
* mid-operation. Reading one workspace's acknowledgements and writing them into the tree of the workspace
|
|
148
148
|
* the owner has just moved to is bookkeeping filed in the wrong place, which no later poll corrects. */
|
package/src/diff.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/* WHAT AN EXTENSION HANDS THE HOST TO OPEN A DIFF
|
|
1
|
+
/* WHAT AN EXTENSION HANDS THE HOST TO OPEN A DIFF, the argument to `api.workspace.openDiff`.
|
|
2
2
|
*
|
|
3
3
|
* A diff belongs in the editor area beside the files it is about, in the same tab strip as everything else the
|
|
4
4
|
* user has open. That strip is the host's, so an extension that has computed a before/after pair has nowhere to
|
|
5
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
|
|
6
|
+
* and it is the whole shape of the contribution, the extension says what changed, the host owns the tab, the
|
|
7
7
|
* viewer, the close orchestration and the dirty-buffer bookkeeping.
|
|
8
8
|
*
|
|
9
9
|
* It lives in the PUBLIC api package rather than in the app because the app is downstream of it: the workspace's
|
|
10
10
|
* own review surfaces build the identical payload, and having two spellings of it is how the two would drift. */
|
|
11
11
|
|
|
12
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
|
|
13
|
+
// modification, there is no stage 0 for such a path, so nothing a commit could record. The host renders each of
|
|
14
14
|
// these as its own letter and colour.
|
|
15
15
|
export type ChangeStatus = "added" | "modified" | "deleted" | "renamed" | "type-changed" | "conflicted";
|
|
16
16
|
|
|
17
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
|
|
19
|
-
* one no after
|
|
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
20
|
* half beside it. */
|
|
21
21
|
export interface DiffRawSides {
|
|
22
22
|
readonly beforeRaw?: string;
|
|
@@ -24,33 +24,33 @@ export interface DiffRawSides {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface DiffPayload extends DiffRawSides {
|
|
27
|
-
/* The diff SOURCE's identity
|
|
27
|
+
/* The diff SOURCE's identity, a commit sha, a snapshot id, `working:<repo>`. Together with `scope` and
|
|
28
28
|
* `path` it is the tab's identity, so re-opening the same file at the same commit focuses the tab that is
|
|
29
29
|
* already open rather than stacking a second copy of it. Pick something stable and collision-free; prefixing
|
|
30
30
|
* with the extension's own id is the safe habit. */
|
|
31
31
|
readonly key: string;
|
|
32
|
-
// Which repo (or snapshot scope) the path is relative to
|
|
32
|
+
// Which repo (or snapshot scope) the path is relative to, the other half of the tab identity.
|
|
33
33
|
readonly scope: string;
|
|
34
34
|
// The tab's label. Short: the strip is narrow, and "file.ts @ a1b2c3d" reads better than a full path.
|
|
35
35
|
readonly label: string;
|
|
36
36
|
readonly status: ChangeStatus;
|
|
37
37
|
readonly path: string;
|
|
38
|
-
// The two sides as text. Absent where the side does not exist, or where the content is binary/oversized
|
|
38
|
+
// The two sides as text. Absent where the side does not exist, or where the content is binary/oversized,
|
|
39
39
|
// `binary` and `truncated` are what the viewer renders instead of an empty pane.
|
|
40
40
|
readonly before?: string;
|
|
41
41
|
readonly after?: string;
|
|
42
42
|
readonly binary?: boolean;
|
|
43
43
|
readonly truncated?: boolean;
|
|
44
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)
|
|
45
|
+
// where the source has no numstat to give (a binary file, a change list without line counts), the toolbar
|
|
46
46
|
// then renders nothing rather than a zero.
|
|
47
47
|
readonly additions?: number;
|
|
48
48
|
readonly deletions?: number;
|
|
49
|
-
/* THE CONTENT IS STILL COMING
|
|
49
|
+
/* THE CONTENT IS STILL COMING, open the tab now and fill it in when it lands (`workspace.fillDiff`).
|
|
50
50
|
*
|
|
51
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
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
|
|
53
|
+
* Everything the tab needs to exist, its identity, its label, the status letter and the line counts, is
|
|
54
54
|
* known before the content is, so the tab opens on the click and the panes fill underneath it.
|
|
55
55
|
*
|
|
56
56
|
* Absent means the payload IS the content, which is what an extension handing over an already-computed
|
package/src/engines.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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
|
|
2
|
+
// version ("0.1.0") or a caret range ("^0.1", "^1", "^1.2.3"). Caret follows semver, same major at or above
|
|
3
3
|
// the floor, and while the major is 0 the minor is breaking too. Anything unparseable fails closed: the loader
|
|
4
4
|
// reports the extension incompatible rather than activating on a guess.
|
|
5
5
|
|
package/src/facts.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/* The stable DETECTION facts
|
|
1
|
+
/* The stable DETECTION facts, the subset of the daemon's wire schemas an extension's `detect()` reads to decide
|
|
2
2
|
* when to activate a view. This is deliberately narrow (evidence over identity) so activation logic doesn't
|
|
3
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
|
|
4
|
+
* `api.sandbox.request/json` against the `@intentic/sandbox-contract` schemas, the first-party wire contract,
|
|
5
5
|
* gated per-route by the manifest's `permissions.sandbox` allowlist. (Because every first-party extension is
|
|
6
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
|
|
7
|
+
* no separate "stable data API" to promote, only detection is version-stable.) The daemon's own summaries
|
|
8
8
|
* (PanelSummary, CapabilitySummary) are structural supersets and flow into `detect()` unmapped, but only THESE
|
|
9
9
|
* fields are guaranteed to it. Optional fields carry `| undefined` so zod-inferred wire types assign under
|
|
10
10
|
* exactOptionalPropertyTypes. */
|
|
11
11
|
|
|
12
|
-
// Daemon-computed content facts for one discovered repository under /work (`repo` is its root-relative dir)
|
|
12
|
+
// Daemon-computed content facts for one discovered repository under /work (`repo` is its root-relative dir),
|
|
13
13
|
// evidence over identity: a repo is served because of what it CONTAINS (deploy.config.ts, pnpm-workspace.yaml
|
|
14
14
|
// + turbo.json, .intentic/ui), not what it happens to be named.
|
|
15
15
|
export interface RepoFacts {
|
|
@@ -23,18 +23,18 @@ export interface RepoFacts {
|
|
|
23
23
|
readonly directoryUi: boolean;
|
|
24
24
|
readonly monorepo: boolean;
|
|
25
25
|
readonly vitest: boolean;
|
|
26
|
-
// Whether the repo describes its features as user stories (a docs/user-stories directory)
|
|
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
29
|
/* Whether the repo carries architecture documentation (a docs/architecture directory).
|
|
30
30
|
*
|
|
31
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
|
|
32
|
+
* have any. The alternative was a read per repo on a poll, an answer the daemon already has from the same
|
|
33
33
|
* one-pass scan that produces every other fact on this interface. */
|
|
34
34
|
readonly docs: boolean;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// One connected capability's secret-free echo
|
|
37
|
+
// One connected capability's secret-free echo, `kind` is an open string: new kinds appear without an API bump,
|
|
38
38
|
// and matching on one couples the extension to that kind's continued existence.
|
|
39
39
|
export interface CapabilityFacts {
|
|
40
40
|
readonly id: string;
|
package/src/host.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { IntenticApi } from "./api.js";
|
|
|
2
2
|
|
|
3
3
|
/* THE AMBIENT HOST HANDLE, one slot per extension. `activate(api)` binds it once, before any view renders, so
|
|
4
4
|
* the extension's composables reach the authenticated daemon transport, cache scoping and workspace facts
|
|
5
|
-
* through `host()
|
|
5
|
+
* through `host()`, the way `vscode.*` is ambient to a VSCode extension. Not app internals: everything flows
|
|
6
6
|
* through the public IntenticApi.
|
|
7
7
|
*
|
|
8
8
|
* A FACTORY rather than a module-level slot here, and that is the whole reason this lives in the API package
|
package/src/protocol.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
/* THE HALF OF THIS PACKAGE THAT RUNS WITHOUT A BROWSER
|
|
1
|
+
/* THE HALF OF THIS PACKAGE THAT RUNS WITHOUT A BROWSER, the protocol version and the matcher that compares a
|
|
2
2
|
* manifest's `engines.intentic` against it.
|
|
3
3
|
*
|
|
4
4
|
* The root barrel is the EXTENSION-facing surface, and an extension runs in the app, so the barrel is free to
|
|
5
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
|
|
6
|
+
* needs exactly these two names, to refuse an incompatible extension (backend-supervisor), to stamp the
|
|
7
7
|
* version it provides (backend-host-main), to write an engines range into a scaffold. It has no vue and no
|
|
8
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
|
|
9
|
+
* `extensionApiVersion` alone dragged `vue` into a Node process that could not resolve it, the daemon crashed
|
|
10
10
|
* on boot and the sandbox never became healthy.
|
|
11
11
|
*
|
|
12
12
|
* Hence this entry point. `@intentic/extension-api/protocol` is what host-side Node code imports; the root
|
|
13
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
|
|
14
|
+
* imports from the root (`ExtensionServerApi` and friends) stay where they are, those are erased at compile
|
|
15
15
|
* time and never reach the loader. */
|
|
16
16
|
|
|
17
17
|
export { satisfiesEngines } from "./engines.js";
|
package/src/route.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* The URL-query rules behind `api.route`, as pure functions
|
|
1
|
+
/* The URL-query rules behind `api.route`, as pure functions, the same split `permissions.ts` uses: the host owns
|
|
2
2
|
* the router, this owns the rule, and the rule is testable without one.
|
|
3
3
|
*
|
|
4
4
|
* A view's internal navigation lives in the QUERY because the path is already spoken for: `/ext/:ext/:key?` has
|
|
@@ -11,7 +11,7 @@ export type RawQuery = Readonly<Record<string, string | readonly (string | null)
|
|
|
11
11
|
/* Flatten a router query to the scalar record extensions read. A repeated key takes its FIRST value rather than
|
|
12
12
|
* its last: a view's state is singular, and the first occurrence is the one a hand-written or shared link means.
|
|
13
13
|
* A valueless key (`?draft`) reads as the empty string, which is falsy-ish for the caller to test but never
|
|
14
|
-
* `undefined
|
|
14
|
+
* `undefined`, absent and present-but-empty are different answers. */
|
|
15
15
|
export const flattenQuery = (query: RawQuery): Record<string, string> => {
|
|
16
16
|
const out: Record<string, string> = {};
|
|
17
17
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -25,7 +25,7 @@ export const flattenQuery = (query: RawQuery): Record<string, string> => {
|
|
|
25
25
|
return out;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
/* Merge a patch into the live query. `undefined` REMOVES its key
|
|
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
29
|
* page" without leaving `?doc=` behind, so the tidy URL is the one you get by default rather than one you have to
|
|
30
30
|
* construct. Every key the patch does not mention is carried through untouched, which is the whole invariant:
|
|
31
31
|
* a documentation view setting `doc` must not drop the terminal's or another view's parameters. */
|
package/src/scope.ts
CHANGED
|
@@ -5,11 +5,11 @@ import { ref, type Ref } from "vue";
|
|
|
5
5
|
* Three tiers of client state exist in this app, and each needs a different answer to "what happens on a
|
|
6
6
|
* switch". Cached server state is keyed by `api.sandbox.key(...)`, so it is answered by construction. State
|
|
7
7
|
* inside a mounted component dies with the component. THIS is the third tier: module state owned by
|
|
8
|
-
* `activate()
|
|
8
|
+
* `activate()`, the badge counts, the document-presence maps, the poll results that must survive the view
|
|
9
9
|
* being unmounted, because a badge you only see once you have already navigated to the view is pointless.
|
|
10
10
|
*
|
|
11
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
|
|
12
|
+
* up to ten minutes after a switch, under the new sandbox's name, a badge is a claim addressed to the reader,
|
|
13
13
|
* and one describing a workspace they are no longer looking at is worse than no badge at all. It was not one
|
|
14
14
|
* extension's mistake either: every extension that badges had the identical shape, which is the signature of a
|
|
15
15
|
* missing primitive rather than of carelessness.
|
|
@@ -19,7 +19,7 @@ import { ref, type Ref } from "vue";
|
|
|
19
19
|
*
|
|
20
20
|
* A MODULE-LEVEL REGISTRY IS CORRECT HERE, and that is worth saying because `hostSlot` in this same package
|
|
21
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
|
|
22
|
+
* (extension-host/hostModules.ts), so a slot held here is shared by all of them, which made it wrong for a
|
|
23
23
|
* per-extension host handle and makes it right for this: one switch empties every extension's scope, and no
|
|
24
24
|
* extension can be missed. */
|
|
25
25
|
|
|
@@ -29,7 +29,7 @@ interface Registered {
|
|
|
29
29
|
|
|
30
30
|
const registered: Registered[] = [];
|
|
31
31
|
|
|
32
|
-
/* Which scope the extensions are in, counted rather than named
|
|
32
|
+
/* Which scope the extensions are in, counted rather than named, this module cannot see the sandbox id, and
|
|
33
33
|
* does not need to. All any caller asks is "is this still the scope I started in", and a counter answers that
|
|
34
34
|
* without this package having to know what a sandbox is. */
|
|
35
35
|
let generation = 0;
|
|
@@ -40,9 +40,9 @@ let generation = 0;
|
|
|
40
40
|
* const unseen = sandboxRef<readonly ChoreVerdict[]>(() => []);
|
|
41
41
|
*
|
|
42
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
|
|
43
|
+
* computed re-renders the tile when it changes, exactly as before. READ, not write, see `sandboxValue`.
|
|
44
44
|
*
|
|
45
|
-
* `dispose` is for state that owns something the garbage collector will not take back
|
|
45
|
+
* `dispose` is for state that owns something the garbage collector will not take back, an object URL, a
|
|
46
46
|
* subscription. It is handed the value being dropped, once, at the moment the scope closes. Most callers need
|
|
47
47
|
* none: a list of verdicts is released by being replaced. */
|
|
48
48
|
export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void): Ref<T> => {
|
|
@@ -56,18 +56,18 @@ export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void)
|
|
|
56
56
|
return state;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
// A sandbox-scoped box that nothing observes. Same shape as a `Ref` on purpose
|
|
59
|
+
// A sandbox-scoped box that nothing observes. Same shape as a `Ref` on purpose, `.value`, read and written,
|
|
60
60
|
// so moving state between the two is one word at the declaration and nothing at the call sites.
|
|
61
61
|
export interface SandboxValue<T> {
|
|
62
62
|
value: T;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/* MODULE STATE FOR ONE SANDBOX THAT NOTHING RENDERS
|
|
65
|
+
/* MODULE STATE FOR ONE SANDBOX THAT NOTHING RENDERS, `sandboxRef`'s lifetime without its reactivity, for the
|
|
66
66
|
* bookkeeping a background poll keeps for ITSELF: which connections to ask about next round, the cursor a
|
|
67
67
|
* fetch resumes from, the id a retry belongs to.
|
|
68
68
|
*
|
|
69
69
|
* It exists because of where `detect()` and `badge()` are called from. Both run INSIDE the host's render
|
|
70
|
-
* computed
|
|
70
|
+
* computed, that is the whole mechanism by which a tile repaints when a poll lands, and a `Ref` WRITTEN from
|
|
71
71
|
* inside a computed is that computed mutating its own dependency. Vue re-runs it, the write happens again, and
|
|
72
72
|
* the rail recurses until Vue abandons the flush mid-frame. What the reader sees then is not one broken tile:
|
|
73
73
|
* every update queued behind the rail is dropped with the flush, so the whole window stops answering, and the
|
|
@@ -76,7 +76,7 @@ export interface SandboxValue<T> {
|
|
|
76
76
|
* So the division is by AUDIENCE, not by lifetime: `sandboxRef` for what a tile SHOWS, `sandboxValue` for what
|
|
77
77
|
* a poll REMEMBERS. Both are emptied on a switch by the same door, and writing this one from a render callback
|
|
78
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
|
|
79
|
+
* be worth showing, promoting it is a one-word change, and the promotion is the moment to check that nothing
|
|
80
80
|
* writes it from `detect()`.
|
|
81
81
|
*
|
|
82
82
|
* `dispose` behaves exactly as it does on `sandboxRef`. */
|
|
@@ -94,7 +94,7 @@ export const sandboxValue = <T>(initial: () => T, dispose?: (previous: T) => voi
|
|
|
94
94
|
/* THE GUARD FOR WORK THAT WAS ALREADY IN FLIGHT WHEN THE SWITCH HAPPENED.
|
|
95
95
|
*
|
|
96
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
|
|
97
|
+
* moment after the switch, and writes the old box's answer into the fresh scope, the same wrong badge, just
|
|
98
98
|
* harder to reproduce. There is no way for this module to cancel that request, so it offers the one thing the
|
|
99
99
|
* caller needs instead: a way to ask, after the await, whether the answer is still wanted.
|
|
100
100
|
*
|
|
@@ -111,7 +111,7 @@ export const sandboxScopeGuard = (): (() => boolean) => {
|
|
|
111
111
|
return () => taken === generation;
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
/* THE HOST'S DOOR, called by the shell when the active sandbox changes
|
|
114
|
+
/* THE HOST'S DOOR, called by the shell when the active sandbox changes, not by extensions, which have nothing
|
|
115
115
|
* to reset and no business resetting each other's. */
|
|
116
116
|
export const resetSandboxScope = (): void => {
|
|
117
117
|
generation += 1;
|
package/src/server.ts
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
/* THE SERVER HALF an extension programs against
|
|
1
|
+
/* THE SERVER HALF an extension programs against, the daemon-side twin of IntenticApi (api.ts).
|
|
2
2
|
*
|
|
3
|
-
* A manifest `server` bundle exports `activateServer(api, context)`, and the daemon's BACKEND HOST
|
|
4
|
-
* separate supervised node process shared by every enabled extension with a backend
|
|
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
5
|
* calls it. A separate process rather than the daemon itself because loaded code can never be unloaded: the
|
|
6
6
|
* off switch, an upgrade to a new sha and a live-edited workspace extension all require the process holding
|
|
7
7
|
* the old code to die, and that process must never be the daemon (chat, terminals and file sync live there).
|
|
8
8
|
* A shared process rather than one per extension because the trust model is full trust (install is owner-only
|
|
9
|
-
* and sha-pinned)
|
|
9
|
+
* and sha-pinned), isolation between extensions would buy robustness nobody is billed for.
|
|
10
10
|
*
|
|
11
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
|
|
12
|
+
* same user the daemon does, so the workspace is reachable with plain `node:fs`, the api hands over PATHS,
|
|
13
13
|
* not a file service. What it does mediate is the two things a path cannot carry: the extension's route
|
|
14
14
|
* namespace (mount), and its reach into the daemon's own routes (daemon.*, gated by the manifest's
|
|
15
|
-
* `permissions.daemon
|
|
15
|
+
* `permissions.daemon`, the daemon refuses undeclared routes, same grammar and same honesty rule as the UI
|
|
16
16
|
* half's `permissions.sandbox`). */
|
|
17
17
|
|
|
18
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
|
|
19
|
+
// handler sees the extension's OWN paths, the same paths its contract declares and its UI half calls.
|
|
20
20
|
// `undefined` means "not mine": the host answers 404 without the extension having to speak HTTP for it.
|
|
21
21
|
export type BackendRouteHandler = (request: Request) => Promise<Response | undefined>;
|
|
22
22
|
|
|
23
23
|
export interface ExtensionServerApi {
|
|
24
|
-
// The host's @intentic/extension-api version
|
|
24
|
+
// The host's @intentic/extension-api version, what `engines.intentic` was checked against.
|
|
25
25
|
readonly apiVersion: string;
|
|
26
|
-
// The workspace root (absolute). The backend reads and writes under it with node's own fs
|
|
26
|
+
// The workspace root (absolute). The backend reads and writes under it with node's own fs, full trust
|
|
27
27
|
// means no file service in between. Durable state belongs in workspace files (the same rule the UI half
|
|
28
28
|
// lives by): it survives restarts, is shared across browsers, and the agent editing it out-of-band is the
|
|
29
29
|
// product.
|
|
30
30
|
readonly workspaceRoot: string;
|
|
31
|
-
// This extension's own checkout (absolute)
|
|
31
|
+
// This extension's own checkout (absolute), where its bundled assets sit.
|
|
32
32
|
readonly extensionDir: string;
|
|
33
33
|
// A line in the daemon's log, attributed to this extension.
|
|
34
34
|
readonly log: (message: string) => void;
|
|
35
35
|
readonly routes: {
|
|
36
|
-
/* Serve this extension's route namespace. The daemon proxies /x/<id>/* here
|
|
36
|
+
/* Serve this extension's route namespace. The daemon proxies /x/<id>/* here, through its ordinary
|
|
37
37
|
* auth (an owner's browser, a member at the route's role floor), so a backend never sees an
|
|
38
38
|
* unauthenticated request and never sees a credential. One handler per extension: the extension owns
|
|
39
39
|
* its whole namespace, and how it routes inside it (an oRPC handler over its own contract, a plain
|
|
40
40
|
* switch) is its own business. A second mount replaces the first. */
|
|
41
41
|
mount(handler: BackendRouteHandler): void;
|
|
42
42
|
};
|
|
43
|
-
/* The authenticated transport to the daemon's own routes
|
|
43
|
+
/* The authenticated transport to the daemon's own routes, the backend's `api.sandbox`. Auth is a minted
|
|
44
44
|
* per-extension token injected here; the daemon's gate checks every call against the manifest's
|
|
45
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
|
|
46
|
+
* exactly like the UI half's. The extension's own namespace needs no declaration, but there is also no
|
|
47
47
|
* reason to dial yourself over HTTP. */
|
|
48
48
|
readonly daemon: {
|
|
49
49
|
request(path: string, init?: RequestInit): Promise<Response>;
|
|
@@ -52,13 +52,13 @@ export interface ExtensionServerApi {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export interface ExtensionServerContext {
|
|
55
|
-
// The extension's routing id
|
|
55
|
+
// The extension's routing id, its /x/<id> namespace segment (the capability entry id for a git-installed
|
|
56
56
|
// extension, publisher.name otherwise; the same id the UI half sees as ExtensionSummary.id).
|
|
57
57
|
readonly extensionId: string;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
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
|
|
61
|
+
// once per backend-host start, after the engines check. There is no deactivate, retirement IS the host
|
|
62
62
|
// process ending, which is the one teardown that cannot leak.
|
|
63
63
|
export interface ExtensionServerModule {
|
|
64
64
|
activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | Promise<void>;
|
package/src/stream.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/* Reading a daemon SSE/ndjson stream
|
|
1
|
+
/* Reading a daemon SSE/ndjson stream, the transport half of `sandbox.request().body`. The daemon emits SSE
|
|
2
2
|
* frames (blank-line separated, each a `data: <JSON>` line, an oRPC event-iterator failure as `event: error`),
|
|
3
3
|
* so consuming a streamed apply/plan/provision means reframing + JSON-parsing. Pure (ReadableStream in, async
|
|
4
|
-
* records out), no deps
|
|
4
|
+
* records out), no deps, extensions bundle it; the shim path never touches it. */
|
|
5
5
|
|
|
6
|
-
// A silent daemon
|
|
6
|
+
// A silent daemon, one that accepts the stream then sends nothing and never closes, would park reader.read()
|
|
7
7
|
// forever, hanging the consumer. A live daemon heartbeats (≤1s) over the stream, so no bytes for this long means
|
|
8
8
|
// the connection is dead: cancel the reader and end the generator instead of waiting indefinitely.
|
|
9
9
|
const SSE_IDLE_MS = 120_000;
|
package/src/surface.json
CHANGED
|
@@ -467,5 +467,71 @@
|
|
|
467
467
|
"sandboxValue",
|
|
468
468
|
"satisfiesEngines"
|
|
469
469
|
]
|
|
470
|
+
},
|
|
471
|
+
"2.9.0": {
|
|
472
|
+
"manifest": [
|
|
473
|
+
"$schema",
|
|
474
|
+
"art",
|
|
475
|
+
"category",
|
|
476
|
+
"contributes",
|
|
477
|
+
"engines",
|
|
478
|
+
"entry",
|
|
479
|
+
"icon",
|
|
480
|
+
"logo",
|
|
481
|
+
"name",
|
|
482
|
+
"permissions",
|
|
483
|
+
"publisher",
|
|
484
|
+
"server",
|
|
485
|
+
"version"
|
|
486
|
+
],
|
|
487
|
+
"contributes": [
|
|
488
|
+
"agent",
|
|
489
|
+
"automationTemplates",
|
|
490
|
+
"bin",
|
|
491
|
+
"capabilities",
|
|
492
|
+
"commands",
|
|
493
|
+
"documents",
|
|
494
|
+
"environment",
|
|
495
|
+
"files",
|
|
496
|
+
"listener",
|
|
497
|
+
"processes",
|
|
498
|
+
"settings",
|
|
499
|
+
"viewers",
|
|
500
|
+
"views"
|
|
501
|
+
],
|
|
502
|
+
"api": [
|
|
503
|
+
"apiVersion",
|
|
504
|
+
"chat",
|
|
505
|
+
"commands",
|
|
506
|
+
"documents",
|
|
507
|
+
"href",
|
|
508
|
+
"models",
|
|
509
|
+
"navigate",
|
|
510
|
+
"processes",
|
|
511
|
+
"route",
|
|
512
|
+
"sandbox",
|
|
513
|
+
"settings",
|
|
514
|
+
"terminal",
|
|
515
|
+
"theme",
|
|
516
|
+
"viewers",
|
|
517
|
+
"views",
|
|
518
|
+
"workspace"
|
|
519
|
+
],
|
|
520
|
+
"listener": ["automation", "events", "provider"],
|
|
521
|
+
"sandboxApi": ["fetch", "json", "key", "origin", "reachable", "request", "role", "rpc"],
|
|
522
|
+
"moduleExports": [
|
|
523
|
+
"extensionApiVersion",
|
|
524
|
+
"flattenQuery",
|
|
525
|
+
"hostSlot",
|
|
526
|
+
"mergeQuery",
|
|
527
|
+
"readDaemonStream",
|
|
528
|
+
"resetSandboxScope",
|
|
529
|
+
"sandboxLedger",
|
|
530
|
+
"sandboxPoll",
|
|
531
|
+
"sandboxRef",
|
|
532
|
+
"sandboxScopeGuard",
|
|
533
|
+
"sandboxValue",
|
|
534
|
+
"satisfiesEngines"
|
|
535
|
+
]
|
|
470
536
|
}
|
|
471
537
|
}
|
package/src/version.ts
CHANGED
|
@@ -1,56 +1,62 @@
|
|
|
1
|
-
// The extension API's protocol version
|
|
1
|
+
// The extension API's protocol version, the value `engines.intentic` ranges are matched against at load, and
|
|
2
2
|
// the value the host reports as IntenticApi.apiVersion. Bumped ONLY with the published package: additive
|
|
3
3
|
// surface = minor, breaking = major. This package is the one deliberate exception to the repo's no-legacy rule.
|
|
4
4
|
//
|
|
5
5
|
// 1.0.0 rather than 0.5.0, and the reason is the drift that forced that bump. While the major was 0 the caret
|
|
6
|
-
// matcher treats the MINOR as breaking (engines.ts), so every addition invalidated every declared range
|
|
6
|
+
// matcher treats the MINOR as breaking (engines.ts), so every addition invalidated every declared range, which
|
|
7
7
|
// made bumping expensive enough that two surface changes (connectors → capabilities, api.documents.open) shipped
|
|
8
8
|
// without one, and the number stopped being true. At 1.x an addition is 1.1.0 and costs authors nothing, so the
|
|
9
9
|
// bump that keeps this honest is the cheap one. surface-guard.test.ts is what makes it non-optional.
|
|
10
10
|
// 2.0.0 makes listener contributions self-describing: the former `eventTypes` array became labelled `events`
|
|
11
|
-
// plus the automation-editor vocabulary the provider owns. That is intentionally breaking
|
|
11
|
+
// plus the automation-editor vocabulary the provider owns. That is intentionally breaking, a 1.x listener
|
|
12
12
|
// manifest cannot honestly promise that a generic host can configure it.
|
|
13
13
|
// 2.1.0 adds the backend half: a manifest `server` bundle (activateServer, run by the daemon's backend host
|
|
14
|
-
// under /x/<id>/…) and `permissions.daemon` beside `permissions.sandbox`. Additive
|
|
14
|
+
// under /x/<id>/…) and `permissions.daemon` beside `permissions.sandbox`. Additive, a 2.0 manifest is a 2.1
|
|
15
15
|
// manifest that ships no backend.
|
|
16
16
|
// 2.2.0 opens the automation composer: `contributes.automationTemplates` lets a pack ship the starting points
|
|
17
17
|
// for its own service, and a `listener` may declare a second narrowing field (`automation.branchField`) for a
|
|
18
18
|
// source whose events carry one. Both fold into the daemon's trigger catalogue, so a pack that knows something
|
|
19
19
|
// worth waking on says so itself instead of being written into the automations surface. Additive.
|
|
20
|
-
// 2.3.0 adds `api.sandbox.role()
|
|
20
|
+
// 2.3.0 adds `api.sandbox.role()`, the signed-in user's trust tier, for affordance gating (the daemon floors
|
|
21
21
|
// every route regardless). Surfaced when the drafts queue moved out of the app: approve/reject are
|
|
22
22
|
// maintainer-and-up, and no extension could say so. Additive. The surface guard grew a `sandboxApi` member
|
|
23
23
|
// list with this release, so additions below the top-level grain are recorded from here on.
|
|
24
24
|
// 2.4.0 gives the manifest an authoring schema: `$schema` is a declared field, and every contribution point now
|
|
25
25
|
// carries the sentence that explains it, generated out to intentic-extension.schema.json. An editor pointed at
|
|
26
|
-
// that URL completes the fields, shows what each one does, and marks a key nothing declares
|
|
26
|
+
// that URL completes the fields, shows what each one does, and marks a key nothing declares, which used to be
|
|
27
27
|
// the one class of mistake nothing caught, because zod strips what it does not know rather than refusing it.
|
|
28
28
|
// Additive: a manifest that names no schema is unchanged.
|
|
29
29
|
// 2.5.0 lets an extension bring its own picture: `art` carries a complete SVG document inline, above the
|
|
30
30
|
// simple-icons `logo` and the host's `icon` in the same ladder. The two tiers before it could say "this is
|
|
31
|
-
// Slack" or "this is a server", and nothing could say "this is mine"
|
|
31
|
+
// Slack" or "this is a server", and nothing could say "this is mine", so a page listing seven unfamiliar
|
|
32
32
|
// extensions drew seven near-identical glyphs, which is the shape of a directory rather than of a shelf. Inline
|
|
33
33
|
// rather than a URL because the row is drawn before any code is cloned: a link would put a stranger's server in
|
|
34
|
-
// the render path, track who is browsing, and rot after approval. Additive
|
|
34
|
+
// the render path, track who is browsing, and rot after approval. Additive, a manifest that ships no drawing
|
|
35
35
|
// falls to exactly the mark it had before.
|
|
36
36
|
// 2.6.0 gives module state an owner across a sandbox switch: `sandboxRef` declares state that belongs to ONE
|
|
37
37
|
// sandbox and `sandboxScopeGuard` protects the write of a read that was already in flight when the switch
|
|
38
|
-
// happened (scope.ts). The tier existed and nothing cleared it
|
|
38
|
+
// happened (scope.ts). The tier existed and nothing cleared it, every extension that badges a rail tile from a
|
|
39
39
|
// timer kept the previous sandbox's count under the new sandbox's name, which is the one thing a badge must
|
|
40
40
|
// never do. Additive: an extension that keeps no module state is unchanged, and the host resets the scope
|
|
41
41
|
// whether or not anything registered.
|
|
42
42
|
// 2.7.0 adds the two halves of work an extension does while none of it is on screen (background.ts):
|
|
43
43
|
// `sandboxPoll`, the timer behind a rail badge, and `sandboxLedger`, the file recording what the owner has
|
|
44
|
-
// already seen. Seven modules across six extensions had hand-written the same poller
|
|
45
|
-
// each, and six of the seven had the sandbox-switch one wrong
|
|
44
|
+
// already seen. Seven modules across six extensions had hand-written the same poller, five invisible rules
|
|
45
|
+
// each, and six of the seven had the sandbox-switch one wrong, and three had hand-written the same tolerant
|
|
46
46
|
// reader and careful write over the same shape of file. Additive: nothing is removed, and an extension that
|
|
47
47
|
// keeps polling by hand still runs.
|
|
48
48
|
// 2.8.0 splits sandbox-scoped module state by AUDIENCE: `sandboxValue` is `sandboxRef`'s lifetime without its
|
|
49
49
|
// reactivity, for what a background poll remembers for itself (which connections to ask about next round, the
|
|
50
50
|
// cursor a fetch resumes from) rather than what a tile shows. `detect()` and `badge()` both run inside the
|
|
51
|
-
// host's render computed, so a `Ref` written from either is a computed mutating its own dependency
|
|
51
|
+
// host's render computed, so a `Ref` written from either is a computed mutating its own dependency. Vue
|
|
52
52
|
// re-runs it, it writes again, and the rail recurses until the flush is abandoned mid-frame, dropping every
|
|
53
53
|
// unrelated update queued behind it. The symptom is a window that stops answering, blamed on whichever
|
|
54
54
|
// component the loop was noticed in, so the fix has to be a box nothing observes rather than a rule to
|
|
55
55
|
// remember. Additive: `sandboxRef` is unchanged, and both are emptied on a switch by the same door.
|
|
56
|
-
|
|
56
|
+
// 2.9.0 adds `api.href` — the same app path the host would navigate to, as a browser address. A view that can
|
|
57
|
+
// only call `navigate` has to draw every destination it offers as a <button>, and a button is not a link: no
|
|
58
|
+
// address under the pointer, nothing in the browser's own right-click menu, nothing to copy, and Ctrl/⌘-click
|
|
59
|
+
// moving the tab the reader is in instead of opening a second one. Six views across three packs had each drawn
|
|
60
|
+
// a place that way, so the fix has to be reachable from the API rather than repeated per pack. Additive:
|
|
61
|
+
// `navigate` is unchanged, and it is still what a plain click calls.
|
|
62
|
+
export const extensionApiVersion = "2.9.0";
|