monomind 2.1.0 → 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.
- package/README.md +74 -92
- package/package.json +3 -3
- package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +24 -10
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +2 -2
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +29 -0
- package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +35 -23
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +21 -0
- package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +73 -2
- package/packages/@monomind/cli/README.md +74 -92
- package/packages/@monomind/cli/dist/src/browser/workflow/store.d.ts +2 -2
- package/packages/@monomind/cli/dist/src/commands/browse.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/commands/doctor-env-checks.js +5 -0
- package/packages/@monomind/cli/dist/src/commands/org.js +46 -29
- package/packages/@monomind/cli/dist/src/commands/platforms.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/init/executor.js +30 -6
- package/packages/@monomind/cli/dist/src/mcp-tools/auto-install.d.ts +8 -8
- package/packages/@monomind/cli/dist/src/mcp-tools/coherence/types.d.ts +31 -31
- package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +5 -1
- package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +28 -3
- package/packages/@monomind/cli/dist/src/mcp-tools/quality/coverage-analysis/prioritize-gaps.d.ts +36 -36
- package/packages/@monomind/cli/dist/src/mcp-tools/quality/security-compliance/detect-secrets.d.ts +4 -4
- package/packages/@monomind/cli/dist/src/memory/ewc-consolidation.d.ts +12 -0
- package/packages/@monomind/cli/dist/src/memory/sona-optimizer.d.ts +12 -0
- package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +3 -1
- package/packages/@monomind/cli/dist/src/orgrt/broker.js +8 -3
- package/packages/@monomind/cli/dist/src/orgrt/bus.d.ts +5 -1
- package/packages/@monomind/cli/dist/src/orgrt/bus.js +5 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +17 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +115 -21
- package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +41 -8
- package/packages/@monomind/cli/dist/src/orgrt/inbox.d.ts +11 -0
- package/packages/@monomind/cli/dist/src/orgrt/inbox.js +56 -0
- package/packages/@monomind/cli/dist/src/orgrt/policy.d.ts +5 -1
- package/packages/@monomind/cli/dist/src/orgrt/policy.js +58 -5
- package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +3 -2
- package/packages/@monomind/cli/dist/src/orgrt/server.js +7 -40
- package/packages/@monomind/cli/dist/src/orgrt/session.d.ts +10 -1
- package/packages/@monomind/cli/dist/src/orgrt/session.js +24 -1
- package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +8 -8
- package/packages/@monomind/cli/dist/src/orgrt/types.d.ts +52 -52
- package/packages/@monomind/cli/dist/src/output.d.ts +29 -29
- package/packages/@monomind/cli/dist/src/output.js +10 -2
- package/packages/@monomind/cli/dist/src/types.d.ts +2 -2
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +203 -9
- package/packages/@monomind/cli/dist/src/ui/orgs-files.js +192 -0
- package/packages/@monomind/cli/dist/src/ui/orgs.html +21 -52
- package/packages/@monomind/cli/dist/src/ui/server.mjs +42 -6
- package/packages/@monomind/cli/package.json +12 -16
- 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 } 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 => {
|
|
47
|
-
|
|
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] };
|
|
@@ -87,36 +99,80 @@ export class OrgDaemon {
|
|
|
87
99
|
lease.start();
|
|
88
100
|
this.leases.set(name, lease);
|
|
89
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` });
|
|
90
113
|
return running;
|
|
91
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
|
+
}
|
|
92
131
|
/** Route a message. to = "role" (same org) or "org:role" (cross-org). Returns a receipt string. */
|
|
93
132
|
async deliver(fromOrg, fromRole, to, subject, body) {
|
|
94
|
-
|
|
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
|
-
}
|
|
133
|
+
const { cross, orgName: targetOrgName, role: targetRole, qualified: toQualified } = this.resolveAddress(fromOrg, to);
|
|
101
134
|
const targetOrg = this.orgs.get(targetOrgName);
|
|
102
135
|
const src = this.orgs.get(fromOrg);
|
|
103
136
|
if (!targetOrg || !targetOrg.agents.has(targetRole)) {
|
|
104
137
|
if (cross && this.opts.crossProcess)
|
|
105
|
-
return this.deliverRemote(fromOrg, fromRole, targetOrgName, targetRole,
|
|
106
|
-
|
|
107
|
-
|
|
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(', ')})`;
|
|
148
|
+
}
|
|
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`;
|
|
108
156
|
}
|
|
109
|
-
const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to:
|
|
157
|
+
const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: toQualified, subject, msg: body };
|
|
110
158
|
src?.bus.emit({ type: cross ? 'xorg' : 'message', ...evt });
|
|
111
159
|
if (cross && targetOrg !== src)
|
|
112
160
|
targetOrg.bus.emit({ type: 'xorg', ...evt });
|
|
113
|
-
|
|
114
|
-
return `delivered to ${
|
|
161
|
+
targetAgent.mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
|
|
162
|
+
return `delivered to ${toQualified}`;
|
|
115
163
|
}
|
|
116
|
-
/** Cross-process leg of deliver(): ask the machine-local broker who hosts targetOrgName, then POST over HTTP.
|
|
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). */
|
|
117
166
|
async deliverRemote(fromOrg, fromRole, targetOrgName, targetRole, to, subject, body, src) {
|
|
118
167
|
const remote = lookupOrg(targetOrgName, this.opts.brokerDir);
|
|
119
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
|
+
}
|
|
120
176
|
src?.bus.emit({ type: 'audit', from: fromRole, to, msg: `undeliverable: ${subject}`, reason: 'unknown recipient' });
|
|
121
177
|
return `ERROR: unknown recipient "${to}" (no local org, and no process on this machine has org "${targetOrgName}" registered)`;
|
|
122
178
|
}
|
|
@@ -146,24 +202,63 @@ export class OrgDaemon {
|
|
|
146
202
|
* agent's mailbox; the agent picks it up on its own next turn (see Mailbox — never interrupts). */
|
|
147
203
|
receiveRemote(toOrg, toRole, fromQualified, subject, body) {
|
|
148
204
|
const org = this.orgs.get(toOrg);
|
|
149
|
-
if (!org)
|
|
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
|
+
}
|
|
150
212
|
return { ok: false, error: `org "${toOrg}" not hosted here` };
|
|
213
|
+
}
|
|
151
214
|
const agent = org.agents.get(toRole);
|
|
152
215
|
if (!agent)
|
|
153
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` };
|
|
154
219
|
org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
|
|
155
220
|
agent.mailbox.push(`[message from ${fromQualified}] subject: ${subject}\n\n${body}`);
|
|
156
221
|
return { ok: true, receipt: `delivered to ${toOrg}:${toRole} (remote)` };
|
|
157
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); });
|
|
235
|
+
}
|
|
158
236
|
async stopOrg(name) {
|
|
159
237
|
const org = this.orgs.get(name);
|
|
160
238
|
if (!org)
|
|
161
|
-
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);
|
|
162
245
|
this.leases.get(name)?.stop();
|
|
163
246
|
this.leases.delete(name);
|
|
164
247
|
for (const a of org.agents.values())
|
|
165
248
|
a.mailbox.close();
|
|
166
|
-
|
|
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
|
+
}
|
|
167
262
|
org.bus.emit({ type: 'status', msg: 'org stopped' });
|
|
168
263
|
await org.bus.flush();
|
|
169
264
|
// the "org stopped" event above triggers the forwarder's final org:complete /
|
|
@@ -177,7 +272,6 @@ export class OrgDaemon {
|
|
|
177
272
|
this.forwarders.delete(name);
|
|
178
273
|
}
|
|
179
274
|
this.persistState(name, 'stopped', org.run);
|
|
180
|
-
this.orgs.delete(name);
|
|
181
275
|
}
|
|
182
276
|
async stopAll() {
|
|
183
277
|
await Promise.all([...this.orgs.keys()].map(n => this.stopOrg(n)));
|
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
* (dist/src/ui/server.mjs, POST /api/mastermind/event) so the existing
|
|
@@ -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
|
+
// 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
|
+
};
|
|
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: {
|
|
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
|
-
|
|
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 (
|
|
113
|
+
if (kind === 'stopped') {
|
|
90
114
|
return [{ ...base, type: 'session:complete', status: 'complete', domains: ['ops'] }];
|
|
91
115
|
}
|
|
92
116
|
return [];
|
|
@@ -109,16 +133,25 @@ 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: {
|
|
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
|
+
}
|
|
117
149
|
case 'status': {
|
|
118
150
|
const msg = e.msg ?? '';
|
|
119
|
-
|
|
151
|
+
const kind = classifyStatus(msg);
|
|
152
|
+
if (kind === 'started')
|
|
120
153
|
return { ...base, type: 'org:start', goal: e.data?.goal ?? '' };
|
|
121
|
-
if (
|
|
154
|
+
if (kind === 'stopped')
|
|
122
155
|
return { ...base, type: 'org:complete' };
|
|
123
156
|
if (msg === 'session starting')
|
|
124
157
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
// packages/@monomind/cli/src/orgrt/server.ts
|
|
2
|
+
// monolean: stripped to xdeliver-only — live UI, WS fanout, and redundant REST
|
|
3
|
+
// endpoints deleted; the control server at :4242 handles all of those.
|
|
2
4
|
import http from 'node:http';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
7
|
-
/** Daemon-owned live view: WS fanout of every bus event + tiny REST surface. */
|
|
8
|
-
export async function startOrgServer(daemon, port) {
|
|
9
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const htmlPath = ['live.html', '../orgrt/live.html']
|
|
11
|
-
.map(p => join(here, p)).find(existsSync) ?? join(here, 'live.html');
|
|
5
|
+
/** Minimal HTTP listener for cross-process org message delivery.
|
|
6
|
+
* Binds an ephemeral port (pass 0) so the daemon can register it with the broker. */
|
|
7
|
+
export async function startOrgServer(daemon, port = 0) {
|
|
12
8
|
const server = http.createServer((req, res) => {
|
|
13
|
-
|
|
14
|
-
if (req.method === 'GET' && url === '/') {
|
|
15
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
16
|
-
res.end(readFileSync(htmlPath, 'utf8'));
|
|
17
|
-
}
|
|
18
|
-
else if (req.method === 'GET' && url === '/api/orgs') {
|
|
19
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
20
|
-
res.end(JSON.stringify(daemon.listOrgs().map(o => ({
|
|
21
|
-
name: o.def.name, run: o.run, goal: o.def.goal,
|
|
22
|
-
agents: [...o.agents.entries()].map(([id, a]) => ({
|
|
23
|
-
id, usage: a.policy.usage, closed: a.mailbox.isClosed,
|
|
24
|
-
status: a.status, error: a.error,
|
|
25
|
-
})),
|
|
26
|
-
}))));
|
|
27
|
-
}
|
|
28
|
-
else if (req.method === 'GET' && url.startsWith('/api/history/')) {
|
|
29
|
-
const name = decodeURIComponent(url.slice('/api/history/'.length));
|
|
30
|
-
const org = daemon.getOrg(name);
|
|
31
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
32
|
-
res.end(JSON.stringify(org ? org.busEvents() : []));
|
|
33
|
-
}
|
|
34
|
-
else if (req.method === 'POST' && url === '/api/xdeliver') {
|
|
35
|
-
// inbound cross-process delivery — see OrgDaemon.deliverRemote() on the sending side
|
|
9
|
+
if (req.method === 'POST' && req.url === '/api/xdeliver') {
|
|
36
10
|
let body = '';
|
|
37
11
|
req.on('data', c => { body += c; });
|
|
38
12
|
req.on('end', () => {
|
|
@@ -58,15 +32,8 @@ export async function startOrgServer(daemon, port) {
|
|
|
58
32
|
res.end('not found');
|
|
59
33
|
}
|
|
60
34
|
});
|
|
61
|
-
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
62
|
-
const unsubscribe = daemon.subscribe(e => {
|
|
63
|
-
const line = JSON.stringify(e);
|
|
64
|
-
for (const c of wss.clients)
|
|
65
|
-
if (c.readyState === WebSocket.OPEN)
|
|
66
|
-
c.send(line);
|
|
67
|
-
});
|
|
68
35
|
await new Promise(r => server.listen(port, r));
|
|
69
36
|
const actual = server.address().port;
|
|
70
|
-
return { port: actual, close: () => {
|
|
37
|
+
return { port: actual, close: () => { server.close(); } };
|
|
71
38
|
}
|
|
72
39
|
//# sourceMappingURL=server.js.map
|
|
@@ -18,6 +18,15 @@ export interface SessionOpts {
|
|
|
18
18
|
}
|
|
19
19
|
/** Role briefing given to each agent session (SDK systemPrompt option). */
|
|
20
20
|
export declare function buildRolePrompt(role: OrgRole, def: Pick<OrgDef, 'name' | 'goal'>, roster: string[]): string;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Runs a role for the life of the org, transparently restarting the
|
|
23
|
+
* underlying SDK session whenever it ends on its own (`maxTurns` reached)
|
|
24
|
+
* while the mailbox is still open. `maxTurns` bounds a single SDK query()
|
|
25
|
+
* call's TOTAL turns, not "turns per incoming message" — since one query()
|
|
26
|
+
* call stays open across every mailbox message for as long as the mailbox
|
|
27
|
+
* itself stays open, without a restart the role would go permanently silent
|
|
28
|
+
* (no crash, no alert) once its lifetime turn count crossed the limit, while
|
|
29
|
+
* deliver() kept queuing new messages into a mailbox nobody was reading.
|
|
30
|
+
*/
|
|
22
31
|
export declare function runAgentSession(opts: SessionOpts): Promise<void>;
|
|
23
32
|
//# sourceMappingURL=session.d.ts.map
|
|
@@ -16,8 +16,31 @@ export function buildRolePrompt(role, def, roster) {
|
|
|
16
16
|
`When your current work is complete and no reply is needed, end your turn without further tool calls.`,
|
|
17
17
|
].filter(Boolean).join('\n\n');
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Runs a role for the life of the org, transparently restarting the
|
|
21
|
+
* underlying SDK session whenever it ends on its own (`maxTurns` reached)
|
|
22
|
+
* while the mailbox is still open. `maxTurns` bounds a single SDK query()
|
|
23
|
+
* call's TOTAL turns, not "turns per incoming message" — since one query()
|
|
24
|
+
* call stays open across every mailbox message for as long as the mailbox
|
|
25
|
+
* itself stays open, without a restart the role would go permanently silent
|
|
26
|
+
* (no crash, no alert) once its lifetime turn count crossed the limit, while
|
|
27
|
+
* deliver() kept queuing new messages into a mailbox nobody was reading.
|
|
28
|
+
*/
|
|
20
29
|
export async function runAgentSession(opts) {
|
|
30
|
+
const { mailbox } = opts;
|
|
31
|
+
// Always run at least once: a mailbox can be closed with queued items still
|
|
32
|
+
// pending (stream() drains the queue before honoring `closed`), which is a
|
|
33
|
+
// normal, valid starting state — checking isClosed before the first run
|
|
34
|
+
// would skip that drain entirely.
|
|
35
|
+
while (true) {
|
|
36
|
+
await runOneSession(opts);
|
|
37
|
+
if (mailbox.isClosed)
|
|
38
|
+
return;
|
|
39
|
+
opts.bus.emit({ type: 'status', from: opts.role.id, msg: 'session restarting (turn limit reached, mailbox still open)' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** One bounded SDK session for a role; resolves when the SDK stream ends (mailbox closed or maxTurns reached). */
|
|
43
|
+
async function runOneSession(opts) {
|
|
21
44
|
const { org, role, bus, policy, mailbox, cwd, deliver } = opts;
|
|
22
45
|
const queryFn = opts.queryFn ?? query;
|
|
23
46
|
const orgServer = createSdkMcpServer({
|