@cohortapp/agent-sdk 2.18.6 → 2.18.8
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.
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.8",
|
|
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.
|
|
@@ -417,6 +417,46 @@ restart_daemon(){
|
|
|
417
417
|
if [ -z "$DAEMON_LABEL" ]; then log "no -daemon plist under ~/Library/LaunchAgents — nothing to kickstart (maestro upgrade installs it; doctor will say)"; return 0; fi
|
|
418
418
|
launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1 || log "WARN: kickstart $DAEMON_LABEL nonzero"
|
|
419
419
|
}
|
|
420
|
+
session_beating(){ # is the front door actually ANSWERING, not merely loaded?
|
|
421
|
+
# `session_reconciled` asks whether the job is loaded and a supervisor process
|
|
422
|
+
# is alive. Both were true on the seats that went silent: on 2026-09-24 three
|
|
423
|
+
# front doors had been wedged on an unanswered modal for days with their
|
|
424
|
+
# supervisors up and their jobs loaded. A process that exists is not a
|
|
425
|
+
# colleague who answers — the same substitution this gate's daemon half was
|
|
426
|
+
# rewritten to stop making.
|
|
427
|
+
local hb="$AGENT_DIR/state/session/heartbeat.json" age
|
|
428
|
+
[ -f "$hb" ] || return 1
|
|
429
|
+
age="$(node -e '
|
|
430
|
+
try {
|
|
431
|
+
const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
432
|
+
const t = Date.parse(j && j.ts);
|
|
433
|
+
process.stdout.write(Number.isFinite(t) ? String(Math.round((Date.now() - t) / 1000)) : "");
|
|
434
|
+
} catch {}
|
|
435
|
+
' "$hb" 2>/dev/null)"
|
|
436
|
+
[ -n "$age" ] || return 1
|
|
437
|
+
[ "$age" -le "$SESSION_STALE_S" ]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
revive_session(){ # a wedged front door cannot restart itself; restart it FOR it
|
|
441
|
+
# The upgrade notice asks the session to restart "at an idle moment". A
|
|
442
|
+
# session sitting on a modal never reaches an idle moment, so the polite ask
|
|
443
|
+
# is exactly the wrong instrument for the one case that needs it. A hard
|
|
444
|
+
# kickstart drops a wedged session and the new supervisor launches a fresh
|
|
445
|
+
# one — which clears the modal by construction and, from this release,
|
|
446
|
+
# watches the pane so it does not happen again.
|
|
447
|
+
[ -n "$SESSION_LABEL" ] || return 0
|
|
448
|
+
# An UNLOADED job is a different fault with a different remedy (`maestro
|
|
449
|
+
# session start`), and kickstarting a label launchd does not know just fails.
|
|
450
|
+
# Leave that one to session_reconciled, which already names it.
|
|
451
|
+
label_loaded "$SESSION_LABEL" || return 0
|
|
452
|
+
session_beating && return 0
|
|
453
|
+
log "front door $SESSION_LABEL is loaded but not beating (> ${SESSION_STALE_S}s) — kickstarting it rather than waiting for a session that cannot answer"
|
|
454
|
+
launchctl kickstart -k "gui/$(id -u)/$SESSION_LABEL" >> "$LOG" 2>&1 \
|
|
455
|
+
&& { log "front door $SESSION_LABEL restarted"; return 0; }
|
|
456
|
+
log "WARN: could not kickstart $SESSION_LABEL (maestro session restart --force)"
|
|
457
|
+
return 1
|
|
458
|
+
}
|
|
459
|
+
|
|
420
460
|
session_reconciled(){ # only asked when a -session plist was generated; never a rollback reason
|
|
421
461
|
label_loaded "$SESSION_LABEL" || { log "reconcile-failed: session job $SESSION_LABEL is not loaded (maestro session start)"; return 1; }
|
|
422
462
|
pgrep -f "$AGENT_DIR/scripts/session/supervisor" >/dev/null 2>&1 || { log "reconcile-failed: $SESSION_LABEL is loaded but no supervisor process is alive for $AGENT_DIR (maestro session status)"; return 1; }
|
|
@@ -544,6 +584,12 @@ if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V |
|
|
|
544
584
|
if is_healthy; then
|
|
545
585
|
log "healthy on $CUR"
|
|
546
586
|
sibling_job_audit
|
|
587
|
+
# The up-to-date path is NOT exempt from the front door, for the same
|
|
588
|
+
# reason it stopped being exempt from the daemon: the seats that went
|
|
589
|
+
# silent for days were all "up to date". A daemon that beats while the
|
|
590
|
+
# front door is wedged is a seat that looks perfect from here and answers
|
|
591
|
+
# nobody.
|
|
592
|
+
revive_session || true
|
|
547
593
|
if [ -n "$STALE_HOP" ]; then
|
|
548
594
|
# A real version hop: complete it the way an automatic one completes —
|
|
549
595
|
# the notice tells the session to restart itself onto the new code.
|
|
@@ -597,6 +643,11 @@ fi
|
|
|
597
643
|
# installed version, never a LATEST). Fail-open: an unreadable last.json holds
|
|
598
644
|
# nothing.
|
|
599
645
|
FAILED_HOLD_S="${MAESTRO_AUTOUPDATE_FAILED_HOLD_S:-86400}"
|
|
646
|
+
# How long a front door may go without beating before this run restarts it FOR
|
|
647
|
+
# it. Ten minutes: long enough that a busy session is never interrupted (the
|
|
648
|
+
# heartbeat rides every tool use), short enough that a wedged one is measured in
|
|
649
|
+
# minutes rather than the days it took to notice the last three.
|
|
650
|
+
SESSION_STALE_S="${MAESTRO_SESSION_STALE_S:-600}"
|
|
600
651
|
failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health within FAILED_HOLD_S
|
|
601
652
|
node -e '
|
|
602
653
|
const [file, latest, holdS] = process.argv.slice(1);
|
|
@@ -668,7 +719,11 @@ restart_daemon
|
|
|
668
719
|
|
|
669
720
|
if is_healthy; then
|
|
670
721
|
REASON=""
|
|
671
|
-
|
|
722
|
+
# Revive BEFORE reconciling: the new code is installed, and a front door that
|
|
723
|
+
# is not beating must be restarted onto it rather than left holding an inbox
|
|
724
|
+
# it cannot answer.
|
|
725
|
+
revive_session || REASON="session-revive-failed"
|
|
726
|
+
if [ -n "$SESSION_LABEL" ] && ! session_reconciled; then REASON="${REASON:-session-reconcile-failed}"; fi
|
|
672
727
|
log "OK: healthy on $LATEST${REASON:+ ($REASON)}"
|
|
673
728
|
sibling_job_audit
|
|
674
729
|
write_upgrade_notice "$CUR" "$LATEST"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|