@crouter/api 0.3.386 → 0.3.388

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 (130) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/core/asset-root.d.ts +7 -0
  5. package/dist/core/asset-root.js +18 -0
  6. package/dist/core/canvas/boot-id.d.ts +6 -0
  7. package/dist/core/canvas/boot-id.js +26 -0
  8. package/dist/core/canvas/paths.d.ts +72 -0
  9. package/dist/core/canvas/paths.js +163 -0
  10. package/dist/core/canvas/pid.d.ts +391 -0
  11. package/dist/core/canvas/pid.js +948 -0
  12. package/dist/core/command-plugins/bundle.d.ts +149 -0
  13. package/dist/core/command-plugins/bundle.js +588 -0
  14. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  15. package/dist/core/command-plugins/endpoint.js +51 -0
  16. package/dist/core/config.d.ts +233 -0
  17. package/dist/core/config.js +1120 -0
  18. package/dist/core/env-name.d.ts +6 -0
  19. package/dist/core/env-name.js +9 -0
  20. package/dist/core/errors.d.ts +38 -0
  21. package/dist/core/errors.js +90 -0
  22. package/dist/core/events/emit.d.ts +6 -0
  23. package/dist/core/events/emit.js +42 -0
  24. package/dist/core/events/envelope.d.ts +2 -0
  25. package/dist/core/events/envelope.js +84 -0
  26. package/dist/core/events/errors.d.ts +4 -0
  27. package/dist/core/events/errors.js +69 -0
  28. package/dist/core/events/operation-id.d.ts +4 -0
  29. package/dist/core/events/operation-id.js +24 -0
  30. package/dist/core/events/serialize.d.ts +4 -0
  31. package/dist/core/events/serialize.js +199 -0
  32. package/dist/core/events/source.d.ts +16 -0
  33. package/dist/core/events/source.js +31 -0
  34. package/dist/core/events/types.d.ts +68 -0
  35. package/dist/core/events/types.js +11 -0
  36. package/dist/core/exclusive-lock.d.ts +34 -0
  37. package/dist/core/exclusive-lock.js +197 -0
  38. package/dist/core/fs-utils.d.ts +44 -0
  39. package/dist/core/fs-utils.js +208 -0
  40. package/dist/core/help.d.ts +309 -0
  41. package/dist/core/help.js +406 -0
  42. package/dist/core/human/page-catalog.d.ts +57 -0
  43. package/dist/core/human/page-catalog.js +172 -0
  44. package/dist/core/installed-plugins.d.ts +2 -0
  45. package/dist/core/installed-plugins.js +79 -0
  46. package/dist/core/io.d.ts +122 -0
  47. package/dist/core/io.js +373 -0
  48. package/dist/core/keybindings/attach-control.d.ts +49 -0
  49. package/dist/core/keybindings/attach-control.js +42 -0
  50. package/dist/core/keybindings/catalog.d.ts +18 -0
  51. package/dist/core/keybindings/catalog.js +257 -0
  52. package/dist/core/keybindings/types.d.ts +42 -0
  53. package/dist/core/keybindings/types.js +1 -0
  54. package/dist/core/layout.d.ts +26 -0
  55. package/dist/core/layout.js +94 -0
  56. package/dist/core/locked-file.d.ts +27 -0
  57. package/dist/core/locked-file.js +118 -0
  58. package/dist/core/log.d.ts +9 -0
  59. package/dist/core/log.js +89 -0
  60. package/dist/core/manifest.d.ts +5 -0
  61. package/dist/core/manifest.js +15 -0
  62. package/dist/core/plugin-env.d.ts +8 -0
  63. package/dist/core/plugin-env.js +31 -0
  64. package/dist/core/plugin-extensions.d.ts +29 -0
  65. package/dist/core/plugin-extensions.js +191 -0
  66. package/dist/core/plugin-swap-lock.d.ts +9 -0
  67. package/dist/core/plugin-swap-lock.js +31 -0
  68. package/dist/core/preview-result-path.d.ts +4 -0
  69. package/dist/core/preview-result-path.js +26 -0
  70. package/dist/core/profiles/env-store.d.ts +22 -0
  71. package/dist/core/profiles/env-store.js +163 -0
  72. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  73. package/dist/core/profiles/fuzzy-match.js +92 -0
  74. package/dist/core/profiles/manifest.d.ts +120 -0
  75. package/dist/core/profiles/manifest.js +529 -0
  76. package/dist/core/rate-limit-scope.d.ts +25 -0
  77. package/dist/core/rate-limit-scope.js +64 -0
  78. package/dist/core/render.d.ts +12 -0
  79. package/dist/core/render.js +138 -0
  80. package/dist/core/resolver.d.ts +14 -0
  81. package/dist/core/resolver.js +111 -0
  82. package/dist/core/runtime/branded-host.d.ts +25 -0
  83. package/dist/core/runtime/branded-host.js +264 -0
  84. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  85. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  86. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  87. package/dist/core/runtime/broker/signal-stream.js +149 -0
  88. package/dist/core/scope.d.ts +32 -0
  89. package/dist/core/scope.js +184 -0
  90. package/dist/core/scoped-state/db.d.ts +17 -0
  91. package/dist/core/scoped-state/db.js +247 -0
  92. package/dist/core/scoped-state/migrate.d.ts +8 -0
  93. package/dist/core/scoped-state/migrate.js +187 -0
  94. package/dist/core/scoped-state/paths.d.ts +9 -0
  95. package/dist/core/scoped-state/paths.js +27 -0
  96. package/dist/core/scoped-state/profiles.d.ts +27 -0
  97. package/dist/core/scoped-state/profiles.js +93 -0
  98. package/dist/core/scoped-state/providers.d.ts +24 -0
  99. package/dist/core/scoped-state/providers.js +19 -0
  100. package/dist/core/scoped-state/schema.d.ts +6 -0
  101. package/dist/core/scoped-state/schema.js +43 -0
  102. package/dist/core/scoped-state/settings.d.ts +28 -0
  103. package/dist/core/scoped-state/settings.js +83 -0
  104. package/dist/core/spaces/open-beneath.d.ts +71 -0
  105. package/dist/core/spaces/open-beneath.js +581 -0
  106. package/dist/core/sqlite-statements.d.ts +4 -0
  107. package/dist/core/sqlite-statements.js +17 -0
  108. package/dist/core/subscription-state.d.ts +121 -0
  109. package/dist/core/subscription-state.js +287 -0
  110. package/dist/core/user-settings.d.ts +377 -0
  111. package/dist/core/user-settings.js +458 -0
  112. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  113. package/dist/daemon/broker-signals/bus.js +87 -0
  114. package/dist/daemon/manage.d.ts +176 -0
  115. package/dist/daemon/manage.js +664 -0
  116. package/dist/daemon/pidfile.d.ts +8 -0
  117. package/dist/daemon/pidfile.js +37 -0
  118. package/dist/daemon/startup-policy.d.ts +1 -0
  119. package/dist/daemon/startup-policy.js +1 -0
  120. package/dist/native/linux.d.ts +29 -0
  121. package/dist/native/linux.js +20 -0
  122. package/dist/shared/env.d.ts +116 -0
  123. package/dist/shared/env.js +271 -0
  124. package/dist/shared/inbox-entry-body.d.ts +22 -0
  125. package/dist/shared/inbox-entry-body.js +116 -0
  126. package/dist/shared/working-activity.d.ts +9 -0
  127. package/dist/shared/working-activity.js +27 -0
  128. package/dist/types.d.ts +562 -0
  129. package/dist/types.js +186 -0
  130. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ // locked-file.ts — crouter's cross-process locked read/modify/write primitive for the
2
+ // small JSON state files the runtime co-owns with pi: pi's own `auth.json` (api keys and
3
+ // non-managed OAuth credentials) and crouter's per-provider subscription pool files, which
4
+ // hold every managed account's own credential.
5
+ //
6
+ // The lock is `proper-lockfile` on the target path, so a crouter process and a pi process
7
+ // contending for `auth.json` still exclude each other: same library, same lock path, and
8
+ // same `realpath: false` option. Every callback runs asynchronously and returns
9
+ // `{ result, next }`; `next === undefined` means "no write". Every write lands by atomic
10
+ // tmp+rename (pool callers do their own, inside the callback), so a whole snapshot is the
11
+ // only thing on disk and READS need no lock at all -- `readUnlocked` serves them. That
12
+ // matters: pi asks its CredentialStore for every registered provider (~75) whenever it
13
+ // builds a model runtime, and routing those through the lock made each boot serialize
14
+ // behind seconds of lock acquisition backoff.
15
+ import { chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, closeSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import lockfile from 'proper-lockfile';
18
+ const WRITE_OPTIONS = { encoding: 'utf-8', mode: 0o600 };
19
+ // proper-lockfile decides a held lock is abandoned when its lockdir mtime is older than
20
+ // the acquirer's `stale`, and refreshes the mtime on the holder's `update` timer. The holder
21
+ // refreshes well inside the staleness threshold so long-running callbacks remain protected.
22
+ const LOCK_STALE_MS = 30_000;
23
+ const LOCK_UPDATE_MS = 5_000;
24
+ /** A 0600 JSON file guarded by a cross-process `proper-lockfile` lock on its own path. */
25
+ export class LockedJsonFile {
26
+ path;
27
+ durable;
28
+ initialContent;
29
+ constructor(path, durable = false, initialContent = '{}') {
30
+ this.path = path;
31
+ this.durable = durable;
32
+ this.initialContent = initialContent;
33
+ }
34
+ readUnlocked() {
35
+ return existsSync(this.path) ? readFileSync(this.path, 'utf-8') : undefined;
36
+ }
37
+ ensureFile() {
38
+ const dir = dirname(this.path);
39
+ if (!existsSync(dir))
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ if (!existsSync(this.path)) {
42
+ writeFileSync(this.path, this.initialContent, WRITE_OPTIONS);
43
+ chmodSync(this.path, 0o600);
44
+ }
45
+ }
46
+ /** Acquire the lock without blocking the event loop, keeping every read-modify-write
47
+ * callback on the same asynchronous path. */
48
+ async withLockAsync(fn) {
49
+ this.ensureFile();
50
+ let release;
51
+ let compromised;
52
+ const throwIfCompromised = () => {
53
+ if (compromised)
54
+ throw compromised;
55
+ };
56
+ try {
57
+ release = await lockfile.lock(this.path, {
58
+ // A different realpath setting would derive a DIFFERENT lock path for a symlinked
59
+ // auth.json and silently stop excluding other writers.
60
+ realpath: false,
61
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
62
+ stale: LOCK_STALE_MS,
63
+ // Refresh the lockdir mtime well inside `stale` so long-running callbacks remain protected.
64
+ update: LOCK_UPDATE_MS,
65
+ onCompromised: (err) => {
66
+ compromised = err;
67
+ },
68
+ });
69
+ throwIfCompromised();
70
+ const current = existsSync(this.path) ? readFileSync(this.path, 'utf-8') : undefined;
71
+ let published = false;
72
+ const publish = (next) => {
73
+ if (published)
74
+ throw new Error(`locked file was published twice: ${this.path}`);
75
+ throwIfCompromised();
76
+ const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
77
+ writeFileSync(tmp, next, WRITE_OPTIONS);
78
+ chmodSync(tmp, 0o600);
79
+ if (this.durable) {
80
+ const fd = openSync(tmp, 'r');
81
+ try {
82
+ fsyncSync(fd);
83
+ }
84
+ finally {
85
+ closeSync(fd);
86
+ }
87
+ }
88
+ renameSync(tmp, this.path);
89
+ if (this.durable) {
90
+ const fd = openSync(dirname(this.path), 'r');
91
+ try {
92
+ fsyncSync(fd);
93
+ }
94
+ finally {
95
+ closeSync(fd);
96
+ }
97
+ }
98
+ published = true;
99
+ };
100
+ const { result, next } = await fn(current, throwIfCompromised, publish);
101
+ throwIfCompromised();
102
+ if (next !== undefined)
103
+ publish(next);
104
+ throwIfCompromised();
105
+ return result;
106
+ }
107
+ finally {
108
+ if (release) {
109
+ try {
110
+ await release();
111
+ }
112
+ catch {
113
+ // A compromised lock's release throws; the compromise itself already surfaced.
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,9 @@
1
+ export declare const MAX_ROTATING_RECORD_BYTES = 65536;
2
+ /** Synchronously appends one complete NDJSON record, retaining active + .1 + .2. */
3
+ export declare function appendRotatingRecord(path: string, recordWithoutLf: string): boolean;
4
+ /** One timed/bounded daemon batch instead of a mkdir/stat/open/write per event.
5
+ * Process 'exit' drains synchronously even after an uncaught exception; SIGKILL
6
+ * and a host power loss cannot run an exit handler. */
7
+ export declare function appendBufferedDaemonRecord(path: string, recordWithoutLf: string): boolean;
8
+ /** Flush the daemon batch; also invoked synchronously on process exit. */
9
+ export declare function flushDaemonLog(): void;
@@ -0,0 +1,89 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ export const MAX_ROTATING_RECORD_BYTES = 65_536;
4
+ const MAX_ACTIVE_BYTES = 5 * 1024 * 1024;
5
+ const MAX_PENDING_BYTES = 128 * 1024;
6
+ const FLUSH_INTERVAL_MS = 200;
7
+ /** Append complete records in batches, rotating at record boundaries. */
8
+ function appendRotatingRecords(path, records) {
9
+ let pending = '';
10
+ try {
11
+ mkdirSync(dirname(path), { recursive: true });
12
+ let currentBytes = existsSync(path) ? statSync(path).size : 0;
13
+ for (const record of records) {
14
+ const line = `${record}\n`;
15
+ const lineBytes = Buffer.byteLength(line, 'utf8');
16
+ if (currentBytes + lineBytes > MAX_ACTIVE_BYTES && currentBytes > 0) {
17
+ if (pending !== '') {
18
+ appendFileSync(path, pending, 'utf8');
19
+ pending = '';
20
+ }
21
+ rmSync(`${path}.2`, { force: true });
22
+ if (existsSync(`${path}.1`))
23
+ renameSync(`${path}.1`, `${path}.2`);
24
+ renameSync(path, `${path}.1`);
25
+ currentBytes = 0;
26
+ }
27
+ pending += line;
28
+ currentBytes += lineBytes;
29
+ }
30
+ if (pending !== '')
31
+ appendFileSync(path, pending, 'utf8');
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** Synchronously appends one complete NDJSON record, retaining active + .1 + .2. */
39
+ export function appendRotatingRecord(path, recordWithoutLf) {
40
+ if (recordWithoutLf.includes('\n') || recordWithoutLf.includes('\r'))
41
+ return false;
42
+ if (Buffer.byteLength(recordWithoutLf, 'utf8') + 1 > MAX_ROTATING_RECORD_BYTES)
43
+ return false;
44
+ return appendRotatingRecords(path, [recordWithoutLf]);
45
+ }
46
+ let daemonBuffer;
47
+ let exitDrainInstalled = false;
48
+ /** One timed/bounded daemon batch instead of a mkdir/stat/open/write per event.
49
+ * Process 'exit' drains synchronously even after an uncaught exception; SIGKILL
50
+ * and a host power loss cannot run an exit handler. */
51
+ export function appendBufferedDaemonRecord(path, recordWithoutLf) {
52
+ if (recordWithoutLf.includes('\n') || recordWithoutLf.includes('\r'))
53
+ return false;
54
+ const bytes = Buffer.byteLength(recordWithoutLf, 'utf8') + 1;
55
+ if (bytes > MAX_ROTATING_RECORD_BYTES)
56
+ return false;
57
+ if (daemonBuffer !== undefined && daemonBuffer.path !== path)
58
+ flushDaemonLog();
59
+ if (daemonBuffer === undefined) {
60
+ daemonBuffer = { path, records: [], bytes: 0 };
61
+ if (!exitDrainInstalled) {
62
+ process.on('exit', flushDaemonLog);
63
+ exitDrainInstalled = true;
64
+ }
65
+ }
66
+ daemonBuffer.path = path;
67
+ daemonBuffer.records.push(recordWithoutLf);
68
+ daemonBuffer.bytes += bytes;
69
+ if (daemonBuffer.bytes >= MAX_PENDING_BYTES)
70
+ flushDaemonLog();
71
+ else if (daemonBuffer.timer === undefined) {
72
+ daemonBuffer.timer = setTimeout(flushDaemonLog, FLUSH_INTERVAL_MS);
73
+ daemonBuffer.timer.unref();
74
+ }
75
+ return true;
76
+ }
77
+ /** Flush the daemon batch; also invoked synchronously on process exit. */
78
+ export function flushDaemonLog() {
79
+ const buffer = daemonBuffer;
80
+ if (buffer === undefined || buffer.records.length === 0)
81
+ return;
82
+ if (buffer.timer !== undefined)
83
+ clearTimeout(buffer.timer);
84
+ daemonBuffer = undefined;
85
+ if (!appendRotatingRecords(buffer.path, buffer.records)) {
86
+ for (const record of buffer.records)
87
+ process.stderr.write(`[crtrd] event log append failed: ${record}\n`);
88
+ }
89
+ }
@@ -0,0 +1,5 @@
1
+ import type { MarketplaceManifest, PluginManifest } from '../types.js';
2
+ export declare function pluginManifestPath(pluginRoot: string): string;
3
+ export declare function marketplaceManifestPath(mktRoot: string): string;
4
+ export declare function readPluginManifest(pluginRoot: string): PluginManifest | null;
5
+ export declare function readMarketplaceManifest(mktRoot: string): MarketplaceManifest | null;
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path';
2
+ import { MARKETPLACE_MANIFEST_DIR, MARKETPLACE_MANIFEST_FILE, PLUGIN_MANIFEST_DIR, PLUGIN_MANIFEST_FILE, } from '../types.js';
3
+ import { readJsonIfExists } from './fs-utils.js';
4
+ export function pluginManifestPath(pluginRoot) {
5
+ return join(pluginRoot, PLUGIN_MANIFEST_DIR, PLUGIN_MANIFEST_FILE);
6
+ }
7
+ export function marketplaceManifestPath(mktRoot) {
8
+ return join(mktRoot, MARKETPLACE_MANIFEST_DIR, MARKETPLACE_MANIFEST_FILE);
9
+ }
10
+ export function readPluginManifest(pluginRoot) {
11
+ return readJsonIfExists(pluginManifestPath(pluginRoot));
12
+ }
13
+ export function readMarketplaceManifest(mktRoot) {
14
+ return readJsonIfExists(marketplaceManifestPath(mktRoot));
15
+ }
@@ -0,0 +1,8 @@
1
+ export interface PluginEnvDeclaration {
2
+ description: string;
3
+ required?: boolean;
4
+ secret?: boolean;
5
+ url?: string;
6
+ }
7
+ export declare function invalidPluginEnvReasons(raw: unknown): string[];
8
+ export declare function requiredPluginEnv(raw: Record<string, PluginEnvDeclaration> | undefined): string[];
@@ -0,0 +1,31 @@
1
+ import { validateEnvVarName } from './env-name.js';
2
+ import { isReservedEnvName } from './profiles/env-store.js';
3
+ export function invalidPluginEnvReasons(raw) {
4
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
5
+ return ['env must be an object mapping variable names to declarations'];
6
+ const reasons = [];
7
+ for (const [name, declaration] of Object.entries(raw)) {
8
+ if (!validateEnvVarName(name).isValid || isReservedEnvName(name))
9
+ reasons.push(`env.${name} has an invalid or reserved name`);
10
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
11
+ reasons.push(`env.${name} must be an object`);
12
+ continue;
13
+ }
14
+ const entry = declaration;
15
+ if (typeof entry['description'] !== 'string' || !entry['description'].trim())
16
+ reasons.push(`env.${name}.description must be a non-empty string`);
17
+ if (entry['required'] !== undefined && typeof entry['required'] !== 'boolean')
18
+ reasons.push(`env.${name}.required must be boolean`);
19
+ if (entry['secret'] !== undefined && typeof entry['secret'] !== 'boolean')
20
+ reasons.push(`env.${name}.secret must be boolean`);
21
+ if (entry['url'] !== undefined && (typeof entry['url'] !== 'string' || !/^https:\/\/[^\s]+$/.test(entry['url'])))
22
+ reasons.push(`env.${name}.url must be an https URL`);
23
+ for (const key of Object.keys(entry))
24
+ if (!['description', 'required', 'secret', 'url'].includes(key))
25
+ reasons.push(`env.${name}.${key} is unknown`);
26
+ }
27
+ return reasons;
28
+ }
29
+ export function requiredPluginEnv(raw) {
30
+ return Object.entries(raw ?? {}).filter(([, entry]) => entry.required !== false).map(([name]) => name);
31
+ }
@@ -0,0 +1,29 @@
1
+ import type { MemoryExtensionDeclarations, MemoryExtensionScalar, PluginManifest, Scope } from '../types.js';
2
+ export type MemoryExtensionCatalog = Record<string, MemoryExtensionDeclarations>;
3
+ export interface MemoryExtensionValidationIssue {
4
+ path: string;
5
+ message: string;
6
+ }
7
+ export interface MemoryExtensionCatalogOptions {
8
+ /** The manifest currently being staged. Its namespace shadows an installed copy only for that candidate validation. */
9
+ candidate?: Pick<PluginManifest, 'name' | 'memory_extensions'>;
10
+ }
11
+ export declare function isMemoryExtensionScalar(value: unknown): value is MemoryExtensionScalar;
12
+ /** Parse the closed declaration addendum. Callers that accept a plugin manifest
13
+ * invoke this at their install/preflight boundary; normal runtime catalogs only
14
+ * admit declarations which have already cleared that boundary. */
15
+ export declare function validateMemoryExtensionDeclarations(raw: unknown): MemoryExtensionDeclarations;
16
+ /** Throw when a manifest carries an invalid addendum; absent is valid. */
17
+ export declare function validatePluginMemoryExtensions(manifest: Pick<PluginManifest, 'memory_extensions'>): void;
18
+ /** Declarations for a native document. Project documents use their owning
19
+ * project scope only, then user scope; profile/node/user docs use user scope. */
20
+ /** Installed declarations a package candidate's documents validate against:
21
+ * a project-scope candidate sees its project's plugins, then user scope; any
22
+ * other scope sees user scope. Disabled plugins remain present so existing
23
+ * values can still be checked. The candidate's namespace shadows an installed
24
+ * copy of the same name. */
25
+ export declare function pluginExtensionCatalog(scope: Scope, scopeRootPath: string, options?: MemoryExtensionCatalogOptions): MemoryExtensionCatalog;
26
+ /** Validate the raw `extensions` frontmatter value. Unresolved namespaces are
27
+ * intentionally reported rather than discarded: removal leaves raw metadata
28
+ * intact but lint-invalid until its plugin returns. */
29
+ export declare function validateMemoryExtensionValues(raw: unknown, catalog: MemoryExtensionCatalog): MemoryExtensionValidationIssue[];
@@ -0,0 +1,191 @@
1
+ // plugin-extensions.ts — a plugin manifest's `memory_extensions` declarations and
2
+ // the `extensions` frontmatter values its package documents carry.
3
+ import { listInstalledPlugins, listInstalledPluginsInRoot } from './resolver.js';
4
+ const LOCAL_FIELD_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
5
+ const DECLARATION_KEYS = new Set(['type', 'values', 'default', 'write_help', 'edit_help']);
6
+ function isRecord(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ export function isMemoryExtensionScalar(value) {
10
+ return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value));
11
+ }
12
+ function declarationError(path, message) {
13
+ return new Error(`invalid memory_extensions declaration at ${path}: ${message}`);
14
+ }
15
+ function validHelp(value) {
16
+ return typeof value === 'string' && value.trim() !== '' && !/[\r\n]/.test(value);
17
+ }
18
+ function validDefault(declaration, value) {
19
+ if (!isMemoryExtensionScalar(value))
20
+ return false;
21
+ if (declaration.type === 'enum')
22
+ return typeof value === 'string' && declaration.values.includes(value);
23
+ return typeof value === declaration.type;
24
+ }
25
+ /** Parse the closed declaration addendum. Callers that accept a plugin manifest
26
+ * invoke this at their install/preflight boundary; normal runtime catalogs only
27
+ * admit declarations which have already cleared that boundary. */
28
+ export function validateMemoryExtensionDeclarations(raw) {
29
+ if (!isRecord(raw))
30
+ throw declarationError('memory_extensions', 'expected a non-empty object');
31
+ const declarations = {};
32
+ const fields = Object.entries(raw);
33
+ if (fields.length === 0)
34
+ throw declarationError('memory_extensions', 'must not be empty');
35
+ for (const [field, value] of fields) {
36
+ const path = `memory_extensions.${field}`;
37
+ if (!LOCAL_FIELD_NAME.test(field)) {
38
+ throw declarationError(path, 'field names must be lowercase kebab-case');
39
+ }
40
+ if (!isRecord(value))
41
+ throw declarationError(path, 'expected a declaration object');
42
+ for (const key of Object.keys(value)) {
43
+ if (!DECLARATION_KEYS.has(key))
44
+ throw declarationError(path, `unknown key \`${key}\``);
45
+ }
46
+ const type = value['type'];
47
+ if (type !== 'boolean' && type !== 'string' && type !== 'number' && type !== 'enum') {
48
+ throw declarationError(`${path}.type`, 'expected boolean, string, number, or enum');
49
+ }
50
+ if (!validHelp(value['write_help']))
51
+ throw declarationError(`${path}.write_help`, 'expected a nonblank single-line string');
52
+ if (!validHelp(value['edit_help']))
53
+ throw declarationError(`${path}.edit_help`, 'expected a nonblank single-line string');
54
+ if (type === 'enum') {
55
+ const values = value['values'];
56
+ if (!Array.isArray(values) || values.length === 0 || !values.every((entry) => typeof entry === 'string' && entry !== '')) {
57
+ throw declarationError(`${path}.values`, 'expected a non-empty string list');
58
+ }
59
+ if (new Set(values).size !== values.length)
60
+ throw declarationError(`${path}.values`, 'values must be unique');
61
+ const declaration = {
62
+ type,
63
+ values: [...values],
64
+ write_help: value['write_help'],
65
+ edit_help: value['edit_help'],
66
+ };
67
+ if ('default' in value) {
68
+ if (!validDefault(declaration, value['default'])) {
69
+ throw declarationError(`${path}.default`, 'must be one of the declared enum values');
70
+ }
71
+ declaration.default = value['default'];
72
+ }
73
+ declarations[field] = declaration;
74
+ continue;
75
+ }
76
+ if ('values' in value)
77
+ throw declarationError(`${path}.values`, 'is allowed only for enum declarations');
78
+ const declaration = {
79
+ type,
80
+ write_help: value['write_help'],
81
+ edit_help: value['edit_help'],
82
+ };
83
+ if ('default' in value) {
84
+ if (!validDefault(declaration, value['default'])) {
85
+ throw declarationError(`${path}.default`, `must be a ${type}`);
86
+ }
87
+ declaration.default = value['default'];
88
+ }
89
+ declarations[field] = declaration;
90
+ }
91
+ return declarations;
92
+ }
93
+ /** Throw when a manifest carries an invalid addendum; absent is valid. */
94
+ export function validatePluginMemoryExtensions(manifest) {
95
+ if (manifest.memory_extensions !== undefined)
96
+ validateMemoryExtensionDeclarations(manifest.memory_extensions);
97
+ }
98
+ function declarationsFromManifest(manifest, strict) {
99
+ if (manifest.memory_extensions === undefined)
100
+ return undefined;
101
+ try {
102
+ return validateMemoryExtensionDeclarations(manifest.memory_extensions);
103
+ }
104
+ catch (error) {
105
+ if (strict)
106
+ throw error;
107
+ return undefined;
108
+ }
109
+ }
110
+ function catalogForPlugins(groups, enabledOnly, candidate) {
111
+ const catalog = {};
112
+ const shadowed = new Set();
113
+ if (candidate !== undefined) {
114
+ // A candidate that removes its addendum must still hide an installed copy
115
+ // while that package's own documents are being validated.
116
+ shadowed.add(candidate.name);
117
+ const declarations = declarationsFromManifest(candidate, true);
118
+ if (declarations !== undefined)
119
+ catalog[candidate.name] = declarations;
120
+ }
121
+ for (const plugins of groups) {
122
+ for (const plugin of plugins) {
123
+ if (enabledOnly && !plugin.enabled)
124
+ continue;
125
+ const namespace = plugin.manifest.name;
126
+ if (shadowed.has(namespace) || catalog[namespace] !== undefined)
127
+ continue;
128
+ const declarations = declarationsFromManifest(plugin.manifest, false);
129
+ if (declarations !== undefined)
130
+ catalog[namespace] = declarations;
131
+ }
132
+ }
133
+ return catalog;
134
+ }
135
+ /** Declarations for a native document. Project documents use their owning
136
+ * project scope only, then user scope; profile/node/user docs use user scope. */
137
+ /** Installed declarations a package candidate's documents validate against:
138
+ * a project-scope candidate sees its project's plugins, then user scope; any
139
+ * other scope sees user scope. Disabled plugins remain present so existing
140
+ * values can still be checked. The candidate's namespace shadows an installed
141
+ * copy of the same name. */
142
+ export function pluginExtensionCatalog(scope, scopeRootPath, options = {}) {
143
+ const groups = scope === 'project'
144
+ ? [listInstalledPluginsInRoot('project', scopeRootPath), listInstalledPlugins('user')]
145
+ : scope === 'builtin' ? [] : [listInstalledPlugins('user')];
146
+ return catalogForPlugins(groups, false, options.candidate);
147
+ }
148
+ /** Validate the raw `extensions` frontmatter value. Unresolved namespaces are
149
+ * intentionally reported rather than discarded: removal leaves raw metadata
150
+ * intact but lint-invalid until its plugin returns. */
151
+ export function validateMemoryExtensionValues(raw, catalog) {
152
+ if (raw === undefined)
153
+ return [];
154
+ if (!isRecord(raw))
155
+ return [{ path: 'extensions', message: 'expected a mapping' }];
156
+ const issues = [];
157
+ for (const [namespace, fields] of Object.entries(raw)) {
158
+ const namespacePath = `extensions.${namespace}`;
159
+ if (!isRecord(fields)) {
160
+ issues.push({ path: namespacePath, message: 'expected a mapping' });
161
+ continue;
162
+ }
163
+ const declarations = catalog[namespace];
164
+ if (declarations === undefined) {
165
+ issues.push({ path: namespacePath, message: 'unresolved extension namespace' });
166
+ for (const [field, value] of Object.entries(fields)) {
167
+ if (!isMemoryExtensionScalar(value)) {
168
+ issues.push({ path: `${namespacePath}.${field}`, message: 'expected a string, boolean, or finite number' });
169
+ }
170
+ }
171
+ continue;
172
+ }
173
+ for (const [field, value] of Object.entries(fields)) {
174
+ const path = `${namespacePath}.${field}`;
175
+ if (!isMemoryExtensionScalar(value)) {
176
+ issues.push({ path, message: 'expected a string, boolean, or finite number' });
177
+ continue;
178
+ }
179
+ const declaration = declarations[field];
180
+ if (declaration === undefined) {
181
+ issues.push({ path, message: 'undeclared extension field' });
182
+ continue;
183
+ }
184
+ if (!validDefault(declaration, value)) {
185
+ const expected = declaration.type === 'enum' ? `one of ${declaration.values.join('|')}` : declaration.type;
186
+ issues.push({ path, message: `expected ${expected}` });
187
+ }
188
+ }
189
+ }
190
+ return issues;
191
+ }
@@ -0,0 +1,9 @@
1
+ /** Under the scope root's `tmp/`, alongside the staging and `.prev` dirs the
2
+ * swap itself uses, so one directory holds the whole transition. */
3
+ export declare function bundleSwapLockPath(scopeRootPath: string, name: string): string;
4
+ /** Whether ANOTHER live process currently holds this plugin's swap lock. Our
5
+ * own lock does not count: the swapper reads the plugin list from inside its
6
+ * own critical section. */
7
+ export declare function bundleSwapInFlightElsewhere(scopeRootPath: string, name: string): boolean;
8
+ /** Wait out another process's swap of this plugin, bounded. */
9
+ export declare function awaitBundleSwap(scopeRootPath: string, name: string): void;
@@ -0,0 +1,31 @@
1
+ // The one name for the lock a bundle-plugin package swap is held under, and the
2
+ // two questions a reader asks about it.
3
+ //
4
+ // Two modules need it and neither may own it: the swapper lives in
5
+ // `commands/pkg/plugin-manage.ts` (the whole command graph), the reader in
6
+ // `core/installed-plugins.ts` (deliberately leaf-safe: types, fs-utils,
7
+ // manifest). This module keeps both dependency-light — `node:path` plus the
8
+ // generic lock primitive, which itself imports only `node:fs` and
9
+ // `node:crypto`.
10
+ import { join } from 'node:path';
11
+ import { awaitExclusiveLockRelease, exclusiveLockOwnerPid } from './exclusive-lock.js';
12
+ /** How long a reader waits for another process's swap to finish before it
13
+ * reports what it can see. A swap is one rename of an already-staged, already-
14
+ * validated tree, so a wait this long means the holder is wedged, not slow. */
15
+ const READER_WAIT_MS = 2_000;
16
+ /** Under the scope root's `tmp/`, alongside the staging and `.prev` dirs the
17
+ * swap itself uses, so one directory holds the whole transition. */
18
+ export function bundleSwapLockPath(scopeRootPath, name) {
19
+ return join(scopeRootPath, 'tmp', `${name}.swap.lock`);
20
+ }
21
+ /** Whether ANOTHER live process currently holds this plugin's swap lock. Our
22
+ * own lock does not count: the swapper reads the plugin list from inside its
23
+ * own critical section. */
24
+ export function bundleSwapInFlightElsewhere(scopeRootPath, name) {
25
+ const pid = exclusiveLockOwnerPid(bundleSwapLockPath(scopeRootPath, name));
26
+ return pid !== null && pid !== process.pid;
27
+ }
28
+ /** Wait out another process's swap of this plugin, bounded. */
29
+ export function awaitBundleSwap(scopeRootPath, name) {
30
+ awaitExclusiveLockRelease(bundleSwapLockPath(scopeRootPath, name), READER_WAIT_MS);
31
+ }
@@ -0,0 +1,4 @@
1
+ export declare const PREVIEW_RESULT_PATH_ENV = "CRTR_PREVIEW_RESULT_PATH";
2
+ /** Deterministic from the tool call id, so the spawning side and the result
3
+ * side derive the same path without sharing state. */
4
+ export declare function previewResultPath(toolCallId: string): string;
@@ -0,0 +1,26 @@
1
+ // The private structured-result side channel between a crtr CLI invocation and
2
+ // the canvas broker that spawned it: the broker names one file per bash tool
3
+ // call, the leaf mirrors its record there, and the broker attaches the record
4
+ // to Pi's ToolResult.details.
5
+ //
6
+ // The path travels in the child process environment. It must never be spliced
7
+ // into the command text — the executed script has to stay exactly what the
8
+ // agent wrote, because `cmd.sh`, job inspection, and the doc substrate's
9
+ // `command` surfaces all read that text as the agent's own words.
10
+ import { envNodeId } from '../shared/env.js';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ export const PREVIEW_RESULT_PATH_ENV = 'CRTR_PREVIEW_RESULT_PATH';
14
+ /** A tool call id is unique only within one broker: some provider adapters
15
+ * synthesize a missing id from a process-local counter and a millisecond
16
+ * clock, so two brokers can mint the same one and collide on a shared file.
17
+ * Node id and pid are equal across both sides of the transport (they run in
18
+ * the same broker process) without either side sharing state. */
19
+ function brokerToken() {
20
+ return `${encodeURIComponent(envNodeId() ?? 'no-node')}-${process.pid}`;
21
+ }
22
+ /** Deterministic from the tool call id, so the spawning side and the result
23
+ * side derive the same path without sharing state. */
24
+ export function previewResultPath(toolCallId) {
25
+ return join(tmpdir(), 'crouter-preview-results', `${brokerToken()}-${encodeURIComponent(toolCallId)}.json`);
26
+ }
@@ -0,0 +1,22 @@
1
+ export declare function isReservedEnvName(name: string): boolean;
2
+ /** Every stored variable NAME for a profile, sorted. Never returns values. */
3
+ export declare function listProfileEnvNames(profileId: string): string[];
4
+ /** Set one variable, replacing any prior value under the same name. Holds the
5
+ * same per-profile lock manifest mutations use (`withProfileManifestLock`),
6
+ * so a concurrent `profile delete` can never race a write into an
7
+ * about-to-vanish profile dir — whichever runs first wins the lock, and the
8
+ * loser either sees `not_found` (delete-then-set) or removes the file right
9
+ * back out from under a just-written value (set-then-delete, via
10
+ * `deleteProfile`'s recursive rm of the whole profile root). Returns whether
11
+ * the name was newly added (`true`) or replaced an existing value (`false`). */
12
+ export declare function setProfileEnvVar(profileId: string, name: string, value: string): boolean;
13
+ /** Remove one variable. Returns whether it was present. */
14
+ export declare function removeProfileEnvVar(profileId: string, name: string): boolean;
15
+ /** Every stored `{name: value}` for a profile — the ONLY function that
16
+ * returns raw values, called exclusively by `buildBrokerEnv`
17
+ * (`core/runtime/spawn-env.ts`) to inject them directly into a launched
18
+ * broker's env. Never throws: a stale/invalid/deleted profile id is a
19
+ * hot-path no-op (mirrors `readRawProfileConfig` in `core/config.ts`),
20
+ * because broker-env resolution must never fail a launch over a bad profile
21
+ * id. */
22
+ export declare function readProfileEnvVars(profileId: string | null): Record<string, string>;