@cohortapp/agent-sdk 2.11.15 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cli/session.mjs — backs `maestro session <sub>` (front-door session,
|
|
3
|
+
* design §3.1 / §3.6 / §6 WP-M3).
|
|
4
|
+
*
|
|
5
|
+
* WHY A LIBRARY AND NOT A `case` BLOCK IN bin/maestro.mjs. Same reason as
|
|
6
|
+
* lib/subagents/cli.mjs: the dispatcher cannot be imported without executing,
|
|
7
|
+
* so a verb surface that reads state and drives launchctl/tmux/screen would be
|
|
8
|
+
* untestable there. `run()` returns `{ok, code, lines, data?}` and only PRINTS
|
|
9
|
+
* when asked; every process boundary (execs, TTY, uid, HOME, clock, pid
|
|
10
|
+
* liveness, the claude binary) is injected via `deps`, so the tests never
|
|
11
|
+
* touch launchd or a multiplexer. bin/maestro.mjs keeps a one-line `case`.
|
|
12
|
+
*
|
|
13
|
+
* Commands:
|
|
14
|
+
*
|
|
15
|
+
* status [--brief|--json] lock holder · heartbeat age · main session id ·
|
|
16
|
+
* mux session · plist installed/loaded · peers ·
|
|
17
|
+
* open handoffs · restart/upgrade flags
|
|
18
|
+
* attach print the exact `tmux attach` / `screen -r` line;
|
|
19
|
+
* exec it when stdin+stdout are a TTY
|
|
20
|
+
* start bootstrap the plist if not loaded, then kickstart
|
|
21
|
+
* stop bootout the label from the gui domain
|
|
22
|
+
* restart [--force] touch state/session/restart-requested, then
|
|
23
|
+
* kickstart (the session exits itself at an idle
|
|
24
|
+
* moment and the supervisor relaunches on the new
|
|
25
|
+
* code); --force = kickstart -k
|
|
26
|
+
* spawn --name <slug> [--cwd <dir>] "<prompt>"
|
|
27
|
+
* detached mux session running the CLI as
|
|
28
|
+
* <first>-<slug> with lib/session-permissions args;
|
|
29
|
+
* registers state/session/peers.json; prints the name
|
|
30
|
+
* peers [--json] list peers, pruning dead ones from the registry
|
|
31
|
+
* handoffs [--json] open cadence handoffs (state/session/handoffs/*.json)
|
|
32
|
+
* ack <tickId> [--result p] delegates to lib/cli/session-ack.mjs (WP-M2) when
|
|
33
|
+
* present; a clear failure when it is not
|
|
34
|
+
*
|
|
35
|
+
* State it reads (all written by the supervisor / feed / daemon, never here
|
|
36
|
+
* except peers.json and the restart marker):
|
|
37
|
+
* state/locks/process/session.pid lib/singleton.js acquireLock("session")
|
|
38
|
+
* state/session/heartbeat.json {pid, ppid, sessionId, name, ts, feedVersion}
|
|
39
|
+
* state/session/main-session.json {sessionId, createdAt, resumes}
|
|
40
|
+
* state/session/peers.json {peers:[{name, muxName, startedAt, purpose, cwd}]}
|
|
41
|
+
* (read-modify-written ONLY through updatePeers(),
|
|
42
|
+
* under the O_EXCL sidecar peers.json.lock)
|
|
43
|
+
* config/session.yaml mux + allowedTools via lib/session/config.mjs
|
|
44
|
+
* (WP-M1) — one source of truth with the supervisor
|
|
45
|
+
* state/session/handoffs/<tick>.json {tickId, cadence, mode, promptPath, enqueuedAt, deadlineAt}
|
|
46
|
+
* state/session/restart-requested marker
|
|
47
|
+
* state/session/upgrade-notice.json {from, to, at} (autoupdate.sh) — reported only
|
|
48
|
+
* while `to` differs from the running SDK version
|
|
49
|
+
*
|
|
50
|
+
* Everything is fail-open: unreadable state reads as absent. `now` is a
|
|
51
|
+
* parameter wherever it changes the answer. Node builtins only. ESM.
|
|
52
|
+
*
|
|
53
|
+
* @module lib/cli/session
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
"use strict";
|
|
57
|
+
|
|
58
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
59
|
+
import { basename, dirname, join } from "node:path";
|
|
60
|
+
import { homedir } from "node:os";
|
|
61
|
+
import { spawnSync } from "node:child_process";
|
|
62
|
+
import { parseArgs } from "node:util";
|
|
63
|
+
import { pathToFileURL } from "node:url";
|
|
64
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
65
|
+
import { resolveAgentRoot } from "../agent-root.mjs";
|
|
66
|
+
import { resolveClaudeBin } from "../claude-bin.mjs";
|
|
67
|
+
import { sessionPermissionArgs } from "../session-permissions.mjs";
|
|
68
|
+
|
|
69
|
+
/** The SDK's own package.json version — what "installed" means for an upgrade notice. */
|
|
70
|
+
function ownVersion() {
|
|
71
|
+
try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version || null; } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Heartbeat older than this is "not live" (design §3.2: 90 s). */
|
|
75
|
+
export const STALE_MS = 90_000;
|
|
76
|
+
|
|
77
|
+
/** A peer slug: lower-case, digits, hyphens; short. */
|
|
78
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Names and paths (pure)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The agent's first name, lower-cased. The chain is EXACTLY generate-plists.sh's,
|
|
86
|
+
* so the CLI and the plist can never disagree on the label:
|
|
87
|
+
* 1. config/agent.json `firstName` (SOT)
|
|
88
|
+
* 2. config/agent.ts inline `firstName: 'X'` (legacy seats; a type-only
|
|
89
|
+
* `firstName: string;` must not match)
|
|
90
|
+
* 3. directory basename minus a `-ai` suffix
|
|
91
|
+
* @param {string} agentRoot
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveFirstName(agentRoot) {
|
|
95
|
+
try {
|
|
96
|
+
const cfg = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf8"));
|
|
97
|
+
const f = typeof cfg.firstName === "string" ? cfg.firstName.trim().toLowerCase() : "";
|
|
98
|
+
if (f && f !== "unconfigured") return f;
|
|
99
|
+
} catch {
|
|
100
|
+
// no/invalid agent.json — fall through (fail-open)
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const ts = readFileSync(join(agentRoot, "config", "agent.ts"), "utf8");
|
|
104
|
+
const m = ts.match(/firstName:\s*['"]([^'"]+)['"]/);
|
|
105
|
+
const f = m ? m[1].trim().toLowerCase() : "";
|
|
106
|
+
if (f && f !== "unconfigured") return f;
|
|
107
|
+
} catch {
|
|
108
|
+
// no agent.ts — fall through
|
|
109
|
+
}
|
|
110
|
+
return basename(agentRoot).toLowerCase().replace(/-ai$/, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {string} first @returns {string} launchd label */
|
|
114
|
+
export function sessionLabel(first) { return `ai.maestro.${first}-session`; }
|
|
115
|
+
|
|
116
|
+
/** @param {string} first @returns {string} the main session's mux name */
|
|
117
|
+
export function muxSessionName(first) { return `maestro-${first}`; }
|
|
118
|
+
|
|
119
|
+
/** @param {string} first @param {string} slug @returns {string} a peer's mux name */
|
|
120
|
+
export function peerMuxName(first, slug) { return `maestro-${first}-${slug}`; }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every state path the CLI touches, in one place.
|
|
124
|
+
* @param {string} agentRoot
|
|
125
|
+
*/
|
|
126
|
+
export function sessionPaths(agentRoot) {
|
|
127
|
+
const dir = join(agentRoot, "state", "session");
|
|
128
|
+
return {
|
|
129
|
+
dir,
|
|
130
|
+
heartbeat: join(dir, "heartbeat.json"),
|
|
131
|
+
mainSession: join(dir, "main-session.json"),
|
|
132
|
+
peers: join(dir, "peers.json"),
|
|
133
|
+
handoffsDir: join(dir, "handoffs"),
|
|
134
|
+
handoffsDone: join(dir, "handoffs", "done"),
|
|
135
|
+
restartRequested: join(dir, "restart-requested"),
|
|
136
|
+
upgradeNotice: join(dir, "upgrade-notice.json"),
|
|
137
|
+
lock: join(agentRoot, "state", "locks", "process", "session.pid"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Pure decisions
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** ms or ISO → epoch ms, else null. */
|
|
146
|
+
function toMs(v) {
|
|
147
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
148
|
+
if (typeof v === "string" && v) { const t = Date.parse(v); return Number.isFinite(t) ? t : null; }
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Age of a heartbeat record at `now`, or null when there is none.
|
|
154
|
+
* @param {{ts?: number|string}|null} hb
|
|
155
|
+
* @param {number} now epoch ms
|
|
156
|
+
* @returns {number|null}
|
|
157
|
+
*/
|
|
158
|
+
export function heartbeatAgeMs(hb, now) {
|
|
159
|
+
if (!hb || typeof hb !== "object") return null;
|
|
160
|
+
const ts = toMs(hb.ts);
|
|
161
|
+
if (ts === null) return null;
|
|
162
|
+
return Math.max(0, now - ts);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Is the front-door session live at `now`? Liveness is MEASURED (a fresh
|
|
167
|
+
* heartbeat), never assumed from a lock or a plist.
|
|
168
|
+
* @param {{ts?: number|string}|null} hb
|
|
169
|
+
* @param {number} now
|
|
170
|
+
* @param {{staleMs?: number}} [o]
|
|
171
|
+
*/
|
|
172
|
+
export function isLive(hb, now, o = {}) {
|
|
173
|
+
const age = heartbeatAgeMs(hb, now);
|
|
174
|
+
return age !== null && age < (o.staleMs ?? STALE_MS);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The exact attach command for the main session.
|
|
179
|
+
* @param {string} first
|
|
180
|
+
* @param {"tmux"|"screen"} kind
|
|
181
|
+
* @returns {string[]} argv
|
|
182
|
+
*/
|
|
183
|
+
export function attachCommand(first, kind) {
|
|
184
|
+
const name = muxSessionName(first);
|
|
185
|
+
return kind === "tmux" ? ["tmux", "attach", "-t", name] : ["screen", "-r", name];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Permission args for an INTERACTIVE peer session. Same rule as the front-door
|
|
190
|
+
* supervisor's main-session posture (lib/session-permissions.mjs, WP-M1): the
|
|
191
|
+
* `MAESTRO_SCOPED_PERMISSIONS=1` switch is honoured, but a peer runs inside a
|
|
192
|
+
* multiplexer with nobody attached, so the conservative read-only `--print`
|
|
193
|
+
* allowlist would only wedge it on its first prompt. Scoped mode therefore
|
|
194
|
+
* uses the operator's explicit allowlist (config/session.yaml `allowedTools`)
|
|
195
|
+
* or bypass; unscoped is byte-for-byte the pre-H1 posture.
|
|
196
|
+
*
|
|
197
|
+
* `sessionPermissionArgs` is still the source of the unscoped default so the
|
|
198
|
+
* two lanes cannot drift on it.
|
|
199
|
+
* @param {{env?:object, allowedTools?:string[]}} [o]
|
|
200
|
+
* @returns {string[]}
|
|
201
|
+
*/
|
|
202
|
+
export function peerPermissionArgs({ env = process.env, allowedTools } = {}) {
|
|
203
|
+
if (!env || env.MAESTRO_SCOPED_PERMISSIONS !== "1") {
|
|
204
|
+
const prev = process.env.MAESTRO_SCOPED_PERMISSIONS;
|
|
205
|
+
// Ask the shared module for the unscoped default without mutating the
|
|
206
|
+
// caller's env for longer than the call.
|
|
207
|
+
if (prev !== undefined) delete process.env.MAESTRO_SCOPED_PERMISSIONS;
|
|
208
|
+
try { return sessionPermissionArgs({}); } finally { if (prev !== undefined) process.env.MAESTRO_SCOPED_PERMISSIONS = prev; }
|
|
209
|
+
}
|
|
210
|
+
const explicit = Array.isArray(allowedTools) ? allowedTools.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()) : [];
|
|
211
|
+
return explicit.length ? ["--allowedTools", explicit.join(",")] : ["--dangerously-skip-permissions"];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* CLI args for a spawned peer: `--name <first>-<slug>`, the interactive
|
|
216
|
+
* permission posture ({@link peerPermissionArgs}), then the prompt. The binary
|
|
217
|
+
* itself is prepended by the caller.
|
|
218
|
+
* @param {{first:string, slug:string, prompt:string, env?:object, allowedTools?:string[]}} o
|
|
219
|
+
* @returns {string[]}
|
|
220
|
+
*/
|
|
221
|
+
export function buildSpawnArgs({ first, slug, prompt, env, allowedTools }) {
|
|
222
|
+
return ["--name", `${first}-${slug}`, ...peerPermissionArgs({ env, allowedTools }), prompt];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Split a peer registry into live and dead, given a mux-presence probe.
|
|
227
|
+
* @param {Array<{muxName:string}>} peers
|
|
228
|
+
* @param {(muxName:string)=>boolean} present
|
|
229
|
+
*/
|
|
230
|
+
export function prunePeers(peers, present) {
|
|
231
|
+
const live = [], dead = [];
|
|
232
|
+
for (const p of Array.isArray(peers) ? peers : []) {
|
|
233
|
+
if (p && typeof p.muxName === "string" && present(p.muxName)) live.push(p); else dead.push(p);
|
|
234
|
+
}
|
|
235
|
+
return { live, dead };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Effects (all behind `deps`)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Real process boundaries. Tests replace every one of these. */
|
|
243
|
+
function defaultDeps() {
|
|
244
|
+
return {
|
|
245
|
+
/** execFile-style, no shell. ENOENT → status 127. */
|
|
246
|
+
exec(cmd, args = [], o = {}) {
|
|
247
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
248
|
+
if (r.error) return { status: 127, stdout: "", stderr: r.error.message };
|
|
249
|
+
return { status: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
250
|
+
},
|
|
251
|
+
/** Foreground, inheriting the terminal (attach). */
|
|
252
|
+
execInherit(cmd, args = []) {
|
|
253
|
+
const r = spawnSync(cmd, args, { stdio: "inherit" });
|
|
254
|
+
if (r.error) return { status: 127, stdout: "", stderr: r.error.message };
|
|
255
|
+
return { status: r.status ?? 1, stdout: "", stderr: "" };
|
|
256
|
+
},
|
|
257
|
+
now: () => Date.now(),
|
|
258
|
+
uid: () => (typeof process.getuid === "function" ? process.getuid() : 501),
|
|
259
|
+
home: homedir(),
|
|
260
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
261
|
+
isAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } },
|
|
262
|
+
claudeBin: null, // resolved lazily via resolveClaudeBin()
|
|
263
|
+
env: process.env,
|
|
264
|
+
/** config/session.yaml via lib/session/config.mjs (WP-M1); {} when that module is absent (fail-open). */
|
|
265
|
+
async loadSessionConfig(agentRoot) {
|
|
266
|
+
try {
|
|
267
|
+
const m = await import("../session/config.mjs");
|
|
268
|
+
return await m.loadSessionConfig(agentRoot);
|
|
269
|
+
} catch { return {}; }
|
|
270
|
+
},
|
|
271
|
+
/** The version the running SDK reports — an upgrade notice for it is already applied. */
|
|
272
|
+
installedVersion: ownVersion(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readJson(p) {
|
|
277
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } // absent/corrupt → null (fail-open)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** peers.json accepts a bare array or {peers:[…]}; returns the array. */
|
|
281
|
+
function readPeers(paths) {
|
|
282
|
+
const j = readJson(paths.peers);
|
|
283
|
+
if (Array.isArray(j)) return j;
|
|
284
|
+
if (j && Array.isArray(j.peers)) return j.peers;
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function writePeers(paths, peers) {
|
|
289
|
+
writeJsonAtomic(paths.peers, { peers });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Sync sleep without a busy loop. */
|
|
293
|
+
function sleepMs(ms) {
|
|
294
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best effort */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** How long an untouched peers.json.lock is trusted before it is broken. */
|
|
298
|
+
const PEERS_LOCK_STALE_MS = 30_000;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read-modify-write peers.json under an O_EXCL sidecar lock, so two `spawn`s
|
|
302
|
+
* (or a `spawn` racing a pruning `status`) from separate processes cannot drop
|
|
303
|
+
* a registration. The lock is `<peers.json>.lock` holding the pid; a lock
|
|
304
|
+
* older than 30 s is treated as abandoned and broken. Fail-open on the
|
|
305
|
+
* registry read (absent/corrupt → []), never on the lock: a caller that cannot
|
|
306
|
+
* get the lock within `waitMs` is told so and leaves the file alone.
|
|
307
|
+
*
|
|
308
|
+
* @param {ReturnType<typeof sessionPaths>} paths
|
|
309
|
+
* @param {(peers:object[])=>object[]} mutate pure: current list → new list
|
|
310
|
+
* @param {{now?:()=>number, waitMs?:number, lockMtimeMs?:(lock:string)=>number|null}} [o]
|
|
311
|
+
* @returns {{ok:true, peers:object[]}|{ok:false, error:string}}
|
|
312
|
+
*/
|
|
313
|
+
export function updatePeers(paths, mutate, o = {}) {
|
|
314
|
+
const now = o.now || (() => Date.now());
|
|
315
|
+
const waitMs = o.waitMs ?? 3000;
|
|
316
|
+
const lock = `${paths.peers}.lock`;
|
|
317
|
+
const mtime = o.lockMtimeMs || ((p) => { try { return statSync(p).mtimeMs; } catch { return null; } });
|
|
318
|
+
try { mkdirSync(dirname(paths.peers), { recursive: true }); } catch { /* reported below by the open */ }
|
|
319
|
+
const deadline = now() + waitMs;
|
|
320
|
+
let fd = null;
|
|
321
|
+
for (;;) {
|
|
322
|
+
try { fd = openSync(lock, "wx"); break; } catch (e) {
|
|
323
|
+
if (!e || e.code !== "EEXIST") return { ok: false, error: `peers lock: ${e && e.message ? e.message : e}` };
|
|
324
|
+
const m = mtime(lock);
|
|
325
|
+
if (m !== null && now() - m > PEERS_LOCK_STALE_MS) { try { unlinkSync(lock); } catch { /* raced; loop */ } continue; }
|
|
326
|
+
if (now() >= deadline) return { ok: false, error: `peers lock held by another process (${lock})` };
|
|
327
|
+
sleepMs(15);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
try { writeFileSync(fd, `${process.pid}\n`); } catch { /* the lock's existence is what matters */ }
|
|
332
|
+
const peers = mutate(readPeers(paths));
|
|
333
|
+
writePeers(paths, peers);
|
|
334
|
+
return { ok: true, peers };
|
|
335
|
+
} catch (e) {
|
|
336
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
337
|
+
} finally {
|
|
338
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
339
|
+
try { unlinkSync(lock); } catch { /* already gone */ }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Is a mux session with this name present? tmux first, then screen -ls. */
|
|
344
|
+
function muxPresence(d, name) {
|
|
345
|
+
const t = d.exec("tmux", ["has-session", "-t", name]);
|
|
346
|
+
if (t.status === 0) return { present: true, kind: "tmux" };
|
|
347
|
+
const s = d.exec("screen", ["-ls"]);
|
|
348
|
+
if (s.status === 0 || /Socket/.test(s.stdout)) {
|
|
349
|
+
const re = new RegExp(`\\.${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m");
|
|
350
|
+
if (re.test(s.stdout)) return { present: true, kind: "screen" };
|
|
351
|
+
}
|
|
352
|
+
return { present: false, kind: null };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Which multiplexer a new peer should use. ONE source of truth with the
|
|
357
|
+
* supervisor (WP-M1): config/session.yaml `mux:` first; `MAESTRO_SESSION_MUX`
|
|
358
|
+
* only when the config is absent or `auto`; then tmux-if-installed, else
|
|
359
|
+
* screen — so `attach`/`peers` never span two multiplexers.
|
|
360
|
+
* @param {object} d deps
|
|
361
|
+
* @param {{mux?:string}} cfg loaded session config (may be {})
|
|
362
|
+
*/
|
|
363
|
+
function chooseMux(d, cfg = {}) {
|
|
364
|
+
const fromCfg = cfg && (cfg.mux === "screen" || cfg.mux === "tmux") ? cfg.mux : null;
|
|
365
|
+
const pref = fromCfg || (d.env && d.env.MAESTRO_SESSION_MUX) || "auto";
|
|
366
|
+
if (pref === "screen") return "screen";
|
|
367
|
+
if (pref === "tmux") return "tmux";
|
|
368
|
+
return d.exec("tmux", ["-V"]).status === 0 ? "tmux" : "screen";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Session config, fail-open to {} (a broken loader must not stop a spawn). */
|
|
372
|
+
async function loadCfg(ctx) {
|
|
373
|
+
try { const c = await ctx.d.loadSessionConfig(ctx.agentRoot); return c && typeof c === "object" ? c : {}; } catch { return {}; }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function lockState(paths, d) {
|
|
377
|
+
let raw = null;
|
|
378
|
+
try { raw = readFileSync(paths.lock, "utf8"); } catch { return { held: false, pid: null }; }
|
|
379
|
+
const pid = parseInt((raw.split("\n")[0] || "").trim(), 10) || 0;
|
|
380
|
+
if (!pid) return { held: false, pid: null };
|
|
381
|
+
// Best-effort liveness (pid only). The identity-aware check lives in
|
|
382
|
+
// lib/singleton.js and runs at acquire time; a status read must not evict.
|
|
383
|
+
return { held: d.isAlive(pid), pid };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function listHandoffs(paths, now) {
|
|
387
|
+
let names = [];
|
|
388
|
+
try { names = readdirSync(paths.handoffsDir); } catch { return []; }
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const n of names) {
|
|
391
|
+
if (!n.endsWith(".json")) continue;
|
|
392
|
+
const p = join(paths.handoffsDir, n);
|
|
393
|
+
try { if (!statSync(p).isFile()) continue; } catch { continue; }
|
|
394
|
+
const h = readJson(p);
|
|
395
|
+
if (!h || typeof h !== "object") continue; // corrupt handoff: skipped, never fatal
|
|
396
|
+
const enq = toMs(h.enqueuedAt);
|
|
397
|
+
const dl = toMs(h.deadlineAt);
|
|
398
|
+
out.push({
|
|
399
|
+
tickId: h.tickId || n.replace(/\.json$/, ""),
|
|
400
|
+
cadence: h.cadence || null,
|
|
401
|
+
mode: h.mode || null,
|
|
402
|
+
promptPath: h.promptPath || null,
|
|
403
|
+
enqueuedAt: h.enqueuedAt ?? null,
|
|
404
|
+
deadlineAt: h.deadlineAt ?? null,
|
|
405
|
+
ageMs: enq === null ? null : Math.max(0, now - enq),
|
|
406
|
+
overdue: dl !== null && now > dl,
|
|
407
|
+
path: p,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return out.sort((a, b) => (toMs(a.enqueuedAt) ?? 0) - (toMs(b.enqueuedAt) ?? 0));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Everything `status` reports, as data. */
|
|
414
|
+
function snapshot(ctx) {
|
|
415
|
+
const { agentRoot, first, d, paths } = ctx;
|
|
416
|
+
const now = d.now();
|
|
417
|
+
const label = sessionLabel(first);
|
|
418
|
+
const hb = readJson(paths.heartbeat);
|
|
419
|
+
const ageMs = heartbeatAgeMs(hb, now);
|
|
420
|
+
const live = isLive(hb, now);
|
|
421
|
+
const mux = muxPresence(d, muxSessionName(first));
|
|
422
|
+
const plistPath = join(d.home, "Library", "LaunchAgents", `${label}.plist`);
|
|
423
|
+
const plistInstalled = existsSync(plistPath);
|
|
424
|
+
const loaded = d.exec("launchctl", ["list", label]).status === 0;
|
|
425
|
+
const { live: peers, dead } = prunePeers(readPeers(paths), (n) => muxPresence(d, n).present);
|
|
426
|
+
if (dead.length) {
|
|
427
|
+
// Prune under the registry lock, dropping only what THIS read saw dead —
|
|
428
|
+
// a peer registered in between survives. A status read never fails on it.
|
|
429
|
+
const gone = new Set(dead.map((p) => p && p.name));
|
|
430
|
+
updatePeers(paths, (cur) => cur.filter((p) => !(p && gone.has(p.name))), { now: d.now, waitMs: 500 });
|
|
431
|
+
}
|
|
432
|
+
const handoffs = listHandoffs(paths, now);
|
|
433
|
+
const main = readJson(paths.mainSession);
|
|
434
|
+
const notice = readJson(paths.upgradeNotice);
|
|
435
|
+
const installed = typeof d.installedVersion === "string" && d.installedVersion ? d.installedVersion : null;
|
|
436
|
+
// An upgrade notice whose target is what is already running was applied by
|
|
437
|
+
// a session restart; it is not pending and must not be surfaced (it stays
|
|
438
|
+
// on disk — the feed keys it by mtime — until the next release overwrites it).
|
|
439
|
+
const upgradeNotice = notice && typeof notice === "object" && !(installed && notice.to === installed) ? notice : null;
|
|
440
|
+
return {
|
|
441
|
+
first,
|
|
442
|
+
name: `${first}-main`,
|
|
443
|
+
label,
|
|
444
|
+
agentRoot,
|
|
445
|
+
live,
|
|
446
|
+
lock: lockState(paths, d),
|
|
447
|
+
heartbeat: {
|
|
448
|
+
live, ageMs,
|
|
449
|
+
ts: hb && hb.ts != null ? hb.ts : null,
|
|
450
|
+
pid: hb && hb.pid != null ? hb.pid : null,
|
|
451
|
+
sessionId: hb && hb.sessionId ? hb.sessionId : null,
|
|
452
|
+
feedVersion: hb && hb.feedVersion != null ? hb.feedVersion : null,
|
|
453
|
+
},
|
|
454
|
+
mainSession: main && typeof main === "object"
|
|
455
|
+
? { sessionId: main.sessionId || null, createdAt: main.createdAt || null, resumes: main.resumes ?? 0 }
|
|
456
|
+
: null,
|
|
457
|
+
mux: { present: mux.present, kind: mux.kind, name: muxSessionName(first) },
|
|
458
|
+
plist: { installed: plistInstalled, loaded, path: plistPath },
|
|
459
|
+
peers,
|
|
460
|
+
handoffs: { open: handoffs.length, overdue: handoffs.filter((h) => h.overdue).length },
|
|
461
|
+
restartRequested: existsSync(paths.restartRequested),
|
|
462
|
+
upgradeNotice,
|
|
463
|
+
sdkVersion: installed,
|
|
464
|
+
ts: new Date(now).toISOString(),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const fmtAge = (ms) => {
|
|
469
|
+
if (ms === null || ms === undefined) return "none";
|
|
470
|
+
const s = Math.round(ms / 1000);
|
|
471
|
+
if (s < 90) return `${s}s`;
|
|
472
|
+
const m = Math.round(s / 60);
|
|
473
|
+
if (m < 90) return `${m}m`;
|
|
474
|
+
return `${Math.round(m / 60)}h`;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
function briefLine(s) {
|
|
478
|
+
const bits = [];
|
|
479
|
+
bits.push(s.heartbeat.ageMs === null ? "no heartbeat" : `hb ${fmtAge(s.heartbeat.ageMs)}`);
|
|
480
|
+
bits.push(s.lock.held ? `lock pid ${s.lock.pid}` : "no lock");
|
|
481
|
+
bits.push(s.mux.present ? `${s.mux.kind} ${s.mux.name}` : "no mux session");
|
|
482
|
+
bits.push(s.plist.installed ? (s.plist.loaded ? "plist loaded" : "plist not loaded") : "plist not installed");
|
|
483
|
+
bits.push(`${s.peers.length} peer${s.peers.length === 1 ? "" : "s"}`);
|
|
484
|
+
bits.push(`${s.handoffs.open} handoff${s.handoffs.open === 1 ? "" : "s"}${s.handoffs.overdue ? ` (${s.handoffs.overdue} overdue)` : ""}`);
|
|
485
|
+
if (s.restartRequested) bits.push("restart requested");
|
|
486
|
+
if (s.upgradeNotice && s.upgradeNotice.to) bits.push(`upgrade → ${s.upgradeNotice.to}`);
|
|
487
|
+
return `${s.name}: ${s.live ? "live" : "not live"} (${bits.join(", ")})`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function humanStatus(s) {
|
|
491
|
+
const L = [];
|
|
492
|
+
L.push(`Front-door session: ${s.name} (${s.label}) — ${s.live ? "LIVE" : "NOT LIVE"}`);
|
|
493
|
+
L.push(` supervisor lock: ${s.lock.held ? `held by pid ${s.lock.pid}` : "not held"}`);
|
|
494
|
+
L.push(` heartbeat: ${s.heartbeat.ageMs === null ? "none" : `${fmtAge(s.heartbeat.ageMs)} ago${s.live ? "" : " (stale)"}`}${s.heartbeat.sessionId ? ` · session ${s.heartbeat.sessionId}` : ""}`);
|
|
495
|
+
L.push(` main session: ${s.mainSession ? `${s.mainSession.sessionId || "?"} (resumes: ${s.mainSession.resumes})` : "none recorded"}`);
|
|
496
|
+
L.push(` mux: ${s.mux.present ? `${s.mux.kind} ${s.mux.name}` : `none (${s.mux.name})`}`);
|
|
497
|
+
L.push(` launchd: ${s.plist.installed ? (s.plist.loaded ? "installed, loaded" : "installed, NOT loaded") : "not installed"}`);
|
|
498
|
+
L.push(` peers: ${s.peers.length ? s.peers.map((p) => p.name).join(", ") : "none"}`);
|
|
499
|
+
L.push(` handoffs open: ${s.handoffs.open}${s.handoffs.overdue ? ` (${s.handoffs.overdue} overdue)` : ""}`);
|
|
500
|
+
const flags = [];
|
|
501
|
+
if (s.restartRequested) flags.push("restart requested");
|
|
502
|
+
if (s.upgradeNotice && s.upgradeNotice.to) flags.push(`upgrade available ${s.upgradeNotice.from || "?"} → ${s.upgradeNotice.to}`);
|
|
503
|
+
if (flags.length) L.push(` flags: ${flags.join("; ")}`);
|
|
504
|
+
if (!s.plist.installed) L.push("Next: npm run init-agent (generates + loads the session plist), then: maestro session start");
|
|
505
|
+
else if (!s.live) L.push("Next: maestro session start (then: maestro session attach)");
|
|
506
|
+
else L.push("Attach: maestro session attach");
|
|
507
|
+
return L;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Ensure the label is loaded (bootstrap the plist if needed). */
|
|
511
|
+
function ensureLoaded(ctx, say) {
|
|
512
|
+
const { d, first } = ctx;
|
|
513
|
+
const label = sessionLabel(first);
|
|
514
|
+
const target = `gui/${d.uid()}`;
|
|
515
|
+
if (d.exec("launchctl", ["list", label]).status === 0) return { ok: true, target, wasLoaded: true };
|
|
516
|
+
const plistPath = join(d.home, "Library", "LaunchAgents", `${label}.plist`);
|
|
517
|
+
if (!existsSync(plistPath)) {
|
|
518
|
+
say(`session: ${label} is not installed (${plistPath}).`);
|
|
519
|
+
say(" Fix: npm run init-agent — or: bash scripts/local-triggers/generate-plists.sh, then copy + launchctl load the session plist.");
|
|
520
|
+
return { ok: false, target, wasLoaded: false };
|
|
521
|
+
}
|
|
522
|
+
const b = d.exec("launchctl", ["bootstrap", target, plistPath]);
|
|
523
|
+
if (b.status !== 0) say(`session: launchctl bootstrap returned ${b.status}${b.stderr ? `: ${b.stderr.trim()}` : ""} — continuing to kickstart`);
|
|
524
|
+
else say(`session: bootstrapped ${label}`);
|
|
525
|
+
return { ok: true, target, wasLoaded: false };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// run()
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
export function usage() {
|
|
533
|
+
return `maestro session <command>
|
|
534
|
+
|
|
535
|
+
status [--brief|--json] lock holder, heartbeat age, mux session, plist, peers, handoffs
|
|
536
|
+
attach print the attach command (tmux attach / screen -r); exec it on a TTY
|
|
537
|
+
start load the launchd job if needed, then kickstart it
|
|
538
|
+
stop launchctl bootout the session job
|
|
539
|
+
restart [--force] ask the session to restart itself at an idle moment (--force: kickstart -k)
|
|
540
|
+
spawn --name <slug> [--cwd <dir>] "<prompt>"
|
|
541
|
+
run a peer as <first>-<slug> in its own detached mux session; prints the name
|
|
542
|
+
peers [--json] list peers (dead ones are pruned from state/session/peers.json)
|
|
543
|
+
handoffs [--json] list open cadence handoffs
|
|
544
|
+
ack <tickId> [--result <p>] mark a handoff done (moves it to handoffs/done/)`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const FLAGS = {
|
|
548
|
+
json: { type: "boolean", default: false },
|
|
549
|
+
brief: { type: "boolean", default: false },
|
|
550
|
+
force: { type: "boolean", default: false },
|
|
551
|
+
name: { type: "string" },
|
|
552
|
+
cwd: { type: "string" },
|
|
553
|
+
result: { type: "string" },
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* @param {string[]} argv
|
|
558
|
+
* @param {{agentRoot?:string, print?:boolean, deps?:object, ackModule?:string}} [opts]
|
|
559
|
+
* @returns {Promise<{ok:boolean, code:number, lines:string[], data?:any}>}
|
|
560
|
+
*/
|
|
561
|
+
export async function run(argv = [], opts = {}) {
|
|
562
|
+
const agentRoot = opts.agentRoot || resolveAgentRoot();
|
|
563
|
+
const d = { ...defaultDeps(), ...(opts.deps || {}) };
|
|
564
|
+
const lines = [];
|
|
565
|
+
const say = (s) => { lines.push(s); if (opts.print !== false) console.log(s); };
|
|
566
|
+
const fail = (msg, code = 1) => { say(msg); return { ok: false, code, lines }; };
|
|
567
|
+
|
|
568
|
+
let parsed;
|
|
569
|
+
try {
|
|
570
|
+
parsed = parseArgs({ args: argv, options: FLAGS, allowPositionals: true, strict: false });
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return fail(`session: ${e && e.message ? e.message : e}`, 2);
|
|
573
|
+
}
|
|
574
|
+
const [sub, ...rest] = parsed.positionals;
|
|
575
|
+
const flags = parsed.values;
|
|
576
|
+
const first = resolveFirstName(agentRoot);
|
|
577
|
+
const paths = sessionPaths(agentRoot);
|
|
578
|
+
const ctx = { agentRoot, first, d, paths };
|
|
579
|
+
|
|
580
|
+
switch (sub) {
|
|
581
|
+
case undefined:
|
|
582
|
+
case "help":
|
|
583
|
+
case "--help":
|
|
584
|
+
say(usage());
|
|
585
|
+
return { ok: true, code: 0, lines };
|
|
586
|
+
|
|
587
|
+
case "status": {
|
|
588
|
+
const s = snapshot(ctx);
|
|
589
|
+
if (flags.json) say(JSON.stringify(s, null, 2));
|
|
590
|
+
else if (flags.brief) say(briefLine(s));
|
|
591
|
+
else for (const l of humanStatus(s)) say(l);
|
|
592
|
+
return { ok: true, code: 0, lines, data: s };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
case "attach": {
|
|
596
|
+
const mux = muxPresence(d, muxSessionName(first));
|
|
597
|
+
if (!mux.present) {
|
|
598
|
+
return fail(`session: no ${muxSessionName(first)} multiplexer session is running. Start it with: maestro session start`);
|
|
599
|
+
}
|
|
600
|
+
const cmd = attachCommand(first, mux.kind);
|
|
601
|
+
say(`Attach: ${cmd.join(" ")}`);
|
|
602
|
+
if (!d.isTTY) { say("(not a TTY — run that command from a terminal)"); return { ok: true, code: 0, lines, data: { command: cmd } }; }
|
|
603
|
+
const r = d.execInherit(cmd[0], cmd.slice(1));
|
|
604
|
+
return { ok: r.status === 0, code: r.status, lines, data: { command: cmd } };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
case "start": {
|
|
608
|
+
const l = ensureLoaded(ctx, say);
|
|
609
|
+
if (!l.ok) return { ok: false, code: 1, lines };
|
|
610
|
+
const label = sessionLabel(first);
|
|
611
|
+
const k = d.exec("launchctl", ["kickstart", `${l.target}/${label}`]);
|
|
612
|
+
if (k.status !== 0) return fail(`session: launchctl kickstart ${label} returned ${k.status}${k.stderr ? `: ${k.stderr.trim()}` : ""}`);
|
|
613
|
+
say(`session: started ${label} (launchd keeps it alive; attach with: maestro session attach)`);
|
|
614
|
+
return { ok: true, code: 0, lines };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
case "stop": {
|
|
618
|
+
const label = sessionLabel(first);
|
|
619
|
+
const r = d.exec("launchctl", ["bootout", `gui/${d.uid()}/${label}`]);
|
|
620
|
+
if (r.status !== 0 && !/not find|No such|not loaded|3: /i.test(r.stderr || "")) {
|
|
621
|
+
return fail(`session: launchctl bootout ${label} returned ${r.status}${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
622
|
+
}
|
|
623
|
+
say(r.status === 0 ? `session: stopped ${label} (launchd will not relaunch it until: maestro session start)` : `session: ${label} was not loaded`);
|
|
624
|
+
return { ok: true, code: 0, lines };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
case "restart": {
|
|
628
|
+
// The marker is what the session's feed turns into a `restart` directive;
|
|
629
|
+
// the session finishes its turn, exits, and the supervisor relaunches on
|
|
630
|
+
// whatever code is on disk now. It is written ONLY once the job is known
|
|
631
|
+
// to be loaded: a job that is not running has nothing to restart, and a
|
|
632
|
+
// marker left behind would fire spuriously on its next first boot.
|
|
633
|
+
// kickstart without -k only starts a job that is not running; --force
|
|
634
|
+
// kills and restarts immediately.
|
|
635
|
+
const l = ensureLoaded(ctx, say);
|
|
636
|
+
if (!l.ok) return { ok: false, code: 1, lines };
|
|
637
|
+
const label = sessionLabel(first);
|
|
638
|
+
if (l.wasLoaded) {
|
|
639
|
+
try {
|
|
640
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
641
|
+
writeFileSync(paths.restartRequested, `${new Date(d.now()).toISOString()}\n`);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
return fail(`session: could not write ${paths.restartRequested}: ${e && e.message ? e.message : e}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const args = flags.force ? ["kickstart", "-k", `${l.target}/${label}`] : ["kickstart", `${l.target}/${label}`];
|
|
647
|
+
const k = d.exec("launchctl", args);
|
|
648
|
+
if (k.status !== 0) return fail(`session: launchctl ${args.join(" ")} returned ${k.status}${k.stderr ? `: ${k.stderr.trim()}` : ""}`);
|
|
649
|
+
if (!l.wasLoaded) say(`session: ${label} was not running — started it (no restart marker written)`);
|
|
650
|
+
else say(flags.force ? `session: ${label} killed and restarted` : `session: restart requested — ${label} will relaunch at its next idle moment (use --force to restart now)`);
|
|
651
|
+
return { ok: true, code: 0, lines };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
case "spawn": {
|
|
655
|
+
const slug = typeof flags.name === "string" ? flags.name.trim() : "";
|
|
656
|
+
if (!slug) return fail("session spawn: --name <slug> is required", 2);
|
|
657
|
+
if (!SLUG_RE.test(slug)) return fail(`session spawn: invalid --name "${slug}" (use lower-case letters, digits and hyphens)`, 2);
|
|
658
|
+
const prompt = rest.join(" ").trim();
|
|
659
|
+
if (!prompt) return fail("session spawn: a prompt is required: maestro session spawn --name <slug> \"<prompt>\"", 2);
|
|
660
|
+
const cwd = flags.cwd ? String(flags.cwd) : agentRoot;
|
|
661
|
+
if (!existsSync(cwd)) return fail(`session spawn: --cwd ${cwd} does not exist`, 2);
|
|
662
|
+
const name = `${first}-${slug}`;
|
|
663
|
+
const muxName = peerMuxName(first, slug);
|
|
664
|
+
|
|
665
|
+
const registered = readPeers(paths).find((p) => p && p.name === name);
|
|
666
|
+
if (registered && muxPresence(d, registered.muxName || muxName).present) {
|
|
667
|
+
return fail(`session spawn: ${name} is already running (${registered.muxName || muxName}); pick another --name or wait for it to finish`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const cfg = await loadCfg(ctx);
|
|
671
|
+
const kind = chooseMux(d, cfg);
|
|
672
|
+
const bin = d.claudeBin || resolveClaudeBin();
|
|
673
|
+
const args = buildSpawnArgs({ first, slug, prompt, env: d.env, allowedTools: cfg.allowedTools });
|
|
674
|
+
const r = kind === "tmux"
|
|
675
|
+
? d.exec("tmux", ["new-session", "-d", "-s", muxName, "-c", cwd, "--", bin, ...args], { cwd, env: d.env })
|
|
676
|
+
: d.exec("screen", ["-dmS", muxName, bin, ...args], { cwd, env: d.env });
|
|
677
|
+
if (r.status !== 0) return fail(`session spawn: ${kind} returned ${r.status}${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
678
|
+
|
|
679
|
+
const startedAt = new Date(d.now()).toISOString();
|
|
680
|
+
const reg = updatePeers(paths, (cur) => [
|
|
681
|
+
...cur.filter((p) => p && p.name !== name),
|
|
682
|
+
{ name, muxName, startedAt, purpose: prompt.slice(0, 200), cwd, mux: kind },
|
|
683
|
+
], { now: d.now });
|
|
684
|
+
if (!reg.ok) say(`session spawn: warning — peers.json not updated: ${reg.error}`);
|
|
685
|
+
const attach = kind === "tmux" ? `tmux attach -t ${muxName}` : `screen -r ${muxName}`;
|
|
686
|
+
say(`session: spawned ${name} in ${kind} session ${muxName} (attach: ${attach})`);
|
|
687
|
+
say(name);
|
|
688
|
+
return { ok: true, code: 0, lines, data: { name, muxName, kind, startedAt } };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case "peers": {
|
|
692
|
+
const { live, dead } = prunePeers(readPeers(paths), (n) => muxPresence(d, n).present);
|
|
693
|
+
if (dead.length) {
|
|
694
|
+
const gone = new Set(dead.map((p) => p && p.name));
|
|
695
|
+
updatePeers(paths, (cur) => cur.filter((p) => !(p && gone.has(p.name))), { now: d.now, waitMs: 500 }); // listing never fails on a registry write
|
|
696
|
+
}
|
|
697
|
+
if (flags.json) { say(JSON.stringify(live, null, 2)); return { ok: true, code: 0, lines, data: live }; }
|
|
698
|
+
if (!live.length) say(`session: no peers running${dead.length ? ` (${dead.length} finished, pruned)` : ""}`);
|
|
699
|
+
else {
|
|
700
|
+
say(`Peers (${live.length})${dead.length ? ` — ${dead.length} finished, pruned` : ""}:`);
|
|
701
|
+
for (const p of live) say(` ${String(p.name).padEnd(28)} ${p.mux || "mux"} ${p.muxName} since ${p.startedAt || "?"}${p.purpose ? ` — ${p.purpose}` : ""}`);
|
|
702
|
+
}
|
|
703
|
+
return { ok: true, code: 0, lines, data: live };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
case "handoffs": {
|
|
707
|
+
const list = listHandoffs(paths, d.now());
|
|
708
|
+
if (flags.json) { say(JSON.stringify(list, null, 2)); return { ok: true, code: 0, lines, data: list }; }
|
|
709
|
+
if (!list.length) say("session: no open handoffs");
|
|
710
|
+
else {
|
|
711
|
+
say(`Open handoffs (${list.length}):`);
|
|
712
|
+
for (const h of list) say(` ${String(h.tickId).padEnd(24)} ${String(h.cadence || "?").padEnd(26)} ${String(h.mode || "").padEnd(9)} age ${fmtAge(h.ageMs)}${h.overdue ? " OVERDUE" : ""}`);
|
|
713
|
+
say("Ack one with: maestro session ack <tickId> [--result <path>]");
|
|
714
|
+
}
|
|
715
|
+
return { ok: true, code: 0, lines, data: list };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
case "ack": {
|
|
719
|
+
const tickId = rest[0];
|
|
720
|
+
if (!tickId) return fail("session ack: <tickId> is required", 2);
|
|
721
|
+
const spec = opts.ackModule || "./session-ack.mjs";
|
|
722
|
+
const url = spec.startsWith("/") ? pathToFileURL(spec).href : new URL(spec, import.meta.url).href;
|
|
723
|
+
let mod;
|
|
724
|
+
try {
|
|
725
|
+
mod = await import(url);
|
|
726
|
+
} catch (e) {
|
|
727
|
+
if (e && (e.code === "ERR_MODULE_NOT_FOUND" || /Cannot find module/.test(String(e.message)))) {
|
|
728
|
+
return fail("session ack: not available in this install — lib/cli/session-ack.mjs is missing (it ships with the daemon-integration package; upgrade the SDK).");
|
|
729
|
+
}
|
|
730
|
+
return fail(`session ack: could not load session-ack: ${e && e.message ? e.message : e}`);
|
|
731
|
+
}
|
|
732
|
+
if (typeof mod.ackHandoff !== "function") return fail("session ack: lib/cli/session-ack.mjs does not export ackHandoff()");
|
|
733
|
+
let r;
|
|
734
|
+
try {
|
|
735
|
+
r = await mod.ackHandoff({ agentRoot, tickId, resultPath: flags.result ? String(flags.result) : null, now: d.now() });
|
|
736
|
+
} catch (e) {
|
|
737
|
+
return fail(`session ack: ${e && e.message ? e.message : e}`);
|
|
738
|
+
}
|
|
739
|
+
if (!r || r.ok === false) return fail(`session ack: ${(r && r.error) || "failed"}`);
|
|
740
|
+
// WP-M2's ackHandoff returns {ok, path, already}; older shapes used movedTo.
|
|
741
|
+
const dest = r.path || r.movedTo || "";
|
|
742
|
+
if (r.already) say(`session: ${tickId} was already acked${dest ? ` (${dest})` : ""}`);
|
|
743
|
+
else say(`session: acked ${tickId}${dest ? ` → ${dest}` : ""}`);
|
|
744
|
+
return { ok: true, code: 0, lines, data: r };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
default:
|
|
748
|
+
return fail(`session: unknown command "${sub}"\n\n${usage()}`, 2);
|
|
749
|
+
}
|
|
750
|
+
}
|