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
@@ -4,25 +4,57 @@ import { resolveBrainLayer } from '../core/config.mjs';
4
4
 
5
5
  function printHelp() {
6
6
  console.log(`
7
- total-recall command — Manage project-local custom CLI commands
7
+ total-recall command — Manage custom composable CLI commands
8
8
 
9
9
  Usage:
10
- total-recall command create <name> "<code>" Create a new custom CLI command
11
- total-recall command read <name> Read the code of a custom CLI command
12
- total-recall command update <name> "<code>" Update an existing custom CLI command
13
- total-recall command remove <name> Remove a custom CLI command
14
- total-recall command list List all custom CLI commands
10
+ total-recall command create <name> "<code>" [--global] Create a new custom CLI command
11
+ total-recall command read <name> [--global] Read the code of a custom CLI command
12
+ total-recall command update <name> "<code>" [--global] Update an existing custom CLI command
13
+ total-recall command remove <name> [--global] Remove a custom CLI command
14
+ total-recall command list [--global] List custom CLI commands
15
15
 
16
16
  Examples:
17
17
  npx total-recall command create hello "console.log('Hello from the brain!');"
18
+ npx total-recall command create my-tool "console.log(args);" --global
18
19
  npx total-recall command read hello
19
20
  npx total-recall command list
20
21
  `);
21
22
  }
22
23
 
23
- export default async function commandCmd(args) {
24
- const action = args[0];
25
- const name = args[1];
24
+ function resolveTargetDir(isGlobal) {
25
+ if (isGlobal) {
26
+ try {
27
+ const gBrain = resolveBrainLayer('global');
28
+ return path.join(gBrain.agentDir, 'commands');
29
+ } catch {
30
+ return path.join(process.env.HOME || '/root', '.agent', 'commands');
31
+ }
32
+ }
33
+
34
+ try {
35
+ const pBrain = resolveBrainLayer('project');
36
+ return path.join(pBrain.agentDir, 'commands');
37
+ } catch {
38
+ try {
39
+ const gBrain = resolveBrainLayer('global');
40
+ return path.join(gBrain.agentDir, 'commands');
41
+ } catch {
42
+ return path.join(process.env.HOME || '/root', '.agent', 'commands');
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function run(argv = []) {
48
+ const args = Array.isArray(argv) ? argv.slice(3) : [];
49
+ return commandCmd(args);
50
+ }
51
+
52
+ export default async function commandCmd(rawArgs = []) {
53
+ const isGlobal = rawArgs.includes('--global') || rawArgs.includes('-g');
54
+ const cleanArgs = rawArgs.filter(a => a !== '--global' && a !== '-g');
55
+
56
+ const action = cleanArgs[0];
57
+ const name = cleanArgs[1];
26
58
 
27
59
  if (!action || action === '--help' || action === '-h') {
28
60
  printHelp();
@@ -31,27 +63,17 @@ export default async function commandCmd(args) {
31
63
 
32
64
  if (!name && action !== 'list') {
33
65
  console.error(`Error: Missing command name.`);
34
- console.error(`Usage: total-recall command ${action} <name>`);
66
+ console.error(`Usage: total-recall command ${action} <name> [--global]`);
35
67
  process.exit(1);
36
68
  }
37
69
 
38
- // Ensure this is run in a project
39
- let projectBrain;
40
- try {
41
- projectBrain = resolveBrainLayer('project');
42
- } catch (err) {
43
- console.error(`Error: You must be inside a Total Recall project to manage custom commands.`);
44
- console.error(err.message);
45
- process.exit(1);
46
- }
47
-
48
- const commandsDir = path.join(projectBrain.agentDir, 'commands');
70
+ const commandsDir = resolveTargetDir(isGlobal);
49
71
 
50
72
  if (action === 'create') {
51
- const code = args[2];
73
+ const code = cleanArgs[2];
52
74
  if (!code) {
53
75
  console.error(`Error: Missing code snippet for the command.`);
54
- console.error(`Usage: total-recall command create <name> "<code>"`);
76
+ console.error(`Usage: total-recall command create <name> "<code>" [--global]`);
55
77
  process.exit(1);
56
78
  }
57
79
 
@@ -61,9 +83,13 @@ export default async function commandCmd(args) {
61
83
 
62
84
  const commandPath = path.join(commandsDir, `${name}.mjs`);
63
85
 
64
- // Wrap code in a default exported function for CLI execution
86
+ // Wrap code in an exported run/default function for composable CLI execution
65
87
  const fileContent = `// Auto-generated custom CLI command: ${name}
66
- export default async function run(args) {
88
+ export async function run(argv = []) {
89
+ const args = Array.isArray(argv) ? argv.slice(3) : [];
90
+ ${code}
91
+ }
92
+ export default async function (args = []) {
67
93
  ${code}
68
94
  }
69
95
  `;
@@ -76,26 +102,29 @@ export default async function run(args) {
76
102
  if (fs.existsSync(commandPath)) {
77
103
  console.log(fs.readFileSync(commandPath, 'utf8'));
78
104
  } else {
79
- console.error(`Error: Custom command '${name}' does not exist.`);
105
+ console.error(`Error: Custom command '${name}' does not exist in ${commandsDir}.`);
80
106
  process.exit(1);
81
107
  }
82
108
  } else if (action === 'update') {
83
- const code = args[2];
109
+ const code = cleanArgs[2];
84
110
  if (!code) {
85
111
  console.error(`Error: Missing code snippet for the command.`);
86
- console.error(`Usage: total-recall command update <name> "<code>"`);
112
+ console.error(`Usage: total-recall command update <name> "<code>" [--global]`);
87
113
  process.exit(1);
88
114
  }
89
115
 
90
116
  const commandPath = path.join(commandsDir, `${name}.mjs`);
91
117
  if (!fs.existsSync(commandPath)) {
92
- console.error(`Error: Custom command '${name}' does not exist.`);
118
+ console.error(`Error: Custom command '${name}' does not exist in ${commandsDir}.`);
93
119
  process.exit(1);
94
120
  }
95
121
 
96
- // Wrap code in a default exported function for CLI execution
97
122
  const fileContent = `// Auto-generated custom CLI command: ${name}
98
- export default async function run(args) {
123
+ export async function run(argv = []) {
124
+ const args = Array.isArray(argv) ? argv.slice(3) : [];
125
+ ${code}
126
+ }
127
+ export default async function (args = []) {
99
128
  ${code}
100
129
  }
101
130
  `;
@@ -104,18 +133,28 @@ export default async function run(args) {
104
133
  console.log(`\x1b[32m✔ Successfully updated custom command: \x1b[1m${name}\x1b[0m`);
105
134
  console.log(` Saved to: ${commandPath}`);
106
135
  } else if (action === 'list') {
107
- if (!fs.existsSync(commandsDir)) {
108
- console.log('No custom commands found.');
109
- return;
136
+ const targets = isGlobal
137
+ ? [{ label: 'Global', dir: resolveTargetDir(true) }]
138
+ : [
139
+ { label: 'Project', dir: resolveTargetDir(false) },
140
+ { label: 'Global', dir: resolveTargetDir(true) }
141
+ ];
142
+
143
+ let foundAny = false;
144
+ for (const t of targets) {
145
+ if (fs.existsSync(t.dir)) {
146
+ const files = fs.readdirSync(t.dir).filter(f => f.endsWith('.mjs'));
147
+ if (files.length > 0) {
148
+ foundAny = true;
149
+ console.log(`\x1b[1m${t.label} CLI commands (${t.dir}):\x1b[0m`);
150
+ files.forEach(f => {
151
+ console.log(` - \x1b[36m${f.replace('.mjs', '')}\x1b[0m (npx total-recall ${f.replace('.mjs', '')})`);
152
+ });
153
+ }
154
+ }
110
155
  }
111
- const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.mjs'));
112
- if (files.length === 0) {
156
+ if (!foundAny) {
113
157
  console.log('No custom commands found.');
114
- } else {
115
- console.log(`\x1b[1mCustom CLI commands in ${commandsDir}:\x1b[0m`);
116
- files.forEach(f => {
117
- console.log(` - ${f.replace('.mjs', '')}`);
118
- });
119
158
  }
120
159
  } else if (action === 'remove') {
121
160
  const commandPath = path.join(commandsDir, `${name}.mjs`);
@@ -25,7 +25,7 @@ Available Harness IDs:
25
25
  Examples:
26
26
  npx total-recall harness list
27
27
  npx total-recall harness dispatch claude "Review and run code quality checks on src/core/"
28
- npx total-recall harness dispatch ollama --node macmini "What is test-time compute scaling?"
28
+ npx total-recall harness dispatch ollama --node build-box "What is test-time compute scaling?"
29
29
  npx total-recall harness council "Propose architecture for decentralized research mesh"
30
30
  `);
31
31
  }
package/src/cli/init.mjs CHANGED
@@ -223,7 +223,7 @@ export default async function init(args) {
223
223
 
224
224
  const dirs = [
225
225
  path.join(agentDir, 'skills'),
226
- path.join(agentDir, 'plugins'),
226
+ path.join(agentDir, 'skills', 'total-recall', 'plugins'),
227
227
  ];
228
228
 
229
229
  let created = 0;
@@ -917,4 +917,6 @@ export default async function init(args) {
917
917
  } else if (opts.yes) {
918
918
  log(`Dashboard (when server running): ${dashboardUrl}`);
919
919
  }
920
+
921
+ process.exit(0);
920
922
  }
package/src/cli/mesh.mjs CHANGED
@@ -25,7 +25,12 @@ import {
25
25
  setHeadscalePolicy,
26
26
  buildMeshSshPolicy,
27
27
  } from '../core/headscale-client.mjs';
28
- import { findMeshNode, listEnrichedMeshNodes, setMeshNodeAccess } from '../core/mesh.mjs';
28
+ import {
29
+ findMeshNode,
30
+ listEnrichedMeshNodes,
31
+ meshVaultRoot,
32
+ setMeshNodeAccess,
33
+ } from '../core/mesh.mjs';
29
34
  import {
30
35
  buildSshArgs,
31
36
  formatAccessTarget,
@@ -49,6 +54,8 @@ function printHelp() {
49
54
  access <node> --user <u> [--port <n>] [--host <h>] [--identity <path>]
50
55
  Record how to reach a node
51
56
  access import Propose access from ~/.ssh/config (--apply to save)
57
+ access sync Learn login accounts from every peer already reachable
58
+ access discover [node] Find a working login with this machine's keys and record it
52
59
  ping [node] Measure round-trip latency to a node (or all nodes) (--json)
53
60
  leader Show cluster leader election status and current leader (--json)
54
61
  enroll [options] Enroll this node on the mesh control server
@@ -76,8 +83,8 @@ function printHelp() {
76
83
  Examples:
77
84
  npx total-recall mesh nodes
78
85
  npx total-recall mesh access import
79
- npx total-recall mesh ssh macmini
80
- npx total-recall mesh ssh macmini 'uptime'
86
+ npx total-recall mesh ssh build-box
87
+ npx total-recall mesh ssh build-box 'uptime'
81
88
  npx total-recall mesh policy init-ssh
82
89
  `);
83
90
  }
@@ -88,6 +95,42 @@ function fail(message, hint) {
88
95
  process.exitCode = 1;
89
96
  }
90
97
 
98
+ function describeDiscoveryFailure(result, node) {
99
+ const name = String(node.hostname).split('.')[0];
100
+ switch (result.reason) {
101
+ case 'host-key':
102
+ return `host key not trusted yet — run 'total-recall mesh ssh ${name}' once to confirm it`;
103
+ case 'unreachable':
104
+ return 'not answering on ssh (asleep, offline, or no ssh server)';
105
+ case 'no-address':
106
+ return 'no address known';
107
+ case 'no-candidate-user':
108
+ return `no account to try — 'total-recall mesh access ${name} --user <login>'`;
109
+ default:
110
+ return `none of ${result.attempts} account/key pairs was accepted — authorise this machine's key on ${name}`;
111
+ }
112
+ }
113
+
114
+ // Say what actually blocks a node, so an untrusted host key or a machine with
115
+ // no ssh server is not reported the same way as a missing account.
116
+ function explainUnreachable(result) {
117
+ const name = String(result.hostname).split('.')[0];
118
+ const text = String(result.error || '');
119
+ if (!result.sshConfigured) {
120
+ return `no login recorded — try 'total-recall mesh access sync' or 'total-recall mesh access discover ${name}'`;
121
+ }
122
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(text)) {
123
+ return `host key not trusted yet — run 'total-recall mesh ssh ${name}' once to confirm it`;
124
+ }
125
+ if (/Permission denied/i.test(text)) {
126
+ return `${result.sshTarget} refused this machine's key — authorise it on ${name}`;
127
+ }
128
+ if (/timed out|No route to host|Connection refused/i.test(text)) {
129
+ return 'not answering on ssh (asleep, offline, or no ssh server)';
130
+ }
131
+ return text.split('\n')[0] || 'probe failed';
132
+ }
133
+
91
134
  // A file-mode control server cannot accept an API-written policy. That is a
92
135
  // server configuration problem, not a user error, so say what to change.
93
136
  function reportPolicyModeError(err) {
@@ -102,18 +145,20 @@ function reportPolicyModeError(err) {
102
145
  }
103
146
 
104
147
  export default async function meshCli(argv = []) {
105
- const args = [...argv];
106
- if (!args.length || args[0] === '--help' || args[0] === '-h') {
148
+ if (!argv.length || argv[0] === '--help' || argv[0] === '-h') {
107
149
  printHelp();
108
150
  return;
109
151
  }
110
152
 
111
- const layer = parseLayerFlag(args);
153
+ // parseLayerFlag returns { layer, remainingArgs }. Passing the object on
154
+ // meant `--global`/`--project` were silently ignored and left in the args.
155
+ const { layer, remainingArgs } = parseLayerFlag(argv);
156
+ const args = [...remainingArgs];
112
157
  const brainDir = resolveBrainDir(layer);
113
- // Node entities must be read and written in the brain the caller selected;
114
- // falling back to the default root silently splits one machine's facts
115
- // across two brains depending on which command touched it last.
116
- const vaultRoot = path.join(brainDir, 'memory-vault');
158
+ // How to reach a machine is a fact about the machine, so node entities live
159
+ // in its global brain by default (see meshVaultRoot); an explicit layer flag
160
+ // still selects a brain for anyone who wants per-project records.
161
+ const vaultRoot = layer === 'auto' ? meshVaultRoot() : path.join(brainDir, 'memory-vault');
117
162
  const command = args.shift();
118
163
 
119
164
  // `--help` after a subcommand must never EXECUTE that subcommand. Only the
@@ -168,8 +213,14 @@ export default async function meshCli(argv = []) {
168
213
  try {
169
214
  const probeCmd = 'echo "agy=$(which agy 2>/dev/null) claude=$(which claude 2>/dev/null) codex=$(which codex 2>/dev/null) gemini=$(which gemini 2>/dev/null) ollama=$(which ollama 2>/dev/null) docker=$(which docker 2>/dev/null) node=$(which node 2>/dev/null) git=$(which git 2>/dev/null)"';
170
215
  const { execMeshCommand } = await import('../core/mesh.mjs');
171
- const res = await execMeshCommand(node.hostname, probeCmd, { vaultRoot, timeoutMs: 4000 });
216
+ // Cloud peers routinely need several seconds; a 4 s budget reported
217
+ // reachable machines as down.
218
+ const res = await execMeshCommand(node.hostname, probeCmd, { vaultRoot, timeoutMs: 15000 });
172
219
  nodeInfo.reachable = res.success;
220
+ if (res.success && !node.access?.verified_at) {
221
+ // A login that just worked is the only kind worth sharing with peers.
222
+ await setMeshNodeAccess(node.hostname, { verified_at: new Date().toISOString() }, { vaultRoot });
223
+ }
173
224
  if (res.success) {
174
225
  const parts = res.stdout.split(/\s+/);
175
226
  for (const part of parts) {
@@ -214,6 +265,10 @@ export default async function meshCli(argv = []) {
214
265
  console.log(`│ ${pad(r.hostname, 40)} │ ${pad(r.ip, 14)} │ ${pad(roleStr, 16)} │ ${pad(sshStr, 16)} │ ${pad(hStr, 36)} │ ${pad(runStr, 24)} │`);
215
266
  }
216
267
  console.log('└──────────────────────────────────────────┴────────────────┴──────────────────┴──────────────────┴──────────────────────────────────────┴──────────────────────────┘\n');
268
+ for (const r of results.filter((x) => !x.self && !x.reachable)) {
269
+ console.log(` ${String(r.hostname).split('.')[0].padEnd(20)} ${explainUnreachable(r)}`);
270
+ }
271
+ if (results.some((x) => !x.self && !x.reachable)) console.log('');
217
272
  return;
218
273
  }
219
274
 
@@ -322,7 +377,7 @@ export default async function meshCli(argv = []) {
322
377
  if (command === 'exec') {
323
378
  const target = args.shift();
324
379
  if (!target || !args.length) {
325
- fail('`exec` requires a node name and a command.', 'Example: total-recall mesh exec macmini uptime');
380
+ fail('`exec` requires a node name and a command.', 'Example: total-recall mesh exec build-box uptime');
326
381
  return;
327
382
  }
328
383
  const jsonMode = args.includes('--json');
@@ -353,6 +408,57 @@ export default async function meshCli(argv = []) {
353
408
  return;
354
409
  }
355
410
 
411
+ if (target === 'discover') {
412
+ const { discoverNodeAccess } = await import('../core/mesh-discover.mjs');
413
+ const wanted = args.find((a) => !a.startsWith('-'));
414
+ const targets = wanted
415
+ ? [findMeshNode(wanted, vaultRoot)].filter(Boolean)
416
+ : listEnrichedMeshNodes(vaultRoot).filter(
417
+ (n) => !n.self && n.ip && n.online !== false && !n.access?.verified_at,
418
+ );
419
+ if (!targets.length) {
420
+ console.log(wanted ? `No mesh node matches "${wanted}".` : 'Every online node already has a verified login.');
421
+ if (wanted) process.exitCode = 1;
422
+ return;
423
+ }
424
+ let missing = 0;
425
+ for (const node of targets) {
426
+ const result = await discoverNodeAccess(node, { vaultRoot });
427
+ const name = String(node.hostname).padEnd(38);
428
+ if (result.found) {
429
+ const key = result.access.identity_file ? ` (key ${result.access.identity_file})` : '';
430
+ console.log(` ✅ ${name} ${result.access.ssh_user}${key}`);
431
+ } else {
432
+ missing += 1;
433
+ console.log(` ❌ ${name} ${describeDiscoveryFailure(result, node)}`);
434
+ }
435
+ }
436
+ if (missing) process.exitCode = 1;
437
+ return;
438
+ }
439
+
440
+ if (target === 'sync') {
441
+ const { syncAccessFromPeers } = await import('../core/mesh-access-sync.mjs');
442
+ const { peers, learned } = await syncAccessFromPeers({ vaultRoot });
443
+ for (const peer of peers) {
444
+ const state = peer.read ? `read ${peer.documents} node record(s)` : `skipped: ${peer.reason}`;
445
+ console.log(` ${String(peer.hostname).padEnd(38)} ${state}`);
446
+ }
447
+ console.log('');
448
+ if (!learned.length) {
449
+ console.log('Nothing new: every login a reachable peer knows is already recorded here.');
450
+ return;
451
+ }
452
+ for (const item of learned) {
453
+ console.log(
454
+ ` ${item.written ? '✅' : '⚠️ '} ${String(item.hostname).padEnd(38)} ${item.access.ssh_user}` +
455
+ ` (from ${String(item.from).split('.')[0]})`,
456
+ );
457
+ }
458
+ if (learned.some((item) => !item.written)) process.exitCode = 1;
459
+ return;
460
+ }
461
+
356
462
  if (target === 'import') {
357
463
  const nodes = listEnrichedMeshNodes(vaultRoot);
358
464
  const proposals = proposeAccessFromSshConfig(nodes, readSshConfig());