@cohortapp/agent-sdk 2.11.15 → 2.13.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 +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -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/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -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 +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -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 +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -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/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -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/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- 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 +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -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/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -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-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -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-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -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 +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- 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/prompt-builder.mjs +19 -3
- 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 +518 -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/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- 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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/liveness.mjs — is the main session alive? Is the daemon?
|
|
3
|
+
*
|
|
4
|
+
* The daemon must never ASSUME a main session is there: liveness is MEASURED.
|
|
5
|
+
* The feed (scripts/session/feed.mjs) writes `state/session/heartbeat.json`
|
|
6
|
+
* every {@link HEARTBEAT_INTERVAL_MS}; anything that needs to know whether the
|
|
7
|
+
* front door is open calls {@link isSessionLive} and gets a boolean derived
|
|
8
|
+
* from that file's age against an injected clock. A beat older than
|
|
9
|
+
* {@link STALE_MS} (six missed beats) means the session is gone and the daemon
|
|
10
|
+
* falls back to legacy `--print` dispatch, so nothing is dropped either way.
|
|
11
|
+
*
|
|
12
|
+
* Heartbeat shape (frozen contract, read by the daemon and the doctor):
|
|
13
|
+
*
|
|
14
|
+
* { pid, ppid, sessionId, name, ts, feedVersion }
|
|
15
|
+
*
|
|
16
|
+
* The daemon's own liveness (for the feed's `daemon-stale` directive) is the
|
|
17
|
+
* mtime of `state/dashboards/daemon-health.yaml`, rescued by a live
|
|
18
|
+
* `state/daemon.pid` — both read by the caller and handed to the pure
|
|
19
|
+
* {@link isDaemonStale}.
|
|
20
|
+
*
|
|
21
|
+
* All decisions are pure over injected inputs; the two I/O helpers
|
|
22
|
+
* ({@link writeHeartbeat}, {@link sessionLiveness}) take injectable fs and never
|
|
23
|
+
* throw.
|
|
24
|
+
*
|
|
25
|
+
* @module lib/session/liveness
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
import { readFileSync as fsReadFileSync } from "node:fs";
|
|
31
|
+
import { writeJsonAtomic as fsWriteJsonAtomic } from "../fs-atomic.mjs";
|
|
32
|
+
import { sessionPaths } from "./config.mjs";
|
|
33
|
+
|
|
34
|
+
/** Bumped when the feed's line vocabulary changes. Carried on every beat. */
|
|
35
|
+
export const FEED_VERSION = "1";
|
|
36
|
+
/** How often the feed beats. */
|
|
37
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
38
|
+
/** A beat older than this means the session is not live (six missed beats). */
|
|
39
|
+
export const STALE_MS = 90_000;
|
|
40
|
+
/** A daemon health file older than this (with no live pid) is a stale daemon. */
|
|
41
|
+
export const DAEMON_STALE_MS = 5 * 60_000;
|
|
42
|
+
|
|
43
|
+
function nowMs(now) { return typeof now === "function" ? Number(now()) : (now == null ? Date.now() : Number(now)); }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure: the heartbeat record.
|
|
47
|
+
* @param {{pid:number, ppid:number, sessionId:string|null, name:string, now?:number|Function, feedVersion?:string}} a
|
|
48
|
+
*/
|
|
49
|
+
export function buildHeartbeat(a) {
|
|
50
|
+
return {
|
|
51
|
+
pid: a.pid,
|
|
52
|
+
ppid: a.ppid,
|
|
53
|
+
sessionId: a.sessionId ?? null,
|
|
54
|
+
name: a.name,
|
|
55
|
+
ts: new Date(nowMs(a.now)).toISOString(),
|
|
56
|
+
feedVersion: a.feedVersion || FEED_VERSION,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pure: parse a heartbeat file body. Null unless it carries a parseable `ts`.
|
|
62
|
+
* @param {string|null|undefined} text
|
|
63
|
+
*/
|
|
64
|
+
export function parseHeartbeat(text) {
|
|
65
|
+
if (typeof text !== "string" || !text.trim()) return null;
|
|
66
|
+
let raw;
|
|
67
|
+
try { raw = JSON.parse(text); } catch { return null; }
|
|
68
|
+
if (!raw || typeof raw !== "object" || typeof raw.ts !== "string" || !Number.isFinite(Date.parse(raw.ts))) return null;
|
|
69
|
+
return {
|
|
70
|
+
pid: Number.isInteger(raw.pid) ? raw.pid : null,
|
|
71
|
+
ppid: Number.isInteger(raw.ppid) ? raw.ppid : null,
|
|
72
|
+
sessionId: typeof raw.sessionId === "string" ? raw.sessionId : null,
|
|
73
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
74
|
+
ts: raw.ts,
|
|
75
|
+
feedVersion: typeof raw.feedVersion === "string" ? raw.feedVersion : "",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pure: is this heartbeat live at `now`?
|
|
81
|
+
*
|
|
82
|
+
* Liveness is a WINDOW around `now`, not a one-sided age: a beat older than
|
|
83
|
+
* `staleMs` is stale, and a beat further in the FUTURE than `staleMs` is
|
|
84
|
+
* clock skew — the clock was stepped back after the beat was written (NTP,
|
|
85
|
+
* a manual correction), so the beat says nothing about whether the feed is
|
|
86
|
+
* running now. Both read as not-live, because the daemon's fallback for
|
|
87
|
+
* not-live is legacy dispatch (fail-open): a wrong "live" withholds every
|
|
88
|
+
* inbound item for the size of the step, a wrong "not live" costs one
|
|
89
|
+
* double-handled item at most. Small skew (under `staleMs`) is tolerated so
|
|
90
|
+
* a resume/suspend jitter does not evict a real session. An optional
|
|
91
|
+
* `pidAlive(pid)` probe lets a caller refuse a fresh beat whose writer is
|
|
92
|
+
* known to be dead.
|
|
93
|
+
*
|
|
94
|
+
* @param {ReturnType<typeof parseHeartbeat>} hb
|
|
95
|
+
* @param {{now?:number|Function, staleMs?:number, pidAlive?:(pid:number)=>boolean}} [opts]
|
|
96
|
+
* @returns {{live:boolean, reason:"fresh"|"stale"|"skew"|"missing"|"pid-dead", ageMs:number|null}}
|
|
97
|
+
*/
|
|
98
|
+
export function heartbeatLiveness(hb, opts = {}) {
|
|
99
|
+
if (!hb) return { live: false, reason: "missing", ageMs: null };
|
|
100
|
+
const staleMs = opts.staleMs ?? STALE_MS;
|
|
101
|
+
const ageMs = nowMs(opts.now) - Date.parse(hb.ts);
|
|
102
|
+
if (ageMs >= staleMs) return { live: false, reason: "stale", ageMs };
|
|
103
|
+
if (ageMs <= -staleMs) return { live: false, reason: "skew", ageMs };
|
|
104
|
+
if (typeof opts.pidAlive === "function" && Number.isInteger(hb.pid) && hb.pid > 0) {
|
|
105
|
+
let alive = true;
|
|
106
|
+
try { alive = !!opts.pidAlive(hb.pid); } catch { alive = true; /* probe failure must not evict a fresh beat */ }
|
|
107
|
+
if (!alive) return { live: false, reason: "pid-dead", ageMs };
|
|
108
|
+
}
|
|
109
|
+
return { live: true, reason: "fresh", ageMs };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Write the heartbeat atomically (tmp + rename). Result frame, never throws.
|
|
114
|
+
* @param {string} agentRoot @param {object} hb @param {{writeJsonAtomic?:Function}} [deps]
|
|
115
|
+
* @returns {{ok:true}|{ok:false, error:string}}
|
|
116
|
+
*/
|
|
117
|
+
export function writeHeartbeat(agentRoot, hb, deps = {}) {
|
|
118
|
+
const writeJsonAtomic = deps.writeJsonAtomic || fsWriteJsonAtomic;
|
|
119
|
+
try {
|
|
120
|
+
writeJsonAtomic(sessionPaths(agentRoot).heartbeatFile, hb);
|
|
121
|
+
return { ok: true };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read the heartbeat file and judge it. Fail-open: any read error is
|
|
129
|
+
* "missing" (not live), because a daemon that cannot see a beat must dispatch.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} agentRoot
|
|
132
|
+
* @param {{now?:number|Function, staleMs?:number, pidAlive?:Function, readFileSync?:Function}} [opts]
|
|
133
|
+
* @returns {{live:boolean, reason:string, ageMs:number|null, heartbeat:object|null}}
|
|
134
|
+
*/
|
|
135
|
+
export function sessionLiveness(agentRoot, opts = {}) {
|
|
136
|
+
const readFileSync = opts.readFileSync || fsReadFileSync;
|
|
137
|
+
let hb = null;
|
|
138
|
+
try {
|
|
139
|
+
hb = parseHeartbeat(readFileSync(sessionPaths(agentRoot).heartbeatFile, "utf8"));
|
|
140
|
+
} catch {
|
|
141
|
+
// No heartbeat file: no session has ever run here, or it is gone. Not live.
|
|
142
|
+
hb = null;
|
|
143
|
+
}
|
|
144
|
+
return { ...heartbeatLiveness(hb, opts), heartbeat: hb };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The boolean the daemon asks for: is the main session live right now?
|
|
149
|
+
* @param {string} agentRoot
|
|
150
|
+
* @param {{now?:number|Function, staleMs?:number, pidAlive?:Function, readFileSync?:Function}} [opts]
|
|
151
|
+
*/
|
|
152
|
+
export function isSessionLive(agentRoot, opts = {}) {
|
|
153
|
+
return sessionLiveness(agentRoot, opts).live;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pure: is the daemon stale? Fresh health-file mtime → no. A live daemon pid
|
|
158
|
+
* → no (a daemon can be up while its dashboard writer is behind). Otherwise,
|
|
159
|
+
* an absent or old health file is stale.
|
|
160
|
+
*
|
|
161
|
+
* @param {{healthMtimeMs:number|null, pidAlive:boolean|null, now?:number|Function, staleMs?:number}} a
|
|
162
|
+
*/
|
|
163
|
+
export function isDaemonStale(a) {
|
|
164
|
+
if (a.pidAlive === true) return false;
|
|
165
|
+
const staleMs = a.staleMs ?? DAEMON_STALE_MS;
|
|
166
|
+
if (!Number.isFinite(a.healthMtimeMs)) return true;
|
|
167
|
+
return nowMs(a.now) - a.healthMtimeMs >= staleMs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export default {
|
|
171
|
+
FEED_VERSION, HEARTBEAT_INTERVAL_MS, STALE_MS, DAEMON_STALE_MS,
|
|
172
|
+
buildHeartbeat, parseHeartbeat, heartbeatLiveness, writeHeartbeat,
|
|
173
|
+
sessionLiveness, isSessionLive, isDaemonStale,
|
|
174
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FEED_VERSION,
|
|
7
|
+
HEARTBEAT_INTERVAL_MS,
|
|
8
|
+
STALE_MS,
|
|
9
|
+
DAEMON_STALE_MS,
|
|
10
|
+
buildHeartbeat,
|
|
11
|
+
parseHeartbeat,
|
|
12
|
+
heartbeatLiveness,
|
|
13
|
+
writeHeartbeat,
|
|
14
|
+
sessionLiveness,
|
|
15
|
+
isSessionLive,
|
|
16
|
+
isDaemonStale,
|
|
17
|
+
} from "./liveness.mjs";
|
|
18
|
+
|
|
19
|
+
const T0 = Date.parse("2026-09-08T10:00:00Z");
|
|
20
|
+
const UUID = "11111111-2222-4333-8444-555555555555";
|
|
21
|
+
|
|
22
|
+
test("constants: 15 s beat, 90 s stale, 5 min daemon stale", () => {
|
|
23
|
+
assert.equal(HEARTBEAT_INTERVAL_MS, 15_000);
|
|
24
|
+
assert.equal(STALE_MS, 90_000);
|
|
25
|
+
assert.equal(DAEMON_STALE_MS, 5 * 60_000);
|
|
26
|
+
assert.equal(typeof FEED_VERSION, "string");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("buildHeartbeat: exactly the documented fields, ts from the injected clock", () => {
|
|
30
|
+
const hb = buildHeartbeat({ pid: 10, ppid: 9, sessionId: UUID, name: "olivia-main", now: T0 });
|
|
31
|
+
assert.deepEqual(hb, { pid: 10, ppid: 9, sessionId: UUID, name: "olivia-main", ts: "2026-09-08T10:00:00.000Z", feedVersion: FEED_VERSION });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parseHeartbeat: round-trip; junk → null", () => {
|
|
35
|
+
const hb = buildHeartbeat({ pid: 10, ppid: 9, sessionId: UUID, name: "n", now: T0 });
|
|
36
|
+
assert.deepEqual(parseHeartbeat(JSON.stringify(hb)), hb);
|
|
37
|
+
assert.equal(parseHeartbeat("{"), null);
|
|
38
|
+
assert.equal(parseHeartbeat(JSON.stringify({ pid: 1 })), null); // no ts
|
|
39
|
+
assert.equal(parseHeartbeat(JSON.stringify({ ts: "yesterday" })), null);
|
|
40
|
+
assert.equal(parseHeartbeat(undefined), null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("heartbeatLiveness: fresh → live; older than staleMs → stale; slightly future-dated beats are live", () => {
|
|
44
|
+
const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
|
|
45
|
+
assert.deepEqual(heartbeatLiveness(hb, { now: T0 + 30_000 }), { live: true, reason: "fresh", ageMs: 30_000 });
|
|
46
|
+
assert.deepEqual(heartbeatLiveness(hb, { now: T0 + 90_000 }), { live: false, reason: "stale", ageMs: 90_000 });
|
|
47
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 + 89_999 }).live, true);
|
|
48
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 + 10_000, staleMs: 5_000 }).live, false);
|
|
49
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 - 5_000 }).live, true);
|
|
50
|
+
assert.deepEqual(heartbeatLiveness(null, { now: T0 }), { live: false, reason: "missing", ageMs: null });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("heartbeatLiveness: a beat further in the FUTURE than staleMs is clock skew, not liveness (fail-open to dispatch)", () => {
|
|
54
|
+
// The feed beat at T0, the session died, and NTP stepped the clock back
|
|
55
|
+
// 20 min: without a bound the dead session would read as live for 20 min
|
|
56
|
+
// and the daemon would withhold dispatch the whole time.
|
|
57
|
+
const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
|
|
58
|
+
assert.deepEqual(heartbeatLiveness(hb, { now: T0 - 20 * 60_000 }), { live: false, reason: "skew", ageMs: -20 * 60_000 });
|
|
59
|
+
assert.deepEqual(heartbeatLiveness(hb, { now: T0 - 90_000 }), { live: false, reason: "skew", ageMs: -90_000 });
|
|
60
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 - 89_999 }).live, true);
|
|
61
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 - 6_000, staleMs: 5_000 }).reason, "skew");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("heartbeatLiveness: a beat whose pid is known-dead is not live even when fresh", () => {
|
|
65
|
+
const hb = buildHeartbeat({ pid: 4242, ppid: 0, sessionId: UUID, name: "n", now: T0 });
|
|
66
|
+
const r = heartbeatLiveness(hb, { now: T0 + 1_000, pidAlive: () => false });
|
|
67
|
+
assert.deepEqual(r, { live: false, reason: "pid-dead", ageMs: 1_000 });
|
|
68
|
+
// pid liveness is optional: without a probe, freshness decides.
|
|
69
|
+
assert.equal(heartbeatLiveness(hb, { now: T0 + 1_000 }).live, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("writeHeartbeat: atomic write to state/session/heartbeat.json; failure is a value", () => {
|
|
73
|
+
const disk = {};
|
|
74
|
+
const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
|
|
75
|
+
assert.deepEqual(writeHeartbeat("/agent", hb, { writeJsonAtomic: (p, o) => { disk[p] = o; } }), { ok: true });
|
|
76
|
+
assert.deepEqual(disk[join("/agent", "state", "session", "heartbeat.json")], hb);
|
|
77
|
+
const bad = writeHeartbeat("/agent", hb, { writeJsonAtomic: () => { throw new Error("ENOSPC"); } });
|
|
78
|
+
assert.equal(bad.ok, false);
|
|
79
|
+
assert.match(bad.error, /ENOSPC/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("sessionLiveness / isSessionLive read the heartbeat file through injected fs", () => {
|
|
83
|
+
const hb = buildHeartbeat({ pid: process.pid, ppid: 0, sessionId: UUID, name: "n", now: T0 });
|
|
84
|
+
const deps = { readFileSync: (p) => { assert.equal(p, join("/agent", "state", "session", "heartbeat.json")); return JSON.stringify(hb); } };
|
|
85
|
+
const r = sessionLiveness("/agent", { now: T0 + 1_000, ...deps });
|
|
86
|
+
assert.equal(r.live, true);
|
|
87
|
+
assert.equal(r.heartbeat.sessionId, UUID);
|
|
88
|
+
assert.equal(isSessionLive("/agent", { now: T0 + 1_000, ...deps }), true);
|
|
89
|
+
assert.equal(isSessionLive("/agent", { now: T0 + 100_000, ...deps }), false);
|
|
90
|
+
assert.equal(isSessionLive("/agent", { now: T0, readFileSync: () => { throw new Error("ENOENT"); } }), false);
|
|
91
|
+
assert.deepEqual(sessionLiveness("/agent", { now: T0, readFileSync: () => { throw new Error("ENOENT"); } }), { live: false, reason: "missing", ageMs: null, heartbeat: null });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("isDaemonStale: fresh health file → not stale; old health + dead pid → stale; a live pid rescues an old file", () => {
|
|
95
|
+
assert.equal(isDaemonStale({ healthMtimeMs: T0 - 60_000, pidAlive: false, now: T0 }), false);
|
|
96
|
+
assert.equal(isDaemonStale({ healthMtimeMs: T0 - 6 * 60_000, pidAlive: false, now: T0 }), true);
|
|
97
|
+
assert.equal(isDaemonStale({ healthMtimeMs: T0 - 6 * 60_000, pidAlive: true, now: T0 }), false);
|
|
98
|
+
assert.equal(isDaemonStale({ healthMtimeMs: null, pidAlive: null, now: T0 }), true);
|
|
99
|
+
assert.equal(isDaemonStale({ healthMtimeMs: null, pidAlive: true, now: T0 }), false);
|
|
100
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/status-summary.mjs — the pure session-status core.
|
|
3
|
+
*
|
|
4
|
+
* ONE derivation of "is the main session live, who are its peers, what is
|
|
5
|
+
* waiting for it" shared by three consumers: the cohort-mcp `session_status`
|
|
6
|
+
* tool, the SessionStart `prime` hook (which injects a one-line status into
|
|
7
|
+
* every new session on the machine), and `maestro board`. The feed
|
|
8
|
+
* (scripts/session/feed.mjs) writes `state/session/heartbeat.json` every 15 s;
|
|
9
|
+
* `maestro session spawn` keeps `state/session/peers.json`; the cadence
|
|
10
|
+
* consumer drops `state/session/handoffs/<tickId>.json`; the daemon caches
|
|
11
|
+
* `board.mine` to `state/org/board-mine.json` (`{ts, items}`) every 5 min via
|
|
12
|
+
* lib/org/board-mine-cache.mjs. This module only
|
|
13
|
+
* READS those — the writers own their formats and this tolerates every
|
|
14
|
+
* partial/absent/corrupt shape by reporting "absent", never by throwing.
|
|
15
|
+
*
|
|
16
|
+
* `summarizeSessionStatus` is pure over injected snapshots + `now`;
|
|
17
|
+
* `readSessionStatus` is the thin disk reader around it.
|
|
18
|
+
*
|
|
19
|
+
* @module lib/session/status-summary
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { buildIdentityClaudeMd, IDENTITY_BEGIN, IDENTITY_END } from "../collective/global-config.mjs";
|
|
27
|
+
|
|
28
|
+
/** A heartbeat older than this is a dead or wedged session (feed beats every 15 s). */
|
|
29
|
+
export const DEFAULT_STALE_MS = 90_000;
|
|
30
|
+
|
|
31
|
+
function toMs(v) {
|
|
32
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
33
|
+
if (typeof v === "string") {
|
|
34
|
+
const n = Date.parse(v);
|
|
35
|
+
if (Number.isFinite(n)) return n;
|
|
36
|
+
const asNum = Number(v);
|
|
37
|
+
if (Number.isFinite(asNum) && v.trim() !== "") return asNum;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function itemsOf(boardMine) {
|
|
43
|
+
if (Array.isArray(boardMine)) return boardMine;
|
|
44
|
+
if (boardMine && typeof boardMine === "object" && Array.isArray(boardMine.items)) return boardMine.items;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function peersOf(peers) {
|
|
49
|
+
if (Array.isArray(peers)) return peers.filter((p) => p && typeof p === "object");
|
|
50
|
+
if (peers && typeof peers === "object" && Array.isArray(peers.peers)) return peers.peers.filter((p) => p && typeof p === "object");
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Derive the session status from snapshots. Pure.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} o
|
|
58
|
+
* @param {object|null} [o.heartbeat] - state/session/heartbeat.json
|
|
59
|
+
* @param {object[]|object|null} [o.peers] - state/session/peers.json (array, or {peers:[]})
|
|
60
|
+
* @param {string[]|null} [o.handoffs] - names of open handoff files
|
|
61
|
+
* @param {object|object[]|null} [o.boardMine] - state/org/board-mine.json ({ts, items}; older {items, fetchedAt}) or a bare array
|
|
62
|
+
* @param {number} [o.now]
|
|
63
|
+
* @param {number} [o.staleMs]
|
|
64
|
+
*/
|
|
65
|
+
export function summarizeSessionStatus(o = {}) {
|
|
66
|
+
const now = Number.isFinite(o.now) ? o.now : Date.now();
|
|
67
|
+
const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_STALE_MS;
|
|
68
|
+
const hb = o.heartbeat && typeof o.heartbeat === "object" ? o.heartbeat : null;
|
|
69
|
+
const ts = hb ? toMs(hb.ts) : null;
|
|
70
|
+
let state = "absent";
|
|
71
|
+
let ageMs = null;
|
|
72
|
+
if (ts !== null) {
|
|
73
|
+
ageMs = Math.max(0, now - ts);
|
|
74
|
+
state = ageMs <= staleMs ? "live" : "stale";
|
|
75
|
+
}
|
|
76
|
+
const peers = peersOf(o.peers);
|
|
77
|
+
const handoffs = Array.isArray(o.handoffs) ? o.handoffs.filter((h) => typeof h === "string") : [];
|
|
78
|
+
const items = itemsOf(o.boardMine);
|
|
79
|
+
// The daemon writes {ts, items} (lib/org/board-mine-cache.mjs); fetchedAt is the earlier spelling.
|
|
80
|
+
const bm = o.boardMine && typeof o.boardMine === "object" && !Array.isArray(o.boardMine) ? o.boardMine : null;
|
|
81
|
+
const fetchedAt = bm && typeof bm.fetchedAt === "string" ? bm.fetchedAt : bm && typeof bm.ts === "string" ? bm.ts : null;
|
|
82
|
+
return {
|
|
83
|
+
live: state === "live",
|
|
84
|
+
state,
|
|
85
|
+
name: hb && typeof hb.name === "string" ? hb.name : null,
|
|
86
|
+
sessionId: hb && typeof hb.sessionId === "string" ? hb.sessionId : null,
|
|
87
|
+
pid: hb && Number.isFinite(hb.pid) ? hb.pid : null,
|
|
88
|
+
heartbeatAt: ts === null ? null : new Date(ts).toISOString(),
|
|
89
|
+
ageMs,
|
|
90
|
+
staleMs,
|
|
91
|
+
peers: peers.map((p) => ({
|
|
92
|
+
name: typeof p.name === "string" ? p.name : null,
|
|
93
|
+
purpose: typeof p.purpose === "string" ? p.purpose : null,
|
|
94
|
+
startedAt: typeof p.startedAt === "string" ? p.startedAt : null,
|
|
95
|
+
})),
|
|
96
|
+
peerCount: peers.length,
|
|
97
|
+
handoffsOpen: handoffs.length,
|
|
98
|
+
handoffs,
|
|
99
|
+
boardMineCount: items.length,
|
|
100
|
+
boardMineFetchedAt: fetchedAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function plural(n, word) { return `${n} ${word}${n === 1 ? "" : "s"}`; }
|
|
105
|
+
|
|
106
|
+
/** One line for a primer: "main session alex-main live (beat 5s ago) · 1 peer · 1 open handoff · 2 board items". */
|
|
107
|
+
export function statusLine(s) {
|
|
108
|
+
const st = s && typeof s === "object" ? s : summarizeSessionStatus({});
|
|
109
|
+
const who = st.name ? `main session ${st.name}` : "main session";
|
|
110
|
+
let head;
|
|
111
|
+
if (st.state === "live") head = `${who} live (beat ${Math.round((st.ageMs || 0) / 1000)}s ago)`;
|
|
112
|
+
else if (st.state === "stale") head = `${who} stale (last beat ${Math.round((st.ageMs || 0) / 1000)}s ago)`;
|
|
113
|
+
else head = `${who} not running (no heartbeat)`;
|
|
114
|
+
return [
|
|
115
|
+
head,
|
|
116
|
+
plural(st.peerCount || 0, "peer"),
|
|
117
|
+
plural(st.handoffsOpen || 0, "open handoff"),
|
|
118
|
+
plural(st.boardMineCount || 0, "board item") + (st.boardMineFetchedAt ? " (cached)" : ""),
|
|
119
|
+
].join(" · ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readJson(p) {
|
|
123
|
+
try {
|
|
124
|
+
if (!existsSync(p)) return null;
|
|
125
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return null; // absent/corrupt state is "not running", never a crash in a hook
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the four state files under an agent root and summarise. Fail-open:
|
|
133
|
+
* an unreadable file is the same as an absent one.
|
|
134
|
+
* @param {string} agentRoot
|
|
135
|
+
* @param {{now?:number, staleMs?:number}} [o]
|
|
136
|
+
*/
|
|
137
|
+
export function readSessionStatus(agentRoot, o = {}) {
|
|
138
|
+
const root = String(agentRoot || "");
|
|
139
|
+
const sess = join(root, "state", "session");
|
|
140
|
+
let handoffs = [];
|
|
141
|
+
try {
|
|
142
|
+
const dir = join(sess, "handoffs");
|
|
143
|
+
if (existsSync(dir)) handoffs = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
144
|
+
} catch {
|
|
145
|
+
handoffs = []; // a vanished directory mid-read is an empty queue
|
|
146
|
+
}
|
|
147
|
+
return summarizeSessionStatus({
|
|
148
|
+
heartbeat: readJson(join(sess, "heartbeat.json")),
|
|
149
|
+
peers: readJson(join(sess, "peers.json")),
|
|
150
|
+
handoffs,
|
|
151
|
+
boardMine: readJson(join(root, "state", "org", "board-mine.json")),
|
|
152
|
+
now: o.now,
|
|
153
|
+
staleMs: o.staleMs,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The SessionStart primer text: the identity block (delimited, so a reader can
|
|
159
|
+
* tell it from the memory digest) + the one-line status. Empty when the
|
|
160
|
+
* agent has no identity yet (a bare/unconfigured repo says nothing).
|
|
161
|
+
* @param {{agentJson:object, agentRoot:string, status:object}} o
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function buildPrimerContext(o = {}) {
|
|
165
|
+
const a = o.agentJson && typeof o.agentJson === "object" ? o.agentJson : null;
|
|
166
|
+
if (!a || !(a.fullName || a.firstName)) return "";
|
|
167
|
+
const block = buildIdentityClaudeMd(a, { agentRoot: o.agentRoot });
|
|
168
|
+
const status = o.status && typeof o.status === "object" ? o.status : summarizeSessionStatus({});
|
|
169
|
+
return [IDENTITY_BEGIN, block, IDENTITY_END, "", `Session status: ${statusLine(status)}.`].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default { summarizeSessionStatus, readSessionStatus, statusLine, buildPrimerContext, DEFAULT_STALE_MS };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-summary.test.mjs — the pure session-status core the cohort-mcp
|
|
3
|
+
* `session_status` tool, the SessionStart primer and `maestro board` share.
|
|
4
|
+
* Run: node --test lib/session/status-summary.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
summarizeSessionStatus, readSessionStatus, statusLine, buildPrimerContext, DEFAULT_STALE_MS,
|
|
16
|
+
} from "./status-summary.mjs";
|
|
17
|
+
|
|
18
|
+
const NOW = Date.parse("2026-09-08T12:00:00Z");
|
|
19
|
+
|
|
20
|
+
test("summarizeSessionStatus: live when the heartbeat is fresh, stale past staleMs, absent when missing", () => {
|
|
21
|
+
const fresh = summarizeSessionStatus({
|
|
22
|
+
heartbeat: { pid: 1, sessionId: "abc", name: "alex-main", ts: new Date(NOW - 10_000).toISOString() },
|
|
23
|
+
now: NOW,
|
|
24
|
+
});
|
|
25
|
+
assert.equal(fresh.live, true);
|
|
26
|
+
assert.equal(fresh.state, "live");
|
|
27
|
+
assert.equal(fresh.name, "alex-main");
|
|
28
|
+
assert.equal(fresh.ageMs, 10_000);
|
|
29
|
+
const stale = summarizeSessionStatus({ heartbeat: { ts: NOW - DEFAULT_STALE_MS - 1 }, now: NOW });
|
|
30
|
+
assert.equal(stale.live, false);
|
|
31
|
+
assert.equal(stale.state, "stale");
|
|
32
|
+
const none = summarizeSessionStatus({ heartbeat: null, now: NOW });
|
|
33
|
+
assert.equal(none.live, false);
|
|
34
|
+
assert.equal(none.state, "absent");
|
|
35
|
+
const garbage = summarizeSessionStatus({ heartbeat: { ts: "not a date" }, now: NOW });
|
|
36
|
+
assert.equal(garbage.state, "absent", "unparseable ts is absent, never a throw");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("summarizeSessionStatus: counts peers, open handoffs and board-mine items; tolerates garbage", () => {
|
|
40
|
+
const s = summarizeSessionStatus({
|
|
41
|
+
heartbeat: { ts: NOW },
|
|
42
|
+
peers: [{ name: "alex-research" }, { name: "alex-deck" }],
|
|
43
|
+
handoffs: ["t1.json", "t2.json", "t3.json"],
|
|
44
|
+
boardMine: { items: [{ itemId: "a" }, { itemId: "b" }], fetchedAt: new Date(NOW - 60_000).toISOString() },
|
|
45
|
+
now: NOW,
|
|
46
|
+
});
|
|
47
|
+
assert.equal(s.peers.length, 2);
|
|
48
|
+
assert.equal(s.peerCount, 2);
|
|
49
|
+
assert.equal(s.handoffsOpen, 3);
|
|
50
|
+
assert.equal(s.boardMineCount, 2);
|
|
51
|
+
assert.equal(s.boardMineFetchedAt, new Date(NOW - 60_000).toISOString());
|
|
52
|
+
const g = summarizeSessionStatus({ peers: "x", handoffs: null, boardMine: [1, 2, 3], now: NOW });
|
|
53
|
+
assert.equal(g.peerCount, 0);
|
|
54
|
+
assert.equal(g.handoffsOpen, 0);
|
|
55
|
+
assert.equal(g.boardMineCount, 3, "a bare array is accepted as the item list");
|
|
56
|
+
const g2 = summarizeSessionStatus({ boardMine: { nope: true }, now: NOW });
|
|
57
|
+
assert.equal(g2.boardMineCount, 0);
|
|
58
|
+
assert.equal(g2.boardMineFetchedAt, null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("statusLine: one line a primer can inject", () => {
|
|
62
|
+
const s = summarizeSessionStatus({
|
|
63
|
+
heartbeat: { ts: NOW - 5_000, name: "alex-main" },
|
|
64
|
+
peers: [{ name: "alex-deck" }],
|
|
65
|
+
handoffs: ["a"],
|
|
66
|
+
boardMine: { items: [{}, {}] },
|
|
67
|
+
now: NOW,
|
|
68
|
+
});
|
|
69
|
+
const line = statusLine(s);
|
|
70
|
+
assert.equal(line.split("\n").length, 1);
|
|
71
|
+
assert.match(line, /alex-main/);
|
|
72
|
+
assert.match(line, /live/);
|
|
73
|
+
assert.match(line, /1 peer/);
|
|
74
|
+
assert.match(line, /1 open handoff/);
|
|
75
|
+
assert.match(line, /2 board item/);
|
|
76
|
+
const absent = statusLine(summarizeSessionStatus({ now: NOW }));
|
|
77
|
+
assert.match(absent, /not running|absent/i);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("readSessionStatus: reads state/session/{heartbeat,peers}.json, handoffs/*.json and state/org/board-mine.json; fail-open", () => {
|
|
81
|
+
const root = mkdtempSync(join(tmpdir(), "status-summary-"));
|
|
82
|
+
try {
|
|
83
|
+
assert.equal(readSessionStatus(root, { now: NOW }).state, "absent", "empty root → absent, no throw");
|
|
84
|
+
mkdirSync(join(root, "state", "session", "handoffs", "done"), { recursive: true });
|
|
85
|
+
mkdirSync(join(root, "state", "org"), { recursive: true });
|
|
86
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 9, name: "alex-main", ts: NOW - 1000 }));
|
|
87
|
+
writeFileSync(join(root, "state", "session", "peers.json"), JSON.stringify([{ name: "alex-deck", muxName: "m" }]));
|
|
88
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
|
|
89
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t2.json"), "{}");
|
|
90
|
+
writeFileSync(join(root, "state", "session", "handoffs", "notes.txt"), "x");
|
|
91
|
+
writeFileSync(join(root, "state", "session", "handoffs", "done", "t0.json"), "{}");
|
|
92
|
+
writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }], fetchedAt: "2026-09-08T11:59:00.000Z" }));
|
|
93
|
+
const s = readSessionStatus(root, { now: NOW });
|
|
94
|
+
assert.equal(s.live, true);
|
|
95
|
+
assert.equal(s.name, "alex-main");
|
|
96
|
+
assert.equal(s.peerCount, 1);
|
|
97
|
+
assert.equal(s.handoffsOpen, 2, "only *.json in handoffs/, not done/ or other files");
|
|
98
|
+
assert.equal(s.boardMineCount, 1);
|
|
99
|
+
// peers.json may also be an object with a `peers` array.
|
|
100
|
+
writeFileSync(join(root, "state", "session", "peers.json"), JSON.stringify({ peers: [{ name: "a" }, { name: "b" }] }));
|
|
101
|
+
assert.equal(readSessionStatus(root, { now: NOW }).peerCount, 2);
|
|
102
|
+
// Corrupt files are absent, not fatal.
|
|
103
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), "{not json");
|
|
104
|
+
assert.equal(readSessionStatus(root, { now: NOW }).state, "absent");
|
|
105
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("buildPrimerContext: identity block + status line + counts; empty when there is no agent identity", () => {
|
|
109
|
+
const status = summarizeSessionStatus({ heartbeat: { ts: NOW, name: "alex-main" }, handoffs: ["a", "b"], boardMine: { items: [{}] }, now: NOW });
|
|
110
|
+
const ctx = buildPrimerContext({ agentJson: { fullName: "Alex Rivera", firstName: "Alex" }, agentRoot: "/r/alex-ai", status });
|
|
111
|
+
assert.match(ctx, /maestro:identity/);
|
|
112
|
+
assert.match(ctx, /Alex Rivera/);
|
|
113
|
+
assert.match(ctx, /alex-main/);
|
|
114
|
+
assert.match(ctx, /2 open handoffs/);
|
|
115
|
+
assert.match(ctx, /1 board item/);
|
|
116
|
+
assert.equal(buildPrimerContext({ agentJson: {}, agentRoot: "/r", status }), "", "no identity → nothing to say");
|
|
117
|
+
assert.equal(buildPrimerContext({ agentJson: null, agentRoot: "/r", status }), "");
|
|
118
|
+
});
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* (`--strict-mcp-config` / `--bare`) exactly as before, so `--strict-mcp-config`
|
|
21
21
|
* is preserved in BOTH modes.
|
|
22
22
|
*
|
|
23
|
-
* Caller sites (today): scripts/daemon/cadence-consumer.mjs#realSpawnSession
|
|
23
|
+
* Caller sites (today): scripts/daemon/cadence-consumer.mjs#realSpawnSession,
|
|
24
|
+
* classifier/responder/assurance (`source`), and the front-door supervisor
|
|
25
|
+
* (`source: "main-session"`, see {@link MAIN_SESSION_SOURCE}).
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
import { getCadenceDef } from "../scripts/daemon/cadence-handlers.mjs";
|
|
@@ -64,6 +66,29 @@ function resolveAllowlist(cadence) {
|
|
|
64
66
|
return [...CONSERVATIVE_DEFAULT_ALLOWLIST];
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The `source` the front-door supervisor passes. The main session is an
|
|
71
|
+
* INTERACTIVE `claude` inside a multiplexer with nobody attached: any tool
|
|
72
|
+
* outside its allowlist raises a y/n prompt that no one can answer, and the
|
|
73
|
+
* session wedges while its feed keeps beating (so the daemon believes the
|
|
74
|
+
* front door is open and withholds dispatch). The conservative `--print`
|
|
75
|
+
* allowlist therefore never applies to it — in scoped mode it gets either
|
|
76
|
+
* the operator's explicit main-session allowlist (`config/session.yaml`
|
|
77
|
+
* `allowedTools`) or, absent one, bypass.
|
|
78
|
+
*/
|
|
79
|
+
export const MAIN_SESSION_SOURCE = "main-session";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pure: a caller-supplied allowlist, cleaned. Non-array / empty → null.
|
|
83
|
+
* @param {unknown} list
|
|
84
|
+
* @returns {string[]|null}
|
|
85
|
+
*/
|
|
86
|
+
function explicitAllowlist(list) {
|
|
87
|
+
if (!Array.isArray(list)) return null;
|
|
88
|
+
const cleaned = list.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim());
|
|
89
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
/**
|
|
68
93
|
* Build the `claude` CLI permission args for a spawned sub-session.
|
|
69
94
|
*
|
|
@@ -83,12 +108,19 @@ function resolveAllowlist(cadence) {
|
|
|
83
108
|
* via `.env` and have a long-lived daemon pick it up on the next spawn without
|
|
84
109
|
* a code change.
|
|
85
110
|
*
|
|
111
|
+
* - "1" with `source: "main-session"` — the interactive front door. Uses
|
|
112
|
+
* `allowedTools` when the caller supplies a non-empty list, else bypass;
|
|
113
|
+
* the conservative `--print` default is never applied (it would hang an
|
|
114
|
+
* unattended interactive session on its first permission prompt).
|
|
115
|
+
*
|
|
86
116
|
* @param {Object} [opts]
|
|
87
117
|
* @param {string} [opts.cadence] cadence name; used to look up a per-cadence allowlist in scoped mode.
|
|
88
118
|
* @param {string} [opts.mode] cadence mode ("inline"|"guarded"|"escalate"); accepted for forward-compat (future per-mode tightening), currently unused.
|
|
89
|
-
* @
|
|
119
|
+
* @param {string} [opts.source] who is spawning ("classifier", "responder", "ack", "main-session", …); only {@link MAIN_SESSION_SOURCE} changes the result.
|
|
120
|
+
* @param {string[]} [opts.allowedTools] main-session only: an explicit allowlist from config/session.yaml.
|
|
121
|
+
* @returns {string[]} permission CLI args to splice into the `claude` argv.
|
|
90
122
|
*/
|
|
91
|
-
export function sessionPermissionArgs({ cadence, mode } = {}) {
|
|
123
|
+
export function sessionPermissionArgs({ cadence, mode, source, allowedTools } = {}) {
|
|
92
124
|
// `mode` is reserved for future per-mode tightening; reference it so linters
|
|
93
125
|
// don't flag it as unused while keeping the signature stable for callers.
|
|
94
126
|
void mode;
|
|
@@ -96,6 +128,10 @@ export function sessionPermissionArgs({ cadence, mode } = {}) {
|
|
|
96
128
|
// Default / back-compat: UNCHANGED behaviour.
|
|
97
129
|
return ["--dangerously-skip-permissions"];
|
|
98
130
|
}
|
|
131
|
+
if (source === MAIN_SESSION_SOURCE) {
|
|
132
|
+
const explicit = explicitAllowlist(allowedTools);
|
|
133
|
+
return explicit ? ["--allowedTools", explicit.join(",")] : ["--dangerously-skip-permissions"];
|
|
134
|
+
}
|
|
99
135
|
const allow = resolveAllowlist(cadence);
|
|
100
136
|
return ["--allowedTools", allow.join(",")];
|
|
101
137
|
}
|
|
@@ -98,3 +98,23 @@ test("conservative default allowlist is read-only / search-only (no write, shell
|
|
|
98
98
|
// And it must be non-empty so scoped mode never emits an empty allowlist.
|
|
99
99
|
assert.ok(CONSERVATIVE_DEFAULT_ALLOWLIST.length > 0);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test("main session: scoped mode never hands the interactive front door the --print allowlist (it would hang on the first prompt)", () => {
|
|
103
|
+
withScopedEnv("1", () => {
|
|
104
|
+
// No main-session allowlist configured → bypass, because nobody is
|
|
105
|
+
// attached to answer a y/n prompt inside the multiplexer.
|
|
106
|
+
assert.deepEqual(sessionPermissionArgs({ source: "main-session" }), ["--dangerously-skip-permissions"]);
|
|
107
|
+
assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: [] }), ["--dangerously-skip-permissions"]);
|
|
108
|
+
assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: "Read" }), ["--dangerously-skip-permissions"]);
|
|
109
|
+
// An operator-defined main-session allowlist IS honoured.
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
sessionPermissionArgs({ source: "main-session", allowedTools: ["Read", " Monitor ", "", 3, "mcp__cohort__*"] }),
|
|
112
|
+
["--allowedTools", "Read,Monitor,mcp__cohort__*"],
|
|
113
|
+
);
|
|
114
|
+
// Other sources are untouched by the new option.
|
|
115
|
+
assert.deepEqual(sessionPermissionArgs({ source: "responder", allowedTools: ["Bash"] }), ["--allowedTools", CONSERVATIVE_DEFAULT_ALLOWLIST.join(",")]);
|
|
116
|
+
});
|
|
117
|
+
withScopedEnv(undefined, () => {
|
|
118
|
+
assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: ["Read"] }), ["--dangerously-skip-permissions"]);
|
|
119
|
+
});
|
|
120
|
+
});
|