@monoes/monomindcli 2.16.13 → 2.16.14
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 +1 -1
- package/dist/src/commands/org-control.d.ts +117 -0
- package/dist/src/commands/org-control.d.ts.map +1 -0
- package/dist/src/commands/org-control.js +274 -0
- package/dist/src/commands/org-control.js.map +1 -0
- package/dist/src/commands/org-lifecycle.d.ts +7 -0
- package/dist/src/commands/org-lifecycle.d.ts.map +1 -0
- package/dist/src/commands/org-lifecycle.js +298 -0
- package/dist/src/commands/org-lifecycle.js.map +1 -0
- package/dist/src/commands/org-manage.d.ts +7 -0
- package/dist/src/commands/org-manage.d.ts.map +1 -0
- package/dist/src/commands/org-manage.js +357 -0
- package/dist/src/commands/org-manage.js.map +1 -0
- package/dist/src/commands/org-memory-command.d.ts +3 -0
- package/dist/src/commands/org-memory-command.d.ts.map +1 -0
- package/dist/src/commands/org-memory-command.js +178 -0
- package/dist/src/commands/org-memory-command.js.map +1 -0
- package/dist/src/commands/org-observe-approvals.d.ts +8 -0
- package/dist/src/commands/org-observe-approvals.d.ts.map +1 -0
- package/dist/src/commands/org-observe-approvals.js +211 -0
- package/dist/src/commands/org-observe-approvals.js.map +1 -0
- package/dist/src/commands/org-observe-checkpoint.d.ts +26 -0
- package/dist/src/commands/org-observe-checkpoint.d.ts.map +1 -0
- package/dist/src/commands/org-observe-checkpoint.js +180 -0
- package/dist/src/commands/org-observe-checkpoint.js.map +1 -0
- package/dist/src/commands/org-observe-config.d.ts +8 -0
- package/dist/src/commands/org-observe-config.d.ts.map +1 -0
- package/dist/src/commands/org-observe-config.js +160 -0
- package/dist/src/commands/org-observe-config.js.map +1 -0
- package/dist/src/commands/org-observe-gates.d.ts +4 -0
- package/dist/src/commands/org-observe-gates.d.ts.map +1 -0
- package/dist/src/commands/org-observe-gates.js +182 -0
- package/dist/src/commands/org-observe-gates.js.map +1 -0
- package/dist/src/commands/org-observe-inbox.d.ts +18 -0
- package/dist/src/commands/org-observe-inbox.d.ts.map +1 -0
- package/dist/src/commands/org-observe-inbox.js +154 -0
- package/dist/src/commands/org-observe-inbox.js.map +1 -0
- package/dist/src/commands/org-observe-logs.d.ts +31 -0
- package/dist/src/commands/org-observe-logs.d.ts.map +1 -0
- package/dist/src/commands/org-observe-logs.js +272 -0
- package/dist/src/commands/org-observe-logs.js.map +1 -0
- package/dist/src/commands/org-observe-questions.d.ts +8 -0
- package/dist/src/commands/org-observe-questions.d.ts.map +1 -0
- package/dist/src/commands/org-observe-questions.js +201 -0
- package/dist/src/commands/org-observe-questions.js.map +1 -0
- package/dist/src/commands/org-observe-report.d.ts +8 -0
- package/dist/src/commands/org-observe-report.d.ts.map +1 -0
- package/dist/src/commands/org-observe-report.js +366 -0
- package/dist/src/commands/org-observe-report.js.map +1 -0
- package/dist/src/commands/org-observe-shared.d.ts +20 -0
- package/dist/src/commands/org-observe-shared.d.ts.map +1 -0
- package/dist/src/commands/org-observe-shared.js +55 -0
- package/dist/src/commands/org-observe-shared.js.map +1 -0
- package/dist/src/commands/org-observe.d.ts +9 -101
- package/dist/src/commands/org-observe.d.ts.map +1 -1
- package/dist/src/commands/org-observe.js +12 -1675
- package/dist/src/commands/org-observe.js.map +1 -1
- package/dist/src/commands/org-poll.d.ts +57 -0
- package/dist/src/commands/org-poll.d.ts.map +1 -0
- package/dist/src/commands/org-poll.js +194 -0
- package/dist/src/commands/org-poll.js.map +1 -0
- package/dist/src/commands/org-run.d.ts +3 -0
- package/dist/src/commands/org-run.d.ts.map +1 -0
- package/dist/src/commands/org-run.js +449 -0
- package/dist/src/commands/org-run.js.map +1 -0
- package/dist/src/commands/org-serve.d.ts +19 -0
- package/dist/src/commands/org-serve.d.ts.map +1 -0
- package/dist/src/commands/org-serve.js +395 -0
- package/dist/src/commands/org-serve.js.map +1 -0
- package/dist/src/commands/org-subcommands-config.d.ts +8 -0
- package/dist/src/commands/org-subcommands-config.d.ts.map +1 -0
- package/dist/src/commands/org-subcommands-config.js +93 -0
- package/dist/src/commands/org-subcommands-config.js.map +1 -0
- package/dist/src/commands/org-subcommands-hil.d.ts +11 -0
- package/dist/src/commands/org-subcommands-hil.d.ts.map +1 -0
- package/dist/src/commands/org-subcommands-hil.js +216 -0
- package/dist/src/commands/org-subcommands-hil.js.map +1 -0
- package/dist/src/commands/org-subcommands-observe.d.ts +12 -0
- package/dist/src/commands/org-subcommands-observe.d.ts.map +1 -0
- package/dist/src/commands/org-subcommands-observe.js +249 -0
- package/dist/src/commands/org-subcommands-observe.js.map +1 -0
- package/dist/src/commands/org-subcommands-runtime.d.ts +12 -0
- package/dist/src/commands/org-subcommands-runtime.d.ts.map +1 -0
- package/dist/src/commands/org-subcommands-runtime.js +163 -0
- package/dist/src/commands/org-subcommands-runtime.js.map +1 -0
- package/dist/src/commands/org.d.ts +3 -151
- package/dist/src/commands/org.d.ts.map +1 -1
- package/dist/src/commands/org.js +43 -2735
- package/dist/src/commands/org.js.map +1 -1
- package/dist/src/orgrt/daemon-types.d.ts +196 -0
- package/dist/src/orgrt/daemon-types.d.ts.map +1 -0
- package/dist/src/orgrt/daemon-types.js +38 -0
- package/dist/src/orgrt/daemon-types.js.map +1 -0
- package/dist/src/orgrt/daemon.d.ts +70 -300
- package/dist/src/orgrt/daemon.d.ts.map +1 -1
- package/dist/src/orgrt/daemon.js +41 -2536
- package/dist/src/orgrt/daemon.js.map +1 -1
- package/dist/src/orgrt/idle-watchdog.d.ts +39 -0
- package/dist/src/orgrt/idle-watchdog.d.ts.map +1 -0
- package/dist/src/orgrt/idle-watchdog.js +289 -0
- package/dist/src/orgrt/idle-watchdog.js.map +1 -0
- package/dist/src/orgrt/org-reload.d.ts +7 -0
- package/dist/src/orgrt/org-reload.d.ts.map +1 -0
- package/dist/src/orgrt/org-reload.js +111 -0
- package/dist/src/orgrt/org-reload.js.map +1 -0
- package/dist/src/orgrt/org-start-steps.d.ts +21 -0
- package/dist/src/orgrt/org-start-steps.d.ts.map +1 -0
- package/dist/src/orgrt/org-start-steps.js +305 -0
- package/dist/src/orgrt/org-start-steps.js.map +1 -0
- package/dist/src/orgrt/org-start.d.ts +7 -0
- package/dist/src/orgrt/org-start.d.ts.map +1 -0
- package/dist/src/orgrt/org-start.js +435 -0
- package/dist/src/orgrt/org-start.js.map +1 -0
- package/dist/src/orgrt/org-state-file.d.ts +8 -0
- package/dist/src/orgrt/org-state-file.d.ts.map +1 -0
- package/dist/src/orgrt/org-state-file.js +116 -0
- package/dist/src/orgrt/org-state-file.js.map +1 -0
- package/dist/src/orgrt/org-stop.d.ts +6 -0
- package/dist/src/orgrt/org-stop.d.ts.map +1 -0
- package/dist/src/orgrt/org-stop.js +332 -0
- package/dist/src/orgrt/org-stop.js.map +1 -0
- package/dist/src/orgrt/org-tools.d.ts +11 -0
- package/dist/src/orgrt/org-tools.d.ts.map +1 -0
- package/dist/src/orgrt/org-tools.js +305 -0
- package/dist/src/orgrt/org-tools.js.map +1 -0
- package/dist/src/orgrt/role-incarnation.d.ts +18 -0
- package/dist/src/orgrt/role-incarnation.d.ts.map +1 -0
- package/dist/src/orgrt/role-incarnation.js +374 -0
- package/dist/src/orgrt/role-incarnation.js.map +1 -0
- package/dist/src/orgrt/role-respawn.d.ts +7 -0
- package/dist/src/orgrt/role-respawn.d.ts.map +1 -0
- package/dist/src/orgrt/role-respawn.js +318 -0
- package/dist/src/orgrt/role-respawn.js.map +1 -0
- package/dist/src/orgrt/role-session-opts.d.ts +389 -0
- package/dist/src/orgrt/role-session-opts.d.ts.map +1 -0
- package/dist/src/orgrt/role-session-opts.js +289 -0
- package/dist/src/orgrt/role-session-opts.js.map +1 -0
- package/dist/src/orgrt/runner-resolve.d.ts +43 -0
- package/dist/src/orgrt/runner-resolve.d.ts.map +1 -0
- package/dist/src/orgrt/runner-resolve.js +77 -0
- package/dist/src/orgrt/runner-resolve.js.map +1 -0
- package/dist/src/orgrt/session-gate.d.ts +34 -0
- package/dist/src/orgrt/session-gate.d.ts.map +1 -0
- package/dist/src/orgrt/session-gate.js +74 -0
- package/dist/src/orgrt/session-gate.js.map +1 -0
- package/dist/src/orgrt/session-prompt.d.ts +31 -0
- package/dist/src/orgrt/session-prompt.d.ts.map +1 -0
- package/dist/src/orgrt/session-prompt.js +115 -0
- package/dist/src/orgrt/session-prompt.js.map +1 -0
- package/dist/src/orgrt/session-run.d.ts +18 -0
- package/dist/src/orgrt/session-run.d.ts.map +1 -0
- package/dist/src/orgrt/session-run.js +429 -0
- package/dist/src/orgrt/session-run.js.map +1 -0
- package/dist/src/orgrt/session-stream.d.ts +21 -0
- package/dist/src/orgrt/session-stream.d.ts.map +1 -0
- package/dist/src/orgrt/session-stream.js +173 -0
- package/dist/src/orgrt/session-stream.js.map +1 -0
- package/dist/src/orgrt/session-types.d.ts +219 -0
- package/dist/src/orgrt/session-types.d.ts.map +1 -0
- package/dist/src/orgrt/session-types.js +2 -0
- package/dist/src/orgrt/session-types.js.map +1 -0
- package/dist/src/orgrt/session-usage.d.ts +29 -0
- package/dist/src/orgrt/session-usage.d.ts.map +1 -0
- package/dist/src/orgrt/session-usage.js +64 -0
- package/dist/src/orgrt/session-usage.js.map +1 -0
- package/dist/src/orgrt/session.d.ts +5 -281
- package/dist/src/orgrt/session.d.ts.map +1 -1
- package/dist/src/orgrt/session.js +8 -1117
- package/dist/src/orgrt/session.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
package/dist/src/commands/org.js
CHANGED
|
@@ -1,2747 +1,55 @@
|
|
|
1
1
|
// packages/@monomind/cli/src/commands/org.ts
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { resolveOrgDefBlueprints } from '../catalog/blueprints.js';
|
|
7
|
-
import { approvalPendingNotice, parseAutoApproveFlag } from '../orgrt/approvals.js';
|
|
8
|
-
import { resolveRoleCostTier } from '../orgrt/cost-tier.js';
|
|
9
|
-
import { OrgDaemon } from '../orgrt/daemon.js';
|
|
10
|
-
import { readIdleStatus } from '../orgrt/idle-deadline.js';
|
|
11
|
-
import { migrateOrgFile } from '../orgrt/migrate.js';
|
|
12
|
-
import { describeRunOutcome, readHistory, readRunEvents, summarizeRun, utcTime, } from '../orgrt/reporting.js';
|
|
13
|
-
import { startOrgServer } from '../orgrt/server.js';
|
|
14
|
-
import { resolveModel } from '../orgrt/session.js';
|
|
15
|
-
import { ORG_DIR, OrgDefSchema } from '../orgrt/types.js';
|
|
16
2
|
import { output } from '../output.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
3
|
+
import { memorySubcommand } from './org-memory-command.js';
|
|
4
|
+
import { createSubcommand, deleteSubcommand, listSubcommand, markCompleteSubcommand, migrateSubcommand, validateSubcommand, } from './org-subcommands-config.js';
|
|
5
|
+
import { answerSubcommand, approvalsSubcommand, approveSubcommand, denySubcommand, gateApproveSubcommand, gateRejectSubcommand, gatesSubcommand, inboxSubcommand, questionsSubcommand, } from './org-subcommands-hil.js';
|
|
6
|
+
import { branchSubcommand, costsSubcommand, decisionsSubcommand, eventsSubcommand, flowSubcommand, logsSubcommand, replaySubcommand, reportSubcommand, resumeFromSubcommand, watchSubcommand, } from './org-subcommands-observe.js';
|
|
7
|
+
import { pauseSubcommand, reloadSubcommand, resumeSubcommand, runSubcommand, serveSubcommand, skillsSubcommand, statusSubcommand, stopSubcommand, supervisorSubcommand, testLoopSubcommand, } from './org-subcommands-runtime.js';
|
|
8
|
+
export { checkServeLock, classifyRun, clearReloadfile, clearStaleControlFiles, clearStopfile, isOrgPaused, listOrgConfigFiles, validateOrgName, } from './org-control.js';
|
|
9
|
+
export { pollReloadfiles, pollRunfiles, pollStopfiles, runOutcomeResult, waitForRunEnd, } from './org-poll.js';
|
|
19
10
|
const log = (text) => {
|
|
20
11
|
console.log(text);
|
|
21
12
|
};
|
|
22
|
-
/** Org names are used to build filesystem paths under .monomind/orgs — reject
|
|
23
|
-
* anything that isn't a plain identifier to prevent path traversal (e.g.
|
|
24
|
-
* `monomind org stop '../../../../tmp/x'`). */
|
|
25
|
-
const ORG_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
26
|
-
export function validateOrgName(name) {
|
|
27
|
-
if (!name)
|
|
28
|
-
return { ok: false, result: { success: false, message: 'org name required' } };
|
|
29
|
-
if (!ORG_NAME_RE.test(name)) {
|
|
30
|
-
log(output.error(`Invalid org name: ${name}`));
|
|
31
|
-
return { ok: false, result: { success: false, message: 'invalid org name' } };
|
|
32
|
-
}
|
|
33
|
-
return { ok: true, name };
|
|
34
|
-
}
|
|
35
|
-
/** Suffixes of org-internal artifact files (state/goals/threads/etc) that
|
|
36
|
-
* share the `<org>.json`/`.jsonl` naming pattern with the org's own config
|
|
37
|
-
* file. Single source of truth for both listOrgConfigFiles() (which must
|
|
38
|
-
* exclude them when discovering real org configs) and deleteAction (which
|
|
39
|
-
* must remove all of them when deleting an org). */
|
|
40
|
-
const ORG_ARTIFACT_SUFFIXES = [
|
|
41
|
-
'-state',
|
|
42
|
-
'-goals',
|
|
43
|
-
'-threads',
|
|
44
|
-
'-activity',
|
|
45
|
-
'-approvals',
|
|
46
|
-
'-members',
|
|
47
|
-
'-secrets',
|
|
48
|
-
'-budgets',
|
|
49
|
-
'-routines',
|
|
50
|
-
'-issues',
|
|
51
|
-
'-projects',
|
|
52
|
-
'-workspaces',
|
|
53
|
-
'-worktrees',
|
|
54
|
-
'-environments',
|
|
55
|
-
'-plugins',
|
|
56
|
-
'-adapters',
|
|
57
|
-
'-join-requests',
|
|
58
|
-
'-bootstrap',
|
|
59
|
-
'-project-workspaces',
|
|
60
|
-
'-approval-comments',
|
|
61
|
-
'-runstate',
|
|
62
|
-
'-skills',
|
|
63
|
-
];
|
|
64
|
-
export function listOrgConfigFiles(orgsDir) {
|
|
65
|
-
// endsWith, not includes: substring matching hid legitimate orgs whose NAME
|
|
66
|
-
// merely contains an artifact suffix anywhere (e.g. "state-machine.json",
|
|
67
|
-
// "issues-triage.json") — and anything hidden here is also invisible to
|
|
68
|
-
// run/list/serve while `org delete <sibling>` would still remove its files.
|
|
69
|
-
//
|
|
70
|
-
// ORG_NAME_RE on the stem (#309): a file is only an org config if its stem
|
|
71
|
-
// is a name an org could actually have. Otherwise any stray `.json` in the
|
|
72
|
-
// orgs dir (a tool's `.mcp.json`, `.DS_Store.json`, ...) becomes a phantom
|
|
73
|
-
// org that `org list` reports but validateOrgName rejects everywhere else.
|
|
74
|
-
// This subsumes the old `._` AppleDouble check (a leading dot fails the
|
|
75
|
-
// pattern) since a leading `.` doesn't match the required first character.
|
|
76
|
-
//
|
|
77
|
-
// A valid-looking stem isn't enough (#309 follow-up): a same-directory
|
|
78
|
-
// tool config can still pass ORG_NAME_RE (e.g. `toolconfig.json`). When a
|
|
79
|
-
// candidate parses as JSON, also require it to carry the one field every
|
|
80
|
-
// org config schema requires (OrgDefSchema's `name`) before calling it an
|
|
81
|
-
// org, so an unrelated tool config is skipped rather than surfaced as one.
|
|
82
|
-
// A file that fails to parse at all is left in the list on purpose: that's
|
|
83
|
-
// a real org config corrupted on disk, and callers like `org validate`
|
|
84
|
-
// (no name given) and `org list` already detect and report that case
|
|
85
|
-
// (invalid-config / validation failure) rather than treating it as healthy
|
|
86
|
-
// — silently dropping it here would hide the corruption instead.
|
|
87
|
-
return readdirSync(orgsDir).filter((f) => {
|
|
88
|
-
if (!f.endsWith('.json') ||
|
|
89
|
-
f.endsWith('.v1.json') ||
|
|
90
|
-
ORG_ARTIFACT_SUFFIXES.some((suf) => f.endsWith(`${suf}.json`)) ||
|
|
91
|
-
!ORG_NAME_RE.test(f.slice(0, -'.json'.length))) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
let parsed;
|
|
95
|
-
try {
|
|
96
|
-
parsed = JSON.parse(readFileSync(join(orgsDir, f), 'utf8'));
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
return OrgDefSchema.pick({ name: true }).safeParse(parsed).success;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
/** Remove a lingering stopfile so a fresh `org run` doesn't self-terminate. */
|
|
105
|
-
export const clearStopfile = (cwd, name) => {
|
|
106
|
-
rmSync(join(cwd, ORG_DIR, name, 'stop'), { force: true });
|
|
107
|
-
};
|
|
108
|
-
/** Remove a lingering reload request so a fresh `org run` doesn't apply it on
|
|
109
|
-
* its first tick. One is left behind when a previous run's stop and reload
|
|
110
|
-
* landed in the same tick: the stop ends the wait before the reload poll runs. */
|
|
111
|
-
export const clearReloadfile = (cwd, name) => {
|
|
112
|
-
rmSync(join(cwd, ORG_DIR, name, 'reload'), { force: true });
|
|
113
|
-
};
|
|
114
|
-
/** Drop stop/reload requests left behind by a previous run, for every org
|
|
115
|
-
* `org serve` serves. #264: runAction clears these before its wait loop, but
|
|
116
|
-
* serveAction started polling with whatever was on disk — so an `org stop`
|
|
117
|
-
* that landed after the last daemon exited stopped the next daemon's org
|
|
118
|
-
* seconds into its run, and a leftover `org reload` was applied to a
|
|
119
|
-
* definition nobody had touched.
|
|
120
|
-
*
|
|
121
|
-
* Scope is the org config files, not `daemon.listRunning()` (what
|
|
122
|
-
* pollStopfiles/pollReloadfiles walk): nothing is running yet at startup, so
|
|
123
|
-
* sweeping by that would sweep nothing. Called before the first org can
|
|
124
|
-
* start, so a request that arrives once serve is up is never discarded.
|
|
125
|
-
*
|
|
126
|
-
* Runfiles are deliberately left in place. `.../run` is not the same kind of
|
|
127
|
-
* leftover: runAction writes one only against a live daemon and retracts it
|
|
128
|
-
* itself when nothing consumes it within 15s, and it reads the file's
|
|
129
|
-
* disappearance as "the daemon took the run". Deleting one here would report
|
|
130
|
-
* success to a waiting `org run` and start nothing — the silent loss that ack
|
|
131
|
-
* loop exists to prevent. A stale runfile at worst starts an org the operator
|
|
132
|
-
* did ask for; pollRunfiles already no-ops on one that is running.
|
|
133
|
-
*
|
|
134
|
-
* Returns the orgs it cleared something for. */
|
|
135
|
-
export const clearStaleControlFiles = (cwd) => {
|
|
136
|
-
const orgDir = join(cwd, ORG_DIR);
|
|
137
|
-
if (!existsSync(orgDir))
|
|
138
|
-
return [];
|
|
139
|
-
const cleared = [];
|
|
140
|
-
for (const f of listOrgConfigFiles(orgDir)) {
|
|
141
|
-
const name = f.replace(/\.json$/, '');
|
|
142
|
-
const stale = ['stop', 'reload'].filter((kind) => existsSync(join(orgDir, name, kind)));
|
|
143
|
-
if (!stale.length)
|
|
144
|
-
continue;
|
|
145
|
-
clearStopfile(cwd, name);
|
|
146
|
-
clearReloadfile(cwd, name);
|
|
147
|
-
log(output.warning(`org ${name}: discarding stale ${stale.join(' and ')} request left by a previous run`));
|
|
148
|
-
cleared.push(name);
|
|
149
|
-
}
|
|
150
|
-
return cleared;
|
|
151
|
-
};
|
|
152
|
-
/** True when a pause sentinel exists for an org. */
|
|
153
|
-
export const isOrgPaused = (cwd, name) => existsSync(join(cwd, ORG_DIR, name, 'pause'));
|
|
154
|
-
const clearPausefile = (cwd, name) => {
|
|
155
|
-
rmSync(join(cwd, ORG_DIR, name, 'pause'), { force: true });
|
|
156
|
-
};
|
|
157
|
-
/** PID of a live `org serve` daemon for this project, or null.
|
|
158
|
-
*
|
|
159
|
-
* The heartbeat file is written every 30s and removed on clean exit, but a
|
|
160
|
-
* SIGKILLed daemon leaves it behind — so liveness is confirmed against the pid
|
|
161
|
-
* itself, not the file's presence. A stale heartbeat must not make `org run`
|
|
162
|
-
* post a runfile nobody will ever read. */
|
|
163
|
-
function liveServeDaemonPid(cwd) {
|
|
164
|
-
try {
|
|
165
|
-
const hb = JSON.parse(readFileSync(join(cwd, '.monomind', 'serve-heartbeat.json'), 'utf8'));
|
|
166
|
-
if (typeof hb.pid !== 'number' || hb.pid === process.pid)
|
|
167
|
-
return null;
|
|
168
|
-
// Freshness as well as liveness. The daemon beats every 30s, so a stamp
|
|
169
|
-
// older than a few beats means it is gone or wedged — and a pid alone can
|
|
170
|
-
// be recycled onto an unrelated process, which would send the runfile to
|
|
171
|
-
// something that will never read it.
|
|
172
|
-
const age = Date.now() - Date.parse(hb.updatedAt ?? '');
|
|
173
|
-
if (!Number.isFinite(age) || age > 3 * 60_000)
|
|
174
|
-
return null;
|
|
175
|
-
process.kill(hb.pid, 0); // throws if the process is gone
|
|
176
|
-
return hb.pid;
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/** How long a run's own event log counts as proof of life after its last
|
|
183
|
-
* appended event. Deliberately generous: calling a live run "crashed" sends
|
|
184
|
-
* the operator to `org mark-complete`, which closes out a run that is still
|
|
185
|
-
* working, while being slow to notice a genuinely dead one costs nothing. */
|
|
186
|
-
const RUN_ACTIVITY_WINDOW_MS = 10 * 60_000;
|
|
187
|
-
/** Liveness verdict for one org's runtime.json record.
|
|
188
|
-
*
|
|
189
|
-
* #274: a single `process.kill(pid, 0)` probe used to be the whole verdict, so
|
|
190
|
-
* a run whose recorded pid had gone stale — the orchestrating process was
|
|
191
|
-
* restarted or re-attached without runtime.json being rewritten — was reported
|
|
192
|
-
* as "crashed" while its roles were actively exchanging messages, with a
|
|
193
|
-
* `mark-complete` suggestion that would have closed out live work. The pid is
|
|
194
|
-
* still the first and best signal; when it is gone, two independent signs of
|
|
195
|
-
* life are cross-checked before declaring a crash: a fresh `org serve`
|
|
196
|
-
* heartbeat that still lists this org, and the run's own event log still
|
|
197
|
-
* growing.
|
|
198
|
-
*
|
|
199
|
-
* Unchanged from before: a record that already says 'crashed' (including one
|
|
200
|
-
* written by the process-level crash handler) stays crashed — that is a
|
|
201
|
-
* recorded fact, not an inference. */
|
|
202
|
-
export function classifyRun(cwd, org, state, now = Date.now()) {
|
|
203
|
-
if (state.status === 'crashed')
|
|
204
|
-
return { state: 'crashed' };
|
|
205
|
-
const busAge = () => {
|
|
206
|
-
if (!state.run)
|
|
207
|
-
return null;
|
|
208
|
-
try {
|
|
209
|
-
const age = now - statSync(join(cwd, ORG_DIR, org, state.run, 'bus.jsonl')).mtimeMs;
|
|
210
|
-
return age >= 0 ? age : 0;
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
const live = (evidence) => {
|
|
217
|
-
const age = busAge();
|
|
218
|
-
return { state: age !== null && age > RUN_ACTIVITY_WINDOW_MS ? 'idle' : 'running', evidence };
|
|
219
|
-
};
|
|
220
|
-
if (state.pid) {
|
|
221
|
-
try {
|
|
222
|
-
process.kill(state.pid, 0);
|
|
223
|
-
return live('pid');
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
/* recorded pid is gone — fall through to the cross-checks */
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
else {
|
|
230
|
-
// No pid was ever recorded; there is nothing to call stale.
|
|
231
|
-
return live('pid');
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
const hb = JSON.parse(readFileSync(join(cwd, '.monomind', 'serve-heartbeat.json'), 'utf8'));
|
|
235
|
-
const age = now - Date.parse(hb.updatedAt ?? '');
|
|
236
|
-
if (typeof hb.pid === 'number' &&
|
|
237
|
-
Number.isFinite(age) &&
|
|
238
|
-
age <= 3 * 60_000 &&
|
|
239
|
-
(hb.running ?? []).includes(org)) {
|
|
240
|
-
process.kill(hb.pid, 0); // throws if that daemon is gone too
|
|
241
|
-
return live('daemon-heartbeat');
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
/* no heartbeat file, unparseable, or its daemon is gone as well */
|
|
246
|
-
}
|
|
247
|
-
const age = busAge();
|
|
248
|
-
if (age !== null && age <= RUN_ACTIVITY_WINDOW_MS)
|
|
249
|
-
return { state: 'running', evidence: 'run-activity' };
|
|
250
|
-
return { state: 'crashed' };
|
|
251
|
-
}
|
|
252
|
-
/** Mutual-exclusion check for `org serve` startup.
|
|
253
|
-
*
|
|
254
|
-
* Two `org serve` processes started against the same project root have zero
|
|
255
|
-
* visibility into each other — each builds its own in-memory `OrgDaemon` and
|
|
256
|
-
* independently decides which orgs are "due now", so both can call
|
|
257
|
-
* `daemon.startOrg(name)` for the same org and have both processes write to
|
|
258
|
-
* the same on-disk state (runtime.json, decisions.jsonl, history.jsonl,
|
|
259
|
-
* .mail/) at once. `liveServeDaemonPid` already knows how to read this
|
|
260
|
-
* project's serve-heartbeat.json and tell a live daemon's heartbeat from a
|
|
261
|
-
* stale one (dead pid, or aged past a few missed 30s beats) — reused here
|
|
262
|
-
* as the actual pidfile lock, rather than inventing a second file, since it
|
|
263
|
-
* already IS a pidfile (pid + timestamp, written at startup and every
|
|
264
|
-
* heartbeat, removed on clean shutdown by `daemon.clearHeartbeat()`).
|
|
265
|
-
*
|
|
266
|
-
* Call this before the daemon/server/scheduler are constructed, so a
|
|
267
|
-
* refusal never opens a port, binds a broker lease, or touches any org's
|
|
268
|
-
* state. */
|
|
269
|
-
export function checkServeLock(cwd) {
|
|
270
|
-
const owner = liveServeDaemonPid(cwd);
|
|
271
|
-
if (owner != null)
|
|
272
|
-
return { ok: false, pid: owner };
|
|
273
|
-
// liveServeDaemonPid() has already ruled out a live owner — any heartbeat
|
|
274
|
-
// file still on disk here is stale (dead pid, or a SIGKILLed daemon that
|
|
275
|
-
// never reached clearHeartbeat()). Remove it so it can't be misread by
|
|
276
|
-
// `org run`/`org status` before this daemon's own writeHeartbeat() lands.
|
|
277
|
-
const heartbeatPath = join(cwd, '.monomind', 'serve-heartbeat.json');
|
|
278
|
-
const staleHeartbeatRemoved = existsSync(heartbeatPath);
|
|
279
|
-
if (staleHeartbeatRemoved)
|
|
280
|
-
rmSync(heartbeatPath, { force: true });
|
|
281
|
-
return { ok: true, staleHeartbeatRemoved };
|
|
282
|
-
}
|
|
283
|
-
const runAction = async (ctx) => {
|
|
284
|
-
// Org runs skip local embeddings entirely — on some machines
|
|
285
|
-
// @huggingface/transformers' native ONNX runtime crashes the whole
|
|
286
|
-
// process (a libc++abi terminate, not a catchable JS error) the moment
|
|
287
|
-
// any memory/KG lookup tries to load its model. A crashed unattended org
|
|
288
|
-
// run is much worse than one that falls back to keyword-only memory
|
|
289
|
-
// search — see the matching guards in memory-bridge.ts/embedding-operations.ts.
|
|
290
|
-
// The embedding-model crash above has a sibling: loadReranker() in
|
|
291
|
-
// memory-bridge.ts loads a SEPARATE cross-encoder model
|
|
292
|
-
// (cross-encoder/ettin-reranker-32m-v1, its own tokenizer architecture)
|
|
293
|
-
// for search-result reranking, independent of the embedder guard above —
|
|
294
|
-
// reranking runs on (query, passage) text pairs directly, so it can
|
|
295
|
-
// still fire and hit the same native crash even with embeddings off.
|
|
296
|
-
// disableLocalModels() turns off both, for THIS process only. It used to be
|
|
297
|
-
// MONOMIND_NO_LOCAL_EMBEDDINGS=1 / MONOMIND_RERANKER=0 on process.env, which
|
|
298
|
-
// every role's CLI and every command a role ran inherited — so a role's own
|
|
299
|
-
// `monomind memory search` silently fell back to keyword-only (#249).
|
|
300
|
-
const { disableLocalModels } = await import('../memory/memory-bridge.js');
|
|
301
|
-
disableLocalModels();
|
|
302
|
-
if (!ctx.args[0])
|
|
303
|
-
return { success: false, message: 'org name required: monomind org run <name> [--task "..."]' };
|
|
304
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
305
|
-
if (!validated.ok)
|
|
306
|
-
return validated.result;
|
|
307
|
-
const name = validated.name;
|
|
308
|
-
// A repeated --task flag is promoted to an array by the parser (deliberate,
|
|
309
|
-
// documented behavior elsewhere — repeats never silently drop a value); a
|
|
310
|
-
// plain `as string` cast would let that array flow straight into the org's
|
|
311
|
-
// goal and get stringified as "a,b" with no warning. Checked before any
|
|
312
|
-
// side effects (starting the xdeliver listener) run.
|
|
313
|
-
const taskFlag = ctx.flags.task;
|
|
314
|
-
if (Array.isArray(taskFlag))
|
|
315
|
-
return { success: false, message: '--task was passed more than once — pass it exactly once' };
|
|
316
|
-
const autoApprove = parseAutoApproveFlag(ctx.flags.autoApprove);
|
|
317
|
-
if ('error' in autoApprove)
|
|
318
|
-
return { success: false, message: autoApprove.error };
|
|
319
|
-
// Fail before any side effects (inbox server) when the org doesn't exist.
|
|
320
|
-
const orgsDir = join(ctx.cwd, ORG_DIR);
|
|
321
|
-
if (!existsSync(join(orgsDir, `${name}.json`))) {
|
|
322
|
-
const known = existsSync(orgsDir)
|
|
323
|
-
? listOrgConfigFiles(orgsDir).map((f) => f.replace(/\.json$/, ''))
|
|
324
|
-
: [];
|
|
325
|
-
log(output.error(`Org not found: ${name}${known.length ? ` — available: ${known.join(', ')}` : ' — create one with /mastermind:createorg'}`));
|
|
326
|
-
return { success: false, message: 'org not found' };
|
|
327
|
-
}
|
|
328
|
-
if (ctx.flags.dryRun === true) {
|
|
329
|
-
// Validate + preview each role's actual briefing without spawning sessions.
|
|
330
|
-
try {
|
|
331
|
-
const parsedDef = OrgDefSchema.parse(JSON.parse(readFileSync(join(orgsDir, `${name}.json`), 'utf8')));
|
|
332
|
-
const bp = resolveOrgDefBlueprints(parsedDef, ctx.cwd);
|
|
333
|
-
for (const n of bp.notes)
|
|
334
|
-
log(output.info(n));
|
|
335
|
-
if (bp.errors.length)
|
|
336
|
-
throw new Error(bp.errors.join('; '));
|
|
337
|
-
const def = bp.def;
|
|
338
|
-
const { buildRolePrompt, resolveRoleExtraGuidance } = await import('../orgrt/session.js');
|
|
339
|
-
const { agentRoles, endpointBriefingLines } = await import('../orgrt/endpoint-roles.js');
|
|
340
|
-
const { expandRolePromptVars, promptVarsFor } = await import('../orgrt/prompt-vars.js');
|
|
341
|
-
const roster = def.roles.map((r) => r.id);
|
|
342
|
-
// M2: endpoint roles get no session, so no briefing and no budget share.
|
|
343
|
-
const sessionRoles = agentRoles(def.roles);
|
|
344
|
-
const bossId = (sessionRoles.find((r) => r.type === 'boss' || r.reports_to === null) ?? sessionRoles[0])?.id;
|
|
345
|
-
const perRole = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / Math.max(1, sessionRoles.length));
|
|
346
|
-
// Same KG entity glossary the live daemon injects — the preview must
|
|
347
|
-
// match what sessions actually receive.
|
|
348
|
-
const glossary = await (async () => {
|
|
349
|
-
try {
|
|
350
|
-
const kg = await import('../memory/memory-kg.js');
|
|
351
|
-
const { orgKgScope } = await import('../orgrt/org-memory.js');
|
|
352
|
-
return await kg.kgGlossary({
|
|
353
|
-
dbPath: join(process.cwd(), '.monomind', 'org-memory'),
|
|
354
|
-
scope: orgKgScope(name),
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
catch {
|
|
358
|
-
return [];
|
|
359
|
-
}
|
|
360
|
-
})();
|
|
361
|
-
log(output.info(`DRY RUN — org ${name}: ${sessionRoles.length} roles, ${perRole} tokens each, goal: ${taskFlag ?? def.goal}`));
|
|
362
|
-
for (const role of sessionRoles) {
|
|
363
|
-
log(output.info(`\n─── ${role.id} (${role.title || role.type})${role.adapter_config?.model ? ` [${role.adapter_config.model}]` : ''} ───`));
|
|
364
|
-
log(buildRolePrompt(
|
|
365
|
-
// Same org root the live daemon uses (new OrgDaemon(ctx.cwd)).
|
|
366
|
-
expandRolePromptVars(role, promptVarsFor(ctx.cwd)), { name: def.name, goal: taskFlag ?? def.goal }, roster, glossary, resolveRoleExtraGuidance(role, ctx.cwd), role.id === bossId ? endpointBriefingLines(def) : undefined));
|
|
367
|
-
}
|
|
368
|
-
return { success: true, message: 'dry run complete — no sessions started' };
|
|
369
|
-
}
|
|
370
|
-
catch (err) {
|
|
371
|
-
log(output.error(`Config invalid: ${err instanceof Error ? err.message : String(err)}`));
|
|
372
|
-
return { success: false, message: 'invalid org config' };
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
// A live `org serve` daemon already owns this project's orgs. Starting our
|
|
376
|
-
// own here would put two processes on one runtime.json and one broker lease,
|
|
377
|
-
// so hand the request to the daemon via its runfile instead of racing it.
|
|
378
|
-
const serveOwner = liveServeDaemonPid(ctx.cwd);
|
|
379
|
-
if (serveOwner != null) {
|
|
380
|
-
// The runfile carries only the task; the serve daemon would drop the list.
|
|
381
|
-
if (autoApprove.tools.length)
|
|
382
|
-
return {
|
|
383
|
-
success: false,
|
|
384
|
-
message: `--auto-approve cannot be handed to the running "org serve" daemon (pid ${serveOwner}) — set the roles' policy.autoApproveTools instead`,
|
|
385
|
-
};
|
|
386
|
-
mkdirSync(join(orgsDir, name), { recursive: true });
|
|
387
|
-
// The task rides along in the runfile. Dropping it here would have made
|
|
388
|
-
// `org run <name> --task "..."` silently start a generic cycle — the flag
|
|
389
|
-
// accepted, the instruction discarded.
|
|
390
|
-
const runfile = join(orgsDir, name, 'run');
|
|
391
|
-
writeFileSync(runfile, JSON.stringify({ ts: Date.now(), task: taskFlag ?? null }), 'utf8');
|
|
392
|
-
// Ack: the serve daemon's runfile poll consumes (deletes) the file within
|
|
393
|
-
// one tick (~2s). The liveness check above is racy — a pid can die or be
|
|
394
|
-
// recycled between the check and the poll, leaving a runfile nobody reads
|
|
395
|
-
// while we report success. Verify consumption; on timeout, retract the
|
|
396
|
-
// runfile and fail loudly instead of losing the run.
|
|
397
|
-
const deadline = Date.now() + 15_000;
|
|
398
|
-
while (Date.now() < deadline && existsSync(runfile)) {
|
|
399
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
400
|
-
}
|
|
401
|
-
if (existsSync(runfile)) {
|
|
402
|
-
rmSync(runfile, { force: true });
|
|
403
|
-
log(output.error(`org ${name}: serve daemon (pid ${serveOwner}) did not pick up the run within 15s — it is dead or wedged.`));
|
|
404
|
-
log(output.info(`Remove the stale heartbeat (.monomind/serve-heartbeat.json) and retry, or start a fresh daemon with: monomind org serve`));
|
|
405
|
-
return { success: false, message: 'serve daemon did not acknowledge the run request' };
|
|
406
|
-
}
|
|
407
|
-
log(output.info(`org ${name}: start requested from the serve daemon (pid ${serveOwner}) — acknowledged`));
|
|
408
|
-
log(output.dim(` watch it with: monomind org logs ${name} --follow`));
|
|
409
|
-
return { success: true, message: 'start requested' };
|
|
410
|
-
}
|
|
411
|
-
const crossProcess = ctx.flags.crossProcess !== false;
|
|
412
|
-
// P1-1: v1 deprecation warning. Detect v1-shaped configs and warn.
|
|
413
|
-
// Gate on MONOMIND_V1_LEGACY=off to refuse v1 orgs entirely (patch-versioning-
|
|
414
|
-
// compliant mechanism — no minor bump needed).
|
|
415
|
-
const V1_ORG_KEYS = [
|
|
416
|
-
'topology',
|
|
417
|
-
'consensus',
|
|
418
|
-
'strategy',
|
|
419
|
-
'board_id',
|
|
420
|
-
'todo_col_id',
|
|
421
|
-
'doing_col_id',
|
|
422
|
-
'done_col_id',
|
|
423
|
-
'loop',
|
|
424
|
-
];
|
|
425
|
-
try {
|
|
426
|
-
const rawCfg = JSON.parse(readFileSync(join(orgsDir, `${name}.json`), 'utf8'));
|
|
427
|
-
const isV1 = V1_ORG_KEYS.some((k) => k in rawCfg);
|
|
428
|
-
if (isV1) {
|
|
429
|
-
const v1Legacy = process.env.MONOMIND_V1_LEGACY;
|
|
430
|
-
if (v1Legacy === 'off') {
|
|
431
|
-
log(output.error(`Org "${name}" uses the v1 config format, but MONOMIND_V1_LEGACY=off.`));
|
|
432
|
-
log(output.info(`Run "monomind org migrate ${name}" to upgrade to v2, or unset MONOMIND_V1_LEGACY to proceed anyway.`));
|
|
433
|
-
return { success: false, message: 'v1 org blocked by MONOMIND_V1_LEGACY=off' };
|
|
434
|
-
}
|
|
435
|
-
log(output.warning(`Org "${name}" uses the v1 config format (deprecated). It will be auto-migrated in-memory.`));
|
|
436
|
-
log(output.dim(` To silence: run "monomind org migrate ${name}". To block v1 orgs: set MONOMIND_V1_LEGACY=off.`));
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
/* config parse errors surface below in cost estimate or daemon.startOrg */
|
|
441
|
-
}
|
|
442
|
-
// P0-17: Upfront cost estimate. `org run` and `/mastermind:autodev` spend real
|
|
443
|
-
// provider tokens. Print an estimate before sessions start; honor --budget-usd
|
|
444
|
-
// as a hard stop and --yes to skip the confirmation prompt. Rates are defaults
|
|
445
|
-
// (per 1M tokens, blended estimate, Aug 2026); override via the model id.
|
|
446
|
-
//
|
|
447
|
-
// No provider Usage API is queried (that needs Admin/Org API credentials most
|
|
448
|
-
// users won't have configured) — rates are derived from the canonical
|
|
449
|
-
// MODEL_PRICING table (src/pricing/model-pricing.ts), using each model's
|
|
450
|
-
// output-token price as the blended per-1M estimate (output tokens dominate
|
|
451
|
-
// role-turn cost). Models not yet tracked in that table (third-party/non-
|
|
452
|
-
// Anthropic providers) fall back to the manually maintained EXTRA table
|
|
453
|
-
// below. A user-editable ~/.monomind/rates.json override, when present,
|
|
454
|
-
// takes precedence over both. Either way this is static data, not a live
|
|
455
|
-
// query, so a "stale rates" warning is always shown to make that
|
|
456
|
-
// limitation visible rather than implying live pricing.
|
|
457
|
-
const DERIVED_RATE_PER_1M = Object.fromEntries(Object.entries(MODEL_PRICING).map(([model, price]) => [model, price.out * 1_000_000]));
|
|
458
|
-
// Models absent from MODEL_PRICING (not yet in the canonical pricing table).
|
|
459
|
-
const EXTRA_MODEL_RATE_PER_1M = {
|
|
460
|
-
'gpt-4': 10,
|
|
461
|
-
'glm-5.2': 2,
|
|
462
|
-
'glm-4': 2,
|
|
463
|
-
'kimi-latest': 3,
|
|
464
|
-
'kimi-k2': 3,
|
|
465
|
-
'kimi-code/k3': 3,
|
|
466
|
-
'kimi-code/k3-256k': 3,
|
|
467
|
-
'gemini-3.1-pro': 8,
|
|
468
|
-
'gemini-3.6-flash-high': 1,
|
|
469
|
-
'gpt-5.6-terra': 10,
|
|
470
|
-
'gpt-5.5': 10,
|
|
471
|
-
};
|
|
472
|
-
const MODEL_RATE_PER_1M = {
|
|
473
|
-
...EXTRA_MODEL_RATE_PER_1M,
|
|
474
|
-
...DERIVED_RATE_PER_1M,
|
|
475
|
-
};
|
|
476
|
-
const DEFAULT_RATE_PER_1M = 10;
|
|
477
|
-
const AVG_TOKENS_PER_TURN = 2000;
|
|
478
|
-
const budgetUsd = ctx.flags.budgetUsd;
|
|
479
|
-
const skipConfirm = ctx.flags.yes === true;
|
|
480
|
-
// User-editable rate overrides: ~/.monomind/rates.json, e.g.
|
|
481
|
-
// { "claude-opus-5": 90, "my-custom-model": 5 }
|
|
482
|
-
const ratesPath = join(homedir(), '.monomind', 'rates.json');
|
|
483
|
-
const userRates = {};
|
|
484
|
-
let ratesFileUsed = false;
|
|
485
|
-
try {
|
|
486
|
-
const parsed = JSON.parse(readFileSync(ratesPath, 'utf8'));
|
|
487
|
-
if (parsed && typeof parsed === 'object') {
|
|
488
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
489
|
-
if (typeof v === 'number')
|
|
490
|
-
userRates[k] = v;
|
|
491
|
-
}
|
|
492
|
-
ratesFileUsed = Object.keys(userRates).length > 0;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
catch {
|
|
496
|
-
/* no rates.json (or invalid) — hardcoded defaults only */
|
|
497
|
-
}
|
|
498
|
-
try {
|
|
499
|
-
const def = OrgDefSchema.parse(JSON.parse(readFileSync(join(orgsDir, `${name}.json`), 'utf8')));
|
|
500
|
-
const defaultMaxTurns = def.run_config.max_turns_per_message ?? 30;
|
|
501
|
-
// Estimate against a realistic planning ceiling, NOT the runtime limit —
|
|
502
|
-
// the schema default is effectively unlimited (DEFAULT_MAX_TURNS_PER_MESSAGE),
|
|
503
|
-
// which would balloon the upfront figure into meaninglessness.
|
|
504
|
-
const ESTIMATE_TURNS_CAP = 30;
|
|
505
|
-
let _totalTokens = 0;
|
|
506
|
-
const perRoleRows = def.roles.map((r) => {
|
|
507
|
-
// Mirror the actual runtime's model resolution (session.ts resolveModel)
|
|
508
|
-
// instead of a hardcoded 'claude-sonnet-5' fallback — otherwise every
|
|
509
|
-
// role without an explicit adapter_config.model (kimicode, antigravity,
|
|
510
|
-
// vercel roles relying on their runtime default) is mislabeled here.
|
|
511
|
-
// ADR-O001 D8: fold the role's cost tier in, at the same precedence the
|
|
512
|
-
// runtime uses (explicit model > tier > runtime/vendor default) — an
|
|
513
|
-
// estimate that ignored the tier would quote the untiered price of a run
|
|
514
|
-
// that is about to cost ~3x less.
|
|
515
|
-
const model = String(r.adapter_config?.model ??
|
|
516
|
-
resolveRoleCostTier({ role: r, def })?.model ??
|
|
517
|
-
resolveModel(r, r.runtime ?? def.runtime, r.provider?.vendor));
|
|
518
|
-
const rate = userRates[model] ?? MODEL_RATE_PER_1M[model] ?? DEFAULT_RATE_PER_1M;
|
|
519
|
-
const roleTurns = Math.min(r.max_turns_per_message ?? defaultMaxTurns, ESTIMATE_TURNS_CAP);
|
|
520
|
-
const tokens = roleTurns * AVG_TOKENS_PER_TURN;
|
|
521
|
-
_totalTokens += tokens;
|
|
522
|
-
return { id: r.id, model, tokens, cost: (tokens * rate) / 1_000_000 };
|
|
523
|
-
});
|
|
524
|
-
const estimate = perRoleRows.reduce((s, r) => s + r.cost, 0);
|
|
525
|
-
log(output.bold('\nCost estimate'));
|
|
526
|
-
log(output.dim(` (roles × max_turns × ~${AVG_TOKENS_PER_TURN} tokens/turn × model rate; estimated at ≤${ESTIMATE_TURNS_CAP} turns/message — the runtime default is effectively unlimited; ${ratesFileUsed ? `rates.json overrides + ` : ''}static defaults, will vary with real usage)`));
|
|
527
|
-
log(output.warning(` ⚠ stale rates: no live provider pricing lookup — ${ratesFileUsed ? `using ~/.monomind/rates.json + ` : ''}hardcoded table (edit ~/.monomind/rates.json to override)`));
|
|
528
|
-
for (const r of perRoleRows) {
|
|
529
|
-
log(` ${r.id.padEnd(20)} ${r.model.padEnd(22)} ~$${r.cost.toFixed(2)}`);
|
|
530
|
-
}
|
|
531
|
-
log(` ${output.bold('Total estimate:'.padEnd(28))} ~$${estimate.toFixed(2)}`);
|
|
532
|
-
if (budgetUsd != null && estimate > budgetUsd) {
|
|
533
|
-
log(output.error(`Estimate $${estimate.toFixed(2)} exceeds --budget-usd $${budgetUsd}. Aborting before any tokens are spent.`));
|
|
534
|
-
return { success: false, message: 'cost estimate exceeded --budget-usd' };
|
|
535
|
-
}
|
|
536
|
-
if (!skipConfirm && process.stdin.isTTY) {
|
|
537
|
-
const { confirm } = await import('../prompt.js');
|
|
538
|
-
const ok = await confirm({
|
|
539
|
-
message: `Start ${def.roles.length}-role org? This will spend real provider tokens.`,
|
|
540
|
-
default: true,
|
|
541
|
-
});
|
|
542
|
-
if (!ok) {
|
|
543
|
-
log(output.dim('Aborted — no tokens spent.'));
|
|
544
|
-
return { success: false, message: 'user declined cost-estimate prompt' };
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
catch (err) {
|
|
549
|
-
// If the config can't be parsed here, daemon.startOrg below will surface a
|
|
550
|
-
// proper error. Don't double-report — just skip the estimate.
|
|
551
|
-
if (process.env.DEBUG || process.env.MONOMIND_DEBUG) {
|
|
552
|
-
log(output.dim(`(cost estimate skipped: ${err instanceof Error ? err.message : String(err)})`));
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
const resumeFlag = ctx.flags.resume === true;
|
|
556
|
-
const daemon = new OrgDaemon(ctx.cwd, { crossProcess });
|
|
557
|
-
let srv;
|
|
558
|
-
if (crossProcess) {
|
|
559
|
-
srv = await startOrgServer(daemon, 0);
|
|
560
|
-
daemon.setInboxUrl(`http://127.0.0.1:${srv.port}`, srv.operatorCredential);
|
|
561
|
-
}
|
|
562
|
-
let running;
|
|
563
|
-
try {
|
|
564
|
-
running = await daemon.startOrg(name, taskFlag, {
|
|
565
|
-
resume: resumeFlag,
|
|
566
|
-
autoApprove: autoApprove.tools,
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
catch (err) {
|
|
570
|
-
// Don't leave the inbox server holding the event loop open on a failed start.
|
|
571
|
-
srv?.close();
|
|
572
|
-
await daemon.stopAll().catch(() => {
|
|
573
|
-
/* nothing started */
|
|
574
|
-
});
|
|
575
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
576
|
-
const hint = err instanceof Error && err.name === 'ZodError'
|
|
577
|
-
? ` — run "monomind org validate ${name}" for details`
|
|
578
|
-
: '';
|
|
579
|
-
log(output.error(`Could not start org ${name}: ${detail}${hint}`));
|
|
580
|
-
return { success: false, message: 'org start failed' };
|
|
581
|
-
}
|
|
582
|
-
log(output.info(`org ${name} running (${running.def.roles.length} agents, run ${running.run}) — Ctrl-C or "monomind org stop ${name}" to stop`));
|
|
583
|
-
// #345: a role waiting on a tool approval is otherwise silent in this log.
|
|
584
|
-
running.bus.subscribe((e) => {
|
|
585
|
-
const notice = approvalPendingNotice(name, e);
|
|
586
|
-
if (notice)
|
|
587
|
-
log(output.warning(notice));
|
|
588
|
-
});
|
|
589
|
-
// The run log's last line: outcome, wall time, total cost (every exit path
|
|
590
|
-
// below, crash handlers included), so a detached run's log has an ending.
|
|
591
|
-
const startedAt = Date.now();
|
|
592
|
-
const printRunEnd = (final, how) => {
|
|
593
|
-
try {
|
|
594
|
-
const events = readRunEvents(ctx.cwd, name, running.run);
|
|
595
|
-
const wallMs = Date.now() - startedAt;
|
|
596
|
-
log(output.info(runEndLine({ name, run: running.run, wallMs, final, events, ...how })));
|
|
597
|
-
}
|
|
598
|
-
catch {
|
|
599
|
-
/* best-effort — never mask the run's real exit */
|
|
600
|
-
}
|
|
601
|
-
};
|
|
602
|
-
// #206 follow-up: without this, an uncaught error in this process left
|
|
603
|
-
// runtime.json's status stuck at 'running' (finishStop never runs), and
|
|
604
|
-
// runOutcomeResult's status === 'crashed' branch — the one meant to
|
|
605
|
-
// surface *why* the run failed — could never actually fire for `org run`,
|
|
606
|
-
// since nothing here ever wrote 'crashed'. Mirrors serveAction's
|
|
607
|
-
// crashExit, but deliberately does NOT touch SIGINT/SIGTERM — those are
|
|
608
|
-
// already handled by the wait loop below as a graceful stop, and
|
|
609
|
-
// registering a second, competing handler here would race it.
|
|
610
|
-
process.on('uncaughtException', (err) => {
|
|
611
|
-
try {
|
|
612
|
-
console.error('[org run] uncaughtException:', err);
|
|
613
|
-
}
|
|
614
|
-
catch {
|
|
615
|
-
/* stderr gone */
|
|
616
|
-
}
|
|
617
|
-
const error = `uncaughtException: ${err instanceof Error ? err.message : String(err)}`;
|
|
618
|
-
daemon.persistCrashStateAll(error);
|
|
619
|
-
printRunEnd({ status: 'crashed', error }, {});
|
|
620
|
-
process.exit(1);
|
|
621
|
-
});
|
|
622
|
-
process.on('unhandledRejection', (err) => {
|
|
623
|
-
try {
|
|
624
|
-
console.error('[org run] unhandledRejection:', err);
|
|
625
|
-
}
|
|
626
|
-
catch {
|
|
627
|
-
/* stderr gone */
|
|
628
|
-
}
|
|
629
|
-
const error = `unhandledRejection: ${err instanceof Error ? err.message : String(err)}`;
|
|
630
|
-
daemon.persistCrashStateAll(error);
|
|
631
|
-
printRunEnd({ status: 'crashed', error }, {});
|
|
632
|
-
process.exit(1);
|
|
633
|
-
});
|
|
634
|
-
// P1-12: Print the dashboard URL so CLI users know where to look.
|
|
635
|
-
// The dashboard is normally spawned by a Claude Code SessionStart hook
|
|
636
|
-
// (.claude/helpers/control-start.cjs) — but `org run` doesn't require
|
|
637
|
-
// Claude Code, and even when the hook exists it only fires once at
|
|
638
|
-
// session start, not per org run. If control.json is stale (points at a
|
|
639
|
-
// dead pid, a server rooted in a different project, or one that no longer
|
|
640
|
-
// accepts our dashboard-token — the exact case control-start.cjs's own
|
|
641
|
-
// "already running" check now self-heals, see its staleAuth handling),
|
|
642
|
-
// `org run` used to just print whatever URL was on file with zero
|
|
643
|
-
// verification. Actively (re)run the same control-start.cjs the hook
|
|
644
|
-
// uses, from this project's own .claude/helpers/ if it's been set up
|
|
645
|
-
// (monomind init), so a stale/dead/mismatched dashboard gets healed on
|
|
646
|
-
// every org run instead of silently trusting old state.
|
|
647
|
-
const controlPath = join(ctx.cwd, '.monomind', 'control.json');
|
|
648
|
-
const controlStartPath = join(ctx.cwd, '.claude', 'helpers', 'control-start.cjs');
|
|
649
|
-
if (existsSync(controlStartPath)) {
|
|
650
|
-
try {
|
|
651
|
-
const { spawnSync } = await import('node:child_process');
|
|
652
|
-
spawnSync(process.execPath, [controlStartPath], {
|
|
653
|
-
cwd: ctx.cwd,
|
|
654
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: ctx.cwd, MONOMIND_HOOK_QUIET: '1' },
|
|
655
|
-
timeout: 5000,
|
|
656
|
-
stdio: 'ignore',
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
catch {
|
|
660
|
-
/* best-effort — fall through to whatever control.json already has */
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
if (existsSync(controlPath)) {
|
|
664
|
-
try {
|
|
665
|
-
const ctl = JSON.parse(readFileSync(controlPath, 'utf8'));
|
|
666
|
-
const dashUrl = ctl.url || (ctl.port ? `http://localhost:${ctl.port}` : 'http://localhost:4242');
|
|
667
|
-
log(output.dim(` Dashboard: ${dashUrl}`));
|
|
668
|
-
}
|
|
669
|
-
catch {
|
|
670
|
-
/* non-critical */
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
else if (existsSync(controlStartPath)) {
|
|
674
|
-
// control-start.cjs ran above (spawnSync'd synchronously with a 5s cap)
|
|
675
|
-
// but control.json still doesn't exist — its own confirm-mode child is
|
|
676
|
-
// still working in the background (npx cold-resolve etc., #142/#144)
|
|
677
|
-
// rather than having failed outright. Point at the default port; the
|
|
678
|
-
// confirm process will correct control.json once it lands.
|
|
679
|
-
log(output.dim(' Dashboard: http://localhost:4242 (starting — check back in a few seconds if unreachable)'));
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
log(output.dim(' Dashboard: run `monomind init` to set up .claude/helpers/, then re-run to launch it automatically'));
|
|
683
|
-
}
|
|
684
|
-
// stopfile poll lets `org stop` work from another terminal; the daemon can
|
|
685
|
-
// also stop the org itself (boss called org_complete, or the idle watchdog
|
|
686
|
-
// fired) — detect that via getOrg() so the CLI exits instead of polling a
|
|
687
|
-
// stopfile forever after a finished run. Clear any stale stop or reload
|
|
688
|
-
// request from a previous run before polling.
|
|
689
|
-
clearStopfile(ctx.cwd, name);
|
|
690
|
-
clearReloadfile(ctx.cwd, name);
|
|
691
|
-
// #206: a human explicitly running `monomind org stop` is a deliberate,
|
|
692
|
-
// successful action regardless of how the run itself ended — capture that
|
|
693
|
-
// BEFORE clearStopfile() below wipes the file, so it isn't lost.
|
|
694
|
-
const { stoppedManually, signal } = await waitForRunEnd(ctx.cwd, name, daemon);
|
|
695
|
-
clearStopfile(ctx.cwd, name);
|
|
696
|
-
await daemon.stopAll();
|
|
697
|
-
srv?.close();
|
|
698
|
-
printRunEnd(runtimeState(ctx.cwd, name), { stoppedManually, signal });
|
|
699
|
-
if (stoppedManually)
|
|
700
|
-
return { success: true, message: `org ${name} stopped` };
|
|
701
|
-
// #206: 'org run' used to exit 0 unconditionally here — a crashed or
|
|
702
|
-
// watchdog-stopped run was indistinguishable from a completed one to any
|
|
703
|
-
// script or supervisor (launchd/systemd) driving off the exit code. Re-read
|
|
704
|
-
// the daemon's final record (same runtime.json pattern isOrgRunning/
|
|
705
|
-
// statusAction already use below) and only report success for a run that
|
|
706
|
-
// actually finished its goal via org_complete.
|
|
707
|
-
return runOutcomeResult(name, runtimeState(ctx.cwd, name));
|
|
708
|
-
};
|
|
709
|
-
/** The foreground `org run` wait loop. Every `intervalMs` it ends the wait
|
|
710
|
-
* when `org stop` wrote the stopfile (stoppedManually) or the daemon no
|
|
711
|
-
* longer hosts the org (org_complete, idle watchdog, crash), and otherwise
|
|
712
|
-
* applies a pending `org reload`. Before the reload poll was added here only
|
|
713
|
-
* `org serve` read the reload file, so `org reload` against an `org run`
|
|
714
|
-
* process printed "picks it up within ~2s", exited 0, and changed nothing —
|
|
715
|
-
* a rotated endpoint URL kept receiving deliveries until it went dead.
|
|
716
|
-
* SIGINT/SIGTERM also end the wait (reported as `signal: true`). */
|
|
717
|
-
export async function waitForRunEnd(cwd, name, daemon, intervalMs = 2000) {
|
|
718
|
-
const stopfile = join(cwd, ORG_DIR, name, 'stop');
|
|
719
|
-
return new Promise((resolvePromise) => {
|
|
720
|
-
const finish = (stoppedManually, signal = false) => {
|
|
721
|
-
clearInterval(iv);
|
|
722
|
-
process.removeListener('SIGINT', onSignal);
|
|
723
|
-
process.removeListener('SIGTERM', onSignal);
|
|
724
|
-
resolvePromise(signal ? { stoppedManually, signal } : { stoppedManually });
|
|
725
|
-
};
|
|
726
|
-
const onSignal = () => finish(false, true);
|
|
727
|
-
const iv = setInterval(() => {
|
|
728
|
-
if (existsSync(stopfile)) {
|
|
729
|
-
finish(true);
|
|
730
|
-
}
|
|
731
|
-
else if (!daemon.getOrg(name)) {
|
|
732
|
-
finish(false);
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
pollReloadfiles(cwd, daemon).catch((err) => {
|
|
736
|
-
console.error('[org run] reloadfile poll failed:', err);
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
}, intervalMs);
|
|
740
|
-
process.once('SIGINT', onSignal);
|
|
741
|
-
process.once('SIGTERM', onSignal);
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
/** runtime.json's final record; {} when unreadable (the non-clean-stop case). */
|
|
745
|
-
const runtimeState = (cwd, name) => {
|
|
746
|
-
try {
|
|
747
|
-
return JSON.parse(readFileSync(join(cwd, ORG_DIR, name, 'runtime.json'), 'utf8'));
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
return {};
|
|
751
|
-
}
|
|
752
|
-
};
|
|
753
|
-
/** Decides `org run`'s exit-code-bearing CommandResult from the run's final
|
|
754
|
-
* recorded state. Extracted (matching resolvedIdleNudgeCount's precedent for
|
|
755
|
-
* the idle watchdog) so this decision is unit-testable without spinning up a
|
|
756
|
-
* real daemon/org. Exit 0 ONLY for a clean, goal-driven end (closedBy:
|
|
757
|
-
* 'org-complete', set by daemon.ts's org-complete auto-stop path) — every
|
|
758
|
-
* other outcome (idle-watchdog stop, boss-restart-exhausted, a process-level
|
|
759
|
-
* crash) exits 1 so scripts/supervisors can tell success from failure. */
|
|
760
|
-
export function runOutcomeResult(name, final) {
|
|
761
|
-
if (final.closedBy === 'org-complete') {
|
|
762
|
-
return { success: true, message: `org ${name} completed` };
|
|
763
|
-
}
|
|
764
|
-
if (final.status === 'crashed') {
|
|
765
|
-
return {
|
|
766
|
-
success: false,
|
|
767
|
-
message: `org ${name} crashed: ${final.error ?? 'unknown error'}`,
|
|
768
|
-
exitCode: 1,
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
return {
|
|
772
|
-
success: false,
|
|
773
|
-
message: `org ${name} stopped without completing (not via org_complete) — check 'monomind org status ${name}' or the run history for the reason`,
|
|
774
|
-
exitCode: 1,
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
/** True when runtime.json records a running org whose recorded pid is still alive. */
|
|
778
|
-
const isOrgRunning = (cwd, name) => {
|
|
779
|
-
try {
|
|
780
|
-
const rt = JSON.parse(readFileSync(join(cwd, ORG_DIR, name, 'runtime.json'), 'utf8'));
|
|
781
|
-
if (rt.status !== 'running' || !rt.pid)
|
|
782
|
-
return false;
|
|
783
|
-
process.kill(rt.pid, 0); // throws if the pid is gone (crashed daemon left a stale file)
|
|
784
|
-
return true;
|
|
785
|
-
}
|
|
786
|
-
catch {
|
|
787
|
-
return false;
|
|
788
|
-
}
|
|
789
|
-
};
|
|
790
|
-
const stopAction = async (ctx) => {
|
|
791
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
792
|
-
if (!validated.ok)
|
|
793
|
-
return validated.result;
|
|
794
|
-
const name = validated.name;
|
|
795
|
-
if (!existsSync(join(ctx.cwd, ORG_DIR, `${name}.json`))) {
|
|
796
|
-
log(output.error(`Org not found: ${name}`));
|
|
797
|
-
return { success: false, message: 'org not found' };
|
|
798
|
-
}
|
|
799
|
-
// The stopfile is only meaningful to a process that polls it (`org run` and, since
|
|
800
|
-
// this fix, `org serve`). Writing it for an org that nothing is running was a silent
|
|
801
|
-
// no-op that still reported "daemon exits within 2s" — say what's actually true.
|
|
802
|
-
let rt;
|
|
803
|
-
try {
|
|
804
|
-
rt = JSON.parse(readFileSync(join(ctx.cwd, ORG_DIR, name, 'runtime.json'), 'utf8'));
|
|
805
|
-
}
|
|
806
|
-
catch {
|
|
807
|
-
/* never run */
|
|
808
|
-
}
|
|
809
|
-
if (rt?.status !== 'running') {
|
|
810
|
-
log(output.warning(`Org "${name}" is not running (runtime state: ${rt?.status ?? 'never run'}) — nothing to stop.`));
|
|
811
|
-
return { success: false, message: 'org not running' };
|
|
812
|
-
}
|
|
813
|
-
if (rt.pid) {
|
|
814
|
-
let alive = true;
|
|
815
|
-
try {
|
|
816
|
-
process.kill(rt.pid, 0);
|
|
817
|
-
}
|
|
818
|
-
catch {
|
|
819
|
-
alive = false;
|
|
820
|
-
}
|
|
821
|
-
if (!alive) {
|
|
822
|
-
log(output.warning(`Org "${name}" is not running — runtime.json says running but pid ${rt.pid} is gone (crashed daemon).`));
|
|
823
|
-
log(output.info(`Clear the stale record with: monomind org mark-complete ${name}`));
|
|
824
|
-
return { success: false, message: 'org crashed — use mark-complete' };
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
mkdirSync(join(ctx.cwd, ORG_DIR, name), { recursive: true });
|
|
828
|
-
writeFileSync(join(ctx.cwd, ORG_DIR, name, 'stop'), new Date().toISOString());
|
|
829
|
-
log(output.info(`Stop requested for "${name}" (pid ${rt.pid}) — the daemon picks it up within ~2s.`));
|
|
830
|
-
return { success: true, message: `stop requested for ${name} (daemon exits within 2s)` };
|
|
831
|
-
};
|
|
832
|
-
const pauseAction = async (ctx) => {
|
|
833
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
834
|
-
if (!validated.ok)
|
|
835
|
-
return validated.result;
|
|
836
|
-
const name = validated.name;
|
|
837
|
-
if (!existsSync(join(ctx.cwd, ORG_DIR, `${name}.json`))) {
|
|
838
|
-
log(output.error(`Org not found: ${name}`));
|
|
839
|
-
return { success: false, message: 'org not found' };
|
|
840
|
-
}
|
|
841
|
-
if (isOrgPaused(ctx.cwd, name)) {
|
|
842
|
-
log(output.warning(`Org "${name}" is already paused.`));
|
|
843
|
-
return { success: true, message: 'already paused' };
|
|
844
|
-
}
|
|
845
|
-
mkdirSync(join(ctx.cwd, ORG_DIR, name), { recursive: true });
|
|
846
|
-
writeFileSync(join(ctx.cwd, ORG_DIR, name, 'pause'), new Date().toISOString());
|
|
847
|
-
log(output.info(`Org "${name}" paused — current turns will finish, no new cycles will start. Resume with: monomind org resume ${name}`));
|
|
848
|
-
return { success: true, message: `org ${name} paused` };
|
|
849
|
-
};
|
|
850
|
-
const resumeAction = async (ctx) => {
|
|
851
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
852
|
-
if (!validated.ok)
|
|
853
|
-
return validated.result;
|
|
854
|
-
const name = validated.name;
|
|
855
|
-
if (!isOrgPaused(ctx.cwd, name)) {
|
|
856
|
-
log(output.warning(`Org "${name}" is not paused.`));
|
|
857
|
-
return { success: true, message: 'not paused' };
|
|
858
|
-
}
|
|
859
|
-
clearPausefile(ctx.cwd, name);
|
|
860
|
-
log(output.info(`Org "${name}" resumed — next scheduled tick will start a cycle.`));
|
|
861
|
-
return { success: true, message: `org ${name} resumed` };
|
|
862
|
-
};
|
|
863
|
-
const reloadAction = async (ctx) => {
|
|
864
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
865
|
-
if (!validated.ok)
|
|
866
|
-
return validated.result;
|
|
867
|
-
const name = validated.name;
|
|
868
|
-
// The reload signal is a file the running daemon polls — same pattern as stop/pause.
|
|
869
|
-
mkdirSync(join(ctx.cwd, ORG_DIR, name), { recursive: true });
|
|
870
|
-
writeFileSync(join(ctx.cwd, ORG_DIR, name, 'reload'), new Date().toISOString());
|
|
871
|
-
log(output.info(`Reload requested for "${name}" — the daemon picks it up within ~2s.`));
|
|
872
|
-
return { success: true, message: `reload requested for ${name}` };
|
|
873
|
-
};
|
|
874
|
-
const statusAction = async (ctx) => {
|
|
875
|
-
let name;
|
|
876
|
-
if (ctx.args[0]) {
|
|
877
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
878
|
-
if (!validated.ok)
|
|
879
|
-
return validated.result;
|
|
880
|
-
name = validated.name;
|
|
881
|
-
// A named org that doesn't exist must error, not report "never run" with exit 0.
|
|
882
|
-
if (!existsSync(join(ctx.cwd, ORG_DIR, `${name}.json`))) {
|
|
883
|
-
log(output.error(`Org not found: ${name}`));
|
|
884
|
-
return { success: false, message: 'org not found' };
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// Protocol JSON mode (agent-exec-protocol.md §7.2): compact runtime state
|
|
888
|
-
// per org — the same liveness rule as the human path (a 'running' record
|
|
889
|
-
// whose pid is gone is a crash, not a running org).
|
|
890
|
-
if (ctx.flags.format === 'json') {
|
|
891
|
-
const orgDir = join(ctx.cwd, ORG_DIR);
|
|
892
|
-
const targets = name
|
|
893
|
-
? [name]
|
|
894
|
-
: existsSync(orgDir)
|
|
895
|
-
? listOrgConfigFiles(orgDir).map((f) => f.replace(/\.json$/, ''))
|
|
896
|
-
: [];
|
|
897
|
-
const readState = (t) => {
|
|
898
|
-
const rt = join(orgDir, t, 'runtime.json');
|
|
899
|
-
if (!existsSync(rt))
|
|
900
|
-
return { name: t, status: 'never run' };
|
|
901
|
-
try {
|
|
902
|
-
const st = JSON.parse(readFileSync(rt, 'utf8'));
|
|
903
|
-
let status = st.status ?? 'never run';
|
|
904
|
-
// #274: an 'idle' run is still a running run to every protocol
|
|
905
|
-
// consumer — only a genuine crash changes the reported status.
|
|
906
|
-
if (status === 'running' || status === 'crashed') {
|
|
907
|
-
if (classifyRun(ctx.cwd, t, st).state === 'crashed')
|
|
908
|
-
status = 'crashed';
|
|
909
|
-
}
|
|
910
|
-
return {
|
|
911
|
-
name: t,
|
|
912
|
-
status,
|
|
913
|
-
run: st.run,
|
|
914
|
-
pid: st.pid,
|
|
915
|
-
paused: isOrgPaused(ctx.cwd, t),
|
|
916
|
-
abandoned_roles: st.abandonedRoles ?? [],
|
|
917
|
-
closed_by: st.closedBy,
|
|
918
|
-
error: st.error,
|
|
919
|
-
// #293: present only when the run's cross-run memory was not stored.
|
|
920
|
-
memory_error: st.memoryError,
|
|
921
|
-
// #296: when the idle watchdog will stop a running org, or why it won't.
|
|
922
|
-
...(status === 'running' ? readIdleStatus(ctx.cwd, t, st.run) : {}),
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
catch {
|
|
926
|
-
return { name: t, status: 'unreadable-runtime' };
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
const items = targets.map(readState);
|
|
930
|
-
// Named org → bare singleton; no name → list envelope (protocol §7.1).
|
|
931
|
-
process.stdout.write(`${JSON.stringify(name ? { v: 1, ...items[0] } : { v: 1, items })}\n`);
|
|
932
|
-
return { success: true };
|
|
933
|
-
}
|
|
934
|
-
const orgDir = join(ctx.cwd, ORG_DIR);
|
|
935
|
-
const targets = name
|
|
936
|
-
? [name]
|
|
937
|
-
: existsSync(orgDir)
|
|
938
|
-
? listOrgConfigFiles(orgDir).map((f) => f.replace(/\.json$/, ''))
|
|
939
|
-
: [];
|
|
940
|
-
for (const t of targets) {
|
|
941
|
-
const rt = join(orgDir, t, 'runtime.json');
|
|
942
|
-
let state = {
|
|
943
|
-
status: 'never run',
|
|
944
|
-
};
|
|
945
|
-
if (existsSync(rt)) {
|
|
946
|
-
try {
|
|
947
|
-
state = JSON.parse(readFileSync(rt, 'utf8'));
|
|
948
|
-
}
|
|
949
|
-
catch (err) {
|
|
950
|
-
log(output.warning(`${t}: could not read runtime.json (${err instanceof Error ? err.message : 'corrupt/truncated file'})`));
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
// A "running" record with no sign of life left means the daemon died
|
|
955
|
-
// without its stopOrg cleanup — surface that instead of reporting it as
|
|
956
|
-
// still running. A stale pid alone is NOT that proof (#274).
|
|
957
|
-
let liveness;
|
|
958
|
-
if (state.status === 'running' || state.status === 'crashed') {
|
|
959
|
-
liveness = classifyRun(ctx.cwd, t, state);
|
|
960
|
-
if (liveness.state === 'crashed') {
|
|
961
|
-
let heartbeatHint = '';
|
|
962
|
-
try {
|
|
963
|
-
const hb = JSON.parse(readFileSync(join(ctx.cwd, '.monomind', 'serve-heartbeat.json'), 'utf8'));
|
|
964
|
-
heartbeatHint = ` (last heartbeat: ${hb.updatedAt})`;
|
|
965
|
-
}
|
|
966
|
-
catch {
|
|
967
|
-
/* no heartbeat file — daemon predates this change or was already cleaned up */
|
|
968
|
-
}
|
|
969
|
-
const closedBy = state.closedBy;
|
|
970
|
-
const label = closedBy === 'crash-handler'
|
|
971
|
-
? 'crashed (caught by crash handler)'
|
|
972
|
-
: `crashed (runtime.json says ${state.status} but pid ${state.pid} is gone and the run has been silent)`;
|
|
973
|
-
log(output.warning(`${t}: ${label}${heartbeatHint}${state.run ? ` — run ${state.run}` : ''} — close it out with "monomind org mark-complete ${t}"`));
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
const paused = isOrgPaused(ctx.cwd, t);
|
|
978
|
-
const statusLabel = state.status === 'running'
|
|
979
|
-
? paused
|
|
980
|
-
? 'running (PAUSED)'
|
|
981
|
-
: liveness?.state === 'idle'
|
|
982
|
-
? 'running (idle)'
|
|
983
|
-
: 'running'
|
|
984
|
-
: state.status;
|
|
985
|
-
// Say when the recorded pid is no longer the thing proving it alive, so a
|
|
986
|
-
// pid that doesn't match any process isn't a silent mystery.
|
|
987
|
-
const staleHint = liveness?.evidence && liveness.evidence !== 'pid'
|
|
988
|
-
? ` — recorded pid ${state.pid} is stale; still live per ${liveness.evidence}`
|
|
989
|
-
: '';
|
|
990
|
-
const line = `${t}: ${statusLabel}${state.run ? ` (run ${state.run}, pid ${state.pid})` : ''}${staleHint}`;
|
|
991
|
-
// A role that never spawned is a silent capability hole — an org with no
|
|
992
|
-
// tester still reports a clean "running". Say it on the status line.
|
|
993
|
-
if (state.abandonedRoles?.length) {
|
|
994
|
-
log(output.warning(`${line} — DEGRADED: ${state.abandonedRoles.length} role(s) never spawned: ${state.abandonedRoles.join(', ')}`));
|
|
995
|
-
}
|
|
996
|
-
else {
|
|
997
|
-
log(output.info(line));
|
|
998
|
-
}
|
|
999
|
-
// #293: cross-run memory that was never written. Without this the only
|
|
1000
|
-
// symptom is org_recall coming back empty runs later, which points nowhere
|
|
1001
|
-
// near a memory backend that failed to load.
|
|
1002
|
-
const memoryError = state.memoryError;
|
|
1003
|
-
if (memoryError) {
|
|
1004
|
-
log(output.warning(` org memory: last run was NOT saved (${memoryError}) — org_recall will not find it`));
|
|
1005
|
-
}
|
|
1006
|
-
// Enriched progress for running orgs
|
|
1007
|
-
if (state.status === 'running' && state.run) {
|
|
1008
|
-
// #296: when the idle watchdog will stop this org, or why it won't.
|
|
1009
|
-
// Say nothing for 'unknown' (no record yet) rather than a misleading zero.
|
|
1010
|
-
const idle = readIdleStatus(ctx.cwd, t, state.run);
|
|
1011
|
-
if (idle.idle_stop_at) {
|
|
1012
|
-
log(` idle stop: in ${fmtDuration(idle.idle_stop_in_seconds * 1000)} (at ${utcTime(Date.parse(idle.idle_stop_at))})`);
|
|
1013
|
-
}
|
|
1014
|
-
else if (idle.idle_hold === 'disabled') {
|
|
1015
|
-
log(` idle watchdog: disabled`);
|
|
1016
|
-
}
|
|
1017
|
-
else if (idle.idle_hold && idle.idle_hold !== 'unknown') {
|
|
1018
|
-
const until = idle.idle_hold_until
|
|
1019
|
-
? ` until ${utcTime(Date.parse(idle.idle_hold_until))}`
|
|
1020
|
-
: '';
|
|
1021
|
-
log(` idle stop: held — ${idle.idle_hold}${until}`);
|
|
1022
|
-
}
|
|
1023
|
-
const events = readRunEvents(ctx.cwd, t, state.run);
|
|
1024
|
-
if (events.length) {
|
|
1025
|
-
const summary = summarizeRun(events);
|
|
1026
|
-
const elapsed = summary.startedAt ? Date.now() - summary.startedAt : null;
|
|
1027
|
-
const elapsedStr = elapsed !== null ? fmtDuration(elapsed) : '?';
|
|
1028
|
-
const lastTs = events[events.length - 1].ts;
|
|
1029
|
-
const quietMs = Date.now() - lastTs;
|
|
1030
|
-
const quietStr = fmtDuration(quietMs);
|
|
1031
|
-
const toolCalls = Object.values(summary.roles).reduce((a, r) => a + r.toolsAllowed + r.toolsDenied, 0);
|
|
1032
|
-
const rolesUp = Object.keys(summary.roles).filter((r) => r !== '(system)').length;
|
|
1033
|
-
log(` elapsed: ${elapsedStr} | events: ${summary.events} | messages: ${summary.messages} | tools: ${toolCalls}`);
|
|
1034
|
-
log(` roles active: ${rolesUp} | tokens: ${fmtNum(summary.totalTokens)} | cost: $${summary.totalCostUsd.toFixed(2)}`);
|
|
1035
|
-
log(` quiet since: ${utcTime(lastTs)} (${quietStr} ago)`);
|
|
1036
|
-
if (summary.crashes.length)
|
|
1037
|
-
log(output.warning(` crashes: ${summary.crashes.join(', ')}`));
|
|
1038
|
-
}
|
|
1039
|
-
// Previous cycle comparison from history
|
|
1040
|
-
const history = readHistory(ctx.cwd, t);
|
|
1041
|
-
const prev = history.filter((h) => h.run !== state.run).at(-1);
|
|
1042
|
-
if (prev) {
|
|
1043
|
-
const dur = prev.durationMs !== null ? fmtDuration(prev.durationMs) : '?';
|
|
1044
|
-
// #302: never blindly "completed" — describeRunOutcome only says
|
|
1045
|
-
// that for a genuine boss outcome, a real crash, or a bare manual
|
|
1046
|
-
// stop; every automated stop path (idle-stop, failed-start, a boss
|
|
1047
|
-
// restart giving up) reports its own real cause, with the runnable
|
|
1048
|
-
// task count if work was left outstanding.
|
|
1049
|
-
const outcome = describeRunOutcome(prev);
|
|
1050
|
-
log(` prev cycle: ${dur}, ${outcome}, ${prev.events} events, ${fmtNum(prev.totalTokens)} tokens`);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
return { success: true };
|
|
1055
|
-
};
|
|
1056
|
-
function fmtDuration(ms) {
|
|
1057
|
-
const s = Math.floor(ms / 1000);
|
|
1058
|
-
if (s < 60)
|
|
1059
|
-
return `${s}s`;
|
|
1060
|
-
const m = Math.floor(s / 60);
|
|
1061
|
-
if (m < 60)
|
|
1062
|
-
return `${m}m${s % 60}s`;
|
|
1063
|
-
const h = Math.floor(m / 60);
|
|
1064
|
-
return `${h}h${m % 60}m`;
|
|
1065
|
-
}
|
|
1066
|
-
function fmtNum(n) {
|
|
1067
|
-
if (n >= 1_000_000)
|
|
1068
|
-
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1069
|
-
if (n >= 1_000)
|
|
1070
|
-
return `${(n / 1_000).toFixed(1)}k`;
|
|
1071
|
-
return String(n);
|
|
1072
|
-
}
|
|
1073
|
-
/** One pass of the `org serve` stopfile poll.
|
|
1074
|
-
*
|
|
1075
|
-
* `monomind org stop <name>` writes `.monomind/orgs/<name>/stop`. `org run` has always
|
|
1076
|
-
* polled that file; `org serve` did not — so against a serve daemon `org stop` was a
|
|
1077
|
-
* silent no-op that still printed "daemon exits within 2s" and exited 0 while the org
|
|
1078
|
-
* kept running. Stops every running org whose stopfile is present, then clears the
|
|
1079
|
-
* stopfile so the next scheduled iteration isn't killed on sight.
|
|
1080
|
-
*
|
|
1081
|
-
* Returns the names it stopped (awaited), so callers/tests don't have to guess. */
|
|
1082
|
-
export const pollStopfiles = async (cwd, daemon) => {
|
|
1083
|
-
const stopped = [];
|
|
1084
|
-
for (const name of daemon.listRunning()) {
|
|
1085
|
-
if (!existsSync(join(cwd, ORG_DIR, name, 'stop')))
|
|
1086
|
-
continue;
|
|
1087
|
-
log(output.info(`org ${name}: stop requested — shutting it down`));
|
|
1088
|
-
try {
|
|
1089
|
-
await daemon.stopOrg(name);
|
|
1090
|
-
stopped.push(name);
|
|
1091
|
-
}
|
|
1092
|
-
catch (err) {
|
|
1093
|
-
console.error(`org ${name}: stop failed:`, err);
|
|
1094
|
-
}
|
|
1095
|
-
finally {
|
|
1096
|
-
clearStopfile(cwd, name);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
return stopped;
|
|
1100
|
-
};
|
|
1101
|
-
export const pollReloadfiles = async (cwd, daemon) => {
|
|
1102
|
-
const reloaded = [];
|
|
1103
|
-
for (const name of daemon.listRunning()) {
|
|
1104
|
-
const reloadFile = join(cwd, ORG_DIR, name, 'reload');
|
|
1105
|
-
if (!existsSync(reloadFile))
|
|
1106
|
-
continue;
|
|
1107
|
-
try {
|
|
1108
|
-
unlinkSync(reloadFile);
|
|
1109
|
-
}
|
|
1110
|
-
catch {
|
|
1111
|
-
/* already gone */
|
|
1112
|
-
}
|
|
1113
|
-
try {
|
|
1114
|
-
const result = daemon.reloadOrgDef(name);
|
|
1115
|
-
const parts = [];
|
|
1116
|
-
if (result.changed.length)
|
|
1117
|
-
parts.push(`${result.changed.length} fields updated`);
|
|
1118
|
-
if (result.newRoles.length)
|
|
1119
|
-
parts.push(`${result.newRoles.length} new roles: ${result.newRoles.join(', ')}`);
|
|
1120
|
-
if (result.removedRoles.length)
|
|
1121
|
-
parts.push(`${result.removedRoles.length} roles removed: ${result.removedRoles.join(', ')}`);
|
|
1122
|
-
log(output.info(`org ${name}: reloaded — ${parts.join('; ') || 'no changes'}`));
|
|
1123
|
-
reloaded.push(name);
|
|
1124
|
-
}
|
|
1125
|
-
catch (err) {
|
|
1126
|
-
log(output.warning(`org ${name}: reload failed — ${err instanceof Error ? err.message : 'unknown'}`));
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
return reloaded;
|
|
1130
|
-
};
|
|
1131
|
-
/** One pass of the `org serve` runfile poll — the mirror of pollStopfiles.
|
|
1132
|
-
*
|
|
1133
|
-
* A serve daemon owns its orgs, and nothing could ask it to start one off-cycle:
|
|
1134
|
-
* a scheduled org simply waited for its next tick, and `org run` against a
|
|
1135
|
-
* served org would spawn a second daemon competing for the same runtime.json
|
|
1136
|
-
* and broker lease. "Run it now" therefore meant killing and restarting the
|
|
1137
|
-
* daemon, which resets the schedule and drops any in-flight work.
|
|
1138
|
-
*
|
|
1139
|
-
* `.monomind/orgs/<name>/run` is the request. Consumed (deleted) before the
|
|
1140
|
-
* start, so a crash mid-run cannot wedge the org into a restart loop, and an
|
|
1141
|
-
* already-running org just clears it — "start now" on something already started
|
|
1142
|
-
* is satisfied, not an error.
|
|
1143
|
-
*
|
|
1144
|
-
* Returns the names it started, so callers/tests don't have to guess. */
|
|
1145
|
-
export const pollRunfiles = async (cwd, daemon) => {
|
|
1146
|
-
const started = [];
|
|
1147
|
-
const orgDir = join(cwd, ORG_DIR);
|
|
1148
|
-
if (!existsSync(orgDir))
|
|
1149
|
-
return started;
|
|
1150
|
-
for (const f of listOrgConfigFiles(orgDir)) {
|
|
1151
|
-
const name = f.replace(/\.json$/, '');
|
|
1152
|
-
const runfile = join(orgDir, name, 'run');
|
|
1153
|
-
if (!existsSync(runfile))
|
|
1154
|
-
continue;
|
|
1155
|
-
// Read before consuming. Pre-runfile writers (and a hand-touched file) left
|
|
1156
|
-
// a bare timestamp or nothing at all, so an unparseable body is a plain
|
|
1157
|
-
// "start it" request, not an error.
|
|
1158
|
-
let task;
|
|
1159
|
-
try {
|
|
1160
|
-
const body = JSON.parse(readFileSync(runfile, 'utf8'));
|
|
1161
|
-
if (typeof body.task === 'string' && body.task.trim())
|
|
1162
|
-
task = body.task;
|
|
1163
|
-
}
|
|
1164
|
-
catch {
|
|
1165
|
-
/* bare/empty runfile — start with the org's own goal */
|
|
1166
|
-
}
|
|
1167
|
-
try {
|
|
1168
|
-
unlinkSync(runfile);
|
|
1169
|
-
}
|
|
1170
|
-
catch {
|
|
1171
|
-
/* already gone */
|
|
1172
|
-
}
|
|
1173
|
-
if (daemon.listRunning().includes(name))
|
|
1174
|
-
continue; // already running — request satisfied
|
|
1175
|
-
log(output.info(`org ${name}: run requested — starting now${task ? ' (with task)' : ''}`));
|
|
1176
|
-
try {
|
|
1177
|
-
await daemon.startOrg(name, task);
|
|
1178
|
-
started.push(name);
|
|
1179
|
-
}
|
|
1180
|
-
catch (err) {
|
|
1181
|
-
console.error(`org ${name}: requested start failed:`, err);
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
return started;
|
|
1185
|
-
};
|
|
1186
|
-
/**
|
|
1187
|
-
* Emit a supervisor unit for `org serve`.
|
|
1188
|
-
*
|
|
1189
|
-
* Why this is an EXTERNAL supervisor and not an `--supervise` flag: the daemon
|
|
1190
|
-
* already logs every death it can observe — signals, uncaught exceptions,
|
|
1191
|
-
* unhandled rejections, and the event loop draining. The one death it cannot
|
|
1192
|
-
* observe is SIGKILL, which is what the OOM killer sends, and which is the
|
|
1193
|
-
* suspected cause of the reported disappearance (its org logs showed repeated
|
|
1194
|
-
* low-memory warnings). No in-process handler survives SIGKILL, so a daemon
|
|
1195
|
-
* that restarts itself is theatre for exactly the case that matters. Only
|
|
1196
|
-
* something outside the process can bring it back.
|
|
1197
|
-
*
|
|
1198
|
-
* launchd and systemd both already do this well, so this generates a correct
|
|
1199
|
-
* unit rather than reimplementing them.
|
|
1200
|
-
*/
|
|
1201
|
-
const supervisorAction = async (ctx) => {
|
|
1202
|
-
const cwd = resolve(ctx.cwd || process.cwd());
|
|
1203
|
-
const requested = String(ctx.flags.format ?? '')
|
|
1204
|
-
.trim()
|
|
1205
|
-
.toLowerCase();
|
|
1206
|
-
const format = requested || (process.platform === 'darwin' ? 'launchd' : 'systemd');
|
|
1207
|
-
if (format !== 'launchd' && format !== 'systemd') {
|
|
1208
|
-
log(output.error(`Unknown --format "${requested}" — expected launchd or systemd.`));
|
|
1209
|
-
return { success: false, message: 'unknown supervisor format' };
|
|
1210
|
-
}
|
|
1211
|
-
// argv[1] is this CLI's entry point — the same resolution `init` uses when it
|
|
1212
|
-
// spawns a watcher. A supervisor must not depend on PATH or npx resolving to
|
|
1213
|
-
// the same version later.
|
|
1214
|
-
const cliEntry = process.argv[1] ? resolve(process.argv[1]) : 'monomind';
|
|
1215
|
-
const node = process.execPath;
|
|
1216
|
-
// Per-project identity. The unit bakes in a WorkingDirectory, so a constant
|
|
1217
|
-
// Label and filename meant `--install` from a second project silently
|
|
1218
|
-
// OVERWROTE the first project's unit — one file, the first daemon left
|
|
1219
|
-
// unsupervised, no warning. Verified before fixing: installing from projA
|
|
1220
|
-
// then projB left a single unit pointing at projB.
|
|
1221
|
-
//
|
|
1222
|
-
// The hash keeps it unique for two directories with the same basename; the
|
|
1223
|
-
// basename keeps it recognisable in `launchctl list` / `systemctl --user`.
|
|
1224
|
-
const slug = `${(cwd.split(/[\\/]/).pop() || 'org').replace(/[^A-Za-z0-9._-]/g, '-')}-${createHash('sha256').update(cwd).digest('hex').slice(0, 8)}`;
|
|
1225
|
-
const label = `com.monomind.org-serve.${slug}`;
|
|
1226
|
-
const logPath = join(cwd, '.monomind', 'org-serve.log');
|
|
1227
|
-
const unit = format === 'launchd'
|
|
1228
|
-
? `<?xml version="1.0" encoding="UTF-8"?>
|
|
1229
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1230
|
-
<plist version="1.0">
|
|
1231
|
-
<dict>
|
|
1232
|
-
<key>Label</key><string>${label}</string>
|
|
1233
|
-
<key>ProgramArguments</key>
|
|
1234
|
-
<array>
|
|
1235
|
-
<string>${node}</string>
|
|
1236
|
-
<string>${cliEntry}</string>
|
|
1237
|
-
<string>org</string>
|
|
1238
|
-
<string>serve</string>
|
|
1239
|
-
</array>
|
|
1240
|
-
<key>WorkingDirectory</key><string>${cwd}</string>
|
|
1241
|
-
<!-- KeepAlive restarts the daemon however it died, including SIGKILL. -->
|
|
1242
|
-
<key>KeepAlive</key><true/>
|
|
1243
|
-
<key>RunAtLoad</key><true/>
|
|
1244
|
-
<key>StandardOutPath</key><string>${logPath}</string>
|
|
1245
|
-
<key>StandardErrorPath</key><string>${logPath}</string>
|
|
1246
|
-
</dict>
|
|
1247
|
-
</plist>
|
|
1248
|
-
`
|
|
1249
|
-
: `[Unit]
|
|
1250
|
-
Description=monomind org serve (${cwd})
|
|
1251
|
-
After=network.target
|
|
1252
|
-
|
|
1253
|
-
[Service]
|
|
1254
|
-
Type=simple
|
|
1255
|
-
WorkingDirectory=${cwd}
|
|
1256
|
-
ExecStart=${node} ${cliEntry} org serve
|
|
1257
|
-
# Restart however it died, including an OOM kill.
|
|
1258
|
-
Restart=always
|
|
1259
|
-
RestartSec=5
|
|
1260
|
-
StandardOutput=append:${logPath}
|
|
1261
|
-
StandardError=append:${logPath}
|
|
1262
|
-
|
|
1263
|
-
[Install]
|
|
1264
|
-
WantedBy=default.target
|
|
1265
|
-
`;
|
|
1266
|
-
const target = format === 'launchd'
|
|
1267
|
-
? `~/Library/LaunchAgents/${label}.plist`
|
|
1268
|
-
: `~/.config/systemd/user/monomind-org-serve-${slug}.service`;
|
|
1269
|
-
if (ctx.flags.install === true) {
|
|
1270
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1271
|
-
if (!home) {
|
|
1272
|
-
log(output.error('Cannot resolve a home directory to install into — write the unit manually.'));
|
|
1273
|
-
return { success: false, message: 'no home directory' };
|
|
1274
|
-
}
|
|
1275
|
-
const dest = format === 'launchd'
|
|
1276
|
-
? join(home, 'Library', 'LaunchAgents', `${label}.plist`)
|
|
1277
|
-
: join(home, '.config', 'systemd', 'user', `monomind-org-serve-${slug}.service`);
|
|
1278
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
1279
|
-
writeFileSync(dest, unit);
|
|
1280
|
-
log(output.success(`Wrote ${dest}`));
|
|
1281
|
-
log(output.info(format === 'launchd'
|
|
1282
|
-
? `Load it with: launchctl load -w ${dest}`
|
|
1283
|
-
: `Load it with: systemctl --user daemon-reload && systemctl --user enable --now monomind-org-serve-${slug}`));
|
|
1284
|
-
return { success: true, message: `supervisor unit written to ${dest}` };
|
|
1285
|
-
}
|
|
1286
|
-
log(unit);
|
|
1287
|
-
log(output.info(`Write this to ${target}, or re-run with --install to do it for you.`));
|
|
1288
|
-
log(output.info('Why a supervisor: the daemon logs every death it can observe, but an OOM kill is SIGKILL — ' +
|
|
1289
|
-
'uncatchable by design, so nothing in-process can restart after one.'));
|
|
1290
|
-
return { success: true, message: `${format} unit emitted` };
|
|
1291
|
-
};
|
|
1292
|
-
const serveAction = async (ctx) => {
|
|
1293
|
-
// Mutual exclusion: refuse to start a second `org serve` for this project
|
|
1294
|
-
// root. Checked before anything else so a refusal never opens a port,
|
|
1295
|
-
// registers a broker lease, or starts a scheduled org that a live daemon
|
|
1296
|
-
// is already running — see checkServeLock's header for the failure mode
|
|
1297
|
-
// this prevents.
|
|
1298
|
-
const lock = checkServeLock(ctx.cwd);
|
|
1299
|
-
if (!lock.ok) {
|
|
1300
|
-
log(output.error(`org serve: another daemon (pid ${lock.pid}) is already running for this project root.`));
|
|
1301
|
-
log(output.info(` Check what it's doing with: monomind org status`));
|
|
1302
|
-
log(output.info(` Stop it first (Ctrl-C in its terminal, or "kill ${lock.pid}"), then retry.`));
|
|
1303
|
-
return { success: false, message: `org serve already running (pid ${lock.pid})` };
|
|
1304
|
-
}
|
|
1305
|
-
if (lock.staleHeartbeatRemoved) {
|
|
1306
|
-
log(output.warning('org serve: cleaned up a stale heartbeat left by a previous daemon that did not shut down cleanly.'));
|
|
1307
|
-
}
|
|
1308
|
-
// See the matching comment in runAction — same rationale, same guard
|
|
1309
|
-
// (embedder and reranker, scoped to this process, not exported to roles).
|
|
1310
|
-
const { disableLocalModels } = await import('../memory/memory-bridge.js');
|
|
1311
|
-
disableLocalModels();
|
|
1312
|
-
const crossProcess = ctx.flags.crossProcess !== false;
|
|
1313
|
-
const daemon = new OrgDaemon(ctx.cwd, { crossProcess });
|
|
1314
|
-
let srv;
|
|
1315
|
-
if (crossProcess) {
|
|
1316
|
-
srv = await startOrgServer(daemon, 0);
|
|
1317
|
-
daemon.setInboxUrl(`http://127.0.0.1:${srv.port}`, srv.operatorCredential);
|
|
1318
|
-
}
|
|
1319
|
-
// Crash handlers: log the reason and persist crashed state so `org status`
|
|
1320
|
-
// shows what happened instead of a silent "pid is gone". A truly unknown
|
|
1321
|
-
// uncaught exception's blast radius can't always be attributed to one org,
|
|
1322
|
-
// so the whole process still exits (the safety-first default) — but naming
|
|
1323
|
-
// which orgs were in flight at the moment it fired at least tells the
|
|
1324
|
-
// operator the blast radius they actually hit, instead of leaving them to
|
|
1325
|
-
// guess from an error with no org context.
|
|
1326
|
-
const crashExit = (label, err) => {
|
|
1327
|
-
try {
|
|
1328
|
-
const running = daemon.listRunning();
|
|
1329
|
-
console.error(`[org serve] ${label}:`, err, running.length ? `— orgs in flight: ${running.join(', ')}` : '— no orgs were running');
|
|
1330
|
-
}
|
|
1331
|
-
catch {
|
|
1332
|
-
try {
|
|
1333
|
-
console.error(`[org serve] ${label}:`, err);
|
|
1334
|
-
}
|
|
1335
|
-
catch {
|
|
1336
|
-
/* stderr gone */
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
daemon.persistCrashStateAll(`${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1340
|
-
daemon.clearHeartbeat();
|
|
1341
|
-
process.exitCode = 1;
|
|
1342
|
-
};
|
|
1343
|
-
process.on('uncaughtException', (err) => {
|
|
1344
|
-
crashExit('uncaughtException', err);
|
|
1345
|
-
process.exit(1);
|
|
1346
|
-
});
|
|
1347
|
-
process.on('unhandledRejection', (err) => {
|
|
1348
|
-
crashExit('unhandledRejection', err);
|
|
1349
|
-
process.exit(1);
|
|
1350
|
-
});
|
|
1351
|
-
// Termination diagnostics (#45). The two handlers above only cover errors
|
|
1352
|
-
// raised *inside* the daemon. A report of the daemon vanishing after hours
|
|
1353
|
-
// had a log holding nothing but its startup lines, because the ways a daemon
|
|
1354
|
-
// usually dies were all unhandled:
|
|
1355
|
-
//
|
|
1356
|
-
// - a signal (SIGTERM from a supervisor/OS, SIGHUP when a terminal closes)
|
|
1357
|
-
// - the event loop simply draining, which exits 0 and says nothing at all
|
|
1358
|
-
//
|
|
1359
|
-
// Both now announce themselves. Note what this deliberately cannot cover:
|
|
1360
|
-
// SIGKILL, which is what the OOM killer sends, is uncatchable by design — no
|
|
1361
|
-
// in-process handler can ever log it. That case is instead made *inferable*:
|
|
1362
|
-
// every shutdown path below prints a terminal line, so a log that starts and
|
|
1363
|
-
// then stops with no such line means the process was killed from outside
|
|
1364
|
-
// (OOM being the usual culprit, and the reporter's org logs did show memory
|
|
1365
|
-
// pressure). Absence of a shutdown line is now evidence, not ambiguity.
|
|
1366
|
-
let shuttingDown = false;
|
|
1367
|
-
const announceExit = (reason) => {
|
|
1368
|
-
if (shuttingDown)
|
|
1369
|
-
return;
|
|
1370
|
-
shuttingDown = true;
|
|
1371
|
-
try {
|
|
1372
|
-
console.error(`[org serve] shutting down: ${reason}`);
|
|
1373
|
-
}
|
|
1374
|
-
catch {
|
|
1375
|
-
/* stderr gone */
|
|
1376
|
-
}
|
|
1377
|
-
};
|
|
1378
|
-
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
1379
|
-
process.on(sig, () => {
|
|
1380
|
-
announceExit(`received ${sig}`);
|
|
1381
|
-
try {
|
|
1382
|
-
daemon.persistCrashStateAll();
|
|
1383
|
-
daemon.clearHeartbeat();
|
|
1384
|
-
}
|
|
1385
|
-
catch {
|
|
1386
|
-
/* best effort */
|
|
1387
|
-
}
|
|
1388
|
-
// A daemon holds ref'd timers, so it will not drain on its own; an
|
|
1389
|
-
// explicit exit is required here and is the intended signal semantics.
|
|
1390
|
-
process.exit(sig === 'SIGTERM' || sig === 'SIGINT' ? 0 : 1);
|
|
1391
|
-
});
|
|
1392
|
-
}
|
|
1393
|
-
process.on('exit', (code) => {
|
|
1394
|
-
// Last word on the way out. Reached for the "event loop drained" case,
|
|
1395
|
-
// which previously produced a completely silent disappearance.
|
|
1396
|
-
announceExit(`process exiting with code ${code}`);
|
|
1397
|
-
});
|
|
1398
|
-
// Heartbeat: write every 30s so `org status` can tell "alive but busy" from
|
|
1399
|
-
// "daemon gone" without relying on pid liveness alone.
|
|
1400
|
-
daemon.writeHeartbeat();
|
|
1401
|
-
const heartbeatInterval = setInterval(() => {
|
|
1402
|
-
daemon.writeHeartbeat();
|
|
1403
|
-
}, 30_000);
|
|
1404
|
-
heartbeatInterval.unref?.();
|
|
1405
|
-
log(output.info('org daemon serving — Ctrl-C to stop'));
|
|
1406
|
-
// schedule orgs whose definition declares an interval (e.g. "15m", "2h")
|
|
1407
|
-
const { OrgScheduler, parseSchedule } = await import('../orgrt/scheduler.js');
|
|
1408
|
-
const sched = new OrgScheduler(async (name, intervalMs) => {
|
|
1409
|
-
if (isOrgPaused(ctx.cwd, name))
|
|
1410
|
-
return;
|
|
1411
|
-
// Run precondition checks before starting a scheduled run
|
|
1412
|
-
try {
|
|
1413
|
-
const defPath = join(ctx.cwd, ORG_DIR, `${name}.json`);
|
|
1414
|
-
if (existsSync(defPath)) {
|
|
1415
|
-
const rawDef = JSON.parse(readFileSync(defPath, 'utf8'));
|
|
1416
|
-
const checks = rawDef?.run_config?.prechecks;
|
|
1417
|
-
if (Array.isArray(checks) && checks.length > 0) {
|
|
1418
|
-
const { runPrechecks } = await import('../orgrt/prechecks.js');
|
|
1419
|
-
const { ok, results } = await runPrechecks(checks, ctx.cwd);
|
|
1420
|
-
if (!ok) {
|
|
1421
|
-
const failed = results.find((r) => !r.passed);
|
|
1422
|
-
log(output.warning(`org ${name}: precheck "${failed?.name}" failed — skipping scheduled run`));
|
|
1423
|
-
if (failed?.output)
|
|
1424
|
-
log(output.warning(` ${failed.output.slice(0, 200)}`));
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
catch (err) {
|
|
1431
|
-
log(output.warning(`org ${name}: precheck evaluation error — ${err instanceof Error ? err.message : 'unknown'}`));
|
|
1432
|
-
}
|
|
1433
|
-
// Only ever stop a run THIS tick started. The runfile poll can start an org
|
|
1434
|
-
// out-of-band, and the scheduler has no visibility into that — so a tick
|
|
1435
|
-
// landing on an already-running org threw "already running", fell into the
|
|
1436
|
-
// finally, and stopped a healthy run that had nothing to do with it. The
|
|
1437
|
-
// tick's job in that case is simply to yield.
|
|
1438
|
-
let startedHere = false;
|
|
1439
|
-
try {
|
|
1440
|
-
await daemon.startOrg(name);
|
|
1441
|
-
startedHere = true;
|
|
1442
|
-
// Scheduled iterations are time-bounded: agents' `done` promises only
|
|
1443
|
-
// resolve after stopOrg closes the mailboxes, so waiting on them alone
|
|
1444
|
-
// deadlocks. Race against a max-run timeout, then ALWAYS stopOrg
|
|
1445
|
-
// (idempotent — it resolves `done` and flushes).
|
|
1446
|
-
const org = daemon.getOrg(name);
|
|
1447
|
-
const allDone = org
|
|
1448
|
-
? Promise.allSettled([...org.agents.values()].map((a) => a.done))
|
|
1449
|
-
: Promise.resolve([]);
|
|
1450
|
-
const maxRun = org?.def
|
|
1451
|
-
?.run_config?.max_run;
|
|
1452
|
-
// Default to the full interval. The old `min(interval, 10min)` clamp
|
|
1453
|
-
// silently guillotined every org that didn't set max_run: real cycles
|
|
1454
|
-
// here run 75-93 minutes, so a 2h-scheduled org was being force-stopped
|
|
1455
|
-
// a twelfth of the way in, every time, with nothing saying so. Ten
|
|
1456
|
-
// minutes was never a considered bound for agent work — it only looked
|
|
1457
|
-
// safe because overrunning the interval used to cost a whole idle
|
|
1458
|
-
// period. Now that a missed tick catches up the moment a run ends
|
|
1459
|
-
// (OrgScheduler.pending), a run may safely use its whole interval.
|
|
1460
|
-
// Set run_config.max_run to bound it tighter — or looser.
|
|
1461
|
-
const maxMs = parseSchedule(maxRun) ?? intervalMs;
|
|
1462
|
-
let timer;
|
|
1463
|
-
await Promise.race([
|
|
1464
|
-
allDone,
|
|
1465
|
-
new Promise((r) => {
|
|
1466
|
-
timer = setTimeout(r, maxMs);
|
|
1467
|
-
timer.unref?.();
|
|
1468
|
-
}),
|
|
1469
|
-
]);
|
|
1470
|
-
if (timer)
|
|
1471
|
-
clearTimeout(timer);
|
|
1472
|
-
}
|
|
1473
|
-
catch (err) {
|
|
1474
|
-
console.error(`org ${name}: scheduled run failed:`, err);
|
|
1475
|
-
}
|
|
1476
|
-
finally {
|
|
1477
|
-
// A deadline stop still lands on agents mid-tool-call. The 15s abort
|
|
1478
|
-
// bound threw that work away; a minute is enough to finish an edit or a
|
|
1479
|
-
// test run and flush, and still well inside any sane interval.
|
|
1480
|
-
if (startedHere) {
|
|
1481
|
-
// #302: tag the real cause — a scheduled run hitting its own
|
|
1482
|
-
// deadline is the same shape as an idle-stop (a timeout ending the
|
|
1483
|
-
// run with possible backlog, not boss consent) and must not be
|
|
1484
|
-
// rendered as a clean, boss-attributed outcome.
|
|
1485
|
-
await daemon
|
|
1486
|
-
.stopOrg(name, { drainMs: 60_000, closedBy: 'scheduled-deadline' })
|
|
1487
|
-
.catch((err) => console.error(`org ${name}: stop failed:`, err));
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
// #264: before anything here can start an org — and so before the stop and
|
|
1492
|
-
// reload polls below get their first pass — drop control files a previous
|
|
1493
|
-
// daemon left behind, which this one would otherwise act on immediately.
|
|
1494
|
-
clearStaleControlFiles(ctx.cwd);
|
|
1495
|
-
const orgDir = join(ctx.cwd, ORG_DIR);
|
|
1496
|
-
if (existsSync(orgDir)) {
|
|
1497
|
-
for (const f of listOrgConfigFiles(orgDir)) {
|
|
1498
|
-
try {
|
|
1499
|
-
const def = JSON.parse(readFileSync(join(orgDir, f), 'utf8'));
|
|
1500
|
-
const ms = parseSchedule(def.schedule);
|
|
1501
|
-
if (ms) {
|
|
1502
|
-
// register by filename stem — that's what startOrg loads
|
|
1503
|
-
const stem = f.replace(/\.json$/, '');
|
|
1504
|
-
if (def.name && def.name !== stem)
|
|
1505
|
-
log(output.warning(`org file ${f}: def.name "${def.name}" differs from filename — scheduling as "${stem}"`));
|
|
1506
|
-
// Due = never run, or last run ended longer ago than the interval.
|
|
1507
|
-
// Without this, starting the daemon meant waiting a full period
|
|
1508
|
-
// before anything happened at all; gating on due-ness means a
|
|
1509
|
-
// restart doesn't stampede every scheduled org back into a run.
|
|
1510
|
-
const lastEnded = readHistory(ctx.cwd, stem).at(-1)?.endedAt ?? 0;
|
|
1511
|
-
const since = lastEnded ? Date.now() - lastEnded : undefined;
|
|
1512
|
-
const due = (since ?? Infinity) >= ms;
|
|
1513
|
-
sched.add(stem, ms, due, since);
|
|
1514
|
-
const waitMin = due ? 0 : Math.round((ms - (since ?? 0)) / 60_000);
|
|
1515
|
-
log(output.info(`scheduled org ${stem} every ${Math.round(ms / 60_000)}m${due ? ' — due now, starting first run' : ` — next run in ~${waitMin}m`}`));
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
catch (err) {
|
|
1519
|
-
log(output.warning(`org file ${f}: could not parse — skipping (${err instanceof Error ? err.message : 'invalid JSON'})`));
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
// Each poll pass already wraps its own per-org daemon calls in try/catch,
|
|
1524
|
-
// but a synchronous throw from something ahead of those (e.g.
|
|
1525
|
-
// daemon.listRunning(), listOrgConfigFiles()) still rejects the async
|
|
1526
|
-
// function itself. `void`-ing that call, as before, discards the promise
|
|
1527
|
-
// without observing a rejection — which becomes an unhandled rejection at
|
|
1528
|
-
// the process level and takes down every other org's in-flight run via
|
|
1529
|
-
// crashExit, even though the failure was local to one poll pass. .catch()
|
|
1530
|
-
// keeps it a per-pass, logged failure instead.
|
|
1531
|
-
const stopPoll = setInterval(() => {
|
|
1532
|
-
pollStopfiles(ctx.cwd, daemon).catch((err) => {
|
|
1533
|
-
console.error('[org serve] stopfile poll failed:', err);
|
|
1534
|
-
});
|
|
1535
|
-
}, 2000);
|
|
1536
|
-
stopPoll.unref?.();
|
|
1537
|
-
const runPoll = setInterval(() => {
|
|
1538
|
-
pollRunfiles(ctx.cwd, daemon).catch((err) => {
|
|
1539
|
-
console.error('[org serve] runfile poll failed:', err);
|
|
1540
|
-
});
|
|
1541
|
-
}, 2000);
|
|
1542
|
-
runPoll.unref?.();
|
|
1543
|
-
const reloadPoll = setInterval(() => {
|
|
1544
|
-
pollReloadfiles(ctx.cwd, daemon).catch((err) => {
|
|
1545
|
-
console.error('[org serve] reloadfile poll failed:', err);
|
|
1546
|
-
});
|
|
1547
|
-
}, 2000);
|
|
1548
|
-
reloadPoll.unref?.();
|
|
1549
|
-
await new Promise((r) => {
|
|
1550
|
-
process.once('SIGINT', () => r());
|
|
1551
|
-
process.once('SIGTERM', () => r());
|
|
1552
|
-
});
|
|
1553
|
-
clearInterval(stopPoll);
|
|
1554
|
-
clearInterval(runPoll);
|
|
1555
|
-
clearInterval(reloadPoll);
|
|
1556
|
-
clearInterval(heartbeatInterval);
|
|
1557
|
-
sched.stop();
|
|
1558
|
-
await daemon.stopAll();
|
|
1559
|
-
daemon.clearHeartbeat();
|
|
1560
|
-
srv?.close();
|
|
1561
|
-
return { success: true };
|
|
1562
|
-
};
|
|
1563
|
-
const testLoopAction = async (ctx) => {
|
|
1564
|
-
// non-literal specifier: test-loop.ts lands in a later task; keeps tsc clean until then
|
|
1565
|
-
const testLoopModule = '../orgrt/test-loop.js';
|
|
1566
|
-
const { runTestLoop } = (await import(testLoopModule));
|
|
1567
|
-
const n = Number(ctx.flags.times ?? ctx.flags.n ?? 5);
|
|
1568
|
-
const scenario = typeof ctx.flags.scenario === 'string' ? ctx.flags.scenario : undefined;
|
|
1569
|
-
const report = await runTestLoop(ctx.cwd, n, scenario);
|
|
1570
|
-
log(output.info(report.summary));
|
|
1571
|
-
return { success: report.failed === 0, message: report.summary };
|
|
1572
|
-
};
|
|
1573
|
-
// ---- legacy management subcommands (list / delete / mark-complete) ----
|
|
1574
|
-
const listAction = async (ctx) => {
|
|
1575
|
-
const orgsDir = join(ctx.cwd || process.cwd(), ORG_DIR);
|
|
1576
|
-
if (!existsSync(orgsDir)) {
|
|
1577
|
-
if (ctx.flags.format === 'json') {
|
|
1578
|
-
process.stdout.write(`${JSON.stringify({ v: 1, items: [] })}\n`);
|
|
1579
|
-
return { success: true };
|
|
1580
|
-
}
|
|
1581
|
-
log(output.info('No orgs directory found. Create an org first with /mastermind:createorg'));
|
|
1582
|
-
return { success: true };
|
|
1583
|
-
}
|
|
1584
|
-
const configs = listOrgConfigFiles(orgsDir);
|
|
1585
|
-
if (!configs.length) {
|
|
1586
|
-
if (ctx.flags.format === 'json') {
|
|
1587
|
-
process.stdout.write(`${JSON.stringify({ v: 1, items: [] })}\n`);
|
|
1588
|
-
return { success: true };
|
|
1589
|
-
}
|
|
1590
|
-
log(output.info('No orgs found.'));
|
|
1591
|
-
return { success: true };
|
|
1592
|
-
}
|
|
1593
|
-
if (ctx.flags.format === 'json') {
|
|
1594
|
-
const items = configs.map((f) => {
|
|
1595
|
-
const stem = f.replace(/\.json$/, '');
|
|
1596
|
-
try {
|
|
1597
|
-
const def = JSON.parse(readFileSync(join(orgsDir, f), 'utf8'));
|
|
1598
|
-
let status = 'never run';
|
|
1599
|
-
try {
|
|
1600
|
-
const rt = JSON.parse(readFileSync(join(orgsDir, stem, 'runtime.json'), 'utf8'));
|
|
1601
|
-
status = rt.status ?? status;
|
|
1602
|
-
// Same liveness rule as `org status`: a 'running' record with a dead
|
|
1603
|
-
// pid is a crashed daemon, not a running org — list must not disagree.
|
|
1604
|
-
if (status === 'running' && rt.pid) {
|
|
1605
|
-
try {
|
|
1606
|
-
process.kill(rt.pid, 0);
|
|
1607
|
-
}
|
|
1608
|
-
catch {
|
|
1609
|
-
status = 'crashed';
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
catch {
|
|
1614
|
-
/* no runtime state yet */
|
|
1615
|
-
}
|
|
1616
|
-
return {
|
|
1617
|
-
name: stem,
|
|
1618
|
-
roles: Array.isArray(def.roles) ? def.roles.length : 0,
|
|
1619
|
-
schedule: def.schedule ?? null,
|
|
1620
|
-
status,
|
|
1621
|
-
goal: typeof def.goal === 'string' ? def.goal : '',
|
|
1622
|
-
};
|
|
1623
|
-
}
|
|
1624
|
-
catch {
|
|
1625
|
-
return { name: stem, roles: 0, schedule: null, status: 'invalid-config', goal: '' };
|
|
1626
|
-
}
|
|
1627
|
-
});
|
|
1628
|
-
process.stdout.write(`${JSON.stringify({ v: 1, items })}\n`);
|
|
1629
|
-
return { success: true };
|
|
1630
|
-
}
|
|
1631
|
-
log(output.info(`Found ${configs.length} org(s):`));
|
|
1632
|
-
for (const f of configs) {
|
|
1633
|
-
const stem = f.replace(/\.json$/, '');
|
|
1634
|
-
let detail = '';
|
|
1635
|
-
try {
|
|
1636
|
-
const def = JSON.parse(readFileSync(join(orgsDir, f), 'utf8'));
|
|
1637
|
-
const roles = Array.isArray(def.roles) ? def.roles.length : 0;
|
|
1638
|
-
const sched = def.schedule ? `every ${def.schedule}` : 'manual';
|
|
1639
|
-
let status = 'never run';
|
|
1640
|
-
try {
|
|
1641
|
-
const rt = JSON.parse(readFileSync(join(orgsDir, stem, 'runtime.json'), 'utf8'));
|
|
1642
|
-
status = rt.status ?? status;
|
|
1643
|
-
// Same liveness rule as `org status`: a 'running' record with a dead
|
|
1644
|
-
// pid is a crashed daemon, not a running org — list must not disagree.
|
|
1645
|
-
if (status === 'running' && rt.pid) {
|
|
1646
|
-
try {
|
|
1647
|
-
process.kill(rt.pid, 0);
|
|
1648
|
-
}
|
|
1649
|
-
catch {
|
|
1650
|
-
status = 'crashed';
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
catch {
|
|
1655
|
-
/* no runtime state yet */
|
|
1656
|
-
}
|
|
1657
|
-
const goal = typeof def.goal === 'string' && def.goal
|
|
1658
|
-
? ` — ${def.goal.length > 60 ? `${def.goal.slice(0, 57)}...` : def.goal}`
|
|
1659
|
-
: '';
|
|
1660
|
-
detail = ` (${roles} role${roles === 1 ? '' : 's'}, ${sched}, ${status})${goal}`;
|
|
1661
|
-
}
|
|
1662
|
-
catch {
|
|
1663
|
-
detail = ' (unreadable config — run `monomind org validate`)';
|
|
1664
|
-
}
|
|
1665
|
-
log(output.info(` • ${stem}${detail}`));
|
|
1666
|
-
}
|
|
1667
|
-
return { success: true };
|
|
1668
|
-
};
|
|
1669
|
-
const deleteAction = async (ctx) => {
|
|
1670
|
-
const orgName = ctx.args[0];
|
|
1671
|
-
if (!orgName) {
|
|
1672
|
-
log(output.error('Usage: monomind org delete <name>'));
|
|
1673
|
-
return { success: false, message: 'org name required' };
|
|
1674
|
-
}
|
|
1675
|
-
if (!ORG_NAME_RE.test(orgName)) {
|
|
1676
|
-
log(output.error(`Invalid org name: ${orgName}`));
|
|
1677
|
-
return { success: false, message: 'invalid org name' };
|
|
1678
|
-
}
|
|
1679
|
-
const confirmed = ctx.flags.yes === true || ctx.args.includes('--yes') || ctx.args.includes('-y');
|
|
1680
|
-
if (!confirmed) {
|
|
1681
|
-
log(output.warning(`This will permanently delete org "${orgName}" and all its data.`));
|
|
1682
|
-
log(output.warning('Pass --yes to confirm.'));
|
|
1683
|
-
return { success: false, message: 'confirmation required' };
|
|
1684
|
-
}
|
|
1685
|
-
const cwd = resolve(ctx.cwd || process.cwd());
|
|
1686
|
-
const orgsDir = join(cwd, ORG_DIR);
|
|
1687
|
-
const configFile = join(orgsDir, `${orgName}.json`);
|
|
1688
|
-
if (!existsSync(configFile)) {
|
|
1689
|
-
log(output.error(`Org not found: ${orgName}`));
|
|
1690
|
-
return { success: false, message: 'org not found' };
|
|
1691
|
-
}
|
|
1692
|
-
if (isOrgRunning(cwd, orgName) && ctx.flags.force !== true) {
|
|
1693
|
-
log(output.error(`Org "${orgName}" is currently running — stop it first (monomind org stop ${orgName}) or pass --force.`));
|
|
1694
|
-
return { success: false, message: 'org is running' };
|
|
1695
|
-
}
|
|
1696
|
-
let removed = 0;
|
|
1697
|
-
for (const suf of ['', ...ORG_ARTIFACT_SUFFIXES]) {
|
|
1698
|
-
for (const ext of ['.json', '.jsonl']) {
|
|
1699
|
-
const f = join(orgsDir, `${orgName}${suf}${ext}`);
|
|
1700
|
-
try {
|
|
1701
|
-
if (existsSync(f)) {
|
|
1702
|
-
unlinkSync(f);
|
|
1703
|
-
removed++;
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
catch {
|
|
1707
|
-
/* ignore */
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
try {
|
|
1712
|
-
unlinkSync(join(orgsDir, '.stops', `${orgName}.stop`));
|
|
1713
|
-
}
|
|
1714
|
-
catch {
|
|
1715
|
-
/* ignore */
|
|
1716
|
-
}
|
|
1717
|
-
const orgSubDir = join(orgsDir, orgName);
|
|
1718
|
-
try {
|
|
1719
|
-
if (existsSync(orgSubDir))
|
|
1720
|
-
rmSync(orgSubDir, { recursive: true, force: true });
|
|
1721
|
-
}
|
|
1722
|
-
catch {
|
|
1723
|
-
/* ignore */
|
|
1724
|
-
}
|
|
1725
|
-
try {
|
|
1726
|
-
unlinkSync(join(cwd, '.monomind', 'loops', `${orgName}.md`));
|
|
1727
|
-
}
|
|
1728
|
-
catch {
|
|
1729
|
-
/* ignore */
|
|
1730
|
-
}
|
|
1731
|
-
try {
|
|
1732
|
-
unlinkSync(join(orgsDir, `${orgName}-run.md`));
|
|
1733
|
-
}
|
|
1734
|
-
catch {
|
|
1735
|
-
/* ignore */
|
|
1736
|
-
}
|
|
1737
|
-
log(output.success(`Org "${orgName}" deleted (${removed} file(s) removed).`));
|
|
1738
|
-
return { success: true };
|
|
1739
|
-
};
|
|
1740
|
-
/** Clear a stale `running` record from runtime.json. This is the state `org status`
|
|
1741
|
-
* reads, so mark-complete MUST touch it — the dashboard's run:complete event alone
|
|
1742
|
-
* left `org status` reporting the same "crashed" line it had just told the user to
|
|
1743
|
-
* fix with this exact command. Refuses when the recorded pid is still alive: a live
|
|
1744
|
-
* daemon would just rewrite the file, and `org stop` is the right command there. */
|
|
1745
|
-
const clearStaleRuntime = (cwd, name) => {
|
|
1746
|
-
const rtPath = join(cwd, ORG_DIR, name, 'runtime.json');
|
|
1747
|
-
if (!existsSync(rtPath))
|
|
1748
|
-
return { cleared: false, reason: 'absent' };
|
|
1749
|
-
let rt;
|
|
1750
|
-
try {
|
|
1751
|
-
rt = JSON.parse(readFileSync(rtPath, 'utf8'));
|
|
1752
|
-
}
|
|
1753
|
-
catch (err) {
|
|
1754
|
-
return {
|
|
1755
|
-
cleared: false,
|
|
1756
|
-
reason: 'unreadable',
|
|
1757
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
1758
|
-
};
|
|
1759
|
-
}
|
|
1760
|
-
if (rt.status !== 'running' && rt.status !== 'crashed')
|
|
1761
|
-
return { cleared: false, reason: 'not-running' };
|
|
1762
|
-
if (rt.status === 'running' && rt.pid) {
|
|
1763
|
-
try {
|
|
1764
|
-
process.kill(rt.pid, 0);
|
|
1765
|
-
return { cleared: false, reason: 'alive', detail: String(rt.pid) };
|
|
1766
|
-
}
|
|
1767
|
-
catch {
|
|
1768
|
-
/* pid is gone — this is exactly the stale case mark-complete exists for */
|
|
1769
|
-
}
|
|
1770
|
-
}
|
|
1771
|
-
// Same shape stopOrg's persistState() writes, so every reader (org status,
|
|
1772
|
-
// isOrgRunning, the mastermind-org* skills' jq checks) sees a stopped org.
|
|
1773
|
-
writeFileSync(rtPath, JSON.stringify({
|
|
1774
|
-
status: 'stopped',
|
|
1775
|
-
run: rt.run,
|
|
1776
|
-
pid: rt.pid,
|
|
1777
|
-
updated: new Date().toISOString(),
|
|
1778
|
-
closedBy: 'mark-complete',
|
|
1779
|
-
}, null, 2));
|
|
1780
|
-
return { cleared: true, run: rt.run };
|
|
1781
|
-
};
|
|
1782
|
-
const markCompleteAction = async (ctx) => {
|
|
1783
|
-
const orgName = ctx.args[0];
|
|
1784
|
-
if (!orgName || !ORG_NAME_RE.test(orgName)) {
|
|
1785
|
-
log(output.error('Usage: monomind org mark-complete <name>'));
|
|
1786
|
-
return { success: false, message: 'valid org name required' };
|
|
1787
|
-
}
|
|
1788
|
-
const cwd = resolve(ctx.cwd || process.cwd());
|
|
1789
|
-
// Reject an org that does not exist, using the same check as runAction. Without
|
|
1790
|
-
// it `org mark-complete nosuchorg` printed "local state was cleared" and exited
|
|
1791
|
-
// 0 — a typo looked like a successful cleanup.
|
|
1792
|
-
const orgsDir = join(cwd, ORG_DIR);
|
|
1793
|
-
if (!existsSync(join(orgsDir, `${orgName}.json`))) {
|
|
1794
|
-
const known = existsSync(orgsDir)
|
|
1795
|
-
? listOrgConfigFiles(orgsDir).map((f) => f.replace(/\.json$/, ''))
|
|
1796
|
-
: [];
|
|
1797
|
-
log(output.error(`Org not found: ${orgName}${known.length ? ` — available: ${known.join(', ')}` : ''}`));
|
|
1798
|
-
return { success: false, message: 'org not found' };
|
|
1799
|
-
}
|
|
1800
|
-
// 1) Local runtime.json — the state `org status` actually reads. Done first and
|
|
1801
|
-
// independently of the dashboard so the recommended remedy works with no server.
|
|
1802
|
-
const local = clearStaleRuntime(cwd, orgName);
|
|
1803
|
-
if (!local.cleared && local.reason === 'alive') {
|
|
1804
|
-
log(output.error(`Org "${orgName}" is still running (pid ${local.detail}) — stop it with "monomind org stop ${orgName}" instead.`));
|
|
1805
|
-
return { success: false, message: 'org is running' };
|
|
1806
|
-
}
|
|
1807
|
-
if (local.cleared)
|
|
1808
|
-
log(output.success(`Cleared stale runtime state for "${orgName}"${local.run ? ` (run ${local.run})` : ''}.`));
|
|
1809
|
-
else if (local.reason === 'unreadable')
|
|
1810
|
-
log(output.warning(`runtime.json for "${orgName}" is unreadable (${local.detail}) — left untouched.`));
|
|
1811
|
-
else
|
|
1812
|
-
log(output.info(`No stale runtime state for "${orgName}" (runtime.json ${local.reason === 'absent' ? 'absent' : 'already not running'}).`));
|
|
1813
|
-
// 2) Dashboard run:complete event — best effort. A missing/unauthorized dashboard
|
|
1814
|
-
// must not make the command fail after the local state was already cleared.
|
|
1815
|
-
let ctrlUrl = 'http://localhost:4242';
|
|
1816
|
-
try {
|
|
1817
|
-
const ctl = JSON.parse(readFileSync(join(cwd, '.monomind', 'control.json'), 'utf8'));
|
|
1818
|
-
if (ctl.url)
|
|
1819
|
-
ctrlUrl = ctl.url;
|
|
1820
|
-
}
|
|
1821
|
-
catch {
|
|
1822
|
-
/* default */
|
|
1823
|
-
}
|
|
1824
|
-
try {
|
|
1825
|
-
// All dashboard /api routes are auth-gated — attach the local session token.
|
|
1826
|
-
let auth = '';
|
|
1827
|
-
try {
|
|
1828
|
-
auth = readFileSync(join(cwd, '.monomind', 'dashboard-token'), 'utf8').trim();
|
|
1829
|
-
}
|
|
1830
|
-
catch {
|
|
1831
|
-
/* server may be pre-auth */
|
|
1832
|
-
}
|
|
1833
|
-
// Bounded. Updating the dashboard is best-effort — the local state has
|
|
1834
|
-
// already been cleared by this point — but the fetch had no timeout, so a
|
|
1835
|
-
// dashboard that holds the port without answering (a wedged build from an
|
|
1836
|
-
// earlier session; see the stale-dashboard issue) hung `mark-complete`
|
|
1837
|
-
// indefinitely. "Unreachable" and "not answering" must cost the same.
|
|
1838
|
-
const res = await fetch(`${ctrlUrl}/api/orgs/${encodeURIComponent(orgName)}/mark-complete`, {
|
|
1839
|
-
method: 'POST',
|
|
1840
|
-
headers: auth ? { 'x-monomind-token': auth } : {},
|
|
1841
|
-
signal: AbortSignal.timeout(5_000),
|
|
1842
|
-
});
|
|
1843
|
-
const body = await res.json().catch(() => ({}));
|
|
1844
|
-
if (!res.ok) {
|
|
1845
|
-
log(output.warning(`Dashboard not updated (${res.status}: ${body.error || 'unknown error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
|
|
1846
|
-
}
|
|
1847
|
-
else {
|
|
1848
|
-
const runId = body.runId;
|
|
1849
|
-
log(output.success(`Dashboard run marked complete for "${orgName}"${runId ? ` (run ${runId})` : ''}.`));
|
|
1850
|
-
}
|
|
1851
|
-
}
|
|
1852
|
-
catch (err) {
|
|
1853
|
-
log(output.warning(`Dashboard unreachable at ${ctrlUrl} (${err instanceof Error ? err.message : 'error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
|
|
1854
|
-
}
|
|
1855
|
-
return local.cleared
|
|
1856
|
-
? { success: true, message: `run marked complete for ${orgName}` }
|
|
1857
|
-
: { success: true, message: `nothing to clear for ${orgName}` };
|
|
1858
|
-
};
|
|
1859
|
-
const migrateAction = async (ctx) => {
|
|
1860
|
-
const validated = validateOrgName(ctx.args[0]);
|
|
1861
|
-
if (!validated.ok)
|
|
1862
|
-
return validated.result;
|
|
1863
|
-
const name = validated.name;
|
|
1864
|
-
const cwd = ctx.cwd;
|
|
1865
|
-
const cfgPath = join(cwd, ORG_DIR, `${name}.json`);
|
|
1866
|
-
if (!existsSync(cfgPath)) {
|
|
1867
|
-
log(output.error(`Org not found: ${name}`));
|
|
1868
|
-
return { success: false, message: 'org not found' };
|
|
1869
|
-
}
|
|
1870
|
-
if (isOrgRunning(cwd, name)) {
|
|
1871
|
-
log(output.error(`Org "${name}" is currently running — stop it first, then migrate.`));
|
|
1872
|
-
return { success: false, message: 'org is running' };
|
|
1873
|
-
}
|
|
1874
|
-
try {
|
|
1875
|
-
const outcome = migrateOrgFile(cfgPath, join(cwd, ORG_DIR, `${name}.v1.json`));
|
|
1876
|
-
if (outcome.status === 'already-v2') {
|
|
1877
|
-
log(output.info(`${name}: already v2 — nothing to migrate.`));
|
|
1878
|
-
return { success: true, message: 'already v2' };
|
|
1879
|
-
}
|
|
1880
|
-
log(output.success(`${name}: migrated to v2 (backup: ${name}.v1.json)`));
|
|
1881
|
-
for (const d of outcome.dropped)
|
|
1882
|
-
log(output.info(` dropped v1 field: ${d}`));
|
|
1883
|
-
for (const n of outcome.notes)
|
|
1884
|
-
log(output.info(` ${n}`));
|
|
1885
|
-
log(output.info(` run it with: monomind org run ${name}`));
|
|
1886
|
-
return { success: true, message: `migrated ${name}` };
|
|
1887
|
-
}
|
|
1888
|
-
catch (err) {
|
|
1889
|
-
log(output.error(`Cannot migrate ${name}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1890
|
-
return { success: false, message: 'migration produced an invalid config' };
|
|
1891
|
-
}
|
|
1892
|
-
};
|
|
1893
13
|
export const orgCommand = {
|
|
1894
14
|
name: 'org',
|
|
1895
15
|
description: 'SDK-based org runtime — run agent organizations as a controlled daemon',
|
|
1896
16
|
subcommands: [
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
const { orgSkillsAction } = await import('./org-skills.js');
|
|
1934
|
-
return orgSkillsAction(ctx);
|
|
1935
|
-
},
|
|
1936
|
-
},
|
|
1937
|
-
{
|
|
1938
|
-
name: 'run',
|
|
1939
|
-
description: 'Start an org (foreground daemon)',
|
|
1940
|
-
options: [
|
|
1941
|
-
{ name: 'task', description: 'Override the org goal for this run', type: 'string' },
|
|
1942
|
-
{
|
|
1943
|
-
name: 'resume',
|
|
1944
|
-
description: 'Resume an org run from its persisted checkpoint instead of starting fresh',
|
|
1945
|
-
type: 'boolean',
|
|
1946
|
-
},
|
|
1947
|
-
{
|
|
1948
|
-
name: 'cross-process',
|
|
1949
|
-
description: 'Discover and message orgs hosted by other monomind processes on this machine (default true)',
|
|
1950
|
-
type: 'boolean',
|
|
1951
|
-
default: true,
|
|
1952
|
-
},
|
|
1953
|
-
{
|
|
1954
|
-
name: 'dry-run',
|
|
1955
|
-
description: "Validate and print each role's briefing without starting any agent sessions",
|
|
1956
|
-
type: 'boolean',
|
|
1957
|
-
},
|
|
1958
|
-
{
|
|
1959
|
-
name: 'budget-usd',
|
|
1960
|
-
description: 'Hard-stop the run if the upfront cost estimate exceeds this USD value (e.g. --budget-usd 5)',
|
|
1961
|
-
type: 'number',
|
|
1962
|
-
},
|
|
1963
|
-
{
|
|
1964
|
-
name: 'yes',
|
|
1965
|
-
short: 'y',
|
|
1966
|
-
description: 'Skip the interactive cost-estimate confirmation prompt',
|
|
1967
|
-
type: 'boolean',
|
|
1968
|
-
},
|
|
1969
|
-
{
|
|
1970
|
-
name: 'auto-approve',
|
|
1971
|
-
description: 'Comma-separated gated tools every role may call without human approval for this run (e.g. org_complete). -y alone approves nothing',
|
|
1972
|
-
type: 'string',
|
|
1973
|
-
},
|
|
1974
|
-
],
|
|
1975
|
-
examples: [
|
|
1976
|
-
{
|
|
1977
|
-
command: 'monomind org run growth --task "weekly report"',
|
|
1978
|
-
description: 'Run the growth org once with a task',
|
|
1979
|
-
},
|
|
1980
|
-
{
|
|
1981
|
-
command: 'monomind org run growth --task "weekly report" -y --auto-approve org_complete',
|
|
1982
|
-
description: 'Unattended one-shot run that may end itself without a human approving org_complete',
|
|
1983
|
-
},
|
|
1984
|
-
],
|
|
1985
|
-
action: runAction,
|
|
1986
|
-
},
|
|
1987
|
-
{ name: 'stop', description: 'Request a running org daemon to stop', action: stopAction },
|
|
1988
|
-
{
|
|
1989
|
-
name: 'pause',
|
|
1990
|
-
description: 'Pause an org — current turns finish, no new cycles start',
|
|
1991
|
-
action: pauseAction,
|
|
1992
|
-
},
|
|
1993
|
-
{ name: 'resume', description: 'Resume a paused org', action: resumeAction },
|
|
1994
|
-
{
|
|
1995
|
-
name: 'reload',
|
|
1996
|
-
description: 'Hot-reload an org definition without stopping sessions',
|
|
1997
|
-
action: reloadAction,
|
|
1998
|
-
},
|
|
1999
|
-
{ name: 'status', description: 'Show runtime state of orgs', action: statusAction },
|
|
2000
|
-
{
|
|
2001
|
-
name: 'serve',
|
|
2002
|
-
description: 'Start the daemon server only (hosts scheduled orgs)',
|
|
2003
|
-
options: [
|
|
2004
|
-
{
|
|
2005
|
-
name: 'cross-process',
|
|
2006
|
-
description: 'Discover and message orgs hosted by other monomind processes on this machine (default true)',
|
|
2007
|
-
type: 'boolean',
|
|
2008
|
-
default: true,
|
|
2009
|
-
},
|
|
2010
|
-
],
|
|
2011
|
-
action: serveAction,
|
|
2012
|
-
},
|
|
2013
|
-
{
|
|
2014
|
-
name: 'supervisor',
|
|
2015
|
-
description: 'Print (or --install) a launchd/systemd unit that keeps `org serve` running',
|
|
2016
|
-
options: [
|
|
2017
|
-
{ name: 'format', description: 'launchd or systemd (default: platform)', type: 'string' },
|
|
2018
|
-
{
|
|
2019
|
-
name: 'install',
|
|
2020
|
-
description: 'Write the unit into the per-user location',
|
|
2021
|
-
type: 'boolean',
|
|
2022
|
-
},
|
|
2023
|
-
],
|
|
2024
|
-
action: supervisorAction,
|
|
2025
|
-
},
|
|
2026
|
-
{
|
|
2027
|
-
name: 'test-loop',
|
|
2028
|
-
description: 'Run the org e2e verification loop N times',
|
|
2029
|
-
options: [
|
|
2030
|
-
{ name: 'times', short: 'n', description: 'Iterations', type: 'number', default: 5 },
|
|
2031
|
-
{
|
|
2032
|
-
name: 'scenario',
|
|
2033
|
-
description: 'Run a declarative scenario file (.monomind/scenarios/<file>) instead of the built-in fixture — structural dry-run only',
|
|
2034
|
-
type: 'string',
|
|
2035
|
-
},
|
|
2036
|
-
],
|
|
2037
|
-
action: testLoopAction,
|
|
2038
|
-
},
|
|
2039
|
-
{
|
|
2040
|
-
name: 'logs',
|
|
2041
|
-
description: 'Show (or follow) the formatted event log of an org run',
|
|
2042
|
-
options: [
|
|
2043
|
-
{ name: 'run', description: 'Run id (default: latest)', type: 'string' },
|
|
2044
|
-
{ name: 'role', description: 'Only events from/to this role', type: 'string' },
|
|
2045
|
-
{
|
|
2046
|
-
name: 'filter-tool',
|
|
2047
|
-
description: 'Filter events by tool name (e.g., Write, Edit)',
|
|
2048
|
-
type: 'string',
|
|
2049
|
-
},
|
|
2050
|
-
{ name: 'filter-role', description: 'Filter events by role ID', type: 'string' },
|
|
2051
|
-
{
|
|
2052
|
-
name: 'tools-only',
|
|
2053
|
-
description: 'Show only tool events (exclude messages/status/audit)',
|
|
2054
|
-
type: 'boolean',
|
|
2055
|
-
},
|
|
2056
|
-
{
|
|
2057
|
-
name: 'audit-filter',
|
|
2058
|
-
description: 'Filter audit events by decision (allow|deny)',
|
|
2059
|
-
type: 'string',
|
|
2060
|
-
},
|
|
2061
|
-
{ name: 'follow', short: 'f', description: 'Keep tailing until Ctrl-C', type: 'boolean' },
|
|
2062
|
-
],
|
|
2063
|
-
examples: [
|
|
2064
|
-
{ command: 'monomind org logs growth --follow', description: 'Live-tail the latest run' },
|
|
2065
|
-
{
|
|
2066
|
-
command: 'monomind org logs growth --tools-only',
|
|
2067
|
-
description: 'Show only tool call events',
|
|
2068
|
-
},
|
|
2069
|
-
],
|
|
2070
|
-
action: async (ctx) => {
|
|
2071
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2072
|
-
if (!v.ok)
|
|
2073
|
-
return v.result;
|
|
2074
|
-
const { logsAction } = await import('./org-observe.js');
|
|
2075
|
-
return logsAction(ctx, v.name);
|
|
2076
|
-
},
|
|
2077
|
-
},
|
|
2078
|
-
{
|
|
2079
|
-
name: 'events',
|
|
2080
|
-
description: "Tail a run's bus events as NDJSON — the machine streaming surface (agent-exec-protocol.md §7.3)",
|
|
2081
|
-
options: [
|
|
2082
|
-
{ name: 'run', description: 'Run id (default: latest)', type: 'string' },
|
|
2083
|
-
{ name: 'follow', short: 'f', description: 'Keep tailing until Ctrl-C', type: 'boolean' },
|
|
2084
|
-
{
|
|
2085
|
-
name: 'since',
|
|
2086
|
-
description: 'Replay cursor: an event id or ISO-8601 timestamp',
|
|
2087
|
-
type: 'string',
|
|
2088
|
-
},
|
|
2089
|
-
{
|
|
2090
|
-
name: 'ndjson',
|
|
2091
|
-
description: 'Accepted for spec symmetry — NDJSON is the only output mode',
|
|
2092
|
-
type: 'boolean',
|
|
2093
|
-
},
|
|
2094
|
-
],
|
|
2095
|
-
examples: [
|
|
2096
|
-
{
|
|
2097
|
-
command: 'monomind org events growth --follow',
|
|
2098
|
-
description: 'Live NDJSON tail of the latest run',
|
|
2099
|
-
},
|
|
2100
|
-
],
|
|
2101
|
-
action: async (ctx) => {
|
|
2102
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2103
|
-
if (!v.ok)
|
|
2104
|
-
return v.result;
|
|
2105
|
-
const { eventsAction } = await import('./org-observe.js');
|
|
2106
|
-
return eventsAction(ctx, v.name);
|
|
2107
|
-
},
|
|
2108
|
-
},
|
|
2109
|
-
{
|
|
2110
|
-
name: 'watch',
|
|
2111
|
-
description: "Live-tail one role's assistant chat text (any runtime) — a filtered, friendlier `logs --follow`",
|
|
2112
|
-
options: [
|
|
2113
|
-
{ name: 'run', description: 'Run id (default: latest)', type: 'string' },
|
|
2114
|
-
{
|
|
2115
|
-
name: 'follow',
|
|
2116
|
-
description: 'Set --follow=false to print current output once and exit instead of live-tailing',
|
|
2117
|
-
type: 'boolean',
|
|
2118
|
-
default: true,
|
|
2119
|
-
},
|
|
2120
|
-
{
|
|
2121
|
-
name: 'verbose',
|
|
2122
|
-
description: 'Also interleave status events (restart/crash/state-change) into the transcript',
|
|
2123
|
-
type: 'boolean',
|
|
2124
|
-
},
|
|
2125
|
-
{
|
|
2126
|
-
name: 'stats',
|
|
2127
|
-
description: 'Print a running token/cost line as usage events arrive',
|
|
2128
|
-
type: 'boolean',
|
|
2129
|
-
},
|
|
2130
|
-
],
|
|
2131
|
-
examples: [
|
|
2132
|
-
{
|
|
2133
|
-
command: 'monomind org watch growth researcher',
|
|
2134
|
-
description: "Watch the researcher role's live output",
|
|
2135
|
-
},
|
|
2136
|
-
{
|
|
2137
|
-
command: 'monomind org watch growth researcher --verbose --stats',
|
|
2138
|
-
description: 'Also show restarts/crashes and a running token/cost total',
|
|
2139
|
-
},
|
|
2140
|
-
],
|
|
2141
|
-
action: async (ctx) => {
|
|
2142
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2143
|
-
if (!v.ok)
|
|
2144
|
-
return v.result;
|
|
2145
|
-
const { watchAction } = await import('./org-observe.js');
|
|
2146
|
-
return watchAction(ctx, v.name);
|
|
2147
|
-
},
|
|
2148
|
-
},
|
|
2149
|
-
{
|
|
2150
|
-
name: 'report',
|
|
2151
|
-
description: 'Summarize an org run: outcome, per-role activity, tokens, assets, crashes',
|
|
2152
|
-
options: [
|
|
2153
|
-
{ name: 'run', description: 'Run id (default: latest)', type: 'string' },
|
|
2154
|
-
{ name: 'all', description: 'List all recorded runs from history', type: 'boolean' },
|
|
2155
|
-
{ name: 'by-role', description: 'Show per-role cost breakdown', type: 'boolean' },
|
|
2156
|
-
{ name: 'audit', description: 'Show tool audit trail', type: 'boolean' },
|
|
2157
|
-
{
|
|
2158
|
-
name: 'tool',
|
|
2159
|
-
description: 'Filter tool audit by tool name (with --audit)',
|
|
2160
|
-
type: 'string',
|
|
2161
|
-
},
|
|
2162
|
-
{ name: 'format', description: 'Output format (mermaid for flowchart)', type: 'string' },
|
|
2163
|
-
],
|
|
2164
|
-
examples: [
|
|
2165
|
-
{ command: 'monomind org report growth', description: 'Report on the latest run' },
|
|
2166
|
-
],
|
|
2167
|
-
action: async (ctx) => {
|
|
2168
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2169
|
-
if (!v.ok)
|
|
2170
|
-
return v.result;
|
|
2171
|
-
const { reportAction } = await import('./org-observe.js');
|
|
2172
|
-
return reportAction(ctx, v.name);
|
|
2173
|
-
},
|
|
2174
|
-
},
|
|
2175
|
-
{
|
|
2176
|
-
name: 'memory',
|
|
2177
|
-
description: "Inspect an org's cross-run memory and knowledge graph (stats | search <query> | rules | rollback <run-ref> | promote <run-ref>)",
|
|
2178
|
-
examples: [
|
|
2179
|
-
{ command: 'monomind org memory growth stats', description: 'KG size and namespaces' },
|
|
2180
|
-
{
|
|
2181
|
-
command: 'monomind org memory growth search "launch checklist"',
|
|
2182
|
-
description: 'Search org memory + KG',
|
|
2183
|
-
},
|
|
2184
|
-
{
|
|
2185
|
-
command: 'monomind org memory growth rollback run:m4x2',
|
|
2186
|
-
description: "Withdraw one run's support from this org's KG",
|
|
2187
|
-
},
|
|
2188
|
-
{
|
|
2189
|
-
command: 'monomind org memory growth promote run:m4x2',
|
|
2190
|
-
description: "Share one run's claims with project-wide knowledge",
|
|
2191
|
-
},
|
|
2192
|
-
],
|
|
2193
|
-
action: async (ctx) => {
|
|
2194
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2195
|
-
if (!v.ok)
|
|
2196
|
-
return v.result;
|
|
2197
|
-
const sub = String(ctx.args[1] ?? 'stats');
|
|
2198
|
-
const { join } = await import('node:path');
|
|
2199
|
-
const cwd = ctx.cwd || process.cwd();
|
|
2200
|
-
const dbPath = join(cwd, '.monomind', 'org-memory');
|
|
2201
|
-
const kg = await import('../memory/memory-kg.js');
|
|
2202
|
-
const bridge = await import('../memory/memory-bridge.js');
|
|
2203
|
-
const { orgKgScope, orgMemoryNamespace } = await import('../orgrt/org-memory.js');
|
|
2204
|
-
// Every KG operation below is scoped to the requested org, so the org
|
|
2205
|
-
// name in the output describes what was actually read, not just what
|
|
2206
|
-
// was asked for. Reads of the shared store are unchanged.
|
|
2207
|
-
const scope = orgKgScope(v.name);
|
|
2208
|
-
const kgNs = kg.kgNamespaces(scope);
|
|
2209
|
-
// Flat org memory is namespaced by the org DEFINITION, not by name —
|
|
2210
|
-
// resolve it the way the runtime writes it (B4), so a configured
|
|
2211
|
-
// `memory_namespace` is searched instead of a guessed `org:<name>`.
|
|
2212
|
-
const defPath = join(cwd, ORG_DIR, `${v.name}.json`);
|
|
2213
|
-
const flatNs = existsSync(defPath)
|
|
2214
|
-
? orgMemoryNamespace(v.name, OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8'))))
|
|
2215
|
-
: `org:${v.name}`;
|
|
2216
|
-
try {
|
|
2217
|
-
if (sub === 'stats') {
|
|
2218
|
-
const [stats, glossary, backend] = await Promise.all([
|
|
2219
|
-
kg.kgStats({ dbPath, scope }),
|
|
2220
|
-
kg.kgGlossary({ dbPath, limit: 15, scope }),
|
|
2221
|
-
bridge.bridgeGetBackendStats(dbPath),
|
|
2222
|
-
]);
|
|
2223
|
-
// The backend reports every namespace in the SHARED store, so
|
|
2224
|
-
// listing it raw put other orgs' namespaces (and their counts)
|
|
2225
|
-
// under this org's name. Keep only what this org owns.
|
|
2226
|
-
// Spread kgNs rather than listing namespaces: the identity work
|
|
2227
|
-
// added `names` (the entity name index), and a hand-written list
|
|
2228
|
-
// silently omits any namespace added later, hiding rows this org
|
|
2229
|
-
// does own.
|
|
2230
|
-
const owned = new Set([...Object.values(kgNs), flatNs]);
|
|
2231
|
-
const byNs = Object.fromEntries(Object.entries(backend?.entriesByNamespace ?? {}).filter(([ns]) => owned.has(ns) || ns.startsWith(`agent:${flatNs}:`)));
|
|
2232
|
-
if (ctx.flags.format === 'json') {
|
|
2233
|
-
const payload = { v: 1, org: v.name, ...stats, glossary, namespaces: byNs };
|
|
2234
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2235
|
-
return { success: true, data: payload };
|
|
2236
|
-
}
|
|
2237
|
-
log(output.info(`Knowledge graph: ${stats.nodes} entities, ${stats.edges} relations, ${stats.rules} rules`));
|
|
2238
|
-
if (glossary.length)
|
|
2239
|
-
log(output.info(`Top entities: ${glossary.join(', ')}`));
|
|
2240
|
-
for (const [ns, count] of Object.entries(byNs))
|
|
2241
|
-
log(output.info(` ${ns}: ${count} entries`));
|
|
2242
|
-
return {
|
|
2243
|
-
success: true,
|
|
2244
|
-
message: 'org memory stats',
|
|
2245
|
-
data: { ...stats, namespaces: byNs },
|
|
2246
|
-
};
|
|
2247
|
-
}
|
|
2248
|
-
if (sub === 'search') {
|
|
2249
|
-
const q = ctx.args.slice(2).join(' ');
|
|
2250
|
-
if (!q)
|
|
2251
|
-
return { success: false, message: 'usage: org memory <org> search <query>' };
|
|
2252
|
-
const [mem, graph] = await Promise.all([
|
|
2253
|
-
bridge.bridgeSearchEntries({
|
|
2254
|
-
query: q,
|
|
2255
|
-
namespace: flatNs,
|
|
2256
|
-
limit: 5,
|
|
2257
|
-
dbPath,
|
|
2258
|
-
}),
|
|
2259
|
-
kg.kgSearch({ query: q, dbPath, limit: 8, scope }),
|
|
2260
|
-
]);
|
|
2261
|
-
if (ctx.flags.format === 'json') {
|
|
2262
|
-
const payload = {
|
|
2263
|
-
v: 1,
|
|
2264
|
-
org: v.name,
|
|
2265
|
-
query: q,
|
|
2266
|
-
memories: mem?.results ?? [],
|
|
2267
|
-
triplets: graph.triplets,
|
|
2268
|
-
kg_context: graph.context ?? null,
|
|
2269
|
-
};
|
|
2270
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2271
|
-
return { success: true, data: payload };
|
|
2272
|
-
}
|
|
2273
|
-
for (const r of mem?.results ?? [])
|
|
2274
|
-
log(output.info(`[${r.score.toFixed(2)}] ${r.key}: ${r.content.slice(0, 160)}`));
|
|
2275
|
-
if (graph.context)
|
|
2276
|
-
log(output.info(`\nKnowledge graph:\n${graph.context}`));
|
|
2277
|
-
return {
|
|
2278
|
-
success: true,
|
|
2279
|
-
message: `${(mem?.results ?? []).length} memories, ${graph.triplets.length} triplets`,
|
|
2280
|
-
};
|
|
2281
|
-
}
|
|
2282
|
-
if (sub === 'rules') {
|
|
2283
|
-
const rules = await kg.kgListRules({ dbPath, limit: 50, scope });
|
|
2284
|
-
if (ctx.flags.format === 'json') {
|
|
2285
|
-
const payload = { v: 1, org: v.name, items: rules };
|
|
2286
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2287
|
-
return { success: true, data: payload };
|
|
2288
|
-
}
|
|
2289
|
-
for (const r of rules)
|
|
2290
|
-
log(output.info(`- ${r.rule.slice(0, 200)}`));
|
|
2291
|
-
return { success: true, message: `${rules.length} rules`, data: { rules } };
|
|
2292
|
-
}
|
|
2293
|
-
if (sub === 'rollback') {
|
|
2294
|
-
const ref = ctx.args[2];
|
|
2295
|
-
if (!ref)
|
|
2296
|
-
return {
|
|
2297
|
-
success: false,
|
|
2298
|
-
message: 'usage: org memory <org> rollback <origin-ref> (e.g. run:m4x2)',
|
|
2299
|
-
};
|
|
2300
|
-
// Scoped: the ref is resolved inside this org's namespaces and
|
|
2301
|
-
// origin space, so the org name in the command is an ownership
|
|
2302
|
-
// restriction rather than a label on an unfiltered rollback.
|
|
2303
|
-
const res = await kg.kgRollback({ originRef: ref, scope, dbPath });
|
|
2304
|
-
if (ctx.flags.format === 'json') {
|
|
2305
|
-
const payload = { v: 1, org: v.name, ref, ...res };
|
|
2306
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2307
|
-
return { success: res.success, data: payload };
|
|
2308
|
-
}
|
|
2309
|
-
log(output.info(`Rolled back ${ref} for org ${v.name}: ${res.deleted} deleted, ${res.retained} retained (shared with other origins)`));
|
|
2310
|
-
return { success: res.success, message: `rollback ${ref}`, data: res };
|
|
2311
|
-
}
|
|
2312
|
-
if (sub === 'promote') {
|
|
2313
|
-
const ref = ctx.args[2];
|
|
2314
|
-
if (!ref)
|
|
2315
|
-
return {
|
|
2316
|
-
success: false,
|
|
2317
|
-
message: 'usage: org memory <org> promote <origin-ref> (e.g. run:m4x2)',
|
|
2318
|
-
};
|
|
2319
|
-
const res = await kg.kgPromote({ originRef: ref, from: scope, dbPath });
|
|
2320
|
-
if (ctx.flags.format === 'json') {
|
|
2321
|
-
const payload = { v: 1, org: v.name, ref, ...res };
|
|
2322
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2323
|
-
return { success: res.success, data: payload };
|
|
2324
|
-
}
|
|
2325
|
-
log(output.info(res.success
|
|
2326
|
-
? `Promoted ${ref} from org ${v.name} to project-shared knowledge: ${res.nodes} entities, ${res.edges} relations, ${res.rules} rules. Withdraw with origin ref ${res.promotedAs}.`
|
|
2327
|
-
: `Promotion of ${ref} failed: ${res.error ?? 'unknown error'}`));
|
|
2328
|
-
return { success: res.success, message: `promote ${ref}`, data: res };
|
|
2329
|
-
}
|
|
2330
|
-
return {
|
|
2331
|
-
success: false,
|
|
2332
|
-
message: `unknown subcommand "${sub}" — use stats | search | rules | rollback | promote`,
|
|
2333
|
-
};
|
|
2334
|
-
}
|
|
2335
|
-
finally {
|
|
2336
|
-
await bridge.shutdownBridge().catch(() => {
|
|
2337
|
-
/* best effort */
|
|
2338
|
-
});
|
|
2339
|
-
}
|
|
2340
|
-
},
|
|
2341
|
-
},
|
|
2342
|
-
{
|
|
2343
|
-
name: 'costs',
|
|
2344
|
-
description: 'Show per-role cost tracking from runtime.json',
|
|
2345
|
-
options: [{ name: 'run', description: 'Run ID (defaults to latest)', type: 'string' }],
|
|
2346
|
-
examples: [
|
|
2347
|
-
{ command: 'monomind org costs growth', description: 'Show cost breakdown for latest run' },
|
|
2348
|
-
{
|
|
2349
|
-
command: 'monomind org costs growth --run run-20240130-123456',
|
|
2350
|
-
description: 'Show cost breakdown for specific run',
|
|
2351
|
-
},
|
|
2352
|
-
],
|
|
2353
|
-
action: async (ctx) => {
|
|
2354
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2355
|
-
if (!v.ok)
|
|
2356
|
-
return v.result;
|
|
2357
|
-
const { costsAction } = await import('./org-observe.js');
|
|
2358
|
-
return costsAction(ctx, v.name);
|
|
2359
|
-
},
|
|
2360
|
-
},
|
|
2361
|
-
{
|
|
2362
|
-
name: 'inbox',
|
|
2363
|
-
description: 'Deliver an inbound cross-org message (live to a running org, queued to inbox.jsonl otherwise) — remote.ts shells out to this over SSH',
|
|
2364
|
-
options: [
|
|
2365
|
-
{
|
|
2366
|
-
name: 'json',
|
|
2367
|
-
description: 'JSON payload: {"from":"orgA:role","subject":"...","body":"..."}',
|
|
2368
|
-
type: 'string',
|
|
2369
|
-
},
|
|
2370
|
-
{ name: 'to', description: "Target role (default: the org's coordinator)", type: 'string' },
|
|
2371
|
-
{
|
|
2372
|
-
name: 'from',
|
|
2373
|
-
description: 'Sender, qualified "org:role" (alternative to --json)',
|
|
2374
|
-
type: 'string',
|
|
2375
|
-
},
|
|
2376
|
-
{ name: 'subject', description: 'Subject (alternative to --json)', type: 'string' },
|
|
2377
|
-
{ name: 'body', description: 'Body (alternative to --json)', type: 'string' },
|
|
2378
|
-
],
|
|
2379
|
-
examples: [
|
|
2380
|
-
{
|
|
2381
|
-
command: 'monomind org inbox growth --json \'{"from":"sales:boss","subject":"leads","body":"..."}\'',
|
|
2382
|
-
description: 'Deliver a message to the growth org',
|
|
2383
|
-
},
|
|
2384
|
-
{
|
|
2385
|
-
command: 'monomind org inbox growth --to lead --from growth:publisher-bot --subject "re: post" --body "done" --format json',
|
|
2386
|
-
description: 'Reply as an automation role; prints {"v":1,"org","to","from","delivery","receipt","messageId"}',
|
|
2387
|
-
},
|
|
2388
|
-
],
|
|
2389
|
-
action: async (ctx) => {
|
|
2390
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2391
|
-
if (!v.ok)
|
|
2392
|
-
return v.result;
|
|
2393
|
-
const { inboxAction } = await import('./org-observe.js');
|
|
2394
|
-
return inboxAction(ctx, v.name);
|
|
2395
|
-
},
|
|
2396
|
-
},
|
|
2397
|
-
{
|
|
2398
|
-
name: 'flow',
|
|
2399
|
-
description: 'Export org flow as Mermaid diagram',
|
|
2400
|
-
options: [{ name: 'run', description: 'Run ID (defaults to latest)', type: 'string' }],
|
|
2401
|
-
examples: [
|
|
2402
|
-
{
|
|
2403
|
-
command: 'monomind org flow growth --run run-20250130120000',
|
|
2404
|
-
description: 'Export Mermaid flowchart',
|
|
2405
|
-
},
|
|
2406
|
-
],
|
|
2407
|
-
action: async (ctx) => {
|
|
2408
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2409
|
-
if (!v.ok)
|
|
2410
|
-
return v.result;
|
|
2411
|
-
const { flowAction } = await import('./org-observe.js');
|
|
2412
|
-
return flowAction(ctx, v.name);
|
|
2413
|
-
},
|
|
2414
|
-
},
|
|
2415
|
-
{
|
|
2416
|
-
name: 'questions',
|
|
2417
|
-
description: "List pending ask_human questions from an org's agents",
|
|
2418
|
-
options: [{ name: 'all', description: 'Include answered questions', type: 'boolean' }],
|
|
2419
|
-
examples: [
|
|
2420
|
-
{ command: 'monomind org questions growth', description: 'Show unanswered questions' },
|
|
2421
|
-
],
|
|
2422
|
-
action: async (ctx) => {
|
|
2423
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2424
|
-
if (!v.ok)
|
|
2425
|
-
return v.result;
|
|
2426
|
-
const { questionsAction } = await import('./org-observe.js');
|
|
2427
|
-
return questionsAction(ctx, v.name);
|
|
2428
|
-
},
|
|
2429
|
-
},
|
|
2430
|
-
{
|
|
2431
|
-
name: 'approvals',
|
|
2432
|
-
description: "List pending tool/action approval requests from an org's agents",
|
|
2433
|
-
options: [{ name: 'all', description: 'Include resolved approvals', type: 'boolean' }],
|
|
2434
|
-
examples: [
|
|
2435
|
-
{ command: 'monomind org approvals growth', description: 'Show pending approvals' },
|
|
2436
|
-
],
|
|
2437
|
-
action: async (ctx) => {
|
|
2438
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2439
|
-
if (!v.ok)
|
|
2440
|
-
return v.result;
|
|
2441
|
-
const { approvalsAction } = await import('./org-observe.js');
|
|
2442
|
-
return approvalsAction(ctx, v.name);
|
|
2443
|
-
},
|
|
2444
|
-
},
|
|
2445
|
-
{
|
|
2446
|
-
name: 'answer',
|
|
2447
|
-
description: 'Answer a pending ask_human question (live if the org is running, queued otherwise)',
|
|
2448
|
-
options: [
|
|
2449
|
-
{
|
|
2450
|
-
name: 'by',
|
|
2451
|
-
description: 'Resolver recorded as resolvedBy (default: human)',
|
|
2452
|
-
type: 'string',
|
|
2453
|
-
},
|
|
2454
|
-
],
|
|
2455
|
-
examples: [
|
|
2456
|
-
{
|
|
2457
|
-
command: 'monomind org answer growth q-123-ab "yes, ship it"',
|
|
2458
|
-
description: 'Answer question q-123-ab',
|
|
2459
|
-
},
|
|
2460
|
-
],
|
|
2461
|
-
action: async (ctx) => {
|
|
2462
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2463
|
-
if (!v.ok)
|
|
2464
|
-
return v.result;
|
|
2465
|
-
const { answerAction } = await import('./org-observe.js');
|
|
2466
|
-
return answerAction(ctx, v.name);
|
|
2467
|
-
},
|
|
2468
|
-
},
|
|
2469
|
-
{
|
|
2470
|
-
name: 'approve',
|
|
2471
|
-
description: 'Approve a pending tool/action approval',
|
|
2472
|
-
options: [
|
|
2473
|
-
{
|
|
2474
|
-
name: 'request',
|
|
2475
|
-
description: 'Resolve only this approval request id (apr-…)',
|
|
2476
|
-
type: 'string',
|
|
2477
|
-
},
|
|
2478
|
-
{
|
|
2479
|
-
name: 'by',
|
|
2480
|
-
description: 'Resolver recorded as resolvedBy (default: human)',
|
|
2481
|
-
type: 'string',
|
|
2482
|
-
},
|
|
2483
|
-
],
|
|
2484
|
-
examples: [
|
|
2485
|
-
{
|
|
2486
|
-
command: 'monomind org approve growth coder "Bash"',
|
|
2487
|
-
description: 'Approve Bash tool for coder role',
|
|
2488
|
-
},
|
|
2489
|
-
],
|
|
2490
|
-
action: async (ctx) => {
|
|
2491
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2492
|
-
if (!v.ok)
|
|
2493
|
-
return v.result;
|
|
2494
|
-
const { approveAction } = await import('./org-observe.js');
|
|
2495
|
-
return approveAction(ctx, v.name);
|
|
2496
|
-
},
|
|
2497
|
-
},
|
|
2498
|
-
{
|
|
2499
|
-
name: 'deny',
|
|
2500
|
-
description: 'Deny a pending tool/action approval',
|
|
2501
|
-
options: [
|
|
2502
|
-
{
|
|
2503
|
-
name: 'request',
|
|
2504
|
-
description: 'Resolve only this approval request id (apr-…)',
|
|
2505
|
-
type: 'string',
|
|
2506
|
-
},
|
|
2507
|
-
{
|
|
2508
|
-
name: 'by',
|
|
2509
|
-
description: 'Resolver recorded as resolvedBy (default: human)',
|
|
2510
|
-
type: 'string',
|
|
2511
|
-
},
|
|
2512
|
-
],
|
|
2513
|
-
examples: [
|
|
2514
|
-
{
|
|
2515
|
-
command: 'monomind org deny growth coder "Bash"',
|
|
2516
|
-
description: 'Deny Bash tool for coder role',
|
|
2517
|
-
},
|
|
2518
|
-
],
|
|
2519
|
-
action: async (ctx) => {
|
|
2520
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2521
|
-
if (!v.ok)
|
|
2522
|
-
return v.result;
|
|
2523
|
-
const { denyAction } = await import('./org-observe.js');
|
|
2524
|
-
return denyAction(ctx, v.name);
|
|
2525
|
-
},
|
|
2526
|
-
},
|
|
2527
|
-
{
|
|
2528
|
-
name: 'gates',
|
|
2529
|
-
description: "List decision gates from an org's agents",
|
|
2530
|
-
options: [{ name: 'all', description: 'Include resolved gates', type: 'boolean' }],
|
|
2531
|
-
examples: [
|
|
2532
|
-
{ command: 'monomind org gates growth', description: 'Show pending gates' },
|
|
2533
|
-
{ command: 'monomind org gates growth --all', description: 'Show all gates' },
|
|
2534
|
-
],
|
|
2535
|
-
action: async (ctx) => {
|
|
2536
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2537
|
-
if (!v.ok)
|
|
2538
|
-
return v.result;
|
|
2539
|
-
const { gatesAction } = await import('./org-observe.js');
|
|
2540
|
-
return gatesAction(ctx, v.name);
|
|
2541
|
-
},
|
|
2542
|
-
},
|
|
2543
|
-
{
|
|
2544
|
-
name: 'gate-approve',
|
|
2545
|
-
description: 'Approve a pending decision gate',
|
|
2546
|
-
options: [
|
|
2547
|
-
{
|
|
2548
|
-
name: 'by',
|
|
2549
|
-
description: 'Resolver recorded as resolvedBy (default: human)',
|
|
2550
|
-
type: 'string',
|
|
2551
|
-
},
|
|
2552
|
-
],
|
|
2553
|
-
examples: [
|
|
2554
|
-
{
|
|
2555
|
-
command: 'monomind org gate-approve growth gate-123-ab "ship it"',
|
|
2556
|
-
description: 'Approve gate with resolution',
|
|
2557
|
-
},
|
|
2558
|
-
],
|
|
2559
|
-
action: async (ctx) => {
|
|
2560
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2561
|
-
if (!v.ok)
|
|
2562
|
-
return v.result;
|
|
2563
|
-
const { gateResolveAction } = await import('./org-observe.js');
|
|
2564
|
-
return gateResolveAction(ctx, v.name, true);
|
|
2565
|
-
},
|
|
2566
|
-
},
|
|
2567
|
-
{
|
|
2568
|
-
name: 'gate-reject',
|
|
2569
|
-
description: 'Reject a pending decision gate',
|
|
2570
|
-
options: [
|
|
2571
|
-
{
|
|
2572
|
-
name: 'by',
|
|
2573
|
-
description: 'Resolver recorded as resolvedBy (default: human)',
|
|
2574
|
-
type: 'string',
|
|
2575
|
-
},
|
|
2576
|
-
],
|
|
2577
|
-
examples: [
|
|
2578
|
-
{
|
|
2579
|
-
command: 'monomind org gate-reject growth gate-123-ab "not ready"',
|
|
2580
|
-
description: 'Reject gate with reason',
|
|
2581
|
-
},
|
|
2582
|
-
],
|
|
2583
|
-
action: async (ctx) => {
|
|
2584
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2585
|
-
if (!v.ok)
|
|
2586
|
-
return v.result;
|
|
2587
|
-
const { gateResolveAction } = await import('./org-observe.js');
|
|
2588
|
-
return gateResolveAction(ctx, v.name, false);
|
|
2589
|
-
},
|
|
2590
|
-
},
|
|
2591
|
-
{
|
|
2592
|
-
name: 'replay',
|
|
2593
|
-
description: 'Time-travel debugging: replay a run\'s bus events (does not resume live execution — use "org run --resume" for that)',
|
|
2594
|
-
examples: [
|
|
2595
|
-
{
|
|
2596
|
-
command: 'monomind org replay growth run-20250130120000-abc',
|
|
2597
|
-
description: "Replay a checkpoint's events for inspection",
|
|
2598
|
-
},
|
|
2599
|
-
],
|
|
2600
|
-
action: async (ctx) => {
|
|
2601
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2602
|
-
if (!v.ok)
|
|
2603
|
-
return v.result;
|
|
2604
|
-
const { replayAction } = await import('./org-observe.js');
|
|
2605
|
-
return replayAction(ctx, v.name);
|
|
2606
|
-
},
|
|
2607
|
-
},
|
|
2608
|
-
{
|
|
2609
|
-
name: 'resume-from',
|
|
2610
|
-
description: "Resume live execution from the org's persisted checkpoint (restores mailbox/policy/session state; subject to TTL and checksum validation)",
|
|
2611
|
-
examples: [
|
|
2612
|
-
{
|
|
2613
|
-
command: 'monomind org resume-from growth',
|
|
2614
|
-
description: 'Resume growth from its last checkpoint',
|
|
2615
|
-
},
|
|
2616
|
-
],
|
|
2617
|
-
action: async (ctx) => {
|
|
2618
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2619
|
-
if (!v.ok)
|
|
2620
|
-
return v.result;
|
|
2621
|
-
const { resumeFromAction } = await import('./org-observe.js');
|
|
2622
|
-
return resumeFromAction(ctx, v.name);
|
|
2623
|
-
},
|
|
2624
|
-
},
|
|
2625
|
-
{
|
|
2626
|
-
name: 'branch',
|
|
2627
|
-
description: "Snapshot a run's event log into a new run for replay — usage: org branch <org> <run-id> <label>. The new run's id is generated; <label> is only a note recorded in its .branch-source",
|
|
2628
|
-
examples: [
|
|
2629
|
-
{
|
|
2630
|
-
command: 'monomind org branch growth run-20250130 "before the outage"',
|
|
2631
|
-
description: 'Snapshot run-20250130 into a new generated run id, noting why in .branch-source (the label does not name the run)',
|
|
2632
|
-
},
|
|
2633
|
-
{
|
|
2634
|
-
command: 'monomind org branch growth run-20250130 pre-outage --format json',
|
|
2635
|
-
description: 'Same, printing {"run": "<generated id>", ...} so a script can replay it without parsing prose',
|
|
2636
|
-
},
|
|
2637
|
-
],
|
|
2638
|
-
action: async (ctx) => {
|
|
2639
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2640
|
-
if (!v.ok)
|
|
2641
|
-
return v.result;
|
|
2642
|
-
const { branchAction } = await import('./org-observe.js');
|
|
2643
|
-
return branchAction(ctx, v.name);
|
|
2644
|
-
},
|
|
2645
|
-
},
|
|
2646
|
-
{
|
|
2647
|
-
name: 'decisions',
|
|
2648
|
-
description: 'Show Rifft-style decision traces',
|
|
2649
|
-
examples: [
|
|
2650
|
-
{
|
|
2651
|
-
command: 'monomind org decisions growth --run run-20250130',
|
|
2652
|
-
description: 'Show decision traces',
|
|
2653
|
-
},
|
|
2654
|
-
],
|
|
2655
|
-
action: async (ctx) => {
|
|
2656
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2657
|
-
if (!v.ok)
|
|
2658
|
-
return v.result;
|
|
2659
|
-
const { decisionsAction } = await import('./org-observe.js');
|
|
2660
|
-
return decisionsAction(ctx, v.name);
|
|
2661
|
-
},
|
|
2662
|
-
},
|
|
2663
|
-
{
|
|
2664
|
-
name: 'create',
|
|
2665
|
-
description: 'Scaffold an org config from a starter template',
|
|
2666
|
-
options: [
|
|
2667
|
-
{
|
|
2668
|
-
name: 'template',
|
|
2669
|
-
description: 'content-team | dev-team | research-pod | kg-extraction | advisor-orchestrator',
|
|
2670
|
-
type: 'string',
|
|
2671
|
-
},
|
|
2672
|
-
{
|
|
2673
|
-
name: 'goal',
|
|
2674
|
-
description: "Org goal (defaults to the template's placeholder)",
|
|
2675
|
-
type: 'string',
|
|
2676
|
-
},
|
|
2677
|
-
{ name: 'schedule', description: 'Daemon schedule, e.g. 30m or 2h', type: 'string' },
|
|
2678
|
-
{ name: 'force', description: 'Overwrite an existing org config', type: 'boolean' },
|
|
2679
|
-
{
|
|
2680
|
-
name: 'yes',
|
|
2681
|
-
short: 'y',
|
|
2682
|
-
description: 'Skip the per-role model confirmation prompt (TTY only)',
|
|
2683
|
-
type: 'boolean',
|
|
2684
|
-
},
|
|
2685
|
-
],
|
|
2686
|
-
examples: [
|
|
2687
|
-
{
|
|
2688
|
-
command: 'monomind org create blog --template content-team --goal "3 posts/week"',
|
|
2689
|
-
description: 'Create a content org',
|
|
2690
|
-
},
|
|
2691
|
-
],
|
|
2692
|
-
action: async (ctx) => {
|
|
2693
|
-
const v = validateOrgName(ctx.args[0]);
|
|
2694
|
-
if (!v.ok)
|
|
2695
|
-
return v.result;
|
|
2696
|
-
const { createAction } = await import('./org-observe.js');
|
|
2697
|
-
return createAction(ctx, v.name);
|
|
2698
|
-
},
|
|
2699
|
-
},
|
|
2700
|
-
{
|
|
2701
|
-
name: 'validate',
|
|
2702
|
-
description: 'Validate org config(s) against the runtime schema and structural invariants',
|
|
2703
|
-
examples: [
|
|
2704
|
-
{ command: 'monomind org validate growth', description: 'Validate one org config' },
|
|
2705
|
-
{
|
|
2706
|
-
command: 'monomind org validate',
|
|
2707
|
-
description: 'Validate every org config in the project',
|
|
2708
|
-
},
|
|
2709
|
-
],
|
|
2710
|
-
action: async (ctx) => {
|
|
2711
|
-
const { validateAction } = await import('./org-observe.js');
|
|
2712
|
-
return validateAction(ctx);
|
|
2713
|
-
},
|
|
2714
|
-
},
|
|
2715
|
-
{
|
|
2716
|
-
name: 'migrate',
|
|
2717
|
-
description: 'Convert a legacy-format org config (topology/board/loop) to the current format',
|
|
2718
|
-
examples: [
|
|
2719
|
-
{
|
|
2720
|
-
command: 'monomind org migrate growth',
|
|
2721
|
-
description: 'Migrate one org; original saved as growth.v1.json',
|
|
2722
|
-
},
|
|
2723
|
-
],
|
|
2724
|
-
action: migrateAction,
|
|
2725
|
-
},
|
|
2726
|
-
{ name: 'list', description: 'List all orgs in the current project', action: listAction },
|
|
2727
|
-
{
|
|
2728
|
-
name: 'delete',
|
|
2729
|
-
description: 'Delete an org and all its data',
|
|
2730
|
-
options: [
|
|
2731
|
-
{ name: 'yes', short: 'y', description: 'Skip confirmation', type: 'boolean' },
|
|
2732
|
-
{
|
|
2733
|
-
name: 'force',
|
|
2734
|
-
description: 'Delete even if the org appears to be running',
|
|
2735
|
-
type: 'boolean',
|
|
2736
|
-
},
|
|
2737
|
-
],
|
|
2738
|
-
action: deleteAction,
|
|
2739
|
-
},
|
|
2740
|
-
{
|
|
2741
|
-
name: 'mark-complete',
|
|
2742
|
-
description: 'Manually close a stale/crashed run',
|
|
2743
|
-
action: markCompleteAction,
|
|
2744
|
-
},
|
|
17
|
+
skillsSubcommand,
|
|
18
|
+
runSubcommand,
|
|
19
|
+
stopSubcommand,
|
|
20
|
+
pauseSubcommand,
|
|
21
|
+
resumeSubcommand,
|
|
22
|
+
reloadSubcommand,
|
|
23
|
+
statusSubcommand,
|
|
24
|
+
serveSubcommand,
|
|
25
|
+
supervisorSubcommand,
|
|
26
|
+
testLoopSubcommand,
|
|
27
|
+
logsSubcommand,
|
|
28
|
+
eventsSubcommand,
|
|
29
|
+
watchSubcommand,
|
|
30
|
+
reportSubcommand,
|
|
31
|
+
memorySubcommand,
|
|
32
|
+
costsSubcommand,
|
|
33
|
+
inboxSubcommand,
|
|
34
|
+
flowSubcommand,
|
|
35
|
+
questionsSubcommand,
|
|
36
|
+
approvalsSubcommand,
|
|
37
|
+
answerSubcommand,
|
|
38
|
+
approveSubcommand,
|
|
39
|
+
denySubcommand,
|
|
40
|
+
gatesSubcommand,
|
|
41
|
+
gateApproveSubcommand,
|
|
42
|
+
gateRejectSubcommand,
|
|
43
|
+
replaySubcommand,
|
|
44
|
+
resumeFromSubcommand,
|
|
45
|
+
branchSubcommand,
|
|
46
|
+
decisionsSubcommand,
|
|
47
|
+
createSubcommand,
|
|
48
|
+
validateSubcommand,
|
|
49
|
+
migrateSubcommand,
|
|
50
|
+
listSubcommand,
|
|
51
|
+
deleteSubcommand,
|
|
52
|
+
markCompleteSubcommand,
|
|
2745
53
|
],
|
|
2746
54
|
examples: [
|
|
2747
55
|
{ command: 'monomind org run my-org', description: 'Run an org under full daemon control' },
|