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.
- package/README.md +20 -5
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +75 -0
- package/dist/approve/approval.d.ts +11 -0
- package/dist/approve/approval.js +75 -8
- package/dist/approve/fingerprint.d.ts +15 -4
- package/dist/approve/fingerprint.js +37 -12
- package/dist/budgets/gate.d.ts +5 -4
- package/dist/budgets/gate.js +16 -12
- package/dist/budgets/readings.d.ts +51 -7
- package/dist/budgets/readings.js +106 -16
- package/dist/budgets/table.d.ts +2 -2
- package/dist/budgets/table.js +24 -11
- package/dist/commands/add.js +11 -4
- package/dist/commands/doctor.d.ts +3 -1
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/down.d.ts +2 -2
- package/dist/commands/down.js +8 -1
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +21 -2
- package/dist/commands/status.js +3 -2
- package/dist/commands/up.d.ts +3 -1
- package/dist/commands/up.js +9 -5
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -17
- package/dist/file/types.d.ts +5 -0
- package/dist/file/validate.js +31 -11
- package/dist/herdr.d.ts +4 -0
- package/dist/herdr.js +40 -11
- package/dist/launch/agent.d.ts +1 -0
- package/dist/launch/agent.js +12 -0
- package/dist/launch/deliver.d.ts +3 -1
- package/dist/launch/deliver.js +10 -1
- package/dist/launch/execute.d.ts +2 -2
- package/dist/launch/execute.js +15 -2
- package/dist/launch/rules.d.ts +4 -2
- package/dist/launch/rules.js +17 -3
- package/dist/profiles/antigravity.yaml +13 -0
- package/dist/profiles/claude-code.yaml +30 -0
- package/dist/profiles/cursor.yaml +4 -1
- package/dist/state.d.ts +7 -4
- package/dist/state.js +37 -0
- package/dist/status/statusline.js +3 -1
- package/dist/watch/check.d.ts +1 -0
- package/dist/watch/checks/budget.js +4 -1
- package/dist/watch/pass.d.ts +3 -2
- package/dist/watch/pass.js +52 -21
- package/dist/watch/screen-core.d.ts +1 -1
- package/dist/watch/screen-core.js +89 -33
- package/dist/watch/screen-data.d.ts +9 -0
- package/dist/watch/screen-file.js +25 -7
- package/dist/watch/screen.d.ts +3 -2
- package/dist/watch/screen.js +9 -4
- package/examples/team.yaml +8 -0
- 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
|
-
|
|
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;
|
package/dist/watch/check.d.ts
CHANGED
|
@@ -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.
|
|
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';
|
package/dist/watch/pass.d.ts
CHANGED
|
@@ -35,8 +35,9 @@ export type PassInput = {
|
|
|
35
35
|
now: number;
|
|
36
36
|
memory: Memory;
|
|
37
37
|
approval?: string[] | null;
|
|
38
|
-
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;
|
package/dist/watch/pass.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import { observe,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
163
|
-
// whose `sources` name `status_line` takes a screen reading: a check-only account never
|
|
164
|
-
// one here, and neither does an account the budgets in force don't name (#50).
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
//
|
|
203
|
-
// `
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
const
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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
|
-
|
|
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(
|
|
229
|
+
return boxToRule(plain, styled, composer, tick);
|
|
208
230
|
if (composer.mode === 'status-last')
|
|
209
|
-
return statusLast(
|
|
231
|
+
return statusLast(plain, styled, composer, tick, false);
|
|
210
232
|
if (composer.mode === 'status-then-one')
|
|
211
|
-
return statusThenOne(
|
|
212
|
-
return twoRules(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
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 = {
|