team 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -12
- 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 +80 -8
- package/dist/approve/fingerprint.d.ts +29 -6
- package/dist/approve/fingerprint.js +57 -32
- package/dist/budgets/gate.d.ts +5 -4
- package/dist/budgets/gate.js +23 -14
- package/dist/budgets/readings.d.ts +57 -9
- package/dist/budgets/readings.js +134 -18
- package/dist/budgets/run.js +8 -2
- package/dist/budgets/table.d.ts +4 -2
- package/dist/budgets/table.js +29 -11
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +8 -5
- package/dist/commands/add.d.ts +0 -1
- package/dist/commands/add.js +18 -9
- package/dist/commands/approve.js +17 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +128 -8
- package/dist/commands/down.d.ts +6 -2
- package/dist/commands/down.js +40 -14
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +4 -2
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +26 -4
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +22 -3
- package/dist/commands/up.d.ts +3 -1
- package/dist/commands/up.js +12 -6
- package/dist/commands/watch.d.ts +4 -0
- package/dist/commands/watch.js +99 -19
- package/dist/conformance/adapter.d.ts +5 -0
- package/dist/conformance/adapter.js +44 -0
- package/dist/file/check.d.ts +20 -0
- package/dist/file/check.js +112 -0
- package/dist/file/lines.js +13 -2
- package/dist/file/sections/budgets.d.ts +15 -0
- package/dist/file/sections/budgets.js +180 -0
- package/dist/file/sections/coordinator.d.ts +2 -0
- package/dist/file/sections/coordinator.js +11 -0
- package/dist/file/sections/format.d.ts +2 -0
- package/dist/file/sections/format.js +13 -0
- package/dist/file/sections/identity.d.ts +2 -0
- package/dist/file/sections/identity.js +103 -0
- package/dist/file/sections/index.d.ts +9 -0
- package/dist/file/sections/index.js +43 -0
- package/dist/file/sections/lead.d.ts +4 -0
- package/dist/file/sections/lead.js +18 -0
- package/dist/file/sections/limits.d.ts +2 -0
- package/dist/file/sections/limits.js +43 -0
- package/dist/file/sections/machine.d.ts +2 -0
- package/dist/file/sections/machine.js +38 -0
- package/dist/file/sections/operator.d.ts +2 -0
- package/dist/file/sections/operator.js +11 -0
- package/dist/file/sections/project.d.ts +2 -0
- package/dist/file/sections/project.js +13 -0
- package/dist/file/sections/rules.d.ts +2 -0
- package/dist/file/sections/rules.js +9 -0
- package/dist/file/sections/seats.d.ts +7 -0
- package/dist/file/sections/seats.js +161 -0
- package/dist/file/sections/section.d.ts +32 -0
- package/dist/file/sections/section.js +7 -0
- package/dist/file/sections/session.d.ts +2 -0
- package/dist/file/sections/session.js +16 -0
- package/dist/file/sections/tools.d.ts +2 -0
- package/dist/file/sections/tools.js +43 -0
- package/dist/file/sections/trust.d.ts +2 -0
- package/dist/file/sections/trust.js +20 -0
- package/dist/file/sections/units.d.ts +16 -0
- package/dist/file/sections/units.js +8 -0
- package/dist/file/sections/visibility.d.ts +2 -0
- package/dist/file/sections/visibility.js +12 -0
- package/dist/file/sections/watch-checks.d.ts +9 -0
- package/dist/file/sections/watch-checks.js +14 -0
- package/dist/file/sections/watch.d.ts +14 -0
- package/dist/file/sections/watch.js +111 -0
- package/dist/file/sections/workspace.d.ts +4 -0
- package/dist/file/sections/workspace.js +77 -0
- package/dist/file/types.d.ts +6 -0
- package/dist/file/validate.d.ts +4 -12
- package/dist/file/validate.js +45 -668
- package/dist/herdr.d.ts +5 -0
- package/dist/herdr.js +51 -12
- package/dist/launch/agent.d.ts +1 -0
- package/dist/launch/agent.js +13 -0
- package/dist/launch/deliver.d.ts +7 -1
- package/dist/launch/deliver.js +159 -7
- package/dist/launch/execute.d.ts +4 -2
- package/dist/launch/execute.js +36 -13
- package/dist/launch/plan.js +16 -2
- package/dist/launch/rules.d.ts +4 -2
- package/dist/launch/rules.js +17 -3
- package/dist/profiles/antigravity.yaml +25 -2
- package/dist/profiles/claude-code.yaml +51 -6
- package/dist/profiles/codex.yaml +14 -1
- package/dist/profiles/cursor.yaml +46 -3
- package/dist/profiles/overrides.d.ts +67 -0
- package/dist/profiles/overrides.js +190 -0
- package/dist/profiles/profile.d.ts +3 -0
- package/dist/profiles/profile.js +5 -1
- package/dist/state.d.ts +9 -4
- package/dist/state.js +37 -0
- package/dist/status/compare.js +7 -4
- package/dist/status/statusline.js +3 -1
- package/dist/store/store.d.ts +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/watch/check.d.ts +1 -0
- package/dist/watch/checks/budget.js +50 -51
- package/dist/watch/dialect.d.ts +6 -1
- package/dist/watch/dialect.js +68 -12
- package/dist/watch/pass.d.ts +10 -2
- package/dist/watch/pass.js +70 -29
- package/dist/watch/screen-core.d.ts +41 -4
- package/dist/watch/screen-core.js +510 -89
- package/dist/watch/screen-data.d.ts +34 -0
- package/dist/watch/screen-file.d.ts +8 -2
- package/dist/watch/screen-file.js +344 -39
- package/dist/watch/screen-profile.d.ts +19 -0
- package/dist/watch/screen-profile.js +1 -0
- package/dist/watch/screen.d.ts +25 -6
- package/dist/watch/screen.js +48 -9
- package/examples/team.yaml +8 -0
- package/package.json +9 -3
- package/schema/team.schema.json +498 -0
package/dist/commands/add.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
|
-
import { approvalDifferences, budgetsInForce } from "../approve/approval.js";
|
|
4
|
+
import { approvalDifferences, budgetsInForce, recordSeatDigest } from "../approve/approval.js";
|
|
5
5
|
import { readArgs } from "../args.js";
|
|
6
6
|
import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
|
|
7
7
|
import { blocksLaunch, doctorFindings, realSources as doctorSources } from "../commands/doctor.js";
|
|
@@ -13,7 +13,7 @@ import { clearStopped, hasSeat, restoreSeat, seatIsStopped } from "../file/lines
|
|
|
13
13
|
import { loadTeamFile, placedProblems } from "../file/load.js";
|
|
14
14
|
import { validateTeamFile } from "../file/validate.js";
|
|
15
15
|
import { writeTeamFile } from "../file/write.js";
|
|
16
|
-
import { agentList, agentRename, paneRead, paneRun, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
|
|
16
|
+
import { agentList, agentRename, paneForeground, paneRead, paneRun, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
|
|
17
17
|
import { executePlan } from "../launch/execute.js";
|
|
18
18
|
import { formatPlan, upPlan } from "../launch/plan.js";
|
|
19
19
|
import { logLine } from "../log.js";
|
|
@@ -36,6 +36,7 @@ const realLaunch = {
|
|
|
36
36
|
return agents === null ? null : agents.map((agent) => agent.pane);
|
|
37
37
|
},
|
|
38
38
|
paneText: (session, pane) => paneRead(pane, 200, aim(session)),
|
|
39
|
+
foreground: (session, pane) => paneForeground(pane, aim(session)),
|
|
39
40
|
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
40
41
|
now: () => new Date(),
|
|
41
42
|
};
|
|
@@ -48,7 +49,7 @@ export const realSources = {
|
|
|
48
49
|
agents: (session) => agentList(aim(session)),
|
|
49
50
|
workspaces(session) {
|
|
50
51
|
const listed = workspaceList(aim(session));
|
|
51
|
-
return listed === null ? null : listed.map((workspace) => ({ id: workspace.id
|
|
52
|
+
return listed === null ? null : listed.map((workspace) => ({ id: workspace.id }));
|
|
52
53
|
},
|
|
53
54
|
doctor: doctorSources,
|
|
54
55
|
machine: readMachine,
|
|
@@ -122,6 +123,7 @@ export async function runAdd(argv, io, sources = realSources) {
|
|
|
122
123
|
return 1;
|
|
123
124
|
}
|
|
124
125
|
const agents = live === 'running' ? sources.agents(session) : [];
|
|
126
|
+
// Read so a session whose workspace list can't be read is refused. The title is not a key.
|
|
125
127
|
const workspaces = live === 'running' ? sources.workspaces(session) : [];
|
|
126
128
|
if (agents === null || workspaces === null) {
|
|
127
129
|
io.stderr(`team add: session ${session} runs, and its agents can't be read\n`);
|
|
@@ -168,7 +170,10 @@ export async function runAdd(argv, io, sources = realSources) {
|
|
|
168
170
|
const doctorTeam = built.temporary
|
|
169
171
|
? { ...prepared.team, seats: prepared.team.seats.map((item) => item.name === built.temporary?.like ? { ...item, stopped: false } : item) }
|
|
170
172
|
: prepared.team;
|
|
171
|
-
|
|
173
|
+
// `team` is the file on disk, already the approved one. `doctorTeam` is the
|
|
174
|
+
// seat about to run, so a stopped seat's CLI is still checked. The digest is
|
|
175
|
+
// recorded with the write, after a refusal has left the file alone.
|
|
176
|
+
for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings, team)) {
|
|
172
177
|
if (blocksLaunch(finding)) {
|
|
173
178
|
io.stderr(`team add: ${finding.text}\n`);
|
|
174
179
|
return 1;
|
|
@@ -198,8 +203,8 @@ export async function runAdd(argv, io, sources = realSources) {
|
|
|
198
203
|
// The gate reads the approved budgets, never the edited file's: the seat this `add` inserts
|
|
199
204
|
// changes no section of its own, and no unapproved reserve may unblock a launch (#50).
|
|
200
205
|
const budgets = budgetsInForce(prepared.team, root, sources.home);
|
|
201
|
-
const decision = seatBudget(budgets, loadReadings(dir
|
|
202
|
-
const stray = unnamedIn(built.
|
|
206
|
+
const decision = seatBudget(budgets, loadReadings(dir), built.seat, sources.now().getTime(), loadSpendReadings(dir));
|
|
207
|
+
const stray = unnamedIn(recorded.seats[built.name]?.workspace, agents);
|
|
203
208
|
const starting = seatPlan(prepared.team, built.seat, start);
|
|
204
209
|
const planned = stray
|
|
205
210
|
? { ...starting, stage: 'launched', pane: stray.pane, workspace: stray.workspace, agentLive: true }
|
|
@@ -246,6 +251,9 @@ export async function runAdd(argv, io, sources = realSources) {
|
|
|
246
251
|
io.stderr(`team add: ${where(problem)}${problem.message}\n`);
|
|
247
252
|
return 2;
|
|
248
253
|
}
|
|
254
|
+
const parsed = validateTeamFile(built.edited);
|
|
255
|
+
if (parsed.ok)
|
|
256
|
+
recordSeatDigest(parsed.team, root, built.name, sources.home);
|
|
249
257
|
}
|
|
250
258
|
const plan = upPlan({ root, session, sessionRunning: live === 'running', seats: [seatForPlan], watchAlive: true });
|
|
251
259
|
const who = describeCaller(caller);
|
|
@@ -347,9 +355,10 @@ function seatPlan(team, seat, start) {
|
|
|
347
355
|
...(start.lobby ? { lobby: true } : {}),
|
|
348
356
|
};
|
|
349
357
|
}
|
|
350
|
-
function unnamedIn(
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
function unnamedIn(workspace, agents) {
|
|
359
|
+
if (!workspace)
|
|
360
|
+
return undefined;
|
|
361
|
+
return agents.find((agent) => !agent.name && agent.workspace === workspace);
|
|
353
362
|
}
|
|
354
363
|
function runningOf(agents, team, state) {
|
|
355
364
|
const seats = new Map(team.seats.map((seat) => [seat.name, seat]));
|
package/dist/commands/approve.js
CHANGED
|
@@ -11,6 +11,7 @@ import { callerOf, describeCaller, isOwner } from "../caller.js";
|
|
|
11
11
|
import { loadTeamFile, placedProblems } from "../file/load.js";
|
|
12
12
|
import { validateTeamFile } from "../file/validate.js";
|
|
13
13
|
import { logLine } from "../log.js";
|
|
14
|
+
import { OVERRIDE_CHANGED, overrideFile } from "../profiles/overrides.js";
|
|
14
15
|
import { readApproval, storePath, writeApproval } from "../store/store.js";
|
|
15
16
|
export const realSources = {
|
|
16
17
|
async ask(question) {
|
|
@@ -86,6 +87,12 @@ export async function runApprove(argv, io, sources) {
|
|
|
86
87
|
for (const warning of checked.warnings)
|
|
87
88
|
io.stderr(`team approve: warning, line ${warning.line}: ${warning.message}\n`);
|
|
88
89
|
const store = storePath(team.project, root, sources.home);
|
|
90
|
+
const live = overrideFile(team.project, root, sources.home);
|
|
91
|
+
if (live.problems.length) {
|
|
92
|
+
for (const problem of live.problems)
|
|
93
|
+
io.stderr(`team approve: ${problem}\n`);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
89
96
|
const problem = storeProblem(store, root, team);
|
|
90
97
|
if (problem) {
|
|
91
98
|
io.stderr(`team approve: ${problem}\n`);
|
|
@@ -119,6 +126,15 @@ export async function runApprove(argv, io, sources) {
|
|
|
119
126
|
io.stdout(`${path}: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
|
|
120
127
|
io.stdout(`${lines.map((line) => ` ${line}`).join('\n')}\n\n`);
|
|
121
128
|
}
|
|
129
|
+
const recorded = Object.hasOwn(previous.approval, 'overrides') ? previous.approval.overrides ?? null : null;
|
|
130
|
+
if (recorded !== live.text) {
|
|
131
|
+
changes.push(OVERRIDE_CHANGED);
|
|
132
|
+
const overrideLines = formatDiff(recorded ?? '', live.text ?? '');
|
|
133
|
+
if (overrideLines.length) {
|
|
134
|
+
io.stdout(`overrides.yaml: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
|
|
135
|
+
io.stdout(`${overrideLines.map((line) => ` ${line}`).join('\n')}\n\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
122
138
|
io.stdout(changes.length ? `Needs a new approval: ${changes.join('; ')}.\n` : 'Nothing in it needs a new approval.\n');
|
|
123
139
|
if (ceilingsLine(previous.approval.ceilings) !== ceilingsLine(ceilings)) {
|
|
124
140
|
io.stdout(`Ceilings approved: ${ceilingsLine(previous.approval.ceilings)}.\n`);
|
|
@@ -139,7 +155,7 @@ export async function runApprove(argv, io, sources) {
|
|
|
139
155
|
return 1;
|
|
140
156
|
}
|
|
141
157
|
const now = sources.now();
|
|
142
|
-
writeApproval(store, { approval: approvalOf(team, root, now, resolved.checks), file: text }, team.seats);
|
|
158
|
+
writeApproval(store, { approval: { ...approvalOf(team, root, now, resolved.checks), overrides: live.text }, file: text }, team.seats);
|
|
143
159
|
logLine(dirname(path), 'approve', describeCaller(caller), `approved ${seats} seats; ceilings: ${ceilingsLine(ceilings)}`, now);
|
|
144
160
|
io.stdout(`Approved. The record is in ${store}; check the rest with \`team doctor\`.\n`);
|
|
145
161
|
return 0;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Caller } from '../caller.ts';
|
|
1
2
|
import type { TeamFile } from '../file/types.ts';
|
|
2
3
|
import type { Command, Io } from '../io.ts';
|
|
3
4
|
import { type Profile } from '../profiles/profile.ts';
|
|
@@ -9,6 +10,7 @@ export type DoctorSources = {
|
|
|
9
10
|
sessionRunning(session: string): boolean | null;
|
|
10
11
|
now(): Date;
|
|
11
12
|
home: string;
|
|
13
|
+
runCheck?(path: string): string | null;
|
|
12
14
|
};
|
|
13
15
|
export type CommandRunner = (binary: string, args: string[]) => {
|
|
14
16
|
status: number | null;
|
|
@@ -30,9 +32,23 @@ export type Finding = {
|
|
|
30
32
|
export declare function blocksLaunch(finding: Finding): boolean;
|
|
31
33
|
export declare const doctor: Command;
|
|
32
34
|
export default doctor;
|
|
35
|
+
/**
|
|
36
|
+
* The budget checks, one line per account, plus the accounts nothing can read. The checks run
|
|
37
|
+
* the way the watch runs them (RFC 0003 § 5): the approved command's file, hashed again before
|
|
38
|
+
* it runs, in a clean environment with the watch's timeout. The raw output is parsed and
|
|
39
|
+
* dropped — never printed, never logged, never written anywhere. Only the owner's `doctor` runs
|
|
40
|
+
* a check, for the same reason the watch is the owner's: a check reads a vendor home. Any other
|
|
41
|
+
* caller gets the same report without the readings, and the one line that says so.
|
|
42
|
+
*/
|
|
43
|
+
export declare function budgetCheckFindings(team: TeamFile, root: string, sources: DoctorSources, caller: Caller): Finding[];
|
|
44
|
+
export declare function seatNameFindings(team: TeamFile, session: string): Finding[];
|
|
33
45
|
export declare function doctorFindings(team: TeamFile, root: string, dir: string, session: string, sources: DoctorSources, warnings: {
|
|
34
46
|
line: number;
|
|
35
47
|
message: string;
|
|
36
|
-
}[]
|
|
48
|
+
}[],
|
|
49
|
+
/** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
|
|
50
|
+
approved?: TeamFile,
|
|
51
|
+
/** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
|
|
52
|
+
budgetChecks?: Finding[]): Finding[];
|
|
37
53
|
export declare function doctorLoginFindings(team: TeamFile, sources: DoctorSources): Finding[];
|
|
38
54
|
export declare function runDoctor(argv: string[], io: Io, sources: DoctorSources): Promise<number>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { accessSync, constants, readFileSync, statSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
5
6
|
import { approvalDifferences, budgetsInForce, watchInForce } from "../approve/approval.js";
|
|
6
7
|
import { readArgs } from "../args.js";
|
|
7
|
-
import {
|
|
8
|
+
import { callerOf, isOwner } from "../caller.js";
|
|
9
|
+
import { checkCommands } from "../budgets/checks.js";
|
|
10
|
+
import { parseOutput, runCommand } from "../budgets/run.js";
|
|
8
11
|
import { loadTeamFile } from "../file/load.js";
|
|
9
12
|
import { HERDR_TESTED, herdrVersion, sessionRunning } from "../herdr.js";
|
|
10
13
|
import { profileFor } from "../profiles/index.js";
|
|
14
|
+
import { overridesInForce, quotaWith } from "../profiles/overrides.js";
|
|
11
15
|
import { versionVerdict } from "../profiles/profile.js";
|
|
12
16
|
import { readState } from "../state.js";
|
|
13
17
|
import { readApproval, storePath } from "../store/store.js";
|
|
@@ -47,6 +51,7 @@ export const realSources = {
|
|
|
47
51
|
sessionRunning,
|
|
48
52
|
now: () => new Date(),
|
|
49
53
|
home: homedir(),
|
|
54
|
+
runCheck: (path) => runCommand(path),
|
|
50
55
|
};
|
|
51
56
|
export const USAGE = 'Usage: team doctor [--session <name>] [--file <path>] [--login]\n';
|
|
52
57
|
// A missing or stale watch is not a reason for `up` to refuse: `up` starts the watch itself.
|
|
@@ -75,13 +80,100 @@ function launchBinary(launch) {
|
|
|
75
80
|
.split(/\s+/)
|
|
76
81
|
.find((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) ?? null);
|
|
77
82
|
}
|
|
83
|
+
// The approved bytes rule, as the watch applies it (budgets/checks.ts): a check whose file no
|
|
84
|
+
// longer hashes to the approval is not the approved check, and is not run.
|
|
85
|
+
function changedSinceApproval(check) {
|
|
86
|
+
try {
|
|
87
|
+
if (!statSync(check.path).isFile())
|
|
88
|
+
return true;
|
|
89
|
+
return createHash('sha256').update(readFileSync(check.path)).digest('hex') !== check.hash;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
78
95
|
function checkFindings(team, root, home) {
|
|
79
96
|
const record = readApproval(storePath(team.project, root, home));
|
|
80
97
|
if (!record)
|
|
81
98
|
return [];
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
const approved = record.approval.checks;
|
|
100
|
+
return checkCommands(budgetsInForce(team, root, home)).flatMap(({ account }) => {
|
|
101
|
+
const known = approved?.[account];
|
|
102
|
+
if (!known) {
|
|
103
|
+
return [{ level: 'warn', text: `the check for ${account} is unapproved; that account reads unknown` }];
|
|
104
|
+
}
|
|
105
|
+
if (changedSinceApproval(known)) {
|
|
106
|
+
return [{ level: 'warn', text: `the check for ${account} changed after approval and was not run; that account reads unknown` }];
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// The CLIs of the seats that spend an account: its own `account:` when the file names one, its
|
|
112
|
+
// vendor otherwise — the gate's resolution.
|
|
113
|
+
function clisOf(team, account) {
|
|
114
|
+
return team.seats.filter((seat) => (seat.account ?? seat.vendor) === account).map((seat) => seat.cli);
|
|
115
|
+
}
|
|
116
|
+
function figureOf(reading) {
|
|
117
|
+
if (reading.kind === 'spend')
|
|
118
|
+
return `${reading.amount.toFixed(2)} ${reading.currency}`;
|
|
119
|
+
return reading.windows.map((one) => `${one.window} ${one.used}% used`).join(', ');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The budget checks, one line per account, plus the accounts nothing can read. The checks run
|
|
123
|
+
* the way the watch runs them (RFC 0003 § 5): the approved command's file, hashed again before
|
|
124
|
+
* it runs, in a clean environment with the watch's timeout. The raw output is parsed and
|
|
125
|
+
* dropped — never printed, never logged, never written anywhere. Only the owner's `doctor` runs
|
|
126
|
+
* a check, for the same reason the watch is the owner's: a check reads a vendor home. Any other
|
|
127
|
+
* caller gets the same report without the readings, and the one line that says so.
|
|
128
|
+
*/
|
|
129
|
+
export function budgetCheckFindings(team, root, sources, caller) {
|
|
130
|
+
const budgets = budgetsInForce(team, root, sources.home);
|
|
131
|
+
const findings = [];
|
|
132
|
+
// An account nothing can read: its sources name the status line but no seat of its CLIs
|
|
133
|
+
// ships a quota pattern for it, or they name a check with no command. A file that names a
|
|
134
|
+
// `check` source without a command is refused on read; the guard is for the type, not the file.
|
|
135
|
+
for (const [name, account] of Object.entries(budgets.accounts)) {
|
|
136
|
+
const patterns = overridesInForce(team.project, root, sources.home).profiles;
|
|
137
|
+
const noPattern = account.sources.includes('status_line') && !clisOf(team, name).some((cli) => quotaWith(cli, patterns).some((one) => one.account === name));
|
|
138
|
+
const noCommand = account.sources.includes('check') && account.check === null;
|
|
139
|
+
if (noPattern || noCommand)
|
|
140
|
+
findings.push({ level: 'warn', text: `${name}: no pattern can read this account` });
|
|
141
|
+
}
|
|
142
|
+
const commands = checkCommands(budgets);
|
|
143
|
+
if (!isOwner(caller)) {
|
|
144
|
+
if (commands.length)
|
|
145
|
+
findings.push({ level: 'note', text: 'the budget checks were not run: only the owner runs them' });
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
148
|
+
const record = readApproval(storePath(team.project, root, sources.home));
|
|
149
|
+
if (!record)
|
|
150
|
+
return findings;
|
|
151
|
+
const run = sources.runCheck ?? runCommand;
|
|
152
|
+
const now = sources.now().getTime();
|
|
153
|
+
for (const { account } of commands) {
|
|
154
|
+
const known = record.approval.checks?.[account];
|
|
155
|
+
const entry = budgets.accounts[account];
|
|
156
|
+
// Unapproved and changed checks are said above, and never run.
|
|
157
|
+
if (!known || !entry || changedSinceApproval(known))
|
|
158
|
+
continue;
|
|
159
|
+
const text = run(known.path);
|
|
160
|
+
if (text === null) {
|
|
161
|
+
findings.push({ level: 'warn', text: `the check for ${account} failed or timed out; that account reads unknown` });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const reading = parseOutput(entry, text, now);
|
|
165
|
+
findings.push(reading
|
|
166
|
+
? { level: 'ok', text: `the check for ${account} reads ${figureOf(reading)}` }
|
|
167
|
+
: { level: 'warn', text: `the check for ${account} broke its output contract; that account reads unknown` });
|
|
168
|
+
}
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
171
|
+
function overrideFindings(team, root, home) {
|
|
172
|
+
const report = overridesInForce(team.project, root, home);
|
|
173
|
+
return [
|
|
174
|
+
...report.differences.map((line) => ({ level: 'miss', text: `run \`team approve\`: ${line}` })),
|
|
175
|
+
...report.problems.map((line) => ({ level: 'miss', text: line })),
|
|
176
|
+
];
|
|
85
177
|
}
|
|
86
178
|
function approvalFindings(team, root, home) {
|
|
87
179
|
const differences = approvalDifferences(team, root, home);
|
|
@@ -150,12 +242,40 @@ function watchFindings(watch, dir, session, running, now) {
|
|
|
150
242
|
}
|
|
151
243
|
return [{ level: 'ok', text: 'the watch is running' }];
|
|
152
244
|
}
|
|
153
|
-
|
|
245
|
+
// The session already carries the project, so a seat whose name or label repeats either is a
|
|
246
|
+
// warning, never a refusal. One line per seat, naming the field.
|
|
247
|
+
export function seatNameFindings(team, session) {
|
|
248
|
+
const needles = [...new Set([team.project, session].filter((value) => value !== ''))];
|
|
249
|
+
const findings = [];
|
|
250
|
+
for (const seat of team.seats) {
|
|
251
|
+
const hits = ['name', 'label'].map((field) => ({
|
|
252
|
+
field,
|
|
253
|
+
found: needles.filter((needle) => seat[field].toLowerCase().includes(needle.toLowerCase())),
|
|
254
|
+
})).filter((hit) => hit.found.length > 0);
|
|
255
|
+
if (hits.length === 0)
|
|
256
|
+
continue;
|
|
257
|
+
const same = hits.length === 2 && hits[0]?.found.join('\0') === hits[1]?.found.join('\0');
|
|
258
|
+
const which = same
|
|
259
|
+
? `its name and its label repeat ${quoted(hits[0]?.found ?? [])}`
|
|
260
|
+
: hits.map((hit) => `its ${hit.field} repeats ${quoted(hit.found)}`).join(' and ');
|
|
261
|
+
findings.push({ level: 'warn', text: `${seat.name}: ${which}; the session already carries it` });
|
|
262
|
+
}
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
function quoted(values) {
|
|
266
|
+
return values.map((value) => `"${value}"`).join(' and ');
|
|
267
|
+
}
|
|
268
|
+
export function doctorFindings(team, root, dir, session, sources, warnings,
|
|
269
|
+
/** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
|
|
270
|
+
approved = team,
|
|
271
|
+
/** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
|
|
272
|
+
budgetChecks = []) {
|
|
154
273
|
const findings = warnings.map((warning) => ({
|
|
155
274
|
level: 'warn',
|
|
156
275
|
text: `the file, line ${warning.line}: ${warning.message}`,
|
|
157
276
|
}));
|
|
158
|
-
findings.push(...
|
|
277
|
+
findings.push(...seatNameFindings(team, session));
|
|
278
|
+
findings.push(...approvalFindings(approved, root, sources.home), ...overrideFindings(team, root, sources.home), ...checkFindings(team, root, sources.home), ...budgetChecks);
|
|
159
279
|
const herdr = sources.herdrVersion();
|
|
160
280
|
const running = herdr === null ? null : sources.sessionRunning(session);
|
|
161
281
|
if (herdr === null)
|
|
@@ -220,7 +340,7 @@ export async function runDoctor(argv, io, sources) {
|
|
|
220
340
|
const session = args.values.session ?? team.session;
|
|
221
341
|
const findings = args.flags.has('login')
|
|
222
342
|
? doctorLoginFindings(team, sources)
|
|
223
|
-
: doctorFindings(team, root, dirname(loaded.path), session, sources, loaded.warnings);
|
|
343
|
+
: doctorFindings(team, root, dirname(loaded.path), session, sources, loaded.warnings, undefined, budgetCheckFindings(team, root, sources, callerOf(io)));
|
|
224
344
|
const label = { ok: 'ok ', warn: 'warn', miss: 'MISS', note: '-- ' };
|
|
225
345
|
io.stdout(findings.map((finding) => `${label[finding.level]} ${finding.text}\n`).join(''));
|
|
226
346
|
const missing = findings.filter((finding) => finding.level === 'miss').length;
|
package/dist/commands/down.d.ts
CHANGED
|
@@ -8,10 +8,12 @@ export type DownSources = {
|
|
|
8
8
|
alive(pid: number): boolean;
|
|
9
9
|
/** Herdr's status is not enough: a permission prompt is reported as idle. */
|
|
10
10
|
screen(session: string, pane: string, cli: string): Screen;
|
|
11
|
+
/** The pane's raw text, for reading the input box back before any Enter. */
|
|
12
|
+
screenText(session: string, pane: string, cli: string): string | undefined;
|
|
11
13
|
/** Herdr's own status for the pane. The Enter waits for idle or done. */
|
|
12
14
|
status(session: string, pane: string): string | null;
|
|
13
|
-
/** Foreground
|
|
14
|
-
foreground
|
|
15
|
+
/** Foreground argv0 names, or null when the pane can't be read. */
|
|
16
|
+
foreground(session: string, pane: string): string[] | null;
|
|
15
17
|
now(): Date;
|
|
16
18
|
sleep?(ms: number): Promise<void>;
|
|
17
19
|
launch?: DownLaunch;
|
|
@@ -22,6 +24,8 @@ export type DownLaunch = {
|
|
|
22
24
|
agentPanes(session: string): string[] | null;
|
|
23
25
|
closeWorkspace(session: string, workspace: string): boolean;
|
|
24
26
|
stopSession(session: string): boolean;
|
|
27
|
+
/** Clears the session this run stopped, so a later `up` starts from the beginning. */
|
|
28
|
+
deleteSession(session: string): boolean;
|
|
25
29
|
kill(pid: number): boolean;
|
|
26
30
|
sleep(ms: number): Promise<void>;
|
|
27
31
|
now(): Date;
|
package/dist/commands/down.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readArgs } from "../args.js";
|
|
2
2
|
import { callerOf, describeCaller, mayChangeTeam } from "../caller.js";
|
|
3
3
|
import { currentTeam } from "../file/current.js";
|
|
4
|
-
import { agentList, agentStatus, paneForeground, paneRead, sessionRunning, pressEnter, sessionStop, typeText, workspaceClose, } from "../herdr.js";
|
|
4
|
+
import { agentList, agentStatus, paneForeground, paneRead, sessionDelete, sessionRunning, pressEnter, sessionStop, typeText, workspaceClose, } from "../herdr.js";
|
|
5
5
|
import { executePlan } from "../launch/execute.js";
|
|
6
|
+
import { reportedLiveAgent } from "../launch/agent.js";
|
|
6
7
|
import { downPlan, formatPlan } from "../launch/plan.js";
|
|
8
|
+
import { boxHoldsText } from "../launch/deliver.js";
|
|
7
9
|
import { profileFor } from "../profiles/index.js";
|
|
8
10
|
import { logLine } from "../log.js";
|
|
9
11
|
import { emptySession, readState, updateState } from "../state.js";
|
|
@@ -20,6 +22,7 @@ const realLaunch = {
|
|
|
20
22
|
},
|
|
21
23
|
closeWorkspace: (session, workspace) => workspaceClose(workspace, aim(session)),
|
|
22
24
|
stopSession: sessionStop,
|
|
25
|
+
deleteSession: sessionDelete,
|
|
23
26
|
kill(pid) {
|
|
24
27
|
try {
|
|
25
28
|
process.kill(pid, 'SIGTERM');
|
|
@@ -47,6 +50,7 @@ export const realSources = {
|
|
|
47
50
|
screen(session, pane, cli) {
|
|
48
51
|
return readScreen(cli, paneRead(pane, 200, aim(session)) ?? undefined);
|
|
49
52
|
},
|
|
53
|
+
screenText: (session, pane) => paneRead(pane, 200, aim(session)) ?? undefined,
|
|
50
54
|
status: (session, pane) => agentStatus(pane, aim(session)),
|
|
51
55
|
foreground: (session, pane) => paneForeground(pane, aim(session)),
|
|
52
56
|
now: () => new Date(),
|
|
@@ -125,6 +129,18 @@ export async function runDown(argv, io, sources) {
|
|
|
125
129
|
if (abandon && !callerOwns(caller)) {
|
|
126
130
|
refusals.push('only the owner abandons a team, from a terminal outside herdr');
|
|
127
131
|
}
|
|
132
|
+
const state = readState(dir).sessions[session];
|
|
133
|
+
const known = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state?.seats ?? {})]);
|
|
134
|
+
const cliOf = new Map(team.seats.map((seat) => [seat.name, seat.cli]));
|
|
135
|
+
// The CLI a running seat was launched with: the file's for the seats it still names, the
|
|
136
|
+
// state's for the rest, so a seat the file renamed is still stopped. A temporary seat falls
|
|
137
|
+
// back to the seat it is like. A state too old to say leaves the seat to its owner, named
|
|
138
|
+
// in the plan.
|
|
139
|
+
const cliFor = (name) => {
|
|
140
|
+
const recorded = state?.seats[name];
|
|
141
|
+
const like = recorded?.temporary?.like;
|
|
142
|
+
return cliOf.get(name) ?? recorded?.cli ?? (like ? cliOf.get(like) ?? state?.seats[like]?.cli : undefined) ?? 'unknown';
|
|
143
|
+
};
|
|
128
144
|
if (!dry && args.flags.has('wait') && refusals.length === 0) {
|
|
129
145
|
const deadline = sources.now().getTime() + WAIT_SECONDS * 1000;
|
|
130
146
|
const sleep = sources.sleep ?? sources.launch?.sleep;
|
|
@@ -132,8 +148,7 @@ export async function runDown(argv, io, sources) {
|
|
|
132
148
|
const waiting = agents.some((agent) => {
|
|
133
149
|
if (!agent.name)
|
|
134
150
|
return false;
|
|
135
|
-
|
|
136
|
-
return stateOf(agent.status, sources.screen(session, agent.pane, cli)) === 'working';
|
|
151
|
+
return stateOf(agent.status, sources.screen(session, agent.pane, cliFor(agent.name))) === 'working';
|
|
137
152
|
});
|
|
138
153
|
if (!waiting)
|
|
139
154
|
break;
|
|
@@ -147,9 +162,6 @@ export async function runDown(argv, io, sources) {
|
|
|
147
162
|
agents = again;
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
|
-
const state = readState(dir).sessions[session];
|
|
151
|
-
const known = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state?.seats ?? {})]);
|
|
152
|
-
const cliOf = new Map(team.seats.map((seat) => [seat.name, seat.cli]));
|
|
153
165
|
const seats = [];
|
|
154
166
|
let extra = 0;
|
|
155
167
|
for (const agent of agents) {
|
|
@@ -157,8 +169,7 @@ export async function runDown(argv, io, sources) {
|
|
|
157
169
|
extra++;
|
|
158
170
|
continue;
|
|
159
171
|
}
|
|
160
|
-
const
|
|
161
|
-
const cli = cliOf.get(agent.name) ?? (like ? cliOf.get(like) : undefined) ?? 'unknown';
|
|
172
|
+
const cli = cliFor(agent.name);
|
|
162
173
|
seats.push({
|
|
163
174
|
name: agent.name,
|
|
164
175
|
cli,
|
|
@@ -201,9 +212,13 @@ export async function runDown(argv, io, sources) {
|
|
|
201
212
|
typeLine(sessionName, pane, text) {
|
|
202
213
|
// Free was decided when the plan was built. Look again, and once more between the text and
|
|
203
214
|
// the Enter: a prompt that appeared would take the key, as the watch's nudge does. Herdr's
|
|
204
|
-
// status is asked both times; Enter waits until it is idle or done and the
|
|
205
|
-
//
|
|
215
|
+
// status is asked both times; the Enter waits until it is idle or done and the box reads
|
|
216
|
+
// back as exactly the typed text.
|
|
206
217
|
const cli = seats.find((seat) => seat.pane === pane)?.cli ?? '';
|
|
218
|
+
const names = profileFor(cli)?.processNames ?? [];
|
|
219
|
+
const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
|
|
220
|
+
if (!live())
|
|
221
|
+
return 'no-agent';
|
|
207
222
|
const look = () => sources.screen(sessionName, pane, cli).kind;
|
|
208
223
|
const resting = () => {
|
|
209
224
|
const status = sources.status(sessionName, pane);
|
|
@@ -213,26 +228,37 @@ export async function runDown(argv, io, sources) {
|
|
|
213
228
|
return false;
|
|
214
229
|
if (!launch.typeText(sessionName, pane, text))
|
|
215
230
|
return false;
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
if (!live())
|
|
232
|
+
return 'no-agent';
|
|
233
|
+
// An idle screen after the typing is the text not rendered, and unsent text alone is not
|
|
234
|
+
// this exit's: only a box that reads back as the typed text gets the Enter.
|
|
235
|
+
if (!resting() || !boxHoldsText(cli, text, sources.screenText(sessionName, pane, cli)))
|
|
218
236
|
return false;
|
|
219
237
|
return launch.pressEnter(sessionName, pane);
|
|
220
238
|
},
|
|
221
239
|
renameAgent: () => false,
|
|
222
240
|
closeWorkspace: launch.closeWorkspace,
|
|
223
241
|
stopSession: launch.stopSession,
|
|
242
|
+
// Only the session this run has itself just stopped, and only once herdr agrees it is no
|
|
243
|
+
// longer running: anything else — still running, or herdr silent — is left to its owner, with
|
|
244
|
+
// the command the line in `executePlan` names. `up` never deletes a session at all.
|
|
245
|
+
deleteSession(name) {
|
|
246
|
+
return sources.sessionRunning(name) === false && launch.deleteSession(name);
|
|
247
|
+
},
|
|
224
248
|
kill: launch.kill,
|
|
225
249
|
agentPanes(sessionName) {
|
|
226
250
|
const listed = launch.agentPanes(sessionName);
|
|
227
251
|
if (!listed)
|
|
228
252
|
return null;
|
|
229
|
-
// A pane back at its shell is no longer the seat. `gone` then finishes and the workspace
|
|
253
|
+
// A pane back at its shell is no longer the seat. `gone` then finishes and the workspace
|
|
254
|
+
// closes. The wait keeps an unreadable list (`paneStillRunning`); the watch's quota read
|
|
255
|
+
// asks the other way — `reportedLiveAgent`, a figure only where the CLI was seen.
|
|
230
256
|
return listed.filter((pane) => {
|
|
231
257
|
const cli = seats.find((seat) => seat.pane === pane)?.cli;
|
|
232
258
|
const names = cli ? profileFor(cli)?.processNames : undefined;
|
|
233
259
|
if (!names)
|
|
234
260
|
return true;
|
|
235
|
-
return paneStillRunning(sources.foreground
|
|
261
|
+
return paneStillRunning(sources.foreground(sessionName, pane), names);
|
|
236
262
|
});
|
|
237
263
|
},
|
|
238
264
|
classify: () => 'unknown',
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Command, Io } from '../io.ts';
|
|
2
2
|
export declare const EXCLUDED: string[];
|
|
3
3
|
export declare const PRIVACY = "The team file is private to this clone: it is listed in .git/info/exclude, never in .gitignore,\nso git doesn't see it and a fresh clone doesn't carry it. A collaborator writes their own, or\nreceives one and approves it themselves.";
|
|
4
|
-
export declare function skeleton(project: string, head: string | null): string;
|
|
4
|
+
export declare function skeleton(project: string, head: string | null, teamVersion?: string): string;
|
|
5
5
|
export declare function exclude(root: string): string[];
|
|
6
6
|
export declare const init: Command;
|
|
7
7
|
export default init;
|
package/dist/commands/init.js
CHANGED
|
@@ -8,6 +8,7 @@ import { validateTeamFile } from "../file/validate.js";
|
|
|
8
8
|
import { logLine } from "../log.js";
|
|
9
9
|
import { LOCK_FILE, LOG_FILE, STATE_FILE, writeAtomic } from "../state.js";
|
|
10
10
|
import { approvedCopy } from "../store/store.js";
|
|
11
|
+
import { version } from "../version.js";
|
|
11
12
|
// What git must never pick up: the file and the runtime files beside it.
|
|
12
13
|
export const EXCLUDED = [TEAM_FILE, `.agents/${STATE_FILE}`, `.agents/${LOG_FILE}*`, `.agents/${LOCK_FILE}`];
|
|
13
14
|
export const PRIVACY = `The team file is private to this clone: it is listed in .git/info/exclude, never in .gitignore,
|
|
@@ -21,8 +22,9 @@ function git(root, ...args) {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
export function skeleton(project, head) {
|
|
25
|
-
return `#
|
|
25
|
+
export function skeleton(project, head, teamVersion = version()) {
|
|
26
|
+
return `# yaml-language-server: $schema=https://raw.githubusercontent.com/floor/team/v${teamVersion}/schema/team.schema.json
|
|
27
|
+
# The team of ${project}. Private to this clone: see .git/info/exclude.
|
|
26
28
|
# Nothing here runs until the owner has read it and run \`team approve\`.
|
|
27
29
|
format: 1
|
|
28
30
|
project: ${project}
|
|
@@ -4,6 +4,8 @@ import { type DownSources } from './down.ts';
|
|
|
4
4
|
export type RemoveSources = DownSources & {
|
|
5
5
|
/** Foreground process names in the pane, or null when the pane can't be read. */
|
|
6
6
|
foreground(session: string, pane: string): string[] | null;
|
|
7
|
+
/** Approval store home. The real command uses the owner's home. */
|
|
8
|
+
home?: string;
|
|
7
9
|
};
|
|
8
10
|
export declare const realSources: RemoveSources;
|
|
9
11
|
export declare const USAGE = "Usage: team remove <name> [--keep] [--abandon] [--session <name>] [--file <path>]\n";
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
4
|
+
import { recordSeatDigest } from "../approve/approval.js";
|
|
3
5
|
import { readArgs } from "../args.js";
|
|
4
6
|
import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
|
|
5
7
|
import { loadTeamFile } from "../file/load.js";
|
|
@@ -7,12 +9,14 @@ import { markStopped, takeOut } from "../file/lines.js";
|
|
|
7
9
|
import { validateTeamFile } from "../file/validate.js";
|
|
8
10
|
import { writeTeamFile } from "../file/write.js";
|
|
9
11
|
import { paneForeground } from "../herdr.js";
|
|
12
|
+
import { reportedLiveAgent } from "../launch/agent.js";
|
|
10
13
|
import { executePlan } from "../launch/execute.js";
|
|
11
14
|
import { downPlan } from "../launch/plan.js";
|
|
12
15
|
import { logLine } from "../log.js";
|
|
13
16
|
import { profileFor } from "../profiles/index.js";
|
|
14
17
|
import { emptySession, readState, updateState, withLock } from "../state.js";
|
|
15
18
|
import { paneStillRunning, realSources as downSources, stateOf } from "./down.js";
|
|
19
|
+
import { boxHoldsText } from "../launch/deliver.js";
|
|
16
20
|
function aim(session) {
|
|
17
21
|
return session === 'default' ? undefined : session;
|
|
18
22
|
}
|
|
@@ -60,7 +64,7 @@ export async function runRemove(argv, io, sources = realSources) {
|
|
|
60
64
|
io.stderr(`team remove: only the owner, the coordinator or the operator runs it; this call is ${describeCaller(caller)}\n`);
|
|
61
65
|
return 1;
|
|
62
66
|
}
|
|
63
|
-
const { team, path } = loaded;
|
|
67
|
+
const { team, path, root } = loaded;
|
|
64
68
|
const session = args.values.session ?? team.session;
|
|
65
69
|
if (session === 'default') {
|
|
66
70
|
io.stderr('team remove: session can\'t be "default", herdr\'s own session\n');
|
|
@@ -133,6 +137,7 @@ export async function runRemove(argv, io, sources = realSources) {
|
|
|
133
137
|
if (!stopped)
|
|
134
138
|
return 1;
|
|
135
139
|
}
|
|
140
|
+
let kept = null;
|
|
136
141
|
if (!temporary) {
|
|
137
142
|
const refused = withLock(dir, () => {
|
|
138
143
|
const text = readFileSync(path, 'utf8');
|
|
@@ -140,7 +145,11 @@ export async function runRemove(argv, io, sources = realSources) {
|
|
|
140
145
|
if (next === text)
|
|
141
146
|
return null;
|
|
142
147
|
const wrote = writeTeamFile(path, next);
|
|
143
|
-
|
|
148
|
+
if (!wrote.ok)
|
|
149
|
+
return wrote.errors;
|
|
150
|
+
if (args.flags.has('keep'))
|
|
151
|
+
kept = next;
|
|
152
|
+
return null;
|
|
144
153
|
});
|
|
145
154
|
if (refused) {
|
|
146
155
|
for (const problem of refused) {
|
|
@@ -149,6 +158,11 @@ export async function runRemove(argv, io, sources = realSources) {
|
|
|
149
158
|
return 2;
|
|
150
159
|
}
|
|
151
160
|
}
|
|
161
|
+
if (kept !== null) {
|
|
162
|
+
const parsed = validateTeamFile(kept);
|
|
163
|
+
if (parsed.ok)
|
|
164
|
+
recordSeatDigest(parsed.team, root, name, sources.home ?? homedir());
|
|
165
|
+
}
|
|
152
166
|
if (!agent && recorded) {
|
|
153
167
|
updateState(dir, (file) => {
|
|
154
168
|
const seats = file.sessions[session]?.seats;
|
|
@@ -185,6 +199,10 @@ export async function stopRunning(input) {
|
|
|
185
199
|
createWorkspace: () => null,
|
|
186
200
|
paneRun: () => false,
|
|
187
201
|
typeLine(sessionName, pane, text) {
|
|
202
|
+
const names = profileFor(seat.cli)?.processNames ?? [];
|
|
203
|
+
const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
|
|
204
|
+
if (!live())
|
|
205
|
+
return 'no-agent';
|
|
188
206
|
const look = () => sources.screen(sessionName, pane, seat.cli).kind;
|
|
189
207
|
const resting = () => {
|
|
190
208
|
const status = sources.status(sessionName, pane);
|
|
@@ -194,8 +212,10 @@ export async function stopRunning(input) {
|
|
|
194
212
|
return false;
|
|
195
213
|
if (!launch.typeText(sessionName, pane, text))
|
|
196
214
|
return false;
|
|
197
|
-
|
|
198
|
-
|
|
215
|
+
if (!live())
|
|
216
|
+
return 'no-agent';
|
|
217
|
+
// As in `down`: only a box that reads back as exactly the typed text gets the Enter.
|
|
218
|
+
if (!resting() || !boxHoldsText(seat.cli, text, sources.screenText(sessionName, pane, seat.cli)))
|
|
199
219
|
return false;
|
|
200
220
|
return launch.pressEnter(sessionName, pane);
|
|
201
221
|
},
|
|
@@ -207,6 +227,8 @@ export async function stopRunning(input) {
|
|
|
207
227
|
const listed = launch.agentPanes(sessionName);
|
|
208
228
|
if (!listed)
|
|
209
229
|
return null;
|
|
230
|
+
// A pane back at its shell is no longer the seat. The wait keeps an unreadable list
|
|
231
|
+
// (`paneStillRunning`); the watch's quota read asks the other way (`reportedLiveAgent`).
|
|
210
232
|
const names = profileFor(seat.cli)?.processNames;
|
|
211
233
|
if (!names)
|
|
212
234
|
return listed;
|