team 0.1.0 → 0.1.2
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/README.md +20 -5
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +75 -0
- package/dist/approve/approval.d.ts +11 -0
- package/dist/approve/approval.js +75 -8
- package/dist/approve/fingerprint.d.ts +15 -4
- package/dist/approve/fingerprint.js +37 -12
- package/dist/budgets/gate.d.ts +12 -7
- package/dist/budgets/gate.js +52 -17
- package/dist/budgets/readings.d.ts +71 -5
- package/dist/budgets/readings.js +136 -9
- package/dist/budgets/table.d.ts +2 -2
- package/dist/budgets/table.js +24 -11
- package/dist/commands/add.js +12 -5
- package/dist/commands/doctor.d.ts +3 -1
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/down.d.ts +2 -2
- package/dist/commands/down.js +8 -1
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +21 -2
- package/dist/commands/status.js +3 -2
- package/dist/commands/up.d.ts +3 -1
- package/dist/commands/up.js +12 -7
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +94 -16
- package/dist/file/types.d.ts +5 -0
- package/dist/file/validate.js +31 -11
- package/dist/herdr.d.ts +4 -0
- package/dist/herdr.js +40 -11
- package/dist/launch/agent.d.ts +1 -0
- package/dist/launch/agent.js +12 -0
- package/dist/launch/deliver.d.ts +3 -1
- package/dist/launch/deliver.js +10 -1
- package/dist/launch/execute.d.ts +2 -2
- package/dist/launch/execute.js +15 -2
- package/dist/launch/rules.d.ts +4 -2
- package/dist/launch/rules.js +17 -3
- package/dist/profiles/antigravity.yaml +13 -0
- package/dist/profiles/claude-code.yaml +30 -0
- package/dist/profiles/cursor.yaml +4 -1
- package/dist/profiles/quota.d.ts +7 -2
- package/dist/profiles/quota.js +15 -14
- package/dist/state.d.ts +8 -3
- package/dist/state.js +37 -0
- package/dist/status/statusline.js +3 -1
- package/dist/watch/check.d.ts +1 -0
- package/dist/watch/checks/budget.js +4 -1
- package/dist/watch/pass.d.ts +14 -1
- package/dist/watch/pass.js +64 -28
- package/dist/watch/screen-core.d.ts +7 -1
- package/dist/watch/screen-core.js +146 -54
- package/dist/watch/screen-data.d.ts +9 -0
- package/dist/watch/screen-file.js +25 -7
- package/dist/watch/screen.d.ts +5 -0
- package/dist/watch/screen.js +16 -2
- package/examples/team.yaml +8 -0
- package/package.json +2 -2
package/dist/commands/up.js
CHANGED
|
@@ -7,7 +7,7 @@ import { readArgs } from "../args.js";
|
|
|
7
7
|
import { callerOf, describeCaller, isOwner } from "../caller.js";
|
|
8
8
|
import { loadTeamFile } from "../file/load.js";
|
|
9
9
|
import { renderSignature } from "../file/signature.js";
|
|
10
|
-
import { agentList, agentStatus, agentRename, paneRead, paneRun, typeText, pressEnter, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
|
|
10
|
+
import { agentList, agentStatus, agentRename, paneForeground, paneRead, paneRun, typeText, pressEnter, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
|
|
11
11
|
import { executePlan } from "../launch/execute.js";
|
|
12
12
|
import { formatPlan, upPlan } from "../launch/plan.js";
|
|
13
13
|
import { rulesText } from "../launch/rules.js";
|
|
@@ -18,7 +18,7 @@ import { profileFor } from "../profiles/index.js";
|
|
|
18
18
|
import { readApproval, storePath } from "../store/store.js";
|
|
19
19
|
import { emptySession, readState, updateState } from "../state.js";
|
|
20
20
|
import { seatBudget } from "../budgets/gate.js";
|
|
21
|
-
import { loadReadings } from "../budgets/readings.js";
|
|
21
|
+
import { loadReadings, loadSpendReadings } from "../budgets/readings.js";
|
|
22
22
|
import { blocksLaunch, doctorFindings, realSources as doctorSources } from "./doctor.js";
|
|
23
23
|
import { launchLimit, readMachine } from "../watch/machine.js";
|
|
24
24
|
import { readScreen } from "../watch/screen.js";
|
|
@@ -49,6 +49,7 @@ const realLaunch = {
|
|
|
49
49
|
typeText: (session, pane, text) => typeText(pane, text, aim(session)),
|
|
50
50
|
pressEnter: (session, pane) => pressEnter(pane, aim(session)),
|
|
51
51
|
agentStatus: (session, pane) => agentStatus(pane, aim(session)),
|
|
52
|
+
foreground: (session, pane) => paneForeground(pane, aim(session)),
|
|
52
53
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
53
54
|
now: () => new Date(),
|
|
54
55
|
};
|
|
@@ -77,9 +78,11 @@ export const realSources = {
|
|
|
77
78
|
export const USAGE = 'Usage: team up [--dry-run] [--session <name>] [--file <path>]\n';
|
|
78
79
|
export const up = (argv, io) => runUp(argv, io, realSources);
|
|
79
80
|
export default up;
|
|
80
|
-
/** The rules one seat gets at launch, with its own signature lines. */
|
|
81
|
+
/** The rules one seat gets at launch, with its own signature lines, as its delivery carries them. */
|
|
81
82
|
export function rulesOf(team, seat) {
|
|
82
83
|
const { commits, pullRequests } = team.identity.signature;
|
|
84
|
+
const profile = profileFor(seat.cli);
|
|
85
|
+
const delivery = profile && profile.rulesOption !== null ? 'option' : 'message';
|
|
83
86
|
return rulesText({
|
|
84
87
|
coordinator: team.coordinator,
|
|
85
88
|
rules: team.rules,
|
|
@@ -89,7 +92,7 @@ export function rulesOf(team, seat) {
|
|
|
89
92
|
commitPosition: commits.position,
|
|
90
93
|
},
|
|
91
94
|
workspace: { mode: seat.mode, protected: team.workspace.protected, branch: team.workspace.branch },
|
|
92
|
-
});
|
|
95
|
+
}, delivery);
|
|
93
96
|
}
|
|
94
97
|
function resolveState(sources, session) {
|
|
95
98
|
if (sources.sessionState)
|
|
@@ -208,7 +211,8 @@ export async function runUp(argv, io, sources) {
|
|
|
208
211
|
}
|
|
209
212
|
const recorded = readState(dir).sessions[session];
|
|
210
213
|
const workspaces = sources.workspaces?.(session) ?? null;
|
|
211
|
-
const readings = loadReadings(dir
|
|
214
|
+
const readings = loadReadings(dir);
|
|
215
|
+
const spend = loadSpendReadings(dir);
|
|
212
216
|
const seats = [];
|
|
213
217
|
const refused = new Set();
|
|
214
218
|
for (const seat of team.seats) {
|
|
@@ -221,7 +225,7 @@ export async function runUp(argv, io, sources) {
|
|
|
221
225
|
}
|
|
222
226
|
const placed = planned.stage === undefined || !planned.pane;
|
|
223
227
|
if (!placed) {
|
|
224
|
-
const budget = seatBudget(budgets, readings, seat, readAt());
|
|
228
|
+
const budget = seatBudget(budgets, readings, seat, readAt(), spend);
|
|
225
229
|
seats.push({ ...planned, ...(budget.kind === 'clear' ? {} : { budget }) });
|
|
226
230
|
continue;
|
|
227
231
|
}
|
|
@@ -233,7 +237,7 @@ export async function runUp(argv, io, sources) {
|
|
|
233
237
|
}
|
|
234
238
|
continue;
|
|
235
239
|
}
|
|
236
|
-
const budget = seatBudget(budgets, readings, seat, readAt());
|
|
240
|
+
const budget = seatBudget(budgets, readings, seat, readAt(), spend);
|
|
237
241
|
seats.push({
|
|
238
242
|
...planned,
|
|
239
243
|
cwd: start.cwd,
|
|
@@ -304,6 +308,7 @@ export async function runUp(argv, io, sources) {
|
|
|
304
308
|
status: () => launch.agentStatus?.(session, pane) ?? null,
|
|
305
309
|
type: (value) => launch.typeText?.(session, pane, value) ?? false,
|
|
306
310
|
enter: () => launch.pressEnter?.(session, pane) ?? false,
|
|
311
|
+
foreground: () => launch.foreground(session, pane),
|
|
307
312
|
now: () => now().getTime(),
|
|
308
313
|
sleep: sources.sleep ?? launch.sleep,
|
|
309
314
|
}),
|
package/dist/commands/watch.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type WatchSources = {
|
|
|
14
14
|
readChecks(team: TeamFile, root: string, now: number): CheckOutcome[];
|
|
15
15
|
screen(pane: string, session: string): string | null;
|
|
16
16
|
status(pane: string, session: string): string | null;
|
|
17
|
+
/** Foreground argv0 names, or null when the pane can't be read. */
|
|
18
|
+
foreground(pane: string, session: string): string[] | null;
|
|
17
19
|
typeText(pane: string, text: string, session: string): boolean;
|
|
18
20
|
pressEnter(pane: string, session: string): boolean;
|
|
19
21
|
notify(text: string): void;
|
package/dist/commands/watch.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readArgs } from "../args.js";
|
|
2
2
|
import { budgetsInForce, watchInForce } from "../approve/approval.js";
|
|
3
|
-
import {
|
|
3
|
+
import { callerOf, describeCaller, isOwner } from "../caller.js";
|
|
4
|
+
import { loadReadings, saveSpendReadings, updateReadings } from "../budgets/readings.js";
|
|
4
5
|
import { runChecks } from "../budgets/run.js";
|
|
5
6
|
import { currentTeam } from "../file/current.js";
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
|
-
import { agentStatus, paneRead, pressEnter, typeText } from "../herdr.js";
|
|
8
|
+
import { agentStatus, paneForeground, paneRead, pressEnter, typeText } from "../herdr.js";
|
|
8
9
|
import { logLine } from "../log.js";
|
|
9
10
|
import { emptySession, readState, updateState } from "../state.js";
|
|
10
11
|
import { readMachine } from "../watch/machine.js";
|
|
@@ -13,9 +14,11 @@ import { newMemory, pass } from "../watch/pass.js";
|
|
|
13
14
|
import { readScreen } from "../watch/screen.js";
|
|
14
15
|
import { judgeTemporary, judgeWorktree } from "../watch/close.js";
|
|
15
16
|
import { readEnd } from "../watch/end.js";
|
|
17
|
+
import { reportedLiveAgent } from "../launch/agent.js";
|
|
16
18
|
import { stopRunning, realSources as removeSources } from "./remove.js";
|
|
17
19
|
import { removeWorktree } from "./worktree.js";
|
|
18
20
|
import { stateOf } from "./down.js";
|
|
21
|
+
import { profileFor } from "../profiles/index.js";
|
|
19
22
|
import { realSources } from "./status.js";
|
|
20
23
|
function waitOrStop(seconds) {
|
|
21
24
|
return new Promise((done) => {
|
|
@@ -41,6 +44,7 @@ export const realWatchSources = {
|
|
|
41
44
|
readChecks: (team, root, now) => runChecks(team, root, now),
|
|
42
45
|
screen: (pane, session) => paneRead(pane, 14, session),
|
|
43
46
|
status: agentStatus,
|
|
47
|
+
foreground: (pane, session) => paneForeground(pane, session),
|
|
44
48
|
typeText,
|
|
45
49
|
pressEnter,
|
|
46
50
|
notify,
|
|
@@ -74,17 +78,30 @@ export async function runWatch(argv, io, sources) {
|
|
|
74
78
|
}
|
|
75
79
|
const session = args.values.session ?? first.team.session;
|
|
76
80
|
const { dir } = first;
|
|
81
|
+
// A seat that starts the session's only watch must not be able to drop the operator's nudge
|
|
82
|
+
// or the operator's desktop notices. The owner's own `--no-notify` still cannot silence a
|
|
83
|
+
// report addressed to the owner: those are routed below.
|
|
84
|
+
if (args.flags.has('no-nudge') || args.flags.has('no-notify')) {
|
|
85
|
+
const caller = callerOf(io, session);
|
|
86
|
+
if (!isOwner(caller)) {
|
|
87
|
+
io.stderr(`team watch: --no-nudge and --no-notify are the owner's, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
77
91
|
const other = readState(dir).sessions[session]?.watch;
|
|
78
92
|
if (other && other.pid !== sources.pid && sources.alive(other.pid)) {
|
|
79
93
|
io.stderr(`team watch: a watch already runs for the session "${session}" (pid ${other.pid})\n`);
|
|
80
94
|
return 1;
|
|
81
95
|
}
|
|
82
|
-
|
|
96
|
+
// The log line is written for every report. `--no-notify` never removes it, and it never
|
|
97
|
+
// removes a desktop notice addressed to the owner. It does silence every other notice.
|
|
98
|
+
const tell = (text, notify) => {
|
|
83
99
|
io.stdout(`${sources.now().toISOString()} ${text}\n`);
|
|
84
100
|
logLine(dir, 'watch', 'watch', text, sources.now());
|
|
85
|
-
if (
|
|
101
|
+
if (notify)
|
|
86
102
|
sources.notify(text);
|
|
87
103
|
};
|
|
104
|
+
const say = (text, desktop) => tell(text, desktop && !args.flags.has('no-notify'));
|
|
88
105
|
const beat = () => updateState(dir, (state) => {
|
|
89
106
|
const record = (state.sessions[session] ??= emptySession());
|
|
90
107
|
record.watch = { pid: sources.pid, heartbeat: sources.now().toISOString() };
|
|
@@ -106,7 +123,16 @@ export async function runWatch(argv, io, sources) {
|
|
|
106
123
|
if (problem !== notice) {
|
|
107
124
|
notice = problem;
|
|
108
125
|
if (problem)
|
|
109
|
-
|
|
126
|
+
tell(problem, true);
|
|
127
|
+
}
|
|
128
|
+
// Only the watch of the file's own session saves readings. A watch on another session —
|
|
129
|
+
// anyone may run one, from any terminal — reads and reports, but the figures it sees are
|
|
130
|
+
// not the project's cache that `up` and `add` count (§ 4.4): it says so once and writes
|
|
131
|
+
// none, budget or spend.
|
|
132
|
+
const foreign = session !== team.session;
|
|
133
|
+
if (foreign && !told.has('foreign-session')) {
|
|
134
|
+
told.add('foreign-session');
|
|
135
|
+
say(`the session "${session}" is not this file's "${team.session}": its readings are not saved`, false);
|
|
110
136
|
}
|
|
111
137
|
// The values in force, read with the file: until the owner approves an edit to `watch`,
|
|
112
138
|
// the watch keeps running with what was approved, or with the defaults.
|
|
@@ -117,7 +143,7 @@ export async function runWatch(argv, io, sources) {
|
|
|
117
143
|
const live = sources.live(session, team);
|
|
118
144
|
if (!live) {
|
|
119
145
|
if (!silent)
|
|
120
|
-
|
|
146
|
+
tell('herdr doesn\'t answer; the watch keeps trying', true);
|
|
121
147
|
silent = true;
|
|
122
148
|
}
|
|
123
149
|
else {
|
|
@@ -131,6 +157,11 @@ export async function runWatch(argv, io, sources) {
|
|
|
131
157
|
if (checksAt === null || now - checksAt >= budget.checkEvery * 1000) {
|
|
132
158
|
checksAt = now;
|
|
133
159
|
outcomes = sources.readChecks(team, root, now);
|
|
160
|
+
// The money a spend check counted is kept, like the pass's screen readings: the launch
|
|
161
|
+
// gate of `up` and `add` reads it later, and one that is stale by then reads unknown
|
|
162
|
+
// (§ 5). An account whose check did not run keeps its stored reading.
|
|
163
|
+
if (!foreign)
|
|
164
|
+
saveSpendReadings(dir, spendOf(outcomes));
|
|
134
165
|
for (const outcome of outcomes) {
|
|
135
166
|
const key = `check:${outcome.account}`;
|
|
136
167
|
if (outcome.state === 'read') {
|
|
@@ -145,18 +176,30 @@ export async function runWatch(argv, io, sources) {
|
|
|
145
176
|
: `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
|
-
const
|
|
179
|
+
const run = (stored) => pass({
|
|
180
|
+
team, state, live, machine: sources.machine(root), now, memory,
|
|
181
|
+
approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
|
|
182
|
+
readings: stored,
|
|
183
|
+
});
|
|
184
|
+
// The pass folds its figures where the state is held: two watches of the project fold one
|
|
185
|
+
// after the other, not over each other. A watch on a foreign session still folds, for its
|
|
186
|
+
// reports, and saves nothing.
|
|
187
|
+
const result = foreign
|
|
188
|
+
? run(loadReadings(dir))
|
|
189
|
+
: updateReadings(dir, now, (stored) => {
|
|
190
|
+
const folded = run(stored);
|
|
191
|
+
return { readings: folded.readings, value: folded };
|
|
192
|
+
});
|
|
149
193
|
for (const report of result.reports)
|
|
150
|
-
|
|
151
|
-
saveReadings(dir, session, result.readings, now);
|
|
194
|
+
tell(report.text, report.to === 'owner' || !args.flags.has('no-notify'));
|
|
152
195
|
if (result.nudge) {
|
|
153
196
|
if (args.flags.has('no-nudge'))
|
|
154
197
|
say(`nudge not typed (--no-nudge): ${result.nudge.text}`, false);
|
|
155
198
|
else
|
|
156
|
-
deliver(result.nudge, team, session, sources, memory, say);
|
|
199
|
+
deliver(result.nudge, team, session, sources, memory, say, tell, told);
|
|
157
200
|
}
|
|
158
201
|
if (result.fallback)
|
|
159
|
-
|
|
202
|
+
tell(result.fallback, true);
|
|
160
203
|
await closeEnded({ team, root, dir, session, live, sources, say, io, told });
|
|
161
204
|
}
|
|
162
205
|
beat();
|
|
@@ -170,13 +213,28 @@ export async function runWatch(argv, io, sources) {
|
|
|
170
213
|
if (record?.watch?.pid === sources.pid)
|
|
171
214
|
delete record.watch;
|
|
172
215
|
});
|
|
173
|
-
|
|
216
|
+
tell(`the watch of "${session}" stopped`, true);
|
|
174
217
|
}
|
|
175
218
|
return 0;
|
|
176
219
|
}
|
|
220
|
+
/** The spend readings among a pass's check outcomes, for the state. */
|
|
221
|
+
function spendOf(outcomes) {
|
|
222
|
+
const list = [];
|
|
223
|
+
for (const outcome of outcomes) {
|
|
224
|
+
if (outcome.state !== 'read' || outcome.reading.kind !== 'spend')
|
|
225
|
+
continue;
|
|
226
|
+
list.push({
|
|
227
|
+
account: outcome.account,
|
|
228
|
+
amount: outcome.reading.amount,
|
|
229
|
+
currency: outcome.reading.currency,
|
|
230
|
+
at: outcome.reading.at,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return list;
|
|
234
|
+
}
|
|
177
235
|
// Types the nudge, after reading the operator's screen once more: the pass saw it free, and a
|
|
178
236
|
// prompt may have appeared since. Anything but an empty idle prompt keeps the nudge pending.
|
|
179
|
-
function deliver(nudge, team, session, sources, memory, say) {
|
|
237
|
+
function deliver(nudge, team, session, sources, memory, say, tell, told) {
|
|
180
238
|
const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
|
|
181
239
|
const look = () => readScreen(cli, sources.screen(nudge.pane, session) ?? undefined).kind;
|
|
182
240
|
const status = sources.status(nudge.pane, session);
|
|
@@ -186,15 +244,29 @@ function deliver(nudge, team, session, sources, memory, say) {
|
|
|
186
244
|
memory.pending.push(...nudge.pending);
|
|
187
245
|
memory.pendingSince ??= sources.now().getTime();
|
|
188
246
|
};
|
|
247
|
+
const names = profileFor(cli)?.processNames ?? [];
|
|
248
|
+
const live = () => reportedLiveAgent(sources.foreground(nudge.pane, session), names);
|
|
249
|
+
const noAgent = 'nudge:no-agent';
|
|
250
|
+
if (!live()) {
|
|
251
|
+
tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
|
|
252
|
+
keep();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
told.delete(noAgent);
|
|
189
256
|
if ((status !== 'idle' && status !== 'done') || look() !== 'idle' || !sources.typeText(nudge.pane, nudge.text, session)) {
|
|
190
257
|
keep();
|
|
191
258
|
return;
|
|
192
259
|
}
|
|
193
|
-
// The text is in the box.
|
|
194
|
-
//
|
|
260
|
+
// The text is in the box. The agent is read again before Enter: it may have exited
|
|
261
|
+
// since the text was typed, and an unframed line is not a box to send.
|
|
262
|
+
if (!live()) {
|
|
263
|
+
tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
|
|
264
|
+
keep();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
195
267
|
const after = look();
|
|
196
268
|
if (after !== 'idle' && after !== 'unsent') {
|
|
197
|
-
|
|
269
|
+
tell('a nudge was typed and not sent: the operator\'s screen changed before the Enter', true);
|
|
198
270
|
keep();
|
|
199
271
|
return;
|
|
200
272
|
}
|
|
@@ -203,6 +275,12 @@ function deliver(nudge, team, session, sources, memory, say) {
|
|
|
203
275
|
else
|
|
204
276
|
keep();
|
|
205
277
|
}
|
|
278
|
+
function tellOnce(told, key, text, tell) {
|
|
279
|
+
if (told.has(key))
|
|
280
|
+
return;
|
|
281
|
+
told.add(key);
|
|
282
|
+
tell(text, true);
|
|
283
|
+
}
|
|
206
284
|
function noteWorked(dir, session, live) {
|
|
207
285
|
const recorded = readState(dir).sessions[session];
|
|
208
286
|
if (!recorded)
|
package/dist/file/types.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export type Seat = {
|
|
|
24
24
|
name: string;
|
|
25
25
|
cli: string;
|
|
26
26
|
vendor: string;
|
|
27
|
+
/**
|
|
28
|
+
* The account whose budget this seat spends, when one vendor has two (RFC 0003 § 3b). Absent
|
|
29
|
+
* means the vendor is the account; a seat that names one spends it instead of the vendor.
|
|
30
|
+
*/
|
|
31
|
+
account?: string;
|
|
27
32
|
model: string;
|
|
28
33
|
version: string;
|
|
29
34
|
display: string;
|
package/dist/file/validate.js
CHANGED
|
@@ -175,15 +175,18 @@ function readTeam(root, check) {
|
|
|
175
175
|
check.fail(item.line, `trust: "${item.value}" ${problem}`);
|
|
176
176
|
}
|
|
177
177
|
const trust = trustItems.map((item) => item.value);
|
|
178
|
+
// Both are read before the seats: a seat's `account:` must be a key of the budgets' accounts,
|
|
179
|
+
// and the check needs the names the file declares.
|
|
180
|
+
const watched = readWatch(top.get('watch'), check);
|
|
181
|
+
const budget = readBudgets(top.get('budgets'), check, watched.watch.quotaMarks, watched.legacyMarks);
|
|
178
182
|
const broken = new Set();
|
|
179
|
-
const seats = readSeats(top.get('seats'), check, trust, root.line, broken);
|
|
183
|
+
const seats = readSeats(top.get('seats'), check, trust, root.line, broken, budget.declared);
|
|
180
184
|
const workspace = readWorkspace(top.get('workspace'), check, project, trust, seats, root.line);
|
|
181
185
|
for (const seat of seats)
|
|
182
186
|
seat.mode ??= workspace.mode;
|
|
183
187
|
const coordinator = readLead(top.get('coordinator'), 'coordinator', seats, broken, check);
|
|
184
188
|
const operator = readLead(top.get('operator'), 'operator', seats, broken, check);
|
|
185
189
|
const limits = readLimits(top.get('limits'), check, seats.length);
|
|
186
|
-
const watched = readWatch(top.get('watch'), check);
|
|
187
190
|
return {
|
|
188
191
|
format: 1,
|
|
189
192
|
project,
|
|
@@ -197,7 +200,7 @@ function readTeam(root, check) {
|
|
|
197
200
|
trust,
|
|
198
201
|
workspace,
|
|
199
202
|
watch: watched.watch,
|
|
200
|
-
budgets:
|
|
203
|
+
budgets: budget.budgets,
|
|
201
204
|
machine: readMachine(top.get('machine'), check),
|
|
202
205
|
limits,
|
|
203
206
|
seats: seats,
|
|
@@ -287,8 +290,9 @@ function readPatterns(entry, name, check) {
|
|
|
287
290
|
return out;
|
|
288
291
|
}
|
|
289
292
|
// `broken` collects the names of seats left out for their own problems, so that a reference to
|
|
290
|
-
// one is not reported a second time.
|
|
291
|
-
|
|
293
|
+
// one is not reported a second time. `accounts` is the names the file's budgets declare: a seat
|
|
294
|
+
// that names an `account:` must name one of these, or it would silently spend nothing budgeted.
|
|
295
|
+
function readSeats(entry, check, trust, topLine, broken, accounts) {
|
|
292
296
|
if (!entry) {
|
|
293
297
|
check.fail(topLine, 'seats is required: at least the coordinator\'s and the operator\'s seat');
|
|
294
298
|
return [];
|
|
@@ -300,7 +304,7 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
300
304
|
const seats = [];
|
|
301
305
|
for (const item of entry.value.items) {
|
|
302
306
|
const fields = check.fields(item, 'a seat', [
|
|
303
|
-
'role', 'name', 'cli', 'vendor', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
|
|
307
|
+
'role', 'name', 'cli', 'vendor', 'account', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
|
|
304
308
|
'parked', 'stopped', 'count',
|
|
305
309
|
]);
|
|
306
310
|
if (item.kind !== 'map')
|
|
@@ -316,6 +320,14 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
316
320
|
check.fail(line, `${at}: cli is required`);
|
|
317
321
|
const cli = check.oneOf(fields.get('cli'), `${at}: cli`, CLIS);
|
|
318
322
|
const vendor = check.required(fields.get('vendor'), `${at}: vendor`, line);
|
|
323
|
+
// The account whose budget the seat spends, when the vendor's name is not it (§ 3b). Optional:
|
|
324
|
+
// absent, the seat spends its vendor, and the fingerprint of every file written so far is
|
|
325
|
+
// unchanged. Named, it must be one of the budgets' accounts: a typo would take the seat out of
|
|
326
|
+
// every budget with no warning at all.
|
|
327
|
+
const account = check.text(fields.get('account'), `${at}: account`);
|
|
328
|
+
if (account !== undefined && !accounts.has(account)) {
|
|
329
|
+
check.fail(fields.get('account')?.line ?? line, `${at}: account "${account}" is not in budgets.accounts`);
|
|
330
|
+
}
|
|
319
331
|
const model = check.required(fields.get('model'), `${at}: model`, line);
|
|
320
332
|
const version = readVersion(fields.get('version'), at, line, check);
|
|
321
333
|
const launch = check.required(fields.get('launch'), `${at}: launch`, line);
|
|
@@ -343,6 +355,7 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
343
355
|
declared: name,
|
|
344
356
|
count,
|
|
345
357
|
instance,
|
|
358
|
+
...(account ? { account } : {}),
|
|
346
359
|
...(mode ? { mode } : {}),
|
|
347
360
|
});
|
|
348
361
|
}
|
|
@@ -545,17 +558,24 @@ export function defaultBudgets() {
|
|
|
545
558
|
function readBudgets(entry, check, fallbackMarks, legacyMarks) {
|
|
546
559
|
const base = { ...defaultBudgets(), marks: fallbackMarks };
|
|
547
560
|
if (!entry)
|
|
548
|
-
return base;
|
|
561
|
+
return { budgets: base, declared: new Set() };
|
|
549
562
|
const fields = check.fields(entry.value, 'budgets', ['stale_after', 'check_every', 'marks', 'accounts']);
|
|
563
|
+
// Every name the accounts map writes, whether or not the account itself reads cleanly: a seat
|
|
564
|
+
// naming one of these is not also told it named nothing.
|
|
565
|
+
const accounts = fields.get('accounts');
|
|
566
|
+
const declared = new Set(accounts?.value.kind === 'map' ? accounts.value.entries.map((account) => account.key) : []);
|
|
550
567
|
const marks = percentList(fields.get('marks'), 'budgets.marks', check);
|
|
551
568
|
if (marks && legacyMarks) {
|
|
552
569
|
check.warnings.push({ line: fields.get('marks')?.line ?? entry.line, message: 'budgets.marks replaces watch.quota_marks' });
|
|
553
570
|
}
|
|
554
571
|
return {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
572
|
+
budgets: {
|
|
573
|
+
staleAfter: check.measure(fields.get('stale_after'), 'budgets.stale_after', DURATION, '30m') ?? base.staleAfter,
|
|
574
|
+
checkEvery: check.measure(fields.get('check_every'), 'budgets.check_every', DURATION, '10m') ?? base.checkEvery,
|
|
575
|
+
marks: marks ?? fallbackMarks,
|
|
576
|
+
accounts: readAccounts(fields.get('accounts'), check),
|
|
577
|
+
},
|
|
578
|
+
declared,
|
|
559
579
|
};
|
|
560
580
|
}
|
|
561
581
|
function percentList(entry, name, check) {
|
package/dist/herdr.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type HerdrAgent = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare function agentList(session?: string): HerdrAgent[] | null;
|
|
10
10
|
export declare function paneRootPid(pane: string, session?: string): number | null;
|
|
11
|
+
export declare function foregroundArgv0(result: unknown): string[] | null;
|
|
11
12
|
export declare function paneForeground(pane: string, session?: string): string[] | null;
|
|
12
13
|
export type HerdrWorkspace = {
|
|
13
14
|
id: string;
|
|
@@ -26,6 +27,9 @@ export declare function paneRun(pane: string, command: string, session?: string)
|
|
|
26
27
|
export declare function agentRename(pane: string, name: string, session?: string): boolean;
|
|
27
28
|
export declare function workspaceClose(workspace: string, session?: string): boolean;
|
|
28
29
|
export declare function sessionStop(name: string): boolean;
|
|
30
|
+
export type PaneExec = (args: string[]) => string;
|
|
31
|
+
/** Swaps the process call paneRead makes; null restores the real one. */
|
|
32
|
+
export declare function setPaneExec(exec: PaneExec | null): void;
|
|
29
33
|
export declare function paneRead(pane: string, lines: number, session?: string): string | null;
|
|
30
34
|
export declare function herdrCommand(session: string | undefined, ...args: string[]): string;
|
|
31
35
|
export declare function typeText(pane: string, text: string, session?: string): boolean;
|
package/dist/herdr.js
CHANGED
|
@@ -33,15 +33,19 @@ export function paneRootPid(pane, session) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
// argv0 of each foreground process, or null when the pane can't be
|
|
37
|
-
//
|
|
36
|
+
// argv0 of each foreground process, in herdr's order, or null when the pane can't be
|
|
37
|
+
// read. `name` is not the process: a Claude entry's name is its version. After a CLI
|
|
38
|
+
// exits, herdr keeps the pane and the foreground process is the shell.
|
|
39
|
+
export function foregroundArgv0(result) {
|
|
40
|
+
const list = result
|
|
41
|
+
?.process_info?.foreground_processes;
|
|
42
|
+
if (!Array.isArray(list))
|
|
43
|
+
return null;
|
|
44
|
+
return list.map((proc) => (typeof proc?.argv0 === 'string' ? proc.argv0 : ''));
|
|
45
|
+
}
|
|
38
46
|
export function paneForeground(pane, session) {
|
|
39
47
|
try {
|
|
40
|
-
|
|
41
|
-
const list = result.process_info?.foreground_processes;
|
|
42
|
-
if (!Array.isArray(list))
|
|
43
|
-
return null;
|
|
44
|
-
return list.map((proc) => (typeof proc.argv0 === 'string' ? proc.argv0 : ''));
|
|
48
|
+
return foregroundArgv0(run(['pane', 'process-info', '--pane', pane], session));
|
|
45
49
|
}
|
|
46
50
|
catch {
|
|
47
51
|
return null;
|
|
@@ -175,15 +179,40 @@ export function sessionStop(name) {
|
|
|
175
179
|
const body = parsed(out);
|
|
176
180
|
return body === null || body.type !== 'error';
|
|
177
181
|
}
|
|
182
|
+
const shellPaneExec = (args) => execFileSync('herdr', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
183
|
+
let paneExec = shellPaneExec;
|
|
184
|
+
/** Swaps the process call paneRead makes; null restores the real one. */
|
|
185
|
+
export function setPaneExec(exec) {
|
|
186
|
+
paneExec = exec ?? shellPaneExec;
|
|
187
|
+
}
|
|
178
188
|
// The visible lines of a pane, or null. `session` undefined reaches the caller's own server.
|
|
189
|
+
// The lines keep their ANSI styling, CRLF folded to LF: a greyed suggestion and typed text
|
|
190
|
+
// read the same as plain text, and only their styling tells them apart, so the readers that
|
|
191
|
+
// want plain text strip it (`stripSgr`). An herdr without the `--format` option refuses the
|
|
192
|
+
// styled call and is read by the exact call main makes today — no flag at all — where
|
|
193
|
+
// unstyled text can never read as dim and the placeholder list alone decides. A herdr that
|
|
194
|
+
// hangs is not asked twice: one timeout reads null.
|
|
179
195
|
export function paneRead(pane, lines, session) {
|
|
196
|
+
const full = [...(session ? ['--session', session] : []), 'pane', 'read', pane, '--source', 'visible', '--lines', String(lines)];
|
|
197
|
+
let styled;
|
|
180
198
|
try {
|
|
181
|
-
|
|
182
|
-
return execFileSync('herdr', full, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
199
|
+
styled = paneExec([...full, '--format', 'ansi']);
|
|
183
200
|
}
|
|
184
|
-
catch {
|
|
185
|
-
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (!(error instanceof Error))
|
|
203
|
+
return null;
|
|
204
|
+
// execFileSync reports its own timeout as an Error with code ETIMEDOUT — signal SIGTERM,
|
|
205
|
+
// status null, and no killed field. Observed under Bun, from a run, not from memory.
|
|
206
|
+
if (error.code === 'ETIMEDOUT')
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
return paneExec(full);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
186
214
|
}
|
|
215
|
+
return styled.replace(/\r\n/g, '\n').replace(/\r$/, '');
|
|
187
216
|
}
|
|
188
217
|
// The words to type for a herdr command, for a repair line.
|
|
189
218
|
export function herdrCommand(session, ...args) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportedLiveAgent(foreground: readonly string[] | null, processNames: readonly string[]): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Herdr reports a live agent when one foreground argv0 is that CLI. An unreadable
|
|
2
|
+
// pane, or the shell left after the CLI has exited, is not one: those are the panes
|
|
3
|
+
// a nudge, a first message, or an exit must not be typed into.
|
|
4
|
+
//
|
|
5
|
+
// Null is not a live agent, so nothing is typed. `paneStillRunning` reads that same
|
|
6
|
+
// null as still there — an unreadable list is not a departure, and the quota work
|
|
7
|
+
// on fix/quota-live-agent keeps that reading. The two questions stay separate.
|
|
8
|
+
export function reportedLiveAgent(foreground, processNames) {
|
|
9
|
+
if (!foreground || processNames.length === 0)
|
|
10
|
+
return false;
|
|
11
|
+
return foreground.some((name) => processNames.includes(name));
|
|
12
|
+
}
|
package/dist/launch/deliver.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ export interface Delivery {
|
|
|
3
3
|
status(): string | null;
|
|
4
4
|
type(text: string): boolean;
|
|
5
5
|
enter(): boolean;
|
|
6
|
+
/** Foreground argv0 names, or null when the pane can't be read. */
|
|
7
|
+
foreground(): string[] | null;
|
|
6
8
|
now(): number;
|
|
7
9
|
sleep(ms: number): Promise<void>;
|
|
8
10
|
}
|
|
9
11
|
/** A first message is accepted only after working is observed with the composer empty again. */
|
|
10
|
-
export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean>;
|
|
12
|
+
export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean | 'no-agent'>;
|
package/dist/launch/deliver.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { reportedLiveAgent } from "./agent.js";
|
|
2
|
+
import { profileFor } from "../profiles/index.js";
|
|
1
3
|
import { classifyComposer, readScreen } from "../watch/screen.js";
|
|
2
4
|
/** A first message is accepted only after working is observed with the composer empty again. */
|
|
3
5
|
export async function deliverRules(cli, text, seconds, io) {
|
|
6
|
+
const names = profileFor(cli)?.processNames ?? [];
|
|
7
|
+
const live = () => reportedLiveAgent(io.foreground(), names);
|
|
4
8
|
const free = () => ['idle', 'done'].includes(io.status() ?? '');
|
|
9
|
+
if (!live())
|
|
10
|
+
return 'no-agent';
|
|
5
11
|
if (!free() || readScreen(cli, io.screen()).kind !== 'idle' || !io.type(text))
|
|
6
12
|
return false;
|
|
7
13
|
const deadline = io.now() + seconds * 1000;
|
|
@@ -19,7 +25,10 @@ export async function deliverRules(cli, text, seconds, io) {
|
|
|
19
25
|
if (io.now() <= before)
|
|
20
26
|
return false;
|
|
21
27
|
}
|
|
22
|
-
// Re-read immediately before Enter
|
|
28
|
+
// Re-read immediately before Enter. The agent is asked again: it may have exited
|
|
29
|
+
// since the paste, and a dialog that appeared gets no key.
|
|
30
|
+
if (!live())
|
|
31
|
+
return 'no-agent';
|
|
23
32
|
if (!free() || readScreen(cli, io.screen()).kind !== 'unsent' || !io.enter())
|
|
24
33
|
return false;
|
|
25
34
|
for (;;) {
|
package/dist/launch/execute.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type Host = {
|
|
|
11
11
|
workspace: string;
|
|
12
12
|
} | null;
|
|
13
13
|
paneRun(session: string, pane: string, command: string): boolean;
|
|
14
|
-
typeLine(session: string, pane: string, text: string): boolean;
|
|
15
|
-
deliverRules?(session: string, pane: string, cli: string, text: string, seconds: number): Promise<boolean>;
|
|
14
|
+
typeLine(session: string, pane: string, text: string): boolean | 'no-agent';
|
|
15
|
+
deliverRules?(session: string, pane: string, cli: string, text: string, seconds: number): Promise<boolean | 'no-agent'>;
|
|
16
16
|
renameAgent(session: string, pane: string, name: string): boolean;
|
|
17
17
|
closeWorkspace(session: string, workspace: string): boolean;
|
|
18
18
|
stopSession(session: string): boolean;
|
package/dist/launch/execute.js
CHANGED
|
@@ -208,7 +208,13 @@ export async function executePlan(steps, session, host) {
|
|
|
208
208
|
if (op.notice)
|
|
209
209
|
host.say(`${op.seat}: ${op.notice}\n`);
|
|
210
210
|
const here = place(op.label, op.pane);
|
|
211
|
-
|
|
211
|
+
const delivered = here ? await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds) : false;
|
|
212
|
+
if (delivered === 'no-agent') {
|
|
213
|
+
dropped.add(op.seat);
|
|
214
|
+
finish(op.seat, 'no live agent in its pane; its rules were not delivered');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!here || !delivered) {
|
|
212
218
|
dropped.add(op.seat);
|
|
213
219
|
finish(op.seat, 'its rules were not delivered; left at named');
|
|
214
220
|
break;
|
|
@@ -230,7 +236,14 @@ export async function executePlan(steps, session, host) {
|
|
|
230
236
|
break;
|
|
231
237
|
}
|
|
232
238
|
case 'type': {
|
|
233
|
-
|
|
239
|
+
const typed = host.typeLine(session, op.pane, op.text);
|
|
240
|
+
if (typed === 'no-agent') {
|
|
241
|
+
held = true;
|
|
242
|
+
dropped.add(op.seat);
|
|
243
|
+
finish(op.seat, 'no live agent in its pane; its exit was not typed');
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
if (!typed) {
|
|
234
247
|
held = true;
|
|
235
248
|
dropped.add(op.seat);
|
|
236
249
|
finish(op.seat, 'its exit was not typed; left as it is');
|
package/dist/launch/rules.d.ts
CHANGED
|
@@ -21,5 +21,7 @@ export interface RulesInput {
|
|
|
21
21
|
* workspace's.
|
|
22
22
|
*/
|
|
23
23
|
export declare function seatRules(input: RulesInput): string[];
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
24
|
+
/** How the rules reach a seat: typed as its first message, or carried as a launch option. */
|
|
25
|
+
export type RulesDelivery = 'message' | 'option';
|
|
26
|
+
/** The rules as the one text a launch option or a first message carries, the closing line last. */
|
|
27
|
+
export declare function rulesText(input: RulesInput, delivery: RulesDelivery): string;
|