monomind 2.0.2 → 2.1.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 (90) hide show
  1. package/package.json +9 -5
  2. package/packages/@monomind/cli/.claude/commands/mastermind/runorg.md +4 -0
  3. package/packages/@monomind/cli/.claude/helpers/control-start.cjs +50 -5
  4. package/packages/@monomind/cli/.claude/helpers/event-logger.cjs +109 -6
  5. package/packages/@monomind/cli/.claude/helpers/graphify-freshen.cjs +19 -17
  6. package/packages/@monomind/cli/.claude/helpers/handlers/agent-start-handler.cjs +60 -28
  7. package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +75 -18
  8. package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +27 -9
  9. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +16 -7
  10. package/packages/@monomind/cli/.claude/helpers/handlers/loops-status-handler.cjs +1 -1
  11. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +4 -4
  12. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +40 -22
  13. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +0 -12
  14. package/packages/@monomind/cli/.claude/helpers/handlers/task-handler.cjs +53 -22
  15. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +54 -4
  16. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +94 -15
  17. package/packages/@monomind/cli/.claude/helpers/session.cjs +6 -1
  18. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +10 -10
  19. package/packages/@monomind/cli/.claude/helpers/token-tracker.cjs +3 -3
  20. package/packages/@monomind/cli/.claude/helpers/utils/agent-registrations.cjs +41 -0
  21. package/packages/@monomind/cli/.claude/helpers/utils/fs-helpers.cjs +183 -0
  22. package/packages/@monomind/cli/.claude/helpers/utils/micro-agents.cjs +10 -6
  23. package/packages/@monomind/cli/.claude/helpers/utils/telemetry.cjs +35 -19
  24. package/packages/@monomind/cli/bin/cli.js +10 -1
  25. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +9 -15
  26. package/packages/@monomind/cli/dist/src/commands/cleanup.js +20 -4
  27. package/packages/@monomind/cli/dist/src/commands/completions.js +66 -261
  28. package/packages/@monomind/cli/dist/src/commands/doctor.js +7 -2
  29. package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +7 -0
  30. package/packages/@monomind/cli/dist/src/commands/index.js +3 -0
  31. package/packages/@monomind/cli/dist/src/commands/mcp.js +11 -6
  32. package/packages/@monomind/cli/dist/src/commands/memory-admin.js +12 -6
  33. package/packages/@monomind/cli/dist/src/commands/memory-transfer.js +6 -13
  34. package/packages/@monomind/cli/dist/src/commands/monograph.js +3 -3
  35. package/packages/@monomind/cli/dist/src/commands/org.d.ts +3 -1
  36. package/packages/@monomind/cli/dist/src/commands/org.js +306 -129
  37. package/packages/@monomind/cli/dist/src/commands/start.js +142 -37
  38. package/packages/@monomind/cli/dist/src/commands/tokens.js +21 -3
  39. package/packages/@monomind/cli/dist/src/config-adapter.js +37 -17
  40. package/packages/@monomind/cli/dist/src/index.js +64 -28
  41. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +44 -11
  42. package/packages/@monomind/cli/dist/src/init/executor.js +63 -49
  43. package/packages/@monomind/cli/dist/src/mcp-client.d.ts +8 -3
  44. package/packages/@monomind/cli/dist/src/mcp-client.js +37 -3
  45. package/packages/@monomind/cli/dist/src/mcp-server.js +8 -2
  46. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.d.ts +20 -0
  47. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.js +10 -5
  48. package/packages/@monomind/cli/dist/src/mcp-tools/github-tools.js +30 -8
  49. package/packages/@monomind/cli/dist/src/mcp-tools/graphify-tools.js +11 -2
  50. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +51 -34
  51. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +9 -1
  52. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-compat.js +56 -14
  53. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +65 -43
  54. package/packages/@monomind/cli/dist/src/mcp-tools/swarm-tools.js +6 -4
  55. package/packages/@monomind/cli/dist/src/mcp-tools/system-tools.js +4 -4
  56. package/packages/@monomind/cli/dist/src/mcp-tools/types.d.ts +20 -0
  57. package/packages/@monomind/cli/dist/src/mcp-tools/types.js +36 -1
  58. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.d.ts +13 -1
  59. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.js +66 -14
  60. package/packages/@monomind/cli/dist/src/memory/intelligence.js +99 -3
  61. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +7 -0
  62. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +20 -0
  63. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +24 -0
  64. package/packages/@monomind/cli/dist/src/orgrt/broker.js +68 -0
  65. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +30 -0
  66. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +96 -7
  67. package/packages/@monomind/cli/dist/src/orgrt/forwarder.d.ts +10 -5
  68. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +105 -8
  69. package/packages/@monomind/cli/dist/src/orgrt/live.html +56 -0
  70. package/packages/@monomind/cli/dist/src/orgrt/provider.js +1 -0
  71. package/packages/@monomind/cli/dist/src/orgrt/scheduler.d.ts +13 -0
  72. package/packages/@monomind/cli/dist/src/orgrt/scheduler.js +48 -0
  73. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +8 -0
  74. package/packages/@monomind/cli/dist/src/orgrt/server.js +72 -0
  75. package/packages/@monomind/cli/dist/src/orgrt/test-loop.d.ts +12 -0
  76. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +108 -0
  77. package/packages/@monomind/cli/dist/src/output.js +12 -5
  78. package/packages/@monomind/cli/dist/src/parser.d.ts +32 -0
  79. package/packages/@monomind/cli/dist/src/parser.js +130 -5
  80. package/packages/@monomind/cli/dist/src/routing/embed-worker.js +13 -1
  81. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +31 -0
  82. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +21 -0
  83. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +60 -7
  84. package/packages/@monomind/cli/dist/src/services/crash-reporter.js +2 -1
  85. package/packages/@monomind/cli/dist/src/ui/dashboard.html +40 -40
  86. package/packages/@monomind/cli/dist/src/ui/orgs.html +110 -11
  87. package/packages/@monomind/cli/dist/src/ui/server.mjs +304 -133
  88. package/packages/@monomind/cli/dist/src/utils/input-guards.d.ts +9 -0
  89. package/packages/@monomind/cli/dist/src/utils/input-guards.js +39 -5
  90. package/packages/@monomind/cli/package.json +6 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Monomind - Power toolkit for Claude Code. Autonomous agent orgs, codebase knowledge graph, keyword-routed specialist prompts, and 80+ slash commands. One init, then walk away.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -60,15 +60,20 @@
60
60
  "v1:security": "npm run security:audit && npm run security:test"
61
61
  },
62
62
  "dependencies": {
63
+ "@anthropic-ai/claude-agent-sdk": "^0.3.207",
63
64
  "@monoes/monobrowse": "^1.0.3",
64
65
  "@monoes/monograph": "^1.4.0",
66
+ "@noble/ed25519": "^2.1.0",
65
67
  "mammoth": "^1.12.0",
66
68
  "pdf-parse": "^2.4.5",
67
- "semver": "^7.6.0"
69
+ "semver": "^7.6.0",
70
+ "ws": "^8.21.0",
71
+ "yaml": "^2.8.0",
72
+ "zod": "^3.25.0"
68
73
  },
69
74
  "overrides": {
70
75
  "hono": ">=4.12.21",
71
- "ws": ">=8.20.1",
76
+ "ws": "$ws",
72
77
  "axios": ">=1.16.0",
73
78
  "qs": ">=6.15.2",
74
79
  "esbuild": ">=0.28.1",
@@ -137,8 +142,7 @@
137
142
  "tsx": "^4.21.0",
138
143
  "typescript": "^5.0.0",
139
144
  "vite": ">=8.0.16",
140
- "vitest": "^4.1.4",
141
- "zod": "^4.3.6"
145
+ "vitest": "^4.1.4"
142
146
  },
143
147
  "engines": {
144
148
  "node": ">=20.0.0"
@@ -1,5 +1,9 @@
1
1
  <!-- Start a saved org as a persistent autonomous agent organization. The boss agent coordinates all roles; agents pick up tasks from a shared board and run until stopped. -->
2
2
 
3
+ > **DEPRECATED (2026-07): superseded by `monomind org run <name>` (SDK org runtime v2).**
4
+ > This prompt-orchestrated path has no delivery guarantees and no ground-truth event stream.
5
+ > It remains only for orgs not yet migrated. New orgs MUST use the daemon.
6
+
3
7
  **If $ARGUMENTS is empty:** List saved orgs and display the following.
4
8
 
5
9
  ---
@@ -12,6 +12,7 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const { spawn } = require('child_process');
15
+ const { claimLock, releaseLock } = require('./utils/fs-helpers.cjs');
15
16
 
16
17
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
18
  const STATUS_FILE = path.join(CWD, '.monomind', 'control.json');
@@ -105,6 +106,31 @@ function probeStatus(p) {
105
106
  });
106
107
  }
107
108
 
109
+ const LOCK_FILE = path.join(CWD, '.monomind', 'control.lock');
110
+
111
+ /**
112
+ * Atomically claim the spawn lock. Concurrent hook events (a busy session can
113
+ * fire dozens of control-starts at once) must not each spawn a server — the
114
+ * loser processes exit and let the winner's server come up. A stale lock
115
+ * (older than 30s) is broken and re-claimed.
116
+ *
117
+ * P2-25: previously this broke a stale lock via unlink-then-writeFileSync(wx),
118
+ * which is a TOCTOU race — a second process that also decided the lock was
119
+ * stale could unlink the FIRST process's freshly-claimed (non-stale) lock
120
+ * between that process's unlink and its own write, letting a third process
121
+ * then also claim it. `claimLock` (utils/fs-helpers.cjs) fixes this by
122
+ * breaking stale locks with an atomic rename-to-claim instead: only one
123
+ * racing process can win the rename, and a loser retries from the top
124
+ * rather than proceeding as if it owns the lock.
125
+ */
126
+ function claimSpawnLock() {
127
+ return claimLock(LOCK_FILE, 30_000);
128
+ }
129
+
130
+ function releaseSpawnLock() {
131
+ releaseLock(LOCK_FILE);
132
+ }
133
+
108
134
  async function main() {
109
135
  // If already running, do nothing
110
136
  const status = readStatus();
@@ -125,6 +151,21 @@ async function main() {
125
151
  }
126
152
  }
127
153
 
154
+ if (!claimSpawnLock()) {
155
+ process.stdout.write('[control] another control-start is already spawning the server — skipping\n');
156
+ process.exit(0);
157
+ }
158
+
159
+ // Test hook: exercise the full flow without leaving a real detached server
160
+ // behind (the test suite spawns this script dozens of times per run — real
161
+ // spawns leaked hundreds of orphan servers on isolated ports).
162
+ if (process.env.MONOMIND_CONTROL_NO_SPAWN === '1') {
163
+ writeStatus(process.pid, DEFAULT_PORT);
164
+ process.stdout.write(`[control] started Neural Control Room on port ${DEFAULT_PORT} (pid ${process.pid}) [no-spawn]\n`);
165
+ releaseSpawnLock();
166
+ process.exit(0);
167
+ }
168
+
128
169
  const { cmd, args, usePort } = findCliPath();
129
170
  // server.mjs accepts port as second positional arg; CLI uses 'ui --no-open --port N'
130
171
  const allArgs = usePort
@@ -159,9 +200,9 @@ async function main() {
159
200
  });
160
201
  }
161
202
 
162
- // Give the server up to ~3 s to start, then confirm the actual port.
203
+ // Give the server up to ~10 s to start, then confirm the actual port.
163
204
  async function confirmPort() {
164
- for (let attempt = 0; attempt < 6; attempt++) {
205
+ for (let attempt = 0; attempt < 20; attempt++) {
165
206
  await new Promise(r => setTimeout(r, 500));
166
207
  for (let delta = 0; delta <= 10; delta++) {
167
208
  const p = DEFAULT_PORT + delta;
@@ -174,11 +215,15 @@ async function main() {
174
215
  }
175
216
  }
176
217
  }
177
- // Server didn't respond in timeleave control.json as-is (best-effort)
178
- process.stdout.write('[control] server did not respond within 3 s control.json may have wrong port\n');
218
+ // Server never became reachable on any expected port kill the child
219
+ // rather than leave an orphan bound to some port nothing will ever read.
220
+ // The next session-start simply retries.
221
+ try { process.kill(child.pid, 'SIGTERM'); } catch { /* already gone */ }
222
+ try { fs.unlinkSync(STATUS_FILE); } catch { /* ignore */ }
223
+ process.stdout.write('[control] server did not respond within 10 s — killed orphan, will retry next session\n');
179
224
  }
180
225
 
181
- confirmPort().catch(() => {}).finally(() => process.exit(0));
226
+ confirmPort().catch(() => {}).finally(() => { releaseSpawnLock(); process.exit(0); });
182
227
  }
183
228
 
184
229
  main().catch(() => process.exit(0));
@@ -22,15 +22,36 @@ safety.unref();
22
22
 
23
23
  const MAX_STDIN = 1024 * 1024; // 1 MiB
24
24
 
25
+ // P3-16: mirrors hook-handler.cjs's readStdin — byte-counted, discard-whole-
26
+ // payload-on-overflow. The old version did `if (data.length < MAX_STDIN)
27
+ // data += c`, which silently DROPPED characters once the cap was hit instead
28
+ // of aborting the read, so an oversized payload produced truncated, invalid
29
+ // JSON that then failed JSON.parse downstream with the whole event (and its
30
+ // toolName/sessionId attribution) discarded anyway — just with extra steps
31
+ // and no signal as to why. Discarding the entire payload up front (with a
32
+ // logged, flagged event) is equivalent in outcome but honest about it.
25
33
  function readStdin() {
26
34
  if (process.stdin.isTTY) return Promise.resolve('');
27
35
  return new Promise((resolve) => {
28
36
  let data = '';
29
- const t = setTimeout(() => { process.stdin.pause(); resolve(data); }, 800);
37
+ let byteCount = 0;
38
+ let truncated = false;
39
+ const t = setTimeout(() => { process.stdin.pause(); resolve({ data, truncated }); }, 800);
30
40
  process.stdin.setEncoding('utf8');
31
- process.stdin.on('data', c => { if (data.length < MAX_STDIN) data += c; });
32
- process.stdin.on('end', () => { clearTimeout(t); resolve(data); });
33
- process.stdin.on('error', () => { clearTimeout(t); resolve(data); });
41
+ process.stdin.on('data', c => {
42
+ if (truncated) return;
43
+ byteCount += Buffer.byteLength(c, 'utf8');
44
+ if (byteCount > MAX_STDIN) {
45
+ truncated = true;
46
+ process.stdin.pause();
47
+ clearTimeout(t);
48
+ resolve({ data: '', truncated: true }); // discard oversized input, same as hook-handler.cjs
49
+ return;
50
+ }
51
+ data += c;
52
+ });
53
+ process.stdin.on('end', () => { clearTimeout(t); resolve({ data, truncated }); });
54
+ process.stdin.on('error', () => { clearTimeout(t); resolve({ data, truncated }); });
34
55
  process.stdin.resume();
35
56
  });
36
57
  }
@@ -60,7 +81,14 @@ function forwardToDashboard(entry) {
60
81
  }
61
82
 
62
83
  async function main() {
63
- const raw = await readStdin();
84
+ const stdinResult = await readStdin();
85
+ const raw = stdinResult.data;
86
+ const stdinTruncated = stdinResult.truncated;
87
+ if (stdinTruncated) {
88
+ // Oversized payload was discarded whole (see readStdin) — log it so the
89
+ // drop is visible instead of silently vanishing as a JSON.parse failure.
90
+ console.error('[event-logger] stdin payload exceeded ' + MAX_STDIN + ' bytes; discarded (event=' + eventType + ')');
91
+ }
64
92
  let payload = {};
65
93
  try { payload = JSON.parse(raw); } catch {}
66
94
 
@@ -80,6 +108,7 @@ async function main() {
80
108
  toolResult: payload.toolResult || payload.tool_result || undefined,
81
109
  message: payload.message || payload.notification || payload.prompt || undefined,
82
110
  raw: (raw.length < 4096) ? payload : undefined,
111
+ truncated: stdinTruncated || undefined,
83
112
  };
84
113
 
85
114
  // Remove undefined keys
@@ -113,6 +142,13 @@ async function main() {
113
142
  fs.appendFileSync(allLog, JSON.stringify(entry) + '\n');
114
143
  } catch {}
115
144
 
145
+ // Rotation — event logs (daily + per-session) accumulate forever with no
146
+ // cleanup otherwise. Cheap: single readdir + stat pass, only on session-start
147
+ // (once per session, not per-event) so normal hook latency is unaffected.
148
+ if (eventType === 'session-start') {
149
+ rotateEventLogs(eventsDir);
150
+ }
151
+
116
152
  // Write to per-session log
117
153
  if (sessionId !== 'unknown') {
118
154
  try {
@@ -148,8 +184,75 @@ async function main() {
148
184
  forwardToDashboard(entry);
149
185
  }
150
186
 
187
+ // Detect route changes and worker metric updates by mtime and forward them too.
188
+ // These aren't Claude Code hook events — they're file writes from route-handler.cjs
189
+ // and the @monomind/hooks workers respectively — so we poll cheaply on every hook invocation.
190
+ forwardFileChanges(monoDir, CWD);
191
+
151
192
  clearTimeout(safety);
152
193
  process.exit(0);
153
194
  }
154
195
 
155
- main().catch(() => process.exit(0));
196
+ function forwardFileChanges(monoDir, workDir) {
197
+ try {
198
+ const cachePath = path.join(monoDir, 'dashboard-watch-cache.json');
199
+ let cache = {};
200
+ try { cache = JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch {}
201
+ let changed = false;
202
+
203
+ // Route changes (.monomind/last-route.json, written every prompt by route-handler.cjs)
204
+ const routePath = path.join(workDir, '.monomind', 'last-route.json');
205
+ try {
206
+ const routeMtime = fs.statSync(routePath).mtimeMs;
207
+ if (routeMtime !== cache.routeMtime) {
208
+ cache.routeMtime = routeMtime;
209
+ changed = true;
210
+ let route = null;
211
+ try { route = JSON.parse(fs.readFileSync(routePath, 'utf8')); } catch {}
212
+ forwardToDashboard({ hookType: 'route-change', ts: Date.now(), route });
213
+ }
214
+ } catch {}
215
+
216
+ // Worker-complete: newest mtime among .monomind/metrics/*.json (written by @monomind/hooks workers)
217
+ const metricsDir = path.join(workDir, '.monomind', 'metrics');
218
+ try {
219
+ const files = fs.readdirSync(metricsDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
220
+ let latestFile = null;
221
+ let latestMtime = cache.metricsMtime || 0;
222
+ for (const f of files) {
223
+ const mtime = fs.statSync(path.join(metricsDir, f)).mtimeMs;
224
+ if (mtime > latestMtime) { latestMtime = mtime; latestFile = f; }
225
+ }
226
+ if (latestFile) {
227
+ cache.metricsMtime = latestMtime;
228
+ changed = true;
229
+ forwardToDashboard({ hookType: 'daemon-complete', ts: Date.now(), metric: latestFile.replace(/\.json$/, '') });
230
+ }
231
+ } catch {}
232
+
233
+ if (changed) fs.writeFileSync(cachePath, JSON.stringify(cache));
234
+ } catch {}
235
+ }
236
+
237
+ // Delete any daily or per-session event log whose mtime is older than
238
+ // maxAgeMs (default 14 days). Single readdir + stat pass — no recursion.
239
+ function rotateEventLogs(eventsDir, maxAgeMs) {
240
+ maxAgeMs = maxAgeMs || 14 * 24 * 60 * 60 * 1000;
241
+ try {
242
+ const cutoff = Date.now() - maxAgeMs;
243
+ const entries = fs.readdirSync(eventsDir).filter(f => !f.startsWith('._'));
244
+ for (const f of entries) {
245
+ if (!f.endsWith('.jsonl')) continue;
246
+ const full = path.join(eventsDir, f);
247
+ try {
248
+ if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full);
249
+ } catch {}
250
+ }
251
+ } catch {}
252
+ }
253
+
254
+ module.exports = { rotateEventLogs };
255
+
256
+ if (require.main === module) {
257
+ main().catch(() => process.exit(0));
258
+ }
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const fs = require('fs');
6
6
  const { spawn } = require('child_process');
7
7
  const { pathToFileURL } = require('url');
8
+ const { claimLock } = require('./utils/fs-helpers.cjs');
8
9
 
9
10
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
11
  const graphDir = path.join(projectDir, '.monomind', 'graph');
@@ -91,20 +92,18 @@ if (fs.existsSync(dbPath)) {
91
92
  }
92
93
 
93
94
  // Skip if another build is already in progress (avoids SQLite BUSY on concurrent init + session-start)
95
+ // P2-24: claim atomically (wx-create) instead of statSync-then-writeFileSync
96
+ // — two concurrent freshen triggers can both cross the same write-count
97
+ // threshold from parallel hook events, and a plain read-check-write lets
98
+ // both pass the check and both spawn a detached rebuild child. claimLock
99
+ // uses the same TOCTOU-safe stale-lock-break (atomic rename-to-claim) as
100
+ // control-start.cjs's spawn lock (see P2-25) — a lock older than 5 minutes
101
+ // is treated as abandoned and safely reclaimed.
94
102
  const lockPath = path.join(graphDir, 'build.lock');
95
- const now = Date.now();
96
- try {
97
- const stat = fs.statSync(lockPath);
98
- // Stale lock older than 5 minutes — remove it and proceed
99
- if (now - stat.mtimeMs < 5 * 60 * 1000) {
100
- console.log('[graph] build already in progress — skipping');
101
- process.exit(0);
102
- }
103
- fs.unlinkSync(lockPath);
104
- } catch { /* lock does not exist — proceed */ }
105
-
106
- // Write lock file; the build process removes it on completion
107
- try { fs.writeFileSync(lockPath, String(process.pid)); } catch { /* non-fatal */ }
103
+ if (!claimLock(lockPath, 5 * 60 * 1000)) {
104
+ console.log('[graph] build already in progress — skipping');
105
+ process.exit(0);
106
+ }
108
107
 
109
108
  // Spawn a detached node process to run buildAsync from @monoes/monograph (ESM).
110
109
  // After the build, VACUUM the DB if it has >50% bloat (reclaim space from
@@ -113,18 +112,21 @@ const dbPathStr = JSON.stringify(path.join(projectDir, '.monomind', 'monograph.d
113
112
  const script = `
114
113
  import { buildAsync } from ${JSON.stringify(pathToFileURL(entryPoint).href)};
115
114
  import { unlinkSync, statSync } from 'fs';
116
- import { execSync } from 'child_process';
115
+ import { execFileSync } from 'child_process';
117
116
  try {
118
117
  await buildAsync(${JSON.stringify(projectDir)});
119
118
  // Vacuum if bloat ratio is high — keeps openDb fast over time.
120
119
  try {
121
120
  const dbPath = ${dbPathStr};
122
121
  const fileMB = statSync(dbPath).size / 1024 / 1024;
122
+ // execFileSync with array argv — no shell involved, so a project
123
+ // directory name containing '"' or '$(...)' cannot break out and
124
+ // execute arbitrary commands (P3-8).
123
125
  const liveMB = parseInt(
124
- execSync('sqlite3 "' + dbPath + '" "SELECT SUM(pgsize)/1024/1024 FROM dbstat;"',
125
- { encoding: 'utf-8' }).trim(), 10);
126
+ execFileSync('sqlite3', [dbPath, 'SELECT SUM(pgsize)/1024/1024 FROM dbstat;'],
127
+ { encoding: 'utf-8', timeout: 30000 }).trim(), 10);
126
128
  if (fileMB > 100 && liveMB / fileMB < 0.5) {
127
- execSync('sqlite3 "' + dbPath + '" "VACUUM;"', { timeout: 120000 });
129
+ execFileSync('sqlite3', [dbPath, 'VACUUM;'], { timeout: 120000 });
128
130
  }
129
131
  } catch (_) {}
130
132
  } finally {
@@ -4,6 +4,8 @@
4
4
 
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
+ const { purgeStaleRegistrations } = require('../utils/agent-registrations.cjs');
8
+ const { cleanEntries, atomicWriteFileSync, withLock } = require('../utils/fs-helpers.cjs');
7
9
 
8
10
  module.exports = {
9
11
  handle: async function(hCtx) {
@@ -18,41 +20,65 @@ module.exports = {
18
20
  fs.mkdirSync(regDir, { recursive: true });
19
21
  const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6);
20
22
  const regFile = path.join(regDir, 'agent-' + id + '.json');
23
+ // P3-15: also stamp the agent's type/slug so handlePostTask (task-handler.cjs)
24
+ // can correlate a completion event back to THIS registration instead of
25
+ // blindly popping the oldest file — the registration previously carried no
26
+ // identity beyond a random id, so post-task had nothing to match against.
27
+ // Same field precedence as the last-dispatch.json write below, computed
28
+ // early so both writers agree on the agent's identity.
29
+ const MAX_TYPE_LEN = 128;
30
+ const agentType = String(hookInput.subagent_type || hookInput.agentType || hookInput.agent_type || hookInput.agentSlug || 'unknown').slice(0, MAX_TYPE_LEN);
21
31
  fs.writeFileSync(regFile, JSON.stringify({
22
32
  agentId: id,
33
+ agentType: agentType,
23
34
  startedAt: new Date().toISOString(),
24
35
  pid: process.pid,
25
36
  }));
37
+ // Purge stragglers older than 30 min on every agent-start too — not just
38
+ // on TeammateIdle/TaskCompleted (task-handler.cjs) — since sessions that
39
+ // never emit those events would otherwise leak registrations forever.
40
+ const activeAfterPurge = purgeStaleRegistrations(regDir);
26
41
  // Refresh swarm-activity.json within the 5-min staleness window.
27
42
  const activityDir = path.join(CWD, '.monomind', 'metrics');
28
43
  fs.mkdirSync(activityDir, { recursive: true });
29
44
  const activityPath = path.join(activityDir, 'swarm-activity.json');
30
45
  const MAX_AGENTS = 1000;
31
- const active = Math.min(fs.readdirSync(regDir).filter(f => f.endsWith('.json')).length, MAX_AGENTS);
32
- // Preserve lastActive (peak) so statusline shows non-zero after completion.
33
- let prevLastActive = 0;
34
- try {
35
- const MAX_ACTIVITY = 64 * 1024; // 64 KiB
36
- var actStat = fs.statSync(activityPath);
37
- if (actStat.size <= MAX_ACTIVITY) {
38
- prevLastActive = (JSON.parse(fs.readFileSync(activityPath, 'utf-8'))?.swarm?.lastActive) || 0;
39
- }
40
- } catch { /* ignore */ }
41
- fs.writeFileSync(activityPath, JSON.stringify({
42
- timestamp: new Date().toISOString(),
43
- swarm: {
44
- active: active > 0,
45
- agent_count: active,
46
- coordination_active: active > 0,
47
- lastActive: Math.max(active, prevLastActive),
48
- },
49
- }));
46
+ const active = Math.min(
47
+ activeAfterPurge != null ? activeAfterPurge : cleanEntries(regDir, f => f.endsWith('.json')).length,
48
+ MAX_AGENTS
49
+ );
50
+ // P2-21: swarm-activity.json is written by both this handler and
51
+ // task-handler.cjs's handlePostTask, concurrently (Claude Code fires
52
+ // hook events for parallel tool calls in one message). The old
53
+ // read-prevLastActive-then-write was a lost-update race — two
54
+ // processes could both read the same prevLastActive before either
55
+ // wrote, so whichever wrote last silently erased the other's peak.
56
+ // Share one lock file with task-handler.cjs so the read-merge-write
57
+ // cycle is a single critical section across both writers.
58
+ withLock(activityPath + '.lock', function() {
59
+ // Preserve lastActive (peak) so statusline shows non-zero after completion.
60
+ let prevLastActive = 0;
61
+ try {
62
+ const MAX_ACTIVITY = 64 * 1024; // 64 KiB
63
+ var actStat = fs.statSync(activityPath);
64
+ if (actStat.size <= MAX_ACTIVITY) {
65
+ prevLastActive = (JSON.parse(fs.readFileSync(activityPath, 'utf-8'))?.swarm?.lastActive) || 0;
66
+ }
67
+ } catch { /* ignore */ }
68
+ atomicWriteFileSync(activityPath, JSON.stringify({
69
+ timestamp: new Date().toISOString(),
70
+ swarm: {
71
+ active: active > 0,
72
+ agent_count: active,
73
+ coordination_active: active > 0,
74
+ lastActive: Math.max(active, prevLastActive),
75
+ },
76
+ }));
77
+ }, 5000);
50
78
 
51
79
  // Write last-dispatch.json so the route handler can suppress redundant
52
80
  // suggestions on the next turn when the same agent type is recommended.
53
- const MAX_TYPE_LEN = 128;
54
81
  const MAX_DESC_LEN = 500;
55
- const agentType = String(hookInput.subagent_type || hookInput.agentType || hookInput.agent_type || hookInput.agentSlug || 'unknown').slice(0, MAX_TYPE_LEN);
56
82
  const agentDesc = String(hookInput.description || hookInput.prompt_description || '').slice(0, MAX_DESC_LEN);
57
83
  fs.writeFileSync(
58
84
  path.join(CWD, '.monomind', 'last-dispatch.json'),
@@ -87,13 +113,19 @@ module.exports = {
87
113
  if (_hooksModule && _hooksModule.executeHooks && _hooksModule.HookEvent) {
88
114
  var agentTypeBridge = String(hookInput.subagent_type || hookInput.agentType || hookInput.agent_type || hookInput.agentSlug || 'unknown');
89
115
  var agentDescBridge = String(hookInput.description || hookInput.prompt_description || '');
90
- _hooksModule.executeHooks(_hooksModule.HookEvent.AgentSpawn, {
91
- agent: {
92
- id: hookInput.agentId || hookInput.agent_id || (agentTypeBridge + '-' + Date.now()),
93
- type: agentTypeBridge,
94
- description: agentDescBridge.slice(0, 500),
95
- },
96
- }, { continueOnError: true, timeout: 1500 }).catch(function() {});
116
+ // Must be awaited (bounded by runWithTimeout, 1500ms) — hook-handler.cjs's
117
+ // main().finally(() => process.exit(...)) reaps any unawaited fire-and-forget
118
+ // promise before it does real I/O. See session-restore-handler.cjs's
119
+ // SessionStart bridge for the reference pattern this mirrors.
120
+ await hCtx.runWithTimeout(function() {
121
+ return _hooksModule.executeHooks(_hooksModule.HookEvent.AgentSpawn, {
122
+ agent: {
123
+ id: hookInput.agentId || hookInput.agent_id || (agentTypeBridge + '-' + Date.now()),
124
+ type: agentTypeBridge,
125
+ description: agentDescBridge.slice(0, 500),
126
+ },
127
+ }, { continueOnError: true, timeout: 1500 });
128
+ }, '@monomind/hooks.AgentSpawn');
97
129
  }
98
130
  } catch (e) { /* non-fatal */ }
99
131
  },
@@ -21,9 +21,39 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const http = require('http');
23
23
  const os = require('os');
24
+ const crypto = require('crypto');
25
+ const { appendJsonlWithRotation } = require('../utils/fs-helpers.cjs');
24
26
 
25
27
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
26
28
  const CAPTURE_DIR = path.join(CWD, '.monomind', 'capture');
29
+ const SNAP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour — orphaned snapshots are cleaned up this old
30
+
31
+ // P2-23: derive a stable key for a subagent invocation from whatever
32
+ // identifying info Claude Code actually gives us in the hook payload.
33
+ // `transcript_path` is the one field that's both present on SubagentStart
34
+ // and SubagentStop AND unique per-subagent (each subagent gets its own
35
+ // transcript file, distinct from the parent session's) — session_id alone
36
+ // is shared by every concurrently-running subagent in the same team, so it
37
+ // can't disambiguate which stop event belongs to which start.
38
+ function subagentKey(hookInput) {
39
+ var raw = hookInput && (hookInput.transcript_path || hookInput.transcriptPath);
40
+ if (!raw) return null;
41
+ return crypto.createHash('md5').update(String(raw)).digest('hex').slice(0, 16);
42
+ }
43
+
44
+ // Delete snap-*.json files older than SNAP_MAX_AGE_MS — orphaned when a
45
+ // SubagentStop never arrived (crash, forced-stop) or was matched to a
46
+ // different snapshot before keyed matching existed.
47
+ function cleanupStaleSnaps() {
48
+ try {
49
+ var files = fs.readdirSync(CAPTURE_DIR).filter(f => f.startsWith('snap-') && f.endsWith('.json') && !f.startsWith('._'));
50
+ var now = Date.now();
51
+ for (var i = 0; i < files.length; i++) {
52
+ var p = path.join(CAPTURE_DIR, files[i]);
53
+ try { if (now - fs.statSync(p).mtimeMs > SNAP_MAX_AGE_MS) fs.unlinkSync(p); } catch { /* ignore */ }
54
+ }
55
+ } catch { /* CAPTURE_DIR may not exist yet */ }
56
+ }
27
57
 
28
58
  // ─── Helpers ────────────────────────────────────────────────────────────────
29
59
 
@@ -46,7 +76,7 @@ function snapshotJSONLFiles() {
46
76
  if (!fs.existsSync(claudeDir)) return [];
47
77
  try {
48
78
  return fs.readdirSync(claudeDir)
49
- .filter(f => f.endsWith('.jsonl'))
79
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('._'))
50
80
  .map(f => {
51
81
  try { return { name: f, size: fs.statSync(path.join(claudeDir, f)).size }; }
52
82
  catch { return { name: f, size: 0 }; }
@@ -170,6 +200,7 @@ function emitEvent(event) {
170
200
 
171
201
  async function handleSubagentStart(hookInput) {
172
202
  fs.mkdirSync(CAPTURE_DIR, { recursive: true });
203
+ cleanupStaleSnaps();
173
204
 
174
205
  const snapshot = snapshotJSONLFiles();
175
206
  const agentType = String(
@@ -190,10 +221,19 @@ async function handleSubagentStart(hookInput) {
190
221
  }
191
222
  const activeSess = getActiveSession(activeRun);
192
223
 
193
- // Write snapshot to FIFO queue subagent-stop pops the oldest
224
+ // P2-23: key the snapshot filename by transcript_path when available so
225
+ // SubagentStop can look up the EXACT matching snapshot instead of
226
+ // FIFO-popping the lexicographically-oldest one — under real concurrency
227
+ // (multiple subagents stopping in close succession) FIFO pop can match a
228
+ // stop event to the wrong subagent's snapshot. Falls back to the old
229
+ // timestamp+random naming when transcript_path isn't present in the
230
+ // payload, so SubagentStop's FIFO fallback path still has something to
231
+ // match against.
232
+ const subKey = subagentKey(hookInput);
194
233
  const snapFile = path.join(
195
234
  CAPTURE_DIR,
196
- 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6) + '.json'
235
+ subKey ? 'snap-' + subKey + '.json'
236
+ : 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6) + '.json'
197
237
  );
198
238
  const leanModePath = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), '.monomind/state/monolean-mode');
199
239
  let leanMode = null;
@@ -235,18 +275,31 @@ async function handleSubagentStart(hookInput) {
235
275
 
236
276
  async function handleSubagentStop(hookInput) {
237
277
  fs.mkdirSync(CAPTURE_DIR, { recursive: true });
238
-
239
- // Pop oldest snapshot (FIFO — matches the agent that just finished)
240
- const snapFiles = fs.readdirSync(CAPTURE_DIR)
241
- .filter(f => f.startsWith('snap-') && f.endsWith('.json'))
242
- .sort(); // lexicographic = timestamp order
243
-
244
- if (snapFiles.length === 0) {
245
- console.log('[CAPTURE:stop] No snapshot to diff — skipping');
246
- return;
278
+ cleanupStaleSnaps();
279
+
280
+ // P2-23: prefer the exact keyed snapshot (see subagentKey/handleSubagentStart)
281
+ // over FIFO-popping the oldest snap-*.json — FIFO misattributes the stop
282
+ // event to the wrong subagent whenever multiple subagents stop in close
283
+ // succession. Fall back to FIFO only when this event's payload carries no
284
+ // transcript_path (older Claude Code versions / unexpected payload shape).
285
+ const subKey = subagentKey(hookInput);
286
+ let snapPath = null;
287
+ if (subKey) {
288
+ const keyedPath = path.join(CAPTURE_DIR, 'snap-' + subKey + '.json');
289
+ if (fs.existsSync(keyedPath)) snapPath = keyedPath;
290
+ }
291
+ if (!snapPath) {
292
+ const snapFiles = fs.readdirSync(CAPTURE_DIR)
293
+ .filter(f => f.startsWith('snap-') && f.endsWith('.json') && !f.startsWith('._'))
294
+ .sort(); // lexicographic = timestamp order (FIFO fallback)
295
+ if (snapFiles.length === 0) {
296
+ console.log('[CAPTURE:stop] No snapshot to diff — skipping');
297
+ return;
298
+ }
299
+ snapPath = path.join(CAPTURE_DIR, snapFiles[0]);
300
+ console.log('[CAPTURE:stop] no transcript_path/keyed snapshot — falling back to FIFO match');
247
301
  }
248
302
 
249
- const snapPath = path.join(CAPTURE_DIR, snapFiles[0]);
250
303
  let snap;
251
304
  try { snap = JSON.parse(fs.readFileSync(snapPath, 'utf8')); } catch {
252
305
  try { fs.unlinkSync(snapPath); } catch {}
@@ -294,11 +347,13 @@ async function handleSubagentStop(hookInput) {
294
347
 
295
348
  if (!org && !session) {
296
349
  // No active org or session — log to general capture file only
350
+ // P2-26: rotate — was a pure appendFileSync with no cap, unlike
351
+ // intelligence-outcomes.jsonl (500-line cap).
297
352
  const genLog = path.join(CAPTURE_DIR, 'unattributed.jsonl');
298
- fs.appendFileSync(genLog, JSON.stringify({
353
+ appendJsonlWithRotation(genLog, JSON.stringify({
299
354
  ts: Date.now(), agentType, tokens_in: totalTin, tokens_out: totalTout,
300
355
  cost_usd: costUsd, capturedFiles, summary: summary.slice(0, 200),
301
- }) + '\n');
356
+ }), 500);
302
357
  return;
303
358
  }
304
359
 
@@ -355,9 +410,11 @@ async function handleSubagentStop(hookInput) {
355
410
  // ── Persist transcript reference to run directory ─────────────────────────
356
411
  if (capturedFiles.length > 0 && org && runId) {
357
412
  const runDir = path.join(CWD, '.monomind', 'orgs', org, 'runs');
358
- fs.mkdirSync(runDir, { recursive: true });
359
413
  const capLog = path.join(runDir, runId + '-captures.jsonl');
360
- fs.appendFileSync(capLog, JSON.stringify({
414
+ // P2-26: rotate — was a pure appendFileSync with no cap. A long-running
415
+ // org loop can spawn far more than 500 agents over its lifetime, so
416
+ // this uses the same 500-line cap convention as the other JSONL logs.
417
+ appendJsonlWithRotation(capLog, JSON.stringify({
361
418
  type: 'agent:capture',
362
419
  org, runId, session,
363
420
  agentType,
@@ -368,7 +425,7 @@ async function handleSubagentStop(hookInput) {
368
425
  capturedFiles,
369
426
  claudeProjectDir: claudeDir,
370
427
  ts: Date.now(),
371
- }) + '\n');
428
+ }), 500);
372
429
  }
373
430
 
374
431
  // ── Wire captured interaction into EpisodicStore ────