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/commands/watch.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readArgs } from "../args.js";
|
|
2
2
|
import { budgetsInForce, watchInForce } from "../approve/approval.js";
|
|
3
|
-
import {
|
|
3
|
+
import { callerOf, describeCaller, isOwner } from "../caller.js";
|
|
4
|
+
import { loadReadings, saveSpendReadings, updateReadings } from "../budgets/readings.js";
|
|
4
5
|
import { runChecks } from "../budgets/run.js";
|
|
5
6
|
import { currentTeam } from "../file/current.js";
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
|
-
import { agentStatus, paneRead, pressEnter, typeText } from "../herdr.js";
|
|
8
|
+
import { agentStatus, paneForeground, paneRead, pressEnter, typeText } from "../herdr.js";
|
|
8
9
|
import { logLine } from "../log.js";
|
|
9
10
|
import { emptySession, readState, updateState } from "../state.js";
|
|
10
11
|
import { readMachine } from "../watch/machine.js";
|
|
@@ -13,9 +14,11 @@ import { newMemory, pass } from "../watch/pass.js";
|
|
|
13
14
|
import { readScreen } from "../watch/screen.js";
|
|
14
15
|
import { judgeTemporary, judgeWorktree } from "../watch/close.js";
|
|
15
16
|
import { readEnd } from "../watch/end.js";
|
|
17
|
+
import { reportedLiveAgent } from "../launch/agent.js";
|
|
16
18
|
import { stopRunning, realSources as removeSources } from "./remove.js";
|
|
17
19
|
import { removeWorktree } from "./worktree.js";
|
|
18
20
|
import { stateOf } from "./down.js";
|
|
21
|
+
import { profileFor } from "../profiles/index.js";
|
|
19
22
|
import { realSources } from "./status.js";
|
|
20
23
|
function waitOrStop(seconds) {
|
|
21
24
|
return new Promise((done) => {
|
|
@@ -41,6 +44,7 @@ export const realWatchSources = {
|
|
|
41
44
|
readChecks: (team, root, now) => runChecks(team, root, now),
|
|
42
45
|
screen: (pane, session) => paneRead(pane, 14, session),
|
|
43
46
|
status: agentStatus,
|
|
47
|
+
foreground: (pane, session) => paneForeground(pane, session),
|
|
44
48
|
typeText,
|
|
45
49
|
pressEnter,
|
|
46
50
|
notify,
|
|
@@ -74,17 +78,30 @@ export async function runWatch(argv, io, sources) {
|
|
|
74
78
|
}
|
|
75
79
|
const session = args.values.session ?? first.team.session;
|
|
76
80
|
const { dir } = first;
|
|
81
|
+
// A seat that starts the session's only watch must not be able to drop the operator's nudge
|
|
82
|
+
// or the operator's desktop notices. The owner's own `--no-notify` still cannot silence a
|
|
83
|
+
// report addressed to the owner: those are routed below.
|
|
84
|
+
if (args.flags.has('no-nudge') || args.flags.has('no-notify')) {
|
|
85
|
+
const caller = callerOf(io, session);
|
|
86
|
+
if (!isOwner(caller)) {
|
|
87
|
+
io.stderr(`team watch: --no-nudge and --no-notify are the owner's, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
77
91
|
const other = readState(dir).sessions[session]?.watch;
|
|
78
92
|
if (other && other.pid !== sources.pid && sources.alive(other.pid)) {
|
|
79
93
|
io.stderr(`team watch: a watch already runs for the session "${session}" (pid ${other.pid})\n`);
|
|
80
94
|
return 1;
|
|
81
95
|
}
|
|
82
|
-
|
|
96
|
+
// The log line is written for every report. `--no-notify` never removes it, and it never
|
|
97
|
+
// removes a desktop notice addressed to the owner. It does silence every other notice.
|
|
98
|
+
const tell = (text, notify) => {
|
|
83
99
|
io.stdout(`${sources.now().toISOString()} ${text}\n`);
|
|
84
100
|
logLine(dir, 'watch', 'watch', text, sources.now());
|
|
85
|
-
if (
|
|
101
|
+
if (notify)
|
|
86
102
|
sources.notify(text);
|
|
87
103
|
};
|
|
104
|
+
const say = (text, desktop) => tell(text, desktop && !args.flags.has('no-notify'));
|
|
88
105
|
const beat = () => updateState(dir, (state) => {
|
|
89
106
|
const record = (state.sessions[session] ??= emptySession());
|
|
90
107
|
record.watch = { pid: sources.pid, heartbeat: sources.now().toISOString() };
|
|
@@ -106,7 +123,16 @@ export async function runWatch(argv, io, sources) {
|
|
|
106
123
|
if (problem !== notice) {
|
|
107
124
|
notice = problem;
|
|
108
125
|
if (problem)
|
|
109
|
-
|
|
126
|
+
tell(problem, true);
|
|
127
|
+
}
|
|
128
|
+
// Only the watch of the file's own session saves readings. A watch on another session —
|
|
129
|
+
// anyone may run one, from any terminal — reads and reports, but the figures it sees are
|
|
130
|
+
// not the project's cache that `up` and `add` count (§ 4.4): it says so once and writes
|
|
131
|
+
// none, budget or spend.
|
|
132
|
+
const foreign = session !== team.session;
|
|
133
|
+
if (foreign && !told.has('foreign-session')) {
|
|
134
|
+
told.add('foreign-session');
|
|
135
|
+
say(`the session "${session}" is not this file's "${team.session}": its readings are not saved`, false);
|
|
110
136
|
}
|
|
111
137
|
// The values in force, read with the file: until the owner approves an edit to `watch`,
|
|
112
138
|
// the watch keeps running with what was approved, or with the defaults.
|
|
@@ -117,7 +143,7 @@ export async function runWatch(argv, io, sources) {
|
|
|
117
143
|
const live = sources.live(session, team);
|
|
118
144
|
if (!live) {
|
|
119
145
|
if (!silent)
|
|
120
|
-
|
|
146
|
+
tell('herdr doesn\'t answer; the watch keeps trying', true);
|
|
121
147
|
silent = true;
|
|
122
148
|
}
|
|
123
149
|
else {
|
|
@@ -134,7 +160,8 @@ export async function runWatch(argv, io, sources) {
|
|
|
134
160
|
// The money a spend check counted is kept, like the pass's screen readings: the launch
|
|
135
161
|
// gate of `up` and `add` reads it later, and one that is stale by then reads unknown
|
|
136
162
|
// (§ 5). An account whose check did not run keeps its stored reading.
|
|
137
|
-
|
|
163
|
+
if (!foreign)
|
|
164
|
+
saveSpendReadings(dir, spendOf(outcomes));
|
|
138
165
|
for (const outcome of outcomes) {
|
|
139
166
|
const key = `check:${outcome.account}`;
|
|
140
167
|
if (outcome.state === 'read') {
|
|
@@ -149,21 +176,30 @@ export async function runWatch(argv, io, sources) {
|
|
|
149
176
|
: `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
|
|
150
177
|
}
|
|
151
178
|
}
|
|
152
|
-
const
|
|
179
|
+
const run = (stored) => pass({
|
|
153
180
|
team, state, live, machine: sources.machine(root), now, memory,
|
|
154
181
|
approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
|
|
182
|
+
readings: stored,
|
|
155
183
|
});
|
|
184
|
+
// The pass folds its figures where the state is held: two watches of the project fold one
|
|
185
|
+
// after the other, not over each other. A watch on a foreign session still folds, for its
|
|
186
|
+
// reports, and saves nothing.
|
|
187
|
+
const result = foreign
|
|
188
|
+
? run(loadReadings(dir))
|
|
189
|
+
: updateReadings(dir, now, (stored) => {
|
|
190
|
+
const folded = run(stored);
|
|
191
|
+
return { readings: folded.readings, value: folded };
|
|
192
|
+
});
|
|
156
193
|
for (const report of result.reports)
|
|
157
|
-
|
|
158
|
-
saveReadings(dir, session, result.readings, now);
|
|
194
|
+
tell(report.text, report.to === 'owner' || !args.flags.has('no-notify'));
|
|
159
195
|
if (result.nudge) {
|
|
160
196
|
if (args.flags.has('no-nudge'))
|
|
161
197
|
say(`nudge not typed (--no-nudge): ${result.nudge.text}`, false);
|
|
162
198
|
else
|
|
163
|
-
deliver(result.nudge, team, session, sources, memory, say);
|
|
199
|
+
deliver(result.nudge, team, session, sources, memory, say, tell, told);
|
|
164
200
|
}
|
|
165
201
|
if (result.fallback)
|
|
166
|
-
|
|
202
|
+
tell(result.fallback, true);
|
|
167
203
|
await closeEnded({ team, root, dir, session, live, sources, say, io, told });
|
|
168
204
|
}
|
|
169
205
|
beat();
|
|
@@ -177,7 +213,7 @@ export async function runWatch(argv, io, sources) {
|
|
|
177
213
|
if (record?.watch?.pid === sources.pid)
|
|
178
214
|
delete record.watch;
|
|
179
215
|
});
|
|
180
|
-
|
|
216
|
+
tell(`the watch of "${session}" stopped`, true);
|
|
181
217
|
}
|
|
182
218
|
return 0;
|
|
183
219
|
}
|
|
@@ -198,7 +234,7 @@ function spendOf(outcomes) {
|
|
|
198
234
|
}
|
|
199
235
|
// Types the nudge, after reading the operator's screen once more: the pass saw it free, and a
|
|
200
236
|
// prompt may have appeared since. Anything but an empty idle prompt keeps the nudge pending.
|
|
201
|
-
function deliver(nudge, team, session, sources, memory, say) {
|
|
237
|
+
function deliver(nudge, team, session, sources, memory, say, tell, told) {
|
|
202
238
|
const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
|
|
203
239
|
const look = () => readScreen(cli, sources.screen(nudge.pane, session) ?? undefined).kind;
|
|
204
240
|
const status = sources.status(nudge.pane, session);
|
|
@@ -208,15 +244,29 @@ function deliver(nudge, team, session, sources, memory, say) {
|
|
|
208
244
|
memory.pending.push(...nudge.pending);
|
|
209
245
|
memory.pendingSince ??= sources.now().getTime();
|
|
210
246
|
};
|
|
247
|
+
const names = profileFor(cli)?.processNames ?? [];
|
|
248
|
+
const live = () => reportedLiveAgent(sources.foreground(nudge.pane, session), names);
|
|
249
|
+
const noAgent = 'nudge:no-agent';
|
|
250
|
+
if (!live()) {
|
|
251
|
+
tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
|
|
252
|
+
keep();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
told.delete(noAgent);
|
|
211
256
|
if ((status !== 'idle' && status !== 'done') || look() !== 'idle' || !sources.typeText(nudge.pane, nudge.text, session)) {
|
|
212
257
|
keep();
|
|
213
258
|
return;
|
|
214
259
|
}
|
|
215
|
-
// The text is in the box.
|
|
216
|
-
//
|
|
260
|
+
// The text is in the box. The agent is read again before Enter: it may have exited
|
|
261
|
+
// since the text was typed, and an unframed line is not a box to send.
|
|
262
|
+
if (!live()) {
|
|
263
|
+
tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
|
|
264
|
+
keep();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
217
267
|
const after = look();
|
|
218
268
|
if (after !== 'idle' && after !== 'unsent') {
|
|
219
|
-
|
|
269
|
+
tell('a nudge was typed and not sent: the operator\'s screen changed before the Enter', true);
|
|
220
270
|
keep();
|
|
221
271
|
return;
|
|
222
272
|
}
|
|
@@ -225,6 +275,12 @@ function deliver(nudge, team, session, sources, memory, say) {
|
|
|
225
275
|
else
|
|
226
276
|
keep();
|
|
227
277
|
}
|
|
278
|
+
function tellOnce(told, key, text, tell) {
|
|
279
|
+
if (told.has(key))
|
|
280
|
+
return;
|
|
281
|
+
told.add(key);
|
|
282
|
+
tell(text, true);
|
|
283
|
+
}
|
|
228
284
|
function noteWorked(dir, session, live) {
|
|
229
285
|
const recorded = readState(dir).sessions[session];
|
|
230
286
|
if (!recorded)
|
package/dist/file/types.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export type Seat = {
|
|
|
24
24
|
name: string;
|
|
25
25
|
cli: string;
|
|
26
26
|
vendor: string;
|
|
27
|
+
/**
|
|
28
|
+
* The account whose budget this seat spends, when one vendor has two (RFC 0003 § 3b). Absent
|
|
29
|
+
* means the vendor is the account; a seat that names one spends it instead of the vendor.
|
|
30
|
+
*/
|
|
31
|
+
account?: string;
|
|
27
32
|
model: string;
|
|
28
33
|
version: string;
|
|
29
34
|
display: string;
|
package/dist/file/validate.js
CHANGED
|
@@ -175,15 +175,18 @@ function readTeam(root, check) {
|
|
|
175
175
|
check.fail(item.line, `trust: "${item.value}" ${problem}`);
|
|
176
176
|
}
|
|
177
177
|
const trust = trustItems.map((item) => item.value);
|
|
178
|
+
// Both are read before the seats: a seat's `account:` must be a key of the budgets' accounts,
|
|
179
|
+
// and the check needs the names the file declares.
|
|
180
|
+
const watched = readWatch(top.get('watch'), check);
|
|
181
|
+
const budget = readBudgets(top.get('budgets'), check, watched.watch.quotaMarks, watched.legacyMarks);
|
|
178
182
|
const broken = new Set();
|
|
179
|
-
const seats = readSeats(top.get('seats'), check, trust, root.line, broken);
|
|
183
|
+
const seats = readSeats(top.get('seats'), check, trust, root.line, broken, budget.declared);
|
|
180
184
|
const workspace = readWorkspace(top.get('workspace'), check, project, trust, seats, root.line);
|
|
181
185
|
for (const seat of seats)
|
|
182
186
|
seat.mode ??= workspace.mode;
|
|
183
187
|
const coordinator = readLead(top.get('coordinator'), 'coordinator', seats, broken, check);
|
|
184
188
|
const operator = readLead(top.get('operator'), 'operator', seats, broken, check);
|
|
185
189
|
const limits = readLimits(top.get('limits'), check, seats.length);
|
|
186
|
-
const watched = readWatch(top.get('watch'), check);
|
|
187
190
|
return {
|
|
188
191
|
format: 1,
|
|
189
192
|
project,
|
|
@@ -197,7 +200,7 @@ function readTeam(root, check) {
|
|
|
197
200
|
trust,
|
|
198
201
|
workspace,
|
|
199
202
|
watch: watched.watch,
|
|
200
|
-
budgets:
|
|
203
|
+
budgets: budget.budgets,
|
|
201
204
|
machine: readMachine(top.get('machine'), check),
|
|
202
205
|
limits,
|
|
203
206
|
seats: seats,
|
|
@@ -287,8 +290,9 @@ function readPatterns(entry, name, check) {
|
|
|
287
290
|
return out;
|
|
288
291
|
}
|
|
289
292
|
// `broken` collects the names of seats left out for their own problems, so that a reference to
|
|
290
|
-
// one is not reported a second time.
|
|
291
|
-
|
|
293
|
+
// one is not reported a second time. `accounts` is the names the file's budgets declare: a seat
|
|
294
|
+
// that names an `account:` must name one of these, or it would silently spend nothing budgeted.
|
|
295
|
+
function readSeats(entry, check, trust, topLine, broken, accounts) {
|
|
292
296
|
if (!entry) {
|
|
293
297
|
check.fail(topLine, 'seats is required: at least the coordinator\'s and the operator\'s seat');
|
|
294
298
|
return [];
|
|
@@ -300,7 +304,7 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
300
304
|
const seats = [];
|
|
301
305
|
for (const item of entry.value.items) {
|
|
302
306
|
const fields = check.fields(item, 'a seat', [
|
|
303
|
-
'role', 'name', 'cli', 'vendor', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
|
|
307
|
+
'role', 'name', 'cli', 'vendor', 'account', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
|
|
304
308
|
'parked', 'stopped', 'count',
|
|
305
309
|
]);
|
|
306
310
|
if (item.kind !== 'map')
|
|
@@ -316,6 +320,14 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
316
320
|
check.fail(line, `${at}: cli is required`);
|
|
317
321
|
const cli = check.oneOf(fields.get('cli'), `${at}: cli`, CLIS);
|
|
318
322
|
const vendor = check.required(fields.get('vendor'), `${at}: vendor`, line);
|
|
323
|
+
// The account whose budget the seat spends, when the vendor's name is not it (§ 3b). Optional:
|
|
324
|
+
// absent, the seat spends its vendor, and the fingerprint of every file written so far is
|
|
325
|
+
// unchanged. Named, it must be one of the budgets' accounts: a typo would take the seat out of
|
|
326
|
+
// every budget with no warning at all.
|
|
327
|
+
const account = check.text(fields.get('account'), `${at}: account`);
|
|
328
|
+
if (account !== undefined && !accounts.has(account)) {
|
|
329
|
+
check.fail(fields.get('account')?.line ?? line, `${at}: account "${account}" is not in budgets.accounts`);
|
|
330
|
+
}
|
|
319
331
|
const model = check.required(fields.get('model'), `${at}: model`, line);
|
|
320
332
|
const version = readVersion(fields.get('version'), at, line, check);
|
|
321
333
|
const launch = check.required(fields.get('launch'), `${at}: launch`, line);
|
|
@@ -343,6 +355,7 @@ function readSeats(entry, check, trust, topLine, broken) {
|
|
|
343
355
|
declared: name,
|
|
344
356
|
count,
|
|
345
357
|
instance,
|
|
358
|
+
...(account ? { account } : {}),
|
|
346
359
|
...(mode ? { mode } : {}),
|
|
347
360
|
});
|
|
348
361
|
}
|
|
@@ -545,17 +558,24 @@ export function defaultBudgets() {
|
|
|
545
558
|
function readBudgets(entry, check, fallbackMarks, legacyMarks) {
|
|
546
559
|
const base = { ...defaultBudgets(), marks: fallbackMarks };
|
|
547
560
|
if (!entry)
|
|
548
|
-
return base;
|
|
561
|
+
return { budgets: base, declared: new Set() };
|
|
549
562
|
const fields = check.fields(entry.value, 'budgets', ['stale_after', 'check_every', 'marks', 'accounts']);
|
|
563
|
+
// Every name the accounts map writes, whether or not the account itself reads cleanly: a seat
|
|
564
|
+
// naming one of these is not also told it named nothing.
|
|
565
|
+
const accounts = fields.get('accounts');
|
|
566
|
+
const declared = new Set(accounts?.value.kind === 'map' ? accounts.value.entries.map((account) => account.key) : []);
|
|
550
567
|
const marks = percentList(fields.get('marks'), 'budgets.marks', check);
|
|
551
568
|
if (marks && legacyMarks) {
|
|
552
569
|
check.warnings.push({ line: fields.get('marks')?.line ?? entry.line, message: 'budgets.marks replaces watch.quota_marks' });
|
|
553
570
|
}
|
|
554
571
|
return {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
572
|
+
budgets: {
|
|
573
|
+
staleAfter: check.measure(fields.get('stale_after'), 'budgets.stale_after', DURATION, '30m') ?? base.staleAfter,
|
|
574
|
+
checkEvery: check.measure(fields.get('check_every'), 'budgets.check_every', DURATION, '10m') ?? base.checkEvery,
|
|
575
|
+
marks: marks ?? fallbackMarks,
|
|
576
|
+
accounts: readAccounts(fields.get('accounts'), check),
|
|
577
|
+
},
|
|
578
|
+
declared,
|
|
559
579
|
};
|
|
560
580
|
}
|
|
561
581
|
function percentList(entry, name, check) {
|
package/dist/herdr.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type HerdrAgent = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare function agentList(session?: string): HerdrAgent[] | null;
|
|
10
10
|
export declare function paneRootPid(pane: string, session?: string): number | null;
|
|
11
|
+
export declare function foregroundArgv0(result: unknown): string[] | null;
|
|
11
12
|
export declare function paneForeground(pane: string, session?: string): string[] | null;
|
|
12
13
|
export type HerdrWorkspace = {
|
|
13
14
|
id: string;
|
|
@@ -26,6 +27,9 @@ export declare function paneRun(pane: string, command: string, session?: string)
|
|
|
26
27
|
export declare function agentRename(pane: string, name: string, session?: string): boolean;
|
|
27
28
|
export declare function workspaceClose(workspace: string, session?: string): boolean;
|
|
28
29
|
export declare function sessionStop(name: string): boolean;
|
|
30
|
+
export type PaneExec = (args: string[]) => string;
|
|
31
|
+
/** Swaps the process call paneRead makes; null restores the real one. */
|
|
32
|
+
export declare function setPaneExec(exec: PaneExec | null): void;
|
|
29
33
|
export declare function paneRead(pane: string, lines: number, session?: string): string | null;
|
|
30
34
|
export declare function herdrCommand(session: string | undefined, ...args: string[]): string;
|
|
31
35
|
export declare function typeText(pane: string, text: string, session?: string): boolean;
|
package/dist/herdr.js
CHANGED
|
@@ -33,15 +33,19 @@ export function paneRootPid(pane, session) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
// argv0 of each foreground process, or null when the pane can't be
|
|
37
|
-
//
|
|
36
|
+
// argv0 of each foreground process, in herdr's order, or null when the pane can't be
|
|
37
|
+
// read. `name` is not the process: a Claude entry's name is its version. After a CLI
|
|
38
|
+
// exits, herdr keeps the pane and the foreground process is the shell.
|
|
39
|
+
export function foregroundArgv0(result) {
|
|
40
|
+
const list = result
|
|
41
|
+
?.process_info?.foreground_processes;
|
|
42
|
+
if (!Array.isArray(list))
|
|
43
|
+
return null;
|
|
44
|
+
return list.map((proc) => (typeof proc?.argv0 === 'string' ? proc.argv0 : ''));
|
|
45
|
+
}
|
|
38
46
|
export function paneForeground(pane, session) {
|
|
39
47
|
try {
|
|
40
|
-
|
|
41
|
-
const list = result.process_info?.foreground_processes;
|
|
42
|
-
if (!Array.isArray(list))
|
|
43
|
-
return null;
|
|
44
|
-
return list.map((proc) => (typeof proc.argv0 === 'string' ? proc.argv0 : ''));
|
|
48
|
+
return foregroundArgv0(run(['pane', 'process-info', '--pane', pane], session));
|
|
45
49
|
}
|
|
46
50
|
catch {
|
|
47
51
|
return null;
|
|
@@ -175,15 +179,40 @@ export function sessionStop(name) {
|
|
|
175
179
|
const body = parsed(out);
|
|
176
180
|
return body === null || body.type !== 'error';
|
|
177
181
|
}
|
|
182
|
+
const shellPaneExec = (args) => execFileSync('herdr', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
183
|
+
let paneExec = shellPaneExec;
|
|
184
|
+
/** Swaps the process call paneRead makes; null restores the real one. */
|
|
185
|
+
export function setPaneExec(exec) {
|
|
186
|
+
paneExec = exec ?? shellPaneExec;
|
|
187
|
+
}
|
|
178
188
|
// The visible lines of a pane, or null. `session` undefined reaches the caller's own server.
|
|
189
|
+
// The lines keep their ANSI styling, CRLF folded to LF: a greyed suggestion and typed text
|
|
190
|
+
// read the same as plain text, and only their styling tells them apart, so the readers that
|
|
191
|
+
// want plain text strip it (`stripSgr`). An herdr without the `--format` option refuses the
|
|
192
|
+
// styled call and is read by the exact call main makes today — no flag at all — where
|
|
193
|
+
// unstyled text can never read as dim and the placeholder list alone decides. A herdr that
|
|
194
|
+
// hangs is not asked twice: one timeout reads null.
|
|
179
195
|
export function paneRead(pane, lines, session) {
|
|
196
|
+
const full = [...(session ? ['--session', session] : []), 'pane', 'read', pane, '--source', 'visible', '--lines', String(lines)];
|
|
197
|
+
let styled;
|
|
180
198
|
try {
|
|
181
|
-
|
|
182
|
-
return execFileSync('herdr', full, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
199
|
+
styled = paneExec([...full, '--format', 'ansi']);
|
|
183
200
|
}
|
|
184
|
-
catch {
|
|
185
|
-
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (!(error instanceof Error))
|
|
203
|
+
return null;
|
|
204
|
+
// execFileSync reports its own timeout as an Error with code ETIMEDOUT — signal SIGTERM,
|
|
205
|
+
// status null, and no killed field. Observed under Bun, from a run, not from memory.
|
|
206
|
+
if (error.code === 'ETIMEDOUT')
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
return paneExec(full);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
186
214
|
}
|
|
215
|
+
return styled.replace(/\r\n/g, '\n').replace(/\r$/, '');
|
|
187
216
|
}
|
|
188
217
|
// The words to type for a herdr command, for a repair line.
|
|
189
218
|
export function herdrCommand(session, ...args) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportedLiveAgent(foreground: readonly string[] | null, processNames: readonly string[]): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Herdr reports a live agent when one foreground argv0 is that CLI. An unreadable
|
|
2
|
+
// pane, or the shell left after the CLI has exited, is not one: those are the panes
|
|
3
|
+
// a nudge, a first message, or an exit must not be typed into.
|
|
4
|
+
//
|
|
5
|
+
// Null is not a live agent, so nothing is typed. `paneStillRunning` reads that same
|
|
6
|
+
// null as still there — an unreadable list is not a departure, and the quota work
|
|
7
|
+
// on fix/quota-live-agent keeps that reading. The two questions stay separate.
|
|
8
|
+
export function reportedLiveAgent(foreground, processNames) {
|
|
9
|
+
if (!foreground || processNames.length === 0)
|
|
10
|
+
return false;
|
|
11
|
+
return foreground.some((name) => processNames.includes(name));
|
|
12
|
+
}
|
package/dist/launch/deliver.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ export interface Delivery {
|
|
|
3
3
|
status(): string | null;
|
|
4
4
|
type(text: string): boolean;
|
|
5
5
|
enter(): boolean;
|
|
6
|
+
/** Foreground argv0 names, or null when the pane can't be read. */
|
|
7
|
+
foreground(): string[] | null;
|
|
6
8
|
now(): number;
|
|
7
9
|
sleep(ms: number): Promise<void>;
|
|
8
10
|
}
|
|
9
11
|
/** A first message is accepted only after working is observed with the composer empty again. */
|
|
10
|
-
export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean>;
|
|
12
|
+
export declare function deliverRules(cli: string, text: string, seconds: number, io: Delivery): Promise<boolean | 'no-agent'>;
|
package/dist/launch/deliver.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { reportedLiveAgent } from "./agent.js";
|
|
2
|
+
import { profileFor } from "../profiles/index.js";
|
|
1
3
|
import { classifyComposer, readScreen } from "../watch/screen.js";
|
|
2
4
|
/** A first message is accepted only after working is observed with the composer empty again. */
|
|
3
5
|
export async function deliverRules(cli, text, seconds, io) {
|
|
6
|
+
const names = profileFor(cli)?.processNames ?? [];
|
|
7
|
+
const live = () => reportedLiveAgent(io.foreground(), names);
|
|
4
8
|
const free = () => ['idle', 'done'].includes(io.status() ?? '');
|
|
9
|
+
if (!live())
|
|
10
|
+
return 'no-agent';
|
|
5
11
|
if (!free() || readScreen(cli, io.screen()).kind !== 'idle' || !io.type(text))
|
|
6
12
|
return false;
|
|
7
13
|
const deadline = io.now() + seconds * 1000;
|
|
@@ -19,7 +25,10 @@ export async function deliverRules(cli, text, seconds, io) {
|
|
|
19
25
|
if (io.now() <= before)
|
|
20
26
|
return false;
|
|
21
27
|
}
|
|
22
|
-
// Re-read immediately before Enter
|
|
28
|
+
// Re-read immediately before Enter. The agent is asked again: it may have exited
|
|
29
|
+
// since the paste, and a dialog that appeared gets no key.
|
|
30
|
+
if (!live())
|
|
31
|
+
return 'no-agent';
|
|
23
32
|
if (!free() || readScreen(cli, io.screen()).kind !== 'unsent' || !io.enter())
|
|
24
33
|
return false;
|
|
25
34
|
for (;;) {
|
package/dist/launch/execute.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type Host = {
|
|
|
11
11
|
workspace: string;
|
|
12
12
|
} | null;
|
|
13
13
|
paneRun(session: string, pane: string, command: string): boolean;
|
|
14
|
-
typeLine(session: string, pane: string, text: string): boolean;
|
|
15
|
-
deliverRules?(session: string, pane: string, cli: string, text: string, seconds: number): Promise<boolean>;
|
|
14
|
+
typeLine(session: string, pane: string, text: string): boolean | 'no-agent';
|
|
15
|
+
deliverRules?(session: string, pane: string, cli: string, text: string, seconds: number): Promise<boolean | 'no-agent'>;
|
|
16
16
|
renameAgent(session: string, pane: string, name: string): boolean;
|
|
17
17
|
closeWorkspace(session: string, workspace: string): boolean;
|
|
18
18
|
stopSession(session: string): boolean;
|
package/dist/launch/execute.js
CHANGED
|
@@ -208,7 +208,13 @@ export async function executePlan(steps, session, host) {
|
|
|
208
208
|
if (op.notice)
|
|
209
209
|
host.say(`${op.seat}: ${op.notice}\n`);
|
|
210
210
|
const here = place(op.label, op.pane);
|
|
211
|
-
|
|
211
|
+
const delivered = here ? await host.deliverRules?.(session, here.pane, op.cli, op.rules, op.seconds) : false;
|
|
212
|
+
if (delivered === 'no-agent') {
|
|
213
|
+
dropped.add(op.seat);
|
|
214
|
+
finish(op.seat, 'no live agent in its pane; its rules were not delivered');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!here || !delivered) {
|
|
212
218
|
dropped.add(op.seat);
|
|
213
219
|
finish(op.seat, 'its rules were not delivered; left at named');
|
|
214
220
|
break;
|
|
@@ -230,7 +236,14 @@ export async function executePlan(steps, session, host) {
|
|
|
230
236
|
break;
|
|
231
237
|
}
|
|
232
238
|
case 'type': {
|
|
233
|
-
|
|
239
|
+
const typed = host.typeLine(session, op.pane, op.text);
|
|
240
|
+
if (typed === 'no-agent') {
|
|
241
|
+
held = true;
|
|
242
|
+
dropped.add(op.seat);
|
|
243
|
+
finish(op.seat, 'no live agent in its pane; its exit was not typed');
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
if (!typed) {
|
|
234
247
|
held = true;
|
|
235
248
|
dropped.add(op.seat);
|
|
236
249
|
finish(op.seat, 'its exit was not typed; left as it is');
|
package/dist/launch/rules.d.ts
CHANGED
|
@@ -21,5 +21,7 @@ export interface RulesInput {
|
|
|
21
21
|
* workspace's.
|
|
22
22
|
*/
|
|
23
23
|
export declare function seatRules(input: RulesInput): string[];
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
24
|
+
/** How the rules reach a seat: typed as its first message, or carried as a launch option. */
|
|
25
|
+
export type RulesDelivery = 'message' | 'option';
|
|
26
|
+
/** The rules as the one text a launch option or a first message carries, the closing line last. */
|
|
27
|
+
export declare function rulesText(input: RulesInput, delivery: RulesDelivery): string;
|
package/dist/launch/rules.js
CHANGED
|
@@ -30,7 +30,21 @@ export function seatRules(input) {
|
|
|
30
30
|
}
|
|
31
31
|
return [...own, ...input.rules, ...signing, ...working];
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The line each form ends with. The message says what the rules are and never that they are not a
|
|
35
|
+
* task, so some models start complying by investigating instead of waiting for a brief. A first
|
|
36
|
+
* message is answered once, so it can ask for the ready reply; a system prompt (a launch option)
|
|
37
|
+
* stays in force on every later turn, so it must not: a Claude seat could answer "ready" to its
|
|
38
|
+
* real brief.
|
|
39
|
+
*/
|
|
40
|
+
const CLOSING = {
|
|
41
|
+
message: 'These are standing rules, not a task: reply ready and wait for your brief.',
|
|
42
|
+
option: 'These are standing rules, not a task.',
|
|
43
|
+
};
|
|
44
|
+
/** The rules as the one text a launch option or a first message carries, the closing line last. */
|
|
45
|
+
export function rulesText(input, delivery) {
|
|
46
|
+
// A file whose own rules already carry the form's closing line gets it once: as the closing line.
|
|
47
|
+
const closing = CLOSING[delivery];
|
|
48
|
+
const rules = seatRules(input).filter((rule) => rule !== closing);
|
|
49
|
+
return ['Rules for this session, from the team file:', ...rules.map((rule) => `- ${rule}`), closing].join('\n');
|
|
36
50
|
}
|
|
@@ -43,6 +43,12 @@ screen:
|
|
|
43
43
|
- 'Do you trust the contents of this project\?'
|
|
44
44
|
- '>\s*Yes, I trust this folder'
|
|
45
45
|
below_last_rule: 'enter Confirm'
|
|
46
|
+
# The captured trust dialog fills the pane: there is no composer rule.
|
|
47
|
+
- all:
|
|
48
|
+
- 'Do you trust the contents of this project\?'
|
|
49
|
+
- '>\s*Yes, I trust this folder'
|
|
50
|
+
- 'enter Confirm'
|
|
51
|
+
without_rule: true
|
|
46
52
|
permission:
|
|
47
53
|
ignore_case: true
|
|
48
54
|
rules:
|
|
@@ -50,6 +56,13 @@ screen:
|
|
|
50
56
|
- 'Requesting permission for:'
|
|
51
57
|
- '^\s*>\s*[0-9]\.\s+Yes\b'
|
|
52
58
|
below_last_rule: '↑/↓ Navigate'
|
|
59
|
+
# A long command pushes the one rule out of the window. The dialog is the
|
|
60
|
+
# three lines, with no composer rule left to sit below.
|
|
61
|
+
- all:
|
|
62
|
+
- 'Requesting permission for:'
|
|
63
|
+
- '^\s*>\s*[0-9]\.\s+Yes\b'
|
|
64
|
+
- '↑/↓ Navigate'
|
|
65
|
+
without_rule: true
|
|
53
66
|
working:
|
|
54
67
|
- any: ['\bGenerating\.\.\.']
|
|
55
68
|
composer:
|
|
@@ -50,12 +50,38 @@ screen:
|
|
|
50
50
|
footer: 'Esc to cancel · Tab to amend'
|
|
51
51
|
below_last_rule: 'Esc to cancel · Tab to amend'
|
|
52
52
|
- below_last_rule: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
|
|
53
|
+
# A pane with no composer rule whose choice is the dialog, including the
|
|
54
|
+
# unmarked "1. Yes" while up is running. A choice quoted above an empty
|
|
55
|
+
# prompt is a transcript: the empty prompt comes after it.
|
|
56
|
+
- all:
|
|
57
|
+
- '^\s*❯?\s*[0-9]\. (Yes|No)\b'
|
|
58
|
+
without_rule: true
|
|
59
|
+
none_after:
|
|
60
|
+
anchor: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
|
|
61
|
+
patterns:
|
|
62
|
+
- '^\s*[❯›>]\s*$'
|
|
53
63
|
question:
|
|
64
|
+
# The phrase is the dialog's last line, as a permission footer is. Prose
|
|
65
|
+
# above the box is not a question.
|
|
66
|
+
- any:
|
|
67
|
+
- 'Enter to select'
|
|
68
|
+
- '↑/↓ to navigate'
|
|
69
|
+
- 'Enter to confirm'
|
|
70
|
+
- 'Esc to cancel'
|
|
71
|
+
on_footer: true
|
|
72
|
+
- below_last_rule: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
|
|
73
|
+
# The footer has a status line under it and the rule is out of the window.
|
|
74
|
+
# A phrase quoted above an empty prompt is a transcript.
|
|
54
75
|
- any:
|
|
55
76
|
- 'Enter to select'
|
|
56
77
|
- '↑/↓ to navigate'
|
|
57
78
|
- 'Enter to confirm'
|
|
58
79
|
- 'Esc to cancel'
|
|
80
|
+
without_rule: true
|
|
81
|
+
none_after:
|
|
82
|
+
anchor: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
|
|
83
|
+
patterns:
|
|
84
|
+
- '^\s*[❯›>]\s*$'
|
|
59
85
|
working:
|
|
60
86
|
- any:
|
|
61
87
|
- '\besc to interrupt\b'
|
|
@@ -64,6 +90,10 @@ screen:
|
|
|
64
90
|
mode: box-to-rule
|
|
65
91
|
prompt: '^\s*[❯›>]'
|
|
66
92
|
rule: '^\s*[─━]{8,}'
|
|
93
|
+
# Claude Code greys every suggestion it shows — "Try …", "Resume briefly.", any other —
|
|
94
|
+
# with faint styling, while typed text carries none. The list below stays the fallback
|
|
95
|
+
# for a source without styling.
|
|
96
|
+
placeholder_style: dim
|
|
67
97
|
placeholders:
|
|
68
98
|
- equals: ''
|
|
69
99
|
- prefix: 'Try "'
|
|
@@ -48,8 +48,11 @@ screen:
|
|
|
48
48
|
- '^\s*→'
|
|
49
49
|
- '^\s+Grok\s+[0-9]'
|
|
50
50
|
working:
|
|
51
|
+
# The running turn is the braille spinner, or the prompt line ending in
|
|
52
|
+
# "ctrl+c to stop". Idle and rules-accepted captures do not paint that suffix.
|
|
53
|
+
# The same words quoted in the transcript are not a turn.
|
|
51
54
|
- any:
|
|
52
|
-
- 'ctrl\+c to stop'
|
|
55
|
+
- '^\s*→.*\s{2,}ctrl\+c to stop\s*$'
|
|
53
56
|
- '^\s*[⠀-⣿]+\s+(Working|Thinking)\b'
|
|
54
57
|
composer:
|
|
55
58
|
mode: status-then-one
|