@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.
- package/lib/org/inbound/directedness.mjs +90 -0
- package/lib/org/inbound/index.mjs +23 -1
- package/lib/org/ui-parity.mjs +13 -1
- package/lib/session/pane.mjs +0 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/seat-provision.md +107 -0
- package/plugins/maestro-skills/skills/seat-upgrade.md +108 -0
- package/scripts/session/supervisor.mjs +86 -5
|
@@ -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 {
|
|
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 });
|
package/lib/org/ui-parity.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|