total-recall-brain 3.41.6 → 3.42.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 (86) hide show
  1. package/package.json +1 -1
  2. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +3118 -17
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -2
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +84 -40
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/src/exit-after-flush.mjs +22 -0
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-api-rotation.mjs +61 -6
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +1 -0
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +8 -0
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +20 -1
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +3 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/state.mjs +15 -7
  12. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  13. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  14. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  15. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +51 -7
  18. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +67 -25
  19. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +4 -4
  20. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +52 -6
  21. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +1 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +21 -8
  23. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/compat-harness.mjs +110 -0
  24. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +48 -22
  25. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +9 -4
  26. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +21 -9
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +9 -5
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +10 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +30 -3
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +6 -1
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +19 -3
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +222 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-worker.mjs +18 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +94 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +70 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +60 -19
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +32 -7
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +24 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +88 -125
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +110 -29
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +35 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +95 -5
  43. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +13 -1
  44. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +5 -5
  45. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +78 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/context-provider.mjs +113 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  48. package/src/cli/agent.mjs +11 -6
  49. package/src/cli/compact.mjs +3 -3
  50. package/src/cli/context.mjs +52 -19
  51. package/src/cli/harness.mjs +65 -147
  52. package/src/cli/init.mjs +4 -2
  53. package/src/cli/preflight.mjs +56 -13
  54. package/src/core/agent-manager.mjs +23 -329
  55. package/src/core/context-compactor.mjs +2 -1
  56. package/src/core/context-compiler.mjs +16 -25
  57. package/src/core/context-providers.mjs +69 -0
  58. package/src/core/meta-harness.mjs +27 -749
  59. package/src/core/plugin-runner.mjs +12 -5
  60. package/src/core/plugin-runtime.mjs +4 -2
  61. package/src/core/runtime.mjs +30 -604
  62. package/src/core/task-context.mjs +122 -0
  63. package/src/server/routes/context.mjs +4 -1
  64. package/scaffold/.agent/skills/decision/SKILL.md +0 -20
  65. package/scaffold/.agent/skills/decision/references/integration.md +0 -25
  66. package/scaffold/.agent/skills/decision/references/use-cases.md +0 -20
  67. package/scaffold/.agent/skills/meta-harness/SKILL.md +0 -15
  68. package/scaffold/.agent/skills/meta-harness/references/optimized/index.md +0 -20
  69. package/scaffold/.agent/skills/meta-harness/references/optimized/manifest.json +0 -79
  70. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +0 -135
  71. package/scaffold/.agent/skills/meta-harness/references/optimized/section-01.md +0 -5
  72. package/scaffold/.agent/skills/meta-harness/references/optimized/section-02.md +0 -17
  73. package/scaffold/.agent/skills/meta-harness/references/optimized/section-03.md +0 -8
  74. package/scaffold/.agent/skills/meta-harness/references/optimized/section-04.md +0 -2
  75. package/scaffold/.agent/skills/meta-harness/references/optimized/section-05.md +0 -6
  76. package/scaffold/.agent/skills/meta-harness/references/optimized/section-06.md +0 -14
  77. package/scaffold/.agent/skills/meta-harness/references/optimized/section-07.md +0 -8
  78. package/scaffold/.agent/skills/meta-harness/references/optimized/section-08.md +0 -4
  79. package/scaffold/.agent/skills/meta-harness/references/optimized/section-09.md +0 -20
  80. package/scaffold/.agent/skills/meta-harness/references/optimized/section-10.md +0 -6
  81. package/scaffold/.agent/skills/meta-harness/references/optimized/section-11.md +0 -5
  82. package/scaffold/.agent/skills/meta-harness/references/optimized/section-12.md +0 -7
  83. package/scaffold/.agent/skills/meta-harness/references/optimized/section-13.md +0 -4
  84. package/scaffold/.agent/skills/meta-harness/references/optimized/section-14.md +0 -7
  85. package/scaffold/.agent/skills/meta-harness/references/optimized/section-15.md +0 -9
  86. package/scaffold/.agent/skills/meta-harness/references/optimized/section-16.md +0 -5
@@ -9,12 +9,16 @@
9
9
  */
10
10
 
11
11
  import { spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
12
14
  import { ADAPTER_CATALOG } from '../capability/index.mjs';
13
15
  import { appendThreadTurn, updateThread } from './threads.mjs';
14
16
  import { notifyAlert } from './alerts.mjs';
15
17
  import { dispatchWithReceipt } from './dispatch.mjs';
16
- import { resolvePool } from './pool.mjs';
18
+ import { resolvePool, localExecutionNode } from './pool.mjs';
17
19
  import { lastJsonWith } from './json-reply.mjs';
20
+ import { createWorktree, removeWorktree, commitAll, headCommit } from './worktree.mjs';
21
+ import { forwardRemoteOperation } from './remote-operation.mjs';
18
22
 
19
23
  const MAX_DIFF = 30000;
20
24
  // Generated surfaces say nothing a reviewer can act on, and they crowd out the source.
@@ -27,25 +31,35 @@ const GENERATED = ['.cursorrules', '.cursor', 'openwiki', 'package-lock.json', '
27
31
  */
28
32
  export function extractGitDiff(options = {}) {
29
33
  const cwd = options.cwd || process.cwd();
30
- const read = (args, extra = []) => spawnSync('git', ['diff', ...extra, ...args, '--', '.', ...GENERATED], { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024 * 1024 });
31
- const done = (args, diff, target) => {
34
+ const run = args => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024 * 1024 });
35
+ const read = (args, extra = []) => run(['diff', ...(extra.length ? extra : ['--binary']), ...args, '--', '.', ...GENERATED]);
36
+ const ref = value => {
37
+ const result = run(['rev-parse', '--verify', `${value}^{commit}`]);
38
+ if (result.status !== 0) throw new Error(String(result.stderr || 'Invalid review reference').trim());
39
+ return result.stdout.trim();
40
+ };
41
+ const done = (args, diff, target, snapshot) => {
32
42
  const stat = read(args, ['--stat']).stdout || '';
33
- const untracked = spawnSync('git', ['ls-files', '-o', '--exclude-standard'], { cwd, encoding: 'utf8', timeout: 15000 }).stdout || '';
34
- return { ok: true, diff, target, stat: stat.trim(), untracked: untracked.trim().split('\n').filter(Boolean) };
43
+ const untracked = snapshot.mode === 'working' ? run(['ls-files', '-o', '--exclude-standard', '-z', '--', '.', ...GENERATED]).stdout || '' : '';
44
+ return { ok: true, diff, target, stat: stat.trim(), untracked: untracked.split('\0').filter(Boolean), snapshot };
35
45
  };
36
46
  const explicit = options.staged === true ? ['--staged'] : (options.target || options.branch) ? [options.target || options.branch] : null;
37
47
  try {
38
48
  if (explicit) {
49
+ if (explicit[0] !== '--staged' && explicit[0].startsWith('-')) throw new Error('Review targets must be Git references, not options');
39
50
  const result = read(explicit);
40
51
  if (result.status !== 0) return { ok: false, error: String(result.stderr || 'git diff failed').trim(), diff: '' };
41
- return done(explicit, result.stdout.trim(), explicit.join(' '));
52
+ let snapshot;
53
+ if (options.staged === true) snapshot = { mode: 'staged', ref: ref('HEAD') };
54
+ else if (explicit[0].includes('..')) snapshot = { mode: 'commit', ref: ref(explicit[0].split(/\.{2,3}/).at(-1) || 'HEAD') };
55
+ else snapshot = { mode: 'working', ref: ref(explicit[0]) };
56
+ return done(explicit, result.stdout.trim(), explicit.join(' '), snapshot);
42
57
  }
43
- const branchRange = `${getDefaultBranch(cwd)}...HEAD`;
44
- const branch = read([branchRange]);
45
- if (branch.status === 0 && branch.stdout.trim()) return done([branchRange], branch.stdout.trim(), branchRange);
46
- const working = read(['HEAD']);
58
+ const mergeBase = run(['merge-base', getDefaultBranch(cwd), 'HEAD']);
59
+ const base = mergeBase.status === 0 ? mergeBase.stdout.trim() : ref('HEAD');
60
+ const working = read([base]);
47
61
  if (working.status !== 0) return { ok: false, error: String(working.stderr || 'git diff failed').trim(), diff: '' };
48
- return done(['HEAD'], working.stdout.trim(), 'HEAD');
62
+ return done([base], working.stdout.trim(), 'working tree', { mode: 'working', ref: base });
49
63
  } catch (error) {
50
64
  return { ok: false, error: error.message, diff: '' };
51
65
  }
@@ -55,12 +69,33 @@ function getDefaultBranch(cwd) {
55
69
  try {
56
70
  const res = spawnSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf8', timeout: 3000 });
57
71
  if (res.status === 0 && res.stdout.includes('/')) {
58
- return res.stdout.trim().split('/').pop();
72
+ return res.stdout.trim();
59
73
  }
60
74
  } catch {}
61
75
  return 'main';
62
76
  }
63
77
 
78
+ /** Pin the selected diff in a disposable commit, so reviewers never read unrelated current files. */
79
+ function reviewSnapshot(cwd, selection) {
80
+ const directory = createWorktree(cwd, { ref: selection.snapshot.ref, prefix: 'meta-harness-review-' });
81
+ try {
82
+ if (selection.snapshot.mode !== 'commit') {
83
+ if (selection.diff) {
84
+ const patch = spawnSync('git', ['apply', '--index', '--binary', '-'], { cwd: directory, input: `${selection.diff}\n`, encoding: 'utf8', timeout: 15000 });
85
+ if (patch.status !== 0) throw new Error(`Could not pin the selected review diff: ${String(patch.stderr || patch.error).trim()}`);
86
+ }
87
+ for (const file of selection.untracked) {
88
+ const source = path.resolve(cwd, file), destination = path.resolve(directory, file);
89
+ if (!source.startsWith(`${path.resolve(cwd)}${path.sep}`) || !destination.startsWith(`${directory}${path.sep}`) || !fs.lstatSync(source).isFile()) throw new Error(`Unsafe untracked review file: ${file}`);
90
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
91
+ fs.copyFileSync(source, destination);
92
+ }
93
+ commitAll(directory, 'meta-harness: pin selected review snapshot');
94
+ }
95
+ return { directory, commit: headCommit(directory) };
96
+ } catch (error) { removeWorktree(cwd, directory); throw error; }
97
+ }
98
+
64
99
  /**
65
100
  * Assign an adversarial reviewer from an alternative model family
66
101
  */
@@ -132,14 +167,17 @@ export function parseReviewVerdict(response) {
132
167
  }
133
168
 
134
169
  export async function executeCodeReview(options = {}) {
170
+ const remote = await forwardRemoteOperation('review', null, options);
171
+ if (remote) return remote;
172
+ localExecutionNode(options);
135
173
  const cwd = options.cwd || process.cwd();
136
174
  const author = options.author || null;
137
175
  const diffResult = extractGitDiff({ cwd, target: options.target, staged: options.staged });
138
176
  if (!diffResult.ok) throw new Error(`Failed to read the diff: ${diffResult.error}`);
139
- if (!diffResult.diff) return { ok: true, verdict: 'NO_DIFF', target: diffResult.target, summary: `Nothing to review for ${diffResult.target}.` };
177
+ if (!diffResult.diff && !diffResult.untracked?.length) return { ok: true, verdict: 'NO_DIFF', target: diffResult.target, summary: `Nothing to review for ${diffResult.target}.` };
140
178
 
141
179
  const { ready, excluded } = await resolvePool(options);
142
- const reviewer = options.reviewer ? (ready.find(h => h.id === options.reviewer) || { id: options.reviewer, name: options.reviewer }) : selectAdversarialReviewer(author, ready);
180
+ const reviewer = options.reviewer ? ready.find(h => h.id === options.reviewer) : selectAdversarialReviewer(author, ready);
143
181
  if (!reviewer) throw new Error(`No independent reviewer can take work right now: ${excluded.map(e => `${e.harness} (${e.reason})`).join(', ') || 'no other harness available'}`);
144
182
 
145
183
  const authorFamily = ADAPTER_CATALOG[author]?.family || null;
@@ -147,14 +185,17 @@ export async function executeCodeReview(options = {}) {
147
185
  if (author && (reviewer.id === author || (authorFamily && authorFamily === reviewerFamily))) throw new Error(`${reviewer.id} shares a model family with the author ${author}, so it cannot review this independently`);
148
186
  const independence = author ? 'verified' : 'unverified: the author was not named';
149
187
  const truncated = diffResult.diff.length > MAX_DIFF;
150
- const result = await dispatchWithReceipt({
151
- agent: reviewer.id, cwd, contextTask: options.task || `Review the code changes in ${diffResult.target}`,
188
+ const snapshot = reviewSnapshot(cwd, diffResult);
189
+ let result;
190
+ try { result = await dispatchWithReceipt({
191
+ agent: reviewer.id, node: 'local', cwd: snapshot.directory, contextCwd: cwd, contextTask: options.task || `Review the code changes in ${diffResult.target}`,
152
192
  prompt: buildReviewPrompt(diffResult.diff, { task: options.task, branch: options.branch || diffResult.target, stat: diffResult.stat, untracked: diffResult.untracked }),
153
- permissions: { read: true, write: false }, allowUnknownAuth: true,
154
- }, { dispatchFn: options.dispatchFn, role: 'Reviewer' });
193
+ permissions: { read: true, write: false }, allowUnknownAuth: options.allowUnknownAuth === true,
194
+ }, { dispatchFn: options.dispatchFn, role: 'Reviewer' }); }
195
+ finally { removeWorktree(cwd, snapshot.directory); }
155
196
  if (!result.ok) return { ok: false, target: diffResult.target, reviewer: reviewer.id, error: result.error, failureKind: result.failureKind, excluded };
156
197
 
157
- const review = { ...parseReviewVerdict(result.response), reviewer: reviewer.id, reviewerName: reviewer.name || reviewer.id, author, target: diffResult.target, truncated, independence, timestamp: new Date().toISOString() };
198
+ const review = { ...parseReviewVerdict(result.response), reviewer: reviewer.id, reviewerName: reviewer.name || reviewer.id, author, target: diffResult.target, snapshotCommit: snapshot.commit, truncated, independence, node: 'local', workspace: cwd, ...(options.threadId ? { threadId: options.threadId } : {}), timestamp: new Date().toISOString() };
158
199
  let threadError;
159
200
  if (options.threadId) {
160
201
  try {
@@ -19,13 +19,13 @@ import { inspectAgents } from './worker.mjs';
19
19
  import { inspectNode } from './executor.mjs';
20
20
  import { getModelCatalog, joinHarnessModels } from './model-catalog.mjs';
21
21
  import { readReceipts, activeQuotaWalls, activeCredentialBlocks } from './learner.mjs';
22
- import { resolvePool, exclusionReason } from './pool.mjs';
22
+ import { resolvePool, exclusionReason, isLocalExecutionNode } from './pool.mjs';
23
23
  import { readCodexRateLimits, measuredWalls } from './telemetry.mjs';
24
24
 
25
25
  /**
26
26
  * Helper to safely query host Total Recall CLI without assumptions
27
27
  */
28
- function runHostCli(args = []) {
28
+ function runHostCli(args = [], { captureFailure = false } = {}) {
29
29
  try {
30
30
  const totalRecallBin = process.env.TOTAL_RECALL_BIN || 'total-recall';
31
31
  const output = execFileSync(totalRecallBin, args, {
@@ -34,8 +34,9 @@ function runHostCli(args = []) {
34
34
  encoding: 'utf8'
35
35
  });
36
36
  return output.trim();
37
- } catch {
38
- return null;
37
+ } catch (error) {
38
+ // A failing owner often explains itself as JSON on stdout. Callers that need the reason read it here.
39
+ return captureFailure ? String(error.stdout || '').trim() || null : null;
39
40
  }
40
41
  }
41
42
 
@@ -136,7 +137,26 @@ export async function inspectQuotas(options = {}) {
136
137
  meteredQuota.quotaNote = `OpenRouter could not be reached: ${error.message}`;
137
138
  meteredQuota.status = 'unavailable';
138
139
  }
140
+ // Per-key usage says nothing about whether the account can pay. Only the account balance does.
141
+ try {
142
+ const management = options.openRouterManagementKey !== undefined ? options.openRouterManagementKey : runHostCli(['auth', 'secret', 'get', 'OPENROUTER_MANAGEMENT_KEY']);
143
+ if (management) {
144
+ const response = await (options.fetchImpl || fetch)('https://openrouter.ai/api/v1/credits', { headers: { Authorization: `Bearer ${management}` }, signal: AbortSignal.timeout(6000) });
145
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
146
+ const { total_credits: credits, total_usage: used } = (await response.json()).data || {};
147
+ if (typeof credits === 'number' && typeof used === 'number') {
148
+ meteredQuota.creditRemainingUSD = +(credits - used).toFixed(2);
149
+ meteredQuota.quotaNote = `Account balance $${meteredQuota.creditRemainingUSD} of $${credits}`;
150
+ if (credits - used <= 0) meteredQuota.status = 'exhausted';
151
+ }
152
+ }
153
+ } catch (error) {
154
+ meteredQuota.balanceError = `The OpenRouter balance could not be read: ${error.message}`;
155
+ }
139
156
  quotas.metered = meteredQuota;
157
+ if (meteredQuota.status === 'exhausted') for (const id of ['dsh', 'hermes', 'pi']) {
158
+ if (quotas[id]?.installed && !['needs_auth', 'blocked'].includes(quotas[id].status)) Object.assign(quotas[id], { status: 'exhausted', quotaNote: `Bills OpenRouter. ${meteredQuota.quotaNote}` });
159
+ }
140
160
 
141
161
  return { ok: true, timestamp: new Date().toISOString(), node: options.node || 'local', fleet: { ok: fleet.ok, error: fleet.error, machines: fleet.machines, answered: fleet.answered, nodes: fleet.nodes }, quotas };
142
162
  }
@@ -154,11 +174,12 @@ export async function getBenchmarks(options = {}) {
154
174
  }
155
175
 
156
176
  /** Ask the decision plugin to classify. Its failure is reported; the caller sees which classifier answered. */
157
- export function classifyWithDecision(task, run = args => runHostCli(args)) {
158
- const questions = { complexity: { type: 'choice', prompt: 'How demanding is this software engineering task for a coding agent?', options: ['local', 'budget', 'frontier'] } };
177
+ export function classifyWithDecision(task, run = args => runHostCli(args, { captureFailure: true })) {
178
+ const questions = { complexity: { type: 'choice', instructions: 'How demanding is this software engineering task for a coding agent?', criteria: { local: 'A small mechanical change a local model can make.', budget: 'Routine engineering with a clear path.', frontier: 'Architecture, security, or work across many files.' } } };
159
179
  const output = run(['decision', 'run', '--state', JSON.stringify({ task: task.slice(0, 2000) }), '--questions', JSON.stringify(questions), '--json']);
160
180
  if (!output) throw new Error('The decision plugin did not answer');
161
181
  const parsed = JSON.parse(output);
182
+ if (parsed.ok === false) throw new Error(parsed.error || 'The decision plugin reported a failure');
162
183
  const answer = parsed.answers?.complexity ?? parsed.decision?.answers?.complexity ?? parsed.result?.complexity;
163
184
  const tier = typeof answer === 'string' ? answer : answer?.choice;
164
185
  if (!['local', 'budget', 'frontier'].includes(tier)) throw new Error('The decision plugin returned no usable tier');
@@ -185,7 +206,11 @@ export async function routeTask(taskDescription, options = {}) {
185
206
  classifier = { source: 'heuristic', decisionError: String(error.message).slice(0, 300) };
186
207
  }
187
208
  }
188
- const [quotaReport, pool, receipts] = await Promise.all([inspectQuotas(options), resolvePool(options), readReceipts()]);
209
+ // Plan-only routing on a selected node chooses from that node's harnesses, not this machine's.
210
+ const remote = options.node && !isLocalExecutionNode(options.node);
211
+ const inspected = remote ? (options.inspectNodeFn || inspectNode)(options.node) : null;
212
+ const scoped = remote ? { ...options, inspectNodeFn: () => inspected, inspect: () => inspected } : options;
213
+ const [quotaReport, pool, receipts] = await Promise.all([inspectQuotas(scoped), resolvePool(scoped), readReceipts()]);
189
214
  const quotas = quotaReport.quotas;
190
215
  const routing = selectOptimalHarness(taskDescription, pool.ready.map(h => h.id), quotas, { ...options, tierOverride: complexity.tier, empiricalScorecard: computeEmpiricalScorecard(receipts) });
191
216
  const selectedQuota = quotas[routing.selected] || {};
@@ -0,0 +1,24 @@
1
+ /** A stopped run takes its agents, checkouts, unfinished branches and open threads with it. */
2
+ import { stopActiveProcesses } from './worker.mjs';
3
+ import { removeActiveWorktrees } from './worktree.mjs';
4
+ import { failOpenThreads } from './coordinator.mjs';
5
+
6
+ const SIGNALS = { SIGTERM: 143, SIGINT: 130, SIGHUP: 129 };
7
+ let installed = false;
8
+
9
+ export function stopOnTermination({ graceMs = 500 } = {}) {
10
+ if (installed) return;
11
+ installed = true;
12
+ let stopping = false;
13
+ for (const [signal, code] of Object.entries(SIGNALS)) process.on(signal, () => {
14
+ if (stopping) return;
15
+ stopping = true;
16
+ stopActiveProcesses('SIGTERM');
17
+ setTimeout(() => {
18
+ stopActiveProcesses('SIGKILL');
19
+ removeActiveWorktrees();
20
+ failOpenThreads();
21
+ process.exit(code);
22
+ }, graceMs);
23
+ });
24
+ }
@@ -1,156 +1,119 @@
1
- /**
2
- * Meta Harness Multi-Platform Thread Manager
3
- * Unifies thread tracking and session supervision across integrated agent platforms:
4
- * agy, claude, codex, dsh, hermes, openclaw, cursor, devin, etc.
5
- *
6
- * Persists thread state, PR branch associations, turn history, and review status.
7
- */
8
-
1
+ /** Thread state is canonical SSSS, mutated through the native kernel with optimistic concurrency. */
9
2
  import fs from 'node:fs';
10
- import path from 'node:path';
3
+ import crypto from 'node:crypto';
11
4
  import { threadsFile } from './paths.mjs';
5
+ import { PluginStateStore } from './ssss-store.mjs';
6
+
7
+ const STATUSES = new Set(['active', 'review_pending', 'approved', 'completed', 'failed']);
8
+ const identifier = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
9
+ const storeForThreads = () => new PluginStateStore(threadsFile(), 'meta-harness');
10
+
11
+ function validateThreads(value) {
12
+ if (!Array.isArray(value)) throw new Error('The thread store is corrupt: threads must be an array');
13
+ const ids = new Set();
14
+ for (const thread of value) {
15
+ if (!thread || typeof thread !== 'object' || !identifier.test(thread.id || '') || ids.has(thread.id)
16
+ || typeof thread.title !== 'string' || !STATUSES.has(thread.status) || !Array.isArray(thread.turns)
17
+ || thread.turns.some(turn => !turn || typeof turn !== 'object' || typeof turn.content !== 'string')) {
18
+ throw new Error('The thread store is corrupt: invalid thread identity, status or turns');
19
+ }
20
+ ids.add(thread.id);
21
+ }
22
+ return value;
23
+ }
12
24
 
13
- /** A missing file is an empty store. A corrupt one is an error, so the next write cannot erase it. */
14
- export function readThreads() {
15
- let content;
16
- try { content = fs.readFileSync(threadsFile(), 'utf8'); }
17
- catch (error) { if (error.code === 'ENOENT') return []; throw error; }
18
- let parsed;
19
- try { parsed = JSON.parse(content); }
20
- catch { throw new Error(`The thread store ${threadsFile()} is corrupt. It was left in place; repair or move it.`); }
21
- return Array.isArray(parsed) ? parsed : (parsed.threads || []);
25
+ /** Existing JSON is read only for migration. The first mutation imports it through SSSS; it is never overwritten. */
26
+ function readState(store) {
27
+ let value;
28
+ try { value = store.read(null); }
29
+ catch (error) { throw new Error(`The canonical thread store is corrupt or unsafe: ${error.message}`); }
30
+ if (value === null) {
31
+ let content;
32
+ try { content = fs.readFileSync(threadsFile(), 'utf8'); }
33
+ catch (error) { if (error.code === 'ENOENT') return { threads: [] }; throw error; }
34
+ try { value = JSON.parse(content); }
35
+ catch { throw new Error(`The thread store ${threadsFile()} is corrupt. It was left in place.`); }
36
+ }
37
+ const state = Array.isArray(value) ? { threads: value } : value;
38
+ if (!state || typeof state !== 'object') throw new Error('The thread store is corrupt: missing thread state');
39
+ validateThreads(state.threads);
40
+ return state;
22
41
  }
23
42
 
24
- /** Serialize read-modify-write across processes. A lock older than 30 seconds belongs to a dead process. */
25
- function withLock(work) {
26
- const lock = `${threadsFile()}.lock`;
27
- fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
28
- const deadline = Date.now() + 10000;
29
- for (;;) {
30
- try { fs.mkdirSync(lock); break; }
31
- catch (error) {
32
- if (error.code !== 'EEXIST') throw error;
33
- try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.rmdirSync(lock); continue; } } catch { continue; }
34
- if (Date.now() > deadline) throw new Error('The thread store is locked by another process');
35
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
43
+ export const threadDocumentPath = () => storeForThreads().documentPath;
44
+ export function readThreads() { return readState(storeForThreads()).threads; }
45
+
46
+ /** Native SSSS owns the mutation guard and leases. CAS retries reload state; no age-based lock stealing. */
47
+ function mutate(work) {
48
+ for (let attempt = 0; attempt < 8; attempt += 1) {
49
+ const store = storeForThreads();
50
+ const state = readState(store);
51
+ const result = work(state.threads);
52
+ validateThreads(state.threads);
53
+ try {
54
+ store.write({ ...state, version: '2.0.0', updatedAt: new Date().toISOString(), threads: state.threads });
55
+ return structuredClone(result);
56
+ } catch (error) {
57
+ if (!String(error.message).includes('version conflict') || attempt === 7) throw error;
36
58
  }
37
59
  }
38
- try { return work(); } finally { fs.rmdirSync(lock); }
39
60
  }
40
61
 
41
62
  export function writeThreads(threads) {
42
- const file = threadsFile();
43
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
- const payload = {
45
- version: '1.0.0',
46
- updatedAt: new Date().toISOString(),
47
- threads: Array.isArray(threads) ? threads : []
48
- };
49
- const temporary = `${file}.${process.pid}.tmp`;
50
- fs.writeFileSync(temporary, JSON.stringify(payload, null, 2), { mode: 0o600 });
51
- fs.renameSync(temporary, file);
63
+ validateThreads(threads);
64
+ return mutate(current => { current.splice(0, current.length, ...structuredClone(threads)); return current; });
52
65
  }
53
66
 
54
67
  export function listThreads(filter = {}) {
55
- const threads = readThreads();
56
- return threads
57
- .filter(t => {
58
- if (filter.platform && t.platform !== filter.platform) return false;
59
- if (filter.status && t.status !== filter.status) return false;
60
- if (filter.branch && t.branch !== filter.branch) return false;
61
- return true;
62
- })
68
+ return readThreads().filter(thread => (!filter.platform || thread.platform === filter.platform)
69
+ && (!filter.status || thread.status === filter.status) && (!filter.branch || thread.branch === filter.branch))
63
70
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
64
71
  }
65
-
66
- export function getThread(id) {
67
- const threads = readThreads();
68
- return threads.find(t => t.id === id) || null;
69
- }
72
+ export function getThread(id) { return readThreads().find(thread => thread.id === id) || null; }
70
73
 
71
74
  export function createThread(data = {}) {
72
- return withLock(() => {
73
- const threads = readThreads();
74
- const now = new Date().toISOString();
75
- const id = data.id || `thread-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
76
-
77
- const thread = {
78
- id,
79
- title: String(data.title || 'Untitled Agent Thread').slice(0, 140),
80
- task: String(data.task || data.title || '').trim(),
81
- platform: data.platform || 'universal',
82
- model: data.model || 'auto',
83
- branch: data.branch || null,
84
- worktreePath: data.worktreePath || null,
85
- prNumber: data.prNumber || null,
86
- prUrl: data.prUrl || null,
87
- status: data.status || 'active', // 'active' | 'review_pending' | 'approved' | 'completed' | 'failed'
88
- createdAt: now,
89
- updatedAt: now,
90
- turns: Array.isArray(data.turns) ? data.turns : [],
91
- review: data.review || null,
92
- summary: data.summary || null
93
- };
94
-
95
- threads.unshift(thread);
96
- writeThreads(threads);
97
- return thread;
75
+ const id = data.id || `thread-${crypto.randomUUID()}`;
76
+ return mutate(threads => {
77
+ if (threads.some(thread => thread.id === id)) throw new Error(`Thread already exists: ${id}`);
78
+ const now = new Date().toISOString();
79
+ const thread = {
80
+ ...data, id, title: String(data.title || 'Untitled Agent Thread').slice(0, 140),
81
+ task: String(data.task || data.title || '').trim(), platform: data.platform || 'universal', model: data.model || 'auto',
82
+ branch: data.branch || null, worktreePath: data.worktreePath || null, prNumber: data.prNumber || null, prUrl: data.prUrl || null,
83
+ status: data.status || 'active', createdAt: now, updatedAt: now,
84
+ turns: Array.isArray(data.turns) ? data.turns : [], review: data.review || null, summary: data.summary || null,
85
+ };
86
+ threads.unshift(thread);
87
+ return thread;
98
88
  });
99
89
  }
100
90
 
101
91
  export function appendThreadTurn(id, turn = {}) {
102
- return withLock(() => {
103
- const threads = readThreads();
104
- const index = threads.findIndex(t => t.id === id);
105
- if (index === -1) throw new Error(`Thread not found: ${id}`);
106
-
107
- const now = new Date().toISOString();
108
- const entry = {
109
- role: turn.role || 'agent',
110
- agent: turn.agent || 'unknown',
111
- content: String(turn.content || '').slice(0, 50000),
112
- timestamp: now
113
- };
114
-
115
- threads[index].turns.push(entry);
116
- threads[index].updatedAt = now;
117
- writeThreads(threads);
118
- return threads[index];
92
+ return mutate(threads => {
93
+ const thread = threads.find(item => item.id === id);
94
+ if (!thread) throw new Error(`Thread not found: ${id}`);
95
+ const now = new Date().toISOString();
96
+ thread.turns.push({ role: turn.role || 'agent', agent: turn.agent || 'unknown', content: String(turn.content || '').slice(0, 50000), timestamp: now });
97
+ thread.updatedAt = now;
98
+ return thread;
119
99
  });
120
100
  }
121
101
 
122
102
  export function updateThread(id, updates = {}) {
123
- return withLock(() => {
124
- const threads = readThreads();
125
- const index = threads.findIndex(t => t.id === id);
126
- if (index === -1) throw new Error(`Thread not found: ${id}`);
127
-
128
- const now = new Date().toISOString();
129
- threads[index] = {
130
- ...threads[index],
131
- ...updates,
132
- id, // protect immutable ID
133
- updatedAt: now
134
- };
135
-
136
- writeThreads(threads);
137
- return threads[index];
103
+ if (!updates || typeof updates !== 'object' || Array.isArray(updates)) throw new Error('Thread updates must be an object');
104
+ return mutate(threads => {
105
+ const index = threads.findIndex(thread => thread.id === id);
106
+ if (index < 0) throw new Error(`Thread not found: ${id}`);
107
+ threads[index] = { ...threads[index], ...updates, id, createdAt: threads[index].createdAt, updatedAt: new Date().toISOString() };
108
+ return threads[index];
138
109
  });
139
110
  }
140
-
141
- export function closeThread(id, resolution = 'completed') {
142
- return updateThread(id, {
143
- status: resolution,
144
- closedAt: new Date().toISOString()
145
- });
146
- }
147
-
111
+ export function closeThread(id, resolution = 'completed') { return updateThread(id, { status: resolution, closedAt: new Date().toISOString() }); }
148
112
  export function deleteThread(id) {
149
- return withLock(() => {
150
- const threads = readThreads();
151
- const filtered = threads.filter(t => t.id !== id);
152
- if (filtered.length === threads.length) return false;
153
- writeThreads(filtered);
113
+ return mutate(threads => {
114
+ const index = threads.findIndex(thread => thread.id === id);
115
+ if (index < 0) return false;
116
+ threads.splice(index, 1);
154
117
  return true;
155
118
  });
156
119
  }