@cohortapp/agent-sdk 2.18.5 → 2.18.7

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.
@@ -939,4 +939,94 @@ export default {
939
939
  matchesMyName,
940
940
  taskIdFromFileKey,
941
941
  surfaceDef,
942
+ isMissedHumanMessage,
943
+ missedHumanRecord,
942
944
  };
945
+
946
+ // ─────────────────────────────────────────────────────────────────────────────
947
+ // A PERSON'S MESSAGE THAT THIS SEAT DROPPED
948
+ // ─────────────────────────────────────────────────────────────────────────────
949
+
950
+ /**
951
+ * Drop reasons that are NOT a person going unanswered, even when a human wrote
952
+ * the message. Each one says the room was never this seat's to read or the
953
+ * item was never a message at all, so counting it would bury the reasons that
954
+ * matter under noise every tick produces.
955
+ *
956
+ * `own_message` is here for the obvious reason; the membership/visibility ones
957
+ * because a room the seat cannot see is not a room it declined to answer in.
958
+ */
959
+ const NOT_A_MISSED_PERSON = Object.freeze([
960
+ "own_message",
961
+ "channel_not_visible",
962
+ "not_a_member",
963
+ "membership_unknown",
964
+ "channel_kind_unknown",
965
+ "surface_disabled",
966
+ ]);
967
+
968
+ /**
969
+ * PURE. Did this seat just drop a message a PERSON wrote in a room the seat
970
+ * belongs to?
971
+ *
972
+ * ── WHY THIS EXISTS ──────────────────────────────────────────────────────────
973
+ * `pullWideInbound` counts every drop into `stats.dropped[reason]` — a tally,
974
+ * by reason, with no identity and, unless something else happened that tick, no
975
+ * log line at all. That tally is why the 2026-09-21 #general roll-call took a
976
+ * reconstruction to explain: thirteen seats each incremented
977
+ * `dropped.no_mentions_not_threaded` by one and none of them said WHICH message
978
+ * or that a human had written it. A counter cannot be audited after the fact
979
+ * and cannot be correlated across seats.
980
+ *
981
+ * A person's message that this seat decided not to answer is a different class
982
+ * of event from ambient chatter it correctly ignored, and it is the only class
983
+ * anyone ever asks about afterwards. So it is named individually, once, at
984
+ * WARN — the same treatment `hydrate` already gives a body it could not read.
985
+ *
986
+ * Deliberately narrow, for the same reason the surface list is: this fires only
987
+ * for a MESSAGE-topic candidate, authored by a member the DIRECTORY says is
988
+ * HUMAN (never assumed from the absence of evidence), in a room whose
989
+ * membership this seat has PROVEN. Everything else is either not a person, not
990
+ * a room, or not knowable — and an over-broad warn line is a line operators
991
+ * learn to skip.
992
+ *
993
+ * @param {Candidate} cand
994
+ * @param {{directed:boolean, reason?:string}} verdict the resolved verdict
995
+ * @param {import("./facts.mjs").Facts} facts
996
+ * @param {string} me this seat's member id
997
+ * @returns {boolean}
998
+ */
999
+ export function isMissedHumanMessage(cand, verdict, facts, me) {
1000
+ if (!cand || !verdict || verdict.directed) return false;
1001
+ if (cand.topic !== "message") return false;
1002
+ const reason = String(verdict.reason || "");
1003
+ if (NOT_A_MISSED_PERSON.includes(reason)) return false;
1004
+ const channelId = cand.ids && cand.ids.channelId;
1005
+ if (!channelId) return false;
1006
+ // A room whose membership is PROVEN, not merely visible: a public channel the
1007
+ // seat can read but has not joined does not address it.
1008
+ const members = asSet(facts && facts.memberChannelIds);
1009
+ if (!has(members, channelId)) return false;
1010
+ const author = cand.actor;
1011
+ if (!author || (me && String(author) === String(me))) return false;
1012
+ // Proven human. `authorKindOf` returns "" when the directory read degraded,
1013
+ // and an unknown author is NOT reported as a missed person — a degraded tick
1014
+ // would otherwise warn about every message in every room.
1015
+ return authorKindOf(facts, author) === "HUMAN";
1016
+ }
1017
+
1018
+ /**
1019
+ * PURE. The redaction-safe record of one missed person's message. No body, no
1020
+ * prose: the ids an operator needs to go and look, plus the reason this seat
1021
+ * gave. Mirrors the payload shape hq's `responder.silence` rows carry, so the
1022
+ * two planes describe the same event in the same words.
1023
+ */
1024
+ export function missedHumanRecord(cand, verdict) {
1025
+ return {
1026
+ seq: cand && cand.seq != null ? cand.seq : null,
1027
+ messageId: (cand && cand.ids && cand.ids.messageId) || cand?.entityId || null,
1028
+ channelId: (cand && cand.ids && cand.ids.channelId) || null,
1029
+ author: (cand && cand.actor) || null,
1030
+ reason: String((verdict && verdict.reason) || "unknown"),
1031
+ };
1032
+ }
@@ -55,7 +55,12 @@
55
55
  "use strict";
56
56
 
57
57
  import { read as clientRead } from "../client.mjs";
58
- import { classifyEvent, resolveDirected } from "./directedness.mjs";
58
+ import {
59
+ classifyEvent,
60
+ resolveDirected,
61
+ isMissedHumanMessage,
62
+ missedHumanRecord,
63
+ } from "./directedness.mjs";
59
64
  import { resolveFacts, DEFAULT_LIMITS } from "./facts.mjs";
60
65
  import { hydrate } from "./hydrate.mjs";
61
66
  import { toMessageEvent } from "./project.mjs";
@@ -106,6 +111,11 @@ export async function pullWideInbound(o = {}) {
106
111
  delivered: 0,
107
112
  bySurface: {},
108
113
  dropped: {},
114
+ // EVERY person's message this tick decided not to answer, named. See
115
+ // `directedness.mjs#isMissedHumanMessage`: `dropped` is a tally by reason
116
+ // and cannot say WHICH message or that a human wrote it, which is the only
117
+ // question anyone asks after a room goes quiet.
118
+ unanswered: [],
109
119
  degraded: [],
110
120
  calls: 0,
111
121
  reads: 0,
@@ -195,6 +205,18 @@ export async function pullWideInbound(o = {}) {
195
205
  const verdict = resolveDirected(cand, me, facts, { enabled, meAliases });
196
206
  if (!verdict.directed) {
197
207
  stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
208
+ // A PERSON's message this seat is dropping is not ambient chatter, and a
209
+ // counter is not a record of it. Name it — once, at WARN, with the ids —
210
+ // so "I posted and nobody replied" is answerable from this machine's own
211
+ // log instead of by reasoning backwards from thirteen tallies.
212
+ if (isMissedHumanMessage(cand, verdict, facts, me)) {
213
+ const rec = missedHumanRecord(cand, verdict);
214
+ stats.unanswered.push(rec);
215
+ log(
216
+ "warn",
217
+ `[inbound] NOT answering a person: message ${rec.messageId || cand.seq} in channel ${rec.channelId} from ${rec.author} — ${rec.reason}`
218
+ );
219
+ }
198
220
  continue;
199
221
  }
200
222
  directed.push({ cand, verdict });
@@ -3317,7 +3317,19 @@ export function messagingSearch(params, o = {}) {
3317
3317
  * message ({ messageId }) or a channel over a window ({ channelId,
3318
3318
  * windowDays? }); one of the two is required. Each row carries the operator
3319
3319
  * reason (human_mentioned | addressed_to_human | nobody_elected |
3320
- * no_responders) and its text. The agent-plane twin of the "Unanswered
3320
+ * no_responders | handed_to_daemons) and its text; a `handed_to_daemons` row
3321
+ * also carries `daemonDriven`, the seats hq stood down for — every eligible
3322
+ * colleague in the room was answering from its OWN machine, so hq deliberately
3323
+ * said nothing and a room that then heard nothing is a fault on the DAEMON
3324
+ * plane, with the seat list already in hand.
3325
+ *
3326
+ * The same sentence is NOT repeated in `protocol.mjs`: that file is pinned by
3327
+ * `protocol.checksum` to hq's vendored copy, so even a comment there is a
3328
+ * cross-repo re-vendor (`scripts/sync-protocol.mjs`). The vocabulary's source
3329
+ * of truth is hq `src/server/llm-responder/silence-verdict.ts`; this JSDoc is
3330
+ * the agent-facing restatement of it.
3331
+ *
3332
+ * The agent-plane twin of the "Unanswered
3321
3333
  * messages" panel on /settings/ai — same reader, same decoder.
3322
3334
  * @param {object} params - { channelId?, messageId?, windowDays?, limit? }
3323
3335
  * @param {object} o - { base, token, fetchImpl? }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.5",
3
+ "version": "2.18.7",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: seat-provision
3
+ description: Bring a brand-new Mac mini or Mac Studio up as a working Cohort agent seat — SDK installed, profile pulled from Cohort, launchd jobs loaded, daemon beating, front door answering. Use when a new machine arrives, when a seat is being re-enrolled after a rebuild, or when someone asks how a colleague gets a machine.
4
+ ---
5
+
6
+ # Bringing up a new seat
7
+
8
+ A seat is one directory (`~/<firstname>`), three launchd jobs, and one org
9
+ credential. It is finished when the machine **answers its humans**, not when
10
+ the install completes.
11
+
12
+ Run this on the machine itself, signed in as that seat's user. You cannot
13
+ provision another agent's machine from yours.
14
+
15
+ ## Before you touch the machine
16
+
17
+ Two things must exist first, and only a person with production access can make
18
+ one of them:
19
+
20
+ - **An org API key for this member.** Minted in hq by
21
+ `scripts/mint-avatar-agent-keys.ts --confirm` — one key per seat, never
22
+ shared, returned exactly once and unrecoverable afterwards. It is a
23
+ production database write and needs explicit authorisation.
24
+ - **A Claude credential for the seat**, long-lived (`CLAUDE_CODE_OAUTH_TOKEN`)
25
+ rather than a keychain login that expires while nobody is watching.
26
+
27
+ Never print either into a log, a commit, a message, or a terminal someone else
28
+ can scroll back through.
29
+
30
+ ## Bring it up
31
+
32
+ ```bash
33
+ cd ~
34
+ npx -y @cohortapp/agent-sdk@latest create <firstname>
35
+ cd ~/<firstname>
36
+ npm install @cohortapp/agent-sdk@latest --save
37
+ ```
38
+
39
+ Write `.env` (mode 600) with the seat's credential and
40
+ `MAESTRO_PREFER_SUBSCRIPTION_AUTH=1`, then pull the profile from Cohort, which
41
+ is the source of truth for who this colleague is:
42
+
43
+ ```bash
44
+ COHORT_BASE=<org base url> \
45
+ COHORT_API_KEY=<the minted key> \
46
+ COHORT_ORG_ID=org_default_adaptic \
47
+ COHORT_AGENT_ID=<slug> \
48
+ node node_modules/@cohortapp/agent-sdk/bin/maestro.mjs setup --yes
49
+ ```
50
+
51
+ **`COHORT_BASE` is silently required.** The profile pull fails open: leave it
52
+ out and setup "succeeds" with an empty identity, no charter and no org config,
53
+ and the seat then runs as nobody. Check `config/agent.json` and
54
+ `config/org.yaml` are populated before going further — that check has caught
55
+ this more than once.
56
+
57
+ Then load the three jobs and start the lanes:
58
+
59
+ ```bash
60
+ maestro upgrade --force-overwrite # lays down plists, skills, scripts
61
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.maestro.<name>-daemon.plist
62
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.maestro.<name>-autoupdate.plist
63
+ maestro session start
64
+ ```
65
+
66
+ ## Prove it, before you call it done
67
+
68
+ A new seat that looks installed and answers nobody is the normal failure mode.
69
+ Five checks:
70
+
71
+ 1. **Identity is real.** `config/agent.json` has a name, a role and a charter —
72
+ not empty strings.
73
+ 2. **The org connection works.** The daemon log shows `org-mesh connected`, and
74
+ `state/org/last-beat.json` is fresh and `{"ok":true}`.
75
+ 3. **The seat is visible to the org.** It appears in the fleet view with its
76
+ hostname, version and a beat inside the last few minutes. If hq cannot see
77
+ it, nobody can route work to it.
78
+ 4. **The front door is live.** `maestro session status` — not `NOT LIVE`. A
79
+ first launch is where a modal is most likely to be waiting; read the pane
80
+ rather than assuming (see `seat-upgrade`).
81
+ 5. **A real message lands.** Ask a colleague to send one and watch it arrive in
82
+ `maestro inbox list` and get answered. Everything up to here proves the
83
+ plumbing; only this proves the seat.
84
+
85
+ ## Things that have actually gone wrong here
86
+
87
+ - **A root-owned npm prefix** makes every global install fail. Give the seat
88
+ its own prefix (`npm config set prefix ~/.npm-global`) rather than using
89
+ `sudo`, which leaves files the seat cannot later update.
90
+ - **Display names are not login names.** The person is "James Kirkland"; the
91
+ account is `James T Kirk`. Read the real login from the directory rather than
92
+ guessing from the display name.
93
+ - **One live key per seat.** If a key was minted twice during a fumbled
94
+ enrolment, revoke the orphan — an unused credential is a liability, and two
95
+ live keys make it impossible to tell which machine is which.
96
+ - **Confirm the autoupdater is armed** before you walk away. Without it the
97
+ seat never takes another release, and nothing will tell you: it simply stays
98
+ where it is while the fleet moves on.
99
+ - **Shred the credential files** you staged during setup.
100
+
101
+ ## What to say afterwards
102
+
103
+ The seat's name, its version, that it is beating, that the front door is live,
104
+ and that a real message was answered. If any of the five checks did not pass,
105
+ say which one and what is still needed — a half-provisioned seat that is
106
+ reported as done is worse than one reported as blocked, because the work gets
107
+ routed to it.
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: seat-upgrade
3
+ description: Take this machine's @cohortapp/agent-sdk to a new version and prove the seat is still answering afterwards — the version, the daemon, the front door and the beat. Use when an upgrade notice arrives, when `maestro session status` says a restart is pending, when this seat is behind the fleet, or when someone asks you to update the SDK here.
4
+ ---
5
+
6
+ # Upgrading this seat
7
+
8
+ An upgrade is not `npm install`. It is: install, lay the framework files down,
9
+ restart the two processes that were running the old code, and then **prove the
10
+ seat still answers its humans**. The last step is the one that gets skipped,
11
+ and skipping it is how a seat goes quiet for three days without anybody
12
+ noticing.
13
+
14
+ Everything here runs on **this** machine, as this seat's own user. You cannot
15
+ reach another agent's machine and must not try.
16
+
17
+ ## What this seat runs, and which part an upgrade breaks
18
+
19
+ Three long-lived things, and they fail independently:
20
+
21
+ | | what it is | how it gets the new code |
22
+ | --- | --- | --- |
23
+ | **the daemon** | `ai.maestro.<name>-daemon`, polls Cohort, classifies, dispatches | must be kickstarted after the install |
24
+ | **the front door** | `ai.maestro.<name>-session`, the long-lived Claude session that answers | must be restarted, and it is the one that hangs |
25
+ | **the autoupdater** | hourly `ai.maestro.<name>-autoupdate` | runs the above by itself, hourly |
26
+
27
+ If the hourly job already did this, you have nothing to do. Check before you
28
+ act: `node scripts/fleet/rollout.mjs --verify-only --deadline 0` from the SDK
29
+ repo if you have it, or simply read the installed version and compare it with
30
+ the registry.
31
+
32
+ ## Do it
33
+
34
+ ```bash
35
+ cd ~/<yourname> # your agent directory, not the SDK repo
36
+ node -p "require('./node_modules/@cohortapp/agent-sdk/package.json').version" # before
37
+ npm view @cohortapp/agent-sdk version --prefer-online # target
38
+ ```
39
+
40
+ `--prefer-online` matters: npm caches registry metadata and will happily serve
41
+ you the previous version for many minutes after a publish. A version that looks
42
+ unchanged is often a stale read, not a stalled release.
43
+
44
+ ```bash
45
+ npm install @cohortapp/agent-sdk@<version> --save --no-audit --no-fund
46
+ ./node_modules/.bin/maestro upgrade --force-overwrite
47
+ ```
48
+
49
+ `maestro upgrade` copies the framework (`lib/`, `scripts/`, skills, plists)
50
+ into your agent directory. Files you own are protected by `.maestroignore`; it
51
+ reports drift rather than clobbering them.
52
+
53
+ Then restart both lanes, because **neither restarts itself**:
54
+
55
+ ```bash
56
+ maestro session restart --force # the front door: kill and relaunch
57
+ launchctl kickstart -k gui/$(id -u)/ai.maestro.<name>-daemon
58
+ ```
59
+
60
+ ## Then prove it, which is the actual job
61
+
62
+ An upgrade that installs and leaves the seat mute is a failed upgrade. Four
63
+ checks, in this order, and none of them is "the process exists":
64
+
65
+ 1. **The version moved.** Re-read `node_modules/@cohortapp/agent-sdk/package.json`.
66
+ 2. **The daemon is beating.** `state/org/last-beat.json` should be fresh (under
67
+ five minutes) and `{"ok":true}`. A daemon can be alive and beating nothing.
68
+ 3. **The front door is live.** `maestro session status` must not say `NOT LIVE`.
69
+ A heartbeat older than a few minutes means it is wedged, not busy.
70
+ 4. **The seat answers.** Look at `maestro inbox list`: if items are arriving and
71
+ nothing is being answered, the seat is silent even though every process is
72
+ up.
73
+
74
+ ## When the front door will not come back
75
+
76
+ This is the failure worth knowing by heart, because it has cost this
77
+ organisation days of silence:
78
+
79
+ **A blocked modal in the front-door session stops the seat answering
80
+ anything.** The session process is alive, `ps` shows it, launchd is happy — and
81
+ it is sitting on a dialog nobody pressed a key on. The one that did it was the
82
+ subscription limit chooser (*"You've hit your weekly limit… 1. Stop and wait 2.
83
+ Wait here, then continue automatically 3. Add funds 4. Upgrade your plan"*),
84
+ which sat unanswered for 65 hours, including 41 hours **after** the limit had
85
+ already reset.
86
+
87
+ Since 2.18.6 the supervisor handles this itself: it captures the pane, names
88
+ the modal, answers it when the answer is free and known by name, and restarts
89
+ the session when it is not. If you are on an older build, or it has given up
90
+ after its bounded retries, do it by hand:
91
+
92
+ ```bash
93
+ maestro session status # NOT LIVE? read on
94
+ screen -S maestro-<name> -p 0 -X hardcopy /tmp/pane.txt # or: tmux capture-pane -p -t =maestro-<name>
95
+ cat /tmp/pane.txt # WHAT IS ON THE SCREEN
96
+ ```
97
+
98
+ Read it before you act. **Never answer a modal option that spends money** —
99
+ "Add funds" and "Upgrade your plan" are not yours to press. If the safe option
100
+ is on screen, send its key; otherwise `maestro session restart --force`, which
101
+ always works and costs only the session's context.
102
+
103
+ ## What to say afterwards
104
+
105
+ Report the version before and after, the beat age, whether the front door is
106
+ live, and anything you had to clear by hand. If you cleared a modal, say which
107
+ one — it is a fleet-wide signal, not a local curiosity, and it is how the next
108
+ seat's failure gets recognised in seconds instead of days.
@@ -53,6 +53,7 @@ import { resolveAgentRoot } from "../../lib/agent-root.mjs";
53
53
  import { writeJsonAtomic as fsWriteJsonAtomic, writeFileAtomic as fsWriteFileAtomic } from "../../lib/fs-atomic.mjs";
54
54
  import { parseHeartbeat } from "../../lib/session/liveness.mjs";
55
55
  import { ensureClaudeConfig, heartbeatSilence, attentionRecord, HEARTBEAT_GRACE_MS } from "../../lib/session/first-run.mjs";
56
+ import { paneTail, classifyPane, watchdogAction, captureCommand, sendKeyCommand } from "../../lib/session/pane.mjs";
56
57
  import { acquireLock as singletonAcquireLock } from "../../lib/singleton.js";
57
58
  import { buildSpawn } from "../../lib/runtime/adapter.mjs";
58
59
  import { resolveSeatSpawn } from "../../lib/runtime/seat-engine.mjs";
@@ -332,15 +333,95 @@ export async function runSupervisor(deps = {}) {
332
333
  const startedAt = Number(d.now());
333
334
  let launchFailed = false;
334
335
  stopCmd = cmds.stop;
335
- const cancelWatchdog = d.after(d.heartbeatGraceMs, () => {
336
+ // ── THE WATCHDOG ────────────────────────────────────────────────────────
337
+ //
338
+ // It used to fire ONCE: write attention.json, log "attach and answer it",
339
+ // and then leave the session wedged for the rest of its life. On 2026-09-24
340
+ // three seats had been silent for days behind that note — and the note was a
341
+ // guess ("probably a first-run dialog or a permission prompt") that was
342
+ // wrong. The real modal, captured off this seat's own pane, was the
343
+ // subscription-limit chooser, still unanswered 41 hours after the limit had
344
+ // reset. The fleet accepts no ssh, so the instruction it printed could not be
345
+ // carried out by anyone.
346
+ //
347
+ // So it now RECURS and ACTS: capture the pane, name what is on it, answer it
348
+ // if the answer is free and known by name, else restart the session. Bounded,
349
+ // because a supervisor that restarts every two minutes for three days is a
350
+ // worse failure than the one it replaces. Every pass records what it saw, so
351
+ // the note stops being a guess and becomes evidence.
352
+ let clears = 0;
353
+ let restarts = 0;
354
+ let watchdogStopped = false;
355
+ let cancelPass = () => {};
356
+ const paneFile = join(agentRoot, "state", "session", "pane-capture.txt");
357
+
358
+ const capturePane = async () => {
359
+ const cmd = captureCommand(mux, muxName, paneFile);
360
+ try {
361
+ const r = await d.execFile(cmd.file, cmd.args, { cwd: agentRoot, env: d.env });
362
+ if (cmd.via === "stdout") return String((r && r.stdout) || "");
363
+ try { return d.readFileSync(paneFile, "utf8"); } catch { return ""; }
364
+ } catch { return ""; }
365
+ };
366
+
367
+ const sendKeys = async (keys) => {
368
+ for (const k of keys) {
369
+ const cmd = sendKeyCommand(mux, muxName, k);
370
+ try { await d.execFile(cmd.file, cmd.args, { cwd: agentRoot, env: d.env }); } catch { return false; }
371
+ await d.sleep(250);
372
+ }
373
+ return true;
374
+ };
375
+
376
+ const watchdogPass = async () => {
377
+ if (watchdogStopped) return;
378
+ // Retire the timer that just fired before scheduling the next one, so the
379
+ // pass that ends the session cancels exactly one live timer and leaves
380
+ // nothing behind.
381
+ try { cancelPass(); } catch { /* already fired */ }
336
382
  let hb = null;
337
383
  try { hb = parseHeartbeat(d.readFileSync(paths.heartbeatFile, "utf8")); } catch { hb = null; }
338
384
  const silence = heartbeatSilence({ startedAt, heartbeat: hb, now: d.now(), graceMs: d.heartbeatGraceMs });
339
- if (!silence.silent) return;
385
+ if (!silence.silent) { schedulePass(); return; }
386
+
387
+ const pane = paneTail(await capturePane());
388
+ const seen = classifyPane(pane);
389
+ const act = watchdogAction({ silent: true, kind: seen.kind, keys: seen.keys, clears, restarts });
390
+
340
391
  const rec = attentionRecord({ reason: silence.reason, mux, muxName, since: startedAt, runMs: silence.runMs });
341
- try { d.writeJsonAtomic(paths.attentionFile, rec); } catch { /* the log line still says it */ }
342
- d.log(`session ${muxName} has been up ${Math.round(silence.runMs / 1000)} s with no heartbeat — probably blocked on a first-run dialog or a permission prompt; attach with \`${rec.attach}\` and answer it`);
343
- });
392
+ // The record now carries WHAT THE SCREEN SAYS and what was done about it.
393
+ // `hint` keeps the attach line for a human who is at the machine, but it is
394
+ // no longer the only remedy on offer.
395
+ const record = { ...rec, modal: seen.kind, modalWhy: seen.why, pane, action: act.act, actionReason: act.reason, clears, restarts };
396
+ try { d.writeJsonAtomic(paths.attentionFile, record); } catch { /* the log line still says it */ }
397
+ d.log(`session ${muxName} silent ${Math.round(silence.runMs / 1000)} s — screen shows: ${seen.kind} (${seen.why}); ${act.reason}`);
398
+
399
+ if (act.act === "clear") {
400
+ clears += 1;
401
+ d.log(`session ${muxName}: answering ${seen.kind} with ${JSON.stringify(seen.choice || seen.keys)}`);
402
+ await sendKeys(seen.keys);
403
+ } else if (act.act === "restart") {
404
+ restarts += 1;
405
+ d.log(`session ${muxName}: restarting (${restarts}) — the session is not answering and the screen cannot be cleared safely`);
406
+ try { await d.execFile(cmds.stop.file, cmds.stop.args, { cwd: agentRoot, env: d.env }); } catch { /* the relaunch loop handles a dead mux */ }
407
+ watchdogStopped = true; // the supervisor's own loop relaunches; do not fight it
408
+ return;
409
+ } else if (act.act === "give-up") {
410
+ // Stop thrashing, keep saying so. The beat carries `attention` and hq
411
+ // alerts on it; this is the state a person genuinely has to see.
412
+ d.log(`session ${muxName}: ${act.reason}`);
413
+ watchdogStopped = true;
414
+ return;
415
+ }
416
+ schedulePass();
417
+ };
418
+
419
+ const schedulePass = () => {
420
+ if (watchdogStopped) return;
421
+ cancelPass = d.after(d.heartbeatGraceMs, () => { watchdogPass().catch(() => {}); });
422
+ };
423
+ schedulePass();
424
+ const cancelWatchdog = () => { watchdogStopped = true; try { cancelPass(); } catch { /* already fired */ } };
344
425
  const dropEnvFile = () => { if (envFile) { try { d.unlinkSync(envFile); } catch { /* the session consumed it */ } } };
345
426
  try {
346
427
  // The engine's env (seat token, every Claude credential scrubbed) rides the