@monoes/monomindcli 2.13.0 → 2.14.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 (136) hide show
  1. package/dist/src/commands/doctor-mcp-probe.d.ts +48 -0
  2. package/dist/src/commands/doctor-mcp-probe.d.ts.map +1 -0
  3. package/dist/src/commands/doctor-mcp-probe.js +205 -0
  4. package/dist/src/commands/doctor-mcp-probe.js.map +1 -0
  5. package/dist/src/commands/doctor-project-checks.d.ts +12 -1
  6. package/dist/src/commands/doctor-project-checks.d.ts.map +1 -1
  7. package/dist/src/commands/doctor-project-checks.js +55 -4
  8. package/dist/src/commands/doctor-project-checks.js.map +1 -1
  9. package/dist/src/commands/doctor.d.ts.map +1 -1
  10. package/dist/src/commands/doctor.js +10 -1
  11. package/dist/src/commands/doctor.js.map +1 -1
  12. package/dist/src/commands/init.d.ts.map +1 -1
  13. package/dist/src/commands/init.js +17 -0
  14. package/dist/src/commands/init.js.map +1 -1
  15. package/dist/src/commands/mcp.d.ts.map +1 -1
  16. package/dist/src/commands/mcp.js +4 -3
  17. package/dist/src/commands/mcp.js.map +1 -1
  18. package/dist/src/commands/org-observe.d.ts.map +1 -1
  19. package/dist/src/commands/org-observe.js +14 -0
  20. package/dist/src/commands/org-observe.js.map +1 -1
  21. package/dist/src/commands/org.d.ts.map +1 -1
  22. package/dist/src/commands/org.js +12 -2
  23. package/dist/src/commands/org.js.map +1 -1
  24. package/dist/src/init/claudemd-generator.d.ts.map +1 -1
  25. package/dist/src/init/claudemd-generator.js +2 -1
  26. package/dist/src/init/claudemd-generator.js.map +1 -1
  27. package/dist/src/init/mcp-generator.d.ts.map +1 -1
  28. package/dist/src/init/mcp-generator.js +3 -3
  29. package/dist/src/init/mcp-generator.js.map +1 -1
  30. package/dist/src/init/types.d.ts +6 -0
  31. package/dist/src/init/types.d.ts.map +1 -1
  32. package/dist/src/init/types.js.map +1 -1
  33. package/dist/src/init/write-runtime-config.d.ts.map +1 -1
  34. package/dist/src/init/write-runtime-config.js +12 -2
  35. package/dist/src/init/write-runtime-config.js.map +1 -1
  36. package/dist/src/orgrt/agent-runner.d.ts +38 -0
  37. package/dist/src/orgrt/agent-runner.d.ts.map +1 -1
  38. package/dist/src/orgrt/agent-runner.js +47 -0
  39. package/dist/src/orgrt/agent-runner.js.map +1 -1
  40. package/dist/src/orgrt/checkpoint.d.ts +12 -1
  41. package/dist/src/orgrt/checkpoint.d.ts.map +1 -1
  42. package/dist/src/orgrt/checkpoint.js +15 -6
  43. package/dist/src/orgrt/checkpoint.js.map +1 -1
  44. package/dist/src/orgrt/completion-gate.d.ts +74 -0
  45. package/dist/src/orgrt/completion-gate.d.ts.map +1 -1
  46. package/dist/src/orgrt/completion-gate.js +45 -0
  47. package/dist/src/orgrt/completion-gate.js.map +1 -1
  48. package/dist/src/orgrt/cost-tier.d.ts +144 -0
  49. package/dist/src/orgrt/cost-tier.d.ts.map +1 -0
  50. package/dist/src/orgrt/cost-tier.js +183 -0
  51. package/dist/src/orgrt/cost-tier.js.map +1 -0
  52. package/dist/src/orgrt/cross-org.d.ts.map +1 -1
  53. package/dist/src/orgrt/cross-org.js +14 -0
  54. package/dist/src/orgrt/cross-org.js.map +1 -1
  55. package/dist/src/orgrt/daemon.d.ts +12 -1
  56. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  57. package/dist/src/orgrt/daemon.js +216 -49
  58. package/dist/src/orgrt/daemon.js.map +1 -1
  59. package/dist/src/orgrt/decisions.d.ts +14 -2
  60. package/dist/src/orgrt/decisions.d.ts.map +1 -1
  61. package/dist/src/orgrt/decisions.js +216 -10
  62. package/dist/src/orgrt/decisions.js.map +1 -1
  63. package/dist/src/orgrt/file-roots.d.ts +7 -0
  64. package/dist/src/orgrt/file-roots.d.ts.map +1 -1
  65. package/dist/src/orgrt/file-roots.js +38 -1
  66. package/dist/src/orgrt/file-roots.js.map +1 -1
  67. package/dist/src/orgrt/forwarder.d.ts.map +1 -1
  68. package/dist/src/orgrt/forwarder.js +16 -2
  69. package/dist/src/orgrt/forwarder.js.map +1 -1
  70. package/dist/src/orgrt/idle-deadline.d.ts +74 -1
  71. package/dist/src/orgrt/idle-deadline.d.ts.map +1 -1
  72. package/dist/src/orgrt/idle-deadline.js +52 -2
  73. package/dist/src/orgrt/idle-deadline.js.map +1 -1
  74. package/dist/src/orgrt/loadouts.d.ts +38 -0
  75. package/dist/src/orgrt/loadouts.d.ts.map +1 -0
  76. package/dist/src/orgrt/loadouts.js +129 -0
  77. package/dist/src/orgrt/loadouts.js.map +1 -0
  78. package/dist/src/orgrt/mailbox.d.ts +17 -1
  79. package/dist/src/orgrt/mailbox.d.ts.map +1 -1
  80. package/dist/src/orgrt/mailbox.js +50 -3
  81. package/dist/src/orgrt/mailbox.js.map +1 -1
  82. package/dist/src/orgrt/policy.d.ts +42 -2
  83. package/dist/src/orgrt/policy.d.ts.map +1 -1
  84. package/dist/src/orgrt/policy.js +53 -8
  85. package/dist/src/orgrt/policy.js.map +1 -1
  86. package/dist/src/orgrt/prompt-vars.d.ts +11 -0
  87. package/dist/src/orgrt/prompt-vars.d.ts.map +1 -0
  88. package/dist/src/orgrt/prompt-vars.js +49 -0
  89. package/dist/src/orgrt/prompt-vars.js.map +1 -0
  90. package/dist/src/orgrt/questions.d.ts +22 -1
  91. package/dist/src/orgrt/questions.d.ts.map +1 -1
  92. package/dist/src/orgrt/questions.js +24 -3
  93. package/dist/src/orgrt/questions.js.map +1 -1
  94. package/dist/src/orgrt/review-packet.d.ts +29 -0
  95. package/dist/src/orgrt/review-packet.d.ts.map +1 -0
  96. package/dist/src/orgrt/review-packet.js +67 -0
  97. package/dist/src/orgrt/review-packet.js.map +1 -0
  98. package/dist/src/orgrt/role-sandbox.d.ts.map +1 -1
  99. package/dist/src/orgrt/role-sandbox.js +16 -3
  100. package/dist/src/orgrt/role-sandbox.js.map +1 -1
  101. package/dist/src/orgrt/session-ledger.d.ts +61 -0
  102. package/dist/src/orgrt/session-ledger.d.ts.map +1 -0
  103. package/dist/src/orgrt/session-ledger.js +113 -0
  104. package/dist/src/orgrt/session-ledger.js.map +1 -0
  105. package/dist/src/orgrt/session.d.ts +45 -5
  106. package/dist/src/orgrt/session.d.ts.map +1 -1
  107. package/dist/src/orgrt/session.js +384 -27
  108. package/dist/src/orgrt/session.js.map +1 -1
  109. package/dist/src/orgrt/task-dag.d.ts +34 -1
  110. package/dist/src/orgrt/task-dag.d.ts.map +1 -1
  111. package/dist/src/orgrt/task-dag.js +58 -7
  112. package/dist/src/orgrt/task-dag.js.map +1 -1
  113. package/dist/src/orgrt/tool-spill.d.ts +94 -0
  114. package/dist/src/orgrt/tool-spill.d.ts.map +1 -0
  115. package/dist/src/orgrt/tool-spill.js +180 -0
  116. package/dist/src/orgrt/tool-spill.js.map +1 -0
  117. package/dist/src/orgrt/types.d.ts +138 -0
  118. package/dist/src/orgrt/types.d.ts.map +1 -1
  119. package/dist/src/orgrt/types.js +148 -0
  120. package/dist/src/orgrt/types.js.map +1 -1
  121. package/dist/src/platform-adapters/renderers/mcp.d.ts +20 -2
  122. package/dist/src/platform-adapters/renderers/mcp.d.ts.map +1 -1
  123. package/dist/src/platform-adapters/renderers/mcp.js +25 -5
  124. package/dist/src/platform-adapters/renderers/mcp.js.map +1 -1
  125. package/dist/src/protocol-capabilities.d.ts +2 -1
  126. package/dist/src/protocol-capabilities.d.ts.map +1 -1
  127. package/dist/src/protocol-capabilities.js +2 -1
  128. package/dist/src/protocol-capabilities.js.map +1 -1
  129. package/dist/src/ui/dashboard.html +450 -164
  130. package/dist/src/ui/org-hil.mjs +276 -0
  131. package/dist/src/ui/org-runtime.mjs +350 -0
  132. package/dist/src/ui/orgs.html +15 -4
  133. package/dist/src/ui/routes-org.mjs +290 -541
  134. package/dist/src/ui/server.mjs +25 -5
  135. package/dist/tsconfig.tsbuildinfo +1 -1
  136. package/package.json +6 -5
@@ -0,0 +1,276 @@
1
+ // Human-in-the-loop actions for the dashboard: approvals, decision gates,
2
+ // ask_human answers and human chat messages for Org Runtime v2 orgs.
3
+ //
4
+ // Same shape as the `monomind org approve|deny|gate-*|answer` commands
5
+ // (commands/org-observe.ts): when a daemon hosts the org, the decision goes to
6
+ // its operator-only routes with the operator credential, and the daemon both
7
+ // records it and wakes the waiting role. When no daemon hosts the org, the
8
+ // decision is recorded in the org's own files, where the next run finds it.
9
+ // Unlike the CLI, a live daemon that REJECTS the call is reported as an error
10
+ // instead of falling back to a file write the daemon would overwrite.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ // Compiled from orgrt/*.ts, like forwarder.js in routes-org.mjs.
14
+ import { lookupOrg, readOperatorCredential } from '../orgrt/broker.js';
15
+ import { queueMessage } from '../orgrt/inbox.js';
16
+
17
+ /** Recorded as resolvedBy on everything decided here (normalizeResolver-valid). */
18
+ export const DASHBOARD_RESOLVER = 'human:dashboard';
19
+
20
+ const orgDir = (root, org) => path.join(root, '.monomind', 'orgs', org);
21
+
22
+ class HilError extends Error {
23
+ constructor(status, message) {
24
+ super(message);
25
+ this.status = status;
26
+ }
27
+ }
28
+
29
+ /** Read `<orgDir>/<file>` → its `key` array. Missing file → []. A file that
30
+ * exists but can't be parsed THROWS: callers rewrite what this returns, and
31
+ * rewriting from [] would erase every other entry. */
32
+ function readList(root, org, file, key) {
33
+ const p = path.join(orgDir(root, org), file);
34
+ let raw;
35
+ try {
36
+ raw = fs.readFileSync(p, 'utf8');
37
+ } catch (err) {
38
+ if (err.code === 'ENOENT') return [];
39
+ throw new HilError(500, `cannot read ${p}: ${err.message}`);
40
+ }
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ } catch (err) {
45
+ throw new HilError(500, `${p} is not valid JSON (${err.message})`);
46
+ }
47
+ const list = parsed?.[key] ?? [];
48
+ if (!Array.isArray(list)) throw new HilError(500, `${p}: "${key}" is not an array`);
49
+ return list;
50
+ }
51
+
52
+ function writeList(root, org, file, key, list) {
53
+ const dir = orgDir(root, org);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const dest = path.join(dir, file);
56
+ const tmp = `${dest}.${process.pid}.tmp`;
57
+ fs.writeFileSync(tmp, JSON.stringify({ [key]: list }, null, 2));
58
+ fs.renameSync(tmp, dest);
59
+ }
60
+
61
+ /** POST to the hosting daemon's operator-only `route`. Returns null when no
62
+ * live daemon hosts `org`; otherwise the daemon's verdict. */
63
+ async function callDaemon(org, route, body) {
64
+ const remote = lookupOrg(org);
65
+ if (!remote) return null;
66
+ const cred = readOperatorCredential(org);
67
+ if (!cred)
68
+ throw new HilError(
69
+ 503,
70
+ `org "${org}" is running, but its operator credential (~/.monomind/orgrt-operator/${org}.json) is missing — the dashboard cannot act for a human`,
71
+ );
72
+ let res;
73
+ try {
74
+ res = await fetch(`${remote.url}${route}`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json', 'x-monomind-cred': cred },
77
+ body: JSON.stringify(body),
78
+ signal: AbortSignal.timeout(10_000),
79
+ });
80
+ } catch (err) {
81
+ throw new HilError(502, `org "${org}" daemon unreachable: ${err.message}`);
82
+ }
83
+ const data = await res.json().catch(() => ({}));
84
+ if (!res.ok || !data.ok)
85
+ throw new HilError(
86
+ res.status >= 400 ? res.status : 502,
87
+ data.error || `daemon returned ${res.status}`,
88
+ );
89
+ return data;
90
+ }
91
+
92
+ function approvalStatus(a) {
93
+ if (a.approved === true) return 'approved';
94
+ if (a.approved === false) return 'denied';
95
+ return 'pending';
96
+ }
97
+
98
+ /** The org's tool/action approvals (`<org>/approvals.json`), newest first. */
99
+ export function listApprovals(root, org) {
100
+ return readList(root, org, 'approvals.json', 'approvals')
101
+ .map((a) => ({
102
+ id: a.requestId || null,
103
+ roleId: a.roleId,
104
+ action: a.action,
105
+ input: a.input || null,
106
+ status: approvalStatus(a),
107
+ ts: a.ts || null,
108
+ resolvedBy: a.resolvedBy || null,
109
+ resolvedAt: a.resolvedAt || null,
110
+ }))
111
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0));
112
+ }
113
+
114
+ /** The org's decision gates (`<org>/gates.json`), newest first. */
115
+ export function listGates(root, org) {
116
+ return readList(root, org, 'gates.json', 'gates')
117
+ .map((g) => ({
118
+ id: g.id,
119
+ name: g.name,
120
+ description: g.description,
121
+ roleId: g.roleId,
122
+ status: g.status || 'pending',
123
+ createdAt: g.createdAt || null,
124
+ resolvedBy: g.resolvedBy || null,
125
+ resolvedAt: g.resolvedAt || null,
126
+ resolution: g.resolution || null,
127
+ }))
128
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
129
+ }
130
+
131
+ /** The org's ask_human questions. `blocking` is false only when the role said
132
+ * it can carry on without an answer; pre-D4 records read as blocking. */
133
+ export function listQuestions(root, org) {
134
+ return readList(root, org, 'questions.json', 'questions').map((q) => ({
135
+ ...q,
136
+ blocking: q.blocking !== false,
137
+ }));
138
+ }
139
+
140
+ /** Everything awaiting a human across every org in `root`. */
141
+ export function pendingForProject(root) {
142
+ const base = path.join(root, '.monomind', 'orgs');
143
+ const out = { questions: [], approvals: [], gates: [], errors: [] };
144
+ let entries = [];
145
+ try {
146
+ entries = fs.readdirSync(base, { withFileTypes: true });
147
+ } catch {
148
+ return out;
149
+ }
150
+ for (const e of entries) {
151
+ if (!e.isDirectory() || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(e.name)) continue;
152
+ const org = e.name;
153
+ try {
154
+ for (const q of listQuestions(root, org)) out.questions.push({ org, ...q });
155
+ for (const a of listApprovals(root, org)) out.approvals.push({ org, ...a });
156
+ for (const g of listGates(root, org)) out.gates.push({ org, ...g });
157
+ } catch (err) {
158
+ out.errors.push({ org, error: err.message });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ export async function resolveApproval(root, org, requestId, approved) {
165
+ const entry = readList(root, org, 'approvals.json', 'approvals').find(
166
+ (a) => a.requestId === requestId,
167
+ );
168
+ if (!entry) throw new HilError(404, `approval "${requestId}" not found for org "${org}"`);
169
+ if (entry.approved !== null)
170
+ throw new HilError(409, `approval "${requestId}" already ${approvalStatus(entry)}`);
171
+ const live = await callDaemon(org, '/api/set-approval', {
172
+ org,
173
+ role: entry.roleId,
174
+ action: entry.action,
175
+ approved,
176
+ resolvedBy: DASHBOARD_RESOLVER,
177
+ requestId,
178
+ });
179
+ if (live) return { delivery: 'live' };
180
+ // Offline: re-read so a write since the lookup above isn't reverted.
181
+ const fresh = readList(root, org, 'approvals.json', 'approvals');
182
+ const item = fresh.find((a) => a.requestId === requestId);
183
+ if (!item || item.approved !== null)
184
+ throw new HilError(409, `approval "${requestId}" was resolved meanwhile`);
185
+ const now = Date.now();
186
+ Object.assign(item, { approved, ts: now, resolvedBy: DASHBOARD_RESOLVER, resolvedAt: now });
187
+ writeList(root, org, 'approvals.json', 'approvals', fresh);
188
+ return { delivery: 'recorded' };
189
+ }
190
+
191
+ export async function resolveGate(root, org, gateId, approved, resolution) {
192
+ const gate = readList(root, org, 'gates.json', 'gates').find((g) => g.id === gateId);
193
+ if (!gate) throw new HilError(404, `gate "${gateId}" not found for org "${org}"`);
194
+ if (gate.status !== 'pending')
195
+ throw new HilError(409, `gate "${gateId}" already resolved (${gate.status})`);
196
+ const live = await callDaemon(org, '/api/resolve-gate', {
197
+ org,
198
+ gateId,
199
+ approved,
200
+ resolution,
201
+ resolvedBy: DASHBOARD_RESOLVER,
202
+ });
203
+ if (live) return { delivery: 'live' };
204
+ const fresh = readList(root, org, 'gates.json', 'gates');
205
+ const idx = fresh.findIndex((g) => g.id === gateId);
206
+ if (idx === -1 || fresh[idx].status !== 'pending')
207
+ throw new HilError(409, `gate "${gateId}" was resolved meanwhile`);
208
+ fresh[idx] = {
209
+ ...fresh[idx],
210
+ status: approved ? 'approved' : 'rejected',
211
+ resolvedAt: Date.now(),
212
+ resolvedBy: DASHBOARD_RESOLVER,
213
+ ...(resolution ? { resolution } : {}),
214
+ };
215
+ writeList(root, org, 'gates.json', 'gates', fresh);
216
+ return { delivery: 'recorded' };
217
+ }
218
+
219
+ export async function answerQuestion(root, org, questionId, answer) {
220
+ const q = readList(root, org, 'questions.json', 'questions').find(
221
+ (x) => x.questionId === questionId,
222
+ );
223
+ if (!q) throw new HilError(404, `question "${questionId}" not found for org "${org}"`);
224
+ if (q.answer !== null && q.answer !== undefined)
225
+ throw new HilError(409, `question "${questionId}" was already answered`);
226
+ const live = await callDaemon(org, '/api/answer-question', {
227
+ org,
228
+ role: q.role,
229
+ questionId,
230
+ answer,
231
+ resolvedBy: DASHBOARD_RESOLVER,
232
+ });
233
+ if (live) return { delivery: 'live', role: q.role };
234
+ const fresh = readList(root, org, 'questions.json', 'questions');
235
+ const freshQ = fresh.find((x) => x.questionId === questionId);
236
+ if (freshQ && freshQ.answer !== null && freshQ.answer !== undefined)
237
+ throw new HilError(409, `question "${questionId}" was answered meanwhile`);
238
+ // Queue BEFORE marking answered (daemon.answerQuestion's rule): a failed
239
+ // append must leave the question pending and answerable.
240
+ const queued = queueMessage(root, org, {
241
+ fromQualified: 'human',
242
+ toRole: q.role,
243
+ subject: `answer:${questionId}`,
244
+ body: `question: ${q.question}\n\nanswer: ${answer}`,
245
+ ts: Date.now(),
246
+ });
247
+ if (!queued) throw new HilError(500, 'could not queue the answer — it was NOT recorded, retry');
248
+ const now = Date.now();
249
+ const answered = { answer, answeredAt: now, resolvedBy: DASHBOARD_RESOLVER };
250
+ const merged = fresh.some((x) => x.questionId === questionId)
251
+ ? fresh.map((x) => (x.questionId === questionId ? { ...x, ...answered } : x))
252
+ : [...fresh, { ...q, ...answered }];
253
+ writeList(root, org, 'questions.json', 'questions', merged);
254
+ return { delivery: 'queued', role: q.role };
255
+ }
256
+
257
+ /** Deliver `text` to `role`: live into its mailbox, else into the org's
258
+ * inbox, which the daemon drains when the org next starts. */
259
+ export async function sendHumanMessage(root, org, role, text) {
260
+ const live = await callDaemon(org, '/api/human-message', { org, role, text });
261
+ if (live) return { delivery: 'live' };
262
+ const queued = queueMessage(root, org, {
263
+ fromQualified: 'human',
264
+ toRole: role,
265
+ subject: 'message from human',
266
+ body: text,
267
+ ts: Date.now(),
268
+ });
269
+ if (!queued) throw new HilError(500, 'could not queue the message');
270
+ return { delivery: 'queued' };
271
+ }
272
+
273
+ /** Map a thrown error to an HTTP status + message. */
274
+ export function hilErrorStatus(err) {
275
+ return { status: err instanceof HilError ? err.status : 500, error: err.message };
276
+ }
@@ -0,0 +1,350 @@
1
+ // What an Org Runtime v2 org is doing, for the dashboard's Runtime pane,
2
+ // assembled from the runtime's own sources rather than the dashboard's
3
+ // derived files:
4
+ // - the live daemon's /api/status (roles, task DAG) when the org runs —
5
+ // read with the org's AGENT credential, which authorizes no decision;
6
+ // - <org>/runtime.json (last run, checkpointed task DAG) when it doesn't;
7
+ // - <org>/<run>/bus.jsonl (per-role usage incl. cache tokens, and the
8
+ // evidence-gate / idle-watchdog audit trail);
9
+ // - <org>/idle-watchdog.json (the idle clock and its hold deadline);
10
+ // - <org>/history.jsonl (how each run actually ended);
11
+ // - the org definition, resolved through cost_tiers like session.ts does.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ // Compiled from orgrt/*.ts, like forwarder.js in routes-org.mjs.
15
+ import { lookupOrg } from '../orgrt/broker.js';
16
+ import { resolveRoleCostTier, validateCostTiers } from '../orgrt/cost-tier.js';
17
+ import { readIdleStatus } from '../orgrt/idle-deadline.js';
18
+ import { OrgDefSchema } from '../orgrt/types.js';
19
+
20
+ const orgDir = (root, org) => path.join(root, '.monomind', 'orgs', org);
21
+
22
+ /** Audit reasons the Runtime pane lists: the completion-evidence gate, the
23
+ * idle watchdog, org_complete refusals and role failures. */
24
+ export const RUNTIME_AUDIT_REASONS = new Set([
25
+ 'task-evidence-refused',
26
+ 'task-evidence-escalated',
27
+ 'org-complete-refused',
28
+ 'idle-nudge',
29
+ 'idle-stop',
30
+ 'no-progress',
31
+ 'hold-expired',
32
+ 'circuit-breaker-tripped',
33
+ 'budget-exhausted',
34
+ 'org-budget-exhausted',
35
+ 'agent-fatal',
36
+ 'worker-crashed',
37
+ 'session-result-error',
38
+ 'loadout-unresolvable',
39
+ 'loadout-mismatch',
40
+ ]);
41
+ const MAX_AUDIT = 60;
42
+
43
+ function readJson(p) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ const zeroUsage = () => ({
52
+ tokens: 0,
53
+ tokens_in: 0,
54
+ tokens_out: 0,
55
+ cache_read: 0,
56
+ cache_creation: 0,
57
+ cost_usd: 0,
58
+ turns: 0,
59
+ // Tokens from usage events recorded before the four-way split (ADR-O001
60
+ // D1). Their `tokens` counted input + output only — no cache — so they add
61
+ // to the uncached figure and leave the billable one unknown.
62
+ legacy_tokens: 0,
63
+ });
64
+
65
+ // A run's bus.jsonl reaches tens of MB; parse each version once.
66
+ const runCache = new Map();
67
+
68
+ /** Per-role usage, last activity and the runtime audit trail of one run. */
69
+ export function readRunDigest(root, org, run) {
70
+ const file = path.join(orgDir(root, org), run, 'bus.jsonl');
71
+ let st;
72
+ try {
73
+ st = fs.statSync(file);
74
+ } catch {
75
+ return null;
76
+ }
77
+ const cached = runCache.get(file);
78
+ if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.digest;
79
+ const usage = {};
80
+ const lastActivity = {};
81
+ const audit = [];
82
+ let startedAt = null;
83
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
84
+ if (!line) continue;
85
+ let e;
86
+ try {
87
+ e = JSON.parse(line);
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (startedAt === null && typeof e.ts === 'number') startedAt = e.ts;
92
+ if (e.from && typeof e.ts === 'number') lastActivity[e.from] = e.ts;
93
+ if (e.type === 'usage' && e.from) {
94
+ const u = (usage[e.from] ??= zeroUsage());
95
+ const d = e.data ?? {};
96
+ if (d.tokens_in === undefined) {
97
+ u.legacy_tokens += Number(d.tokens) || 0;
98
+ u.cost_usd += Number(d.cost_usd) || 0;
99
+ u.turns += 1;
100
+ continue;
101
+ }
102
+ u.tokens += Number(d.tokens) || 0;
103
+ u.tokens_in += Number(d.tokens_in) || 0;
104
+ u.tokens_out += Number(d.tokens_out) || 0;
105
+ u.cache_read += Number(d.cache_read) || 0;
106
+ u.cache_creation += Number(d.cache_creation) || 0;
107
+ u.cost_usd += Number(d.cost_usd) || 0;
108
+ u.turns += 1;
109
+ } else if (e.type === 'audit' && RUNTIME_AUDIT_REASONS.has(e.reason)) {
110
+ audit.push({
111
+ ts: e.ts,
112
+ reason: e.reason,
113
+ from: e.from ?? null,
114
+ msg: e.msg ?? '',
115
+ data: e.data ?? null,
116
+ });
117
+ if (audit.length > MAX_AUDIT) audit.shift();
118
+ }
119
+ }
120
+ const digest = { startedAt, usage, lastActivity, audit: audit.reverse() };
121
+ runCache.set(file, { size: st.size, mtimeMs: st.mtimeMs, digest });
122
+ return digest;
123
+ }
124
+
125
+ /** The live daemon's status for `org`, or null when no daemon hosts it. */
126
+ async function liveStatus(org) {
127
+ const entry = lookupOrg(org);
128
+ if (!entry) return null;
129
+ try {
130
+ const res = await fetch(`${entry.url}/api/status`, {
131
+ headers: entry.credential ? { 'x-monomind-cred': entry.credential } : {},
132
+ signal: AbortSignal.timeout(3000),
133
+ });
134
+ if (!res.ok) return { unreachable: `daemon answered ${res.status}` };
135
+ const snap = await res.json();
136
+ return (
137
+ (snap.orgs || []).find((o) => o.name === org) ?? {
138
+ unreachable: 'daemon does not list this org',
139
+ }
140
+ );
141
+ } catch (err) {
142
+ return { unreachable: err.message };
143
+ }
144
+ }
145
+
146
+ /** Last `n` history.jsonl entries, newest first, trimmed to what a run
147
+ * outcome needs (`closedBy` before `outcome` — see reporting.ts). */
148
+ export function readRecentHistory(root, org, n = 10) {
149
+ let raw;
150
+ try {
151
+ raw = fs.readFileSync(path.join(orgDir(root, org), 'history.jsonl'), 'utf8');
152
+ } catch {
153
+ return [];
154
+ }
155
+ const out = [];
156
+ for (const line of raw.split('\n').filter(Boolean).slice(-n).reverse()) {
157
+ try {
158
+ const h = JSON.parse(line);
159
+ out.push({
160
+ run: h.run,
161
+ startedAt: h.startedAt ?? null,
162
+ endedAt: h.endedAt ?? null,
163
+ durationMs: h.durationMs ?? null,
164
+ closedBy: h.closedBy ?? null,
165
+ outcome: h.outcome ?? null,
166
+ blocker: h.blocker ?? null,
167
+ blockerDetail: h.blockerDetail ?? null,
168
+ runnableTasksAtStop: h.runnableTasksAtStop ?? 0,
169
+ crashes: h.crashes ?? [],
170
+ totalTokens: h.totalTokens ?? 0,
171
+ totalCostUsd: h.totalCostUsd ?? 0,
172
+ });
173
+ } catch {
174
+ /* one bad line doesn't hide the rest */
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** Model and effort a role runs with, with where each came from. Mirrors
181
+ * session.ts: an explicit adapter_config.model beats the tier's model; the
182
+ * tier's effort applies either way. */
183
+ export function describeRoleModel(role, def) {
184
+ let tier = null;
185
+ let tierError = null;
186
+ try {
187
+ tier = resolveRoleCostTier({ role, def }) ?? null;
188
+ } catch (err) {
189
+ tierError = err.message;
190
+ }
191
+ const explicit = role.adapter_config?.model || null;
192
+ return {
193
+ runtime: role.runtime ?? def.runtime ?? 'claude',
194
+ model: explicit ?? tier?.model ?? null,
195
+ modelSource: explicit ? 'adapter_config' : tier ? 'tier' : 'runtime default',
196
+ effort: tier?.effort ?? null,
197
+ tier: tier?.tier ?? null,
198
+ tierError,
199
+ };
200
+ }
201
+
202
+ export async function runtimeView(root, org) {
203
+ const raw = readJson(path.join(root, '.monomind', 'orgs', `${org}.json`));
204
+ if (!raw) return null;
205
+ const parsed = OrgDefSchema.safeParse(raw);
206
+ // Defaults (run_config) come from the schema; an invalid definition still
207
+ // renders, with the problems listed.
208
+ const def = parsed.success ? parsed.data : { ...raw, run_config: raw.run_config ?? {} };
209
+ const rc = def.run_config ?? {};
210
+ const runtime = readJson(path.join(orgDir(root, org), 'runtime.json'));
211
+ const live = await liveStatus(org);
212
+ const isLive = !!(live && !live.unreachable);
213
+ const run = (isLive ? live.run : null) ?? runtime?.run ?? null;
214
+ const digest = run ? readRunDigest(root, org, run) : null;
215
+ const liveRoles = new Map((isLive ? live.roles : []).map((r) => [r.id, r]));
216
+ const pendingRoles = new Set(isLive ? live.pendingRoles : []);
217
+
218
+ const roles = (def.roles || []).map((r) => {
219
+ const lr = liveRoles.get(r.id);
220
+ return {
221
+ id: r.id,
222
+ title: r.title || r.id,
223
+ reportsTo: r.reports_to ?? null,
224
+ ...describeRoleModel(r, def),
225
+ status: lr?.status ?? (pendingRoles.has(r.id) ? 'not started' : isLive ? 'idle' : 'stopped'),
226
+ usage: digest?.usage[r.id] ?? zeroUsage(),
227
+ lastActivity: digest?.lastActivity[r.id] ?? null,
228
+ };
229
+ });
230
+ const totals = roles.reduce((t, r) => {
231
+ for (const k of Object.keys(t)) t[k] += r.usage[k];
232
+ return t;
233
+ }, zeroUsage());
234
+ const basis = rc.budget_tokens_basis ?? 'uncached';
235
+
236
+ return {
237
+ org,
238
+ valid: parsed.success,
239
+ problems: parsed.success
240
+ ? []
241
+ : parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
242
+ live: isLive,
243
+ liveError: live?.unreachable ?? null,
244
+ run,
245
+ status: isLive ? 'running' : (runtime?.status ?? 'never run'),
246
+ closedBy: isLive ? null : (runtime?.closedBy ?? null),
247
+ startedAt: digest?.startedAt ?? null,
248
+ updated: runtime?.updated ?? null,
249
+ idle: isLive && run ? readIdleStatus(root, org, run) : null,
250
+ settings: {
251
+ completion: rc.completion ?? 'boss',
252
+ completion_evidence: !!rc.completion_evidence,
253
+ max_evidence_attempts: rc.max_evidence_attempts ?? 3,
254
+ idle_minutes: rc.idle_minutes ?? 10,
255
+ session_scope: rc.session_scope ?? 'role',
256
+ workspace: rc.workspace ?? 'repo',
257
+ max_concurrent_agents: rc.max_concurrent_agents ?? 4,
258
+ },
259
+ budget: {
260
+ tokens: rc.budget_tokens ?? null,
261
+ basis,
262
+ // Enforcement counts cache tokens only on 'billable'. Pre-split usage
263
+ // (legacy_tokens) is uncached, so it leaves a billable total unknown.
264
+ used:
265
+ basis === 'billable'
266
+ ? totals.legacy_tokens > 0
267
+ ? null
268
+ : totals.tokens
269
+ : totals.tokens_in + totals.tokens_out + totals.legacy_tokens,
270
+ },
271
+ totals,
272
+ roles,
273
+ tasks: (isLive ? live.tasks : runtime?.checkpoint?.tasks) ?? [],
274
+ audit: digest?.audit ?? [],
275
+ history: readRecentHistory(root, org),
276
+ };
277
+ }
278
+
279
+ /** run_config keys the dashboard's Config tab may set. Everything else in the
280
+ * definition is left exactly as it is on disk. */
281
+ const EDITABLE_RUN_CONFIG = new Set([
282
+ 'max_concurrent_agents',
283
+ 'budget_tokens',
284
+ 'budget_tokens_basis',
285
+ 'idle_minutes',
286
+ 'completion',
287
+ 'completion_evidence',
288
+ 'max_evidence_attempts',
289
+ 'session_scope',
290
+ 'workspace',
291
+ 'memory_namespace',
292
+ 'max_turns_per_message',
293
+ ]);
294
+
295
+ export class ConfigRejected extends Error {
296
+ constructor(problems) {
297
+ super(problems.join('; '));
298
+ this.problems = problems;
299
+ }
300
+ }
301
+
302
+ /** Apply `patch` ({ goal?, schedule?, run_config?, cost_tiers_default? }) to
303
+ * the org definition on disk and write it only if the result passes the same
304
+ * OrgDefSchema parse (and cost-tier check) `org run` performs. A `null`
305
+ * run_config value removes that key, so the runtime default applies. Returns
306
+ * the definition as written. */
307
+ export function patchOrgConfig(root, org, patch) {
308
+ const file = path.join(root, '.monomind', 'orgs', `${org}.json`);
309
+ let raw;
310
+ try {
311
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'));
312
+ } catch (err) {
313
+ throw new ConfigRejected([`cannot read ${file}: ${err.message}`]);
314
+ }
315
+ const problems = [];
316
+ const next = { ...raw };
317
+ if (patch.goal !== undefined) next.goal = String(patch.goal);
318
+ if (patch.schedule !== undefined) next.schedule = patch.schedule === '' ? null : patch.schedule;
319
+ if (patch.run_config !== undefined) {
320
+ const rc = { ...(raw.run_config ?? {}) };
321
+ for (const [k, v] of Object.entries(patch.run_config ?? {})) {
322
+ if (!EDITABLE_RUN_CONFIG.has(k)) {
323
+ problems.push(`run_config.${k} is not editable here`);
324
+ continue;
325
+ }
326
+ if (v === null) delete rc[k];
327
+ else rc[k] = v;
328
+ }
329
+ next.run_config = rc;
330
+ }
331
+ if (patch.cost_tiers_default !== undefined) {
332
+ const ct = { ...(raw.cost_tiers ?? {}) };
333
+ if (patch.cost_tiers_default) ct.default = String(patch.cost_tiers_default);
334
+ else delete ct.default;
335
+ if (Object.keys(ct).length) next.cost_tiers = ct;
336
+ else delete next.cost_tiers;
337
+ }
338
+ if (problems.length) throw new ConfigRejected(problems);
339
+ const parsed = OrgDefSchema.safeParse(next);
340
+ if (!parsed.success)
341
+ throw new ConfigRejected(
342
+ parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
343
+ );
344
+ const tierProblems = validateCostTiers(parsed.data);
345
+ if (tierProblems.length) throw new ConfigRejected(tierProblems);
346
+ const tmp = `${file}.${process.pid}.tmp`;
347
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
348
+ fs.renameSync(tmp, file);
349
+ return next;
350
+ }
@@ -1827,6 +1827,9 @@ async function loadRunHistory() {
1827
1827
  ? `<span style="color:${outcomeColor(h.outcome.status)}">${esc(h.outcome.status.toUpperCase())}</span>
1828
1828
  <span style="color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(h.outcome.summary || '')}</span>`
1829
1829
  : `<span style="color:var(--dim)">NO OUTCOME</span>`}
1830
+ ${h.blocker ? `<span style="color:var(--amber, #d90)">blocker: ${esc(h.blocker)}</span>` : ''}
1831
+ <span style="color:var(--dim);white-space:nowrap" title="How the run actually ended (history closedBy)">${esc(h.closedBy ? `closed by ${h.closedBy}` : 'stopped manually')}</span>
1832
+ ${Number(h.runnableTasksAtStop) > 0 ? `<span style="color:var(--amber, #d90);white-space:nowrap">${Number(h.runnableTasksAtStop)} tasks open</span>` : ''}
1830
1833
  ${Array.isArray(h.crashes) && h.crashes.length ? `<span style="color:var(--red)">✗ ${Number(h.crashes.length)} crash</span>` : ''}
1831
1834
  </div>`).join('')}
1832
1835
  </div>`;
@@ -2111,13 +2114,21 @@ async function sendChatMessage() {
2111
2114
  btn.disabled = true;
2112
2115
  input.value = '';
2113
2116
  input.style.height = '';
2117
+ let error = null;
2114
2118
  try {
2115
- await fetch(`/api/orgs/${encodeURIComponent(selectedOrg)}/chat`, {
2119
+ const res = await fetch(`/api/orgs/${encodeURIComponent(selectedOrg)}/chat`, {
2116
2120
  method: 'POST',
2117
2121
  headers: { 'Content-Type': 'application/json' },
2118
2122
  body: JSON.stringify({ text }),
2119
2123
  });
2120
- } catch(_) {}
2124
+ const data = await res.json().catch(() => ({}));
2125
+ if (!res.ok || !data.ok) error = data.error || `HTTP ${res.status}`;
2126
+ } catch (e) { error = e.message; }
2127
+ if (error) {
2128
+ // Keep what was typed; say why it did not go out.
2129
+ input.value = text;
2130
+ alert(`Not delivered: ${error}`);
2131
+ }
2121
2132
  btn.disabled = false;
2122
2133
  input.focus();
2123
2134
  }
@@ -2312,13 +2323,13 @@ function connectSSE() {
2312
2323
  renderActivity();
2313
2324
  }
2314
2325
  // Reload org list if start/stop event
2315
- if (ev.type === 'org:start' || ev.type === 'org:stop') {
2326
+ if (ev.type === 'org:start' || ev.type === 'org:stop' || ev.type === 'org:complete') {
2316
2327
  setTimeout(loadOrgs, 400);
2317
2328
  }
2318
2329
  // Update live strip for selected org on chart tab
2319
2330
  if (name === selectedOrg && currentTab === 'chart') {
2320
2331
  if (ev.type === 'org:start') orgRunStartMs = Date.now();
2321
- if (ev.type === 'org:stop') orgRunStartMs = null;
2332
+ if (ev.type === 'org:stop' || ev.type === 'org:complete') orgRunStartMs = null;
2322
2333
  appendLiveFeedRow(ev, true);
2323
2334
  updateLiveStrip();
2324
2335
  }