team 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +20 -5
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +75 -8
  6. package/dist/approve/fingerprint.d.ts +15 -4
  7. package/dist/approve/fingerprint.js +37 -12
  8. package/dist/budgets/gate.d.ts +5 -4
  9. package/dist/budgets/gate.js +16 -12
  10. package/dist/budgets/readings.d.ts +51 -7
  11. package/dist/budgets/readings.js +106 -16
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +11 -4
  15. package/dist/commands/doctor.d.ts +3 -1
  16. package/dist/commands/doctor.js +4 -2
  17. package/dist/commands/down.d.ts +2 -2
  18. package/dist/commands/down.js +8 -1
  19. package/dist/commands/remove.d.ts +2 -0
  20. package/dist/commands/remove.js +21 -2
  21. package/dist/commands/status.js +3 -2
  22. package/dist/commands/up.d.ts +3 -1
  23. package/dist/commands/up.js +9 -5
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +73 -17
  26. package/dist/file/types.d.ts +5 -0
  27. package/dist/file/validate.js +31 -11
  28. package/dist/herdr.d.ts +4 -0
  29. package/dist/herdr.js +40 -11
  30. package/dist/launch/agent.d.ts +1 -0
  31. package/dist/launch/agent.js +12 -0
  32. package/dist/launch/deliver.d.ts +3 -1
  33. package/dist/launch/deliver.js +10 -1
  34. package/dist/launch/execute.d.ts +2 -2
  35. package/dist/launch/execute.js +15 -2
  36. package/dist/launch/rules.d.ts +4 -2
  37. package/dist/launch/rules.js +17 -3
  38. package/dist/profiles/antigravity.yaml +13 -0
  39. package/dist/profiles/claude-code.yaml +30 -0
  40. package/dist/profiles/cursor.yaml +4 -1
  41. package/dist/state.d.ts +7 -4
  42. package/dist/state.js +37 -0
  43. package/dist/status/statusline.js +3 -1
  44. package/dist/watch/check.d.ts +1 -0
  45. package/dist/watch/checks/budget.js +4 -1
  46. package/dist/watch/pass.d.ts +3 -2
  47. package/dist/watch/pass.js +52 -21
  48. package/dist/watch/screen-core.d.ts +1 -1
  49. package/dist/watch/screen-core.js +89 -33
  50. package/dist/watch/screen-data.d.ts +9 -0
  51. package/dist/watch/screen-file.js +25 -7
  52. package/dist/watch/screen.d.ts +3 -2
  53. package/dist/watch/screen.js +9 -4
  54. package/examples/team.yaml +8 -0
  55. package/package.json +1 -1
package/dist/state.d.ts CHANGED
@@ -35,10 +35,6 @@ export type SessionState = {
35
35
  nudge?: {
36
36
  pending_since: string | null;
37
37
  };
38
- /** The last screen readings for this project, keyed by account, window and seat. */
39
- budgets?: Record<string, StoredReading>;
40
- /** The last spend check readings for this project, keyed by account. */
41
- spend?: Record<string, StoredSpend>;
42
38
  };
43
39
  export type State = {
44
40
  format: 1;
@@ -46,6 +42,13 @@ export type State = {
46
42
  read_at: string;
47
43
  file: string;
48
44
  };
45
+ /**
46
+ * The project's last readings, whatever session saw them: a screen reading keyed by account,
47
+ * window and seat, a check reading by account and window (§ 4.4).
48
+ */
49
+ budgets?: Record<string, StoredReading>;
50
+ /** The project's last spend check readings, keyed by account. */
51
+ spend?: Record<string, StoredSpend>;
49
52
  sessions: Record<string, SessionState>;
50
53
  };
51
54
  export declare const STATE_FILE = "team.state.json";
package/dist/state.js CHANGED
@@ -26,9 +26,46 @@ export function readState(dir) {
26
26
  for (const session of Object.values(state.sessions)) {
27
27
  session.seats ??= {};
28
28
  session.worktrees ??= {};
29
+ // Read old, write new: readings used to be held under a session. They are the project's
30
+ // (§ 4.4), so every session's records are merged to the top level here — no reader loses one,
31
+ // and the next write persists the new shape.
32
+ const held = session;
33
+ if (held.budgets) {
34
+ state.budgets = mergeReadings(state.budgets, held.budgets);
35
+ delete held.budgets;
36
+ }
37
+ if (held.spend) {
38
+ state.spend = mergeSpend(state.spend, held.spend);
39
+ delete held.spend;
40
+ }
29
41
  }
30
42
  return state;
31
43
  }
44
+ /**
45
+ * One record per slot when two sessions hold the same account, window and seat: the reading that
46
+ * counts wins — a confirmed one, and among those the newest change (§ 4.3).
47
+ */
48
+ function mergeReadings(top, held) {
49
+ const merged = top ?? {};
50
+ for (const [key, reading] of Object.entries(held)) {
51
+ const current = merged[key];
52
+ merged[key] = !current
53
+ || (reading.confirmed && !current.confirmed)
54
+ || (reading.confirmed === current.confirmed && Date.parse(reading.changedAt) > Date.parse(current.changedAt))
55
+ ? reading
56
+ : current;
57
+ }
58
+ return merged;
59
+ }
60
+ /** The same for spend: one record per account, the reading whose check ran last winning. */
61
+ function mergeSpend(top, held) {
62
+ const merged = top ?? {};
63
+ for (const [key, reading] of Object.entries(held)) {
64
+ const current = merged[key];
65
+ merged[key] = !current || Date.parse(reading.at) > Date.parse(current.at) ? reading : current;
66
+ }
67
+ return merged;
68
+ }
32
69
  // Reads the state, lets `change` edit it, and writes it back, all under the lock.
33
70
  export function updateState(dir, change) {
34
71
  return withLock(dir, () => {
@@ -2,10 +2,12 @@
2
2
  // visible text into the file's two fields, `model` and `version`, or null when the text doesn't
3
3
  // show them: "unread" is never a mismatch. The last six lines are the window. A line the rules
4
4
  // claim but cannot name clears an earlier match; that is how an unknown Codex footer stays unread.
5
+ import { stripSgr } from "../ansi.js";
5
6
  import { statusOnLine } from "../profiles/profile.js";
6
7
  export function runningModel(cli, screen) {
7
8
  let found = null;
8
- for (const line of screen.split('\n').slice(-6)) {
9
+ // The pane's text may keep its ANSI styling; the model is read from the plain form.
10
+ for (const line of stripSgr(screen).split('\n').slice(-6)) {
9
11
  const hit = statusOnLine(cli, line);
10
12
  if (hit === 'unreadable')
11
13
  found = null;
@@ -29,6 +29,7 @@ export type SeatObservation = {
29
29
  screen: Screen;
30
30
  cli: string;
31
31
  vendor: string;
32
+ account: string;
32
33
  quota: QuotaFigure[];
33
34
  running: boolean;
34
35
  quiet: boolean;
@@ -67,6 +67,9 @@ function countedOf(name, account, team, staleMs, now) {
67
67
  for (const reading of team.readings) {
68
68
  if (reading.account !== name)
69
69
  continue;
70
+ // The check readings of the same account live in this slot too: this source reads screens.
71
+ if (reading.source !== 'status_line')
72
+ continue;
70
73
  const group = groups.get(reading.window) ?? [];
71
74
  group.push(reading);
72
75
  groups.set(reading.window, group);
@@ -98,7 +101,7 @@ function countedOf(name, account, team, staleMs, now) {
98
101
  }
99
102
  /** The account reads unknown while a seat that spends it is running: the operator can stop it. */
100
103
  function unknown(name, team, ctx) {
101
- const on = team.seats.filter((seat) => seat.running && seat.vendor === name).map((seat) => seat.name);
104
+ const on = team.seats.filter((seat) => seat.running && seat.account === name).map((seat) => seat.name);
102
105
  if (!on.length)
103
106
  return null;
104
107
  const verb = on.length === 1 ? 'runs' : 'run';
@@ -35,8 +35,9 @@ export type PassInput = {
35
35
  now: number;
36
36
  memory: Memory;
37
37
  approval?: string[] | null;
38
- watch?: TeamFile['watch'];
38
+ watch: TeamFile['watch'];
39
39
  outcomes?: readonly CheckOutcome[];
40
40
  budgets?: TeamFile['budgets'];
41
+ readings?: readonly Seen[];
41
42
  };
42
- export declare function pass({ team, state, live, machine, now, memory, approval, watch, outcomes, budgets, }: PassInput): PassResult;
43
+ export declare function pass({ team, state, live, machine, now, memory, approval, watch, outcomes, budgets, readings: stored, }: PassInput): PassResult;
@@ -2,8 +2,8 @@
2
2
  // runs the registered checks in the order it owns, applies the report-once rule, and dials the
3
3
  // nudge. A check never reads herdr, a screen or the file itself — only the observation it is
4
4
  // handed (RFC 0002 § 4.2).
5
- import { WATCH_CHECKS_CHANGED } from "../approve/fingerprint.js";
6
- import { observe, recall } from "../budgets/readings.js";
5
+ import { seatNamed } from "../approve/fingerprint.js";
6
+ import { observe, observeCheck } from "../budgets/readings.js";
7
7
  import { quotaFor } from "../profiles/profile.js";
8
8
  import { figuresOf } from "../profiles/quota.js";
9
9
  import { seatModel } from "../status/statusline.js";
@@ -33,8 +33,8 @@ export function newMemory() {
33
33
  export const NUDGE_TEXT = 'Team watch: reports are waiting in .agents/team.log';
34
34
  // The seat checks, in the order their reports land in the log; the team checks, after them. The
35
35
  // order is the core's: a seat's wrong model is reported before its permission prompt, and every
36
- // seat before the team. Disabling a check in the file's `watch.checks` takes it out of the run,
37
- // except the four that can't be turned off.
36
+ // seat before the team. Disabling a check in the `watch` section in force takes it out of the
37
+ // run, except the four that can't be turned off.
38
38
  export const SEAT_CHECKS = [missing, modelDrift, attention, unsent, idle];
39
39
  export const TEAM_CHECKS = [extra, teamIdle, approval, load, memoryCheck, disk, swapFree, swapGrowth, budget];
40
40
  // The one exclusive reading of a seat's screen and herdr status. herdr can report a seat at a
@@ -61,7 +61,7 @@ function quotaOf(cli, screen, pane) {
61
61
  return figuresOf(quotaFor(cli), statusRow(cli, pane));
62
62
  }
63
63
  // One pass of the watch. Pure: it reads what it is handed and changes only `memory`.
64
- export function pass({ team, state, live, machine, now, memory, approval, watch = team.watch, outcomes = [], budgets = team.budgets, }) {
64
+ export function pass({ team, state, live, machine, now, memory, approval, watch, outcomes = [], budgets = team.budgets, readings: stored = [], }) {
65
65
  const reports = [];
66
66
  const current = new Set();
67
67
  // Reported when it starts, and again only after it has cleared.
@@ -81,6 +81,7 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
81
81
  name: seat.name,
82
82
  cli: seat.cli,
83
83
  vendor: seat.vendor,
84
+ account: seat.account ?? seat.vendor,
84
85
  parked: seat.parked || seat.stopped,
85
86
  stopped: seat.stopped,
86
87
  seat,
@@ -88,10 +89,18 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
88
89
  ...Object.entries(state.seats).filter(([, recorded]) => recorded.temporary).map(([name, recorded]) => {
89
90
  // A temporary seat signs like the seat it is like: its CLI, and its account.
90
91
  const like = team.seats.find((seat) => seat.name === recorded.temporary?.like);
91
- return { name, cli: like?.cli ?? '', vendor: like?.vendor ?? '', parked: false, stopped: false, seat: undefined };
92
+ return {
93
+ name,
94
+ cli: like?.cli ?? '',
95
+ vendor: like?.vendor ?? '',
96
+ account: like?.account ?? like?.vendor ?? '',
97
+ parked: false,
98
+ stopped: false,
99
+ seat: undefined,
100
+ };
92
101
  }),
93
102
  ];
94
- for (const { name, cli, vendor, parked, stopped, seat } of seats) {
103
+ for (const { name, cli, vendor, account, parked, stopped, seat } of seats) {
95
104
  const agent = live.agents.find((candidate) => candidate.name === name);
96
105
  const lead = name === team.coordinator || name === team.operator;
97
106
  if (!agent) {
@@ -104,6 +113,7 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
104
113
  screen: { kind: 'unknown' },
105
114
  cli,
106
115
  vendor,
116
+ account,
107
117
  quota: [],
108
118
  running: false,
109
119
  quiet: false,
@@ -146,6 +156,7 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
146
156
  screen,
147
157
  cli,
148
158
  vendor,
159
+ account,
149
160
  quota: quotaOf(cli, screen, pane),
150
161
  running: true,
151
162
  quiet,
@@ -159,19 +170,40 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
159
170
  history: { lastWorking: history.lastWorking, idleSince: history.idleSince },
160
171
  });
161
172
  }
162
- // The figures this pass saw, folded into the readings the state keeps (§ 4.3). Only an account
163
- // whose `sources` name `status_line` takes a screen reading: a check-only account never records
164
- // one here, and neither does an account the budgets in force don't name (#50).
165
- let readings = recall(state.budgets);
173
+ // The figures this pass saw, folded into the readings the project keeps (§ 4.3, § 4.4). Only an
174
+ // account whose `sources` name `status_line` takes a screen reading: a check-only account never
175
+ // records one here, and neither does an account the budgets in force don't name (#50). A figure
176
+ // names the account its pattern measures (§ 3b): one that measures this seat's vendor is this
177
+ // seat's account — its own `account:` when the file names one, so two accounts of one vendor
178
+ // keep two buckets — while a figure on another account (a launcher showing the vendor it really
179
+ // runs on) stays that account's, whichever seat's screen showed it.
180
+ //
181
+ // A seat the approval lists as changed — or as not in the approved file — is drift, and its
182
+ // figures are read as they are but none is folded: an unapproved edit to its `account:` must
183
+ // not move its figure into another account's bucket, where `up` and `add` would count it.
184
+ // Nothing is saved for that seat until the owner approves.
185
+ const drift = new Set((Array.isArray(approval) ? approval : []).map(seatNamed).filter((name) => name !== null));
186
+ let readings = stored.slice();
166
187
  for (const seat of observations) {
167
- if (!seat.running)
188
+ if (!seat.running || drift.has(seat.name))
168
189
  continue;
169
190
  for (const figure of seat.quota) {
170
- if (budgets.accounts[figure.account]?.sources.includes('status_line')) {
171
- readings = observe(readings, figure, seat.name, now);
191
+ const account = figure.account === seat.vendor ? seat.account : figure.account;
192
+ if (budgets.accounts[account]?.sources.includes('status_line')) {
193
+ readings = observe(readings, { ...figure, account }, seat.name, now);
172
194
  }
173
195
  }
174
196
  }
197
+ // A check reading lands in the same slot (§ 5): what an approved check read outlives the watch,
198
+ // so `up` and `add` count it after the watch has exited. Only an account whose sources name
199
+ // `check`, and only when its check actually read this pass.
200
+ for (const outcome of outcomes) {
201
+ if (outcome.state !== 'read' || outcome.reading.kind !== 'subscription')
202
+ continue;
203
+ if (!budgets.accounts[outcome.account]?.sources.includes('check'))
204
+ continue;
205
+ readings = observeCheck(readings, outcome.account, outcome.reading.windows);
206
+ }
175
207
  const teamObservation = {
176
208
  team,
177
209
  state,
@@ -196,15 +228,14 @@ export function pass({ team, state, live, machine, now, memory, approval, watch
196
228
  return memory.slots[kind];
197
229
  },
198
230
  };
199
- // The four § 4.2 keeps out of `watch.checks` run whatever the file says: the validation refuses
231
+ // The four § 4.2 keeps out of `watch.checks` run whatever the list says: the validation refuses
200
232
  // them, and this holds even for a file that reached memory another way.
201
233
  //
202
- // And nothing is turned off until the owner approves (RFC 0002 § 4.2): a file whose
203
- // `watch.checks` differs from the approved one, a file never approved, and an approval that
204
- // wasn't looked at keep every check running and report the difference instead. A difference in
205
- // some other section leaves the checks turned off as approved — the owner owns both questions.
206
- const approved = Array.isArray(approval) && !approval.includes(WATCH_CHECKS_CHANGED);
207
- const off = approved ? new Set(team.watch.checks) : new Set();
234
+ // The off list is the section in force, which the caller passes as `watch`. A file that was
235
+ // approved — `approval` is a list, empty or not — runs that list. An unapproved `watch.checks`
236
+ // edit does not turn anything new off, and a check the approved list turned off stays off.
237
+ // A file never approved, or whose approval was not looked at, turns nothing off.
238
+ const off = Array.isArray(approval) ? new Set(watch.checks) : new Set();
208
239
  const enabled = (name) => ALWAYS_ON.includes(name) || !off.has(name);
209
240
  for (const seat of observations) {
210
241
  for (const check of SEAT_CHECKS)
@@ -1,6 +1,6 @@
1
1
  import type { Screen } from './screen.ts';
2
2
  import type { ScreenData } from './screen-data.ts';
3
- export type { Composer, FallbackRule, LinePattern, Placeholder, Rule, ScreenData, Stage } from './screen-data.ts';
3
+ export type { Composer, FallbackRule, LinePattern, Placeholder, PlaceholderStyle, Rule, ScreenData, Stage } from './screen-data.ts';
4
4
  export type ReadClock = {
5
5
  now(): number;
6
6
  budgetMs: number;
@@ -1,3 +1,7 @@
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*$/;
@@ -39,12 +43,19 @@ export function statusRowOf(data, lines) {
39
43
  const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
40
44
  return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
41
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
+ }
42
52
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
43
53
  export function classifyLines(data, lines, clock) {
44
54
  const now = clock?.now ?? Date.now;
45
55
  const budget = clock?.budgetMs ?? BUDGET_MS;
46
56
  const start = now();
47
57
  const tick = () => now() - start > budget;
58
+ const plain = plainLines(lines);
48
59
  const order = [
49
60
  ['unknown', data.unknown],
50
61
  ['trust', data.trust],
@@ -56,19 +67,19 @@ export function classifyLines(data, lines, clock) {
56
67
  if (!stage)
57
68
  continue;
58
69
  for (const rule of stage.rules) {
59
- const hit = ruleMatches(data, lines, rule, tick);
70
+ const hit = ruleMatches(data, plain, rule, tick);
60
71
  if (hit === 'stop')
61
72
  return { kind: 'unknown' };
62
73
  if (hit)
63
74
  return { kind };
64
75
  }
65
76
  }
66
- const composed = compose(data, lines, tick);
77
+ const composed = compose(data, plain, lines, tick);
67
78
  if (composed.kind === 'stop' || composed.kind === 'unknown')
68
79
  return { kind: 'unknown' };
69
80
  if (composed.kind !== 'idle' && composed.kind !== 'unsent')
70
81
  return { kind: composed.kind };
71
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
82
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
72
83
  if (marked === 'stop' || marked)
73
84
  return { kind: 'unknown' };
74
85
  return { kind: composed.kind };
@@ -82,28 +93,30 @@ export function composeLines(data, lines, clock) {
82
93
  const budget = clock?.budgetMs ?? BUDGET_MS;
83
94
  const start = now();
84
95
  const tick = () => now() - start > budget;
96
+ const plain = plainLines(lines);
85
97
  if (data.unknown) {
86
98
  for (const rule of data.unknown.rules) {
87
- const hit = ruleMatches(data, lines, rule, tick);
99
+ const hit = ruleMatches(data, plain, rule, tick);
88
100
  if (hit === 'stop')
89
101
  return { kind: 'unknown' };
90
102
  if (hit)
91
103
  return { kind: 'unknown' };
92
104
  }
93
105
  }
94
- const composed = compose(data, lines, tick);
106
+ const composed = compose(data, plain, lines, tick);
95
107
  if (composed.kind === 'stop' || composed.kind === 'unknown')
96
108
  return { kind: 'unknown' };
97
109
  if (composed.kind !== 'idle' && composed.kind !== 'unsent')
98
110
  return { kind: composed.kind };
99
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
111
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
100
112
  if (marked === 'stop' || marked)
101
113
  return { kind: 'unknown' };
102
114
  return { kind: composed.kind };
103
115
  }
104
116
  function ruleMatches(data, lines, rule, tick) {
117
+ const where = rule.onFooter ? [footerLine(data, lines)] : lines;
105
118
  if (rule.any) {
106
- const hit = anyLine(lines, rule.any, tick);
119
+ const hit = anyLine(where, rule.any, tick);
107
120
  if (hit === 'stop')
108
121
  return 'stop';
109
122
  if (!hit)
@@ -111,7 +124,7 @@ function ruleMatches(data, lines, rule, tick) {
111
124
  }
112
125
  if (rule.all) {
113
126
  for (const pattern of rule.all) {
114
- const hit = lineSomewhere(lines, pattern, tick);
127
+ const hit = lineSomewhere(where, pattern, tick);
115
128
  if (hit === 'stop')
116
129
  return 'stop';
117
130
  if (!hit)
@@ -122,12 +135,20 @@ function ruleMatches(data, lines, rule, tick) {
122
135
  if (footerLine(data, lines) !== rule.footer)
123
136
  return false;
124
137
  }
138
+ if (rule.withoutRule) {
139
+ if (tick())
140
+ return 'stop';
141
+ if (findLast(lines, (line) => RULE_LINE.test(line)) >= 0)
142
+ return false;
143
+ }
125
144
  if (rule.belowLastRule) {
126
145
  if (tick())
127
146
  return 'stop';
128
- // No rule line counts as below one: today's check is `index > lastIndex`, and
129
- // 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`.
130
149
  const ruleAt = findLast(lines, (line) => RULE_LINE.test(line));
150
+ if (ruleAt < 0)
151
+ return false;
131
152
  const at = findLast(lines, (line) => rule.belowLastRule?.test(line) ?? false);
132
153
  if (at < 0 || at <= ruleAt)
133
154
  return false;
@@ -201,17 +222,18 @@ function matches(line, pattern, tick) {
201
222
  }
202
223
  return true;
203
224
  }
204
- 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) {
205
227
  const composer = data.composer;
206
228
  if (composer.mode === 'box-to-rule')
207
- return boxToRule(lines, composer, tick);
229
+ return boxToRule(plain, styled, composer, tick);
208
230
  if (composer.mode === 'status-last')
209
- return statusLast(lines, composer, tick, false);
231
+ return statusLast(plain, styled, composer, tick, false);
210
232
  if (composer.mode === 'status-then-one')
211
- return statusThenOne(lines, composer, tick);
212
- return twoRules(lines, composer, tick);
233
+ return statusThenOne(plain, styled, composer, tick);
234
+ return twoRules(plain, styled, composer, tick);
213
235
  }
214
- function boxToRule(lines, composer, tick) {
236
+ function boxToRule(lines, styled, composer, tick) {
215
237
  let input = -1;
216
238
  for (let i = lines.length - 1; i >= 0; i--) {
217
239
  if (tick())
@@ -223,19 +245,49 @@ function boxToRule(lines, composer, tick) {
223
245
  }
224
246
  if (input < 0)
225
247
  return { kind: 'unknown' };
226
- 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;
227
252
  for (let i = input + 1; i < lines.length; i++) {
228
253
  if (tick())
229
254
  return { kind: 'stop' };
230
- if (composer.rule.test(lines[i] ?? ''))
255
+ if (composer.rule.test(lines[i] ?? '')) {
256
+ close = i;
257
+ break;
258
+ }
259
+ }
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++) {
265
+ if (tick())
266
+ return { kind: 'stop' };
267
+ if (statusFooter(lines[j] ?? '')) {
268
+ footer = true;
231
269
  break;
270
+ }
271
+ }
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)
275
+ return { kind: 'unknown' };
276
+ const from = above ? input - 1 : input;
277
+ for (let i = input + 1; i < close; i++) {
232
278
  if ((lines[i] ?? '').trim())
233
279
  return { kind: 'unsent', from, input };
234
280
  }
235
281
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
236
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
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);
237
289
  }
238
- function statusLast(lines, composer, tick, allowOneTrailing) {
290
+ function statusLast(lines, styled, composer, tick, allowOneTrailing) {
239
291
  const status = statusIndex(composer, lines, allowOneTrailing, tick);
240
292
  if (status === 'stop')
241
293
  return { kind: 'stop' };
@@ -249,10 +301,10 @@ function statusLast(lines, composer, tick, allowOneTrailing) {
249
301
  for (let i = input + 1; i < status; i++)
250
302
  if ((lines[i] ?? '').trim())
251
303
  return { kind: 'unsent', from: input, input };
252
- 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 };
253
305
  }
254
- function statusThenOne(lines, composer, tick) {
255
- const found = statusLast(lines, composer, tick, true);
306
+ function statusThenOne(lines, styled, composer, tick) {
307
+ const found = statusLast(lines, styled, composer, tick, true);
256
308
  if (found.kind !== 'unknown')
257
309
  return found;
258
310
  // No status line: the fallback rules decide. Anything they don't name is unknown.
@@ -279,7 +331,7 @@ function statusThenOne(lines, composer, tick) {
279
331
  }
280
332
  return { kind: 'unknown' };
281
333
  }
282
- function twoRules(lines, composer, tick) {
334
+ function twoRules(lines, styled, composer, tick) {
283
335
  let bottom = -1;
284
336
  let top = -1;
285
337
  let input = -1;
@@ -312,20 +364,24 @@ function twoRules(lines, composer, tick) {
312
364
  if (lines.slice(input + 1, bottom).some((line) => line.trim()))
313
365
  return { kind: 'unsent', from: top, input };
314
366
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
315
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from: top, input };
316
- }
317
- function boundaryBefore(lines, input, rule) {
318
- for (let i = input - 1; i >= 0; i--)
319
- if (rule.test(lines[i] ?? ''))
320
- return i;
321
- return input;
367
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: top, input };
322
368
  }
323
369
  function stripTyped(line, composer) {
324
370
  const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');
325
371
  return stripped.trim();
326
372
  }
327
- function placeholder(typed, list) {
328
- 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)));
329
385
  }
330
386
  function floorHits(lines, from, input, chrome, tick) {
331
387
  for (let i = Math.max(0, from); i < lines.length; i++) {
@@ -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 = {