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
@@ -10,9 +10,9 @@
10
10
  *
11
11
  * @module v1/cli/intelligence
12
12
  */
13
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
- import { join } from 'node:path';
15
+ import { dirname, join } from 'node:path';
16
16
  import { cosineSimilarity as sharedCosineSimilarity } from '../utils/cosine-similarity.js';
17
17
  import { readJsonFileSync, writeJsonFileAtomic } from '../utils/json-file.js';
18
18
  // ============================================================================
@@ -49,6 +49,75 @@ function ensureDataDir() {
49
49
  function getPatternsPath() {
50
50
  return join(getDataDir(), 'patterns.json');
51
51
  }
52
+ // ── multi-process-safe patterns.json flush (P1-14) ─────────────────────────
53
+ // patterns.json can be flushed by more than one process at once — e.g. the
54
+ // long-lived MCP server process and a short-lived CJS hook subprocess each
55
+ // load their own in-memory copy, then flush independently. Without a lock +
56
+ // re-read-before-write, whichever flush lands last silently erases whatever
57
+ // the other process added since its own load. flushToDisk() below re-reads
58
+ // the current on-disk file immediately before writing and merges this
59
+ // process's in-memory patterns in by `id` (keeping whichever record has the
60
+ // newer `lastUsedAt`), guarded by a short advisory lock.
61
+ function getPatternsLockPath() {
62
+ return `${getPatternsPath()}.lock`;
63
+ }
64
+ const PATTERNS_LOCK_STALE_MS = 10_000; // single JSON write — 10s is generous
65
+ function isPidAlive(pid) {
66
+ if (!Number.isInteger(pid) || pid <= 0)
67
+ return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ }
72
+ catch (e) {
73
+ return e?.code === 'EPERM';
74
+ }
75
+ }
76
+ /**
77
+ * Mirrors the stale-lock-breaking pattern in
78
+ * .claude/helpers/control-start.cjs (claimSpawnLock/releaseSpawnLock):
79
+ * wx-flag write to claim, break the lock if its holder is dead or the lock
80
+ * is older than the stale threshold, retry once, then give up and proceed
81
+ * unlocked (best-effort — a missed lock still merges via the re-read, it
82
+ * just narrows a tiny TOCTOU window further).
83
+ */
84
+ function claimPatternsLock() {
85
+ const lockPath = getPatternsLockPath();
86
+ try {
87
+ mkdirSync(dirname(lockPath), { recursive: true });
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ try {
93
+ writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
94
+ return true;
95
+ }
96
+ catch {
97
+ try {
98
+ const stat = statSync(lockPath);
99
+ const holder = Number(readFileSync(lockPath, 'utf-8'));
100
+ if (Date.now() - stat.mtimeMs < PATTERNS_LOCK_STALE_MS && isPidAlive(holder))
101
+ return false;
102
+ unlinkSync(lockPath);
103
+ writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ }
111
+ function releasePatternsLock() {
112
+ const lockPath = getPatternsLockPath();
113
+ try {
114
+ if (Number(readFileSync(lockPath, 'utf-8')) === process.pid)
115
+ unlinkSync(lockPath);
116
+ }
117
+ catch {
118
+ /* ignore */
119
+ }
120
+ }
52
121
  /**
53
122
  * Get the stats file path
54
123
  */
@@ -412,7 +481,34 @@ class LocalReasoningBank {
412
481
  return;
413
482
  try {
414
483
  ensureDataDir();
415
- writeJsonFileAtomic(getPatternsPath(), this.patternList);
484
+ let acquired = claimPatternsLock();
485
+ if (!acquired)
486
+ acquired = claimPatternsLock(); // one retry — lock is short-lived
487
+ try {
488
+ // Re-read the current on-disk patterns and merge this process's
489
+ // in-memory changes in by id (newer lastUsedAt wins) instead of
490
+ // blindly overwriting the whole file with this.patternList — see
491
+ // the P1-14 comment above claimPatternsLock() for why.
492
+ const diskPatterns = readJsonFileSync(getPatternsPath(), []);
493
+ const merged = new Map();
494
+ if (Array.isArray(diskPatterns)) {
495
+ for (const p of diskPatterns) {
496
+ if (p && typeof p.id === 'string')
497
+ merged.set(p.id, p);
498
+ }
499
+ }
500
+ for (const p of this.patternList) {
501
+ const existing = merged.get(p.id);
502
+ if (!existing || (p.lastUsedAt ?? 0) >= (existing.lastUsedAt ?? 0)) {
503
+ merged.set(p.id, p);
504
+ }
505
+ }
506
+ writeJsonFileAtomic(getPatternsPath(), Array.from(merged.values()));
507
+ }
508
+ finally {
509
+ if (acquired)
510
+ releasePatternsLock();
511
+ }
416
512
  this.dirty = false;
417
513
  }
418
514
  catch {
@@ -7,6 +7,8 @@
7
7
  * @module v1/cli/memory-bridge
8
8
  */
9
9
  export declare function safeParseEmbedding(raw: string | null | undefined): number[] | null;
10
+ /** Resolve the real on-disk LanceDB path for a given custom path (or the default). */
11
+ export declare function bridgeGetDbPath(customPath?: string): string;
10
12
  export declare function bridgeStoreEntry(options: {
11
13
  key: string;
12
14
  value: string;
@@ -113,6 +115,11 @@ export declare function bridgeLoadEmbeddingModel(dbPath?: string): Promise<{
113
115
  modelName: string;
114
116
  loadTime?: number;
115
117
  } | null>;
118
+ export declare function bridgeGetBackendStats(dbPath?: string): Promise<{
119
+ totalEntries: number;
120
+ entriesByNamespace: Record<string, number>;
121
+ memoryUsage: number;
122
+ } | null>;
116
123
  export declare function bridgeGetHNSWStatus(dbPath?: string): Promise<{
117
124
  built: boolean;
118
125
  size: number;
@@ -88,6 +88,10 @@ function getDbPath(customPath) {
88
88
  return resolved;
89
89
  return defaultDir;
90
90
  }
91
+ /** Resolve the real on-disk LanceDB path for a given custom path (or the default). */
92
+ export function bridgeGetDbPath(customPath) {
93
+ return getDbPath(customPath);
94
+ }
91
95
  function getAutomemConfig() {
92
96
  const defaults = { dedupThreshold: 0.85, staleDays: 7 };
93
97
  try {
@@ -439,6 +443,22 @@ export async function bridgeLoadEmbeddingModel(dbPath) {
439
443
  return null;
440
444
  }
441
445
  }
446
+ export async function bridgeGetBackendStats(dbPath) {
447
+ const backend = await getBackend(dbPath);
448
+ if (!backend)
449
+ return null;
450
+ try {
451
+ const stats = await backend.getStats();
452
+ return {
453
+ totalEntries: stats?.totalEntries ?? 0,
454
+ entriesByNamespace: stats?.entriesByNamespace ?? {},
455
+ memoryUsage: stats?.memoryUsage ?? 0,
456
+ };
457
+ }
458
+ catch {
459
+ return null;
460
+ }
461
+ }
442
462
  // ===== HNSW (replaced by LanceDB ANN — stubs kept for API compat) =====
443
463
  export async function bridgeGetHNSWStatus(dbPath) {
444
464
  const backend = await getBackend(dbPath);
@@ -0,0 +1,24 @@
1
+ export interface BrokerEntry {
2
+ url: string;
3
+ pid: number;
4
+ updatedAt: number;
5
+ }
6
+ export declare function defaultRegistryDir(): string;
7
+ /** Publish that this process hosts org `name`, reachable via `url`. Call again periodically (heartbeat) — see BrokerLease. */
8
+ export declare function registerOrg(name: string, url: string, dir?: string): void;
9
+ /** Remove this process's registration for `name` (best effort). */
10
+ export declare function unregisterOrg(name: string, dir?: string): void;
11
+ /** Find which process (if any) currently hosts org `name`. Null if never registered or the registration is stale (owner crashed without cleanup). */
12
+ export declare function lookupOrg(name: string, dir?: string, staleMs?: number): BrokerEntry | null;
13
+ /** Keeps a broker registration alive with periodic heartbeats until stop() is called. */
14
+ export declare class BrokerLease {
15
+ private name;
16
+ private url;
17
+ private dir;
18
+ private intervalMs;
19
+ private timer;
20
+ constructor(name: string, url: string, dir?: string, intervalMs?: number);
21
+ start(): void;
22
+ stop(): void;
23
+ }
24
+ //# sourceMappingURL=broker.d.ts.map
@@ -0,0 +1,68 @@
1
+ // packages/@monomind/cli/src/orgrt/broker.ts
2
+ // monolean: file-based local broker for cross-process org discovery (different
3
+ // `monomind org` processes / project directories, same machine). Upgrade path:
4
+ // a real network registry when cross-machine discovery is needed.
5
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ const SAFE_NAME = /^[a-z0-9][a-z0-9_-]*$/i;
9
+ const DEFAULT_STALE_MS = 90_000;
10
+ export function defaultRegistryDir() {
11
+ return process.env.MONOMIND_ORGRT_BROKER_DIR || join(homedir(), '.monomind', 'orgrt-broker');
12
+ }
13
+ function entryPath(name, dir) {
14
+ if (!SAFE_NAME.test(name))
15
+ throw new Error(`invalid org name for broker registry: ${name}`);
16
+ return join(dir, `${name}.json`);
17
+ }
18
+ /** Publish that this process hosts org `name`, reachable via `url`. Call again periodically (heartbeat) — see BrokerLease. */
19
+ export function registerOrg(name, url, dir = defaultRegistryDir()) {
20
+ mkdirSync(dir, { recursive: true });
21
+ const entry = { url, pid: process.pid, updatedAt: Date.now() };
22
+ writeFileSync(entryPath(name, dir), JSON.stringify(entry));
23
+ }
24
+ /** Remove this process's registration for `name` (best effort). */
25
+ export function unregisterOrg(name, dir = defaultRegistryDir()) {
26
+ try {
27
+ unlinkSync(entryPath(name, dir));
28
+ }
29
+ catch { /* already gone */ }
30
+ }
31
+ /** Find which process (if any) currently hosts org `name`. Null if never registered or the registration is stale (owner crashed without cleanup). */
32
+ export function lookupOrg(name, dir = defaultRegistryDir(), staleMs = DEFAULT_STALE_MS) {
33
+ try {
34
+ const entry = JSON.parse(readFileSync(entryPath(name, dir), 'utf8'));
35
+ if (Date.now() - entry.updatedAt >= staleMs)
36
+ return null;
37
+ return entry;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Keeps a broker registration alive with periodic heartbeats until stop() is called. */
44
+ export class BrokerLease {
45
+ name;
46
+ url;
47
+ dir;
48
+ intervalMs;
49
+ timer = null;
50
+ constructor(name, url, dir = defaultRegistryDir(), intervalMs = 20_000) {
51
+ this.name = name;
52
+ this.url = url;
53
+ this.dir = dir;
54
+ this.intervalMs = intervalMs;
55
+ }
56
+ start() {
57
+ registerOrg(this.name, this.url, this.dir);
58
+ this.timer = setInterval(() => registerOrg(this.name, this.url, this.dir), this.intervalMs);
59
+ this.timer.unref?.();
60
+ }
61
+ stop() {
62
+ if (this.timer)
63
+ clearInterval(this.timer);
64
+ this.timer = null;
65
+ unregisterOrg(this.name, this.dir);
66
+ }
67
+ }
68
+ //# sourceMappingURL=broker.js.map
@@ -7,6 +7,9 @@ interface AgentRuntime {
7
7
  mailbox: Mailbox;
8
8
  policy: PolicyEngine;
9
9
  done: Promise<void>;
10
+ /** 'running' until the session promise settles; 'crashed' if it rejected (see error). */
11
+ status: 'running' | 'ended' | 'crashed';
12
+ error?: string;
10
13
  }
11
14
  export interface RunningOrg {
12
15
  def: OrgDef;
@@ -19,13 +22,28 @@ export interface DaemonOpts {
19
22
  queryFn?: typeof query;
20
23
  forward?: boolean;
21
24
  controlJson?: string;
25
+ /** Enables cross-process inter-org routing: on a local delivery miss, ask the
26
+ * machine-local broker whether another `monomind org` process (e.g. a
27
+ * different project directory) hosts the target org, and deliver over HTTP
28
+ * if so. Off by default — tests and single-process runs don't need it. */
29
+ crossProcess?: boolean;
30
+ /** Base URL at which OTHER processes can reach this daemon's inbox (see
31
+ * server.ts POST /api/xdeliver). Set this to make orgs hosted here
32
+ * discoverable; omit for outbound-only cross-process delivery. */
33
+ inboxUrl?: string;
34
+ /** Override the broker's file registry directory (tests only). */
35
+ brokerDir?: string;
22
36
  }
23
37
  export declare class OrgDaemon {
24
38
  private root;
25
39
  private opts;
26
40
  private orgs;
27
41
  private globalSubscribers;
42
+ private leases;
43
+ private forwarders;
28
44
  constructor(root: string, opts?: DaemonOpts);
45
+ /** Publish this daemon's inbox so orgs started AFTER this call register with the broker. */
46
+ setInboxUrl(url: string): void;
29
47
  /** subscribe to events from ALL running orgs (dashboard server uses this) */
30
48
  subscribe(fn: (e: BusEvent) => void): () => void;
31
49
  listOrgs(): RunningOrg[];
@@ -33,6 +51,18 @@ export declare class OrgDaemon {
33
51
  startOrg(name: string, taskOverride?: string): Promise<RunningOrg>;
34
52
  /** Route a message. to = "role" (same org) or "org:role" (cross-org). Returns a receipt string. */
35
53
  deliver(fromOrg: string, fromRole: string, to: string, subject: string, body: string): Promise<string>;
54
+ /** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP. */
55
+ private deliverRemote;
56
+ /** Inbound handler for cross-process delivery — called by the server's POST /api/xdeliver route
57
+ * when ANOTHER process's deliverRemote() reaches this daemon. Pushes straight into the target
58
+ * agent's mailbox; the agent picks it up on its own next turn (see Mailbox — never interrupts). */
59
+ receiveRemote(toOrg: string, toRole: string, fromQualified: string, subject: string, body: string): {
60
+ ok: true;
61
+ receipt: string;
62
+ } | {
63
+ ok: false;
64
+ error: string;
65
+ };
36
66
  stopOrg(name: string): Promise<void>;
37
67
  stopAll(): Promise<void>;
38
68
  private persistState;
@@ -7,16 +7,21 @@ 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';
10
11
  import { OrgDefSchema, ORG_DIR } from './types.js';
11
12
  export class OrgDaemon {
12
13
  root;
13
14
  opts;
14
15
  orgs = new Map();
15
16
  globalSubscribers = new Set();
17
+ leases = new Map();
18
+ forwarders = new Map();
16
19
  constructor(root, opts = {}) {
17
20
  this.root = root;
18
21
  this.opts = opts;
19
22
  }
23
+ /** Publish this daemon's inbox so orgs started AFTER this call register with the broker. */
24
+ setInboxUrl(url) { this.opts.inboxUrl = url; }
20
25
  /** subscribe to events from ALL running orgs (dashboard server uses this) */
21
26
  subscribe(fn) {
22
27
  this.globalSubscribers.add(fn);
@@ -29,7 +34,9 @@ export class OrgDaemon {
29
34
  throw new Error(`org ${name} already running`);
30
35
  const defPath = join(this.root, ORG_DIR, `${name}.json`);
31
36
  const def = OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8')));
32
- const run = `run-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)}`;
37
+ // random suffix: second-precision stamps collide across processes (two CLI
38
+ // invocations in the same second would share a run dir and its bus.jsonl)
39
+ const run = `run-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`;
33
40
  const dir = join(this.root, ORG_DIR, name, run);
34
41
  mkdirSync(dir, { recursive: true });
35
42
  const cwd = join(this.root, ORG_DIR, name, 'workspace');
@@ -39,35 +46,63 @@ export class OrgDaemon {
39
46
  bus.subscribe(e => { collected.push(e); for (const fn of this.globalSubscribers)
40
47
  fn(e); });
41
48
  if (this.opts.forward !== false)
42
- attachForwarder(bus, this.opts.controlJson ?? join(this.root, '.monomind/control.json'));
49
+ this.forwarders.set(name, attachForwarder(bus, this.opts.controlJson ?? join(this.root, '.monomind/control.json')));
43
50
  const running = { def, run, bus, agents: new Map(), busEvents: () => [...collected] };
44
51
  this.orgs.set(name, running);
45
52
  const perRoleBudget = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / def.roles.length);
46
53
  for (const role of def.roles) {
47
54
  const mailbox = new Mailbox();
48
55
  const policy = new PolicyEngine(role.id, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, cwd);
49
- const done = runAgentSession({
56
+ const runtime = { mailbox, policy, status: 'running', done: Promise.resolve() };
57
+ runtime.done = runAgentSession({
50
58
  org: name, role, bus, policy, mailbox, cwd, def,
51
59
  maxTurns: def.run_config.max_turns_per_message,
52
60
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
53
61
  queryFn: this.opts.queryFn,
54
- }).catch(() => { });
55
- running.agents.set(role.id, { mailbox, policy, done });
62
+ }).then(() => { runtime.status = 'ended'; })
63
+ .catch((err) => {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ runtime.status = 'crashed';
66
+ runtime.error = message;
67
+ // runAgentSession already emits a 'status' event for the raw error; emit an
68
+ // 'audit' event too so dashboards/alerts that filter on actionable failures
69
+ // (not routine status chatter) can surface a dead agent instead of a run that
70
+ // silently never progresses.
71
+ bus.emit({
72
+ type: 'audit', from: role.id,
73
+ msg: `agent "${role.id}" crashed: ${message}`,
74
+ reason: 'agent-session-crash',
75
+ data: { agentId: role.id, error: message },
76
+ });
77
+ });
78
+ running.agents.set(role.id, runtime);
56
79
  }
57
80
  const boss = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
58
81
  running.agents.get(boss.id).mailbox.push(`Org "${name}" started (run ${run}).\nGoal: ${taskOverride ?? def.goal}\n` +
59
82
  `Coordinate your team via org_send. Report completion by ending your turn.`);
60
83
  bus.emit({ type: 'status', msg: `org started (${def.roles.length} agents)`, data: { goal: taskOverride ?? def.goal } });
61
84
  this.persistState(name, 'running', run);
85
+ if (this.opts.crossProcess && this.opts.inboxUrl) {
86
+ const lease = new BrokerLease(name, this.opts.inboxUrl, this.opts.brokerDir);
87
+ lease.start();
88
+ this.leases.set(name, lease);
89
+ }
62
90
  return running;
63
91
  }
64
92
  /** Route a message. to = "role" (same org) or "org:role" (cross-org). Returns a receipt string. */
65
93
  async deliver(fromOrg, fromRole, to, subject, body) {
66
- const cross = to.includes(':');
67
- const [targetOrgName, targetRole] = cross ? to.split(':', 2) : [fromOrg, to];
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
+ }
68
101
  const targetOrg = this.orgs.get(targetOrgName);
69
102
  const src = this.orgs.get(fromOrg);
70
103
  if (!targetOrg || !targetOrg.agents.has(targetRole)) {
104
+ if (cross && this.opts.crossProcess)
105
+ return this.deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src);
71
106
  src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
72
107
  return `ERROR: unknown recipient "${to}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
73
108
  }
@@ -78,15 +113,69 @@ export class OrgDaemon {
78
113
  targetOrg.agents.get(targetRole).mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
79
114
  return `delivered to ${to}`;
80
115
  }
116
+ /** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP. */
117
+ async deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src) {
118
+ const remote = lookupOrg(targetOrgName, this.opts.brokerDir);
119
+ if (!remote) {
120
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
121
+ return `ERROR: unknown recipient "${to}" (no local org, and no process on this machine has org "${targetOrgName}" registered)`;
122
+ }
123
+ try {
124
+ const res = await fetch(`${remote.url}/api/xdeliver`, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ fromOrg, fromRole, toOrg: targetOrgName, toRole: targetRole, subject, body }),
128
+ signal: AbortSignal.timeout(10_000),
129
+ });
130
+ const data = await res.json().catch(() => ({}));
131
+ if (res.ok && data.ok) {
132
+ src?.bus.emit({ type: 'xorg', from: `${fromOrg}:${fromRole}`, to, subject, msg: body });
133
+ return data.receipt ?? `delivered to ${to} (remote)`;
134
+ }
135
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `remote delivery rejected: ${data.error ?? res.status}`, reason: 'remote-delivery-rejected' });
136
+ return `ERROR: remote org "${to}" rejected delivery: ${data.error ?? res.status}`;
137
+ }
138
+ catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `remote delivery failed: ${message}`, reason: 'remote-delivery-failed' });
141
+ return `ERROR: remote org "${targetOrgName}" unreachable: ${message}`;
142
+ }
143
+ }
144
+ /** Inbound handler for cross-process delivery — called by the server's POST /api/xdeliver route
145
+ * when ANOTHER process's deliverRemote() reaches this daemon. Pushes straight into the target
146
+ * agent's mailbox; the agent picks it up on its own next turn (see Mailbox — never interrupts). */
147
+ receiveRemote(toOrg, toRole, fromQualified, subject, body) {
148
+ const org = this.orgs.get(toOrg);
149
+ if (!org)
150
+ return { ok: false, error: `org "${toOrg}" not hosted here` };
151
+ const agent = org.agents.get(toRole);
152
+ if (!agent)
153
+ return { ok: false, error: `role "${toRole}" not found in org "${toOrg}"` };
154
+ org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
155
+ agent.mailbox.push(`[message from ${fromQualified}] subject: ${subject}\n\n${body}`);
156
+ return { ok: true, receipt: `delivered to ${toOrg}:${toRole} (remote)` };
157
+ }
81
158
  async stopOrg(name) {
82
159
  const org = this.orgs.get(name);
83
160
  if (!org)
84
161
  return;
162
+ this.leases.get(name)?.stop();
163
+ this.leases.delete(name);
85
164
  for (const a of org.agents.values())
86
165
  a.mailbox.close();
87
166
  await Promise.allSettled([...org.agents.values()].map(a => a.done));
88
167
  org.bus.emit({ type: 'status', msg: 'org stopped' });
89
168
  await org.bus.flush();
169
+ // the "org stopped" event above triggers the forwarder's final org:complete /
170
+ // session:complete POST — without waiting for it here, the CLI process can exit
171
+ // (and kill the in-flight fetch) before that last event reaches the dashboard,
172
+ // leaving the run stuck showing "running" forever.
173
+ const forwarder = this.forwarders.get(name);
174
+ if (forwarder) {
175
+ await forwarder.settle();
176
+ forwarder.unsubscribe();
177
+ this.forwarders.delete(name);
178
+ }
90
179
  this.persistState(name, 'stopped', org.run);
91
180
  this.orgs.delete(name);
92
181
  }
@@ -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,36 @@
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
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}`; }
8
34
  export function attachForwarder(bus, controlJsonPath = '.monomind/control.json') {
9
35
  let chain = Promise.resolve();
10
36
  const baseUrl = () => {
@@ -18,20 +44,91 @@ export function attachForwarder(bus, controlJsonPath = '.monomind/control.json')
18
44
  return null;
19
45
  }
20
46
  };
47
+ const post = async (url, payload) => {
48
+ await fetch(`${url}/api/mastermind/event`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(payload),
52
+ signal: AbortSignal.timeout(3000),
53
+ }).then(r => { r.body?.cancel(); }).catch(() => { });
54
+ };
21
55
  const unsubscribe = bus.subscribe((e) => {
22
56
  chain = chain.then(async () => {
23
57
  const url = baseUrl();
24
58
  if (!url)
25
59
  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(() => { });
60
+ for (const payload of companionEvents(e))
61
+ await post(url, payload);
62
+ await post(url, translate(e));
33
63
  }).catch(() => { });
34
64
  });
35
65
  return { settle: () => chain, unsubscribe };
36
66
  }
67
+ const TEXTUAL_EXT = new Set(['.md', '.txt', '.json', '.ts', '.tsx', '.js', '.jsx', '.mjs',
68
+ '.py', '.sh', '.yaml', '.yml', '.html', '.css', '.xml', '.log', '.csv']);
69
+ function guessMimeType(path) {
70
+ if (!path)
71
+ return 'application/octet-stream';
72
+ return TEXTUAL_EXT.has(extname(path).toLowerCase()) ? 'text/plain' : 'application/octet-stream';
73
+ }
74
+ /**
75
+ * Companion dashboard events a single bus event needs beyond its primary
76
+ * translate() mapping. Currently: org-started -> session:start (registers
77
+ * the run in the client's session list) and org-stopped -> session:complete.
78
+ * Exported for tests.
79
+ */
80
+ export function companionEvents(e) {
81
+ if (e.type !== 'status')
82
+ return [];
83
+ const base = { session: sessionId(e.org, e.run), org: e.org, ts: e.ts };
84
+ const msg = e.msg ?? '';
85
+ if (msg.startsWith('org started')) {
86
+ const goal = e.data?.goal;
87
+ return [{ ...base, type: 'session:start', prompt: goal && goal.length ? goal : e.org }];
88
+ }
89
+ if (msg === 'org stopped') {
90
+ return [{ ...base, type: 'session:complete', status: 'complete', domains: ['ops'] }];
91
+ }
92
+ return [];
93
+ }
94
+ /** BusEvent → dashboard-native mastermind event. Exported for tests. */
95
+ export function translate(e) {
96
+ const base = {
97
+ org: e.org,
98
+ runId: e.run,
99
+ session: sessionId(e.org, e.run),
100
+ domain: 'ops',
101
+ ts: e.ts,
102
+ };
103
+ switch (e.type) {
104
+ case 'chat':
105
+ return { ...base, type: 'org:comms', from: e.from, to: 'all', msg: e.msg };
106
+ case 'message':
107
+ case 'xorg':
108
+ return {
109
+ ...base, type: 'org:comms', from: e.from, to: e.to,
110
+ msg: e.subject ? `[${e.subject}] ${e.msg ?? ''}` : e.msg,
111
+ };
112
+ case 'asset':
113
+ return {
114
+ ...base, type: 'org:artifact', from: e.from,
115
+ artifact: { label: basename(e.path ?? 'asset'), type: 'file', path: e.path, mimeType: guessMimeType(e.path) },
116
+ };
117
+ case 'status': {
118
+ const msg = e.msg ?? '';
119
+ if (msg.startsWith('org started'))
120
+ return { ...base, type: 'org:start', goal: e.data?.goal ?? '' };
121
+ if (msg === 'org stopped')
122
+ return { ...base, type: 'org:complete' };
123
+ if (msg === 'session starting')
124
+ return { ...base, type: 'org:agent:online', role: e.from, title: e.from, agent_type: e.from };
125
+ if (msg === 'session ended' || msg.startsWith('session error'))
126
+ return { ...base, type: 'org:agent:offline', from: e.from, reason: msg };
127
+ return { ...base, type: 'org:checkpoint', progress: msg, from: e.from };
128
+ }
129
+ default:
130
+ // tool / usage / audit — no native widget; keep raw for run file + SSE
131
+ return { ...e, ...base, type: `org:${e.type}` };
132
+ }
133
+ }
37
134
  //# sourceMappingURL=forwarder.js.map