total-recall-brain 3.26.0 → 3.28.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 (65) hide show
  1. package/bin/total-recall.mjs +31 -1
  2. package/docs/ARCHITECTURE.md +76 -9
  3. package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +39 -1
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/plugins.ts +167 -0
  8. package/frontend/src/pages/ChatPage.tsx +44 -17
  9. package/frontend/src/pages/IntegrationsPage.tsx +7 -0
  10. package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
  11. package/frontend/src/pages/PluginsPage.tsx +1331 -0
  12. package/package.json +1 -1
  13. package/scaffold/.agent/plugins/.gitkeep +0 -0
  14. package/scaffold/.agent/plugins/code-quality/README.md +16 -0
  15. package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
  16. package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
  17. package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
  18. package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
  19. package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
  20. package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
  21. package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
  22. package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
  23. package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
  24. package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
  25. package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
  26. package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
  27. package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
  28. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
  29. package/src/cli/agent.mjs +173 -0
  30. package/src/cli/connect.mjs +89 -4
  31. package/src/cli/deploy-ui.mjs +6 -0
  32. package/src/cli/harness.mjs +135 -0
  33. package/src/cli/ingest-openwiki.mjs +71 -2
  34. package/src/cli/init.mjs +1 -0
  35. package/src/cli/lint.mjs +47 -3
  36. package/src/cli/mesh.mjs +286 -0
  37. package/src/cli/plugin/create.mjs +186 -0
  38. package/src/cli/plugin/index.mjs +139 -0
  39. package/src/cli/plugin/install.mjs +141 -0
  40. package/src/cli/plugin/list.mjs +83 -0
  41. package/src/cli/plugin/remove.mjs +51 -0
  42. package/src/cli/plugin/search.mjs +37 -0
  43. package/src/cli/skill-projection.mjs +14 -0
  44. package/src/cli/wizard.html +9 -0
  45. package/src/core/agent-manager.mjs +290 -0
  46. package/src/core/cache-prune.mjs +341 -0
  47. package/src/core/context-cache.mjs +130 -0
  48. package/src/core/daemon-loop.mjs +47 -1
  49. package/src/core/evolving-context.mjs +171 -0
  50. package/src/core/mesh-activity.mjs +140 -0
  51. package/src/core/mesh.mjs +174 -1
  52. package/src/core/meta-harness.mjs +251 -0
  53. package/src/core/plugin-context.mjs +206 -0
  54. package/src/core/plugin-loader.mjs +210 -0
  55. package/src/core/runtime.mjs +65 -13
  56. package/src/core/surface.mjs +32 -10
  57. package/src/server/api.mjs +18 -2
  58. package/src/server/index.mjs +8 -2
  59. package/src/server/rest.mjs +4 -0
  60. package/src/server/routes/integrations.mjs +5 -1
  61. package/src/server/routes/mesh-presence.mjs +143 -0
  62. package/src/server/routes/models.mjs +42 -30
  63. package/src/server/routes/plugins.mjs +517 -0
  64. package/src/server/tools.mjs +266 -0
  65. package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ getCachedSection,
5
+ loadSectionCache,
6
+ saveSectionCache,
7
+ computeSectionHash,
8
+ manageContextBudget
9
+ } from './context-cache.mjs';
10
+
11
+ /**
12
+ * Compile evolving context blocks across multi-domain SSSS nodes and plugins.
13
+ * Supports deep token windows up to 500,000 tokens with section-level caching.
14
+ *
15
+ * @param {object} options
16
+ * @param {string} [options.derivedDir] - Target directory for memory-derived artifacts
17
+ * @param {Array<object>} [options.nodes] - SSSS memory nodes
18
+ * @param {Array<object>} [options.plugins] - Active plugin descriptors
19
+ * @param {number} [options.maxChars=500000] - Token/character budget ceiling
20
+ * @param {number} [options.slidingWindowLimit=50] - Number of recent historical items to retain
21
+ * @returns {Promise<{ content: string, cacheHits: number, cacheMisses: number, totalSections: number }>}
22
+ */
23
+ export async function compileEvolvingContext(options = {}) {
24
+ const {
25
+ derivedDir,
26
+ nodes = [],
27
+ plugins = [],
28
+ maxChars = 500_000,
29
+ slidingWindowLimit = 50
30
+ } = options;
31
+
32
+ let cacheHits = 0;
33
+ let cacheMisses = 0;
34
+
35
+ const activeNodes = nodes.filter((n) => n && n.status !== 'deprecated' && n.status !== 'archived');
36
+
37
+ // Partition nodes by domain
38
+ const userProjects = activeNodes.filter((n) => n.category === 'user-projects');
39
+ const benchmarks = activeNodes.filter((n) => n.category === 'benchmarks');
40
+ const researchNodes = activeNodes.filter((n) => n.category === 'research' || n.category === 'frontier-capabilities');
41
+
42
+ // Section 1: User Projects & Active Blockers (Priority 100 - unevictable)
43
+ const projectsSection = await getCachedSection({
44
+ derivedDir,
45
+ sectionKey: 'user_projects',
46
+ nodes: userProjects,
47
+ generatorFn: async () => {
48
+ if (userProjects.length === 0) return '';
49
+ const lines = ['### 1. Grounded User Science & Active Blockers\n'];
50
+ for (const p of userProjects) {
51
+ lines.push(`* **Project**: \`${p.title || p.slug}\` (Path: \`${p.source?.repo_path || 'local'}\`)`);
52
+ if (p.description) lines.push(` * **Mission**: ${p.description}`);
53
+ if (Array.isArray(p.enables) && p.enables.length > 0) {
54
+ lines.push(` * **Required Capabilities**: ${p.enables.join(', ')}`);
55
+ }
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+ });
60
+ if (projectsSection.cached) cacheHits++; else cacheMisses++;
61
+
62
+ // Section 2: Benchmark Ledger (Priority 90)
63
+ const benchmarkSection = await getCachedSection({
64
+ derivedDir,
65
+ sectionKey: 'benchmark_ledger',
66
+ nodes: benchmarks,
67
+ generatorFn: async () => {
68
+ if (benchmarks.length === 0) return '';
69
+ const lines = [
70
+ '### 2. State-of-the-Art Benchmark Ledger & Pareto Frontiers\n',
71
+ '| Domain / Metric | Current World Record | Baseline | Verification Tier | Source / Lab |',
72
+ '|---|---|---|---|---|'
73
+ ];
74
+ for (const b of benchmarks) {
75
+ const src = b.source || {};
76
+ const metricCode = src.metric_name || b.slug;
77
+ const metricDisplay = b.title && metricCode && b.title !== metricCode
78
+ ? `${b.title} (\`${metricCode}\`)`
79
+ : `\`${metricCode || b.title}\``;
80
+ const current = src.current_record ? `**${src.current_record} ${src.unit || ''}**`.trim() : '—';
81
+ const baseline = src.previous_record ? `${src.previous_record} ${src.unit || ''}`.trim() : '—';
82
+ const tier = src.verification_status || 'unverified';
83
+ const ref = src.doi ? `[${src.doi}](https://doi.org/${src.doi})` : (src.lab || 'empirical');
84
+ lines.push(`| ${metricDisplay} | ${current} | ${baseline} | ${tier} | ${ref} |`);
85
+ }
86
+ return lines.join('\n');
87
+ }
88
+ });
89
+ if (benchmarkSection.cached) cacheHits++; else cacheMisses++;
90
+
91
+ // Section 3: Verified Capability Breakthroughs (Priority 80)
92
+ const breakthroughsSection = await getCachedSection({
93
+ derivedDir,
94
+ sectionKey: 'breakthroughs',
95
+ nodes: researchNodes,
96
+ generatorFn: async () => {
97
+ if (researchNodes.length === 0) return '';
98
+ const lines = ['### 3. Verified Capability Breakthroughs (What Is Now Possible)\n'];
99
+ // Apply sliding window to prioritize the most recent breakthroughs
100
+ const sorted = [...researchNodes].sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime());
101
+ const windowed = sorted.slice(0, slidingWindowLimit);
102
+
103
+ for (const r of windowed) {
104
+ const src = r.source || {};
105
+ const tier = src.epistemic_tier ? `[Tier ${src.epistemic_tier}] ` : '';
106
+ const modality = src.empirical_modality ? `(\`${src.empirical_modality}\`)` : '';
107
+ lines.push(`#### ${tier}${r.title || r.slug} ${modality}`);
108
+ if (r.description) lines.push(`> **Capability**: ${r.description}\n`);
109
+ if (src.doi) lines.push(`* **Primary DOI**: https://doi.org/${src.doi}`);
110
+ if (Array.isArray(r.enables) && r.enables.length > 0) {
111
+ lines.push(`* **Unlocks Subgraph**: ${r.enables.join(', ')}`);
112
+ }
113
+ lines.push('');
114
+ }
115
+ return lines.join('\n');
116
+ }
117
+ });
118
+ if (breakthroughsSection.cached) cacheHits++; else cacheMisses++;
119
+
120
+ // Section 4: Dynamic Plugin Injections
121
+ const pluginSections = [];
122
+ for (const plug of plugins) {
123
+ const plugNodes = activeNodes.filter(n => Array.isArray(plug.categories) && plug.categories.includes(n.category));
124
+ const pSec = await getCachedSection({
125
+ derivedDir,
126
+ sectionKey: `plugin_${plug.id}`,
127
+ nodes: plugNodes,
128
+ extraInput: JSON.stringify(plug.manifest || {}),
129
+ generatorFn: async () => {
130
+ const lines = [`### Plugin: ${plug.name || plug.id}\n`];
131
+ if (plug.description) lines.push(`> ${plug.description}\n`);
132
+ for (const pn of plugNodes) {
133
+ lines.push(`- **${pn.title || pn.slug}**: ${pn.body || pn.description || ''}`);
134
+ }
135
+ return lines.join('\n');
136
+ }
137
+ });
138
+ if (pSec.cached) cacheHits++; else cacheMisses++;
139
+ pluginSections.push({ priority: 60, content: pSec.content });
140
+ }
141
+
142
+ // Manage total context budget
143
+ const allSections = [
144
+ { priority: 100, content: projectsSection.content, allowTruncation: false },
145
+ { priority: 90, content: benchmarkSection.content },
146
+ { priority: 80, content: breakthroughsSection.content },
147
+ ...pluginSections
148
+ ].filter(s => s.content && s.content.trim().length > 0);
149
+
150
+ const assembledContent = manageContextBudget(allSections, { maxChars });
151
+
152
+ // Persist to memory-derived/evolving-context.md if derivedDir is provided
153
+ if (derivedDir) {
154
+ try {
155
+ if (!fs.existsSync(derivedDir)) {
156
+ fs.mkdirSync(derivedDir, { recursive: true });
157
+ }
158
+ const targetPath = path.join(derivedDir, 'evolving-context.md');
159
+ fs.writeFileSync(targetPath, assembledContent, 'utf8');
160
+ } catch {
161
+ // Best-effort write
162
+ }
163
+ }
164
+
165
+ return {
166
+ content: assembledContent,
167
+ cacheHits,
168
+ cacheMisses,
169
+ totalSections: allSections.length
170
+ };
171
+ }
@@ -0,0 +1,140 @@
1
+ import os from 'node:os';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { getMeshIp, getMeshHostname } from './mesh.mjs';
6
+
7
+ /**
8
+ * Measure user idle time in seconds on the local operating system.
9
+ * macOS uses IOHIDSystem (HIDIdleTime nanoseconds).
10
+ * Linux falls back to w/proc.
11
+ * @returns {number} Idle duration in seconds
12
+ */
13
+ export function getLocalIdleSeconds() {
14
+ const platform = process.platform;
15
+
16
+ if (platform === 'darwin') {
17
+ try {
18
+ const res = spawnSync('ioreg', ['-c', 'IOHIDSystem'], {
19
+ encoding: 'utf8',
20
+ timeout: 1000,
21
+ stdio: ['ignore', 'pipe', 'ignore']
22
+ });
23
+ if (res.status === 0 && res.stdout) {
24
+ const match = res.stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/);
25
+ if (match && match[1]) {
26
+ const nanos = BigInt(match[1]);
27
+ const secs = Number(nanos / 1_000_000_000n);
28
+ return Math.max(0, secs);
29
+ }
30
+ }
31
+ } catch {
32
+ // Fallback
33
+ }
34
+ }
35
+
36
+ // Linux or fallback: check w / who idle time
37
+ try {
38
+ const res = spawnSync('w', ['-u'], {
39
+ encoding: 'utf8',
40
+ timeout: 1000,
41
+ stdio: ['ignore', 'pipe', 'ignore']
42
+ });
43
+ if (res.status === 0 && res.stdout) {
44
+ const lines = res.stdout.trim().split('\n').slice(2);
45
+ if (lines.length > 0) {
46
+ // Just report 0 if user is logged into an active TTY session
47
+ return 0;
48
+ }
49
+ }
50
+ } catch {}
51
+
52
+ return 0;
53
+ }
54
+
55
+ /**
56
+ * Identify the active editor or developer surface on the local machine.
57
+ * @returns {string} E.g. 'antigravity', 'cursor', 'claude', 'vscode', 'terminal'
58
+ */
59
+ export function detectActiveSurface() {
60
+ try {
61
+ const res = spawnSync('ps', ['-ax', '-o', 'comm'], {
62
+ encoding: 'utf8',
63
+ timeout: 1500,
64
+ stdio: ['ignore', 'pipe', 'ignore']
65
+ });
66
+
67
+ if (res.status === 0 && res.stdout) {
68
+ const procs = res.stdout.toLowerCase();
69
+ if (procs.includes('antigravity')) return 'antigravity';
70
+ if (procs.includes('cursor')) return 'cursor';
71
+ if (procs.includes('code') || procs.includes('electron')) return 'vscode';
72
+ if (procs.includes('claude')) return 'claude';
73
+ }
74
+ } catch {}
75
+
76
+ return 'terminal';
77
+ }
78
+
79
+ /**
80
+ * Generate a complete presence heartbeat for the local node.
81
+ * @returns {object} Presence record
82
+ */
83
+ export function getLocalPresence() {
84
+ const idleSecs = getLocalIdleSeconds();
85
+ const isActive = idleSecs < 300; // Active if interacted within last 5 minutes
86
+ const now = Date.now();
87
+ const lastInteraction = now - (idleSecs * 1000);
88
+
89
+ let hostname = 'unknown-node';
90
+ try {
91
+ hostname = getMeshHostname() || os.hostname();
92
+ } catch {
93
+ hostname = os.hostname();
94
+ }
95
+
96
+ let meshIp = null;
97
+ try {
98
+ meshIp = getMeshIp();
99
+ } catch {}
100
+
101
+ return {
102
+ node_id: hostname,
103
+ mesh_ip: meshIp,
104
+ user_active: isActive,
105
+ idle_seconds: idleSecs,
106
+ last_interaction: lastInteraction,
107
+ active_surface: detectActiveSurface(),
108
+ timestamp: new Date(now).toISOString()
109
+ };
110
+ }
111
+
112
+ /**
113
+ * "Follow the User" dynamic dispatch resolver.
114
+ * Evaluates presence records across mesh nodes to determine the user's currently active device.
115
+ * @param {Array<object>} presenceList - Array of presence records from cluster nodes
116
+ * @returns {object} The most actively interacted node
117
+ */
118
+ export function resolveActiveDevice(presenceList = []) {
119
+ if (!Array.isArray(presenceList) || presenceList.length === 0) {
120
+ return {
121
+ node_id: 'local',
122
+ mesh_ip: null,
123
+ user_active: true,
124
+ active_surface: 'local'
125
+ };
126
+ }
127
+
128
+ // Filter for active nodes
129
+ const activeNodes = presenceList.filter(p => p && p.user_active);
130
+
131
+ if (activeNodes.length === 0) {
132
+ // If no nodes are actively interacted with within threshold, select the most recently interacted
133
+ const sorted = [...presenceList].sort((a, b) => (b.last_interaction || 0) - (a.last_interaction || 0));
134
+ return sorted[0] || { node_id: 'local', mesh_ip: null, user_active: false };
135
+ }
136
+
137
+ // Return the active node with the most recent interaction timestamp
138
+ activeNodes.sort((a, b) => (b.last_interaction || 0) - (a.last_interaction || 0));
139
+ return activeNodes[0];
140
+ }
package/src/core/mesh.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import {
@@ -8,6 +8,7 @@ import {
8
8
  } from './vfs-documents.mjs';
9
9
  import { logger } from './logger.mjs';
10
10
  import { STATUS_TIMEOUT_MS, resolveTailscaleBinary } from './tailscale-cli.mjs';
11
+ import { buildSshArgs, resolveNodeAccess } from './mesh-access.mjs';
11
12
 
12
13
  const CACHE_MS = 2_000;
13
14
 
@@ -355,6 +356,67 @@ export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}
355
356
  return { written: !!result?.success, path: vfsPath, access: merged, result };
356
357
  }
357
358
 
359
+ /**
360
+ * Ensure every live mesh peer has an entity document.
361
+ *
362
+ * `patchOwnMeshNode()` describes only the machine it runs on, so a peer that
363
+ * will never run a brain can never be described: a phone cannot run one, a
364
+ * control-plane droplet may not, and a machine that is simply switched off
365
+ * never gets the chance. On a five-node tailnet that left three nodes with no
366
+ * entity at all — including the always-on host, which is the one an agent is
367
+ * most likely to need access details for.
368
+ *
369
+ * Offline peers are deliberately skipped rather than created. A node that has
370
+ * not been seen in weeks is usually a dead registration, and minting an entity
371
+ * for it re-legitimises something the operator may be trying to retire. They
372
+ * are reported instead, so the choice stays with the person.
373
+ *
374
+ * Creation goes through `setMeshNodeAccess`, so it is the same Operation
375
+ * Contract path as every other entity write — never a raw file write.
376
+ *
377
+ * Discovery and the writer are injectable so this is testable without a live
378
+ * tailnet or a real vault; production callers pass neither.
379
+ *
380
+ * @param {{vaultRoot?: string, includeOffline?: boolean, peers?: object[],
381
+ * entities?: object[], write?: Function}} [options]
382
+ * @returns {Promise<{created: string[], skipped_offline: string[], existing: number}>}
383
+ */
384
+ export async function ensureMeshNodeEntities(options = {}) {
385
+ const vaultRoot = options.vaultRoot || defaultVaultRoot();
386
+ const peers = options.peers || getMeshPeers({ includeSelf: true });
387
+ const entities = options.entities || listMeshNodeEntities(vaultRoot);
388
+ const write = options.write || ((host) => setMeshNodeAccess(host, {}, { vaultRoot }));
389
+
390
+ const knownKeys = new Set(entities.map((e) => meshNodeKey(e.hostname)).filter(Boolean));
391
+ const knownIps = new Set(entities.map((e) => e.ip).filter(Boolean).map(String));
392
+
393
+ const created = [];
394
+ const skippedOffline = [];
395
+
396
+ for (const peer of peers) {
397
+ if (!peer.hostname) continue;
398
+ // Match on either identity: the same host can be recorded under a bare or
399
+ // suffixed name, and creating a second document for it is the duplicate
400
+ // problem meshNodeKey exists to prevent.
401
+ if (knownKeys.has(meshNodeKey(peer.hostname))) continue;
402
+ if (peer.ip && knownIps.has(String(peer.ip))) continue;
403
+
404
+ if (!peer.online && !options.includeOffline) {
405
+ skippedOffline.push(peer.hostname);
406
+ continue;
407
+ }
408
+
409
+ const res = await write(peer.hostname);
410
+ if (res.written) {
411
+ created.push(peer.hostname);
412
+ knownKeys.add(meshNodeKey(peer.hostname));
413
+ if (peer.ip) knownIps.add(String(peer.ip));
414
+ }
415
+ }
416
+
417
+ return { created, skipped_offline: skippedOffline, existing: entities.length };
418
+ }
419
+
358
420
  /**
359
421
  * Upsert **this** node's mesh_node entity from live discovery.
360
422
  * Does not hardcode device names — slug and fields come from Tailscale self.
@@ -507,3 +569,114 @@ export async function patchOwnMeshNode(options = {}) {
507
569
  result,
508
570
  };
509
571
  }
572
+
573
+ /**
574
+ * Execute a command on a remote mesh node over SSH.
575
+ *
576
+ * @param {string} target - Node hostname or IP
577
+ * @param {string} command - Shell command to execute
578
+ * @param {object} options
579
+ * @param {string} [options.vaultRoot]
580
+ * @param {number} [options.timeoutMs=60000]
581
+ * @param {boolean} [options.loginShell=true]
582
+ * @returns {Promise<{ node: string, hostname: string, ip: string, exitCode: number, success: boolean, stdout: string, stderr: string }>}
583
+ */
584
+ export async function execMeshCommand(target, command, options = {}) {
585
+ const { vaultRoot = defaultVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
586
+ const node = findMeshNode(target, vaultRoot);
587
+ if (!node) {
588
+ throw new Error(`No mesh node matches "${target}".`);
589
+ }
590
+ const resolved = resolveNodeAccess(node);
591
+ if (!resolved.complete) {
592
+ throw new Error(
593
+ `No login account recorded for ${node.hostname}. Run 'total-recall mesh access ${target} --user <login>'`,
594
+ );
595
+ }
596
+
597
+ const standardPath = 'export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$HOME/.local/bin:$PATH";';
598
+ const remoteCommand = `${standardPath} ${command}`;
599
+ const sshArgs = buildSshArgs(resolved, {
600
+ command: remoteCommand,
601
+ extraOptions: ['BatchMode=yes', 'ConnectTimeout=10'],
602
+ });
603
+
604
+ return new Promise((resolve, reject) => {
605
+ const proc = spawn('ssh', sshArgs);
606
+ let stdout = '';
607
+ let stderr = '';
608
+ let timedOut = false;
609
+
610
+ const timer = setTimeout(() => {
611
+ timedOut = true;
612
+ proc.kill('SIGTERM');
613
+ reject(new Error(`Command on "${target}" timed out after ${timeoutMs}ms.`));
614
+ }, timeoutMs);
615
+
616
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
617
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
618
+
619
+ proc.on('close', (code) => {
620
+ clearTimeout(timer);
621
+ if (timedOut) return;
622
+ resolve({
623
+ node: target,
624
+ hostname: node.hostname,
625
+ ip: node.ip,
626
+ exitCode: code ?? 1,
627
+ success: code === 0,
628
+ stdout: stdout.trim(),
629
+ stderr: stderr.trim(),
630
+ });
631
+ });
632
+
633
+ proc.on('error', (err) => {
634
+ clearTimeout(timer);
635
+ reject(err);
636
+ });
637
+ });
638
+ }
639
+
640
+ /**
641
+ * Resolves the primary or Cloudflare Tunnel fallback URL for mesh control plane access.
642
+ * Returns { url, source, is_fallback }
643
+ *
644
+ * @param {object} [options]
645
+ * @param {string} [options.url]
646
+ * @param {string} [options.loginServer]
647
+ * @returns {{ url: string|null, source: string, is_fallback: boolean }}
648
+ */
649
+ export function resolveMeshControlUrl(options = {}) {
650
+ const explicit = options.url || options.loginServer;
651
+ if (explicit && String(explicit).trim()) {
652
+ const clean = String(explicit).trim();
653
+ return {
654
+ url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
655
+ source: 'explicit',
656
+ is_fallback: false
657
+ };
658
+ }
659
+
660
+ const envPrimary = process.env.TR_LOGIN_SERVER || process.env.TR_HEADSCALE_URL;
661
+ if (envPrimary && envPrimary.trim()) {
662
+ const clean = envPrimary.trim();
663
+ return {
664
+ url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
665
+ source: 'env_primary',
666
+ is_fallback: false
667
+ };
668
+ }
669
+
670
+ const envTunnel = process.env.TR_CLOUDFLARE_TUNNEL_URL || process.env.CF_TUNNEL_URL;
671
+ if (envTunnel && envTunnel.trim()) {
672
+ const clean = envTunnel.trim();
673
+ return {
674
+ url: clean.endsWith('/') ? clean.slice(0, -1) : clean,
675
+ source: 'cloudflare_tunnel_fallback',
676
+ is_fallback: true
677
+ };
678
+ }
679
+
680
+ return { url: null, source: 'none', is_fallback: false };
681
+ }
682
+