team 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +24 -0
- package/dist/budgets/gate.js +127 -0
- package/dist/budgets/readings.d.ts +69 -0
- package/dist/budgets/readings.js +156 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +362 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +324 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +27 -0
- package/dist/profiles/quota.js +57 -0
- package/dist/state.d.ts +61 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +42 -0
- package/dist/watch/pass.js +242 -0
- package/dist/watch/screen-core.d.ts +20 -0
- package/dist/watch/screen-core.js +356 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +24 -0
- package/dist/watch/screen.js +47 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- package/package.json +29 -10
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Seen } from '../budgets/readings.ts';
|
|
2
|
+
import type { CheckOutcome } from '../budgets/run.ts';
|
|
3
|
+
import type { TeamFile } from '../file/types.ts';
|
|
4
|
+
import type { SessionState } from '../state.ts';
|
|
5
|
+
import type { Live } from '../status/compare.ts';
|
|
6
|
+
import { type Report, type SeatCheck, type SeatHistory, type TeamCheck } from './check.ts';
|
|
7
|
+
import type { Machine } from './machine.ts';
|
|
8
|
+
export type Memory = {
|
|
9
|
+
history: Record<string, SeatHistory>;
|
|
10
|
+
slots: Record<string, unknown>;
|
|
11
|
+
active: Set<string>;
|
|
12
|
+
pending: string[];
|
|
13
|
+
pendingSince: number | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function newMemory(): Memory;
|
|
16
|
+
export type { Report } from './check.ts';
|
|
17
|
+
export declare const NUDGE_TEXT = "Team watch: reports are waiting in .agents/team.log";
|
|
18
|
+
export type PassResult = {
|
|
19
|
+
reports: Report[];
|
|
20
|
+
nudge: {
|
|
21
|
+
pane: string;
|
|
22
|
+
text: string;
|
|
23
|
+
pending: string[];
|
|
24
|
+
} | null;
|
|
25
|
+
fallback: string | null;
|
|
26
|
+
readings: Seen[];
|
|
27
|
+
};
|
|
28
|
+
export declare const SEAT_CHECKS: SeatCheck[];
|
|
29
|
+
export declare const TEAM_CHECKS: TeamCheck[];
|
|
30
|
+
export type PassInput = {
|
|
31
|
+
team: TeamFile;
|
|
32
|
+
state: SessionState;
|
|
33
|
+
live: Live;
|
|
34
|
+
machine: Machine;
|
|
35
|
+
now: number;
|
|
36
|
+
memory: Memory;
|
|
37
|
+
approval?: string[] | null;
|
|
38
|
+
watch?: TeamFile['watch'];
|
|
39
|
+
outcomes?: readonly CheckOutcome[];
|
|
40
|
+
budgets?: TeamFile['budgets'];
|
|
41
|
+
};
|
|
42
|
+
export declare function pass({ team, state, live, machine, now, memory, approval, watch, outcomes, budgets, }: PassInput): PassResult;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// One pass of the watch: the core. It builds one observation per seat before any check runs,
|
|
2
|
+
// runs the registered checks in the order it owns, applies the report-once rule, and dials the
|
|
3
|
+
// nudge. A check never reads herdr, a screen or the file itself — only the observation it is
|
|
4
|
+
// handed (RFC 0002 § 4.2).
|
|
5
|
+
import { WATCH_CHECKS_CHANGED } from "../approve/fingerprint.js";
|
|
6
|
+
import { observe, recall } from "../budgets/readings.js";
|
|
7
|
+
import { quotaFor } from "../profiles/profile.js";
|
|
8
|
+
import { figuresOf } from "../profiles/quota.js";
|
|
9
|
+
import { seatModel } from "../status/statusline.js";
|
|
10
|
+
import { ALWAYS_ON, } from "./check.js";
|
|
11
|
+
import { minutes } from "./check.js";
|
|
12
|
+
import { attention } from "./checks/attention.js";
|
|
13
|
+
import { approval } from "./checks/approval.js";
|
|
14
|
+
import { budget } from "./checks/budget.js";
|
|
15
|
+
import { disk } from "./checks/disk.js";
|
|
16
|
+
import { extra } from "./checks/extra.js";
|
|
17
|
+
import { idle } from "./checks/idle.js";
|
|
18
|
+
import { load } from "./checks/load.js";
|
|
19
|
+
import { memory as memoryCheck } from "./checks/memory.js";
|
|
20
|
+
import { missing } from "./checks/missing.js";
|
|
21
|
+
import { modelDrift } from "./checks/model-drift.js";
|
|
22
|
+
import { swapFree } from "./checks/swap-free.js";
|
|
23
|
+
import { swapGrowth } from "./checks/swap-growth.js";
|
|
24
|
+
import { teamIdle } from "./checks/team-idle.js";
|
|
25
|
+
import { unsent } from "./checks/unsent.js";
|
|
26
|
+
import { readScreen, statusRow } from "./screen.js";
|
|
27
|
+
export function newMemory() {
|
|
28
|
+
return { history: {}, slots: {}, active: new Set(), pending: [], pendingSince: null };
|
|
29
|
+
}
|
|
30
|
+
// The one line the watch types into the operator's pane. Fixed, and free of report text: a dialog
|
|
31
|
+
// can open between the screen read and the typing, and a digit in the line would answer it. The
|
|
32
|
+
// reports go to the log and to the desktop notification; the line only says where they are.
|
|
33
|
+
export const NUDGE_TEXT = 'Team watch: reports are waiting in .agents/team.log';
|
|
34
|
+
// The seat checks, in the order their reports land in the log; the team checks, after them. The
|
|
35
|
+
// order is the core's: a seat's wrong model is reported before its permission prompt, and every
|
|
36
|
+
// seat before the team. Disabling a check in the file's `watch.checks` takes it out of the run,
|
|
37
|
+
// except the four that can't be turned off.
|
|
38
|
+
export const SEAT_CHECKS = [missing, modelDrift, attention, unsent, idle];
|
|
39
|
+
export const TEAM_CHECKS = [extra, teamIdle, approval, load, memoryCheck, disk, swapFree, swapGrowth, budget];
|
|
40
|
+
// The one exclusive reading of a seat's screen and herdr status. herdr can report a seat at a
|
|
41
|
+
// permission prompt as idle, so the screen decides first.
|
|
42
|
+
function attentionOf(screen, status, quiet) {
|
|
43
|
+
if (screen.kind === 'permission' || screen.kind === 'trust')
|
|
44
|
+
return 'permission';
|
|
45
|
+
if (screen.kind === 'question')
|
|
46
|
+
return 'question';
|
|
47
|
+
if (status === 'blocked')
|
|
48
|
+
return 'blocked';
|
|
49
|
+
if (!quiet && status !== 'working')
|
|
50
|
+
return 'unknown';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// The figures off a seat's own pane: only a composer screen — idle, unsent or working — shows a
|
|
54
|
+
// status row at all, and only that row's line is read. A dialog, a question, a trust screen or an
|
|
55
|
+
// unknown one gives no figures, and neither does a line the seat printed or typed.
|
|
56
|
+
function quotaOf(cli, screen, pane) {
|
|
57
|
+
if (pane === undefined)
|
|
58
|
+
return [];
|
|
59
|
+
if (screen.kind !== 'idle' && screen.kind !== 'unsent' && screen.kind !== 'working')
|
|
60
|
+
return [];
|
|
61
|
+
return figuresOf(quotaFor(cli), statusRow(cli, pane));
|
|
62
|
+
}
|
|
63
|
+
// One pass of the watch. Pure: it reads what it is handed and changes only `memory`.
|
|
64
|
+
export function pass({ team, state, live, machine, now, memory, approval, watch = team.watch, outcomes = [], budgets = team.budgets, }) {
|
|
65
|
+
const reports = [];
|
|
66
|
+
const current = new Set();
|
|
67
|
+
// Reported when it starts, and again only after it has cleared.
|
|
68
|
+
const once = (key, text, to = 'operator') => {
|
|
69
|
+
current.add(key);
|
|
70
|
+
return memory.active.has(key) ? null : { key, text, to };
|
|
71
|
+
};
|
|
72
|
+
const known = new Set();
|
|
73
|
+
const workers = [];
|
|
74
|
+
const observations = [];
|
|
75
|
+
// A stopped seat is kept in the file and not started; should its owner start one by hand, herdr
|
|
76
|
+
// shows it running, and the watch gives it a parked seat's treatment: attention (permission,
|
|
77
|
+
// trust, question, blocked) and unsent text are reported, idle is not — and it is never an agent
|
|
78
|
+
// the file doesn't hold. A stopped seat that is not running is the normal state, and draws nothing.
|
|
79
|
+
const seats = [
|
|
80
|
+
...team.seats.map((seat) => ({
|
|
81
|
+
name: seat.name,
|
|
82
|
+
cli: seat.cli,
|
|
83
|
+
vendor: seat.vendor,
|
|
84
|
+
parked: seat.parked || seat.stopped,
|
|
85
|
+
stopped: seat.stopped,
|
|
86
|
+
seat,
|
|
87
|
+
})),
|
|
88
|
+
...Object.entries(state.seats).filter(([, recorded]) => recorded.temporary).map(([name, recorded]) => {
|
|
89
|
+
// A temporary seat signs like the seat it is like: its CLI, and its account.
|
|
90
|
+
const like = team.seats.find((seat) => seat.name === recorded.temporary?.like);
|
|
91
|
+
return { name, cli: like?.cli ?? '', vendor: like?.vendor ?? '', parked: false, stopped: false, seat: undefined };
|
|
92
|
+
}),
|
|
93
|
+
];
|
|
94
|
+
for (const { name, cli, vendor, parked, stopped, seat } of seats) {
|
|
95
|
+
const agent = live.agents.find((candidate) => candidate.name === name);
|
|
96
|
+
const lead = name === team.coordinator || name === team.operator;
|
|
97
|
+
if (!agent) {
|
|
98
|
+
delete memory.history[name];
|
|
99
|
+
observations.push({
|
|
100
|
+
name,
|
|
101
|
+
seat,
|
|
102
|
+
agent: undefined,
|
|
103
|
+
herdr: live.running,
|
|
104
|
+
screen: { kind: 'unknown' },
|
|
105
|
+
cli,
|
|
106
|
+
vendor,
|
|
107
|
+
quota: [],
|
|
108
|
+
running: false,
|
|
109
|
+
quiet: false,
|
|
110
|
+
working: false,
|
|
111
|
+
prompt: false,
|
|
112
|
+
lead,
|
|
113
|
+
parked,
|
|
114
|
+
stopped,
|
|
115
|
+
attention: null,
|
|
116
|
+
model: null,
|
|
117
|
+
history: { lastWorking: undefined, idleSince: undefined },
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
known.add(agent.pane);
|
|
122
|
+
const pane = live.screens[agent.pane];
|
|
123
|
+
const screen = readScreen(cli, pane);
|
|
124
|
+
const quiet = agent.status === 'idle' || agent.status === 'done';
|
|
125
|
+
// Working is herdr's word or the screen's: a seat mid-turn is never idle, whatever its
|
|
126
|
+
// status says.
|
|
127
|
+
const working = agent.status === 'working' || screen.kind === 'working';
|
|
128
|
+
const prompt = screen.kind === 'permission' || screen.kind === 'trust' || screen.kind === 'question';
|
|
129
|
+
if (!lead && !parked)
|
|
130
|
+
workers.push({ idle: quiet && !working && !prompt });
|
|
131
|
+
// The history the idle clock counts from. A seat seen working starts it again; a seat quiet
|
|
132
|
+
// with no work behind it was already idle when the watch started, and counts from then.
|
|
133
|
+
const history = (memory.history[name] ??= { lastWorking: undefined, idleSince: undefined });
|
|
134
|
+
if (working) {
|
|
135
|
+
history.lastWorking = now;
|
|
136
|
+
delete history.idleSince;
|
|
137
|
+
}
|
|
138
|
+
else if (quiet && !prompt && !lead && !parked && history.lastWorking === undefined) {
|
|
139
|
+
history.idleSince ??= now;
|
|
140
|
+
}
|
|
141
|
+
observations.push({
|
|
142
|
+
name,
|
|
143
|
+
seat,
|
|
144
|
+
agent,
|
|
145
|
+
herdr: live.running,
|
|
146
|
+
screen,
|
|
147
|
+
cli,
|
|
148
|
+
vendor,
|
|
149
|
+
quota: quotaOf(cli, screen, pane),
|
|
150
|
+
running: true,
|
|
151
|
+
quiet,
|
|
152
|
+
working,
|
|
153
|
+
prompt,
|
|
154
|
+
lead,
|
|
155
|
+
parked,
|
|
156
|
+
stopped,
|
|
157
|
+
attention: attentionOf(screen, agent.status, quiet),
|
|
158
|
+
model: seat ? seatModel(seat, live.screens[agent.pane]) : null,
|
|
159
|
+
history: { lastWorking: history.lastWorking, idleSince: history.idleSince },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// The figures this pass saw, folded into the readings the state keeps (§ 4.3). Only an account
|
|
163
|
+
// whose `sources` name `status_line` takes a screen reading: a check-only account never records
|
|
164
|
+
// one here, and neither does an account the budgets in force don't name (#50).
|
|
165
|
+
let readings = recall(state.budgets);
|
|
166
|
+
for (const seat of observations) {
|
|
167
|
+
if (!seat.running)
|
|
168
|
+
continue;
|
|
169
|
+
for (const figure of seat.quota) {
|
|
170
|
+
if (budgets.accounts[figure.account]?.sources.includes('status_line')) {
|
|
171
|
+
readings = observe(readings, figure, seat.name, now);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const teamObservation = {
|
|
176
|
+
team,
|
|
177
|
+
state,
|
|
178
|
+
live,
|
|
179
|
+
machine,
|
|
180
|
+
limits: team.machine,
|
|
181
|
+
seats: observations,
|
|
182
|
+
known,
|
|
183
|
+
workers,
|
|
184
|
+
approval,
|
|
185
|
+
readings,
|
|
186
|
+
outcomes,
|
|
187
|
+
};
|
|
188
|
+
const ctx = {
|
|
189
|
+
now,
|
|
190
|
+
watch,
|
|
191
|
+
budgets,
|
|
192
|
+
once,
|
|
193
|
+
memory: (kind, start) => {
|
|
194
|
+
if (!Object.hasOwn(memory.slots, kind))
|
|
195
|
+
memory.slots[kind] = start();
|
|
196
|
+
return memory.slots[kind];
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
// The four § 4.2 keeps out of `watch.checks` run whatever the file says: the validation refuses
|
|
200
|
+
// them, and this holds even for a file that reached memory another way.
|
|
201
|
+
//
|
|
202
|
+
// And nothing is turned off until the owner approves (RFC 0002 § 4.2): a file whose
|
|
203
|
+
// `watch.checks` differs from the approved one, a file never approved, and an approval that
|
|
204
|
+
// wasn't looked at keep every check running and report the difference instead. A difference in
|
|
205
|
+
// some other section leaves the checks turned off as approved — the owner owns both questions.
|
|
206
|
+
const approved = Array.isArray(approval) && !approval.includes(WATCH_CHECKS_CHANGED);
|
|
207
|
+
const off = approved ? new Set(team.watch.checks) : new Set();
|
|
208
|
+
const enabled = (name) => ALWAYS_ON.includes(name) || !off.has(name);
|
|
209
|
+
for (const seat of observations) {
|
|
210
|
+
for (const check of SEAT_CHECKS)
|
|
211
|
+
if (enabled(check.name))
|
|
212
|
+
reports.push(...check.run(seat, ctx));
|
|
213
|
+
}
|
|
214
|
+
for (const check of TEAM_CHECKS)
|
|
215
|
+
if (enabled(check.name))
|
|
216
|
+
reports.push(...check.run(teamObservation, ctx));
|
|
217
|
+
memory.active = current;
|
|
218
|
+
// The nudge: kept until the operator is free, never typed into anything but an empty idle prompt.
|
|
219
|
+
for (const report of reports)
|
|
220
|
+
if (report.to === 'operator')
|
|
221
|
+
memory.pending.push(report.text);
|
|
222
|
+
let nudge = null;
|
|
223
|
+
let fallback = null;
|
|
224
|
+
if (memory.pending.length) {
|
|
225
|
+
memory.pendingSince ??= now;
|
|
226
|
+
const operator = live.agents.find((agent) => agent.name === team.operator);
|
|
227
|
+
const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
|
|
228
|
+
const free = operator !== undefined && (operator.status === 'idle' || operator.status === 'done')
|
|
229
|
+
&& readScreen(cli, live.screens[operator.pane]).kind === 'idle';
|
|
230
|
+
if (operator && free) {
|
|
231
|
+
nudge = { pane: operator.pane, text: NUDGE_TEXT, pending: memory.pending.slice() };
|
|
232
|
+
}
|
|
233
|
+
else if (now - memory.pendingSince >= watch.nudgeWait * 1000) {
|
|
234
|
+
fallback = `the operator could not be nudged for ${minutes(now - memory.pendingSince)} minutes; ${memory.pending.length} report(s) wait: ${memory.pending.join('; ')}`;
|
|
235
|
+
}
|
|
236
|
+
if (nudge || fallback) {
|
|
237
|
+
memory.pending = [];
|
|
238
|
+
memory.pendingSince = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { reports, nudge, fallback, readings };
|
|
242
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Screen } from './screen.ts';
|
|
2
|
+
import type { ScreenData } from './screen-data.ts';
|
|
3
|
+
export type { Composer, FallbackRule, LinePattern, Placeholder, Rule, ScreenData, Stage } from './screen-data.ts';
|
|
4
|
+
export type ReadClock = {
|
|
5
|
+
now(): number;
|
|
6
|
+
budgetMs: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The one line a quota figure may be read from: the composer's own status row, or null when the
|
|
10
|
+
* window shows none. A dialog, a transcript line, the input box — anything the seat printed or
|
|
11
|
+
* typed — is never the row. The caller still has to know the screen is a composer screen.
|
|
12
|
+
*/
|
|
13
|
+
export declare function statusRowOf(data: ScreenData, lines: string[]): string | null;
|
|
14
|
+
/** `lines` is already the window: the last 20 lines, each trimmed at the end. */
|
|
15
|
+
export declare function classifyLines(data: ScreenData, lines: string[], clock?: ReadClock): Screen;
|
|
16
|
+
/**
|
|
17
|
+
* The composer alone, for a turn that is still running. The unknown stage is
|
|
18
|
+
* read first, as in the full classification, and the floor still applies.
|
|
19
|
+
*/
|
|
20
|
+
export declare function composeLines(data: ScreenData, lines: string[], clock?: ReadClock): Screen;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
const BUDGET_MS = 100;
|
|
2
|
+
// The rule a `below_last_rule` pattern has to sit under. It is the core's, not the profile's.
|
|
3
|
+
const RULE_LINE = /^\s*[─━]{8,}\s*$/;
|
|
4
|
+
// The safety floor's markers, matched without case: a dialog's wording is not always title-case
|
|
5
|
+
// (`Press enter to confirm or esc to cancel`). RFC 0002 § 4.1 permits it.
|
|
6
|
+
const FLOOR_PHRASES = ['do you want to', 'esc to cancel', 'enter confirm', 'enter continue', 'esc quit', 'esc skip', '↑/↓'];
|
|
7
|
+
/**
|
|
8
|
+
* The index of the composer's status line in the window, or -1. A `status-last` composer pins it
|
|
9
|
+
* as the last non-blank line; a `status-then-one` composer allows one chrome line below it. The
|
|
10
|
+
* classification and the quota read both take their line from here, so the two cannot disagree
|
|
11
|
+
* about which line the composer's status line is.
|
|
12
|
+
*/
|
|
13
|
+
function statusIndex(composer, lines, allowOneTrailing, tick) {
|
|
14
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
15
|
+
if (tick())
|
|
16
|
+
return 'stop';
|
|
17
|
+
if (!composer.statusLine.test(lines[i] ?? ''))
|
|
18
|
+
continue;
|
|
19
|
+
const trailing = lines.slice(i + 1).filter((line) => line.trim());
|
|
20
|
+
if (allowOneTrailing) {
|
|
21
|
+
if (trailing.length > 0 && (trailing.length > 1 || composer.prompt.test(trailing[0] ?? '')))
|
|
22
|
+
return -1;
|
|
23
|
+
}
|
|
24
|
+
else if (trailing.length > 0)
|
|
25
|
+
return -1;
|
|
26
|
+
return i;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The one line a quota figure may be read from: the composer's own status row, or null when the
|
|
32
|
+
* window shows none. A dialog, a transcript line, the input box — anything the seat printed or
|
|
33
|
+
* typed — is never the row. The caller still has to know the screen is a composer screen.
|
|
34
|
+
*/
|
|
35
|
+
export function statusRowOf(data, lines) {
|
|
36
|
+
const composer = data.composer;
|
|
37
|
+
if (composer.mode !== 'status-last' && composer.mode !== 'status-then-one')
|
|
38
|
+
return null;
|
|
39
|
+
const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
|
|
40
|
+
return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
|
|
41
|
+
}
|
|
42
|
+
/** `lines` is already the window: the last 20 lines, each trimmed at the end. */
|
|
43
|
+
export function classifyLines(data, lines, clock) {
|
|
44
|
+
const now = clock?.now ?? Date.now;
|
|
45
|
+
const budget = clock?.budgetMs ?? BUDGET_MS;
|
|
46
|
+
const start = now();
|
|
47
|
+
const tick = () => now() - start > budget;
|
|
48
|
+
const order = [
|
|
49
|
+
['unknown', data.unknown],
|
|
50
|
+
['trust', data.trust],
|
|
51
|
+
['permission', data.permission],
|
|
52
|
+
['question', data.question],
|
|
53
|
+
['working', data.working],
|
|
54
|
+
];
|
|
55
|
+
for (const [kind, stage] of order) {
|
|
56
|
+
if (!stage)
|
|
57
|
+
continue;
|
|
58
|
+
for (const rule of stage.rules) {
|
|
59
|
+
const hit = ruleMatches(data, lines, rule, tick);
|
|
60
|
+
if (hit === 'stop')
|
|
61
|
+
return { kind: 'unknown' };
|
|
62
|
+
if (hit)
|
|
63
|
+
return { kind };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const composed = compose(data, lines, tick);
|
|
67
|
+
if (composed.kind === 'stop' || composed.kind === 'unknown')
|
|
68
|
+
return { kind: 'unknown' };
|
|
69
|
+
if (composed.kind !== 'idle' && composed.kind !== 'unsent')
|
|
70
|
+
return { kind: composed.kind };
|
|
71
|
+
const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
|
|
72
|
+
if (marked === 'stop' || marked)
|
|
73
|
+
return { kind: 'unknown' };
|
|
74
|
+
return { kind: composed.kind };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The composer alone, for a turn that is still running. The unknown stage is
|
|
78
|
+
* read first, as in the full classification, and the floor still applies.
|
|
79
|
+
*/
|
|
80
|
+
export function composeLines(data, lines, clock) {
|
|
81
|
+
const now = clock?.now ?? Date.now;
|
|
82
|
+
const budget = clock?.budgetMs ?? BUDGET_MS;
|
|
83
|
+
const start = now();
|
|
84
|
+
const tick = () => now() - start > budget;
|
|
85
|
+
if (data.unknown) {
|
|
86
|
+
for (const rule of data.unknown.rules) {
|
|
87
|
+
const hit = ruleMatches(data, lines, rule, tick);
|
|
88
|
+
if (hit === 'stop')
|
|
89
|
+
return { kind: 'unknown' };
|
|
90
|
+
if (hit)
|
|
91
|
+
return { kind: 'unknown' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const composed = compose(data, lines, tick);
|
|
95
|
+
if (composed.kind === 'stop' || composed.kind === 'unknown')
|
|
96
|
+
return { kind: 'unknown' };
|
|
97
|
+
if (composed.kind !== 'idle' && composed.kind !== 'unsent')
|
|
98
|
+
return { kind: composed.kind };
|
|
99
|
+
const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
|
|
100
|
+
if (marked === 'stop' || marked)
|
|
101
|
+
return { kind: 'unknown' };
|
|
102
|
+
return { kind: composed.kind };
|
|
103
|
+
}
|
|
104
|
+
function ruleMatches(data, lines, rule, tick) {
|
|
105
|
+
if (rule.any) {
|
|
106
|
+
const hit = anyLine(lines, rule.any, tick);
|
|
107
|
+
if (hit === 'stop')
|
|
108
|
+
return 'stop';
|
|
109
|
+
if (!hit)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (rule.all) {
|
|
113
|
+
for (const pattern of rule.all) {
|
|
114
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
115
|
+
if (hit === 'stop')
|
|
116
|
+
return 'stop';
|
|
117
|
+
if (!hit)
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (rule.footer !== undefined) {
|
|
122
|
+
if (footerLine(data, lines) !== rule.footer)
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (rule.belowLastRule) {
|
|
126
|
+
if (tick())
|
|
127
|
+
return 'stop';
|
|
128
|
+
// No rule line counts as below one: today's check is `index > lastIndex`, and
|
|
129
|
+
// lastIndex is -1 when the window has no rule. A later rule still hides a quoted dialog.
|
|
130
|
+
const ruleAt = findLast(lines, (line) => RULE_LINE.test(line));
|
|
131
|
+
const at = findLast(lines, (line) => rule.belowLastRule?.test(line) ?? false);
|
|
132
|
+
if (at < 0 || at <= ruleAt)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (rule.noneAfter) {
|
|
136
|
+
let anchor = -1;
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
const hit = matches(lines[i] ?? '', rule.noneAfter.anchor, tick);
|
|
139
|
+
if (hit === 'stop')
|
|
140
|
+
return 'stop';
|
|
141
|
+
if (hit)
|
|
142
|
+
anchor = i;
|
|
143
|
+
}
|
|
144
|
+
if (anchor < 0)
|
|
145
|
+
return false;
|
|
146
|
+
for (let i = anchor + 1; i < lines.length; i++) {
|
|
147
|
+
const later = anyLine([lines[i] ?? ''], rule.noneAfter.patterns, tick);
|
|
148
|
+
if (later === 'stop')
|
|
149
|
+
return 'stop';
|
|
150
|
+
if (later)
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
function anyLine(lines, patterns, tick) {
|
|
157
|
+
for (const pattern of patterns) {
|
|
158
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
159
|
+
if (hit === 'stop')
|
|
160
|
+
return 'stop';
|
|
161
|
+
if (hit)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function lineSomewhere(lines, pattern, tick) {
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
const hit = matches(line, pattern, tick);
|
|
169
|
+
if (hit === 'stop')
|
|
170
|
+
return 'stop';
|
|
171
|
+
if (hit)
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The line a rule's footer must be: the last non-blank one. The composer's own chrome is the one
|
|
178
|
+
* thing allowed below it — a `status-last` mode pins the status line to the pane's last line, and
|
|
179
|
+
* a dialog drawn above it is still the dialog its footer belongs to. Any other line below the
|
|
180
|
+
* footer means the footer is not the dialog's, and the rule doesn't match.
|
|
181
|
+
*/
|
|
182
|
+
function footerLine(data, lines) {
|
|
183
|
+
const nonBlank = lines.filter((line) => line.trim());
|
|
184
|
+
const last = nonBlank[nonBlank.length - 1] ?? '';
|
|
185
|
+
const composer = data.composer;
|
|
186
|
+
if (composer.mode === 'status-last' && composer.statusLine.test(last)) {
|
|
187
|
+
return (nonBlank[nonBlank.length - 2] ?? '').trim();
|
|
188
|
+
}
|
|
189
|
+
return last.trim();
|
|
190
|
+
}
|
|
191
|
+
function matches(line, pattern, tick) {
|
|
192
|
+
if (tick())
|
|
193
|
+
return 'stop';
|
|
194
|
+
if (!pattern.match.test(line))
|
|
195
|
+
return false;
|
|
196
|
+
for (const except of pattern.except) {
|
|
197
|
+
if (tick())
|
|
198
|
+
return 'stop';
|
|
199
|
+
if (except.test(line))
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function compose(data, lines, tick) {
|
|
205
|
+
const composer = data.composer;
|
|
206
|
+
if (composer.mode === 'box-to-rule')
|
|
207
|
+
return boxToRule(lines, composer, tick);
|
|
208
|
+
if (composer.mode === 'status-last')
|
|
209
|
+
return statusLast(lines, composer, tick, false);
|
|
210
|
+
if (composer.mode === 'status-then-one')
|
|
211
|
+
return statusThenOne(lines, composer, tick);
|
|
212
|
+
return twoRules(lines, composer, tick);
|
|
213
|
+
}
|
|
214
|
+
function boxToRule(lines, composer, tick) {
|
|
215
|
+
let input = -1;
|
|
216
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
217
|
+
if (tick())
|
|
218
|
+
return { kind: 'stop' };
|
|
219
|
+
if (composer.prompt.test(lines[i] ?? '')) {
|
|
220
|
+
input = i;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (input < 0)
|
|
225
|
+
return { kind: 'unknown' };
|
|
226
|
+
const from = boundaryBefore(lines, input, composer.rule);
|
|
227
|
+
for (let i = input + 1; i < lines.length; i++) {
|
|
228
|
+
if (tick())
|
|
229
|
+
return { kind: 'stop' };
|
|
230
|
+
if (composer.rule.test(lines[i] ?? ''))
|
|
231
|
+
break;
|
|
232
|
+
if ((lines[i] ?? '').trim())
|
|
233
|
+
return { kind: 'unsent', from, input };
|
|
234
|
+
}
|
|
235
|
+
const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
|
|
236
|
+
return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
|
|
237
|
+
}
|
|
238
|
+
function statusLast(lines, composer, tick, allowOneTrailing) {
|
|
239
|
+
const status = statusIndex(composer, lines, allowOneTrailing, tick);
|
|
240
|
+
if (status === 'stop')
|
|
241
|
+
return { kind: 'stop' };
|
|
242
|
+
if (status < 0)
|
|
243
|
+
return { kind: 'unknown' };
|
|
244
|
+
let input = status - 1;
|
|
245
|
+
while (input >= 0 && !composer.prompt.test(lines[input] ?? ''))
|
|
246
|
+
input--;
|
|
247
|
+
if (input < 0)
|
|
248
|
+
return { kind: 'unknown' };
|
|
249
|
+
for (let i = input + 1; i < status; i++)
|
|
250
|
+
if ((lines[i] ?? '').trim())
|
|
251
|
+
return { kind: 'unsent', from: input, input };
|
|
252
|
+
return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer.placeholders) ? 'idle' : 'unsent', from: input, input };
|
|
253
|
+
}
|
|
254
|
+
function statusThenOne(lines, composer, tick) {
|
|
255
|
+
const found = statusLast(lines, composer, tick, true);
|
|
256
|
+
if (found.kind !== 'unknown')
|
|
257
|
+
return found;
|
|
258
|
+
// No status line: the fallback rules decide. Anything they don't name is unknown.
|
|
259
|
+
const hasStatus = lines.some((line) => composer.statusLine.test(line));
|
|
260
|
+
if (hasStatus)
|
|
261
|
+
return { kind: 'unknown' };
|
|
262
|
+
for (const rule of composer.fallback) {
|
|
263
|
+
let ok = true;
|
|
264
|
+
for (const pattern of rule.all) {
|
|
265
|
+
const hit = lineSomewhere(lines, pattern, tick);
|
|
266
|
+
if (hit === 'stop')
|
|
267
|
+
return { kind: 'stop' };
|
|
268
|
+
if (!hit) {
|
|
269
|
+
ok = false;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (ok) {
|
|
274
|
+
// The floor's region starts at the prompt, the composer's top boundary.
|
|
275
|
+
// A phrase in the transcript above it is not the floor's to read.
|
|
276
|
+
const input = lines.findIndex((line) => composer.prompt.test(line));
|
|
277
|
+
return { kind: rule.kind, from: input < 0 ? 0 : input, input };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { kind: 'unknown' };
|
|
281
|
+
}
|
|
282
|
+
function twoRules(lines, composer, tick) {
|
|
283
|
+
let bottom = -1;
|
|
284
|
+
let top = -1;
|
|
285
|
+
let input = -1;
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
if (tick())
|
|
288
|
+
return { kind: 'stop' };
|
|
289
|
+
const line = lines[i] ?? '';
|
|
290
|
+
if (composer.rule.test(line)) {
|
|
291
|
+
if (bottom === -1)
|
|
292
|
+
bottom = i;
|
|
293
|
+
else if (top === -1 && input !== -1) {
|
|
294
|
+
top = i;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (bottom !== -1 && input === -1 && composer.prompt.test(line))
|
|
299
|
+
input = i;
|
|
300
|
+
}
|
|
301
|
+
if (top < 0 || bottom < 0 || input <= top || input >= bottom)
|
|
302
|
+
return { kind: 'unknown' };
|
|
303
|
+
let footer = false;
|
|
304
|
+
for (let i = bottom + 1; i < lines.length; i++) {
|
|
305
|
+
if (tick())
|
|
306
|
+
return { kind: 'stop' };
|
|
307
|
+
if (composer.footers.some((pattern) => pattern.test(lines[i] ?? '')))
|
|
308
|
+
footer = true;
|
|
309
|
+
}
|
|
310
|
+
if (!footer)
|
|
311
|
+
return { kind: 'unknown' };
|
|
312
|
+
if (lines.slice(input + 1, bottom).some((line) => line.trim()))
|
|
313
|
+
return { kind: 'unsent', from: top, input };
|
|
314
|
+
const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
|
|
315
|
+
return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from: top, input };
|
|
316
|
+
}
|
|
317
|
+
function boundaryBefore(lines, input, rule) {
|
|
318
|
+
for (let i = input - 1; i >= 0; i--)
|
|
319
|
+
if (rule.test(lines[i] ?? ''))
|
|
320
|
+
return i;
|
|
321
|
+
return input;
|
|
322
|
+
}
|
|
323
|
+
function stripTyped(line, composer) {
|
|
324
|
+
const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');
|
|
325
|
+
return stripped.trim();
|
|
326
|
+
}
|
|
327
|
+
function placeholder(typed, list) {
|
|
328
|
+
return list.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
|
|
329
|
+
}
|
|
330
|
+
function floorHits(lines, from, input, chrome, tick) {
|
|
331
|
+
for (let i = Math.max(0, from); i < lines.length; i++) {
|
|
332
|
+
if (tick())
|
|
333
|
+
return 'stop';
|
|
334
|
+
const line = lines[i] ?? '';
|
|
335
|
+
if (chrome.some((pattern) => pattern.test(line)))
|
|
336
|
+
continue;
|
|
337
|
+
if (i !== input && choiceLine(line) && lines.slice(i + 1).some((later) => twoLine(later)))
|
|
338
|
+
return true;
|
|
339
|
+
const lower = line.toLowerCase();
|
|
340
|
+
if (FLOOR_PHRASES.some((phrase) => lower.includes(phrase)))
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
function choiceLine(line) {
|
|
346
|
+
return /^\s*[❯›>]\s*1\.\s/.test(line);
|
|
347
|
+
}
|
|
348
|
+
function twoLine(line) {
|
|
349
|
+
return /^\s*[❯›>]?\s*2\.\s/.test(line);
|
|
350
|
+
}
|
|
351
|
+
function findLast(lines, test) {
|
|
352
|
+
for (let i = lines.length - 1; i >= 0; i--)
|
|
353
|
+
if (test(lines[i] ?? ''))
|
|
354
|
+
return i;
|
|
355
|
+
return -1;
|
|
356
|
+
}
|