monomind 2.0.3 → 2.1.3

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 (113) hide show
  1. package/README.md +74 -92
  2. package/package.json +11 -7
  3. package/packages/@monomind/cli/.claude/helpers/control-start.cjs +13 -20
  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 +99 -28
  8. package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +27 -9
  9. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +18 -9
  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 +33 -4
  12. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +75 -45
  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 +75 -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/monograph.cjs +73 -2
  24. package/packages/@monomind/cli/.claude/helpers/utils/telemetry.cjs +35 -19
  25. package/packages/@monomind/cli/README.md +74 -92
  26. package/packages/@monomind/cli/bin/cli.js +10 -1
  27. package/packages/@monomind/cli/dist/src/browser/workflow/store.d.ts +2 -2
  28. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +9 -15
  29. package/packages/@monomind/cli/dist/src/commands/browse.d.ts +1 -1
  30. package/packages/@monomind/cli/dist/src/commands/cleanup.js +20 -4
  31. package/packages/@monomind/cli/dist/src/commands/completions.js +66 -261
  32. package/packages/@monomind/cli/dist/src/commands/doctor-env-checks.js +5 -0
  33. package/packages/@monomind/cli/dist/src/commands/doctor.js +7 -2
  34. package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +7 -0
  35. package/packages/@monomind/cli/dist/src/commands/index.js +3 -0
  36. package/packages/@monomind/cli/dist/src/commands/mcp.js +11 -6
  37. package/packages/@monomind/cli/dist/src/commands/memory-admin.js +12 -6
  38. package/packages/@monomind/cli/dist/src/commands/memory-transfer.js +6 -13
  39. package/packages/@monomind/cli/dist/src/commands/monograph.js +3 -3
  40. package/packages/@monomind/cli/dist/src/commands/org.d.ts +3 -1
  41. package/packages/@monomind/cli/dist/src/commands/org.js +321 -127
  42. package/packages/@monomind/cli/dist/src/commands/platforms.d.ts +1 -1
  43. package/packages/@monomind/cli/dist/src/commands/start.js +142 -37
  44. package/packages/@monomind/cli/dist/src/commands/tokens.js +21 -3
  45. package/packages/@monomind/cli/dist/src/config-adapter.js +37 -17
  46. package/packages/@monomind/cli/dist/src/index.js +64 -28
  47. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +44 -11
  48. package/packages/@monomind/cli/dist/src/init/executor.js +93 -55
  49. package/packages/@monomind/cli/dist/src/mcp-client.d.ts +8 -3
  50. package/packages/@monomind/cli/dist/src/mcp-client.js +37 -3
  51. package/packages/@monomind/cli/dist/src/mcp-server.js +8 -2
  52. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.d.ts +20 -0
  53. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.js +10 -5
  54. package/packages/@monomind/cli/dist/src/mcp-tools/auto-install.d.ts +8 -8
  55. package/packages/@monomind/cli/dist/src/mcp-tools/coherence/types.d.ts +31 -31
  56. package/packages/@monomind/cli/dist/src/mcp-tools/github-tools.js +30 -8
  57. package/packages/@monomind/cli/dist/src/mcp-tools/graphify-tools.js +11 -2
  58. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +56 -35
  59. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +9 -1
  60. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-compat.js +56 -14
  61. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +88 -41
  62. package/packages/@monomind/cli/dist/src/mcp-tools/quality/coverage-analysis/prioritize-gaps.d.ts +36 -36
  63. package/packages/@monomind/cli/dist/src/mcp-tools/quality/security-compliance/detect-secrets.d.ts +4 -4
  64. package/packages/@monomind/cli/dist/src/mcp-tools/swarm-tools.js +6 -4
  65. package/packages/@monomind/cli/dist/src/mcp-tools/system-tools.js +4 -4
  66. package/packages/@monomind/cli/dist/src/mcp-tools/types.d.ts +20 -0
  67. package/packages/@monomind/cli/dist/src/mcp-tools/types.js +36 -1
  68. package/packages/@monomind/cli/dist/src/memory/ewc-consolidation.d.ts +12 -0
  69. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.d.ts +13 -1
  70. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.js +66 -14
  71. package/packages/@monomind/cli/dist/src/memory/intelligence.js +99 -3
  72. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +7 -0
  73. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +20 -0
  74. package/packages/@monomind/cli/dist/src/memory/sona-optimizer.d.ts +12 -0
  75. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +26 -0
  76. package/packages/@monomind/cli/dist/src/orgrt/broker.js +73 -0
  77. package/packages/@monomind/cli/dist/src/orgrt/bus.d.ts +5 -1
  78. package/packages/@monomind/cli/dist/src/orgrt/bus.js +5 -1
  79. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +46 -0
  80. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +201 -18
  81. package/packages/@monomind/cli/dist/src/orgrt/forwarder.d.ts +10 -5
  82. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +138 -8
  83. package/packages/@monomind/cli/dist/src/orgrt/inbox.d.ts +11 -0
  84. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +56 -0
  85. package/packages/@monomind/cli/dist/src/orgrt/policy.d.ts +5 -1
  86. package/packages/@monomind/cli/dist/src/orgrt/policy.js +58 -5
  87. package/packages/@monomind/cli/dist/src/orgrt/provider.js +1 -0
  88. package/packages/@monomind/cli/dist/src/orgrt/scheduler.d.ts +13 -0
  89. package/packages/@monomind/cli/dist/src/orgrt/scheduler.js +48 -0
  90. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +9 -0
  91. package/packages/@monomind/cli/dist/src/orgrt/server.js +39 -0
  92. package/packages/@monomind/cli/dist/src/orgrt/session.d.ts +10 -1
  93. package/packages/@monomind/cli/dist/src/orgrt/session.js +24 -1
  94. package/packages/@monomind/cli/dist/src/orgrt/test-loop.d.ts +12 -0
  95. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +108 -0
  96. package/packages/@monomind/cli/dist/src/orgrt/types.d.ts +52 -52
  97. package/packages/@monomind/cli/dist/src/output.d.ts +29 -29
  98. package/packages/@monomind/cli/dist/src/output.js +22 -7
  99. package/packages/@monomind/cli/dist/src/parser.d.ts +32 -0
  100. package/packages/@monomind/cli/dist/src/parser.js +130 -5
  101. package/packages/@monomind/cli/dist/src/routing/embed-worker.js +13 -1
  102. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +31 -0
  103. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +21 -0
  104. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +60 -7
  105. package/packages/@monomind/cli/dist/src/services/crash-reporter.js +2 -1
  106. package/packages/@monomind/cli/dist/src/types.d.ts +2 -2
  107. package/packages/@monomind/cli/dist/src/ui/dashboard.html +243 -49
  108. package/packages/@monomind/cli/dist/src/ui/orgs-files.js +192 -0
  109. package/packages/@monomind/cli/dist/src/ui/orgs.html +79 -11
  110. package/packages/@monomind/cli/dist/src/ui/server.mjs +346 -139
  111. package/packages/@monomind/cli/dist/src/utils/input-guards.d.ts +9 -0
  112. package/packages/@monomind/cli/dist/src/utils/input-guards.js +39 -5
  113. package/packages/@monomind/cli/package.json +15 -15
@@ -1,22 +1,29 @@
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 } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { OrgBus } from './bus.js';
6
6
  import { PolicyEngine } from './policy.js';
7
7
  import { Mailbox } from './mailbox.js';
8
8
  import { runAgentSession } from './session.js';
9
9
  import { attachForwarder } from './forwarder.js';
10
+ import { BrokerLease, lookupOrg } from './broker.js';
11
+ import { queueMessage, drainInbox } from './inbox.js';
10
12
  import { OrgDefSchema, ORG_DIR } from './types.js';
11
13
  export class OrgDaemon {
12
14
  root;
13
15
  opts;
14
16
  orgs = new Map();
17
+ waking = new Set();
15
18
  globalSubscribers = new Set();
19
+ leases = new Map();
20
+ forwarders = new Map();
16
21
  constructor(root, opts = {}) {
17
22
  this.root = root;
18
23
  this.opts = opts;
19
24
  }
25
+ /** Publish this daemon's inbox so orgs started AFTER this call register with the broker. */
26
+ setInboxUrl(url) { this.opts.inboxUrl = url; }
20
27
  /** subscribe to events from ALL running orgs (dashboard server uses this) */
21
28
  subscribe(fn) {
22
29
  this.globalSubscribers.add(fn);
@@ -29,66 +36,242 @@ export class OrgDaemon {
29
36
  throw new Error(`org ${name} already running`);
30
37
  const defPath = join(this.root, ORG_DIR, `${name}.json`);
31
38
  const def = OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8')));
32
- const run = `run-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)}`;
39
+ // random suffix: second-precision stamps collide across processes (two CLI
40
+ // invocations in the same second would share a run dir and its bus.jsonl)
41
+ const run = `run-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`;
33
42
  const dir = join(this.root, ORG_DIR, name, run);
34
43
  mkdirSync(dir, { recursive: true });
35
44
  const cwd = join(this.root, ORG_DIR, name, 'workspace');
36
45
  mkdirSync(cwd, { recursive: true });
37
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;
38
52
  const collected = [];
39
- bus.subscribe(e => { collected.push(e); for (const fn of this.globalSubscribers)
40
- 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
+ });
41
60
  if (this.opts.forward !== false)
42
- attachForwarder(bus, this.opts.controlJson ?? join(this.root, '.monomind/control.json'));
61
+ this.forwarders.set(name, attachForwarder(bus, this.opts.controlJson ?? join(this.root, '.monomind/control.json')));
43
62
  const running = { def, run, bus, agents: new Map(), busEvents: () => [...collected] };
44
63
  this.orgs.set(name, running);
45
64
  const perRoleBudget = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / def.roles.length);
46
65
  for (const role of def.roles) {
47
66
  const mailbox = new Mailbox();
48
67
  const policy = new PolicyEngine(role.id, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, cwd);
49
- const done = runAgentSession({
68
+ const runtime = { mailbox, policy, status: 'running', done: Promise.resolve() };
69
+ runtime.done = runAgentSession({
50
70
  org: name, role, bus, policy, mailbox, cwd, def,
51
71
  maxTurns: def.run_config.max_turns_per_message,
52
72
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
53
73
  queryFn: this.opts.queryFn,
54
- }).catch(() => { });
55
- running.agents.set(role.id, { mailbox, policy, done });
74
+ }).then(() => { runtime.status = 'ended'; })
75
+ .catch((err) => {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ runtime.status = 'crashed';
78
+ runtime.error = message;
79
+ // runAgentSession already emits a 'status' event for the raw error; emit an
80
+ // 'audit' event too so dashboards/alerts that filter on actionable failures
81
+ // (not routine status chatter) can surface a dead agent instead of a run that
82
+ // silently never progresses.
83
+ bus.emit({
84
+ type: 'audit', from: role.id,
85
+ msg: `agent "${role.id}" crashed: ${message}`,
86
+ reason: 'agent-session-crash',
87
+ data: { agentId: role.id, error: message },
88
+ });
89
+ });
90
+ running.agents.set(role.id, runtime);
56
91
  }
57
92
  const boss = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
58
93
  running.agents.get(boss.id).mailbox.push(`Org "${name}" started (run ${run}).\nGoal: ${taskOverride ?? def.goal}\n` +
59
94
  `Coordinate your team via org_send. Report completion by ending your turn.`);
60
95
  bus.emit({ type: 'status', msg: `org started (${def.roles.length} agents)`, data: { goal: taskOverride ?? def.goal } });
61
96
  this.persistState(name, 'running', run);
97
+ if (this.opts.crossProcess && this.opts.inboxUrl) {
98
+ const lease = new BrokerLease(name, this.opts.inboxUrl, this.opts.brokerDir);
99
+ lease.start();
100
+ this.leases.set(name, lease);
101
+ }
102
+ // Drain any messages that arrived while the org was offline
103
+ const queued = drainInbox(this.root, name);
104
+ for (const msg of queued) {
105
+ const agent = running.agents.get(msg.toRole);
106
+ if (agent && !agent.mailbox.isClosed) {
107
+ bus.emit({ type: 'xorg', from: msg.fromQualified, to: `${name}:${msg.toRole}`, subject: msg.subject, msg: msg.body });
108
+ agent.mailbox.push(`[message from ${msg.fromQualified}] subject: ${msg.subject}\n\n${msg.body}`);
109
+ }
110
+ }
111
+ if (queued.length)
112
+ bus.emit({ type: 'status', msg: `drained ${queued.length} queued message(s) from inbox` });
62
113
  return running;
63
114
  }
115
+ /**
116
+ * Resolves an org_send `to` address ("role" for same-org, "org:role" for
117
+ * cross-org) into its parts. Centralizes the one addressing rule that
118
+ * matters (an "own-org:role" self-prefix is intra-org, not cross-org) so
119
+ * deliver()/deliverRemote() don't each re-derive it — the qualified `to`
120
+ * string returned is always the canonical display form for that address.
121
+ */
122
+ resolveAddress(fromOrg, to) {
123
+ const cross = to.includes(':');
124
+ if (!cross)
125
+ return { cross: false, orgName: fromOrg, role: to, qualified: to };
126
+ const [orgName, role] = to.split(':', 2);
127
+ if (orgName === fromOrg)
128
+ return { cross: false, orgName, role, qualified: role }; // self-prefixed — still intra-org
129
+ return { cross: true, orgName, role, qualified: to };
130
+ }
64
131
  /** Route a message. to = "role" (same org) or "org:role" (cross-org). Returns a receipt string. */
65
132
  async deliver(fromOrg, fromRole, to, subject, body) {
66
- const cross = to.includes(':');
67
- const [targetOrgName, targetRole] = cross ? to.split(':', 2) : [fromOrg, to];
133
+ const { cross, orgName: targetOrgName, role: targetRole, qualified: toQualified } = this.resolveAddress(fromOrg, to);
68
134
  const targetOrg = this.orgs.get(targetOrgName);
69
135
  const src = this.orgs.get(fromOrg);
70
136
  if (!targetOrg || !targetOrg.agents.has(targetRole)) {
71
- src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
72
- return `ERROR: unknown recipient "${to}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
137
+ if (cross && this.opts.crossProcess)
138
+ return this.deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, toQualified, subject, body, src);
139
+ // Queue + auto-wake: if the org definition exists locally but isn't running, spool the message and start it
140
+ if (cross && this.hasOrgDef(targetOrgName)) {
141
+ queueMessage(this.root, targetOrgName, { fromQualified: `${fromOrg}:${fromRole}`, toRole: targetRole, subject, body, ts: Date.now() });
142
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to: toQualified, subject, msg: body, data: { queued: true } });
143
+ this.autoWake(targetOrgName);
144
+ return `queued for ${toQualified} (org starting)`;
145
+ }
146
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
147
+ return `ERROR: unknown recipient "${toQualified}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
73
148
  }
74
- const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: cross ? to : targetRole, subject, msg: body };
149
+ const targetAgent = targetOrg.agents.get(targetRole);
150
+ if (targetAgent.mailbox.isClosed) {
151
+ // The org is mid-shutdown: mailboxes close before the org is removed
152
+ // from `this.orgs`, so a message can arrive in that window. push() would
153
+ // silently no-op — report the real outcome instead of a false "delivered".
154
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'target mailbox closed (org shutting down)' });
155
+ return `ERROR: recipient "${toQualified}" is shutting down — message not delivered`;
156
+ }
157
+ const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: toQualified, subject, msg: body };
75
158
  src?.bus.emit({ type: cross ? 'xorg' : 'message', ...evt });
76
159
  if (cross && targetOrg !== src)
77
160
  targetOrg.bus.emit({ type: 'xorg', ...evt });
78
- targetOrg.agents.get(targetRole).mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
79
- return `delivered to ${to}`;
161
+ targetAgent.mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
162
+ return `delivered to ${toQualified}`;
163
+ }
164
+ /** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP.
165
+ * `to` here is always the fully-qualified "org:role" display form (resolveAddress already normalized it). */
166
+ async deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src) {
167
+ const remote = lookupOrg(targetOrgName, this.opts.brokerDir);
168
+ if (!remote) {
169
+ // No remote host either — queue + auto-wake if the org def exists locally
170
+ if (this.hasOrgDef(targetOrgName)) {
171
+ queueMessage(this.root, targetOrgName, { fromQualified: `${fromOrg}:${fromRole}`, toRole: targetRole, subject, body, ts: Date.now() });
172
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to, subject, msg: body, data: { queued: true } });
173
+ this.autoWake(targetOrgName);
174
+ return `queued for ${to} (org starting)`;
175
+ }
176
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
177
+ return `ERROR: unknown recipient "${to}" (no local org, and no process on this machine has org "${targetOrgName}" registered)`;
178
+ }
179
+ try {
180
+ const res = await fetch(`${remote.url}/api/xdeliver`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ fromOrg, fromRole, toOrg: targetOrgName, toRole: targetRole, subject, body }),
184
+ signal: AbortSignal.timeout(10_000),
185
+ });
186
+ const data = await res.json().catch(() => ({}));
187
+ if (res.ok && data.ok) {
188
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to, subject, msg: body });
189
+ return data.receipt ?? `delivered to ${to} (remote)`;
190
+ }
191
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `remote delivery rejected: ${data.error ?? res.status}`, reason: 'remote-delivery-rejected' });
192
+ return `ERROR: remote org "${to}" rejected delivery: ${data.error ?? res.status}`;
193
+ }
194
+ catch (err) {
195
+ const message = err instanceof Error ? err.message : String(err);
196
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `remote delivery failed: ${message}`, reason: 'remote-delivery-failed' });
197
+ return `ERROR: remote org "${targetOrgName}" unreachable: ${message}`;
198
+ }
199
+ }
200
+ /** Inbound handler for cross-process delivery — called by the server's POST /api/xdeliver route
201
+ * when ANOTHER process's deliverRemote() reaches this daemon. Pushes straight into the target
202
+ * agent's mailbox; the agent picks it up on its own next turn (see Mailbox — never interrupts). */
203
+ receiveRemote(toOrg, toRole, fromQualified, subject, body) {
204
+ const org = this.orgs.get(toOrg);
205
+ if (!org) {
206
+ // Org not running — queue the message and auto-wake if the def exists
207
+ if (this.hasOrgDef(toOrg)) {
208
+ queueMessage(this.root, toOrg, { fromQualified, toRole, subject, body, ts: Date.now() });
209
+ this.autoWake(toOrg);
210
+ return { ok: true, receipt: `queued for ${toOrg}:${toRole} (org waking)` };
211
+ }
212
+ return { ok: false, error: `org "${toOrg}" not hosted here` };
213
+ }
214
+ const agent = org.agents.get(toRole);
215
+ if (!agent)
216
+ return { ok: false, error: `role "${toRole}" not found in org "${toOrg}"` };
217
+ if (agent.mailbox.isClosed)
218
+ return { ok: false, error: `role "${toRole}" in org "${toOrg}" is shutting down` };
219
+ org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
220
+ agent.mailbox.push(`[message from ${fromQualified}] subject: ${subject}\n\n${body}`);
221
+ return { ok: true, receipt: `delivered to ${toOrg}:${toRole} (remote)` };
222
+ }
223
+ hasOrgDef(name) {
224
+ return existsSync(join(this.root, ORG_DIR, `${name}.json`));
225
+ }
226
+ /** Start an offline org in the background so queued messages get drained.
227
+ * Fire-and-forget — errors are logged but don't propagate to the sender. */
228
+ autoWake(name) {
229
+ if (this.orgs.has(name) || this.waking.has(name))
230
+ return;
231
+ this.waking.add(name);
232
+ this.startOrg(name)
233
+ .catch(err => { console.error(`auto-wake org "${name}" failed:`, err instanceof Error ? err.message : err); })
234
+ .finally(() => { this.waking.delete(name); });
80
235
  }
81
236
  async stopOrg(name) {
82
237
  const org = this.orgs.get(name);
83
238
  if (!org)
84
- return;
239
+ return; // already stopped, or another concurrent stopOrg() call is handling it
240
+ // Remove immediately (not at the end) so a concurrent stopOrg(name) call —
241
+ // e.g. stopAll() racing a scheduler-triggered stop on SIGINT — sees the org
242
+ // is already gone and no-ops instead of re-running the whole shutdown and
243
+ // double-emitting 'org stopped' (duplicate org:complete/session:complete).
244
+ this.orgs.delete(name);
245
+ this.leases.get(name)?.stop();
246
+ this.leases.delete(name);
85
247
  for (const a of org.agents.values())
86
248
  a.mailbox.close();
87
- await Promise.allSettled([...org.agents.values()].map(a => a.done));
249
+ // Bounded: a genuinely hung agent session (stuck mid-tool-call, not just
250
+ // idle) must not make stopOrg() hang forever — callers like the scheduler
251
+ // already race their own timeout around a run, and this wait re-blocking
252
+ // unboundedly on the same never-resolving promises defeated that bound.
253
+ const stopWaitMs = this.opts.stopWaitMs ?? 15_000;
254
+ const allDone = Promise.allSettled([...org.agents.values()].map(a => a.done)).then(() => false);
255
+ const timedOut = await Promise.race([allDone, new Promise(r => setTimeout(() => r(true), stopWaitMs))]);
256
+ if (timedOut) {
257
+ org.bus.emit({
258
+ type: 'audit', msg: `org stop timed out after ${stopWaitMs}ms waiting for agent sessions to finish — proceeding anyway`,
259
+ reason: 'stop-timeout',
260
+ });
261
+ }
88
262
  org.bus.emit({ type: 'status', msg: 'org stopped' });
89
263
  await org.bus.flush();
264
+ // the "org stopped" event above triggers the forwarder's final org:complete /
265
+ // session:complete POST — without waiting for it here, the CLI process can exit
266
+ // (and kill the in-flight fetch) before that last event reaches the dashboard,
267
+ // leaving the run stuck showing "running" forever.
268
+ const forwarder = this.forwarders.get(name);
269
+ if (forwarder) {
270
+ await forwarder.settle();
271
+ forwarder.unsubscribe();
272
+ this.forwarders.delete(name);
273
+ }
90
274
  this.persistState(name, 'stopped', org.run);
91
- this.orgs.delete(name);
92
275
  }
93
276
  async stopAll() {
94
277
  await Promise.all([...this.orgs.keys()].map(n => this.stopOrg(n)));
@@ -1,11 +1,16 @@
1
1
  import type { OrgBus } from './bus.js';
2
- /**
3
- * Forwards every bus event to the running mastermind control server
4
- * (dist/src/ui/server.mjs, POST /api/mastermind/event) so the existing
5
- * dashboard SSE stream shows org activity. Best-effort: failures are dropped.
6
- */
2
+ import type { BusEvent } from './types.js';
7
3
  export declare function attachForwarder(bus: OrgBus, controlJsonPath?: string): {
8
4
  settle: () => Promise<void>;
9
5
  unsubscribe: () => void;
10
6
  };
7
+ /**
8
+ * Companion dashboard events a single bus event needs beyond its primary
9
+ * translate() mapping. Currently: org-started -> session:start (registers
10
+ * the run in the client's session list) and org-stopped -> session:complete.
11
+ * Exported for tests.
12
+ */
13
+ export declare function companionEvents(e: BusEvent): Record<string, unknown>[];
14
+ /** BusEvent → dashboard-native mastermind event. Exported for tests. */
15
+ export declare function translate(e: BusEvent): Record<string, unknown>;
11
16
  //# sourceMappingURL=forwarder.d.ts.map
@@ -1,10 +1,43 @@
1
1
  // packages/@monomind/cli/src/orgrt/forwarder.ts
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
+ import { basename, extname, dirname, join } from 'node:path';
3
4
  /**
4
5
  * Forwards every bus event to the running mastermind control server
5
6
  * (dist/src/ui/server.mjs, POST /api/mastermind/event) so the existing
6
- * dashboard SSE stream shows org activity. Best-effort: failures are dropped.
7
+ * dashboard shows org activity. Best-effort: failures are dropped.
8
+ *
9
+ * Events are translated into the dashboard's native vocabulary
10
+ * (org:start / org:comms / org:agent:online|offline / org:artifact /
11
+ * org:checkpoint / org:complete) so the Orgs panel and Chat tab render
12
+ * them; every payload carries org + runId so the server routes it to the
13
+ * run file. Event kinds without a native rendering (tool/usage/audit) are
14
+ * forwarded as raw org:<type> — they still land in the run file and SSE.
15
+ *
16
+ * companionEvents() additionally emits session:start/session:complete: the
17
+ * dashboard's client-side Chat tab only lists a run in its session dropdown
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
20
+ * creates the chatSessions entry solely on session:start). Companions are
21
+ * sent before the primary translate() payload for the same bus event so the
22
+ * client-side session record exists before anything tries to append to it.
23
+ *
24
+ * sessionId() below joins org/run with "__", not ":" — server.mjs's
25
+ * per-session persistence (data/sessions/<id>.jsonl + _index.json, the store
26
+ * GET /api/mastermind/sessions reads on page load) validates the session id
27
+ * against /^(?!.*\.\.)[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/, which rejects colons. A
28
+ * colon-joined id silently fails that check — the event still lands in the
29
+ * raw mastermind-events.jsonl log, but the run never persists to the session
30
+ * index, so a fresh dashboard load never lists it (only a client that was
31
+ * already connected via SSE when session:start fired would show it).
7
32
  */
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
+ }
8
41
  export function attachForwarder(bus, controlJsonPath = '.monomind/control.json') {
9
42
  let chain = Promise.resolve();
10
43
  const baseUrl = () => {
@@ -18,20 +51,117 @@ export function attachForwarder(bus, controlJsonPath = '.monomind/control.json')
18
51
  return null;
19
52
  }
20
53
  };
54
+ // dist/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
+ };
66
+ const post = async (url, payload) => {
67
+ const credential = readAuthCredential();
68
+ await fetch(`${url}/api/mastermind/event`, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ ...(credential ? { 'x-monomind-token': credential } : {}),
73
+ },
74
+ body: JSON.stringify(payload),
75
+ signal: AbortSignal.timeout(3000),
76
+ }).then(r => { r.body?.cancel(); }).catch(() => { });
77
+ };
21
78
  const unsubscribe = bus.subscribe((e) => {
22
79
  chain = chain.then(async () => {
23
80
  const url = baseUrl();
24
81
  if (!url)
25
82
  return;
26
- const payload = { ...e, type: `org:${e.type}`, session: `${e.org}:${e.run}`, domain: 'ops' };
27
- await fetch(`${url}/api/mastermind/event`, {
28
- method: 'POST',
29
- headers: { 'Content-Type': 'application/json' },
30
- body: JSON.stringify(payload),
31
- signal: AbortSignal.timeout(3000),
32
- }).then(r => { r.body?.cancel(); }).catch(() => { });
83
+ for (const payload of companionEvents(e))
84
+ await post(url, payload);
85
+ await post(url, translate(e));
33
86
  }).catch(() => { });
34
87
  });
35
88
  return { settle: () => chain, unsubscribe };
36
89
  }
90
+ const TEXTUAL_EXT = new Set(['.md', '.txt', '.json', '.ts', '.tsx', '.js', '.jsx', '.mjs',
91
+ '.py', '.sh', '.yaml', '.yml', '.html', '.css', '.xml', '.log', '.csv']);
92
+ function guessMimeType(path) {
93
+ if (!path)
94
+ return 'application/octet-stream';
95
+ return TEXTUAL_EXT.has(extname(path).toLowerCase()) ? 'text/plain' : 'application/octet-stream';
96
+ }
97
+ /**
98
+ * Companion dashboard events a single bus event needs beyond its primary
99
+ * translate() mapping. Currently: org-started -> session:start (registers
100
+ * the run in the client's session list) and org-stopped -> session:complete.
101
+ * Exported for tests.
102
+ */
103
+ export function companionEvents(e) {
104
+ if (e.type !== 'status')
105
+ return [];
106
+ const base = { session: sessionId(e.org, e.run), org: e.org, ts: e.ts };
107
+ const msg = e.msg ?? '';
108
+ const kind = classifyStatus(msg);
109
+ if (kind === 'started') {
110
+ const goal = e.data?.goal;
111
+ return [{ ...base, type: 'session:start', prompt: goal && goal.length ? goal : e.org }];
112
+ }
113
+ if (kind === 'stopped') {
114
+ return [{ ...base, type: 'session:complete', status: 'complete', domains: ['ops'] }];
115
+ }
116
+ return [];
117
+ }
118
+ /** BusEvent → dashboard-native mastermind event. Exported for tests. */
119
+ export function translate(e) {
120
+ const base = {
121
+ org: e.org,
122
+ runId: e.run,
123
+ session: sessionId(e.org, e.run),
124
+ domain: 'ops',
125
+ ts: e.ts,
126
+ };
127
+ switch (e.type) {
128
+ case 'chat':
129
+ return { ...base, type: 'org:comms', from: e.from, to: 'all', msg: e.msg };
130
+ case 'message':
131
+ case 'xorg':
132
+ return {
133
+ ...base, type: 'org:comms', from: e.from, to: e.to,
134
+ msg: e.subject ? `[${e.subject}] ${e.msg ?? ''}` : e.msg,
135
+ };
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;
141
+ return {
142
+ ...base, type: 'org:artifact', from: e.from,
143
+ artifact: {
144
+ label: basename(e.path ?? 'asset'), type: 'file', path: e.path, mimeType: guessMimeType(e.path),
145
+ ...(content !== undefined ? { content } : {}),
146
+ },
147
+ };
148
+ }
149
+ case 'status': {
150
+ const msg = e.msg ?? '';
151
+ const kind = classifyStatus(msg);
152
+ if (kind === 'started')
153
+ return { ...base, type: 'org:start', goal: e.data?.goal ?? '' };
154
+ if (kind === 'stopped')
155
+ return { ...base, type: 'org:complete' };
156
+ if (msg === 'session starting')
157
+ return { ...base, type: 'org:agent:online', role: e.from, title: e.from, agent_type: e.from };
158
+ if (msg === 'session ended' || msg.startsWith('session error'))
159
+ return { ...base, type: 'org:agent:offline', from: e.from, reason: msg };
160
+ return { ...base, type: 'org:checkpoint', progress: msg, from: e.from };
161
+ }
162
+ default:
163
+ // tool / usage / audit — no native widget; keep raw for run file + SSE
164
+ return { ...e, ...base, type: `org:${e.type}` };
165
+ }
166
+ }
37
167
  //# sourceMappingURL=forwarder.js.map
@@ -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('..'))