team 0.1.1 → 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 (127) hide show
  1. package/README.md +38 -12
  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 +80 -8
  6. package/dist/approve/fingerprint.d.ts +29 -6
  7. package/dist/approve/fingerprint.js +57 -32
  8. package/dist/budgets/gate.d.ts +5 -4
  9. package/dist/budgets/gate.js +23 -14
  10. package/dist/budgets/readings.d.ts +57 -9
  11. package/dist/budgets/readings.js +134 -18
  12. package/dist/budgets/run.js +8 -2
  13. package/dist/budgets/table.d.ts +4 -2
  14. package/dist/budgets/table.js +29 -11
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.js +8 -5
  17. package/dist/commands/add.d.ts +0 -1
  18. package/dist/commands/add.js +18 -9
  19. package/dist/commands/approve.js +17 -1
  20. package/dist/commands/doctor.d.ts +17 -1
  21. package/dist/commands/doctor.js +128 -8
  22. package/dist/commands/down.d.ts +6 -2
  23. package/dist/commands/down.js +40 -14
  24. package/dist/commands/init.d.ts +1 -1
  25. package/dist/commands/init.js +4 -2
  26. package/dist/commands/remove.d.ts +2 -0
  27. package/dist/commands/remove.js +26 -4
  28. package/dist/commands/status.d.ts +2 -0
  29. package/dist/commands/status.js +22 -3
  30. package/dist/commands/up.d.ts +3 -1
  31. package/dist/commands/up.js +12 -6
  32. package/dist/commands/watch.d.ts +4 -0
  33. package/dist/commands/watch.js +99 -19
  34. package/dist/conformance/adapter.d.ts +5 -0
  35. package/dist/conformance/adapter.js +44 -0
  36. package/dist/file/check.d.ts +20 -0
  37. package/dist/file/check.js +112 -0
  38. package/dist/file/lines.js +13 -2
  39. package/dist/file/sections/budgets.d.ts +15 -0
  40. package/dist/file/sections/budgets.js +180 -0
  41. package/dist/file/sections/coordinator.d.ts +2 -0
  42. package/dist/file/sections/coordinator.js +11 -0
  43. package/dist/file/sections/format.d.ts +2 -0
  44. package/dist/file/sections/format.js +13 -0
  45. package/dist/file/sections/identity.d.ts +2 -0
  46. package/dist/file/sections/identity.js +103 -0
  47. package/dist/file/sections/index.d.ts +9 -0
  48. package/dist/file/sections/index.js +43 -0
  49. package/dist/file/sections/lead.d.ts +4 -0
  50. package/dist/file/sections/lead.js +18 -0
  51. package/dist/file/sections/limits.d.ts +2 -0
  52. package/dist/file/sections/limits.js +43 -0
  53. package/dist/file/sections/machine.d.ts +2 -0
  54. package/dist/file/sections/machine.js +38 -0
  55. package/dist/file/sections/operator.d.ts +2 -0
  56. package/dist/file/sections/operator.js +11 -0
  57. package/dist/file/sections/project.d.ts +2 -0
  58. package/dist/file/sections/project.js +13 -0
  59. package/dist/file/sections/rules.d.ts +2 -0
  60. package/dist/file/sections/rules.js +9 -0
  61. package/dist/file/sections/seats.d.ts +7 -0
  62. package/dist/file/sections/seats.js +161 -0
  63. package/dist/file/sections/section.d.ts +32 -0
  64. package/dist/file/sections/section.js +7 -0
  65. package/dist/file/sections/session.d.ts +2 -0
  66. package/dist/file/sections/session.js +16 -0
  67. package/dist/file/sections/tools.d.ts +2 -0
  68. package/dist/file/sections/tools.js +43 -0
  69. package/dist/file/sections/trust.d.ts +2 -0
  70. package/dist/file/sections/trust.js +20 -0
  71. package/dist/file/sections/units.d.ts +16 -0
  72. package/dist/file/sections/units.js +8 -0
  73. package/dist/file/sections/visibility.d.ts +2 -0
  74. package/dist/file/sections/visibility.js +12 -0
  75. package/dist/file/sections/watch-checks.d.ts +9 -0
  76. package/dist/file/sections/watch-checks.js +14 -0
  77. package/dist/file/sections/watch.d.ts +14 -0
  78. package/dist/file/sections/watch.js +111 -0
  79. package/dist/file/sections/workspace.d.ts +4 -0
  80. package/dist/file/sections/workspace.js +77 -0
  81. package/dist/file/types.d.ts +6 -0
  82. package/dist/file/validate.d.ts +4 -12
  83. package/dist/file/validate.js +45 -668
  84. package/dist/herdr.d.ts +5 -0
  85. package/dist/herdr.js +51 -12
  86. package/dist/launch/agent.d.ts +1 -0
  87. package/dist/launch/agent.js +13 -0
  88. package/dist/launch/deliver.d.ts +7 -1
  89. package/dist/launch/deliver.js +159 -7
  90. package/dist/launch/execute.d.ts +4 -2
  91. package/dist/launch/execute.js +36 -13
  92. package/dist/launch/plan.js +16 -2
  93. package/dist/launch/rules.d.ts +4 -2
  94. package/dist/launch/rules.js +17 -3
  95. package/dist/profiles/antigravity.yaml +25 -2
  96. package/dist/profiles/claude-code.yaml +51 -6
  97. package/dist/profiles/codex.yaml +14 -1
  98. package/dist/profiles/cursor.yaml +46 -3
  99. package/dist/profiles/overrides.d.ts +67 -0
  100. package/dist/profiles/overrides.js +190 -0
  101. package/dist/profiles/profile.d.ts +3 -0
  102. package/dist/profiles/profile.js +5 -1
  103. package/dist/state.d.ts +9 -4
  104. package/dist/state.js +37 -0
  105. package/dist/status/compare.js +7 -4
  106. package/dist/status/statusline.js +3 -1
  107. package/dist/store/store.d.ts +5 -0
  108. package/dist/version.d.ts +1 -0
  109. package/dist/version.js +7 -0
  110. package/dist/watch/check.d.ts +1 -0
  111. package/dist/watch/checks/budget.js +50 -51
  112. package/dist/watch/dialect.d.ts +6 -1
  113. package/dist/watch/dialect.js +68 -12
  114. package/dist/watch/pass.d.ts +10 -2
  115. package/dist/watch/pass.js +70 -29
  116. package/dist/watch/screen-core.d.ts +41 -4
  117. package/dist/watch/screen-core.js +510 -89
  118. package/dist/watch/screen-data.d.ts +34 -0
  119. package/dist/watch/screen-file.d.ts +8 -2
  120. package/dist/watch/screen-file.js +344 -39
  121. package/dist/watch/screen-profile.d.ts +19 -0
  122. package/dist/watch/screen-profile.js +1 -0
  123. package/dist/watch/screen.d.ts +25 -6
  124. package/dist/watch/screen.js +48 -9
  125. package/examples/team.yaml +8 -0
  126. package/package.json +9 -3
  127. package/schema/team.schema.json +498 -0
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,10 @@ 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 declare function sessionDelete(name: string): boolean;
31
+ export type PaneExec = (args: string[]) => string;
32
+ /** Swaps the process call paneRead makes; null restores the real one. */
33
+ export declare function setPaneExec(exec: PaneExec | null): void;
29
34
  export declare function paneRead(pane: string, lines: number, session?: string): string | null;
30
35
  export declare function herdrCommand(session: string | undefined, ...args: string[]): string;
31
36
  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;
@@ -167,7 +171,7 @@ export function workspaceClose(workspace, session) {
167
171
  const body = parsed(out);
168
172
  return body !== null && body.type !== 'error';
169
173
  }
170
- // 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.
171
175
  export function sessionStop(name) {
172
176
  const out = capture(['session', 'stop', name]);
173
177
  if (out === null)
@@ -175,15 +179,50 @@ export function sessionStop(name) {
175
179
  const body = parsed(out);
176
180
  return body === null || body.type !== 'error';
177
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
+ }
192
+ const shellPaneExec = (args) => execFileSync('herdr', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
193
+ let paneExec = shellPaneExec;
194
+ /** Swaps the process call paneRead makes; null restores the real one. */
195
+ export function setPaneExec(exec) {
196
+ paneExec = exec ?? shellPaneExec;
197
+ }
178
198
  // The visible lines of a pane, or null. `session` undefined reaches the caller's own server.
199
+ // The lines keep their ANSI styling, CRLF folded to LF: a greyed suggestion and typed text
200
+ // read the same as plain text, and only their styling tells them apart, so the readers that
201
+ // want plain text strip it (`stripSgr`). An herdr without the `--format` option refuses the
202
+ // styled call and is read by the exact call main makes today — no flag at all — where
203
+ // unstyled text can never read as dim and the placeholder list alone decides. A herdr that
204
+ // hangs is not asked twice: one timeout reads null.
179
205
  export function paneRead(pane, lines, session) {
206
+ const full = [...(session ? ['--session', session] : []), 'pane', 'read', pane, '--source', 'visible', '--lines', String(lines)];
207
+ let styled;
180
208
  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 });
209
+ styled = paneExec([...full, '--format', 'ansi']);
183
210
  }
184
- catch {
185
- return null;
211
+ catch (error) {
212
+ if (!(error instanceof Error))
213
+ return null;
214
+ // execFileSync reports its own timeout as an Error with code ETIMEDOUT — signal SIGTERM,
215
+ // status null, and no killed field. Observed under Bun, from a run, not from memory.
216
+ if (error.code === 'ETIMEDOUT')
217
+ return null;
218
+ try {
219
+ return paneExec(full);
220
+ }
221
+ catch {
222
+ return null;
223
+ }
186
224
  }
225
+ return styled.replace(/\r\n/g, '\n').replace(/\r$/, '');
187
226
  }
188
227
  // The words to type for a herdr command, for a repair line.
189
228
  export function herdrCommand(session, ...args) {
@@ -0,0 +1 @@
1
+ export declare function reportedLiveAgent(foreground: readonly string[] | null, processNames: readonly string[]): boolean;
@@ -0,0 +1,13 @@
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, 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`.
9
+ export function reportedLiveAgent(foreground, processNames) {
10
+ if (!foreground || processNames.length === 0)
11
+ return false;
12
+ return foreground.some((name) => processNames.includes(name));
13
+ }
@@ -3,8 +3,14 @@ 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
  }
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;
9
15
  /** 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>;
16
+ export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean | 'no-agent'>;
@@ -1,26 +1,178 @@
1
- import { classifyComposer, readScreen } from "../watch/screen.js";
1
+ import { reportedLiveAgent } from "./agent.js";
2
+ import { profileFor } from "../profiles/index.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
+ }
2
144
  /** A first message is accepted only after working is observed with the composer empty again. */
3
145
  export async function deliverRules(cli, text, seconds, io) {
146
+ const names = profileFor(cli)?.processNames ?? [];
147
+ const live = () => reportedLiveAgent(io.foreground(), names);
4
148
  const free = () => ['idle', 'done'].includes(io.status() ?? '');
149
+ if (!live())
150
+ return 'no-agent';
5
151
  if (!free() || readScreen(cli, io.screen()).kind !== 'idle' || !io.type(text))
6
152
  return false;
7
153
  const deadline = io.now() + seconds * 1000;
8
- // 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.
9
157
  for (;;) {
10
- const kind = readScreen(cli, io.screen()).kind;
11
158
  if (!free())
12
159
  return false;
13
- if (kind === 'unsent')
160
+ const state = boxState(cli, text, io.screen());
161
+ if (state === 'ready')
14
162
  break;
15
- if (kind !== 'idle' || io.now() >= deadline)
163
+ if (state === 'no' || io.now() >= deadline)
16
164
  return false;
17
165
  const before = io.now();
18
166
  await io.sleep(100);
19
167
  if (io.now() <= before)
20
168
  return false;
21
169
  }
22
- // Re-read immediately before Enter; a dialog that appeared after the paste gets no key.
23
- if (!free() || readScreen(cli, io.screen()).kind !== 'unsent' || !io.enter())
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.
173
+ if (!live())
174
+ return 'no-agent';
175
+ if (!free() || boxState(cli, text, io.screen()) !== 'ready' || !io.enter())
24
176
  return false;
25
177
  for (;;) {
26
178
  const status = io.status();
@@ -11,11 +11,13 @@ 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;
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,8 +208,14 @@ 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
- if (!here || !await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds)) {
211
+ const here = place(op.seat, op.pane);
212
+ const delivered = here ? await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds) : false;
213
+ if (delivered === 'no-agent') {
214
+ dropped.add(op.seat);
215
+ finish(op.seat, 'no live agent in its pane; its rules were not delivered');
216
+ break;
217
+ }
218
+ if (!here || !delivered) {
212
219
  dropped.add(op.seat);
213
220
  finish(op.seat, 'its rules were not delivered; left at named');
214
221
  break;
@@ -230,7 +237,14 @@ export async function executePlan(steps, session, host) {
230
237
  break;
231
238
  }
232
239
  case 'type': {
233
- if (!host.typeLine(session, op.pane, op.text)) {
240
+ const typed = host.typeLine(session, op.pane, op.text);
241
+ if (typed === 'no-agent') {
242
+ held = true;
243
+ dropped.add(op.seat);
244
+ finish(op.seat, 'no live agent in its pane; its exit was not typed');
245
+ break;
246
+ }
247
+ if (!typed) {
234
248
  held = true;
235
249
  dropped.add(op.seat);
236
250
  finish(op.seat, 'its exit was not typed; left as it is');
@@ -270,10 +284,19 @@ export async function executePlan(steps, session, host) {
270
284
  host.say(`session ${op.session}: not stopped, something was left in it\n`);
271
285
  break;
272
286
  }
273
- if (!host.stopSession(op.session))
287
+ if (!host.stopSession(op.session)) {
274
288
  host.say(`session ${op.session}: it did not stop\n`);
275
- 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)
276
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
+ }
277
300
  break;
278
301
  }
279
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
  }
@@ -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,13 +56,30 @@ 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:
56
69
  mode: two-rules-footer-below
57
70
  ignore_case: true
58
- prompt: '^\s*>\s*'
59
- 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*$'
60
83
  footers:
61
84
  - '\? for shortcuts'
62
85
  - 'Gemini\s+[0-9]'