@avantf/dsh-plugin-base 0.5.3 → 0.6.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 (44) hide show
  1. package/README.md +132 -13
  2. package/lib/bootstrap.d.ts +1 -1
  3. package/lib/bootstrap.js +1 -1
  4. package/lib/client/load_more.d.ts +157 -0
  5. package/lib/client/load_more.d.ts.map +1 -0
  6. package/lib/client/load_more.js +252 -0
  7. package/lib/conformance.d.ts.map +1 -1
  8. package/lib/conformance.js +5 -0
  9. package/lib/errors.d.ts +3 -1
  10. package/lib/errors.d.ts.map +1 -1
  11. package/lib/fs.d.ts.map +1 -1
  12. package/lib/fs.js +3 -0
  13. package/lib/index.d.ts +10 -4
  14. package/lib/index.d.ts.map +1 -1
  15. package/lib/index.js +18 -3
  16. package/lib/interface.d.ts +165 -32
  17. package/lib/interface.d.ts.map +1 -1
  18. package/lib/interface.js +56 -16
  19. package/lib/internal.d.ts +4 -0
  20. package/lib/internal.d.ts.map +1 -1
  21. package/lib/internal.js +22 -0
  22. package/lib/lint.d.ts.map +1 -1
  23. package/lib/lint.js +39 -1
  24. package/lib/providers/npm.d.ts +10 -1
  25. package/lib/providers/npm.d.ts.map +1 -1
  26. package/lib/providers/npm.js +2 -2
  27. package/lib/providers/runtime_deps.d.ts +184 -0
  28. package/lib/providers/runtime_deps.d.ts.map +1 -0
  29. package/lib/providers/runtime_deps.js +1214 -0
  30. package/lib/provisioner.d.ts.map +1 -1
  31. package/lib/provisioner.js +4 -1
  32. package/lib/runtime_deps_helper.d.ts +71 -0
  33. package/lib/runtime_deps_helper.d.ts.map +1 -0
  34. package/lib/runtime_deps_helper.js +161 -0
  35. package/lib/session_notices.d.ts +95 -0
  36. package/lib/session_notices.d.ts.map +1 -0
  37. package/lib/session_notices.js +172 -0
  38. package/lib/state.d.ts +8 -1
  39. package/lib/state.d.ts.map +1 -1
  40. package/lib/state.js +20 -4
  41. package/lib/types.d.ts +14 -1
  42. package/lib/types.d.ts.map +1 -1
  43. package/package.json +7 -4
  44. package/src/client/controls.module.css +294 -0
@@ -1 +1 @@
1
- {"version":3,"file":"provisioner.d.ts","sourceRoot":"","sources":["../src/provisioner.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,SAAS,EACT,KAAK,EAeL,aAAa,EAMb,WAAW,EAEX,kBAAkB,EAMnB,MAAM,YAAY,CAAA;AAEnB,yDAAyD;AACzD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAA6C,CAAA;AAEvF,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,QAAS,CAAA;AAEzC,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAepC,UAAU,iBAAiB;IACzB,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CACtB;AAED;;;iFAGiF;AACjF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,iBAAiB,CAKvF;AA0DD;cACc;AACd,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAs/B1E"}
1
+ {"version":3,"file":"provisioner.d.ts","sourceRoot":"","sources":["../src/provisioner.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,SAAS,EACT,KAAK,EAeL,aAAa,EAMb,WAAW,EAEX,kBAAkB,EAOnB,MAAM,YAAY,CAAA;AAEnB,yDAAyD;AACzD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAA6C,CAAA;AAEvF,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,QAAS,CAAA;AAEzC,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAepC,UAAU,iBAAiB;IACzB,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CACtB;AAED;;;iFAGiF;AACjF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,iBAAiB,CAKvF;AA0DD;cACc;AACd,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAy/B1E"}
@@ -744,7 +744,10 @@ export function createProvisioner(options) {
744
744
  },
745
745
  };
746
746
  putRow(declared, resolved.version, state, resolved.source);
747
- return entryOf(declared, { action: 'installed', source: resolved.source, started, version: resolved.version });
747
+ // A provider that says it only REUSED an equivalent root (another process published it while
748
+ // this one waited) placed nothing: the honest action is `present`, not `installed`.
749
+ const action = resolved.reused === true ? 'present' : 'installed';
750
+ return entryOf(declared, { action, source: resolved.source, started, version: resolved.version });
748
751
  }
749
752
  catch (error) {
750
753
  const { code, message } = reasonOf(error);
@@ -0,0 +1,71 @@
1
+ import type { DependencySource, InstallAttempt } from './providers/runtime_deps.js';
2
+ import type { SessionNoticeRegistry, SessionNoticeSpec } from './session_notices.js';
3
+ import type { ProvisionItem, ProvisionReportEntry, Provisioner, Resolved } from './types.js';
4
+ /**
5
+ * What a plugin declares. A superset of {@link RuntimeDepsSpec} (the provider's own input) plus the
6
+ * identity and the notices: the spec fields are forwarded verbatim, so nothing here decides anything
7
+ * about packages, registries or managers.
8
+ */
9
+ export interface RuntimeDepsConfig {
10
+ /** The plugin's namespace (`mem`); the default item id is `${namespace}:runtime-deps`. */
11
+ readonly namespace: string;
12
+ /** Override the item id; must still be prefixed with `${namespace}:`. */
13
+ readonly itemId?: string;
14
+ /** Package name → dependency spec. Forwarded to {@link RuntimeDepsSpec.packages}. */
15
+ readonly packages: Readonly<Record<string, string>>;
16
+ /** The registry/cache endpoints the attempts reference. */
17
+ readonly sources: readonly DependencySource[];
18
+ /** Install attempts, tried in order. */
19
+ readonly attempts: readonly InstallAttempt[];
20
+ /** Specifiers every successful root must resolve; non-empty. */
21
+ readonly verify: readonly string[];
22
+ /** Host-provided singletons, linked never installed. */
23
+ readonly peers?: Readonly<Record<string, string>>;
24
+ /** A lockfile whose bytes seed/audit the install. */
25
+ readonly lockfile?: string;
26
+ /**
27
+ * Notices to deliver once per session when this item is NOT ready. Each one's `when` selects which
28
+ * observed states match: `failed` → the item failed; `missing` → it is missing or was skipped;
29
+ * `degraded` → any of those three (the broad "the shared root is not there" condition).
30
+ */
31
+ readonly notices?: readonly SessionNoticeSpec[];
32
+ }
33
+ /** What {@link createRuntimeDeps} hands back. */
34
+ export interface RuntimeDepsHandle {
35
+ /** The item to declare through the caller's own provisioner. */
36
+ readonly item: ProvisionItem;
37
+ /** The registry, pre-loaded with {@link RuntimeDepsConfig.notices}. */
38
+ readonly notices: SessionNoticeRegistry;
39
+ /**
40
+ * Ensure THIS item through the caller's provisioner and return its report entry.
41
+ *
42
+ * The item must already be declared (the helper never declares); `undefined` means the report
43
+ * carried no entry for it — a background item is dispatched and reports through `onSettled`
44
+ * instead, and `resolve()` reflects it as soon as it settles. A rejected `ensure` propagates: that
45
+ * is a dispatch failure, not a provisioning outcome.
46
+ */
47
+ ensure(provisioner: Provisioner): Promise<ProvisionReportEntry | undefined>;
48
+ /** The ready shared root as the core reports it, or `undefined` while it is not ready. */
49
+ resolve(): Resolved | undefined;
50
+ /**
51
+ * Resolve `specifier` from the shared root and import its entry (dynamic `import()`).
52
+ *
53
+ * Read-only by construction: `createRequire(<root>/package.json).resolve()` plus `import()` of the
54
+ * resolved file — no copy, no `node_modules` write, no link. The module is imported by FILE URL, so
55
+ * a package that must not be resolved twice through the host's tree still gets this root's copy.
56
+ *
57
+ * Rejects with a {@link ProvisionError} (`invalid-option`) when the root is not ready or the
58
+ * specifier is empty, and with the underlying resolution/import error otherwise — a failed resolve
59
+ * is the caller's specifier meeting this root, not a provisioning failure with a code.
60
+ */
61
+ load<T = unknown>(specifier: string): Promise<T>;
62
+ }
63
+ /**
64
+ * Build the handle for one shared runtime-dependency root.
65
+ *
66
+ * @param config - the plugin's declaration; forwarded to the provider field by field.
67
+ * @throws ProvisionError (`invalid-option`) for a namespace/itemId that cannot make a namespaced item
68
+ * id — a caller bug at construction time, not a provisioning outcome.
69
+ */
70
+ export declare function createRuntimeDeps(config: RuntimeDepsConfig): RuntimeDepsHandle;
71
+ //# sourceMappingURL=runtime_deps_helper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime_deps_helper.d.ts","sourceRoot":"","sources":["../src/runtime_deps_helper.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAmB,MAAM,6BAA6B,CAAA;AACpG,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AACpF,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,WAAW,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAE3G;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,qFAAqF;IACrF,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;IAC7C,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAA;IAC5C,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACjD,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAChD;AAED,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,qBAAqB,CAAA;IACvC;;;;;;;OAOG;IACH,MAAM,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAA;IAC3E,0FAA0F;IAC1F,OAAO,IAAI,QAAQ,GAAG,SAAS,CAAA;IAC/B;;;;;;;;;;OAUG;IACH,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;CACjD;AAiDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,iBAAiB,CA+E9E"}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `createRuntimeDeps` — the thin, library-level helper a plugin uses to DECLARE one shared
3
+ * runtime-dependency root and then reach it: the item, the session notices and the loader.
4
+ *
5
+ * It is sugar over two members this package already exports, never a second implementation of them:
6
+ *
7
+ * - `runtimeDepsProvider()` (interface v5) owns the content-addressed store, the install attempts,
8
+ * verify, peer linking and failure reporting;
9
+ * - `createSessionNotices()` (interface v6) owns the once-per-session notice bookkeeping.
10
+ *
11
+ * What the helper adds is the wiring in between, and nothing else:
12
+ *
13
+ * - **one item** built from the caller's spec — `kind: 'runtime-deps'`, the canonical
14
+ * `<home>/deps/runtime` store, this build's item schema. Base names no package;
15
+ * - **notices bound to the ITEM's real state**: each {@link SessionNoticeSpec.when} is mapped onto the
16
+ * provisioner's own `resolve(item.id)`, re-read on every `consume` — so a root that healed stops
17
+ * matching, exactly as the registry requires;
18
+ * - **`ensure()` / `resolve()`** as the caller's handle onto the provisioner it already owns (the
19
+ * plugin still declares the item and still calls `ensure`; the helper only narrows the answer to
20
+ * this item);
21
+ * - **`load()`**, which resolves a specifier from the shared root and imports it — it COPIES NO
22
+ * PACKAGE and writes nothing, including no `node_modules`.
23
+ *
24
+ * Deliberately absent: no hook registration, no configuration reading, no filesystem access at
25
+ * construction, no session bookkeeping of its own. Base stays a library, and the plugin stays the only
26
+ * DSH lifecycle owner.
27
+ *
28
+ * @module runtime_deps_helper
29
+ */
30
+ import { createRequire } from 'node:module';
31
+ import { join } from 'node:path';
32
+ import { pathToFileURL } from 'node:url';
33
+ import { ProvisionError } from './errors.js';
34
+ import { ITEM_SCHEMA_VERSION } from './provisioner.js';
35
+ import { RUNTIME_DEPS_DIR, RUNTIME_DEPS_KIND } from './providers/runtime_deps.js';
36
+ import { createSessionNotices } from './session_notices.js';
37
+ /** `plugin` namespaces this build accepts in an item id: anything but `:`, `/` and whitespace. */
38
+ const NAMESPACE = /^[^\s:/]+$/u;
39
+ /** The observed states one {@link SessionNoticeSpec.when} covers. */
40
+ function matchesWhen(when, state) {
41
+ if (state === undefined)
42
+ return false;
43
+ switch (state.state) {
44
+ case 'failed':
45
+ return when === 'failed' || when === 'degraded';
46
+ case 'missing':
47
+ case 'skipped':
48
+ return when === 'missing' || when === 'degraded';
49
+ // `pending` is deliberately NOT a notice: the install/job is still in flight, and a latch taken
50
+ // while it runs would make a "not ready" line permanent for that session even after it succeeds.
51
+ case 'pending':
52
+ case 'ready':
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * The `Resolved` shape of a ready handle.
58
+ *
59
+ * The fields come from the CORE's {@link ResourceHandle}; `entryDir` equals `dir` because the shared
60
+ * store publishes its root as the usable entry (the provider's own `resolvedOf` says the same), and
61
+ * `name` is the item id the caller declared. Nothing is invented: a handle that is not ready has no
62
+ * `Resolved` at all.
63
+ */
64
+ function resolvedOf(state) {
65
+ if (state?.state !== 'ready')
66
+ return undefined;
67
+ const handle = state.handle;
68
+ return {
69
+ key: handle.key,
70
+ name: handle.id,
71
+ version: handle.version ?? '',
72
+ dir: handle.dir,
73
+ entryDir: handle.dir,
74
+ source: handle.source,
75
+ ...(Object.keys(handle.env).length === 0 ? {} : { env: handle.env }),
76
+ };
77
+ }
78
+ /** A declared list, or an empty one: a missing/malformed field is the provider's `invalid-option`. */
79
+ function arrayOf(value) {
80
+ return Array.isArray(value) ? value : [];
81
+ }
82
+ /**
83
+ * Build the handle for one shared runtime-dependency root.
84
+ *
85
+ * @param config - the plugin's declaration; forwarded to the provider field by field.
86
+ * @throws ProvisionError (`invalid-option`) for a namespace/itemId that cannot make a namespaced item
87
+ * id — a caller bug at construction time, not a provisioning outcome.
88
+ */
89
+ export function createRuntimeDeps(config) {
90
+ const namespace = config.namespace;
91
+ if (typeof namespace !== 'string' || !NAMESPACE.test(namespace)) {
92
+ throw new ProvisionError('invalid-option', `runtime-deps 的 namespace 必须是插件名(不含 :、/ 与空白):${JSON.stringify(namespace)}`);
93
+ }
94
+ const id = config.itemId ?? `${namespace}:runtime-deps`;
95
+ if (typeof id !== 'string' || !id.startsWith(`${namespace}:`) || id.length <= namespace.length + 1) {
96
+ throw new ProvisionError('invalid-option', `runtime-deps 的 itemId 必须以 "${namespace}:" 前缀化且非空:${JSON.stringify(config.itemId)}`);
97
+ }
98
+ // COPIED, field by field, for two reasons: the caller may keep mutating its own config object (the
99
+ // spec is content-key material, so it must be frozen at construction), and a missing field becomes an
100
+ // empty one, which the PROVIDER then rejects as `invalid-option` on the item — a bad declaration
101
+ // degrades to a failed item instead of throwing out of the plugin's mount path.
102
+ const spec = {
103
+ packages: { ...config.packages },
104
+ sources: arrayOf(config.sources).map(source => ({ ...source })),
105
+ attempts: arrayOf(config.attempts).map(attempt => ({ ...attempt })),
106
+ verify: [...arrayOf(config.verify)],
107
+ ...(config.peers === undefined ? {} : { peers: { ...config.peers } }),
108
+ ...(config.lockfile === undefined ? {} : { lockfile: config.lockfile }),
109
+ };
110
+ const item = {
111
+ id,
112
+ kind: RUNTIME_DEPS_KIND,
113
+ spec,
114
+ // The provider's canonical shared store: every plugin declaring the same spec meets in ONE root.
115
+ target: { root: RUNTIME_DEPS_DIR },
116
+ schemaVersion: ITEM_SCHEMA_VERSION,
117
+ };
118
+ /** The provisioner handed to {@link RuntimeDepsHandle.ensure}; notices need it to observe state. */
119
+ let active;
120
+ const stateNow = () => {
121
+ if (active === undefined)
122
+ return undefined;
123
+ try {
124
+ return active.resolve(id);
125
+ }
126
+ catch {
127
+ // `resolve` is a map read and does not throw; a hostile/partial provisioner must not turn a
128
+ // prompt line into a crash either way.
129
+ return undefined;
130
+ }
131
+ };
132
+ const resolveNow = () => resolvedOf(stateNow());
133
+ const notices = createSessionNotices();
134
+ for (const notice of arrayOf(config.notices)) {
135
+ notices.register(notice, () => matchesWhen(notice.when, stateNow()));
136
+ }
137
+ return {
138
+ item,
139
+ notices,
140
+ async ensure(provisioner) {
141
+ active = provisioner;
142
+ const report = await provisioner.ensure({ only: [id] });
143
+ return report.entries.find(entry => entry.id === id);
144
+ },
145
+ resolve: resolveNow,
146
+ async load(specifier) {
147
+ const resolved = resolveNow();
148
+ if (resolved === undefined) {
149
+ throw new ProvisionError('invalid-option', `runtime-deps 句柄尚未就绪:item ${id} 没有共享根(先 ensure,并在 settle 之后再 load)`);
150
+ }
151
+ if (typeof specifier !== 'string' || specifier.trim() === '') {
152
+ throw new ProvisionError('invalid-option', `runtime-deps.load 需要一个非空的 specifier(item ${id})`);
153
+ }
154
+ // The root's own `package.json` is the resolution anchor: `node_modules` under it is what the
155
+ // provider installed, and it is the ONLY tree this import may see.
156
+ const entry = createRequire(join(resolved.dir, 'package.json')).resolve(specifier);
157
+ return (await import(pathToFileURL(entry).href));
158
+ },
159
+ };
160
+ }
161
+ //# sourceMappingURL=runtime_deps_helper.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Session-scoped notices — the generic, in-memory channel through which a plugin tells ONE agent
3
+ * session that one of its optional capabilities is not ready.
4
+ *
5
+ * The defect this exists for is a shape, not a package: a plugin provisions an optional capability
6
+ * (a shared runtime-dependency root, a model cache, an archive) in the BACKGROUND, mounts either way,
7
+ * and then has to say so in the owner's prompt — exactly once, with the caller's own wording, and
8
+ * without ever being able to refuse the mount or to leak a stale line after the capability healed.
9
+ * Every family plugin re-implemented that bookkeeping locally; this is the ONE implementation, so a
10
+ * fix here is a base release and not one rebuild per plugin.
11
+ *
12
+ * The registry deliberately knows nothing else:
13
+ *
14
+ * - **No wording.** {@link SessionNoticeSpec.body} is returned VERBATIM; the registry adds no prefix,
15
+ * no severity label and no punctuation. Base names no package, command or user-facing sentence.
16
+ * - **No state of its own.** `state()` is the caller's predicate and it is evaluated on EVERY
17
+ * {@link SessionNoticeRegistry.consume}: a healed dependency simply stops matching, without the
18
+ * registry having to know what "healed" means.
19
+ * - **No session identity.** The caller supplies the session id and decides which sessions may see a
20
+ * line ({@link SessionNoticeRegistry.consume}'s `ownerOnly`); the registry only remembers, per
21
+ * session id, which notice ids it has already delivered.
22
+ * - **No persistence.** Session ids live in a `Map` for the lifetime of the instance. Nothing here
23
+ * touches the filesystem, a database, `process.env` or any global: a notice is a prompt line, not
24
+ * a record.
25
+ * - **No throwing.** `consume` runs on the prompt path of a live agent; a caller bug or a throwing
26
+ * `state()` must cost one skipped line, never the turn.
27
+ *
28
+ * The registry is a LIBRARY, not a DSH plugin: it registers no hook, mounts nothing and reads no
29
+ * configuration. The caller owns the lifecycle (which hook, which session, which text) and the
30
+ * registry owns only the once-per-session bookkeeping.
31
+ *
32
+ * @module session_notices
33
+ */
34
+ /**
35
+ * One notice a caller may want shown.
36
+ *
37
+ * `id` is the notice's identity for the once-per-session latch, so it must be namespaced by the
38
+ * plugin's own item/plugin prefix (`<plugin>:…`): two plugins sharing a process must not be able to
39
+ * silently suppress each other's line by picking the same id.
40
+ */
41
+ export interface SessionNoticeSpec {
42
+ /** Stable id, namespaced `<plugin>:…`; the unit the once-per-session rule applies to. */
43
+ readonly id: string;
44
+ /** How loud the line is. Ordering only — the registry renders no label for it. */
45
+ readonly severity: 'info' | 'warning' | 'degraded';
46
+ /**
47
+ * Which failure shape the notice describes. The caller's `state()` decides WHETHER it matches; this
48
+ * field is carried so the caller's own wiring (and the runtime-deps helper) can map one spec onto
49
+ * several observed states without inventing a second type.
50
+ */
51
+ readonly when: 'failed' | 'missing' | 'degraded';
52
+ /** The line itself, self-contained (no path into this repository's docs). Returned verbatim. */
53
+ readonly body: string;
54
+ }
55
+ /** The registry {@link createSessionNotices} returns. */
56
+ export interface SessionNoticeRegistry {
57
+ /**
58
+ * Register one notice with the caller's state predicate.
59
+ *
60
+ * `state()` is called on every `consume`, so it must be cheap, total and side-effect free; a throw
61
+ * is a warning and skips that notice for that call (it is NOT latched, so a later consume may still
62
+ * deliver it). Registration is guarded the same way: a malformed spec, or a second registration of
63
+ * the same id, is a warning and is ignored — never a throw, because registration happens on the
64
+ * plugin's mount path.
65
+ */
66
+ register(spec: SessionNoticeSpec, state: () => boolean): void;
67
+ /**
68
+ * The notices that match RIGHT NOW and have not yet been delivered to `sessionId`, merged into one
69
+ * array of bodies: severity first (`degraded` > `warning` > `info`), then registration order.
70
+ *
71
+ * Every returned body is latched for that session id at the moment it is returned, so a notice is
72
+ * emitted at most once per session id even if `state()` flips back to `true` later. `consume` never
73
+ * throws: a throwing `state()` is a warning and that notice is skipped.
74
+ *
75
+ * `ownerOnly: true` is the caller's claim that this call is on behalf of the session that OWNS the
76
+ * plugin's delivery — the family's "the caller owns owner/worker filtering" rule. Only a claimed
77
+ * call is served, so a worker/other session stays quiet by not claiming; the caller that does want a
78
+ * line in every session simply claims from every session (the latch stays per session id either
79
+ * way). A call without the claim returns `[]` and latches nothing.
80
+ */
81
+ consume(sessionId: string, options?: {
82
+ ownerOnly?: boolean;
83
+ }): string[];
84
+ }
85
+ /**
86
+ * Build one session-notice registry.
87
+ *
88
+ * @param logger - optional warning sink. Omitted means diagnostics are dropped (the registry still
89
+ * never throws); a throwing sink is swallowed for the same reason.
90
+ * @returns the registry; every member is total.
91
+ */
92
+ export declare function createSessionNotices(logger?: {
93
+ warn(message: string): void;
94
+ }): SessionNoticeRegistry;
95
+ //# sourceMappingURL=session_notices.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session_notices.d.ts","sourceRoot":"","sources":["../src/session_notices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,yFAAyF;IACzF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAAA;IAClD;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAA;IAChD,gGAAgG;IAChG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,yDAAyD;AACzD,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,OAAO,GAAG,IAAI,CAAA;IAC7D;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM,EAAE,CAAA;CACxE;AA6CD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,qBAAqB,CA+FpG"}
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Session-scoped notices — the generic, in-memory channel through which a plugin tells ONE agent
3
+ * session that one of its optional capabilities is not ready.
4
+ *
5
+ * The defect this exists for is a shape, not a package: a plugin provisions an optional capability
6
+ * (a shared runtime-dependency root, a model cache, an archive) in the BACKGROUND, mounts either way,
7
+ * and then has to say so in the owner's prompt — exactly once, with the caller's own wording, and
8
+ * without ever being able to refuse the mount or to leak a stale line after the capability healed.
9
+ * Every family plugin re-implemented that bookkeeping locally; this is the ONE implementation, so a
10
+ * fix here is a base release and not one rebuild per plugin.
11
+ *
12
+ * The registry deliberately knows nothing else:
13
+ *
14
+ * - **No wording.** {@link SessionNoticeSpec.body} is returned VERBATIM; the registry adds no prefix,
15
+ * no severity label and no punctuation. Base names no package, command or user-facing sentence.
16
+ * - **No state of its own.** `state()` is the caller's predicate and it is evaluated on EVERY
17
+ * {@link SessionNoticeRegistry.consume}: a healed dependency simply stops matching, without the
18
+ * registry having to know what "healed" means.
19
+ * - **No session identity.** The caller supplies the session id and decides which sessions may see a
20
+ * line ({@link SessionNoticeRegistry.consume}'s `ownerOnly`); the registry only remembers, per
21
+ * session id, which notice ids it has already delivered.
22
+ * - **No persistence.** Session ids live in a `Map` for the lifetime of the instance. Nothing here
23
+ * touches the filesystem, a database, `process.env` or any global: a notice is a prompt line, not
24
+ * a record.
25
+ * - **No throwing.** `consume` runs on the prompt path of a live agent; a caller bug or a throwing
26
+ * `state()` must cost one skipped line, never the turn.
27
+ *
28
+ * The registry is a LIBRARY, not a DSH plugin: it registers no hook, mounts nothing and reads no
29
+ * configuration. The caller owns the lifecycle (which hook, which session, which text) and the
30
+ * registry owns only the once-per-session bookkeeping.
31
+ *
32
+ * @module session_notices
33
+ */
34
+ /** The severity order of {@link SessionNoticeSpec.severity}, loudest first. */
35
+ const SEVERITY_RANK = {
36
+ degraded: 0,
37
+ warning: 1,
38
+ info: 2,
39
+ };
40
+ /** The severities this build understands (a JS caller can hand over anything). */
41
+ const SEVERITIES = ['info', 'warning', 'degraded'];
42
+ /** The conditions this build understands. */
43
+ const CONDITIONS = ['failed', 'missing', 'degraded'];
44
+ /** `<plugin>:<rest>` — a notice id must be namespaced, see {@link SessionNoticeSpec.id}. */
45
+ const NAMESPACED_ID = /^[^\s:]+:\S+$/u;
46
+ /** Why a spec cannot be registered, or `undefined` when it can. Reads every field once. */
47
+ function specProblem(spec, state) {
48
+ if (typeof spec !== 'object' || spec === null)
49
+ return 'spec 必须是对象';
50
+ const candidate = spec;
51
+ if (typeof candidate.id !== 'string' || !NAMESPACED_ID.test(candidate.id)) {
52
+ return `id 必须以 "<plugin>:" 前缀化(收到 ${JSON.stringify(candidate.id)})`;
53
+ }
54
+ if (typeof candidate.severity !== 'string' || !SEVERITIES.includes(candidate.severity)) {
55
+ return `severity 只能是 ${SEVERITIES.join(' / ')}(收到 ${JSON.stringify(candidate.severity)})`;
56
+ }
57
+ if (typeof candidate.when !== 'string' || !CONDITIONS.includes(candidate.when)) {
58
+ return `when 只能是 ${CONDITIONS.join(' / ')}(收到 ${JSON.stringify(candidate.when)})`;
59
+ }
60
+ if (typeof candidate.body !== 'string' || candidate.body.trim() === '')
61
+ return 'body 必须是非空字符串';
62
+ if (typeof state !== 'function')
63
+ return 'state 必须是函数';
64
+ return undefined;
65
+ }
66
+ /**
67
+ * Build one session-notice registry.
68
+ *
69
+ * @param logger - optional warning sink. Omitted means diagnostics are dropped (the registry still
70
+ * never throws); a throwing sink is swallowed for the same reason.
71
+ * @returns the registry; every member is total.
72
+ */
73
+ export function createSessionNotices(logger) {
74
+ const entries = [];
75
+ /** notice id → the session ids it was already delivered to (the once-per-session latch). */
76
+ const delivered = new Map();
77
+ const warn = (message) => {
78
+ try {
79
+ logger?.warn(message);
80
+ }
81
+ catch {
82
+ // A logging failure must never break the prompt path this registry exists to serve.
83
+ }
84
+ };
85
+ const reasonOf = (error) => {
86
+ try {
87
+ if (error instanceof Error && typeof error.message === 'string' && error.message !== '')
88
+ return error.message;
89
+ return String(error);
90
+ }
91
+ catch {
92
+ return 'unknown';
93
+ }
94
+ };
95
+ const wasDelivered = (id, sessionId) => delivered.get(id)?.has(sessionId) === true;
96
+ const latch = (id, sessionId) => {
97
+ let sessions = delivered.get(id);
98
+ if (sessions === undefined) {
99
+ sessions = new Set();
100
+ delivered.set(id, sessions);
101
+ }
102
+ sessions.add(sessionId);
103
+ };
104
+ return {
105
+ register(spec, state) {
106
+ try {
107
+ const problem = specProblem(spec, state);
108
+ if (problem !== undefined) {
109
+ warn(`session-notices: 忽略一个非法 notice — ${problem}`);
110
+ return;
111
+ }
112
+ if (entries.some(entry => entry.id === spec.id)) {
113
+ // "An id is emitted at most once" only has one meaning: the first registration owns the id.
114
+ warn(`session-notices: notice id 重复,保留先注册的那个 — ${spec.id}`);
115
+ return;
116
+ }
117
+ // Snapshot the delivery fields: the registry's decisions (ordering and the returned body) must
118
+ // not change because the caller later mutated the object it registered. `when` is validated
119
+ // above but belongs to the caller's own mapping — the registry matches on `state()` alone.
120
+ entries.push({
121
+ id: spec.id,
122
+ severity: spec.severity,
123
+ body: spec.body,
124
+ state,
125
+ order: entries.length,
126
+ });
127
+ }
128
+ catch (error) {
129
+ warn(`session-notices: 注册 notice 失败,已忽略(${reasonOf(error)})`);
130
+ }
131
+ },
132
+ consume(sessionId, options) {
133
+ try {
134
+ if (typeof sessionId !== 'string' || sessionId === '') {
135
+ warn('session-notices: consume 需要一个非空的 session id');
136
+ return [];
137
+ }
138
+ if (options?.ownerOnly !== true)
139
+ return [];
140
+ const matched = [];
141
+ for (const entry of entries) {
142
+ if (wasDelivered(entry.id, sessionId))
143
+ continue;
144
+ let active;
145
+ try {
146
+ active = entry.state();
147
+ }
148
+ catch (error) {
149
+ // Skipped, NOT latched: the predicate failed, which says nothing about the notice itself.
150
+ warn(`session-notices: notice ${entry.id} 的 state() 抛错,本次跳过(${reasonOf(error)})`);
151
+ continue;
152
+ }
153
+ if (active !== true)
154
+ continue;
155
+ matched.push({ id: entry.id, body: entry.body, rank: SEVERITY_RANK[entry.severity], order: entry.order });
156
+ }
157
+ matched.sort((left, right) => left.rank - right.rank || left.order - right.order);
158
+ const lines = [];
159
+ for (const item of matched) {
160
+ lines.push(item.body);
161
+ latch(item.id, sessionId);
162
+ }
163
+ return lines;
164
+ }
165
+ catch (error) {
166
+ warn(`session-notices: consume 失败,本次不返回任何 notice(${reasonOf(error)})`);
167
+ return [];
168
+ }
169
+ },
170
+ };
171
+ }
172
+ //# sourceMappingURL=session_notices.js.map
package/lib/state.d.ts CHANGED
@@ -58,7 +58,14 @@ export type StatusRead = {
58
58
  } | {
59
59
  readonly kind: 'unreadable';
60
60
  };
61
- /** Read `status.json`; a newer schema yields `too-new`. */
61
+ /**
62
+ * Read `status.json`; a newer schema yields `too-new`.
63
+ *
64
+ * A STRUCTURALLY damaged file (rows not an array, a row that is not an object, a row without
65
+ * `key`/`version`) is `unreadable`, never `ok` with fewer rows: a dropped row silently drops a
66
+ * reference, and a reader that treats the result as the complete reference list (GC) would then
67
+ * delete a live root. `unreadable` is the "cannot tell" the callers already know how to handle.
68
+ */
62
69
  export declare function readStatus(fs: ProvisionFs, home: string): Promise<StatusRead>;
63
70
  /** Merge two row sets by `key × version`; newer `updated_at` wins, `items`/`plugins` are unioned. */
64
71
  export declare function mergeRows(current: readonly StatusRow[], incoming: readonly StatusRow[]): readonly StatusRow[];
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"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"}
package/lib/state.js CHANGED
@@ -52,7 +52,14 @@ export async function writeLayout(fs, home, layout, writtenBy) {
52
52
  const file = { schemaVersion: LAYOUT_SCHEMA_VERSION, layout, writtenBy };
53
53
  await fs.atomicWrite(layoutPath(home), new TextEncoder().encode(`${JSON.stringify(file, null, 2)}\n`));
54
54
  }
55
- /** Read `status.json`; a newer schema yields `too-new`. */
55
+ /**
56
+ * Read `status.json`; a newer schema yields `too-new`.
57
+ *
58
+ * A STRUCTURALLY damaged file (rows not an array, a row that is not an object, a row without
59
+ * `key`/`version`) is `unreadable`, never `ok` with fewer rows: a dropped row silently drops a
60
+ * reference, and a reader that treats the result as the complete reference list (GC) would then
61
+ * delete a live root. `unreadable` is the "cannot tell" the callers already know how to handle.
62
+ */
56
63
  export async function readStatus(fs, home) {
57
64
  const path = statusPath(home);
58
65
  if (!(await exists(fs, path)))
@@ -63,15 +70,17 @@ export async function readStatus(fs, home) {
63
70
  const schemaVersion = typeof raw['schemaVersion'] === 'number' ? raw['schemaVersion'] : 0;
64
71
  if (schemaVersion > STATUS_SCHEMA_VERSION)
65
72
  return { kind: 'too-new' };
66
- const rows = Array.isArray(raw['rows']) ? raw['rows'] : [];
73
+ const rows = raw['rows'];
74
+ if (!Array.isArray(rows))
75
+ return { kind: 'unreadable' };
67
76
  const parsed = [];
68
77
  for (const row of rows) {
69
78
  if (!isRecord(row))
70
- continue;
79
+ return { kind: 'unreadable' };
71
80
  const key = str(row['key']);
72
81
  const version = typeof row['version'] === 'string' ? row['version'] : undefined;
73
82
  if (key === undefined || version === undefined)
74
- continue;
83
+ return { kind: 'unreadable' };
75
84
  const lastError = isRecord(row['last_error']) ? row['last_error'] : undefined;
76
85
  const code = lastError === undefined ? undefined : str(lastError['code']);
77
86
  const detail = lastError === undefined ? undefined : str(lastError['detail']);
@@ -165,6 +174,13 @@ export async function persistStatus(fs, lock, home, rows, logger) {
165
174
  const existing = await readStatus(fs, home);
166
175
  if (existing.kind === 'too-new')
167
176
  return 'too-new';
177
+ // A structurally damaged file is NOT an empty one: overwriting it would drop the rows of every
178
+ // other plugin (and with them the references GC trusts), so it is left untouched and reported
179
+ // the same way an unreadable/corrupt file already was.
180
+ if (existing.kind === 'unreadable') {
181
+ logger.warn(`state: ${statusPath(home)} 结构不可读,本次不覆盖(保留原文,状态面按只读处理)`);
182
+ return 'read-only';
183
+ }
168
184
  const base = existing.kind === 'ok' ? existing.rows : [];
169
185
  const merged = mergeRows(base, rows);
170
186
  const file = { schemaVersion: STATUS_SCHEMA_VERSION, rows: merged };
package/lib/types.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import type { ProvisionCode } from './errors.js';
6
6
  /** The kinds the framework ships providers for; these names are reserved.
7
7
  * @stable */
8
- export type BuiltinKind = 'npm-package' | 'binary-archive' | 'model-cache';
8
+ export type BuiltinKind = 'npm-package' | 'binary-archive' | 'model-cache' | 'runtime-deps';
9
9
  /** A kind is a namespaced string; third-party kinds must carry a prefix.
10
10
  * @stable */
11
11
  export type Kind = BuiltinKind | (string & {});
@@ -260,6 +260,13 @@ export interface ProvisionFs {
260
260
  readlink(path: string): Promise<string | undefined>;
261
261
  copyFile(from: string, to: string): Promise<void>;
262
262
  chmod(path: string, mode: number): Promise<void>;
263
+ /** The canonical path with every symlink resolved.
264
+ *
265
+ * Optional, and absent means "this seam cannot tell": a provider that identifies a directory
266
+ * physically (the same host copy must name one shared root, however it was spelled) uses it when
267
+ * present and falls back to the spelled path when not. It is read-only, so an fs that cannot
268
+ * answer costs nothing but a weaker identity — never a failed install. */
269
+ realpath?(path: string): Promise<string>;
263
270
  }
264
271
  /** Control-plane mutual exclusion; the default is `<home>/.envinit/.lock`.
265
272
  * @stable */
@@ -330,6 +337,12 @@ export interface Resolved {
330
337
  readonly source: ResourceSource;
331
338
  /** Environment additions for the handle (a managed binary's PATH). */
332
339
  readonly env?: Readonly<Record<string, string>>;
340
+ /**
341
+ * `true` when `install()` found an equivalent root already published (another process wrote it
342
+ * while this one waited) and reused it: nothing was placed by this call, so the core reports the
343
+ * item as `present` instead of `installed`. Omitted = the provider placed what it returned.
344
+ */
345
+ readonly reused?: boolean;
333
346
  }
334
347
  /** @stable */
335
348
  export interface ProviderContext {