team 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +20 -5
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +75 -8
  6. package/dist/approve/fingerprint.d.ts +15 -4
  7. package/dist/approve/fingerprint.js +37 -12
  8. package/dist/budgets/gate.d.ts +12 -7
  9. package/dist/budgets/gate.js +52 -17
  10. package/dist/budgets/readings.d.ts +71 -5
  11. package/dist/budgets/readings.js +136 -9
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +12 -5
  15. package/dist/commands/doctor.d.ts +3 -1
  16. package/dist/commands/doctor.js +4 -2
  17. package/dist/commands/down.d.ts +2 -2
  18. package/dist/commands/down.js +8 -1
  19. package/dist/commands/remove.d.ts +2 -0
  20. package/dist/commands/remove.js +21 -2
  21. package/dist/commands/status.js +3 -2
  22. package/dist/commands/up.d.ts +3 -1
  23. package/dist/commands/up.js +12 -7
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +94 -16
  26. package/dist/file/types.d.ts +5 -0
  27. package/dist/file/validate.js +31 -11
  28. package/dist/herdr.d.ts +4 -0
  29. package/dist/herdr.js +40 -11
  30. package/dist/launch/agent.d.ts +1 -0
  31. package/dist/launch/agent.js +12 -0
  32. package/dist/launch/deliver.d.ts +3 -1
  33. package/dist/launch/deliver.js +10 -1
  34. package/dist/launch/execute.d.ts +2 -2
  35. package/dist/launch/execute.js +15 -2
  36. package/dist/launch/rules.d.ts +4 -2
  37. package/dist/launch/rules.js +17 -3
  38. package/dist/profiles/antigravity.yaml +13 -0
  39. package/dist/profiles/claude-code.yaml +30 -0
  40. package/dist/profiles/cursor.yaml +4 -1
  41. package/dist/profiles/quota.d.ts +7 -2
  42. package/dist/profiles/quota.js +15 -14
  43. package/dist/state.d.ts +8 -3
  44. package/dist/state.js +37 -0
  45. package/dist/status/statusline.js +3 -1
  46. package/dist/watch/check.d.ts +1 -0
  47. package/dist/watch/checks/budget.js +4 -1
  48. package/dist/watch/pass.d.ts +14 -1
  49. package/dist/watch/pass.js +64 -28
  50. package/dist/watch/screen-core.d.ts +7 -1
  51. package/dist/watch/screen-core.js +146 -54
  52. package/dist/watch/screen-data.d.ts +9 -0
  53. package/dist/watch/screen-file.js +25 -7
  54. package/dist/watch/screen.d.ts +5 -0
  55. package/dist/watch/screen.js +16 -2
  56. package/examples/team.yaml +8 -0
  57. package/package.json +2 -2
@@ -1,13 +1,61 @@
1
+ // The screen core. Stage order, dialog primitives, the four composer modes and the
2
+ // safety floor live here, never in a profile. A profile is data: it can add a
3
+ // pattern, and it cannot move a stage or turn the floor off.
4
+ import { allDimAfter, hasSgr, stripSgr } from "../ansi.js";
1
5
  const BUDGET_MS = 100;
2
6
  // The rule a `below_last_rule` pattern has to sit under. It is the core's, not the profile's.
3
7
  const RULE_LINE = /^\s*[─━]{8,}\s*$/;
4
- const FLOOR_PHRASES = ['Do you want to', 'Esc to cancel', 'enter Confirm', 'enter continue', 'esc quit', 'esc skip', '↑/↓'];
8
+ // The safety floor's markers, matched without case: a dialog's wording is not always title-case
9
+ // (`Press enter to confirm or esc to cancel`). RFC 0002 § 4.1 permits it.
10
+ const FLOOR_PHRASES = ['do you want to', 'esc to cancel', 'enter confirm', 'enter continue', 'esc quit', 'esc skip', '↑/↓'];
11
+ /**
12
+ * The index of the composer's status line in the window, or -1. A `status-last` composer pins it
13
+ * as the last non-blank line; a `status-then-one` composer allows one chrome line below it. The
14
+ * classification and the quota read both take their line from here, so the two cannot disagree
15
+ * about which line the composer's status line is.
16
+ */
17
+ function statusIndex(composer, lines, allowOneTrailing, tick) {
18
+ for (let i = lines.length - 1; i >= 0; i--) {
19
+ if (tick())
20
+ return 'stop';
21
+ if (!composer.statusLine.test(lines[i] ?? ''))
22
+ continue;
23
+ const trailing = lines.slice(i + 1).filter((line) => line.trim());
24
+ if (allowOneTrailing) {
25
+ if (trailing.length > 0 && (trailing.length > 1 || composer.prompt.test(trailing[0] ?? '')))
26
+ return -1;
27
+ }
28
+ else if (trailing.length > 0)
29
+ return -1;
30
+ return i;
31
+ }
32
+ return -1;
33
+ }
34
+ /**
35
+ * The one line a quota figure may be read from: the composer's own status row, or null when the
36
+ * window shows none. A dialog, a transcript line, the input box — anything the seat printed or
37
+ * typed — is never the row. The caller still has to know the screen is a composer screen.
38
+ */
39
+ export function statusRowOf(data, lines) {
40
+ const composer = data.composer;
41
+ if (composer.mode !== 'status-last' && composer.mode !== 'status-then-one')
42
+ return null;
43
+ const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
44
+ return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
45
+ }
46
+ /** `lines` is already the window: the last 20 lines, each keeping any ANSI styling. Matching
47
+ * runs on each line's plain form, trimmed at the end; the styled form is kept for the one
48
+ * question styling can answer, whether the composer's input line is all dim. */
49
+ function plainLines(lines) {
50
+ return lines.map((line) => stripSgr(line).trimEnd());
51
+ }
5
52
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
6
53
  export function classifyLines(data, lines, clock) {
7
54
  const now = clock?.now ?? Date.now;
8
55
  const budget = clock?.budgetMs ?? BUDGET_MS;
9
56
  const start = now();
10
57
  const tick = () => now() - start > budget;
58
+ const plain = plainLines(lines);
11
59
  const order = [
12
60
  ['unknown', data.unknown],
13
61
  ['trust', data.trust],
@@ -19,19 +67,19 @@ export function classifyLines(data, lines, clock) {
19
67
  if (!stage)
20
68
  continue;
21
69
  for (const rule of stage.rules) {
22
- const hit = ruleMatches(lines, rule, tick);
70
+ const hit = ruleMatches(data, plain, rule, tick);
23
71
  if (hit === 'stop')
24
72
  return { kind: 'unknown' };
25
73
  if (hit)
26
74
  return { kind };
27
75
  }
28
76
  }
29
- const composed = compose(data, lines, tick);
77
+ const composed = compose(data, plain, lines, tick);
30
78
  if (composed.kind === 'stop' || composed.kind === 'unknown')
31
79
  return { kind: 'unknown' };
32
80
  if (composed.kind !== 'idle' && composed.kind !== 'unsent')
33
81
  return { kind: composed.kind };
34
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
82
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
35
83
  if (marked === 'stop' || marked)
36
84
  return { kind: 'unknown' };
37
85
  return { kind: composed.kind };
@@ -45,28 +93,30 @@ export function composeLines(data, lines, clock) {
45
93
  const budget = clock?.budgetMs ?? BUDGET_MS;
46
94
  const start = now();
47
95
  const tick = () => now() - start > budget;
96
+ const plain = plainLines(lines);
48
97
  if (data.unknown) {
49
98
  for (const rule of data.unknown.rules) {
50
- const hit = ruleMatches(lines, rule, tick);
99
+ const hit = ruleMatches(data, plain, rule, tick);
51
100
  if (hit === 'stop')
52
101
  return { kind: 'unknown' };
53
102
  if (hit)
54
103
  return { kind: 'unknown' };
55
104
  }
56
105
  }
57
- const composed = compose(data, lines, tick);
106
+ const composed = compose(data, plain, lines, tick);
58
107
  if (composed.kind === 'stop' || composed.kind === 'unknown')
59
108
  return { kind: 'unknown' };
60
109
  if (composed.kind !== 'idle' && composed.kind !== 'unsent')
61
110
  return { kind: composed.kind };
62
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
111
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
63
112
  if (marked === 'stop' || marked)
64
113
  return { kind: 'unknown' };
65
114
  return { kind: composed.kind };
66
115
  }
67
- function ruleMatches(lines, rule, tick) {
116
+ function ruleMatches(data, lines, rule, tick) {
117
+ const where = rule.onFooter ? [footerLine(data, lines)] : lines;
68
118
  if (rule.any) {
69
- const hit = anyLine(lines, rule.any, tick);
119
+ const hit = anyLine(where, rule.any, tick);
70
120
  if (hit === 'stop')
71
121
  return 'stop';
72
122
  if (!hit)
@@ -74,7 +124,7 @@ function ruleMatches(lines, rule, tick) {
74
124
  }
75
125
  if (rule.all) {
76
126
  for (const pattern of rule.all) {
77
- const hit = lineSomewhere(lines, pattern, tick);
127
+ const hit = lineSomewhere(where, pattern, tick);
78
128
  if (hit === 'stop')
79
129
  return 'stop';
80
130
  if (!hit)
@@ -82,16 +132,23 @@ function ruleMatches(lines, rule, tick) {
82
132
  }
83
133
  }
84
134
  if (rule.footer !== undefined) {
85
- const last = [...lines].reverse().find((line) => line.trim());
86
- if ((last ?? '').trim() !== rule.footer)
135
+ if (footerLine(data, lines) !== rule.footer)
136
+ return false;
137
+ }
138
+ if (rule.withoutRule) {
139
+ if (tick())
140
+ return 'stop';
141
+ if (findLast(lines, (line) => RULE_LINE.test(line)) >= 0)
87
142
  return false;
88
143
  }
89
144
  if (rule.belowLastRule) {
90
145
  if (tick())
91
146
  return 'stop';
92
- // No rule line counts as below one: today's check is `index > lastIndex`, and
93
- // lastIndex is -1 when the window has no rule. A later rule still hides a quoted dialog.
147
+ // A pattern sits below a composer rule. A window with no rule line is not
148
+ // below one: a dialog that fills the pane says so with `without_rule`.
94
149
  const ruleAt = findLast(lines, (line) => RULE_LINE.test(line));
150
+ if (ruleAt < 0)
151
+ return false;
95
152
  const at = findLast(lines, (line) => rule.belowLastRule?.test(line) ?? false);
96
153
  if (at < 0 || at <= ruleAt)
97
154
  return false;
@@ -137,6 +194,21 @@ function lineSomewhere(lines, pattern, tick) {
137
194
  }
138
195
  return false;
139
196
  }
197
+ /**
198
+ * The line a rule's footer must be: the last non-blank one. The composer's own chrome is the one
199
+ * thing allowed below it — a `status-last` mode pins the status line to the pane's last line, and
200
+ * a dialog drawn above it is still the dialog its footer belongs to. Any other line below the
201
+ * footer means the footer is not the dialog's, and the rule doesn't match.
202
+ */
203
+ function footerLine(data, lines) {
204
+ const nonBlank = lines.filter((line) => line.trim());
205
+ const last = nonBlank[nonBlank.length - 1] ?? '';
206
+ const composer = data.composer;
207
+ if (composer.mode === 'status-last' && composer.statusLine.test(last)) {
208
+ return (nonBlank[nonBlank.length - 2] ?? '').trim();
209
+ }
210
+ return last.trim();
211
+ }
140
212
  function matches(line, pattern, tick) {
141
213
  if (tick())
142
214
  return 'stop';
@@ -150,17 +222,18 @@ function matches(line, pattern, tick) {
150
222
  }
151
223
  return true;
152
224
  }
153
- function compose(data, lines, tick) {
225
+ /** `plain` is the window's plain form; `styled` the window itself, for the input line's dim question. */
226
+ function compose(data, plain, styled, tick) {
154
227
  const composer = data.composer;
155
228
  if (composer.mode === 'box-to-rule')
156
- return boxToRule(lines, composer, tick);
229
+ return boxToRule(plain, styled, composer, tick);
157
230
  if (composer.mode === 'status-last')
158
- return statusLast(lines, composer, tick, false);
231
+ return statusLast(plain, styled, composer, tick, false);
159
232
  if (composer.mode === 'status-then-one')
160
- return statusThenOne(lines, composer, tick);
161
- return twoRules(lines, composer, tick);
233
+ return statusThenOne(plain, styled, composer, tick);
234
+ return twoRules(plain, styled, composer, tick);
162
235
  }
163
- function boxToRule(lines, composer, tick) {
236
+ function boxToRule(lines, styled, composer, tick) {
164
237
  let input = -1;
165
238
  for (let i = lines.length - 1; i >= 0; i--) {
166
239
  if (tick())
@@ -172,39 +245,53 @@ function boxToRule(lines, composer, tick) {
172
245
  }
173
246
  if (input < 0)
174
247
  return { kind: 'unknown' };
175
- const from = boundaryBefore(lines, input, composer.rule);
248
+ // The frame is the box itself: a closing rule under this prompt. A rule anywhere
249
+ // above, or a rule with shell output under it, is not the box. A shell's last
250
+ // prompt never has that closing rule.
251
+ let close = -1;
176
252
  for (let i = input + 1; i < lines.length; i++) {
177
253
  if (tick())
178
254
  return { kind: 'stop' };
179
- if (composer.rule.test(lines[i] ?? ''))
255
+ if (composer.rule.test(lines[i] ?? '')) {
256
+ close = i;
180
257
  break;
181
- if ((lines[i] ?? '').trim())
182
- return { kind: 'unsent', from, input };
258
+ }
183
259
  }
184
- const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
185
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
186
- }
187
- function statusLast(lines, composer, tick, allowOneTrailing) {
188
- let status = -1;
189
- for (let i = lines.length - 1; i >= 0; i--) {
260
+ if (close < 0)
261
+ return { kind: 'unknown' };
262
+ const above = input > 0 && composer.rule.test(lines[input - 1] ?? '');
263
+ let footer = false;
264
+ for (let j = close + 1; j < lines.length; j++) {
190
265
  if (tick())
191
266
  return { kind: 'stop' };
192
- if (composer.statusLine.test(lines[i] ?? '')) {
193
- status = i;
267
+ if (statusFooter(lines[j] ?? '')) {
268
+ footer = true;
194
269
  break;
195
270
  }
196
271
  }
197
- if (status < 0)
272
+ // The opening rule on the line directly above, or the status footer when that
273
+ // rule has scrolled out of the window.
274
+ if (!above && !footer)
198
275
  return { kind: 'unknown' };
199
- const trailing = [];
200
- for (let i = status + 1; i < lines.length; i++)
276
+ const from = above ? input - 1 : input;
277
+ for (let i = input + 1; i < close; i++) {
201
278
  if ((lines[i] ?? '').trim())
202
- trailing.push(lines[i] ?? '');
203
- if (allowOneTrailing) {
204
- if (trailing.length > 1 || trailing.some((line) => composer.prompt.test(line)))
205
- return { kind: 'unknown' };
279
+ return { kind: 'unsent', from, input };
206
280
  }
207
- else if (trailing.length > 0)
281
+ const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
282
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from, input };
283
+ }
284
+ // The composer footer, not a line of shell output under a stray rule. The model
285
+ // row uses a middot; the permissions line is the other footer.
286
+ function statusFooter(line) {
287
+ const text = line.trim();
288
+ return text.includes('·') || /bypass permissions/i.test(text);
289
+ }
290
+ function statusLast(lines, styled, composer, tick, allowOneTrailing) {
291
+ const status = statusIndex(composer, lines, allowOneTrailing, tick);
292
+ if (status === 'stop')
293
+ return { kind: 'stop' };
294
+ if (status < 0)
208
295
  return { kind: 'unknown' };
209
296
  let input = status - 1;
210
297
  while (input >= 0 && !composer.prompt.test(lines[input] ?? ''))
@@ -214,10 +301,10 @@ function statusLast(lines, composer, tick, allowOneTrailing) {
214
301
  for (let i = input + 1; i < status; i++)
215
302
  if ((lines[i] ?? '').trim())
216
303
  return { kind: 'unsent', from: input, input };
217
- return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer.placeholders) ? 'idle' : 'unsent', from: input, input };
304
+ return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: input, input };
218
305
  }
219
- function statusThenOne(lines, composer, tick) {
220
- const found = statusLast(lines, composer, tick, true);
306
+ function statusThenOne(lines, styled, composer, tick) {
307
+ const found = statusLast(lines, styled, composer, tick, true);
221
308
  if (found.kind !== 'unknown')
222
309
  return found;
223
310
  // No status line: the fallback rules decide. Anything they don't name is unknown.
@@ -244,7 +331,7 @@ function statusThenOne(lines, composer, tick) {
244
331
  }
245
332
  return { kind: 'unknown' };
246
333
  }
247
- function twoRules(lines, composer, tick) {
334
+ function twoRules(lines, styled, composer, tick) {
248
335
  let bottom = -1;
249
336
  let top = -1;
250
337
  let input = -1;
@@ -277,20 +364,24 @@ function twoRules(lines, composer, tick) {
277
364
  if (lines.slice(input + 1, bottom).some((line) => line.trim()))
278
365
  return { kind: 'unsent', from: top, input };
279
366
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
280
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from: top, input };
281
- }
282
- function boundaryBefore(lines, input, rule) {
283
- for (let i = input - 1; i >= 0; i--)
284
- if (rule.test(lines[i] ?? ''))
285
- return i;
286
- return input;
367
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: top, input };
287
368
  }
288
369
  function stripTyped(line, composer) {
289
370
  const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');
290
371
  return stripped.trim();
291
372
  }
292
- function placeholder(typed, list) {
293
- return list.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
373
+ // A placeholder, not text. When the composer says its suggestions render dim and the input
374
+ // line carries any styling at all, the styling decides alone: all faint past the prompt is a
375
+ // greyed suggestion, anything else is text — the list is not asked, so no suggestion's
376
+ // styling can lend its words to typed characters after it. A line without styling (a plain
377
+ // read, an old herdr) leaves the list to decide, as does a composer without the style.
378
+ function placeholder(typed, composer, plainLine, styledLine) {
379
+ if (composer.placeholderStyle === 'dim' && styledLine !== undefined && hasSgr(styledLine)) {
380
+ const hit = composer.prompt.exec(plainLine);
381
+ const skip = hit === null ? 0 : plainLine.slice(0, hit.index + hit[0].length).replace(/\s/g, '').length;
382
+ return allDimAfter(styledLine, skip);
383
+ }
384
+ return composer.placeholders.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
294
385
  }
295
386
  function floorHits(lines, from, input, chrome, tick) {
296
387
  for (let i = Math.max(0, from); i < lines.length; i++) {
@@ -301,7 +392,8 @@ function floorHits(lines, from, input, chrome, tick) {
301
392
  continue;
302
393
  if (i !== input && choiceLine(line) && lines.slice(i + 1).some((later) => twoLine(later)))
303
394
  return true;
304
- if (FLOOR_PHRASES.some((phrase) => line.includes(phrase)))
395
+ const lower = line.toLowerCase();
396
+ if (FLOOR_PHRASES.some((phrase) => lower.includes(phrase)))
305
397
  return true;
306
398
  }
307
399
  return false;
@@ -7,7 +7,11 @@ export type Rule = {
7
7
  any?: LinePattern[];
8
8
  all?: LinePattern[];
9
9
  footer?: string;
10
+ /** `any` and `all` are read on the dialog's last line, not on every line. */
11
+ onFooter?: boolean;
10
12
  belowLastRule?: RegExp;
13
+ /** The window has no composer rule: the dialog is the pane. */
14
+ withoutRule?: boolean;
11
15
  noneAfter?: {
12
16
  anchor: LinePattern;
13
17
  patterns: LinePattern[];
@@ -25,23 +29,27 @@ export type FallbackRule = {
25
29
  all: LinePattern[];
26
30
  kind: Screen['kind'];
27
31
  };
32
+ export type PlaceholderStyle = 'dim';
28
33
  type Box = {
29
34
  mode: 'box-to-rule';
30
35
  prompt: RegExp;
31
36
  rule: RegExp;
32
37
  placeholders: Placeholder[];
38
+ placeholderStyle?: PlaceholderStyle;
33
39
  };
34
40
  type StatusLast = {
35
41
  mode: 'status-last';
36
42
  statusLine: RegExp;
37
43
  prompt: RegExp;
38
44
  placeholders: Placeholder[];
45
+ placeholderStyle?: PlaceholderStyle;
39
46
  };
40
47
  type StatusThenOne = {
41
48
  mode: 'status-then-one';
42
49
  statusLine: RegExp;
43
50
  prompt: RegExp;
44
51
  placeholders: Placeholder[];
52
+ placeholderStyle?: PlaceholderStyle;
45
53
  stripSuffix: RegExp | null;
46
54
  fallback: FallbackRule[];
47
55
  };
@@ -51,6 +59,7 @@ type TwoRules = {
51
59
  rule: RegExp;
52
60
  footers: RegExp[];
53
61
  placeholders: Placeholder[];
62
+ placeholderStyle?: PlaceholderStyle;
54
63
  };
55
64
  export type Composer = Box | StatusLast | StatusThenOne | TwoRules;
56
65
  export type ScreenData = {
@@ -50,14 +50,16 @@ function stageOf(node) {
50
50
  }
51
51
  function ruleOf(node, ignoreCase) {
52
52
  const entries = mapping(node, 'a rule');
53
- only(entries, ['any', 'all', 'footer', 'below_last_rule', 'none_after']);
53
+ only(entries, ['any', 'all', 'footer', 'on_footer', 'below_last_rule', 'without_rule', 'none_after']);
54
54
  if (entries.length === 0)
55
55
  fail(node.line, 'a rule has no primitive');
56
56
  const rule = {};
57
57
  const any = optional(entries, 'any');
58
58
  const all = optional(entries, 'all');
59
59
  const footer = optional(entries, 'footer');
60
+ const onFooter = optional(entries, 'on_footer');
60
61
  const below = optional(entries, 'below_last_rule');
62
+ const withoutRule = optional(entries, 'without_rule');
61
63
  const noneAfter = optional(entries, 'none_after');
62
64
  if (any)
63
65
  rule.any = patternsOf(any.value, 'any', ignoreCase);
@@ -65,8 +67,12 @@ function ruleOf(node, ignoreCase) {
65
67
  rule.all = patternsOf(all.value, 'all', ignoreCase);
66
68
  if (footer)
67
69
  rule.footer = stringOf(footer.value) ?? fail(footer.line, '"footer" must be a string');
70
+ if (onFooter)
71
+ rule.onFooter = boolOf(onFooter.value, 'on_footer');
68
72
  if (below)
69
73
  rule.belowLastRule = patternOf(stringOf(below.value) ?? fail(below.line, '"below_last_rule" must be a string'), ignoreCase, below.line);
74
+ if (withoutRule)
75
+ rule.withoutRule = boolOf(withoutRule.value, 'without_rule');
70
76
  if (noneAfter)
71
77
  rule.noneAfter = noneAfterOf(noneAfter.value, ignoreCase);
72
78
  return rule;
@@ -116,16 +122,20 @@ function composerOf(node) {
116
122
  const entries = mapping(node, 'composer');
117
123
  const mode = required(entries, 'mode', node.line);
118
124
  const name = stringOf(mode.value);
125
+ // How the CLI renders its greyed suggestions, read off the input line's styling. Optional:
126
+ // a composer whose suggestions plain text already names needs none of it.
127
+ const style = optional(entries, 'placeholder_style');
128
+ const placeholderStyle = style ? placeholderStyleOf(style) : undefined;
119
129
  if (name === 'box-to-rule') {
120
- only(entries, ['mode', 'prompt', 'rule', 'placeholders']);
121
- return { mode: name, prompt: regexField(entries, 'prompt', node.line), rule: regexField(entries, 'rule', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value) };
130
+ only(entries, ['mode', 'prompt', 'rule', 'placeholders', 'placeholder_style']);
131
+ return { mode: name, prompt: regexField(entries, 'prompt', node.line), rule: regexField(entries, 'rule', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value), placeholderStyle };
122
132
  }
123
133
  if (name === 'status-last') {
124
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders']);
125
- return { mode: name, statusLine: regexField(entries, 'status_line', node.line), prompt: regexField(entries, 'prompt', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value) };
134
+ only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style']);
135
+ return { mode: name, statusLine: regexField(entries, 'status_line', node.line), prompt: regexField(entries, 'prompt', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value), placeholderStyle };
126
136
  }
127
137
  if (name === 'status-then-one') {
128
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'strip_suffix', 'fallback']);
138
+ only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style', 'strip_suffix', 'fallback']);
129
139
  const suffix = optional(entries, 'strip_suffix');
130
140
  const fallback = required(entries, 'fallback', node.line);
131
141
  return {
@@ -133,12 +143,13 @@ function composerOf(node) {
133
143
  statusLine: regexField(entries, 'status_line', node.line),
134
144
  prompt: regexField(entries, 'prompt', node.line),
135
145
  placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
146
+ placeholderStyle,
136
147
  stripSuffix: suffix ? patternOf(stringOf(suffix.value) ?? fail(suffix.line, '"strip_suffix" must be a string'), false, suffix.line) : null,
137
148
  fallback: fallbackOf(fallback.value),
138
149
  };
139
150
  }
140
151
  if (name === 'two-rules-footer-below') {
141
- only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders']);
152
+ only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders', 'placeholder_style']);
142
153
  const flag = optional(entries, 'ignore_case');
143
154
  const ignoreCase = flag ? boolOf(flag.value, 'ignore_case') : false;
144
155
  const footers = required(entries, 'footers', node.line);
@@ -155,10 +166,17 @@ function composerOf(node) {
155
166
  return patternOf(text, ignoreCase, item.line);
156
167
  }),
157
168
  placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
169
+ placeholderStyle,
158
170
  };
159
171
  }
160
172
  fail(mode.line, `"mode" must be box-to-rule, status-last, status-then-one or two-rules-footer-below`);
161
173
  }
174
+ function placeholderStyleOf(entry) {
175
+ const text = stringOf(entry.value);
176
+ if (text !== 'dim')
177
+ fail(entry.line, '"placeholder_style" must be dim');
178
+ return text;
179
+ }
162
180
  function fallbackOf(node) {
163
181
  if (node.kind !== 'seq')
164
182
  fail(node.line, '"fallback" must be a list');
@@ -18,3 +18,8 @@ export declare function classify(cli: string, lines: string[]): Screen;
18
18
  /** The composer alone. A running turn would otherwise hide an empty input box. */
19
19
  export declare function classifyComposer(cli: string, lines: string[]): Screen;
20
20
  export declare function readScreen(cli: string, screen: string | undefined): Screen;
21
+ /** The one line a quota figure may come from: the composer's own status row, in the pane's last
22
+ * 20 lines, read plain — a figure is never styled. Null for a CLI with no status line, and for
23
+ * a window that shows no status row — a dialog, a question, a trust screen, a shell or an
24
+ * unknown one reads no figures. */
25
+ export declare function statusRow(cli: string, screen: string | undefined): string | null;
@@ -1,7 +1,10 @@
1
1
  // What a pane's visible text shows, read against the shapes of its CLI. A screen that matches no
2
2
  // shape is "unknown": never ready, never idle, and never grounds for typing anything.
3
+ // The text may keep its ANSI styling, as `herdr pane read --format ansi` reads it: matching
4
+ // runs on the plain form, and the styling tells a greyed suggestion from typed text.
3
5
  import { readFileSync } from 'node:fs';
4
- import { classifyLines, composeLines } from "./screen-core.js";
6
+ import { stripSgr } from "../ansi.js";
7
+ import { classifyLines, composeLines, statusRowOf } from "./screen-core.js";
5
8
  import { loadScreen } from "./screen-file.js";
6
9
  function load(name) {
7
10
  return loadScreen(readFileSync(new URL(`../profiles/${name}.yaml`, import.meta.url), 'utf8'));
@@ -13,7 +16,8 @@ const DATA = {
13
16
  cursor: load('cursor'),
14
17
  antigravity: load('antigravity'),
15
18
  };
16
- // The window every pattern sees: the pane's last 20 lines, each trimmed at the end.
19
+ // The window every pattern sees: the pane's last 20 lines. Styled lines are trimmed only past
20
+ // their last escape; the core trims each line's plain form for matching.
17
21
  function windowOf(lines) {
18
22
  return lines.map((line) => line.trimEnd()).slice(-20);
19
23
  }
@@ -36,3 +40,13 @@ export function readScreen(cli, screen) {
36
40
  return { kind: 'unknown' };
37
41
  return classify(cli, screen.split('\n'));
38
42
  }
43
+ /** The one line a quota figure may come from: the composer's own status row, in the pane's last
44
+ * 20 lines, read plain — a figure is never styled. Null for a CLI with no status line, and for
45
+ * a window that shows no status row — a dialog, a question, a trust screen, a shell or an
46
+ * unknown one reads no figures. */
47
+ export function statusRow(cli, screen) {
48
+ const data = DATA[cli];
49
+ if (data === undefined || screen === undefined)
50
+ return null;
51
+ return statusRowOf(data, windowOf(stripSgr(screen).split('\n')));
52
+ }
@@ -49,6 +49,7 @@ seats:
49
49
  label: codex
50
50
  cli: codex
51
51
  vendor: openai
52
+ account: openai-team # the seat's account, when one vendor has two; absent, its vendor
52
53
  model: GPT Comet
53
54
  version: "3"
54
55
  display: GPT-3 Comet # the vendor's spelling, for the signature
@@ -74,3 +75,10 @@ seats:
74
75
  version: "1"
75
76
  launch: nimbus-claude # a launcher on the PATH, holding the account's key and endpoint
76
77
  count: 3
78
+
79
+ budgets: # the owner's: what is budgeted, and how long a figure stays fresh
80
+ accounts:
81
+ openai-team: # the account codex-beacon spends
82
+ kind: subscription
83
+ reserve: 20% # refuse a launch on a figure inside it
84
+ sources: [status_line] # Codex's status line carries its weekly figure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Set up, change and watch a project's team of AI agents from one file",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -23,7 +23,7 @@
23
23
  "typecheck": "tsc --noEmit",
24
24
  "test": "bun test",
25
25
  "e2e": "bun scripts/e2e.ts",
26
- "build": "tsc -p tsconfig.build.json && mkdir -p dist/profiles && cp src/profiles/*.yaml dist/profiles/",
26
+ "build": "bun scripts/build.ts",
27
27
  "ci": "bun run typecheck && bun test && bun run build && node dist/cli.js --version"
28
28
  },
29
29
  "devDependencies": {