team 0.1.1 → 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 (55) 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 +5 -4
  9. package/dist/budgets/gate.js +16 -12
  10. package/dist/budgets/readings.d.ts +51 -7
  11. package/dist/budgets/readings.js +106 -16
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +11 -4
  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 +9 -5
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +73 -17
  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/state.d.ts +7 -4
  42. package/dist/state.js +37 -0
  43. package/dist/status/statusline.js +3 -1
  44. package/dist/watch/check.d.ts +1 -0
  45. package/dist/watch/checks/budget.js +4 -1
  46. package/dist/watch/pass.d.ts +3 -2
  47. package/dist/watch/pass.js +52 -21
  48. package/dist/watch/screen-core.d.ts +1 -1
  49. package/dist/watch/screen-core.js +89 -33
  50. package/dist/watch/screen-data.d.ts +9 -0
  51. package/dist/watch/screen-file.js +25 -7
  52. package/dist/watch/screen.d.ts +3 -2
  53. package/dist/watch/screen.js +9 -4
  54. package/examples/team.yaml +8 -0
  55. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import { readArgs } from "../args.js";
2
2
  import { budgetsInForce, watchInForce } from "../approve/approval.js";
3
- import { saveReadings, saveSpendReadings } 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 {
@@ -134,7 +160,8 @@ export async function runWatch(argv, io, sources) {
134
160
  // The money a spend check counted is kept, like the pass's screen readings: the launch
135
161
  // gate of `up` and `add` reads it later, and one that is stale by then reads unknown
136
162
  // (§ 5). An account whose check did not run keeps its stored reading.
137
- saveSpendReadings(dir, session, spendOf(outcomes));
163
+ if (!foreign)
164
+ saveSpendReadings(dir, spendOf(outcomes));
138
165
  for (const outcome of outcomes) {
139
166
  const key = `check:${outcome.account}`;
140
167
  if (outcome.state === 'read') {
@@ -149,21 +176,30 @@ export async function runWatch(argv, io, sources) {
149
176
  : `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
150
177
  }
151
178
  }
152
- const result = pass({
179
+ const run = (stored) => pass({
153
180
  team, state, live, machine: sources.machine(root), now, memory,
154
181
  approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
182
+ readings: stored,
155
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
+ });
156
193
  for (const report of result.reports)
157
- say(report.text, true);
158
- saveReadings(dir, session, result.readings, now);
194
+ tell(report.text, report.to === 'owner' || !args.flags.has('no-notify'));
159
195
  if (result.nudge) {
160
196
  if (args.flags.has('no-nudge'))
161
197
  say(`nudge not typed (--no-nudge): ${result.nudge.text}`, false);
162
198
  else
163
- deliver(result.nudge, team, session, sources, memory, say);
199
+ deliver(result.nudge, team, session, sources, memory, say, tell, told);
164
200
  }
165
201
  if (result.fallback)
166
- say(result.fallback, true);
202
+ tell(result.fallback, true);
167
203
  await closeEnded({ team, root, dir, session, live, sources, say, io, told });
168
204
  }
169
205
  beat();
@@ -177,7 +213,7 @@ export async function runWatch(argv, io, sources) {
177
213
  if (record?.watch?.pid === sources.pid)
178
214
  delete record.watch;
179
215
  });
180
- say(`the watch of "${session}" stopped`, true);
216
+ tell(`the watch of "${session}" stopped`, true);
181
217
  }
182
218
  return 0;
183
219
  }
@@ -198,7 +234,7 @@ function spendOf(outcomes) {
198
234
  }
199
235
  // Types the nudge, after reading the operator's screen once more: the pass saw it free, and a
200
236
  // prompt may have appeared since. Anything but an empty idle prompt keeps the nudge pending.
201
- function deliver(nudge, team, session, sources, memory, say) {
237
+ function deliver(nudge, team, session, sources, memory, say, tell, told) {
202
238
  const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
203
239
  const look = () => readScreen(cli, sources.screen(nudge.pane, session) ?? undefined).kind;
204
240
  const status = sources.status(nudge.pane, session);
@@ -208,15 +244,29 @@ function deliver(nudge, team, session, sources, memory, say) {
208
244
  memory.pending.push(...nudge.pending);
209
245
  memory.pendingSince ??= sources.now().getTime();
210
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);
211
256
  if ((status !== 'idle' && status !== 'done') || look() !== 'idle' || !sources.typeText(nudge.pane, nudge.text, session)) {
212
257
  keep();
213
258
  return;
214
259
  }
215
- // The text is in the box. A dialog that opened meanwhile must not get the Enter: the text
216
- // 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
+ }
217
267
  const after = look();
218
268
  if (after !== 'idle' && after !== 'unsent') {
219
- 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);
220
270
  keep();
221
271
  return;
222
272
  }
@@ -225,6 +275,12 @@ function deliver(nudge, team, session, sources, memory, say) {
225
275
  else
226
276
  keep();
227
277
  }
278
+ function tellOnce(told, key, text, tell) {
279
+ if (told.has(key))
280
+ return;
281
+ told.add(key);
282
+ tell(text, true);
283
+ }
228
284
  function noteWorked(dir, session, live) {
229
285
  const recorded = readState(dir).sessions[session];
230
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;
@@ -30,7 +30,21 @@ export function seatRules(input) {
30
30
  }
31
31
  return [...own, ...input.rules, ...signing, ...working];
32
32
  }
33
- /** The rules as the one text a launch option or a first message carries. */
34
- export function rulesText(input) {
35
- return ['Rules for this session, from the team file:', ...seatRules(input).map((rule) => `- ${rule}`)].join('\n');
33
+ /**
34
+ * The line each form ends with. The message says what the rules are and never that they are not a
35
+ * task, so some models start complying by investigating instead of waiting for a brief. A first
36
+ * message is answered once, so it can ask for the ready reply; a system prompt (a launch option)
37
+ * stays in force on every later turn, so it must not: a Claude seat could answer "ready" to its
38
+ * real brief.
39
+ */
40
+ const CLOSING = {
41
+ message: 'These are standing rules, not a task: reply ready and wait for your brief.',
42
+ option: 'These are standing rules, not a task.',
43
+ };
44
+ /** The rules as the one text a launch option or a first message carries, the closing line last. */
45
+ export function rulesText(input, delivery) {
46
+ // A file whose own rules already carry the form's closing line gets it once: as the closing line.
47
+ const closing = CLOSING[delivery];
48
+ const rules = seatRules(input).filter((rule) => rule !== closing);
49
+ return ['Rules for this session, from the team file:', ...rules.map((rule) => `- ${rule}`), closing].join('\n');
36
50
  }
@@ -43,6 +43,12 @@ screen:
43
43
  - 'Do you trust the contents of this project\?'
44
44
  - '>\s*Yes, I trust this folder'
45
45
  below_last_rule: 'enter Confirm'
46
+ # The captured trust dialog fills the pane: there is no composer rule.
47
+ - all:
48
+ - 'Do you trust the contents of this project\?'
49
+ - '>\s*Yes, I trust this folder'
50
+ - 'enter Confirm'
51
+ without_rule: true
46
52
  permission:
47
53
  ignore_case: true
48
54
  rules:
@@ -50,6 +56,13 @@ screen:
50
56
  - 'Requesting permission for:'
51
57
  - '^\s*>\s*[0-9]\.\s+Yes\b'
52
58
  below_last_rule: '↑/↓ Navigate'
59
+ # A long command pushes the one rule out of the window. The dialog is the
60
+ # three lines, with no composer rule left to sit below.
61
+ - all:
62
+ - 'Requesting permission for:'
63
+ - '^\s*>\s*[0-9]\.\s+Yes\b'
64
+ - '↑/↓ Navigate'
65
+ without_rule: true
53
66
  working:
54
67
  - any: ['\bGenerating\.\.\.']
55
68
  composer:
@@ -50,12 +50,38 @@ screen:
50
50
  footer: 'Esc to cancel · Tab to amend'
51
51
  below_last_rule: 'Esc to cancel · Tab to amend'
52
52
  - below_last_rule: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
53
+ # A pane with no composer rule whose choice is the dialog, including the
54
+ # unmarked "1. Yes" while up is running. A choice quoted above an empty
55
+ # prompt is a transcript: the empty prompt comes after it.
56
+ - all:
57
+ - '^\s*❯?\s*[0-9]\. (Yes|No)\b'
58
+ without_rule: true
59
+ none_after:
60
+ anchor: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
61
+ patterns:
62
+ - '^\s*[❯›>]\s*$'
53
63
  question:
64
+ # The phrase is the dialog's last line, as a permission footer is. Prose
65
+ # above the box is not a question.
66
+ - any:
67
+ - 'Enter to select'
68
+ - '↑/↓ to navigate'
69
+ - 'Enter to confirm'
70
+ - 'Esc to cancel'
71
+ on_footer: true
72
+ - below_last_rule: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
73
+ # The footer has a status line under it and the rule is out of the window.
74
+ # A phrase quoted above an empty prompt is a transcript.
54
75
  - any:
55
76
  - 'Enter to select'
56
77
  - '↑/↓ to navigate'
57
78
  - 'Enter to confirm'
58
79
  - 'Esc to cancel'
80
+ without_rule: true
81
+ none_after:
82
+ anchor: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
83
+ patterns:
84
+ - '^\s*[❯›>]\s*$'
59
85
  working:
60
86
  - any:
61
87
  - '\besc to interrupt\b'
@@ -64,6 +90,10 @@ screen:
64
90
  mode: box-to-rule
65
91
  prompt: '^\s*[❯›>]'
66
92
  rule: '^\s*[─━]{8,}'
93
+ # Claude Code greys every suggestion it shows — "Try …", "Resume briefly.", any other —
94
+ # with faint styling, while typed text carries none. The list below stays the fallback
95
+ # for a source without styling.
96
+ placeholder_style: dim
67
97
  placeholders:
68
98
  - equals: ''
69
99
  - prefix: 'Try "'
@@ -48,8 +48,11 @@ screen:
48
48
  - '^\s*→'
49
49
  - '^\s+Grok\s+[0-9]'
50
50
  working:
51
+ # The running turn is the braille spinner, or the prompt line ending in
52
+ # "ctrl+c to stop". Idle and rules-accepted captures do not paint that suffix.
53
+ # The same words quoted in the transcript are not a turn.
51
54
  - any:
52
- - 'ctrl\+c to stop'
55
+ - '^\s*→.*\s{2,}ctrl\+c to stop\s*$'
53
56
  - '^\s*[⠀-⣿]+\s+(Working|Thinking)\b'
54
57
  composer:
55
58
  mode: status-then-one