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.
Files changed (57) hide show
  1. package/README.md +20 -5
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +75 -8
  6. package/dist/approve/fingerprint.d.ts +15 -4
  7. package/dist/approve/fingerprint.js +37 -12
  8. package/dist/budgets/gate.d.ts +12 -7
  9. package/dist/budgets/gate.js +52 -17
  10. package/dist/budgets/readings.d.ts +71 -5
  11. package/dist/budgets/readings.js +136 -9
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +12 -5
  15. package/dist/commands/doctor.d.ts +3 -1
  16. package/dist/commands/doctor.js +4 -2
  17. package/dist/commands/down.d.ts +2 -2
  18. package/dist/commands/down.js +8 -1
  19. package/dist/commands/remove.d.ts +2 -0
  20. package/dist/commands/remove.js +21 -2
  21. package/dist/commands/status.js +3 -2
  22. package/dist/commands/up.d.ts +3 -1
  23. package/dist/commands/up.js +12 -7
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +94 -16
  26. package/dist/file/types.d.ts +5 -0
  27. package/dist/file/validate.js +31 -11
  28. package/dist/herdr.d.ts +4 -0
  29. package/dist/herdr.js +40 -11
  30. package/dist/launch/agent.d.ts +1 -0
  31. package/dist/launch/agent.js +12 -0
  32. package/dist/launch/deliver.d.ts +3 -1
  33. package/dist/launch/deliver.js +10 -1
  34. package/dist/launch/execute.d.ts +2 -2
  35. package/dist/launch/execute.js +15 -2
  36. package/dist/launch/rules.d.ts +4 -2
  37. package/dist/launch/rules.js +17 -3
  38. package/dist/profiles/antigravity.yaml +13 -0
  39. package/dist/profiles/claude-code.yaml +30 -0
  40. package/dist/profiles/cursor.yaml +4 -1
  41. package/dist/profiles/quota.d.ts +7 -2
  42. package/dist/profiles/quota.js +15 -14
  43. package/dist/state.d.ts +8 -3
  44. package/dist/state.js +37 -0
  45. package/dist/status/statusline.js +3 -1
  46. package/dist/watch/check.d.ts +1 -0
  47. package/dist/watch/checks/budget.js +4 -1
  48. package/dist/watch/pass.d.ts +14 -1
  49. package/dist/watch/pass.js +64 -28
  50. package/dist/watch/screen-core.d.ts +7 -1
  51. package/dist/watch/screen-core.js +146 -54
  52. package/dist/watch/screen-data.d.ts +9 -0
  53. package/dist/watch/screen-file.js +25 -7
  54. package/dist/watch/screen.d.ts +5 -0
  55. package/dist/watch/screen.js +16 -2
  56. package/examples/team.yaml +8 -0
  57. package/package.json +2 -2
@@ -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, session);
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
  }),
@@ -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;
@@ -1,10 +1,11 @@
1
1
  import { readArgs } from "../args.js";
2
2
  import { budgetsInForce, watchInForce } from "../approve/approval.js";
3
- import { saveReadings } from "../budgets/readings.js";
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
- const say = (text, desktop) => {
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 (desktop && !args.flags.has('no-notify'))
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
- say(problem, true);
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
- say('herdr doesn\'t answer; the watch keeps trying', true);
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 result = pass(team, state, live, sources.machine(root), now, memory, sources.approval(team, root), inForce, outcomes, budget);
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
- say(report.text, true);
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
- say(result.fallback, true);
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
- say(`the watch of "${session}" stopped`, true);
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. A dialog that opened meanwhile must not get the Enter: the text
194
- // then stays unsent, which the next passes report, and the nudge is kept.
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
- say('a nudge was typed and not sent: the operator\'s screen changed before the Enter', true);
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)
@@ -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;
@@ -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: readBudgets(top.get('budgets'), check, watched.watch.quotaMarks, watched.legacyMarks),
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
- function readSeats(entry, check, trust, topLine, broken) {
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
- staleAfter: check.measure(fields.get('stale_after'), 'budgets.stale_after', DURATION, '30m') ?? base.staleAfter,
556
- checkEvery: check.measure(fields.get('check_every'), 'budgets.check_every', DURATION, '10m') ?? base.checkEvery,
557
- marks: marks ?? fallbackMarks,
558
- accounts: readAccounts(fields.get('accounts'), check),
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 read. After a CLI exits,
37
- // herdr keeps the pane and the foreground process is the shell.
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
- const result = run(['pane', 'process-info', '--pane', pane], session);
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
- const full = [...(session ? ['--session', session] : []), 'pane', 'read', pane, '--source', 'visible', '--lines', String(lines)];
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
- return null;
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
+ }
@@ -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'>;
@@ -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; a dialog that appeared after the paste gets no key.
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 (;;) {
@@ -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;
@@ -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
- if (!here || !await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds)) {
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
- if (!host.typeLine(session, op.pane, op.text)) {
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');
@@ -21,5 +21,7 @@ export interface RulesInput {
21
21
  * workspace's.
22
22
  */
23
23
  export declare function seatRules(input: RulesInput): string[];
24
- /** The rules as the one text a launch option or a first message carries. */
25
- export declare function rulesText(input: RulesInput): string;
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;