@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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/feed-core.mjs — scan → events, pure.
|
|
3
|
+
*
|
|
4
|
+
* The feed runner (scripts/session/feed.mjs) takes a SNAPSHOT of the files the
|
|
5
|
+
* main session cares about and hands it here with the SEEN-SET from the last
|
|
6
|
+
* tick; this module answers "which lines does the session need to read now"
|
|
7
|
+
* and returns the seen-set to persist. No I/O, no clock reads, no randomness —
|
|
8
|
+
* every decision is a function of (snapshot, seen, now).
|
|
9
|
+
*
|
|
10
|
+
* Snapshot shape (built by the runner):
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* inbox: { [service]: [{ file, path, body }] }, // directory listing + bodies
|
|
14
|
+
* handoffs: [{ file, path, body }], // state/session/handoffs/*.json
|
|
15
|
+
* signals: {
|
|
16
|
+
* restartRequestedMtimeMs: number|null, // state/session/restart-requested
|
|
17
|
+
* upgradeNotice: { mtimeMs, body }|null, // state/session/upgrade-notice.json
|
|
18
|
+
* daemonHealthMtimeMs: number|null, // state/dashboards/daemon-health.yaml
|
|
19
|
+
* daemonPidAlive: boolean|null, // state/daemon.pid liveness
|
|
20
|
+
* },
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Lines emitted (one JSON object each — the session's whole vocabulary):
|
|
24
|
+
*
|
|
25
|
+
* {"type":"inbound","id","service","surface","topic","reason","from","channelId","channelName","threadId","preview","path","seq"}
|
|
26
|
+
* {"type":"handoff","id","cadence","promptPath","enqueuedAt","path","seq"}
|
|
27
|
+
* {"type":"directive","action":"restart"|"upgrade-available"|"daemon-stale","detail","seq"}
|
|
28
|
+
*
|
|
29
|
+
* The seen-set is keyed by file, and a key whose file has GONE is dropped —
|
|
30
|
+
* so an item the daemon's assurance sweep re-opens (`.dispatched` → `.yaml`)
|
|
31
|
+
* is re-emitted, exactly as the design requires. `seq` is monotonic across
|
|
32
|
+
* runner restarts because it lives in the seen-set.
|
|
33
|
+
*
|
|
34
|
+
* @module lib/session/feed-core
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
"use strict";
|
|
38
|
+
|
|
39
|
+
import { parseInboxItemYaml } from "../../scripts/poller/inbox-scan-poller.mjs";
|
|
40
|
+
import { SURFACES } from "../org/inbound/surfaces.mjs";
|
|
41
|
+
import { maskTextString } from "../security/redact.mjs";
|
|
42
|
+
import { isDaemonStale } from "./liveness.mjs";
|
|
43
|
+
|
|
44
|
+
/** Seen-set schema version. */
|
|
45
|
+
export const SEEN_VERSION = 1;
|
|
46
|
+
/** Inbound preview cap (characters). */
|
|
47
|
+
export const PREVIEW_MAX = 240;
|
|
48
|
+
|
|
49
|
+
function nowMs(now) { return typeof now === "function" ? Number(now()) : (now == null ? Date.now() : Number(now)); }
|
|
50
|
+
|
|
51
|
+
/** A fresh seen-set. */
|
|
52
|
+
export function emptySeen() {
|
|
53
|
+
return { version: SEEN_VERSION, seq: 0, inbox: {}, handoffs: {}, signals: {} };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse a persisted seen-set; anything unexpected → empty (replay is bounded by what is still on disk). */
|
|
57
|
+
export function parseSeen(text) {
|
|
58
|
+
if (typeof text !== "string") return emptySeen();
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(text);
|
|
61
|
+
if (!raw || typeof raw !== "object" || raw.version !== SEEN_VERSION) return emptySeen();
|
|
62
|
+
return {
|
|
63
|
+
version: SEEN_VERSION,
|
|
64
|
+
seq: Number.isInteger(raw.seq) && raw.seq >= 0 ? raw.seq : 0,
|
|
65
|
+
inbox: raw.inbox && typeof raw.inbox === "object" ? { ...raw.inbox } : {},
|
|
66
|
+
handoffs: raw.handoffs && typeof raw.handoffs === "object" ? { ...raw.handoffs } : {},
|
|
67
|
+
signals: raw.signals && typeof raw.signals === "object" ? { ...raw.signals } : {},
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// A torn or foreign file is not worth a crash: start over, bounded by disk.
|
|
71
|
+
return emptySeen();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A live inbox item: `*.yaml`, not a `.dispatched/.processed/.deferred` sibling, not hidden. */
|
|
76
|
+
export function isLiveInboxFile(name) {
|
|
77
|
+
return typeof name === "string" && !name.startsWith(".") && name.endsWith(".yaml");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A handoff: `*.json` directly in the handoffs dir (temp siblings excluded). */
|
|
81
|
+
export function isHandoffFile(name) {
|
|
82
|
+
return typeof name === "string" && !name.startsWith(".") && name.endsWith(".json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** kind → topic, from the surface table; unknown kinds are plain messages. */
|
|
86
|
+
const TOPIC_BY_KIND = (() => {
|
|
87
|
+
const m = new Map();
|
|
88
|
+
for (const s of Object.values(SURFACES)) if (s && s.kind && !m.has(s.kind)) m.set(s.kind, s.topic);
|
|
89
|
+
return m;
|
|
90
|
+
})();
|
|
91
|
+
|
|
92
|
+
function preview(text) {
|
|
93
|
+
const masked = maskTextString(String(text || "")).replace(/\s+/g, " ").trim();
|
|
94
|
+
return masked.length > PREVIEW_MAX ? masked.slice(0, PREVIEW_MAX) : masked;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pure: one inbox file → an `inbound` line, or null when the body is not an item.
|
|
99
|
+
* @param {{service:string, path:string, body:string, seq:number}} a
|
|
100
|
+
*/
|
|
101
|
+
export function inboundEvent(a) {
|
|
102
|
+
if (typeof a.body !== "string" || !a.body.trim()) return null;
|
|
103
|
+
let item;
|
|
104
|
+
try { item = parseInboxItemYaml(a.body); } catch { return null; }
|
|
105
|
+
if (!item || !item.id) return null;
|
|
106
|
+
const surface = item.kind || "message";
|
|
107
|
+
return {
|
|
108
|
+
type: "inbound",
|
|
109
|
+
id: item.id,
|
|
110
|
+
service: a.service,
|
|
111
|
+
surface,
|
|
112
|
+
topic: TOPIC_BY_KIND.get(surface) || "message",
|
|
113
|
+
reason: item.direct_reason || "",
|
|
114
|
+
from: item.sender || "",
|
|
115
|
+
channelId: item.channel_id || "",
|
|
116
|
+
channelName: item.channel || "",
|
|
117
|
+
threadId: item.thread_id || "",
|
|
118
|
+
preview: preview(item.content),
|
|
119
|
+
path: a.path,
|
|
120
|
+
seq: a.seq,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure: one handoff file → a `handoff` line, or null when the body is not JSON.
|
|
126
|
+
* @param {{path:string, file:string, body:string, seq:number}} a
|
|
127
|
+
*/
|
|
128
|
+
export function handoffEvent(a) {
|
|
129
|
+
let raw;
|
|
130
|
+
try { raw = JSON.parse(a.body); } catch { return null; }
|
|
131
|
+
if (!raw || typeof raw !== "object") return null;
|
|
132
|
+
const id = typeof raw.tickId === "string" && raw.tickId ? raw.tickId : String(a.file).replace(/\.json$/, "");
|
|
133
|
+
return {
|
|
134
|
+
type: "handoff",
|
|
135
|
+
id,
|
|
136
|
+
cadence: typeof raw.cadence === "string" ? raw.cadence : "",
|
|
137
|
+
promptPath: typeof raw.promptPath === "string" ? raw.promptPath : "",
|
|
138
|
+
enqueuedAt: typeof raw.enqueuedAt === "string" ? raw.enqueuedAt : "",
|
|
139
|
+
path: a.path,
|
|
140
|
+
seq: a.seq,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pure: the tick. Returns the lines to emit and the seen-set to persist.
|
|
146
|
+
* @param {object} snapshot see module docblock
|
|
147
|
+
* @param {ReturnType<typeof emptySeen>} seenIn
|
|
148
|
+
* @param {{now?:number|Function}} [opts]
|
|
149
|
+
* @returns {{events:object[], seen:ReturnType<typeof emptySeen>}}
|
|
150
|
+
*/
|
|
151
|
+
export function computeFeedEvents(snapshot, seenIn, opts = {}) {
|
|
152
|
+
const now = nowMs(opts.now);
|
|
153
|
+
const prev = seenIn || emptySeen();
|
|
154
|
+
const seen = { version: SEEN_VERSION, seq: prev.seq || 0, inbox: {}, handoffs: {}, signals: { ...(prev.signals || {}) } };
|
|
155
|
+
const events = [];
|
|
156
|
+
const next = () => ++seen.seq;
|
|
157
|
+
|
|
158
|
+
// Inbox: every configured service, files in name order (timestamp-prefixed
|
|
159
|
+
// names sort chronologically), only live `*.yaml`.
|
|
160
|
+
const inbox = snapshot && snapshot.inbox && typeof snapshot.inbox === "object" ? snapshot.inbox : {};
|
|
161
|
+
for (const service of Object.keys(inbox).sort()) {
|
|
162
|
+
const entries = (inbox[service] || []).filter((e) => e && isLiveInboxFile(e.file)).sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0));
|
|
163
|
+
for (const e of entries) {
|
|
164
|
+
const key = `${service}/${e.file}`;
|
|
165
|
+
if (prev.inbox && prev.inbox[key] != null) { seen.inbox[key] = prev.inbox[key]; continue; }
|
|
166
|
+
const ev = inboundEvent({ service, path: e.path, body: e.body, seq: seen.seq + 1 });
|
|
167
|
+
if (!ev) continue; // unreadable now; retried next tick, never marked seen
|
|
168
|
+
next();
|
|
169
|
+
events.push(ev);
|
|
170
|
+
seen.inbox[key] = now;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Handoffs: `*.json` in name order.
|
|
175
|
+
const handoffs = (snapshot && Array.isArray(snapshot.handoffs) ? snapshot.handoffs : [])
|
|
176
|
+
.filter((e) => e && isHandoffFile(e.file))
|
|
177
|
+
.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0));
|
|
178
|
+
for (const e of handoffs) {
|
|
179
|
+
if (prev.handoffs && prev.handoffs[e.file] != null) { seen.handoffs[e.file] = prev.handoffs[e.file]; continue; }
|
|
180
|
+
const ev = handoffEvent({ path: e.path, file: e.file, body: e.body, seq: seen.seq + 1 });
|
|
181
|
+
if (!ev) continue;
|
|
182
|
+
next();
|
|
183
|
+
events.push(ev);
|
|
184
|
+
seen.handoffs[e.file] = now;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Signals.
|
|
188
|
+
const sig = snapshot && snapshot.signals && typeof snapshot.signals === "object" ? snapshot.signals : {};
|
|
189
|
+
|
|
190
|
+
// restart-requested: once per appearance (keyed by the marker's mtime).
|
|
191
|
+
if (Number.isFinite(sig.restartRequestedMtimeMs)) {
|
|
192
|
+
if (seen.signals.restart !== sig.restartRequestedMtimeMs) {
|
|
193
|
+
seen.signals.restart = sig.restartRequestedMtimeMs;
|
|
194
|
+
events.push({ type: "directive", action: "restart", detail: { requestedAt: new Date(sig.restartRequestedMtimeMs).toISOString() }, seq: next() });
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
delete seen.signals.restart;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// upgrade-notice: once per notice (keyed by mtime), detail = parsed body.
|
|
201
|
+
const un = sig.upgradeNotice;
|
|
202
|
+
if (un && Number.isFinite(un.mtimeMs)) {
|
|
203
|
+
if (seen.signals.upgrade !== un.mtimeMs) {
|
|
204
|
+
seen.signals.upgrade = un.mtimeMs;
|
|
205
|
+
let notice = null;
|
|
206
|
+
try { notice = JSON.parse(un.body); } catch { notice = null; /* a hand-written marker still announces the upgrade */ }
|
|
207
|
+
const detail = notice && typeof notice === "object" ? { notice } : { notice: null, raw: String(un.body || "").slice(0, PREVIEW_MAX) };
|
|
208
|
+
events.push({ type: "directive", action: "upgrade-available", detail, seq: next() });
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
delete seen.signals.upgrade;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// daemon-stale: on the transition only; re-armed once the daemon is fresh.
|
|
215
|
+
const stale = isDaemonStale({ healthMtimeMs: sig.daemonHealthMtimeMs ?? null, pidAlive: sig.daemonPidAlive ?? null, now });
|
|
216
|
+
if (stale && seen.signals.daemonStale !== true) {
|
|
217
|
+
events.push({
|
|
218
|
+
type: "directive",
|
|
219
|
+
action: "daemon-stale",
|
|
220
|
+
detail: { healthAgeMs: Number.isFinite(sig.daemonHealthMtimeMs) ? now - sig.daemonHealthMtimeMs : null, pidAlive: sig.daemonPidAlive ?? null },
|
|
221
|
+
seq: next(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
seen.signals.daemonStale = stale;
|
|
225
|
+
|
|
226
|
+
return { events, seen };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default { SEEN_VERSION, PREVIEW_MAX, emptySeen, parseSeen, isLiveInboxFile, isHandoffFile, inboundEvent, handoffEvent, computeFeedEvents };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PREVIEW_MAX,
|
|
6
|
+
emptySeen,
|
|
7
|
+
parseSeen,
|
|
8
|
+
isLiveInboxFile,
|
|
9
|
+
isHandoffFile,
|
|
10
|
+
inboundEvent,
|
|
11
|
+
handoffEvent,
|
|
12
|
+
computeFeedEvents,
|
|
13
|
+
} from "./feed-core.mjs";
|
|
14
|
+
import { inboxItemToYaml } from "../../scripts/poller/utils.mjs";
|
|
15
|
+
|
|
16
|
+
const T0 = Date.parse("2026-09-08T10:00:00Z");
|
|
17
|
+
|
|
18
|
+
function cohortItem(over = {}) {
|
|
19
|
+
return inboxItemToYaml({
|
|
20
|
+
id: "msg-1",
|
|
21
|
+
service: "cohort",
|
|
22
|
+
channel: "dm/casey",
|
|
23
|
+
channel_id: "ch_abc",
|
|
24
|
+
sender: "Casey Ng",
|
|
25
|
+
sender_privilege: "ceo",
|
|
26
|
+
timestamp: "2026-09-08T09:59:00.000Z",
|
|
27
|
+
subject: "Cohort message",
|
|
28
|
+
content: "Can you pull the Q3 numbers? my key is sk-ant-api03-" + "a".repeat(40),
|
|
29
|
+
thread_id: "th_1",
|
|
30
|
+
kind: "mention",
|
|
31
|
+
direct_reason: "mention",
|
|
32
|
+
channel_kind: "DM",
|
|
33
|
+
raw_ref: "cohort:ch_abc:msg-1",
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("emptySeen / parseSeen: versioned, fail-open", () => {
|
|
39
|
+
assert.deepEqual(emptySeen(), { version: 1, seq: 0, inbox: {}, handoffs: {}, signals: {} });
|
|
40
|
+
assert.deepEqual(parseSeen("{"), emptySeen());
|
|
41
|
+
assert.deepEqual(parseSeen(JSON.stringify({ version: 99 })), emptySeen());
|
|
42
|
+
const s = { version: 1, seq: 7, inbox: { "cohort/a.yaml": T0 }, handoffs: {}, signals: { restart: 1 } };
|
|
43
|
+
assert.deepEqual(parseSeen(JSON.stringify(s)), s);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isLiveInboxFile / isHandoffFile", () => {
|
|
47
|
+
assert.equal(isLiveInboxFile("2026-09-08T09-59-00-000Z-msg-1.yaml"), true);
|
|
48
|
+
assert.equal(isLiveInboxFile("x.yaml.dispatched"), false);
|
|
49
|
+
assert.equal(isLiveInboxFile("x.yaml.processed"), false);
|
|
50
|
+
assert.equal(isLiveInboxFile("x.yaml.deferred"), false);
|
|
51
|
+
assert.equal(isLiveInboxFile(".hidden.yaml"), false);
|
|
52
|
+
assert.equal(isHandoffFile("tick-1.json"), true);
|
|
53
|
+
assert.equal(isHandoffFile("tick-1.json.tmp.1.2.ab"), false);
|
|
54
|
+
assert.equal(isHandoffFile("done"), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("inboundEvent: the documented line, surface from kind, topic from the surface table, preview redacted + capped", () => {
|
|
58
|
+
const ev = inboundEvent({ service: "cohort", path: "/agent/state/inbox/cohort/a.yaml", body: cohortItem(), seq: 3 });
|
|
59
|
+
assert.equal(ev.type, "inbound");
|
|
60
|
+
assert.equal(ev.id, "msg-1");
|
|
61
|
+
assert.equal(ev.service, "cohort");
|
|
62
|
+
assert.equal(ev.surface, "mention");
|
|
63
|
+
assert.equal(ev.topic, "message");
|
|
64
|
+
assert.equal(ev.reason, "mention");
|
|
65
|
+
assert.equal(ev.from, "Casey Ng");
|
|
66
|
+
assert.equal(ev.channelId, "ch_abc");
|
|
67
|
+
assert.equal(ev.channelName, "dm/casey");
|
|
68
|
+
assert.equal(ev.threadId, "th_1");
|
|
69
|
+
assert.equal(ev.path, "/agent/state/inbox/cohort/a.yaml");
|
|
70
|
+
assert.equal(ev.seq, 3);
|
|
71
|
+
assert.match(ev.preview, /Q3 numbers/);
|
|
72
|
+
assert.doesNotMatch(ev.preview, /sk-ant-api03/);
|
|
73
|
+
assert.ok(ev.preview.length <= PREVIEW_MAX);
|
|
74
|
+
assert.deepEqual(Object.keys(ev), ["type", "id", "service", "surface", "topic", "reason", "from", "channelId", "channelName", "threadId", "preview", "path", "seq"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("inboundEvent: a plain message maps to surface=message/topic=message; a task kind maps to topic=task; long previews are capped", () => {
|
|
78
|
+
const plain = inboundEvent({ service: "cohort", path: "/p", body: cohortItem({ kind: undefined, direct_reason: undefined, content: "x ".repeat(400) }), seq: 1 });
|
|
79
|
+
assert.equal(plain.surface, "message");
|
|
80
|
+
assert.equal(plain.topic, "message");
|
|
81
|
+
assert.equal(plain.reason, "");
|
|
82
|
+
assert.equal(plain.preview.length, PREVIEW_MAX);
|
|
83
|
+
const task = inboundEvent({ service: "cohort", path: "/p", body: cohortItem({ kind: "task_assigned", direct_reason: "assignee" }), seq: 2 });
|
|
84
|
+
assert.equal(task.topic, "task");
|
|
85
|
+
assert.equal(task.reason, "assignee");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("inboundEvent: an unparseable body yields null (the file is skipped, not a crash)", () => {
|
|
89
|
+
assert.equal(inboundEvent({ service: "cohort", path: "/p", body: "", seq: 1 }), null);
|
|
90
|
+
assert.equal(inboundEvent({ service: "cohort", path: "/p", body: null, seq: 1 }), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("handoffEvent: from the consumer's handoff JSON; id falls back to the file name", () => {
|
|
94
|
+
const body = JSON.stringify({ tickId: "t-9", cadence: "morning-brief", mode: "escalate", promptPath: "/agent/state/session/handoffs/t-9.prompt.md", enqueuedAt: "2026-09-08T10:00:00Z", deadlineAt: "x" });
|
|
95
|
+
const ev = handoffEvent({ path: "/agent/state/session/handoffs/t-9.json", file: "t-9.json", body, seq: 5 });
|
|
96
|
+
assert.deepEqual(ev, { type: "handoff", id: "t-9", cadence: "morning-brief", promptPath: "/agent/state/session/handoffs/t-9.prompt.md", enqueuedAt: "2026-09-08T10:00:00Z", path: "/agent/state/session/handoffs/t-9.json", seq: 5 });
|
|
97
|
+
const bare = handoffEvent({ path: "/h/tick-2.json", file: "tick-2.json", body: "{}", seq: 6 });
|
|
98
|
+
assert.equal(bare.id, "tick-2");
|
|
99
|
+
assert.equal(bare.cadence, "");
|
|
100
|
+
assert.equal(handoffEvent({ path: "/h/x.json", file: "x.json", body: "{", seq: 7 }), null);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function snapshot(over = {}) {
|
|
104
|
+
return {
|
|
105
|
+
inbox: { cohort: [] },
|
|
106
|
+
handoffs: [],
|
|
107
|
+
signals: { restartRequestedMtimeMs: null, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null },
|
|
108
|
+
...over,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test("computeFeedEvents: new inbox items and handoffs are emitted once, in a stable order, with increasing seq", () => {
|
|
113
|
+
const snap = snapshot({
|
|
114
|
+
inbox: {
|
|
115
|
+
cohort: [
|
|
116
|
+
{ file: "b.yaml", path: "/i/b.yaml", body: cohortItem({ id: "b" }) },
|
|
117
|
+
{ file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) },
|
|
118
|
+
{ file: "c.yaml.dispatched", path: "/i/c.yaml.dispatched", body: cohortItem({ id: "c" }) },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
handoffs: [{ file: "t.json", path: "/h/t.json", body: JSON.stringify({ tickId: "t", cadence: "c" }) }],
|
|
122
|
+
});
|
|
123
|
+
const r1 = computeFeedEvents(snap, emptySeen(), { now: T0 });
|
|
124
|
+
assert.deepEqual(r1.events.map((e) => [e.type, e.id, e.seq]), [["inbound", "a", 1], ["inbound", "b", 2], ["handoff", "t", 3]]);
|
|
125
|
+
assert.equal(r1.seen.seq, 3);
|
|
126
|
+
assert.deepEqual(Object.keys(r1.seen.inbox).sort(), ["cohort/a.yaml", "cohort/b.yaml"]);
|
|
127
|
+
assert.deepEqual(Object.keys(r1.seen.handoffs), ["t.json"]);
|
|
128
|
+
// Same snapshot again → nothing new.
|
|
129
|
+
const r2 = computeFeedEvents(snap, r1.seen, { now: T0 + 1_000 });
|
|
130
|
+
assert.deepEqual(r2.events, []);
|
|
131
|
+
assert.equal(r2.seen.seq, 3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("computeFeedEvents: a file that disappears leaves the seen-set; if it re-appears it is re-emitted (assurance re-open)", () => {
|
|
135
|
+
const withA = snapshot({ inbox: { cohort: [{ file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) }] } });
|
|
136
|
+
const r1 = computeFeedEvents(withA, emptySeen(), { now: T0 });
|
|
137
|
+
assert.equal(r1.events.length, 1);
|
|
138
|
+
const r2 = computeFeedEvents(snapshot(), r1.seen, { now: T0 + 1 });
|
|
139
|
+
assert.deepEqual(r2.seen.inbox, {});
|
|
140
|
+
const r3 = computeFeedEvents(withA, r2.seen, { now: T0 + 2 });
|
|
141
|
+
assert.equal(r3.events.length, 1);
|
|
142
|
+
assert.equal(r3.events[0].seq, 2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("computeFeedEvents: services not in the snapshot are simply absent; unparseable bodies are skipped and not marked seen", () => {
|
|
146
|
+
const snap = snapshot({ inbox: { cohort: [{ file: "bad.yaml", path: "/i/bad.yaml", body: "" }], slack: [{ file: "s.yaml", path: "/i/s.yaml", body: cohortItem({ id: "s", service: "slack" }) }] } });
|
|
147
|
+
const r = computeFeedEvents(snap, emptySeen(), { now: T0 });
|
|
148
|
+
assert.deepEqual(r.events.map((e) => e.id), ["s"]);
|
|
149
|
+
assert.equal(r.events[0].service, "slack");
|
|
150
|
+
assert.deepEqual(Object.keys(r.seen.inbox), ["slack/s.yaml"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("computeFeedEvents: restart-requested → one directive per appearance (keyed by mtime); cleared when the file goes", () => {
|
|
154
|
+
const s1 = snapshot({ signals: { restartRequestedMtimeMs: T0, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
|
|
155
|
+
const r1 = computeFeedEvents(s1, emptySeen(), { now: T0 });
|
|
156
|
+
assert.deepEqual(r1.events, [{ type: "directive", action: "restart", detail: { requestedAt: "2026-09-08T10:00:00.000Z" }, seq: 1 }]);
|
|
157
|
+
const r2 = computeFeedEvents(s1, r1.seen, { now: T0 + 1 });
|
|
158
|
+
assert.deepEqual(r2.events, []);
|
|
159
|
+
const r3 = computeFeedEvents(snapshot(), r2.seen, { now: T0 + 2 });
|
|
160
|
+
assert.deepEqual(r3.events, []);
|
|
161
|
+
assert.equal(r3.seen.signals.restart, undefined);
|
|
162
|
+
const s4 = snapshot({ signals: { restartRequestedMtimeMs: T0 + 5, upgradeNotice: null, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
|
|
163
|
+
assert.equal(computeFeedEvents(s4, r3.seen, { now: T0 + 5 }).events.length, 1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("computeFeedEvents: upgrade-notice → directive carrying the parsed notice, once per mtime", () => {
|
|
167
|
+
const s = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: { mtimeMs: T0, body: JSON.stringify({ from: "2.11.17", to: "2.12.0" }) }, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
|
|
168
|
+
const r1 = computeFeedEvents(s, emptySeen(), { now: T0 });
|
|
169
|
+
assert.deepEqual(r1.events, [{ type: "directive", action: "upgrade-available", detail: { notice: { from: "2.11.17", to: "2.12.0" } }, seq: 1 }]);
|
|
170
|
+
assert.deepEqual(computeFeedEvents(s, r1.seen, { now: T0 + 1 }).events, []);
|
|
171
|
+
// Junk notice body → detail carries the raw text, still one directive.
|
|
172
|
+
const junk = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: { mtimeMs: T0 + 9, body: "not json" }, daemonHealthMtimeMs: T0, daemonPidAlive: null } });
|
|
173
|
+
const rj = computeFeedEvents(junk, r1.seen, { now: T0 + 9 });
|
|
174
|
+
assert.equal(rj.events.length, 1);
|
|
175
|
+
assert.deepEqual(rj.events[0].detail, { notice: null, raw: "not json" });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("computeFeedEvents: daemon-stale fires once on the transition to stale and re-arms when the daemon is fresh again", () => {
|
|
179
|
+
const stale = snapshot({ signals: { restartRequestedMtimeMs: null, upgradeNotice: null, daemonHealthMtimeMs: T0 - 10 * 60_000, daemonPidAlive: false } });
|
|
180
|
+
const r1 = computeFeedEvents(stale, emptySeen(), { now: T0 });
|
|
181
|
+
assert.equal(r1.events.length, 1);
|
|
182
|
+
assert.equal(r1.events[0].action, "daemon-stale");
|
|
183
|
+
assert.deepEqual(r1.events[0].detail, { healthAgeMs: 10 * 60_000, pidAlive: false });
|
|
184
|
+
assert.deepEqual(computeFeedEvents(stale, r1.seen, { now: T0 + 1 }).events, []);
|
|
185
|
+
const fresh = snapshot();
|
|
186
|
+
const r3 = computeFeedEvents(fresh, r1.seen, { now: T0 + 2 });
|
|
187
|
+
assert.deepEqual(r3.events, []);
|
|
188
|
+
assert.equal(r3.seen.signals.daemonStale, false);
|
|
189
|
+
assert.equal(computeFeedEvents(stale, r3.seen, { now: T0 + 3 }).events.length, 1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("computeFeedEvents: never mutates its inputs", () => {
|
|
193
|
+
const seen = emptySeen();
|
|
194
|
+
const frozen = JSON.stringify(seen);
|
|
195
|
+
const snap = snapshot({ inbox: { cohort: [{ file: "a.yaml", path: "/i/a.yaml", body: cohortItem({ id: "a" }) }] } });
|
|
196
|
+
computeFeedEvents(snap, seen, { now: T0 });
|
|
197
|
+
assert.equal(JSON.stringify(seen), frozen);
|
|
198
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/first-run.mjs — the dialogs an unattended session cannot answer.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor launches an INTERACTIVE `claude` inside a multiplexer with
|
|
5
|
+
* nobody attached. On a fresh seat that first launch shows two one-time
|
|
6
|
+
* dialogs and waits for a keypress that never comes:
|
|
7
|
+
*
|
|
8
|
+
* - the bypass-permissions acknowledgement (`--dangerously-skip-permissions`),
|
|
9
|
+
* recorded in `~/.claude.json` as `bypassPermissionsModeAccepted: true`;
|
|
10
|
+
* - the per-directory trust dialog, recorded as
|
|
11
|
+
* `projects[<cwd>].hasTrustDialogAccepted: true`.
|
|
12
|
+
*
|
|
13
|
+
* Neither is a secret and both are exactly what the operator answered when
|
|
14
|
+
* they set the seat up by hand, so the supervisor SEEDS them before launch:
|
|
15
|
+
* {@link seedClaudeConfig} is the pure merge (adds only the missing flags,
|
|
16
|
+
* preserves every other key, refuses to touch a malformed file), and
|
|
17
|
+
* {@link ensureClaudeConfig} is its I/O shell (writes only when something
|
|
18
|
+
* changed, so the one-time race with a `--print` session rewriting the same
|
|
19
|
+
* file is confined to the first boot of a seat).
|
|
20
|
+
*
|
|
21
|
+
* Belt and braces: a session that is alive but has never beaten after
|
|
22
|
+
* {@link HEARTBEAT_GRACE_MS} is exactly the shape of a blocked dialog (or a
|
|
23
|
+
* missing skill, or a wedged permission prompt). {@link heartbeatSilence}
|
|
24
|
+
* decides that from the heartbeat and an injected clock; the supervisor logs
|
|
25
|
+
* it and writes `state/session/attention.json` ({@link attentionRecord}) so
|
|
26
|
+
* `doctor` can report it and an operator knows which command attaches.
|
|
27
|
+
*
|
|
28
|
+
* Pure except the one I/O shell; no ambient clock, fs or home.
|
|
29
|
+
*
|
|
30
|
+
* @module lib/session/first-run
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
"use strict";
|
|
34
|
+
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
/** Claude Code's user-level config file, relative to the home directory. */
|
|
38
|
+
export const CLAUDE_CONFIG_FILE = ".claude.json";
|
|
39
|
+
/** A session alive this long without a beat is flagged for attention. */
|
|
40
|
+
export const HEARTBEAT_GRACE_MS = 120_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pure: merge the two acknowledgements into a `~/.claude.json` body.
|
|
44
|
+
*
|
|
45
|
+
* @param {string|null|undefined} text current file body (absent → empty)
|
|
46
|
+
* @param {{agentRoot:string, bypass?:boolean}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions)
|
|
47
|
+
* @returns {{ok:true, changed:boolean, applied:string[], config:object}|{ok:false, changed:false, applied:[], error:string}}
|
|
48
|
+
*/
|
|
49
|
+
export function seedClaudeConfig(text, a) {
|
|
50
|
+
let raw = {};
|
|
51
|
+
if (typeof text === "string" && text.trim()) {
|
|
52
|
+
try { raw = JSON.parse(text); } catch { return { ok: false, changed: false, applied: [], error: "malformed JSON" }; }
|
|
53
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, changed: false, applied: [], error: "not an object" };
|
|
54
|
+
}
|
|
55
|
+
const applied = [];
|
|
56
|
+
const next = { ...raw };
|
|
57
|
+
if (a.bypass !== false && next.bypassPermissionsModeAccepted !== true) {
|
|
58
|
+
next.bypassPermissionsModeAccepted = true;
|
|
59
|
+
applied.push("bypassPermissionsModeAccepted");
|
|
60
|
+
}
|
|
61
|
+
const projects = next.projects && typeof next.projects === "object" && !Array.isArray(next.projects) ? next.projects : {};
|
|
62
|
+
const proj = projects[a.agentRoot] && typeof projects[a.agentRoot] === "object" && !Array.isArray(projects[a.agentRoot]) ? projects[a.agentRoot] : {};
|
|
63
|
+
if (proj.hasTrustDialogAccepted !== true) {
|
|
64
|
+
next.projects = { ...projects, [a.agentRoot]: { ...proj, hasTrustDialogAccepted: true } };
|
|
65
|
+
applied.push(`projects[${a.agentRoot}].hasTrustDialogAccepted`);
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, changed: applied.length > 0, applied, config: next };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read `~/.claude.json`, seed it, write it back only if something changed.
|
|
72
|
+
* Result frame; never throws (a seat must still launch when this fails —
|
|
73
|
+
* the dialog then blocks and {@link heartbeatSilence} reports it).
|
|
74
|
+
*
|
|
75
|
+
* @param {{homeDir:string, agentRoot:string, bypass?:boolean, readFileSync:Function, writeFileAtomic:Function}} a
|
|
76
|
+
* @returns {{ok:boolean, changed:boolean, applied:string[], path:string|null, error?:string}}
|
|
77
|
+
*/
|
|
78
|
+
export function ensureClaudeConfig(a) {
|
|
79
|
+
if (!a.homeDir) return { ok: false, changed: false, applied: [], path: null, error: "no home directory" };
|
|
80
|
+
const path = join(a.homeDir, CLAUDE_CONFIG_FILE);
|
|
81
|
+
let text = null;
|
|
82
|
+
try { text = a.readFileSync(path, "utf8"); } catch (err) {
|
|
83
|
+
if (!err || err.code !== "ENOENT") return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
|
|
84
|
+
text = null; // first launch on this account
|
|
85
|
+
}
|
|
86
|
+
const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass });
|
|
87
|
+
if (!seeded.ok) return { ok: false, changed: false, applied: [], path, error: seeded.error };
|
|
88
|
+
if (!seeded.changed) return { ok: true, changed: false, applied: [], path };
|
|
89
|
+
try {
|
|
90
|
+
a.writeFileAtomic(path, JSON.stringify(seeded.config, null, 2) + "\n");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, changed: true, applied: seeded.applied, path };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pure: has the session run past the grace period without beating?
|
|
99
|
+
* @param {{startedAt:number, heartbeat:{ts:string}|null, now:number, graceMs?:number}} a
|
|
100
|
+
* @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat", runMs:number}}
|
|
101
|
+
*/
|
|
102
|
+
export function heartbeatSilence(a) {
|
|
103
|
+
const graceMs = a.graceMs ?? HEARTBEAT_GRACE_MS;
|
|
104
|
+
const runMs = Number(a.now) - Number(a.startedAt);
|
|
105
|
+
if (runMs < graceMs) return { silent: false, reason: "within-grace", runMs };
|
|
106
|
+
const ts = a.heartbeat && typeof a.heartbeat.ts === "string" ? Date.parse(a.heartbeat.ts) : NaN;
|
|
107
|
+
if (Number.isFinite(ts) && ts >= Number(a.startedAt)) return { silent: false, reason: "beating", runMs };
|
|
108
|
+
return { silent: true, reason: Number.isFinite(ts) ? "stale-heartbeat" : "no-heartbeat", runMs };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure: the `state/session/attention.json` body.
|
|
113
|
+
* @param {{reason:string, mux:{kind:string}, muxName:string, since:number, runMs:number}} a
|
|
114
|
+
*/
|
|
115
|
+
export function attentionRecord(a) {
|
|
116
|
+
const attach = a.mux && a.mux.kind === "tmux" ? `tmux attach -t =${a.muxName}` : `screen -r ${a.muxName}`;
|
|
117
|
+
return {
|
|
118
|
+
reason: a.reason,
|
|
119
|
+
since: new Date(Number(a.since)).toISOString(),
|
|
120
|
+
runMs: a.runMs,
|
|
121
|
+
attach,
|
|
122
|
+
hint: `The session has been up ${Math.round(a.runMs / 1000)} s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`${attach}\` and answer it.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default { CLAUDE_CONFIG_FILE, HEARTBEAT_GRACE_MS, seedClaudeConfig, ensureClaudeConfig, heartbeatSilence, attentionRecord };
|