total-recall-brain 3.36.0 → 3.37.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.
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-DZ4ypNEl.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-CpvEw3iA.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.36.0",
3
+ "version": "3.37.0",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
package/src/cli/agent.mjs CHANGED
@@ -23,6 +23,14 @@ Options:
23
23
  --detach Spawn process detached in background (default: true)
24
24
  --name <label> Assign a human-readable name to the agent session
25
25
  --tail <lines> Number of log lines to inspect (default: 50)
26
+ --cwd <dir> Working directory for the agent (on the target node with --node)
27
+ --tools <list> Built-in tools for the harness: default (all), none, or e.g. "Bash,Read,WebFetch"
28
+ (alias --allow-tools; --no-tools = none). Spawns default to all tools.
29
+ --setting-sources <list> Settings layers the harness loads (e.g. local, or user,project)
30
+
31
+ Auth:
32
+ Claude Code reads CLAUDE_CODE_OAUTH_TOKEN (from \`claude setup-token\`) from the environment,
33
+ else from the Total Recall secret store, so SSH/launchd sessions without a login keychain work.
26
34
 
27
35
  Available Harnesses:
28
36
  agy, claude, codex, gemini
@@ -31,11 +39,53 @@ Examples:
31
39
  npx total-recall agent list
32
40
  npx total-recall agent spawn claude "Refactor src/cli/harness.mjs error handling"
33
41
  npx total-recall agent spawn agy "Crawl recent preprints on quantum shuttling" --name "Quantum Scout"
42
+ npx total-recall agent spawn claude "Run the lead search workflow" --node build-box --cwd '~/code/app'
34
43
  npx total-recall agent logs agent-claude-xyz --tail 100
35
44
  npx total-recall agent kill agent-claude-xyz
36
45
  `);
37
46
  }
38
47
 
48
+ const SPAWN_VALUE_FLAGS = {
49
+ '--node': 'targetNode',
50
+ '-n': 'targetNode',
51
+ '--name': 'name',
52
+ '--cwd': 'cwd',
53
+ '--tools': 'tools',
54
+ '--allow-tools': 'tools',
55
+ '--setting-sources': 'settingSources',
56
+ };
57
+
58
+ /**
59
+ * Parse `agent spawn` arguments. Option values are consumed with their flag, so
60
+ * they never leak into the task text; `--tools ""` is a real (empty) value.
61
+ */
62
+ export function parseSpawnArgs(rest) {
63
+ const out = { detach: true, isJson: false, name: null, targetNode: null, cwd: undefined, tools: undefined, settingSources: undefined };
64
+ const positional = [];
65
+ for (let i = 0; i < rest.length; i++) {
66
+ const arg = rest[i];
67
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
68
+ const flag = eq > 0 ? arg.slice(0, eq) : arg;
69
+ if (SPAWN_VALUE_FLAGS[flag]) {
70
+ let value;
71
+ if (eq > 0) value = arg.slice(eq + 1);
72
+ else {
73
+ if (i + 1 >= rest.length) throw new Error(`${flag} requires a value`);
74
+ value = rest[++i];
75
+ }
76
+ out[SPAWN_VALUE_FLAGS[flag]] = value;
77
+ } else if (arg === '--json') out.isJson = true;
78
+ else if (arg === '--no-detach') out.detach = false;
79
+ else if (arg === '--detach') out.detach = true;
80
+ else if (arg === '--no-tools') out.tools = 'none';
81
+ else if (arg.startsWith('--')) throw new Error(`Unknown option for agent spawn: ${arg}`);
82
+ else positional.push(arg);
83
+ }
84
+ out.harness = positional[0];
85
+ out.task = positional.slice(1).join(' ').trim();
86
+ return out;
87
+ }
88
+
39
89
  export async function run(argv) {
40
90
  let args = argv;
41
91
  if (Array.isArray(args) && args[0]?.endsWith('node')) {
@@ -79,34 +129,25 @@ export async function run(argv) {
79
129
  }
80
130
 
81
131
  if (command === 'spawn') {
82
- let targetNode = null;
83
- const filteredRest = [];
84
- for (let i = 0; i < rest.length; i++) {
85
- if (rest[i] === '--node' || rest[i] === '-n') {
86
- targetNode = rest[i + 1];
87
- i++;
88
- } else {
89
- filteredRest.push(rest[i]);
90
- }
132
+ let parsed;
133
+ try {
134
+ parsed = parseSpawnArgs(rest);
135
+ } catch (err) {
136
+ console.error(`Error: ${err.message}`);
137
+ process.exit(1);
91
138
  }
92
-
93
- const harness = filteredRest[0];
94
- const task = filteredRest.filter(r => !r.startsWith('--')).slice(1).join(' ').trim();
95
- const detach = !filteredRest.includes('--no-detach');
96
- const isJson = filteredRest.includes('--json');
97
- const nameIdx = filteredRest.indexOf('--name');
98
- const name = nameIdx !== -1 ? filteredRest[nameIdx + 1] : null;
139
+ const { harness, task, detach, isJson, name, targetNode, cwd, tools, settingSources } = parsed;
99
140
 
100
141
  if (!harness || !task) {
101
142
  console.error('Error: Please specify harness ID and task prompt.');
102
- console.error('Usage: npx total-recall agent spawn <harness> "<task>" [--name "..."] [--node <node>]');
143
+ console.error('Usage: npx total-recall agent spawn <harness> "<task>" [--name "..."] [--node <node>] [--cwd <dir>] [--tools <list|none|default>]');
103
144
  process.exit(1);
104
145
  }
105
146
 
106
147
  const nodeStr = targetNode ? ` on mesh node \x1b[1;33m${targetNode}\x1b[0m` : '';
107
148
  if (!isJson) console.log(`\n🚀 Spawning agent on harness: \x1b[1;36m${harness}\x1b[0m${nodeStr}...`);
108
149
  try {
109
- const record = await spawnAgent(harness, task, { detach, name, node: targetNode });
150
+ const record = await spawnAgent(harness, task, { detach, name, node: targetNode, cwd, tools, settingSources });
110
151
  if (isJson) {
111
152
  console.log(JSON.stringify(record, null, 2));
112
153
  return;
@@ -13,11 +13,104 @@ function toTitleCase(kebab) {
13
13
  .join(" ");
14
14
  }
15
15
 
16
+ /** CLI token subcommands every API-key plugin starts with. */
17
+ function tokenCliBlock(apiKeys) {
18
+ return `import { spawnSync } from "node:child_process";
19
+
20
+ // The service API key lives in the Total Recall secrets store, never argv or logs.
21
+ const TOKEN_KEY = ${JSON.stringify(apiKeys[0])};
22
+ const TR = process.env.TOTAL_RECALL_BIN || "total-recall";
23
+
24
+ function tokenValue() {
25
+ if (process.env[TOKEN_KEY]) return process.env[TOKEN_KEY].trim();
26
+ const run = spawnSync(TR, ["secret", "get", TOKEN_KEY], { encoding: "utf8" });
27
+ if (run.status !== 0) return "";
28
+ const lines = String(run.stdout || "").split("\\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("{"));
29
+ return lines.length ? lines[lines.length - 1] : "";
30
+ }
31
+
32
+ async function readStdin() {
33
+ const chunks = [];
34
+ for await (const chunk of process.stdin) chunks.push(chunk);
35
+ return Buffer.concat(chunks).toString("utf8").trim();
36
+ }
37
+
38
+ // TODO(plugin author): call a cheap authenticated endpoint here and return
39
+ // { valid: true } or { valid: false, message }. Never save a key that fails it.
40
+ async function verifyToken(_value) {
41
+ return { valid: true };
42
+ }
43
+
44
+ async function tokenCommand(args) {
45
+ const [action = "status"] = args;
46
+ const json = args.includes("--json");
47
+ if (action === "status") {
48
+ const status = { present: Boolean(tokenValue()), secretKey: TOKEN_KEY };
49
+ console.log(json ? JSON.stringify(status) : \`API key: \${status.present ? "present" : "missing"} (\${TOKEN_KEY})\`);
50
+ if (!status.present) process.exitCode = 1;
51
+ return;
52
+ }
53
+ if (action === "set" && args.includes("--stdin")) {
54
+ const value = await readStdin();
55
+ if (!value) { console.error("No key received on stdin."); process.exitCode = 2; return; }
56
+ const check = await verifyToken(value);
57
+ if (!check.valid) { console.error(\`Key rejected, not saved: \${check.message || "verification failed"}\`); process.exitCode = 1; return; }
58
+ const saved = spawnSync(TR, ["secret", "set", TOKEN_KEY, "--stdin"], { input: value, encoding: "utf8" });
59
+ if (saved.status !== 0) { console.error("Could not save the key to the secrets store."); process.exitCode = 1; return; }
60
+ console.log(json ? JSON.stringify({ saved: true, valid: true }) : "API key verified and saved.");
61
+ return;
62
+ }
63
+ console.error("usage: token <status|set --stdin>");
64
+ process.exitCode = 2;
65
+ }
66
+
67
+ `;
68
+ }
69
+
70
+ /** UI panel with the paste-your-key form every API-key plugin starts with. */
71
+ function tokenPanelSource(id, panelClass, pluginName, apiKeys) {
72
+ return `// Paste-your-key panel. Emits \`save-token\`; the host pipes it to
73
+ // \`total-recall ${id} token set --stdin\` (stored in secrets, never shown again).
74
+ class ${panelClass} extends HTMLElement {
75
+ set view(value) { this._view = value; this.render(); }
76
+ get view() { return this._view; }
77
+ connectedCallback() { this.render(); }
78
+ render() {
79
+ const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
80
+ root.replaceChildren();
81
+ const present = Boolean(this._view && this._view.present);
82
+ const style = document.createElement('style');
83
+ style.textContent = ':host { display:block; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--spacing-md); } .row { display:flex; gap:8px; margin-top:8px; } input { flex:1; padding:6px 8px; }';
84
+ const title = document.createElement('h3');
85
+ title.textContent = ${JSON.stringify(pluginName + ' API key')};
86
+ const state = document.createElement('p');
87
+ state.textContent = present ? 'A key is saved. Paste a new one to replace it.' : 'No key yet. Paste it to start using this plugin.';
88
+ const form = document.createElement('form');
89
+ form.className = 'row';
90
+ const input = document.createElement('input');
91
+ input.type = 'password'; input.autocomplete = 'off'; input.required = true;
92
+ input.placeholder = ${JSON.stringify(apiKeys[0])};
93
+ input.setAttribute('aria-label', ${JSON.stringify(pluginName + ' API key')});
94
+ const button = document.createElement('button');
95
+ button.type = 'submit'; button.textContent = 'Save and test';
96
+ form.append(input, button);
97
+ form.addEventListener('submit', (event) => {
98
+ event.preventDefault();
99
+ this.dispatchEvent(new CustomEvent('save-token', { detail: { key: ${JSON.stringify(apiKeys[0])}, token: input.value.trim() }, bubbles: true, composed: true }));
100
+ input.value = '';
101
+ });
102
+ root.append(style, title, state, form);
103
+ }
104
+ }
105
+ customElements.define('${id}-panel', ${panelClass});
106
+ `;
107
+ }
108
+
16
109
  export async function createPlugin(args = []) {
17
110
  const isGlobal = args.includes("--global") || args.includes("-g");
18
- const withCli = args.includes("--with-cli");
111
+ let withCli = args.includes("--with-cli");
19
112
  const withGenerator = args.includes("--with-generator");
20
- const withUi = args.includes("--with-ui");
113
+ let withUi = args.includes("--with-ui");
21
114
  const withTask = args.includes("--with-task");
22
115
  const createGithub = args.includes("--github");
23
116
  const publicRepo = args.includes("--public");
@@ -32,6 +125,7 @@ export async function createPlugin(args = []) {
32
125
  let taskSchedule = null;
33
126
  let taskIntent = null;
34
127
  const useCases = [];
128
+ const apiKeys = [];
35
129
 
36
130
  const cleanArgs = [];
37
131
  for (let i = 0; i < args.length; i++) {
@@ -39,6 +133,7 @@ export async function createPlugin(args = []) {
39
133
  if (["--global", "-g", "--with-cli", "--with-generator", "--with-ui", "--with-task", "--with-skill", "--capability", "--github", "--public", "--no-install"].includes(arg)) {
40
134
  continue;
41
135
  }
136
+ if (arg === "--api-key" && args[i + 1]) { apiKeys.push(args[++i]); continue; }
42
137
  if (arg === "--repo-dir" && args[i + 1]) { repoDirArg = args[++i]; continue; }
43
138
  if (arg === "--name" && args[i + 1]) {
44
139
  name = args[i + 1];
@@ -152,6 +247,14 @@ export async function createPlugin(args = []) {
152
247
  };
153
248
  if (useCases.length > 0) manifest.use_cases = useCases;
154
249
 
250
+ // House rule: a plugin for a service that needs an API key ships a UI to paste
251
+ // that key and a CLI to save it, so it works the moment the key is pasted.
252
+ if (apiKeys.length) {
253
+ withCli = true;
254
+ withUi = true;
255
+ manifest.secrets = apiKeys.map((key) => ({ key, description: `${pluginName} service API key (paste it in the plugin UI)`, required: true }));
256
+ }
257
+
155
258
  if (category) {
156
259
  manifest.ssss_schemas = {
157
260
  categories: [
@@ -183,7 +286,7 @@ export async function createPlugin(args = []) {
183
286
  */
184
287
  // Scaffold: report honestly what exists. Add each real subcommand to
185
288
  // IMPLEMENTED and to plugin.json "cli.subcommands" as it is built.
186
- const IMPLEMENTED = {};
289
+ ${apiKeys.length ? tokenCliBlock(apiKeys) : ''}const IMPLEMENTED = {};${apiKeys.length ? '\nIMPLEMENTED.token = tokenCommand;' : ''}
187
290
 
188
291
  export async function run(argv = []) {
189
292
  const args = Array.isArray(argv) ? argv.slice(3) : [];
@@ -221,7 +324,11 @@ export default run;
221
324
  const panelClass = `${id.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join('')}Panel`;
222
325
  fs.mkdirSync(path.join(pluginDir, 'ui'), { recursive: true });
223
326
  fs.writeFileSync(path.join(pluginDir, 'ui', 'DESIGN.md'), '---\ncolors:\n text: "#20242a"\n surface: "#ffffff"\n border: "#dce2e8"\nspacing:\n md: 12px\nrounded:\n md: 8px\n---\n\n# Design tokens\n\nReplace these defaults with the host design tokens.\n');
224
- fs.writeFileSync(path.join(pluginDir, 'ui', 'panel.js'), `class ${panelClass} extends HTMLElement {\n connectedCallback() {\n const root = this.shadowRoot || this.attachShadow({ mode: 'open' });\n root.replaceChildren();\n const style = document.createElement('style');\n style.textContent = ':host { display:block; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--spacing-md); }';\n const text = document.createElement('p');\n text.textContent = ${JSON.stringify(`${pluginName} panel scaffold. Add the actual capability before sharing.`)};\n root.append(style, text);\n }\n}\ncustomElements.define('${id}-panel', ${panelClass});\n`);
327
+ if (apiKeys.length) {
328
+ manifest.ui.elements[0].events = ['save-token'];
329
+ manifest.ui.elements[0].props = { view: { type: 'object', description: `Printed by \`total-recall ${id} token status --json\`: { present, secretKey }` } };
330
+ }
331
+ fs.writeFileSync(path.join(pluginDir, 'ui', 'panel.js'), apiKeys.length ? tokenPanelSource(id, panelClass, pluginName, apiKeys) : `class ${panelClass} extends HTMLElement {\n connectedCallback() {\n const root = this.shadowRoot || this.attachShadow({ mode: 'open' });\n root.replaceChildren();\n const style = document.createElement('style');\n style.textContent = ':host { display:block; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--spacing-md); }';\n const text = document.createElement('p');\n text.textContent = ${JSON.stringify(`${pluginName} panel scaffold. Add the actual capability before sharing.`)};\n root.append(style, text);\n }\n}\ncustomElements.define('${id}-panel', ${panelClass});\n`);
225
332
  }
226
333
 
227
334
  if (isCapability) {
@@ -348,6 +455,7 @@ export default generateContext;
348
455
  if (withCli) lines.push(`- **CLI Command**: \`npx total-recall ${id}\``);
349
456
  if (withTask) lines.push(`- **Scheduled task**: \`${taskCommand}\` at \`${taskSchedule}\` (selected node; implement the command and choose a node before use)`);
350
457
  if (withUi) lines.push('- **UI**: `ui/panel.js` custom element with DESIGN.md tokens');
458
+ if (apiKeys.length) lines.push('- **API key**: paste it in the plugin UI (`save-token` event) or pipe it to `npx total-recall ' + id + ' token set --stdin`; stored as `' + apiKeys[0] + '` in the secrets store');
351
459
  lines.push("");
352
460
 
353
461
  fs.writeFileSync(path.join(pluginDir, "README.md"), lines.join("\n"), "utf8");
@@ -81,10 +81,34 @@ import { agentDir as configuredAgentDir, getActiveBrains } from '../core/config.
81
81
  // pasted/piped value normally carries), not all whitespace, so a value that
82
82
  // legitimately starts or ends with a space round-trips intact.
83
83
  function readStdinValue() {
84
+ // A terminal never sends EOF on Enter, so readFileSync(0) would wait for a
85
+ // Ctrl-D on an empty line. Read one line (Enter ends it) with echo off.
86
+ if (process.stdin.isTTY) return readTtyLine();
84
87
  const raw = fs.readFileSync(0, 'utf8');
85
88
  return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
86
89
  }
87
90
 
91
+ function readTtyLine() {
92
+ const stty = (arg) => { try { execFileSync('stty', [arg], { stdio: ['inherit', 'ignore', 'ignore'] }); } catch { /* not a tty */ } };
93
+ process.stderr.write('Paste the value and press Enter: ');
94
+ stty('-echo');
95
+ let line = '';
96
+ try {
97
+ const chunk = Buffer.alloc(4096);
98
+ for (;;) {
99
+ let n;
100
+ try { n = fs.readSync(0, chunk, 0, chunk.length, null); } catch (err) { if (err.code === 'EAGAIN') continue; throw err; }
101
+ if (n === 0) break;
102
+ line += chunk.toString('utf8', 0, n);
103
+ if (/[\r\n]/.test(line)) break;
104
+ }
105
+ } finally {
106
+ stty('echo');
107
+ process.stderr.write('\n');
108
+ }
109
+ return line.split(/[\r\n]/)[0];
110
+ }
111
+
88
112
  /**
89
113
  * Read the OS clipboard. Keeps a pasted credential inside this process rather
90
114
  * than routing it through argv, shell history, or an agent transcript.
@@ -133,13 +157,16 @@ function readSecretValue(label) {
133
157
  input.resume();
134
158
  input.setEncoding('utf8');
135
159
  let buf = '';
136
- const onData = (ch) => {
160
+ // A paste arrives as one chunk (often with the Enter in it): walk it character by character.
161
+ const onData = (chunk) => { for (const ch of chunk) { if (onChar(ch)) return; } };
162
+ const onChar = (ch) => {
137
163
  if (ch === '\n' || ch === '\r' || ch === '\u0004') {
138
164
  input.setRawMode(false);
139
165
  input.pause();
140
166
  input.removeListener('data', onData);
141
167
  process.stdout.write('\n');
142
168
  resolve(buf.trim());
169
+ return true;
143
170
  } else if (ch === '\u0003') {
144
171
  input.setRawMode(false);
145
172
  process.stdout.write('\n');
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
- import { HARNESS_SPECS, detectHarnesses } from './meta-harness.mjs';
5
+ import { HARNESS_SPECS, buildHarnessArgs, normalizeToolSelection, resolveHarnessEnv, shellQuote } from './meta-harness.mjs';
6
6
  import { findBinaryInPath } from './runtime.mjs';
7
7
  import { logger } from './logger.mjs';
8
8
 
@@ -81,14 +81,45 @@ export function listAgents() {
81
81
  return updated;
82
82
  }
83
83
 
84
+ /** Tool selection as recorded in the agent registry. */
85
+ function describeTools(tools) {
86
+ const selection = normalizeToolSelection(tools);
87
+ return selection === null ? 'default' : selection || 'none';
88
+ }
89
+
90
+ /**
91
+ * The `total-recall agent spawn` command line that recreates a spawn on another
92
+ * node. Every value is shell-quoted: the prompt, name and paths are caller data.
93
+ */
94
+ export function buildRemoteSpawnCommand(harnessId, taskPrompt, options = {}) {
95
+ const parts = ['total-recall', 'agent', 'spawn', harnessId, taskPrompt];
96
+ if (options.name) parts.push('--name', options.name);
97
+ if (options.cwd) parts.push('--cwd', options.cwd);
98
+ if (options.tools !== undefined && options.tools !== null) {
99
+ parts.push('--tools', Array.isArray(options.tools) ? options.tools.join(',') : String(options.tools));
100
+ }
101
+ if (options.settingSources) parts.push('--setting-sources', options.settingSources);
102
+ if (options.detach === false) parts.push('--no-detach');
103
+ parts.push('--json');
104
+ return parts.map(shellQuote).join(' ');
105
+ }
106
+
84
107
  /**
85
108
  * Spawn an agent harness headlessly or in background detach mode.
109
+ *
110
+ * A spawn runs a workflow, so the harness keeps its full built-in tool set and
111
+ * normal settings unless the caller narrows them:
112
+ * options.tools 'none' | 'default' | 'Bash,Read' | ['Bash', 'Read']
113
+ * options.settingSources e.g. 'local' or 'user,project'
114
+ * options.cwd working directory (on the target node for --node)
86
115
  */
87
116
  export async function spawnAgent(harnessId, taskPrompt, options = {}) {
88
117
  const spec = HARNESS_SPECS[harnessId];
89
118
  if (!spec) {
90
119
  throw new Error(`Unknown harness ID "${harnessId}". Available: ${Object.keys(HARNESS_SPECS).join(', ')}`);
91
120
  }
121
+ // Validate the tool/settings selection before touching any node.
122
+ const harnessArgs = buildHarnessArgs(spec, { tools: options.tools, settingSources: options.settingSources });
92
123
 
93
124
  // Remote mesh execution branch
94
125
  if (options.node) {
@@ -102,9 +133,10 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
102
133
 
103
134
  if (!isSelf) {
104
135
  const id = `agent-${harnessId}-${Date.now().toString(36)}`;
105
- const remoteCmd = `total-recall agent spawn ${harnessId} ${JSON.stringify(taskPrompt)} ${options.name ? `--name ${JSON.stringify(options.name)}` : ''} --json`;
136
+ const remoteCmd = buildRemoteSpawnCommand(harnessId, taskPrompt, options);
106
137
  const execResult = await execMeshCommand(options.node, remoteCmd, {
107
138
  vaultRoot: options.vaultRoot,
139
+ timeoutMs: options.timeoutMs,
108
140
  });
109
141
 
110
142
  let remoteRecord = null;
@@ -118,7 +150,8 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
118
150
  id: remoteRecord?.id || id,
119
151
  node: options.node,
120
152
  remote: true,
121
- status: execResult.success ? 'running' : 'failed',
153
+ status: execResult.success ? (remoteRecord?.status || 'running') : 'failed',
154
+ ...(execResult.success ? {} : { error: (execResult.stderr || execResult.stdout || '').slice(-2000) }),
122
155
  startedAt: remoteRecord?.startedAt || new Date().toISOString(),
123
156
  task: taskPrompt,
124
157
  harness: harnessId,
@@ -136,20 +169,27 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
136
169
  throw new Error(`Binary "${spec.binary}" for harness "${spec.name}" not found on PATH.`);
137
170
  }
138
171
 
172
+ // A remote caller sends `~/…` unexpanded; it means this node's home.
173
+ const requestedCwd = options.cwd && /^~(?=$|\/)/.test(options.cwd) ? path.join(os.homedir(), options.cwd.slice(1)) : options.cwd;
174
+ const cwd = requestedCwd ? path.resolve(requestedCwd) : process.cwd();
175
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
176
+ throw new Error(`Working directory does not exist: ${cwd}`);
177
+ }
178
+ const env = await resolveHarnessEnv(spec, process.env, options);
179
+
139
180
  const id = `agent-${harnessId}-${Date.now().toString(36)}`;
140
181
  const logDir = resolveLogDir();
141
182
  const logFile = path.join(logDir, `${id}.log`);
142
183
  const logFd = fs.openSync(logFile, 'a');
143
184
 
144
- const args = [...spec.defaultFlags, taskPrompt];
145
- const cwd = options.cwd || process.cwd();
185
+ const args = [...harnessArgs, taskPrompt];
146
186
 
147
187
  const child = spawn(binPath, args, {
148
188
  cwd,
149
189
  detached: options.detach !== false,
150
190
  stdio: options.detach !== false ? ['ignore', logFd, logFd] : 'pipe',
151
191
  env: {
152
- ...process.env,
192
+ ...env,
153
193
  TR_AGENT_ID: id,
154
194
  TR_HARNESS: harnessId
155
195
  }
@@ -163,6 +203,7 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
163
203
  pid: child.pid,
164
204
  task: taskPrompt,
165
205
  cwd,
206
+ tools: describeTools(options.tools),
166
207
  logFile,
167
208
  startedAt: new Date().toISOString(),
168
209
  status: 'running',
@@ -154,6 +154,43 @@ export const DEFAULT_POLICIES = [
154
154
  },
155
155
  ];
156
156
 
157
+ /**
158
+ * Logs written next to the brain rather than inside it. The installed
159
+ * LaunchAgents and `auto-pull.sh` send daemon/server/auto-pull output to
160
+ * `<agent home>/logs` (normally `~/.agent/logs`), which the brain policies above
161
+ * never saw: one node's `daemon.log` there reached 9 GB with the disk at 95%.
162
+ * Same rule as the brain log: truncate in place, never unlink.
163
+ *
164
+ * @type {Policy[]}
165
+ */
166
+ export const AGENT_HOME_POLICIES = [
167
+ {
168
+ id: 'agent-logs',
169
+ dir: 'logs',
170
+ match: /\.log$/,
171
+ mode: 'size',
172
+ limit: 25 * 1024 * 1024,
173
+ keepBytes: 5 * 1024 * 1024,
174
+ },
175
+ {
176
+ id: 'agent-system-logs',
177
+ dir: 'logs',
178
+ match: /^system-\d{4}-\d{2}-\d{2}\.jsonl$/,
179
+ mode: 'age',
180
+ limit: 14,
181
+ },
182
+ ];
183
+
184
+ /**
185
+ * The agent home that holds a conventionally placed brain
186
+ * (`<agent home>/skills/total-recall`), or null for any other layout.
187
+ */
188
+ export function agentHomeFor(brainDir) {
189
+ const abs = path.resolve(brainDir);
190
+ if (path.basename(abs) !== 'total-recall' || path.basename(path.dirname(abs)) !== 'skills') return null;
191
+ return path.dirname(path.dirname(abs));
192
+ }
193
+
157
194
  /** Is this path inside a protected directory? */
158
195
  export function isProtected(brainDir, target) {
159
196
  const root = path.resolve(brainDir);
@@ -219,10 +256,31 @@ function truncateKeepingTail(file, keepBytes) {
219
256
  /**
220
257
  * Apply every policy.
221
258
  *
222
- * @param {{brainDir: string, policies?: Policy[], dryRun?: boolean, now?: number}} opts
259
+ * `agentDir` (derived from a conventionally placed brain) also gets the
260
+ * AGENT_HOME_POLICIES; pass `agentDir: null` to skip it.
261
+ *
262
+ * @param {{brainDir: string, policies?: Policy[], agentDir?: string|null, agentPolicies?: Policy[], dryRun?: boolean, now?: number}} opts
223
263
  * @returns {{freed_bytes: number, removed: number, dry_run: boolean, results: object[]}}
224
264
  */
225
- export function pruneCaches({ brainDir, policies = DEFAULT_POLICIES, dryRun = false, now = Date.now() } = {}) {
265
+ export function pruneCaches({
266
+ brainDir,
267
+ policies = DEFAULT_POLICIES,
268
+ agentDir = agentHomeFor(brainDir),
269
+ agentPolicies = AGENT_HOME_POLICIES,
270
+ dryRun = false,
271
+ now = Date.now(),
272
+ } = {}) {
273
+ const report = runPolicies(brainDir, policies, { dryRun, now });
274
+ if (agentDir && path.resolve(agentDir) !== path.resolve(brainDir) && agentPolicies?.length) {
275
+ const extra = runPolicies(agentDir, agentPolicies, { dryRun, now });
276
+ report.freed_bytes += extra.freed_bytes;
277
+ report.removed += extra.removed;
278
+ report.results.push(...extra.results.map((r) => ({ ...r, root: 'agent-home' })));
279
+ }
280
+ return report;
281
+ }
282
+
283
+ function runPolicies(brainDir, policies, { dryRun, now }) {
226
284
  const results = [];
227
285
  let freed = 0;
228
286
  let removed = 0;