monomind 2.1.0 → 2.1.4

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 (57) hide show
  1. package/README.md +77 -94
  2. package/package.json +3 -3
  3. package/packages/@monomind/cli/.claude/commands/mastermind/createorg.md +8 -9
  4. package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +24 -10
  5. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +2 -2
  6. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +29 -0
  7. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +35 -23
  8. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +21 -0
  9. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +73 -2
  10. package/packages/@monomind/cli/.claude/skills/mastermind-skills/createorg.md +91 -701
  11. package/packages/@monomind/cli/.claude/skills/mastermind-skills/org-settings.md +46 -47
  12. package/packages/@monomind/cli/.claude/skills/mastermind-skills/runorg.md +12 -1
  13. package/packages/@monomind/cli/README.md +77 -94
  14. package/packages/@monomind/cli/dist/src/browser/workflow/store.d.ts +2 -2
  15. package/packages/@monomind/cli/dist/src/commands/browse.d.ts +1 -1
  16. package/packages/@monomind/cli/dist/src/commands/doctor-env-checks.js +5 -0
  17. package/packages/@monomind/cli/dist/src/commands/org.js +46 -29
  18. package/packages/@monomind/cli/dist/src/commands/platforms.d.ts +1 -1
  19. package/packages/@monomind/cli/dist/src/init/executor.js +30 -6
  20. package/packages/@monomind/cli/dist/src/mcp-tools/auto-install.d.ts +8 -8
  21. package/packages/@monomind/cli/dist/src/mcp-tools/coherence/types.d.ts +31 -31
  22. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +5 -1
  23. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +28 -3
  24. package/packages/@monomind/cli/dist/src/mcp-tools/quality/coverage-analysis/prioritize-gaps.d.ts +36 -36
  25. package/packages/@monomind/cli/dist/src/mcp-tools/quality/security-compliance/detect-secrets.d.ts +4 -4
  26. package/packages/@monomind/cli/dist/src/memory/ewc-consolidation.d.ts +12 -0
  27. package/packages/@monomind/cli/dist/src/memory/sona-optimizer.d.ts +12 -0
  28. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +3 -1
  29. package/packages/@monomind/cli/dist/src/orgrt/broker.js +8 -3
  30. package/packages/@monomind/cli/dist/src/orgrt/bus.d.ts +5 -1
  31. package/packages/@monomind/cli/dist/src/orgrt/bus.js +5 -1
  32. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +35 -1
  33. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +190 -21
  34. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +47 -10
  35. package/packages/@monomind/cli/dist/src/orgrt/inbox.d.ts +11 -0
  36. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +56 -0
  37. package/packages/@monomind/cli/dist/src/orgrt/policy.d.ts +5 -1
  38. package/packages/@monomind/cli/dist/src/orgrt/policy.js +58 -5
  39. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +3 -2
  40. package/packages/@monomind/cli/dist/src/orgrt/server.js +31 -40
  41. package/packages/@monomind/cli/dist/src/orgrt/session.d.ts +11 -1
  42. package/packages/@monomind/cli/dist/src/orgrt/session.js +32 -1
  43. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +8 -8
  44. package/packages/@monomind/cli/dist/src/orgrt/types.d.ts +53 -53
  45. package/packages/@monomind/cli/dist/src/output.d.ts +29 -29
  46. package/packages/@monomind/cli/dist/src/output.js +10 -2
  47. package/packages/@monomind/cli/dist/src/pricing/model-pricing.d.ts +1 -1
  48. package/packages/@monomind/cli/dist/src/types.d.ts +2 -2
  49. package/packages/@monomind/cli/dist/src/ui/collector.mjs +20 -5
  50. package/packages/@monomind/cli/dist/src/ui/dashboard.html +465 -500
  51. package/packages/@monomind/cli/dist/src/ui/orgs-files.js +192 -0
  52. package/packages/@monomind/cli/dist/src/ui/orgs.html +21 -52
  53. package/packages/@monomind/cli/dist/src/ui/server.mjs +174 -164
  54. package/packages/@monomind/cli/package.json +12 -16
  55. package/packages/@monomind/cli/scripts/publish.sh +6 -0
  56. package/scripts/generate-agent-avatars.mjs +3 -3
  57. package/packages/@monomind/cli/dist/src/orgrt/live.html +0 -56
@@ -1,6 +1,6 @@
1
1
  // packages/@monomind/cli/src/orgrt/daemon.ts
2
2
  // monolean: single-process inter-org — upgrade path = daemon-to-daemon HTTP when multi-host is real
3
- import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { OrgBus } from './bus.js';
6
6
  import { PolicyEngine } from './policy.js';
@@ -8,11 +8,13 @@ import { Mailbox } from './mailbox.js';
8
8
  import { runAgentSession } from './session.js';
9
9
  import { attachForwarder } from './forwarder.js';
10
10
  import { BrokerLease, lookupOrg } from './broker.js';
11
+ import { queueMessage, drainInbox } from './inbox.js';
11
12
  import { OrgDefSchema, ORG_DIR } from './types.js';
12
13
  export class OrgDaemon {
13
14
  root;
14
15
  opts;
15
16
  orgs = new Map();
17
+ waking = new Set();
16
18
  globalSubscribers = new Set();
17
19
  leases = new Map();
18
20
  forwarders = new Map();
@@ -42,9 +44,19 @@ export class OrgDaemon {
42
44
  const cwd = join(this.root, ORG_DIR, name, 'workspace');
43
45
  mkdirSync(cwd, { recursive: true });
44
46
  const bus = new OrgBus(name, run, dir);
47
+ // Bounded in-memory tail: bus.jsonl on disk is the full durable record;
48
+ // this buffer only backs busEvents() (test-loop, /api/history) and would
49
+ // otherwise grow without limit for a long-running scheduled org — each
50
+ // Write's captured content snapshot alone can be up to 200KB (policy.ts).
51
+ const MAX_COLLECTED = 5000;
45
52
  const collected = [];
46
- bus.subscribe(e => { collected.push(e); for (const fn of this.globalSubscribers)
47
- fn(e); });
53
+ bus.subscribe(e => {
54
+ collected.push(e);
55
+ if (collected.length > MAX_COLLECTED)
56
+ collected.splice(0, collected.length - MAX_COLLECTED);
57
+ for (const fn of this.globalSubscribers)
58
+ fn(e);
59
+ });
48
60
  if (this.opts.forward !== false)
49
61
  this.forwarders.set(name, attachForwarder(bus, this.opts.controlJson ?? join(this.root, '.monomind/control.json')));
50
62
  const running = { def, run, bus, agents: new Map(), busEvents: () => [...collected] };
@@ -58,6 +70,7 @@ export class OrgDaemon {
58
70
  org: name, role, bus, policy, mailbox, cwd, def,
59
71
  maxTurns: def.run_config.max_turns_per_message,
60
72
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
73
+ askHuman: (role, question) => this.askHuman(name, role, question),
61
74
  queryFn: this.opts.queryFn,
62
75
  }).then(() => { runtime.status = 'ended'; })
63
76
  .catch((err) => {
@@ -87,36 +100,80 @@ export class OrgDaemon {
87
100
  lease.start();
88
101
  this.leases.set(name, lease);
89
102
  }
103
+ // Drain any messages that arrived while the org was offline
104
+ const queued = drainInbox(this.root, name);
105
+ for (const msg of queued) {
106
+ const agent = running.agents.get(msg.toRole);
107
+ if (agent && !agent.mailbox.isClosed) {
108
+ bus.emit({ type: 'xorg', from: msg.fromQualified, to: `${name}:${msg.toRole}`, subject: msg.subject, msg: msg.body });
109
+ agent.mailbox.push(`[message from ${msg.fromQualified}] subject: ${msg.subject}\n\n${msg.body}`);
110
+ }
111
+ }
112
+ if (queued.length)
113
+ bus.emit({ type: 'status', msg: `drained ${queued.length} queued message(s) from inbox` });
90
114
  return running;
91
115
  }
116
+ /**
117
+ * Resolves an org_send `to` address ("role" for same-org, "org:role" for
118
+ * cross-org) into its parts. Centralizes the one addressing rule that
119
+ * matters (an "own-org:role" self-prefix is intra-org, not cross-org) so
120
+ * deliver()/deliverRemote() don't each re-derive it — the qualified `to`
121
+ * string returned is always the canonical display form for that address.
122
+ */
123
+ resolveAddress(fromOrg, to) {
124
+ const cross = to.includes(':');
125
+ if (!cross)
126
+ return { cross: false, orgName: fromOrg, role: to, qualified: to };
127
+ const [orgName, role] = to.split(':', 2);
128
+ if (orgName === fromOrg)
129
+ return { cross: false, orgName, role, qualified: role }; // self-prefixed — still intra-org
130
+ return { cross: true, orgName, role, qualified: to };
131
+ }
92
132
  /** Route a message. to = "role" (same org) or "org:role" (cross-org). Returns a receipt string. */
93
133
  async deliver(fromOrg, fromRole, to, subject, body) {
94
- let cross = to.includes(':');
95
- let [targetOrgName, targetRole] = cross ? to.split(':', 2) : [fromOrg, to];
96
- // "own-org:role" is intra-org — agents often self-prefix; don't tag it xorg
97
- if (cross && targetOrgName === fromOrg) {
98
- cross = false;
99
- to = targetRole;
100
- }
134
+ const { cross, orgName: targetOrgName, role: targetRole, qualified: toQualified } = this.resolveAddress(fromOrg, to);
101
135
  const targetOrg = this.orgs.get(targetOrgName);
102
136
  const src = this.orgs.get(fromOrg);
103
137
  if (!targetOrg || !targetOrg.agents.has(targetRole)) {
104
138
  if (cross && this.opts.crossProcess)
105
- return this.deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src);
106
- src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
107
- return `ERROR: unknown recipient "${to}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
139
+ return this.deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, toQualified, subject, body, src);
140
+ // Queue + auto-wake: if the org definition exists locally but isn't running, spool the message and start it
141
+ if (cross && this.hasOrgDef(targetOrgName)) {
142
+ queueMessage(this.root, targetOrgName, { fromQualified: `${fromOrg}:${fromRole}`, toRole: targetRole, subject, body, ts: Date.now() });
143
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to: toQualified, subject, msg: body, data: { queued: true } });
144
+ this.autoWake(targetOrgName);
145
+ return `queued for ${toQualified} (org starting)`;
146
+ }
147
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
148
+ return `ERROR: unknown recipient "${toQualified}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
149
+ }
150
+ const targetAgent = targetOrg.agents.get(targetRole);
151
+ if (targetAgent.mailbox.isClosed) {
152
+ // The org is mid-shutdown: mailboxes close before the org is removed
153
+ // from `this.orgs`, so a message can arrive in that window. push() would
154
+ // silently no-op — report the real outcome instead of a false "delivered".
155
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'target mailbox closed (org shutting down)' });
156
+ return `ERROR: recipient "${toQualified}" is shutting down — message not delivered`;
108
157
  }
109
- const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: cross ? to : targetRole, subject, msg: body };
158
+ const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: toQualified, subject, msg: body };
110
159
  src?.bus.emit({ type: cross ? 'xorg' : 'message', ...evt });
111
160
  if (cross && targetOrg !== src)
112
161
  targetOrg.bus.emit({ type: 'xorg', ...evt });
113
- targetOrg.agents.get(targetRole).mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
114
- return `delivered to ${to}`;
162
+ targetAgent.mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
163
+ return `delivered to ${toQualified}`;
115
164
  }
116
- /** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP. */
165
+ /** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP.
166
+ * `to` here is always the fully-qualified "org:role" display form (resolveAddress already normalized it). */
117
167
  async deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src) {
118
168
  const remote = lookupOrg(targetOrgName, this.opts.brokerDir);
119
169
  if (!remote) {
170
+ // No remote host either — queue + auto-wake if the org def exists locally
171
+ if (this.hasOrgDef(targetOrgName)) {
172
+ queueMessage(this.root, targetOrgName, { fromQualified: `${fromOrg}:${fromRole}`, toRole: targetRole, subject, body, ts: Date.now() });
173
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to, subject, msg: body, data: { queued: true } });
174
+ this.autoWake(targetOrgName);
175
+ return `queued for ${to} (org starting)`;
176
+ }
120
177
  src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
121
178
  return `ERROR: unknown recipient "${to}" (no local org, and no process on this machine has org "${targetOrgName}" registered)`;
122
179
  }
@@ -146,24 +203,137 @@ export class OrgDaemon {
146
203
  * agent's mailbox; the agent picks it up on its own next turn (see Mailbox — never interrupts). */
147
204
  receiveRemote(toOrg, toRole, fromQualified, subject, body) {
148
205
  const org = this.orgs.get(toOrg);
149
- if (!org)
206
+ if (!org) {
207
+ // Org not running — queue the message and auto-wake if the def exists
208
+ if (this.hasOrgDef(toOrg)) {
209
+ queueMessage(this.root, toOrg, { fromQualified, toRole, subject, body, ts: Date.now() });
210
+ this.autoWake(toOrg);
211
+ return { ok: true, receipt: `queued for ${toOrg}:${toRole} (org waking)` };
212
+ }
150
213
  return { ok: false, error: `org "${toOrg}" not hosted here` };
214
+ }
151
215
  const agent = org.agents.get(toRole);
152
216
  if (!agent)
153
217
  return { ok: false, error: `role "${toRole}" not found in org "${toOrg}"` };
218
+ if (agent.mailbox.isClosed)
219
+ return { ok: false, error: `role "${toRole}" in org "${toOrg}" is shutting down` };
154
220
  org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
155
221
  agent.mailbox.push(`[message from ${fromQualified}] subject: ${subject}\n\n${body}`);
156
222
  return { ok: true, receipt: `delivered to ${toOrg}:${toRole} (remote)` };
157
223
  }
224
+ hasOrgDef(name) {
225
+ return existsSync(join(this.root, ORG_DIR, `${name}.json`));
226
+ }
227
+ questionsPath(org) {
228
+ return join(this.root, ORG_DIR, org, 'questions.json');
229
+ }
230
+ readQuestions(org) {
231
+ try {
232
+ return JSON.parse(readFileSync(this.questionsPath(org), 'utf8'));
233
+ }
234
+ catch {
235
+ return { questions: [] };
236
+ }
237
+ }
238
+ writeQuestions(org, data) {
239
+ const dest = this.questionsPath(org);
240
+ mkdirSync(join(this.root, ORG_DIR, org), { recursive: true });
241
+ const tmp = `${dest}.${process.pid}.tmp`;
242
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
243
+ renameSync(tmp, dest);
244
+ }
245
+ /** Agent-initiated human question (ask_human tool). Persists to questions.json (survives
246
+ * process/dashboard restarts) and emits a 'question' BusEvent so the dashboard's SSE
247
+ * stream and global inbox pick it up in real time. Returns a receipt string for the tool call. */
248
+ async askHuman(org, role, question) {
249
+ const running = this.orgs.get(org);
250
+ const questionId = `q-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
251
+ const data = this.readQuestions(org);
252
+ data.questions.push({ questionId, role, question, ts: Date.now(), answer: null, answeredAt: null });
253
+ this.writeQuestions(org, data);
254
+ running?.bus.emit({ type: 'question', from: role, data: { questionId, question } });
255
+ return `Question recorded (id ${questionId}) — a human will answer it; you'll receive the answer as a new message.`;
256
+ }
257
+ /** Delivers a human's answer to a pending ask_human question. If the org is still
258
+ * running, pushes straight into the role's live mailbox (picked up on its very next
259
+ * generator tick — see Mailbox.stream()). If the org has since stopped, queues the
260
+ * answer via the same offline fallback deliver()/receiveRemote() already use
261
+ * (inbox.ts + autoWake) and it's delivered when the org next starts. */
262
+ async answerQuestion(org, role, questionId, answer) {
263
+ const data = this.readQuestions(org);
264
+ const idx = data.questions.findIndex(q => q.questionId === questionId);
265
+ if (idx === -1)
266
+ return { ok: false, error: `question "${questionId}" not found for org "${org}"` };
267
+ if (data.questions[idx].answer !== null)
268
+ return { ok: false, error: `question "${questionId}" already answered` };
269
+ data.questions[idx] = { ...data.questions[idx], answer, answeredAt: Date.now() };
270
+ this.writeQuestions(org, data);
271
+ const running = this.orgs.get(org);
272
+ if (running) {
273
+ // Org IS running — deliver or report a real error, but never fall through to the
274
+ // offline queue+autoWake path below: autoWake() no-ops when this.orgs already has
275
+ // the org (see its own guard), so a role-specific delivery failure here (mailbox
276
+ // closed, role unknown) would otherwise queue the answer forever with no real error
277
+ // and no delivery. Mirrors deliver()'s existing "shutting down" error for the same
278
+ // mid-shutdown-mailbox-closed race.
279
+ const agent = running.agents.get(role);
280
+ if (!agent)
281
+ return { ok: false, error: `role "${role}" not found in org "${org}"` };
282
+ if (agent.mailbox.isClosed)
283
+ return { ok: false, error: `role "${role}" in org "${org}" is shutting down — answer not delivered` };
284
+ running.bus.emit({ type: 'status', from: role, msg: 'question answered', data: { questionId } });
285
+ agent.mailbox.push(`[answer from human] question: ${data.questions[idx].question}\n\nanswer: ${answer}`);
286
+ return { ok: true };
287
+ }
288
+ // Org not running at all — queue for delivery on next start, matching deliver()'s
289
+ // existing offline fallback exactly (inbox.ts + autoWake).
290
+ if (!this.hasOrgDef(org))
291
+ return { ok: false, error: `org "${org}" not found (no saved definition)` };
292
+ queueMessage(this.root, org, {
293
+ fromQualified: 'human', toRole: role,
294
+ subject: `answer:${questionId}`,
295
+ body: `question: ${data.questions[idx].question}\n\nanswer: ${answer}`,
296
+ ts: Date.now(),
297
+ });
298
+ this.autoWake(org);
299
+ return { ok: true };
300
+ }
301
+ /** Start an offline org in the background so queued messages get drained.
302
+ * Fire-and-forget — errors are logged but don't propagate to the sender. */
303
+ autoWake(name) {
304
+ if (this.orgs.has(name) || this.waking.has(name))
305
+ return;
306
+ this.waking.add(name);
307
+ this.startOrg(name)
308
+ .catch(err => { console.error(`auto-wake org "${name}" failed:`, err instanceof Error ? err.message : err); })
309
+ .finally(() => { this.waking.delete(name); });
310
+ }
158
311
  async stopOrg(name) {
159
312
  const org = this.orgs.get(name);
160
313
  if (!org)
161
- return;
314
+ return; // already stopped, or another concurrent stopOrg() call is handling it
315
+ // Remove immediately (not at the end) so a concurrent stopOrg(name) call —
316
+ // e.g. stopAll() racing a scheduler-triggered stop on SIGINT — sees the org
317
+ // is already gone and no-ops instead of re-running the whole shutdown and
318
+ // double-emitting 'org stopped' (duplicate org:complete/session:complete).
319
+ this.orgs.delete(name);
162
320
  this.leases.get(name)?.stop();
163
321
  this.leases.delete(name);
164
322
  for (const a of org.agents.values())
165
323
  a.mailbox.close();
166
- await Promise.allSettled([...org.agents.values()].map(a => a.done));
324
+ // Bounded: a genuinely hung agent session (stuck mid-tool-call, not just
325
+ // idle) must not make stopOrg() hang forever — callers like the scheduler
326
+ // already race their own timeout around a run, and this wait re-blocking
327
+ // unboundedly on the same never-resolving promises defeated that bound.
328
+ const stopWaitMs = this.opts.stopWaitMs ?? 15_000;
329
+ const allDone = Promise.allSettled([...org.agents.values()].map(a => a.done)).then(() => false);
330
+ const timedOut = await Promise.race([allDone, new Promise(r => setTimeout(() => r(true), stopWaitMs))]);
331
+ if (timedOut) {
332
+ org.bus.emit({
333
+ type: 'audit', msg: `org stop timed out after ${stopWaitMs}ms waiting for agent sessions to finish — proceeding anyway`,
334
+ reason: 'stop-timeout',
335
+ });
336
+ }
167
337
  org.bus.emit({ type: 'status', msg: 'org stopped' });
168
338
  await org.bus.flush();
169
339
  // the "org stopped" event above triggers the forwarder's final org:complete /
@@ -177,7 +347,6 @@ export class OrgDaemon {
177
347
  this.forwarders.delete(name);
178
348
  }
179
349
  this.persistState(name, 'stopped', org.run);
180
- this.orgs.delete(name);
181
350
  }
182
351
  async stopAll() {
183
352
  await Promise.all([...this.orgs.keys()].map(n => this.stopOrg(n)));
@@ -1,9 +1,9 @@
1
1
  // packages/@monomind/cli/src/orgrt/forwarder.ts
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
- import { basename, extname } from 'node:path';
3
+ import { basename, extname, dirname, join } from 'node:path';
4
4
  /**
5
5
  * Forwards every bus event to the running mastermind control server
6
- * (dist/src/ui/server.mjs, POST /api/mastermind/event) so the existing
6
+ * (src/ui/server.mjs, POST /api/mastermind/event) so the existing
7
7
  * dashboard shows org activity. Best-effort: failures are dropped.
8
8
  *
9
9
  * Events are translated into the dashboard's native vocabulary
@@ -16,7 +16,7 @@ import { basename, extname } from 'node:path';
16
16
  * companionEvents() additionally emits session:start/session:complete: the
17
17
  * dashboard's client-side Chat tab only lists a run in its session dropdown
18
18
  * once it has seen a session:start for that session id — org:start alone is
19
- * NOT enough (verified against dist/src/ui/orgs.html's handleMmEvent, which
19
+ * NOT enough (verified against src/ui/orgs.html's handleMmEvent, which
20
20
  * creates the chatSessions entry solely on session:start). Companions are
21
21
  * sent before the primary translate() payload for the same bus event so the
22
22
  * client-side session record exists before anything tries to append to it.
@@ -31,6 +31,13 @@ import { basename, extname } from 'node:path';
31
31
  * already connected via SSE when session:start fired would show it).
32
32
  */
33
33
  function sessionId(org, run) { return `${org}__${run}`; }
34
+ function classifyStatus(msg) {
35
+ if (msg.startsWith('org started'))
36
+ return 'started';
37
+ if (msg === 'org stopped')
38
+ return 'stopped';
39
+ return 'other';
40
+ }
34
41
  export function attachForwarder(bus, controlJsonPath = '.monomind/control.json') {
35
42
  let chain = Promise.resolve();
36
43
  const baseUrl = () => {
@@ -44,10 +51,26 @@ export function attachForwarder(bus, controlJsonPath = '.monomind/control.json')
44
51
  return null;
45
52
  }
46
53
  };
54
+ // src/ui/server.mjs's startServer() writes a per-process auth credential
55
+ // to 'dashboard-token' next to control.json (same .monomind dir) and requires
56
+ // it via a header on every non-GET request. Read fresh each POST since the
57
+ // credential rotates whenever the server restarts.
58
+ const readAuthCredential = () => {
59
+ try {
60
+ return readFileSync(join(dirname(controlJsonPath), 'dashboard-token'), 'utf8').trim();
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ };
47
66
  const post = async (url, payload) => {
67
+ const credential = readAuthCredential();
48
68
  await fetch(`${url}/api/mastermind/event`, {
49
69
  method: 'POST',
50
- headers: { 'Content-Type': 'application/json' },
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ ...(credential ? { 'x-monomind-token': credential } : {}),
73
+ },
51
74
  body: JSON.stringify(payload),
52
75
  signal: AbortSignal.timeout(3000),
53
76
  }).then(r => { r.body?.cancel(); }).catch(() => { });
@@ -82,11 +105,12 @@ export function companionEvents(e) {
82
105
  return [];
83
106
  const base = { session: sessionId(e.org, e.run), org: e.org, ts: e.ts };
84
107
  const msg = e.msg ?? '';
85
- if (msg.startsWith('org started')) {
108
+ const kind = classifyStatus(msg);
109
+ if (kind === 'started') {
86
110
  const goal = e.data?.goal;
87
111
  return [{ ...base, type: 'session:start', prompt: goal && goal.length ? goal : e.org }];
88
112
  }
89
- if (msg === 'org stopped') {
113
+ if (kind === 'stopped') {
90
114
  return [{ ...base, type: 'session:complete', status: 'complete', domains: ['ops'] }];
91
115
  }
92
116
  return [];
@@ -109,16 +133,29 @@ export function translate(e) {
109
133
  ...base, type: 'org:comms', from: e.from, to: e.to,
110
134
  msg: e.subject ? `[${e.subject}] ${e.msg ?? ''}` : e.msg,
111
135
  };
112
- case 'asset':
136
+ case 'asset': {
137
+ // content (when PolicyEngine captured a Write snapshot) rides along so the
138
+ // dashboard can diff this exact version later instead of only ever seeing
139
+ // whatever is currently on disk at `path`.
140
+ const content = e.data?.content;
113
141
  return {
114
142
  ...base, type: 'org:artifact', from: e.from,
115
- artifact: { label: basename(e.path ?? 'asset'), type: 'file', path: e.path, mimeType: guessMimeType(e.path) },
143
+ artifact: {
144
+ label: basename(e.path ?? 'asset'), type: 'file', path: e.path, mimeType: guessMimeType(e.path),
145
+ ...(content !== undefined ? { content } : {}),
146
+ },
116
147
  };
148
+ }
149
+ case 'question': {
150
+ const q = e.data ?? {};
151
+ return { ...base, type: 'org:question', from: e.from, questionId: q.questionId, question: q.question };
152
+ }
117
153
  case 'status': {
118
154
  const msg = e.msg ?? '';
119
- if (msg.startsWith('org started'))
155
+ const kind = classifyStatus(msg);
156
+ if (kind === 'started')
120
157
  return { ...base, type: 'org:start', goal: e.data?.goal ?? '' };
121
- if (msg === 'org stopped')
158
+ if (kind === 'stopped')
122
159
  return { ...base, type: 'org:complete' };
123
160
  if (msg === 'session starting')
124
161
  return { ...base, type: 'org:agent:online', role: e.from, title: e.from, agent_type: e.from };
@@ -0,0 +1,11 @@
1
+ export interface QueuedMessage {
2
+ fromQualified: string;
3
+ toRole: string;
4
+ subject: string;
5
+ body: string;
6
+ ts: number;
7
+ }
8
+ export declare function queueMessage(root: string, orgName: string, msg: QueuedMessage): void;
9
+ export declare function drainInbox(root: string, orgName: string): QueuedMessage[];
10
+ export declare function inboxCount(root: string, orgName: string): number;
11
+ //# sourceMappingURL=inbox.d.ts.map
@@ -0,0 +1,56 @@
1
+ // packages/@monomind/cli/src/orgrt/inbox.ts
2
+ // Persistent message queue for offline orgs. Messages that can't be delivered
3
+ // (target org not running) are spooled here and drained when the org starts.
4
+ import { appendFileSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { ORG_DIR } from './types.js';
7
+ function inboxPath(root, orgName) {
8
+ return join(root, ORG_DIR, orgName, 'inbox.jsonl');
9
+ }
10
+ export function queueMessage(root, orgName, msg) {
11
+ const dir = join(root, ORG_DIR, orgName);
12
+ mkdirSync(dir, { recursive: true });
13
+ appendFileSync(inboxPath(root, orgName), JSON.stringify(msg) + '\n');
14
+ }
15
+ export function drainInbox(root, orgName) {
16
+ const path = inboxPath(root, orgName);
17
+ if (!existsSync(path))
18
+ return [];
19
+ // Rename-then-read: if the process crashes after rename but before we finish
20
+ // reading, the .draining file survives for manual recovery. A plain
21
+ // read-then-truncate would lose messages on a mid-drain crash.
22
+ const draining = `${path}.draining`;
23
+ try {
24
+ renameSync(path, draining);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const raw = readFileSync(draining, 'utf8').trim();
30
+ if (!raw) {
31
+ writeFileSync(draining, '');
32
+ renameSync(draining, path);
33
+ return [];
34
+ }
35
+ // Clear the draining file — messages are now the caller's responsibility
36
+ writeFileSync(draining, '');
37
+ renameSync(draining, path);
38
+ const msgs = [];
39
+ for (const line of raw.split('\n')) {
40
+ try {
41
+ msgs.push(JSON.parse(line));
42
+ }
43
+ catch { /* skip corrupt lines */ }
44
+ }
45
+ return msgs;
46
+ }
47
+ export function inboxCount(root, orgName) {
48
+ const path = inboxPath(root, orgName);
49
+ if (!existsSync(path))
50
+ return 0;
51
+ const raw = readFileSync(path, 'utf8').trim();
52
+ if (!raw)
53
+ return 0;
54
+ return raw.split('\n').length;
55
+ }
56
+ //# sourceMappingURL=inbox.js.map
@@ -7,7 +7,11 @@ export type Decision = {
7
7
  behavior: 'deny';
8
8
  message: string;
9
9
  };
10
- /** tiny glob→RegExp: supports ** (any depth) and * (single segment). */
10
+ /**
11
+ * tiny glob→RegExp: `**\/` matches zero-or-more leading directories (so
12
+ * `**\/*.md` matches both `README.md` and `docs/README.md`, standard glob
13
+ * semantics), bare `**` matches any depth, `*` matches one path segment.
14
+ */
11
15
  export declare function globToRegExp(glob: string): RegExp;
12
16
  export declare class PolicyEngine {
13
17
  readonly role: string;
@@ -3,11 +3,44 @@ import { relative, resolve } from 'node:path';
3
3
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
4
4
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep']);
5
5
  const WEB_TOOLS = new Set(['WebFetch', 'WebSearch']);
6
- /** tiny glob→RegExp: supports ** (any depth) and * (single segment). */
6
+ /** Cap for inline content snapshots on 'asset' events (bytes, UTF-16 chars) — keeps
7
+ * bus.jsonl / the dashboard's per-session event log from bloating on large writes. */
8
+ const SNAPSHOT_MAX_CHARS = 200_000;
9
+ const REGEX_METACHARS = new Set('.+^${}()|[]\\'.split(''));
10
+ /**
11
+ * tiny glob→RegExp: `**\/` matches zero-or-more leading directories (so
12
+ * `**\/*.md` matches both `README.md` and `docs/README.md`, standard glob
13
+ * semantics), bare `**` matches any depth, `*` matches one path segment.
14
+ */
7
15
  export function globToRegExp(glob) {
8
- const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
9
- .replace(/\*\*/g, ' ').replace(/\*/g, '[^/]*').replace(/ /g, '.*');
10
- return new RegExp(`^${esc}$`);
16
+ let out = '';
17
+ let i = 0;
18
+ while (i < glob.length) {
19
+ if (glob.startsWith('**/', i)) {
20
+ out += '(?:.*/)?';
21
+ i += 3;
22
+ continue;
23
+ }
24
+ if (glob.startsWith('**', i)) {
25
+ out += '.*';
26
+ i += 2;
27
+ continue;
28
+ }
29
+ const c = glob[i];
30
+ if (c === '*') {
31
+ out += '[^/]*';
32
+ i++;
33
+ continue;
34
+ }
35
+ if (REGEX_METACHARS.has(c)) {
36
+ out += '\\' + c;
37
+ i++;
38
+ continue;
39
+ }
40
+ out += c;
41
+ i++;
42
+ }
43
+ return new RegExp(`^${out}$`);
11
44
  }
12
45
  export class PolicyEngine {
13
46
  role;
@@ -34,7 +67,19 @@ export class PolicyEngine {
34
67
  const allow = () => {
35
68
  this.bus.emit({ type: 'tool', from: this.role, tool, decision: 'allow', data: { input: summarize(input) } });
36
69
  if (WRITE_TOOLS.has(tool) && typeof input.file_path === 'string') {
37
- this.bus.emit({ type: 'asset', from: this.role, path: String(input.file_path) });
70
+ // Snapshot the full resulting content when we actually have it at decide()
71
+ // time. Write's `content` param IS the complete post-write file — capture
72
+ // it inline on the event so the dashboard can diff this version against a
73
+ // later one without re-reading disk (which only ever holds the CURRENT
74
+ // version). Edit only carries old_string/new_string fragments, not the
75
+ // resulting whole file, so there is nothing accurate to snapshot there —
76
+ // the event still records the write (path, from), just without content.
77
+ const content = tool === 'Write' && typeof input.content === 'string'
78
+ && input.content.length <= SNAPSHOT_MAX_CHARS ? input.content : undefined;
79
+ this.bus.emit({
80
+ type: 'asset', from: this.role, path: String(input.file_path),
81
+ ...(content !== undefined ? { data: { content } } : {}),
82
+ });
38
83
  }
39
84
  return { behavior: 'allow', updatedInput: input };
40
85
  };
@@ -46,8 +91,16 @@ export class PolicyEngine {
46
91
  return deny(`tool ${tool} not in allowlist for role ${this.role}`);
47
92
  if (WRITE_TOOLS.has(tool) || READ_TOOLS.has(tool)) {
48
93
  const globs = WRITE_TOOLS.has(tool) ? (this.policy.fileWrite ?? ['**']) : (this.policy.fileRead ?? ['**']);
94
+ const unrestricted = globs.length === 1 && globs[0] === '**';
49
95
  const p = typeof input.file_path === 'string' ? input.file_path
50
96
  : typeof input.path === 'string' ? input.path : null;
97
+ if (p === null && !unrestricted) {
98
+ // Grep/Glob's `path` argument is optional in the SDK (defaults to cwd,
99
+ // i.e. searches everything) — without this check, a path-less call
100
+ // sailed straight through to allow() and bypassed fileRead/fileWrite
101
+ // scoping entirely. Deny rather than guess which files it would touch.
102
+ return deny(`${tool} has no path argument, but role ${this.role}'s ${WRITE_TOOLS.has(tool) ? 'write' : 'read'} scope is restricted — refusing an unscoped call`);
103
+ }
51
104
  if (p !== null) {
52
105
  const rel = relative(this.cwd, resolve(this.cwd, p));
53
106
  if (rel.startsWith('..'))
@@ -3,6 +3,7 @@ export interface OrgServer {
3
3
  port: number;
4
4
  close: () => void;
5
5
  }
6
- /** Daemon-owned live view: WS fanout of every bus event + tiny REST surface. */
7
- export declare function startOrgServer(daemon: OrgDaemon, port: number): Promise<OrgServer>;
6
+ /** Minimal HTTP listener for cross-process org message delivery.
7
+ * Binds an ephemeral port (pass 0) so the daemon can register it with the broker. */
8
+ export declare function startOrgServer(daemon: OrgDaemon, port?: number): Promise<OrgServer>;
8
9
  //# sourceMappingURL=server.d.ts.map