total-recall-brain 3.28.2 → 3.30.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.
Files changed (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Peer-to-peer plugin discovery over the Total Recall mesh.
3
+ *
4
+ * There is no central catalog. Each node serves the plugins its owner chose to
5
+ * share (GET /api/mesh/plugins, see routes/plugins-mesh.mjs) and this module
6
+ * asks every online peer directly. What comes back is reported per peer
7
+ * exactly as observed — reachable, offline, not configured, running a version
8
+ * without plugin sharing, or failing — never smoothed over.
9
+ */
10
+ import { listEnrichedMeshNodes, isMeshAvailable } from './mesh.mjs';
11
+ import { getMeshSyncAuthorization } from './mesh-auth.mjs';
12
+ import { throttledFetch } from './throttled-fetch.mjs';
13
+
14
+ const LIST_TIMEOUT_MS = 5_000;
15
+ const BUNDLE_TIMEOUT_MS = 20_000;
16
+ const MAX_BUNDLE_RESPONSE_BYTES = 8 * 1024 * 1024; // base64 of the 5 MB bundle cap, plus envelope
17
+
18
+ function serverPort() {
19
+ const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
20
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
21
+ }
22
+
23
+ /** Build a peer URL, refusing anything outside the mesh CGNAT range (100.64.0.0/10). */
24
+ export function meshPeerUrl(ip, route, port = serverPort()) {
25
+ const octets = String(ip || '').split('.').map(Number);
26
+ if (
27
+ octets.length !== 4 ||
28
+ octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ||
29
+ octets[0] !== 100 || octets[1] < 64 || octets[1] > 127
30
+ ) {
31
+ throw new Error(`Peer address ${ip} is outside the mesh range`);
32
+ }
33
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid peer brain port');
34
+ return `http://${ip}:${port}${route}`;
35
+ }
36
+
37
+ function describeHttpFailure(status) {
38
+ if (status === 404) return { status: 'unsupported', error: 'Peer does not serve plugins (older Total Recall version)' };
39
+ if (status === 401) return { status: 'error', error: 'Peer rejected the mesh sync credential' };
40
+ if (status === 403) return { status: 'error', error: 'Peer refused the request source address' };
41
+ if (status === 503) return { status: 'not_configured', error: 'Mesh sync is not configured on the peer' };
42
+ return { status: 'error', error: `Peer returned HTTP ${status}` };
43
+ }
44
+
45
+ async function readJsonOrNull(res) {
46
+ const type = String(res.headers?.get?.('content-type') || '');
47
+ if (type && !type.includes('json')) return null;
48
+ try {
49
+ const body = await res.json();
50
+ return body && typeof body === 'object' ? body : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Ask every mesh peer which plugins it shares.
58
+ * @param {{ peers?: object[], fetchImpl?: Function, authorization?: string|null, meshAvailable?: boolean }} [deps]
59
+ */
60
+ export async function listPeerPlugins(deps = {}) {
61
+ const meshAvailable = deps.meshAvailable ?? isMeshAvailable();
62
+ if (!meshAvailable) {
63
+ return { mesh: { available: false, configured: false }, peers: [] };
64
+ }
65
+
66
+ const peers = (deps.peers ?? listEnrichedMeshNodes()).filter((p) => !p.self);
67
+ let authorization = deps.authorization;
68
+ if (authorization === undefined) {
69
+ try {
70
+ authorization = await getMeshSyncAuthorization();
71
+ } catch {
72
+ authorization = null;
73
+ }
74
+ }
75
+ const doFetch = deps.fetchImpl || throttledFetch;
76
+
77
+ const results = await Promise.all(peers.map(async (peer) => {
78
+ const base = { hostname: peer.hostname, ip: peer.ip, online: !!peer.online, os: peer.os || null, plugins: [] };
79
+ if (!peer.online) return { ...base, status: 'offline' };
80
+ if (!authorization) {
81
+ return { ...base, status: 'not_configured', error: 'TR_MESH_SYNC_TOKEN is not set on this node' };
82
+ }
83
+ try {
84
+ const res = await doFetch(meshPeerUrl(peer.ip, '/api/mesh/plugins', peer.brain_port || serverPort()), { headers: { Authorization: authorization } }, LIST_TIMEOUT_MS);
85
+ if (!res.ok) return { ...base, ...describeHttpFailure(res.status) };
86
+ // An older brain has no such route and its SPA fallback answers with the
87
+ // dashboard's index.html — that is "does not serve plugins", not a parse error.
88
+ const body = await readJsonOrNull(res);
89
+ if (!body) return { ...base, ...describeHttpFailure(404) };
90
+ const plugins = Array.isArray(body?.plugins) ? body.plugins : [];
91
+ return {
92
+ ...base,
93
+ status: 'ok',
94
+ plugins: plugins
95
+ .filter((p) => p && typeof p.id === 'string' && typeof p.sha256 === 'string')
96
+ .map((p) => ({
97
+ id: p.id,
98
+ name: String(p.name || p.id),
99
+ version: String(p.version || ''),
100
+ description: String(p.description || ''),
101
+ use_cases: Array.isArray(p.use_cases) ? p.use_cases.map(String) : [],
102
+ sha256: p.sha256,
103
+ file_count: Number(p.file_count) || 0,
104
+ size_bytes: Number(p.size_bytes) || 0
105
+ }))
106
+ };
107
+ } catch (err) {
108
+ const msg = err?.message || String(err);
109
+ return {
110
+ ...base,
111
+ status: 'unreachable',
112
+ error: msg === 'fetch failed' ? `No Total Recall server answered at ${peer.ip}:${peer.brain_port || serverPort()}` : msg
113
+ };
114
+ }
115
+ }));
116
+
117
+ results.sort((a, b) => String(a.hostname).localeCompare(String(b.hostname)));
118
+ return { mesh: { available: true, configured: !!authorization }, peers: results };
119
+ }
120
+
121
+ /** Parse `peer:<hostname>/<id>`. */
122
+ export function parsePeerSource(source) {
123
+ const m = /^peer:([^/\s]+)\/([a-z][a-z0-9-]{1,63})$/.exec(String(source || '').trim());
124
+ return m ? { hostname: m[1], id: m[2] } : null;
125
+ }
126
+
127
+ function sameHost(a, b) {
128
+ const norm = (h) => String(h || '').toLowerCase().replace(/\.$/, '').split('.')[0];
129
+ return norm(a) === norm(b);
130
+ }
131
+
132
+ /**
133
+ * Fetch one plugin bundle from a named peer. The advertised hash comes from the
134
+ * peer's own listing; plugin-bundle.decodeBundle() verifies the bytes against it.
135
+ * @returns {Promise<{ bundle: object, advertised: object, peer: object }>}
136
+ */
137
+ export async function fetchPeerBundle(hostname, id, deps = {}) {
138
+ const peers = deps.peers ?? listEnrichedMeshNodes();
139
+ const peer = peers.find((p) => !p.self && (sameHost(p.hostname, hostname) || p.ip === hostname));
140
+ if (!peer) throw new Error(`No mesh peer named '${hostname}'`);
141
+ if (!peer.online) throw new Error(`Mesh peer '${peer.hostname}' is offline`);
142
+
143
+ const authorization = deps.authorization ?? await getMeshSyncAuthorization();
144
+ const doFetch = deps.fetchImpl || throttledFetch;
145
+ const headers = { Authorization: authorization };
146
+
147
+ const listRes = await doFetch(meshPeerUrl(peer.ip, '/api/mesh/plugins', peer.brain_port || serverPort()), { headers }, LIST_TIMEOUT_MS);
148
+ if (!listRes.ok) throw new Error(describeHttpFailure(listRes.status).error);
149
+ const listing = await readJsonOrNull(listRes);
150
+ if (!listing) throw new Error(describeHttpFailure(404).error);
151
+ const advertised = (listing?.plugins || []).find((p) => p.id === id);
152
+ if (!advertised) throw new Error(`Peer '${peer.hostname}' does not share plugin '${id}'`);
153
+
154
+ const res = await doFetch(
155
+ meshPeerUrl(peer.ip, `/api/mesh/plugins/${encodeURIComponent(id)}/bundle`, peer.brain_port || serverPort()),
156
+ { headers },
157
+ BUNDLE_TIMEOUT_MS
158
+ );
159
+ if (!res.ok) throw new Error(describeHttpFailure(res.status).error);
160
+ const text = await res.text();
161
+ if (text.length > MAX_BUNDLE_RESPONSE_BYTES) throw new Error('Peer bundle exceeds the size limit');
162
+ return { bundle: JSON.parse(text), advertised, peer };
163
+ }
@@ -0,0 +1,85 @@
1
+ /** Direct, hash-pinned plugin exchange between Total Recall users. */
2
+ import dns from 'node:dns/promises';
3
+ import https from 'node:https';
4
+ import net from 'node:net';
5
+
6
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
7
+ const SOURCE_PATH = /^\/api\/public\/plugins\/([a-z][a-z0-9-]{1,63})\/bundle$/;
8
+
9
+ function publicIpv4(address) {
10
+ if (net.isIP(address) !== 4) return false;
11
+ const [a, b, c] = address.split('.').map(Number);
12
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
13
+ if (a === 100 && b >= 64 && b <= 127) return false;
14
+ if (a === 169 && b === 254) return false;
15
+ if (a === 172 && b >= 16 && b <= 31) return false;
16
+ if (a === 192 && (b === 168 || b === 0 || (b === 88 && c === 99))) return false;
17
+ if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false;
18
+ if (a === 203 && b === 0 && c === 113) return false;
19
+ return true;
20
+ }
21
+
22
+ export function parsePublicPluginSource(source) {
23
+ let url;
24
+ try { url = new URL(source); } catch { return null; }
25
+ const match = SOURCE_PATH.exec(url.pathname);
26
+ if (!match) return null;
27
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.port) {
28
+ throw new Error('Plugin share links require HTTPS with no credentials, query, or custom port');
29
+ }
30
+ const hash = /^#sha256=([a-f0-9]{64})$/.exec(url.hash);
31
+ if (!hash) throw new Error('Plugin share link is missing its SHA-256 content pin');
32
+ return { url, id: match[1], sha256: hash[1] };
33
+ }
34
+
35
+ export function publicPluginShareUrl(id, sha256, base = process.env.TR_PUBLIC_BASE_URL) {
36
+ if (!base) return null;
37
+ const origin = new URL(base);
38
+ if (origin.protocol !== 'https:' || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== '/' || origin.port) {
39
+ throw new Error('TR_PUBLIC_BASE_URL must be a public HTTPS origin without a path or custom port');
40
+ }
41
+ if (!/^[a-z][a-z0-9-]{1,63}$/.test(id) || !/^[a-f0-9]{64}$/.test(sha256)) throw new Error('Invalid plugin share link data');
42
+ return `${origin.origin}/api/public/plugins/${id}/bundle#sha256=${sha256}`;
43
+ }
44
+
45
+ export async function fetchPublicBundle(source, deps = {}) {
46
+ const parsed = parsePublicPluginSource(source);
47
+ if (!parsed) throw new Error('Invalid Total Recall plugin share link');
48
+ const host = parsed.url.hostname;
49
+ const addresses = net.isIP(host) ? [{ address: host }] : await (deps.lookup || dns.lookup)(host, { all: true, family: 4 });
50
+ const selected = addresses.find(({ address }) => publicIpv4(address));
51
+ if (!selected || addresses.some(({ address }) => !publicIpv4(address))) {
52
+ throw new Error('Plugin share host must resolve only to public IPv4 addresses');
53
+ }
54
+ const request = deps.request || https.request;
55
+ const body = await new Promise((resolve, reject) => {
56
+ const req = request(parsed.url, {
57
+ method: 'GET',
58
+ timeout: 20_000,
59
+ lookup: (_host, _options, callback) => callback(null, selected.address, 4),
60
+ headers: { Accept: 'application/json' }
61
+ }, (res) => {
62
+ if (res.statusCode !== 200) {
63
+ res.resume();
64
+ reject(new Error(`Plugin share returned HTTP ${res.statusCode}`));
65
+ return;
66
+ }
67
+ const chunks = [];
68
+ let size = 0;
69
+ res.on('data', (chunk) => {
70
+ size += chunk.length;
71
+ if (size > MAX_RESPONSE_BYTES) {
72
+ req.destroy(new Error('Plugin bundle exceeds the size limit'));
73
+ return;
74
+ }
75
+ chunks.push(chunk);
76
+ });
77
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
78
+ res.on('error', reject);
79
+ });
80
+ req.on('timeout', () => req.destroy(new Error('Plugin share timed out')));
81
+ req.on('error', reject);
82
+ req.end();
83
+ });
84
+ return { bundle: JSON.parse(body), id: parsed.id, sha256: parsed.sha256 };
85
+ }
@@ -0,0 +1,24 @@
1
+ // Child-process entry for plugin-runner.mjs. Imports one plugin CLI handler and
2
+ // invokes it with the argv shape `total-recall <command> [sub] [...args]` would
3
+ // give it, then exits. Runs in its own process so a plugin that calls
4
+ // process.exit(), throws asynchronously, or leaks handles cannot affect the
5
+ // brain server.
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const [handlerPath, ...pluginArgv] = process.argv.slice(2);
9
+
10
+ try {
11
+ const mod = await import(pathToFileURL(handlerPath).href);
12
+ const argv = ['node', 'total-recall', ...pluginArgv];
13
+ if (typeof mod.run === 'function') {
14
+ await mod.run(argv);
15
+ } else if (typeof mod.default === 'function') {
16
+ await mod.default(argv.slice(3));
17
+ } else {
18
+ console.error(`Plugin handler ${handlerPath} exports neither run() nor a default function`);
19
+ process.exitCode = 1;
20
+ }
21
+ } catch (err) {
22
+ console.error(err?.stack || String(err));
23
+ process.exitCode = 1;
24
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Run a plugin's CLI handler in a separate Node process.
3
+ *
4
+ * The dashboard runner used to import() handlers into the brain server and
5
+ * swap console.log process-wide to capture output: a plugin calling
6
+ * process.exit() stopped the server, and concurrent runs interleaved output.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { PACKAGE_ROOT } from './plugin-loader.mjs';
13
+
14
+ const CHILD = path.join(path.dirname(fileURLToPath(import.meta.url)), 'plugin-runner-child.mjs');
15
+ export const DEFAULT_TIMEOUT_MS = 30_000;
16
+ export const MAX_OUTPUT_BYTES = 256 * 1024;
17
+
18
+ /**
19
+ * @param {object} plugin A discovered plugin ({ id, dir, manifest }).
20
+ * @param {{ subcommand?: string, args?: string[], cwd?: string, timeoutMs?: number }} [options]
21
+ * @returns {Promise<{ ok: boolean, exitCode: number|null, signal: string|null, timedOut: boolean, truncated: boolean, output: string, durationMs: number }>}
22
+ */
23
+ export function runPluginCommand(plugin, options = {}) {
24
+ const { subcommand = '', args = [], cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS } = options;
25
+ const handlerRel = plugin?.manifest?.cli?.handler;
26
+ if (!handlerRel) return Promise.reject(new Error(`Plugin ${plugin?.id} does not declare a CLI handler`));
27
+
28
+ const handler = path.resolve(plugin.dir, handlerRel);
29
+ const rel = path.relative(fs.realpathSync(plugin.dir), fs.existsSync(handler) ? fs.realpathSync(handler) : handler);
30
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
31
+ return Promise.reject(new Error(`Plugin ${plugin.id} handler resolves outside the plugin directory`));
32
+ }
33
+ if (!fs.existsSync(handler)) return Promise.reject(new Error(`Plugin CLI handler file not found: ${handler}`));
34
+
35
+ const command = plugin.manifest.cli.command || plugin.id;
36
+ const safeArgs = (Array.isArray(args) ? args : []).map(String);
37
+ const childArgv = [CHILD, handler, command, ...(subcommand ? [String(subcommand)] : []), ...safeArgs];
38
+
39
+ return new Promise((resolve) => {
40
+ const started = Date.now();
41
+ const chunks = [];
42
+ let bytes = 0;
43
+ let truncated = false;
44
+ let timedOut = false;
45
+ let settled = false;
46
+
47
+ const child = spawn(process.execPath, childArgv, {
48
+ cwd,
49
+ env: {
50
+ ...process.env,
51
+ TR_PACKAGE_ROOT: PACKAGE_ROOT,
52
+ TR_PLUGIN_ID: plugin.id,
53
+ TR_PLUGIN_DIR: plugin.dir,
54
+ FORCE_COLOR: '0',
55
+ NO_COLOR: '1'
56
+ },
57
+ stdio: ['ignore', 'pipe', 'pipe']
58
+ });
59
+
60
+ const collect = (buf) => {
61
+ if (truncated) return;
62
+ if (bytes + buf.length > MAX_OUTPUT_BYTES) {
63
+ chunks.push(buf.subarray(0, MAX_OUTPUT_BYTES - bytes));
64
+ bytes = MAX_OUTPUT_BYTES;
65
+ truncated = true;
66
+ return;
67
+ }
68
+ chunks.push(buf);
69
+ bytes += buf.length;
70
+ };
71
+ child.stdout.on('data', collect);
72
+ child.stderr.on('data', collect);
73
+
74
+ const timer = setTimeout(() => {
75
+ timedOut = true;
76
+ child.kill('SIGKILL');
77
+ }, timeoutMs);
78
+
79
+ const finish = (exitCode, signal, spawnError) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ let output = Buffer.concat(chunks).toString('utf8');
84
+ if (spawnError) output += `${output ? '\n' : ''}[runner] ${spawnError.message}`;
85
+ if (truncated) output += `\n[runner] output truncated at ${MAX_OUTPUT_BYTES} bytes`;
86
+ if (timedOut) output += `\n[runner] killed after ${timeoutMs} ms`;
87
+ resolve({
88
+ ok: !spawnError && !timedOut && exitCode === 0,
89
+ exitCode,
90
+ signal,
91
+ timedOut,
92
+ truncated,
93
+ output,
94
+ durationMs: Date.now() - started
95
+ });
96
+ };
97
+
98
+ // spawn reports a missing binary / bad cwd as an 'error' event, not a throw.
99
+ child.on('error', (err) => finish(null, null, err));
100
+ child.on('close', (code, signal) => finish(code, signal));
101
+ });
102
+ }