team 0.1.2 → 0.2.0

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 (114) hide show
  1. package/README.md +22 -11
  2. package/dist/approve/approval.js +14 -9
  3. package/dist/approve/fingerprint.d.ts +14 -2
  4. package/dist/approve/fingerprint.js +21 -21
  5. package/dist/budgets/gate.js +7 -2
  6. package/dist/budgets/readings.d.ts +6 -2
  7. package/dist/budgets/readings.js +28 -2
  8. package/dist/budgets/run.js +8 -2
  9. package/dist/budgets/table.d.ts +2 -0
  10. package/dist/budgets/table.js +10 -5
  11. package/dist/cli.d.ts +2 -1
  12. package/dist/cli.js +8 -5
  13. package/dist/commands/add.d.ts +0 -1
  14. package/dist/commands/add.js +7 -5
  15. package/dist/commands/approve.js +17 -1
  16. package/dist/commands/doctor.d.ts +15 -1
  17. package/dist/commands/doctor.js +126 -8
  18. package/dist/commands/down.d.ts +4 -0
  19. package/dist/commands/down.js +32 -13
  20. package/dist/commands/init.d.ts +1 -1
  21. package/dist/commands/init.js +4 -2
  22. package/dist/commands/remove.js +5 -2
  23. package/dist/commands/status.d.ts +2 -0
  24. package/dist/commands/status.js +19 -1
  25. package/dist/commands/up.js +3 -1
  26. package/dist/commands/watch.d.ts +2 -0
  27. package/dist/commands/watch.js +28 -4
  28. package/dist/conformance/adapter.d.ts +5 -0
  29. package/dist/conformance/adapter.js +44 -0
  30. package/dist/file/check.d.ts +20 -0
  31. package/dist/file/check.js +112 -0
  32. package/dist/file/lines.js +13 -2
  33. package/dist/file/sections/budgets.d.ts +15 -0
  34. package/dist/file/sections/budgets.js +180 -0
  35. package/dist/file/sections/coordinator.d.ts +2 -0
  36. package/dist/file/sections/coordinator.js +11 -0
  37. package/dist/file/sections/format.d.ts +2 -0
  38. package/dist/file/sections/format.js +13 -0
  39. package/dist/file/sections/identity.d.ts +2 -0
  40. package/dist/file/sections/identity.js +103 -0
  41. package/dist/file/sections/index.d.ts +9 -0
  42. package/dist/file/sections/index.js +43 -0
  43. package/dist/file/sections/lead.d.ts +4 -0
  44. package/dist/file/sections/lead.js +18 -0
  45. package/dist/file/sections/limits.d.ts +2 -0
  46. package/dist/file/sections/limits.js +43 -0
  47. package/dist/file/sections/machine.d.ts +2 -0
  48. package/dist/file/sections/machine.js +38 -0
  49. package/dist/file/sections/operator.d.ts +2 -0
  50. package/dist/file/sections/operator.js +11 -0
  51. package/dist/file/sections/project.d.ts +2 -0
  52. package/dist/file/sections/project.js +13 -0
  53. package/dist/file/sections/rules.d.ts +2 -0
  54. package/dist/file/sections/rules.js +9 -0
  55. package/dist/file/sections/seats.d.ts +7 -0
  56. package/dist/file/sections/seats.js +161 -0
  57. package/dist/file/sections/section.d.ts +32 -0
  58. package/dist/file/sections/section.js +7 -0
  59. package/dist/file/sections/session.d.ts +2 -0
  60. package/dist/file/sections/session.js +16 -0
  61. package/dist/file/sections/tools.d.ts +2 -0
  62. package/dist/file/sections/tools.js +43 -0
  63. package/dist/file/sections/trust.d.ts +2 -0
  64. package/dist/file/sections/trust.js +20 -0
  65. package/dist/file/sections/units.d.ts +16 -0
  66. package/dist/file/sections/units.js +8 -0
  67. package/dist/file/sections/visibility.d.ts +2 -0
  68. package/dist/file/sections/visibility.js +12 -0
  69. package/dist/file/sections/watch-checks.d.ts +9 -0
  70. package/dist/file/sections/watch-checks.js +14 -0
  71. package/dist/file/sections/watch.d.ts +14 -0
  72. package/dist/file/sections/watch.js +111 -0
  73. package/dist/file/sections/workspace.d.ts +4 -0
  74. package/dist/file/sections/workspace.js +77 -0
  75. package/dist/file/types.d.ts +1 -0
  76. package/dist/file/validate.d.ts +4 -12
  77. package/dist/file/validate.js +45 -688
  78. package/dist/herdr.d.ts +1 -0
  79. package/dist/herdr.js +11 -1
  80. package/dist/launch/agent.js +4 -3
  81. package/dist/launch/deliver.d.ts +4 -0
  82. package/dist/launch/deliver.js +151 -8
  83. package/dist/launch/execute.d.ts +2 -0
  84. package/dist/launch/execute.js +21 -11
  85. package/dist/launch/plan.js +16 -2
  86. package/dist/profiles/antigravity.yaml +12 -2
  87. package/dist/profiles/claude-code.yaml +21 -6
  88. package/dist/profiles/codex.yaml +14 -1
  89. package/dist/profiles/cursor.yaml +45 -5
  90. package/dist/profiles/overrides.d.ts +67 -0
  91. package/dist/profiles/overrides.js +190 -0
  92. package/dist/profiles/profile.d.ts +3 -0
  93. package/dist/profiles/profile.js +5 -1
  94. package/dist/state.d.ts +2 -0
  95. package/dist/status/compare.js +7 -4
  96. package/dist/store/store.d.ts +5 -0
  97. package/dist/version.d.ts +1 -0
  98. package/dist/version.js +7 -0
  99. package/dist/watch/checks/budget.js +49 -53
  100. package/dist/watch/dialect.d.ts +6 -1
  101. package/dist/watch/dialect.js +68 -12
  102. package/dist/watch/pass.d.ts +8 -1
  103. package/dist/watch/pass.js +19 -9
  104. package/dist/watch/screen-core.d.ts +41 -4
  105. package/dist/watch/screen-core.js +458 -93
  106. package/dist/watch/screen-data.d.ts +25 -0
  107. package/dist/watch/screen-file.d.ts +8 -2
  108. package/dist/watch/screen-file.js +325 -38
  109. package/dist/watch/screen-profile.d.ts +19 -0
  110. package/dist/watch/screen-profile.js +1 -0
  111. package/dist/watch/screen.d.ts +22 -4
  112. package/dist/watch/screen.js +39 -5
  113. package/package.json +9 -3
  114. package/schema/team.schema.json +498 -0
package/dist/herdr.d.ts CHANGED
@@ -27,6 +27,7 @@ export declare function paneRun(pane: string, command: string, session?: string)
27
27
  export declare function agentRename(pane: string, name: string, session?: string): boolean;
28
28
  export declare function workspaceClose(workspace: string, session?: string): boolean;
29
29
  export declare function sessionStop(name: string): boolean;
30
+ export declare function sessionDelete(name: string): boolean;
30
31
  export type PaneExec = (args: string[]) => string;
31
32
  /** Swaps the process call paneRead makes; null restores the real one. */
32
33
  export declare function setPaneExec(exec: PaneExec | null): void;
package/dist/herdr.js CHANGED
@@ -171,7 +171,7 @@ export function workspaceClose(workspace, session) {
171
171
  const body = parsed(out);
172
172
  return body !== null && body.type !== 'error';
173
173
  }
174
- // Stops a session. Never deletes one: clearing a stopped session is the owner's command to run.
174
+ // Stops a session, which herdr keeps listed as stopped.
175
175
  export function sessionStop(name) {
176
176
  const out = capture(['session', 'stop', name]);
177
177
  if (out === null)
@@ -179,6 +179,16 @@ export function sessionStop(name) {
179
179
  const body = parsed(out);
180
180
  return body === null || body.type !== 'error';
181
181
  }
182
+ // Clears a stopped session — the one case `team` deletes: `down`, on the session it has itself
183
+ // just stopped in the same run, so the next `up` starts from the beginning. `up` never deletes:
184
+ // its refusal names this command and leaves it to the owner.
185
+ export function sessionDelete(name) {
186
+ const out = capture(['session', 'delete', name]);
187
+ if (out === null)
188
+ return false;
189
+ const body = parsed(out);
190
+ return body !== null && body.type !== 'error';
191
+ }
182
192
  const shellPaneExec = (args) => execFileSync('herdr', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
183
193
  let paneExec = shellPaneExec;
184
194
  /** Swaps the process call paneRead makes; null restores the real one. */
@@ -2,9 +2,10 @@
2
2
  // pane, or the shell left after the CLI has exited, is not one: those are the panes
3
3
  // a nudge, a first message, or an exit must not be typed into.
4
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.
5
+ // Null is not a live agent, so nothing is typed, and the watch's quota read asks this
6
+ // same question: a figure is only read where the CLI was seen. `paneStillRunning`
7
+ // reads that same null as still there — an unreadable list is not a departure — and
8
+ // that reading is only for the departure wait in `down` and `remove`.
8
9
  export function reportedLiveAgent(foreground, processNames) {
9
10
  if (!foreground || processNames.length === 0)
10
11
  return false;
@@ -8,5 +8,9 @@ export interface Delivery {
8
8
  now(): number;
9
9
  sleep(ms: number): Promise<void>;
10
10
  }
11
+ /** Whether the pane's box holds exactly `text` right now: an unsent composer, no fold marker, and
12
+ * rows that read back as the text. The one check the watch's nudge and the exit typing share
13
+ * with delivery — false for anything not observed, never `true` on trust. */
14
+ export declare function boxHoldsText(cli: string, text: string, screen: string | undefined): boolean;
11
15
  /** A first message is accepted only after working is observed with the composer empty again. */
12
16
  export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean | 'no-agent'>;
@@ -1,6 +1,146 @@
1
1
  import { reportedLiveAgent } from "./agent.js";
2
2
  import { profileFor } from "../profiles/index.js";
3
- import { classifyComposer, readScreen } from "../watch/screen.js";
3
+ import { classifyComposer, readBox, readFold, readFoldMark, readScreen } from "../watch/screen.js";
4
+ /** The rows a composer shows for `text` at `width` columns: a hard wrap, a line longer than the
5
+ * width split into width-sized chunks, no word wrapping. The fold's tail and count are checked
6
+ * against this model; the fixtures README names the same one. */
7
+ function rowsOf(text, width) {
8
+ const rows = [];
9
+ for (const line of text.split('\n')) {
10
+ if (line.length === 0) {
11
+ rows.push('');
12
+ continue;
13
+ }
14
+ for (let at = 0; at < line.length; at += width)
15
+ rows.push(line.slice(at, at + width));
16
+ }
17
+ return rows;
18
+ }
19
+ /** Whether the folded box is exactly `text` at the width the box reports: its visible tail rows
20
+ * are the text's last rows, and its hidden-row count closes the gap. Anything else is not the
21
+ * typed text and gets no Enter. */
22
+ function holdsText(text, fold) {
23
+ if (fold.width <= 0 || fold.rows.length === 0)
24
+ return false;
25
+ const all = rowsOf(text, fold.width);
26
+ if (fold.rows.length >= all.length)
27
+ return false;
28
+ if (fold.count !== all.length - fold.rows.length)
29
+ return false;
30
+ const tail = all.slice(-fold.rows.length);
31
+ return fold.rows.every((row, i) => row.trimEnd() === (tail[i] ?? '').trimEnd());
32
+ }
33
+ /** The whitespace a row break may stand for: the pane replaces it by the break, or adds the
34
+ * continuation row's indentation where the text has none. */
35
+ const BREAK = /[ \t\n\r]/;
36
+ /** Where the text's next row starts after `pos`, or null when `row` is not the text's row there.
37
+ * A row that is the text's at that very position is always read as such — that normalises
38
+ * nothing. A `word` boundary additionally skips the whitespace run the pane broke at before the
39
+ * row; a `hard` one reads the text's next characters as the row carries them; a composer with no
40
+ * rule (`null`) reads either way, since no capture showed how that one wraps. Every character
41
+ * inside a row must match the text as it is: only the row break may stand for whitespace. */
42
+ function continues(text, pos, row, kind) {
43
+ if (text.startsWith(row, pos))
44
+ return pos + row.length;
45
+ if (kind === 'hard')
46
+ return null;
47
+ let at = pos;
48
+ while (at < text.length && BREAK.test(text[at] ?? ''))
49
+ at++;
50
+ return at > pos && text.startsWith(row, at) ? at + row.length : null;
51
+ }
52
+ /** Where a blank row sits in the typed text: the pane draws an empty row only for a blank line
53
+ * the text itself has. The line's own content — nothing a box row shows, so only spaces and
54
+ * tabs, which a pane need not paint — is consumed; an interior blank line ends at the next
55
+ * newline, a blank last line ends with the text. A blank row anywhere else is not the text's
56
+ * own and is refused, and a blank line the box does not show a row for is refused after the
57
+ * last row (the trailing-newline check in `holdsBox`). */
58
+ function blankLine(text, pos) {
59
+ if (text[pos] !== '\n')
60
+ return null;
61
+ let at = pos + 1;
62
+ while (at < text.length && (text[at] === ' ' || text[at] === '\t'))
63
+ at++;
64
+ return at === text.length || text[at] === '\n' ? at : null;
65
+ }
66
+ /** Whether the box shows exactly `text`: the first line after the prompt and every continuation
67
+ * row at the box's own column — no more rows, no fewer, none changed. A continuation row that
68
+ * does not start at that column is not the text's own row and the box is not trusted. Only the
69
+ * whitespace a row break itself may stand for is normalised — the run a word break was made at,
70
+ * or a blank line the text itself has — and inside a row every character must match, runs of
71
+ * spaces included. A box that is not the text waits and is refused at the deadline, never
72
+ * entered on trust. */
73
+ function holdsBox(text, box) {
74
+ const pad = ' '.repeat(box.indent);
75
+ const rows = [];
76
+ for (const row of box.rows) {
77
+ if (row === '') {
78
+ rows.push('');
79
+ continue;
80
+ }
81
+ if (!row.startsWith(pad))
82
+ return false;
83
+ rows.push(row.slice(pad.length));
84
+ }
85
+ const kind = box.wrap?.kind ?? null;
86
+ const visual = [box.first, ...rows];
87
+ let at = 0;
88
+ for (let i = 0; i < visual.length; i++) {
89
+ const row = visual[i] ?? '';
90
+ if (i === 0) {
91
+ if (!text.startsWith(row))
92
+ return false;
93
+ at = row.length;
94
+ continue;
95
+ }
96
+ if (row === '') {
97
+ const next = blankLine(text, at);
98
+ if (next === null)
99
+ return false;
100
+ at = next;
101
+ continue;
102
+ }
103
+ const next = continues(text, at, row, kind);
104
+ if (next === null)
105
+ return false;
106
+ at = next;
107
+ }
108
+ // What is left after the box's last row. Trailing spaces and tabs are not observable in a
109
+ // box — a pane may not paint the end of a row — so they are not required to match. A newline
110
+ // is observable: it is the box's next row, and every row has been read above; one left over
111
+ // is a row the box does not show, and the box is not the typed text.
112
+ return /^[ \t]*$/.test(text.slice(at));
113
+ }
114
+ /** What the box holds right now: `ready` to submit, still rendering (`wait`), or a state that is
115
+ * never the typed text (`no`). A folded box — and one showing a fold marker whatever its count —
116
+ * is ready only when its tail and hidden-row count are the ones the text renders to at the width
117
+ * the box reports. An ordinary box is ready only when its rows read back as exactly the text
118
+ * typed; anything else waits, and a wait that outlives the deadline is a refusal, not an Enter. */
119
+ function boxState(cli, text, screen) {
120
+ const kind = readScreen(cli, screen).kind;
121
+ const fold = readFold(cli, screen);
122
+ if (fold || readFoldMark(cli, screen)) {
123
+ if (kind !== 'unsent' && kind !== 'unknown')
124
+ return 'no';
125
+ return fold !== null && holdsText(text, fold) ? 'ready' : 'wait';
126
+ }
127
+ if (kind === 'unsent') {
128
+ const box = readBox(cli, screen);
129
+ return box !== null && holdsBox(text, box) ? 'ready' : 'wait';
130
+ }
131
+ return kind === 'idle' ? 'wait' : 'no';
132
+ }
133
+ /** Whether the pane's box holds exactly `text` right now: an unsent composer, no fold marker, and
134
+ * rows that read back as the text. The one check the watch's nudge and the exit typing share
135
+ * with delivery — false for anything not observed, never `true` on trust. */
136
+ export function boxHoldsText(cli, text, screen) {
137
+ if (readFoldMark(cli, screen))
138
+ return false;
139
+ if (readScreen(cli, screen).kind !== 'unsent')
140
+ return false;
141
+ const box = readBox(cli, screen);
142
+ return box !== null && holdsBox(text, box);
143
+ }
4
144
  /** A first message is accepted only after working is observed with the composer empty again. */
5
145
  export async function deliverRules(cli, text, seconds, io) {
6
146
  const names = profileFor(cli)?.processNames ?? [];
@@ -11,25 +151,28 @@ export async function deliverRules(cli, text, seconds, io) {
11
151
  if (!free() || readScreen(cli, io.screen()).kind !== 'idle' || !io.type(text))
12
152
  return false;
13
153
  const deadline = io.now() + seconds * 1000;
14
- // Terminal rendering can lag send-text. Never send Enter until the pasted text is visible.
154
+ // Terminal rendering can lag send-text. Never press Enter until the box is verified to hold the
155
+ // text: an ordinary box reads `unsent`, and a box that folded the paste counts only when the
156
+ // tail and hidden-row count it shows are the ones the typed text renders to.
15
157
  for (;;) {
16
- const kind = readScreen(cli, io.screen()).kind;
17
158
  if (!free())
18
159
  return false;
19
- if (kind === 'unsent')
160
+ const state = boxState(cli, text, io.screen());
161
+ if (state === 'ready')
20
162
  break;
21
- if (kind !== 'idle' || io.now() >= deadline)
163
+ if (state === 'no' || io.now() >= deadline)
22
164
  return false;
23
165
  const before = io.now();
24
166
  await io.sleep(100);
25
167
  if (io.now() <= before)
26
168
  return false;
27
169
  }
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.
170
+ // Re-read immediately before Enter. The agent is asked again: it may have exited since the
171
+ // paste, and a dialog that appeared gets no key; a folded box that no longer matches the text
172
+ // gets none either.
30
173
  if (!live())
31
174
  return 'no-agent';
32
- if (!free() || readScreen(cli, io.screen()).kind !== 'unsent' || !io.enter())
175
+ if (!free() || boxState(cli, text, io.screen()) !== 'ready' || !io.enter())
33
176
  return false;
34
177
  for (;;) {
35
178
  const status = io.status();
@@ -16,6 +16,8 @@ export type Host = {
16
16
  renameAgent(session: string, pane: string, name: string): boolean;
17
17
  closeWorkspace(session: string, workspace: string): boolean;
18
18
  stopSession(session: string): boolean;
19
+ /** Clears the session this run has just stopped. Present on `down` only: `up` never deletes. */
20
+ deleteSession?(session: string): boolean;
19
21
  kill(pid: number): boolean;
20
22
  /** Pane ids herdr lists an agent in, or null when the list can't be read. */
21
23
  agentPanes(session: string): string[] | null;
@@ -29,14 +29,15 @@ export async function executePlan(steps, session, host) {
29
29
  host.say(`${seat}: ${what}\n`);
30
30
  host.log(seat, what);
31
31
  };
32
- const place = (label, pane, workspace) => {
33
- const have = places.get(label);
32
+ // The display label may be shared. The seat's name is the only key; the watch has no seat.
33
+ const place = (key, pane, workspace) => {
34
+ const have = places.get(key);
34
35
  if (have)
35
36
  return have;
36
37
  if (!pane)
37
38
  return null;
38
39
  const made = { pane, workspace };
39
- places.set(label, made);
40
+ places.set(key, made);
40
41
  return made;
41
42
  };
42
43
  for (const step of steps) {
@@ -116,7 +117,7 @@ export async function executePlan(steps, session, host) {
116
117
  }
117
118
  break;
118
119
  }
119
- places.set(op.label, made);
120
+ places.set(op.seat ?? op.label, made);
120
121
  if (op.seat) {
121
122
  host.record(op.seat, { stage: 'launched', pane: made.pane, workspace: made.workspace });
122
123
  host.running(op.seat);
@@ -132,7 +133,7 @@ export async function executePlan(steps, session, host) {
132
133
  finish(op.seat, why);
133
134
  break;
134
135
  }
135
- const here = place(op.label, op.pane);
136
+ const here = place(op.seat, op.pane);
136
137
  if (!here || !host.paneRun(session, here.pane, op.command)) {
137
138
  dropped.add(op.seat);
138
139
  finish(op.seat, 'its launch command did not run; left at launched');
@@ -144,7 +145,7 @@ export async function executePlan(steps, session, host) {
144
145
  case 'idle': {
145
146
  if (op.notice)
146
147
  host.say(`${op.seat}: ${op.notice}\n`);
147
- const here = place(op.label, op.pane, op.workspace);
148
+ const here = place(op.seat, op.pane, op.workspace);
148
149
  if (!here) {
149
150
  dropped.add(op.seat);
150
151
  finish(op.seat, 'has no pane to read; left at launched');
@@ -167,7 +168,7 @@ export async function executePlan(steps, session, host) {
167
168
  }
168
169
  if (outcome === 'idle')
169
170
  break;
170
- const workspace = here.workspace ?? places.get(op.label)?.workspace;
171
+ const workspace = here.workspace ?? places.get(op.seat)?.workspace;
171
172
  if (outcome === 'permission' || outcome === 'trust' || outcome === 'question') {
172
173
  // A trust question is closed with no key and no text. The same for a permission or a question.
173
174
  if (workspace)
@@ -185,7 +186,7 @@ export async function executePlan(steps, session, host) {
185
186
  break;
186
187
  }
187
188
  case 'rename': {
188
- const here = place(op.label, op.pane);
189
+ const here = place(op.seat, op.pane);
189
190
  if (!here) {
190
191
  dropped.add(op.seat);
191
192
  finish(op.seat, 'has no pane to name; left at launched');
@@ -207,7 +208,7 @@ export async function executePlan(steps, session, host) {
207
208
  case 'deliver': {
208
209
  if (op.notice)
209
210
  host.say(`${op.seat}: ${op.notice}\n`);
210
- const here = place(op.label, op.pane);
211
+ const here = place(op.seat, op.pane);
211
212
  const delivered = here ? await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds) : false;
212
213
  if (delivered === 'no-agent') {
213
214
  dropped.add(op.seat);
@@ -283,10 +284,19 @@ export async function executePlan(steps, session, host) {
283
284
  host.say(`session ${op.session}: not stopped, something was left in it\n`);
284
285
  break;
285
286
  }
286
- if (!host.stopSession(op.session))
287
+ if (!host.stopSession(op.session)) {
287
288
  host.say(`session ${op.session}: it did not stop\n`);
288
- else
289
+ break;
290
+ }
291
+ // Without a `deleteSession` the stop is the whole step. `down` goes one further and clears
292
+ // the session it has itself just stopped; a clear that does not happen names the command.
293
+ if (host.deleteSession === undefined)
289
294
  host.say(`session ${op.session}: stopped\n`);
295
+ else if (host.deleteSession(op.session))
296
+ host.say(`session ${op.session}: stopped and cleared\n`);
297
+ else {
298
+ host.say(`session ${op.session}: stopped; it did not clear, run \`herdr session delete ${op.session}\`\n`);
299
+ }
290
300
  break;
291
301
  }
292
302
  default:
@@ -46,7 +46,7 @@ export function upPlan(input) {
46
46
  steps.push({ kind: 'skip', text: `${seat.name}: already ready; left as it is` });
47
47
  continue;
48
48
  }
49
- const pane = seat.pane ?? paneOf(seat.label);
49
+ const pane = seat.pane ?? paneOf(seat.name);
50
50
  const cwd = join(input.root, seat.cwd);
51
51
  const fresh = seat.stage === undefined || !seat.pane;
52
52
  // The same condition as the launch step below. A seat already running keeps
@@ -202,6 +202,15 @@ export function downPlan(input) {
202
202
  });
203
203
  continue;
204
204
  }
205
+ if (seat.cli === 'unknown') {
206
+ steps.push({
207
+ kind: 'skip',
208
+ text: `${seat.name}: the state doesn't say which CLI it runs, so it can't be asked to exit; ` +
209
+ 'left running (`team down --abandon` closes it without typing)',
210
+ });
211
+ left++;
212
+ continue;
213
+ }
205
214
  steps.push({
206
215
  kind: 'skip',
207
216
  text: `${seat.name}: no launch profile for \`${seat.cli}\` in this version; left running`,
@@ -259,7 +268,12 @@ export function downPlan(input) {
259
268
  });
260
269
  }
261
270
  else {
262
- steps.push({ kind: 'run', argv: ['herdr', 'session', 'stop', session], do: { do: 'stop', session } });
271
+ steps.push({
272
+ kind: 'run',
273
+ argv: ['herdr', 'session', 'stop', session],
274
+ note: 'stopped, then cleared: the session this run stopped, so a later `up` starts from the beginning',
275
+ do: { do: 'stop', session },
276
+ });
263
277
  }
264
278
  return steps;
265
279
  }
@@ -68,8 +68,18 @@ screen:
68
68
  composer:
69
69
  mode: two-rules-footer-below
70
70
  ignore_case: true
71
- prompt: '^\s*>\s*'
72
- rule: '^\s*[─━]{8,}\s*$'
71
+ # The input row is the box's first row under its opening rule, carrying the prompt at the
72
+ # pane's first column — unsent.txt draws `> Rules...` there, content at the second — and a
73
+ # prompt glyph on a continuation row is content, never the input row. The reader anchors
74
+ # this pattern to the captured column.
75
+ prompt: '^>\s*'
76
+ # Both rules are unbroken runs of ─ from the pane's first column (unsent.txt draws them at
77
+ # rows 7 and 12). A rule-looking row the box holds is drawn at the content column and is
78
+ # content, never the frame.
79
+ rule: '^[─━]{8,}\s*$'
80
+ # A long paste folds: the row reads `↑ N more lines` (with the prompt glyph on the
81
+ # input row's own marker), the rows below it are the tail. Capture 1 is N.
82
+ fold: '^\s*(?:>\s*)?↑\s*([0-9]+)\s+more lines\s*$'
73
83
  footers:
74
84
  - '\? for shortcuts'
75
85
  - 'Gemini\s+[0-9]'
@@ -1,6 +1,6 @@
1
- # Claude Code's screen. The questions are written out in both cases: the old
2
- # classifier ignored case on those two lines and not on the "1. Yes" choice, and
3
- # ignore_case would apply to the whole rule.
1
+ # Claude Code's screen. The trust question carries ignore_case on its own pattern:
2
+ # the old classifier ignored case on those lines and not on the "1. Yes" choice,
3
+ # and a rule-wide or stage-wide flag would have reached the choice too.
4
4
  format: 1
5
5
  cli: claude-code
6
6
  binary: claude
@@ -39,8 +39,12 @@ status_model:
39
39
  screen:
40
40
  trust:
41
41
  - all:
42
- - '(?:[Dd][Oo] [Yy][Oo][Uu] [Tt][Rr][Uu][Ss][Tt] (?:[Tt][Hh][Ii][Ss]|[Tt][Hh][Ee]) [Ff][Oo][Ll][Dd][Ee][Rr]\?|[Oo][Nn][Ee] [Yy][Oo][Uu] [Tt][Rr][Uu][Ss][Tt]\?)'
42
+ - match: '(?:Do you trust (?:this|the) folder\?|One you trust\?)'
43
+ ignore_case: true
43
44
  - '^\s*[❯›>]?\s*1\.\s+Yes\b'
45
+ - all:
46
+ - '^\s*[❯›>]?\s*Yes, I trust this folder\b'
47
+ footer: 'Enter to confirm · Esc to cancel'
44
48
  # A quoted question above the composer's rule is not a dialog. The footer is the
45
49
  # captured last line and needs the question. A Yes/No choice below the last rule
46
50
  # is the other shape; the mark is optional, as on an unmarked "2. No".
@@ -88,8 +92,19 @@ screen:
88
92
  - '^[^\S\n]*[✢✳✶✻✼✽][^\n]*…\s*\(\s*\d'
89
93
  composer:
90
94
  mode: box-to-rule
91
- prompt: '^\s*[❯›>]'
92
- rule: '^\s*[─━]{8,}'
95
+ # The prompt sits at the pane's first column, as unsent-typed-ansi.txt draws `❯ Fix the`;
96
+ # the pane draws continuation rows at the content column — two spaces — so a prompt glyph
97
+ # on one of those is content, never the input row. The reader takes the box's first row
98
+ # under its opening rule and anchors this pattern to the captured column: a row the box
99
+ # holds can never be mistaken for the input.
100
+ prompt: '^[❯›>]'
101
+ # The box's two rules, as unsent-typed-ansi.txt draws them: one unbroken run of ─ from the
102
+ # pane's first column, the closing rule the window's last rule row. A rule-looking row the
103
+ # box holds is drawn at the content column and is content, never the frame.
104
+ rule: '^[─━]{8,}$'
105
+ footers:
106
+ - '^\s{2,}.*·.*(?:Opus|Sonnet|Haiku|Fable)\s+[0-9]+.*$'
107
+ - '^\s{2,}(?:⏵⏵\s+)?bypass permissions\b.*$'
93
108
  # Claude Code greys every suggestion it shows — "Try …", "Resume briefly.", any other —
94
109
  # with faint styling, while typed text carries none. The list below stays the fallback
95
110
  # for a source without styling.
@@ -44,6 +44,9 @@ status_model:
44
44
  model: 'GPT {2:title}'
45
45
  version: '{1}'
46
46
  # A later footer that is not one of those four names is unread, as the old id map was.
47
+ # The `{1}` names a group this pattern does not have, on purpose: a template that cannot
48
+ # fill reads the line as naming no model. There is no plainer spelling — an empty string
49
+ # is refused and a literal would read as a found model, not as unread.
47
50
  - match: '^\s+GPT-[0-9][\w.-]*\s[^·]*·'
48
51
  model: '{1}'
49
52
  version: '{1}'
@@ -71,7 +74,17 @@ screen:
71
74
  composer:
72
75
  mode: status-last
73
76
  status_line: '^\s+GPT-[0-9][\w.-]*\s+[^·]*·'
74
- prompt: '^\s*›(?:\s|$)'
77
+ # The input row carries the prompt at the pane's first column — unsent.txt draws `› Rules.`
78
+ # there, content on the rows below at the second, and exit-typed.txt keeps the old
79
+ # transcript and menu rows above it — and a prompt glyph on a content row is never the
80
+ # input row. The read scans up from the status line and anchors this pattern to the
81
+ # captured column.
82
+ prompt: '^›(?:\s|$)'
75
83
  placeholders:
76
84
  - equals: ''
77
85
  - equals: Ask Codex to do anything
86
+ # unsent.txt and exit-typed.txt show one empty row under the text, between it and the
87
+ # status line: the drop the pane draws of its own, inside the box's frame. The box read
88
+ # strips up to one trailing empty row as that frame; a row beyond it is one the typed
89
+ # text does not have.
90
+ frame_rows: 1
@@ -48,21 +48,61 @@ 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
+ # The running turn is the braille spinner (whatever verb it paints), the
52
+ # prompt line ending in "ctrl+c to stop", or the follow-up queue: the box's
53
+ # `enter …` hint row with, below it, the composer's own row — empty, or its
54
+ # placeholder. The order is the frame's: what follows the hint row may be
55
+ # chrome or that unpainted composer row and nothing else, so a typed row
56
+ # under the box falls to the composer read and says unsent. A transcript
57
+ # that quotes the box paints its prose lines there, so the queue rule reads
58
+ # them as content and leaves the screen alone. Idle and rules-accepted
59
+ # captures paint none of these.
54
60
  - any:
55
61
  - '^\s*→.*\s{2,}ctrl\+c to stop\s*$'
56
- - '^\s*[⠀-⣿]+\s+(Working|Thinking)\b'
62
+ - '^\s*[⠀-⣿]+\s+\S'
63
+ - all:
64
+ - '^\s*│\s*enter (?:steer|interrupt and send) · ↑ select/edit · esc cancel\s*│\s*$'
65
+ - '^\s*→\s*(?:Add a follow-up|Plan, search, build anything)?\s*$'
66
+ none_after:
67
+ anchor: '^\s*│\s*enter (?:steer|interrupt and send) · ↑ select/edit · esc cancel\s*│\s*$'
68
+ patterns:
69
+ - match: '^\s*\S'
70
+ except:
71
+ - '^\s*[│┌└─┐┘├┤]'
72
+ - '^\s*[⠀-⣿]'
73
+ - '^\s*Tip:'
74
+ - '^\s*→\s*(?:Add a follow-up|Plan, search, build anything)?\s*$'
75
+ - '^\s+Grok\s'
76
+ - '^\s*<workspace>'
77
+ - '^\s*~/'
78
+ - '^\s*❯'
57
79
  composer:
58
80
  mode: status-then-one
59
81
  status_line: '^\s+Grok\s+[0-9]'
60
- prompt: '^\s*→'
82
+ # The input row carries the prompt at the pane's second column — unsent.txt and
83
+ # follow-up-queue-typed.txt draw ` → Plan, search, build anything` there, continuations at
84
+ # the fourth — and a prompt glyph on a content or popup row is never the input row. The
85
+ # read scans up from the status line and anchors this pattern to the captured column.
86
+ prompt: '^ {2}→'
61
87
  strip_suffix: '\s*ctrl\+c to stop\s*$'
62
88
  placeholders:
63
89
  - equals: ''
64
90
  - equals: Plan, search, build anything
65
91
  - equals: Add a follow-up
92
+ # A line wider than the pane's content column continues on the next row at the text
93
+ # column, broken at a space: unsent.txt is its sentence word-wrapped this way on a
94
+ # 51-column pane, and the box is compared with the typed text before any Enter. Only
95
+ # the whitespace the row break itself stands for is normalised — the run the break was
96
+ # made at, or a blank line the text itself has; inside a row, every character must
97
+ # match, runs of spaces included.
98
+ wrap:
99
+ continuation: text-column
100
+ kind: word
101
+ # unsent.txt and follow-up-queue-typed.txt show two empty rows under the text, between
102
+ # it and the status line: the drop the pane draws of its own, inside the box's frame.
103
+ # The box read strips up to two trailing empty rows as that frame; a row beyond them is
104
+ # one the typed text does not have.
105
+ frame_rows: 2
66
106
  fallback:
67
107
  - all:
68
108
  - match: '^\s*→\s+\S'
@@ -0,0 +1,67 @@
1
+ import type { QuotaPattern } from './quota.ts';
2
+ import type { ScreenData, Stage } from '../watch/screen-data.ts';
3
+ import { type Screen } from '../watch/screen.ts';
4
+ export declare const DIALOG_STAGES: readonly ["unknown", "trust", "permission", "question"];
5
+ export type DialogStage = (typeof DIALOG_STAGES)[number];
6
+ export type ProfileOverride = {
7
+ cli: string;
8
+ screen: Partial<Record<DialogStage, Stage>>;
9
+ quota: QuotaPattern[];
10
+ };
11
+ export type OverrideProblem = {
12
+ line: number;
13
+ message: string;
14
+ };
15
+ export type OverrideParse = {
16
+ ok: true;
17
+ profiles: ProfileOverride[];
18
+ } | {
19
+ ok: false;
20
+ errors: OverrideProblem[];
21
+ };
22
+ /** `<store>/overrides.yaml`, the same folder as the approval for this root. */
23
+ export declare function overridesPath(project: string, root: string, home: string): string;
24
+ /** The file's added patterns, or the line that refuses it. One error, the first. */
25
+ export declare function parseOverrides(text: string): OverrideParse;
26
+ /**
27
+ * Shipped rules stay first, and the added ones follow. The composer and the
28
+ * chrome are the shipped profile's own objects, so an override cannot retune
29
+ * them by merging.
30
+ */
31
+ export declare function mergeScreen(base: ScreenData, added: ProfileOverride): ScreenData;
32
+ /**
33
+ * An added pattern may move a reading toward a dialog. It may not make a screen
34
+ * idle or unsent when the shipped profile does not, and it may not stop a
35
+ * shipped permission, trust or question pattern from matching.
36
+ */
37
+ export declare function guardReading(shipped: Screen, merged: Screen): Screen;
38
+ /** The shipped reading, then the same window with this profile's added patterns, guarded. */
39
+ export declare function classifyWith(cli: string, screen: string | undefined, profiles: readonly ProfileOverride[]): Screen;
40
+ /** Shipped quota patterns, then the override's. The shipped ones are never dropped. */
41
+ export declare function quotaWith(cli: string, profiles: readonly ProfileOverride[]): readonly QuotaPattern[];
42
+ /** What `approve` records when the override file itself is the change. */
43
+ export declare const OVERRIDE_CHANGED = "`overrides` changed";
44
+ /** A stored copy that does not parse. The shipped profiles stay in force. */
45
+ export declare const OVERRIDE_UNREADABLE = "the approved overrides can't be read";
46
+ export type OverrideForce = {
47
+ /** The approved patterns. Empty when none were approved, or the copy cannot be read. */
48
+ profiles: ProfileOverride[];
49
+ /** Drift from the approved copy. Empty when the file is the approved one, or there is none. */
50
+ differences: string[];
51
+ /** The live file, when it cannot be read or parsed. Already carrying the path and the line. */
52
+ problems: string[];
53
+ };
54
+ /** The override file's text, and why it was refused. A missing file is an empty text and no problem. */
55
+ export declare function overrideFile(project: string, root: string, home: string): {
56
+ path: string;
57
+ text: string | null;
58
+ problems: string[];
59
+ };
60
+ /**
61
+ * The patterns in force for this project. Keyed on the project root, the same
62
+ * store as the team-file approval: another checkout of the same name has its
63
+ * own. No file, or a stored copy that cannot be read, leaves the shipped profiles.
64
+ * The live file is used only when it is the text `approve` recorded.
65
+ */
66
+ export declare function overridesInForce(project: string, root: string, home: string): OverrideForce;
67
+ export declare function overrideProblem(path: string, problem: OverrideProblem): string;