team 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +20 -5
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +75 -8
  6. package/dist/approve/fingerprint.d.ts +15 -4
  7. package/dist/approve/fingerprint.js +37 -12
  8. package/dist/budgets/gate.d.ts +12 -7
  9. package/dist/budgets/gate.js +52 -17
  10. package/dist/budgets/readings.d.ts +71 -5
  11. package/dist/budgets/readings.js +136 -9
  12. package/dist/budgets/table.d.ts +2 -2
  13. package/dist/budgets/table.js +24 -11
  14. package/dist/commands/add.js +12 -5
  15. package/dist/commands/doctor.d.ts +3 -1
  16. package/dist/commands/doctor.js +4 -2
  17. package/dist/commands/down.d.ts +2 -2
  18. package/dist/commands/down.js +8 -1
  19. package/dist/commands/remove.d.ts +2 -0
  20. package/dist/commands/remove.js +21 -2
  21. package/dist/commands/status.js +3 -2
  22. package/dist/commands/up.d.ts +3 -1
  23. package/dist/commands/up.js +12 -7
  24. package/dist/commands/watch.d.ts +2 -0
  25. package/dist/commands/watch.js +94 -16
  26. package/dist/file/types.d.ts +5 -0
  27. package/dist/file/validate.js +31 -11
  28. package/dist/herdr.d.ts +4 -0
  29. package/dist/herdr.js +40 -11
  30. package/dist/launch/agent.d.ts +1 -0
  31. package/dist/launch/agent.js +12 -0
  32. package/dist/launch/deliver.d.ts +3 -1
  33. package/dist/launch/deliver.js +10 -1
  34. package/dist/launch/execute.d.ts +2 -2
  35. package/dist/launch/execute.js +15 -2
  36. package/dist/launch/rules.d.ts +4 -2
  37. package/dist/launch/rules.js +17 -3
  38. package/dist/profiles/antigravity.yaml +13 -0
  39. package/dist/profiles/claude-code.yaml +30 -0
  40. package/dist/profiles/cursor.yaml +4 -1
  41. package/dist/profiles/quota.d.ts +7 -2
  42. package/dist/profiles/quota.js +15 -14
  43. package/dist/state.d.ts +8 -3
  44. package/dist/state.js +37 -0
  45. package/dist/status/statusline.js +3 -1
  46. package/dist/watch/check.d.ts +1 -0
  47. package/dist/watch/checks/budget.js +4 -1
  48. package/dist/watch/pass.d.ts +14 -1
  49. package/dist/watch/pass.js +64 -28
  50. package/dist/watch/screen-core.d.ts +7 -1
  51. package/dist/watch/screen-core.js +146 -54
  52. package/dist/watch/screen-data.d.ts +9 -0
  53. package/dist/watch/screen-file.js +25 -7
  54. package/dist/watch/screen.d.ts +5 -0
  55. package/dist/watch/screen.js +16 -2
  56. package/examples/team.yaml +8 -0
  57. package/package.json +2 -2
@@ -1,4 +1,7 @@
1
1
  import type { QuotaFigure, WindowName } from '../profiles/quota.ts';
2
+ import type { CheckWindow } from './run.ts';
3
+ /** Where a reading came from: a pane's own status line, or an approved check command (§ 3). */
4
+ export type ReadingSource = 'status_line' | 'check';
2
5
  export type Seen = {
3
6
  account: string;
4
7
  window: WindowName;
@@ -6,7 +9,9 @@ export type Seen = {
6
9
  used: number;
7
10
  changedAt: number;
8
11
  resetsAt: number | null;
9
- seat: string;
12
+ /** The seat whose screen showed it; null for a check reading, which belongs to no seat. */
13
+ seat: string | null;
14
+ source: ReadingSource;
10
15
  confirmed: boolean;
11
16
  };
12
17
  export type StoredReading = {
@@ -16,7 +21,9 @@ export type StoredReading = {
16
21
  used: number;
17
22
  changedAt: string;
18
23
  resetsAt: string | null;
19
- seat: string;
24
+ seat: string | null;
25
+ /** Absent in state files written before sources were recorded: a screen reading then. */
26
+ source?: ReadingSource;
20
27
  confirmed: boolean;
21
28
  };
22
29
  export type Verdict = {
@@ -25,8 +32,31 @@ export type Verdict = {
25
32
  } | {
26
33
  kind: 'unknown';
27
34
  };
35
+ /** A spend check reading (RFC 0003 § 5): the money the account still holds. */
36
+ export type SpendReading = {
37
+ account: string;
38
+ amount: number;
39
+ currency: string;
40
+ /** When the check ran: a spend line carries no time of its own (§ 5). */
41
+ at: number;
42
+ };
43
+ export type StoredSpend = {
44
+ account: string;
45
+ amount: number;
46
+ currency: string;
47
+ at: string;
48
+ };
28
49
  /** Fold one seat's figure into the readings kept for this pass. */
29
50
  export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat: string, now: number): Seen[];
51
+ /**
52
+ * Fold a check command's windows into the readings kept for this pass (§ 5). A check reading is
53
+ * confirmed at first sight and belongs to no seat; one is kept per account and window, so a new
54
+ * check replaces the last, and the screen readings of the same window are left alone.
55
+ */
56
+ export declare function observeCheck(list: readonly Seen[], account: string, windows: readonly CheckWindow[]): Seen[];
57
+ /** The screen readings in a list, and the check readings: the two slots of the same account. */
58
+ export declare function screenOf(list: readonly Seen[]): Seen[];
59
+ export declare function checkOf(list: readonly Seen[]): Seen[];
30
60
  /**
31
61
  * The reading that counts. A figure from before a known reset is dropped.
32
62
  * Among confirmed live readings, the newest change wins. A first sight counts
@@ -35,12 +65,48 @@ export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat
35
65
  * with no reset time it is unknown.
36
66
  */
37
67
  export declare function verdict(list: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): Verdict;
68
+ export type CountedReading = {
69
+ kind: 'counted';
70
+ reading: Seen;
71
+ } | {
72
+ kind: 'unconfirmed';
73
+ reading: Seen;
74
+ } | {
75
+ kind: 'unknown';
76
+ };
77
+ /**
78
+ * The reading that counts for one window, from the sources the account names in order (§ 3, § 5):
79
+ * a lower source is used only when every higher one is failed, unknown or stale. A check reading
80
+ * counts while it is fresh by § 5's own time and its reset has not passed; a screen reading falls
81
+ * through when it is unknown, and a bare first sight falls through too, so a fresh check below it
82
+ * still counts (§ 4.3 rule 5). A stale reading inside its reserve with its reset ahead is kept as
83
+ * the fallback, whichever source it came from, so rule 4 still refuses when nothing below counts.
84
+ */
85
+ export declare function countedFor(sources: readonly ReadingSource[], screen: readonly Seen[], checks: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): CountedReading;
38
86
  /** Readings whose reset has passed are left out. One with no reset time is kept. */
39
87
  export declare function remember(list: readonly Seen[], now: number): Record<string, StoredReading>;
40
88
  export declare function recall(stored: Record<string, StoredReading> | undefined): Seen[];
41
- /** Write the readings that still count. The state file is the per-project cache. */
42
- export declare function saveReadings(dir: string, session: string, list: readonly Seen[], now?: number): void;
43
- export declare function loadReadings(dir: string, session: string): Seen[];
89
+ /** Write the readings that still count. The state file is the per-project cache (§ 4.4). */
90
+ export declare function saveReadings(dir: string, list: readonly Seen[], now?: number): void;
91
+ /**
92
+ * One pass's fold, read and written under the state lock (§ 4.4): `fold` is handed the readings
93
+ * the state holds and returns the list to keep, so a watch folding while another watch writes
94
+ * folds onto what was written, never over a list it read before it. Whatever the fold returns as
95
+ * its `value` is handed back.
96
+ */
97
+ export declare function updateReadings<T>(dir: string, now: number, fold: (stored: Seen[]) => {
98
+ readings: Seen[];
99
+ value: T;
100
+ }): T;
101
+ export declare function loadReadings(dir: string): Seen[];
102
+ /**
103
+ * Write the spend readings a pass's checks read, merging by account: an account whose check did
104
+ * not run this pass keeps the reading the state already holds. Nothing read, nothing written.
105
+ */
106
+ export declare function saveSpendReadings(dir: string, list: readonly SpendReading[]): void;
107
+ export declare function loadSpendReadings(dir: string): SpendReading[];
108
+ export declare function storeSpend(reading: SpendReading): StoredSpend;
109
+ export declare function recallSpend(stored: Record<string, StoredSpend> | undefined): SpendReading[];
44
110
  export declare function store(reading: Seen): StoredReading;
45
111
  export declare function revive(stored: StoredReading): Seen;
46
112
  /** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
@@ -1,4 +1,4 @@
1
- import { emptySession, readState, updateState } from "../state.js";
1
+ import { readState, updateState } from "../state.js";
2
2
  /** Fold one seat's figure into the readings kept for this pass. */
3
3
  export function observe(list, figure, seat, now) {
4
4
  const next = list.slice();
@@ -19,6 +19,7 @@ export function observe(list, figure, seat, now) {
19
19
  changedAt: same && previous ? previous.changedAt : now,
20
20
  resetsAt: same && previous && previous.resetsAt !== null ? previous.resetsAt : resetsFrom(figure.resets, now),
21
21
  seat,
22
+ source: 'status_line',
22
23
  confirmed: previous === null ? agreed : same ? previous.confirmed || agreed : true,
23
24
  };
24
25
  if (at < 0)
@@ -27,6 +28,40 @@ export function observe(list, figure, seat, now) {
27
28
  next[at] = reading;
28
29
  return next;
29
30
  }
31
+ /**
32
+ * Fold a check command's windows into the readings kept for this pass (§ 5). A check reading is
33
+ * confirmed at first sight and belongs to no seat; one is kept per account and window, so a new
34
+ * check replaces the last, and the screen readings of the same window are left alone.
35
+ */
36
+ export function observeCheck(list, account, windows) {
37
+ const next = list.slice();
38
+ for (const window of windows) {
39
+ const reading = {
40
+ account,
41
+ window: window.window,
42
+ left: window.left,
43
+ used: window.used,
44
+ changedAt: window.at,
45
+ resetsAt: window.resetsAt,
46
+ seat: null,
47
+ source: 'check',
48
+ confirmed: true,
49
+ };
50
+ const at = next.findIndex((item) => item.account === account && item.window === window.window && item.source === 'check');
51
+ if (at < 0)
52
+ next.push(reading);
53
+ else
54
+ next[at] = reading;
55
+ }
56
+ return next;
57
+ }
58
+ /** The screen readings in a list, and the check readings: the two slots of the same account. */
59
+ export function screenOf(list) {
60
+ return list.filter((item) => item.source === 'status_line');
61
+ }
62
+ export function checkOf(list) {
63
+ return list.filter((item) => item.source === 'check');
64
+ }
30
65
  /**
31
66
  * The reading that counts. A figure from before a known reset is dropped.
32
67
  * Among confirmed live readings, the newest change wins. A first sight counts
@@ -56,13 +91,57 @@ export function verdict(list, now, staleAfterMs, reserve) {
56
91
  return { kind: 'unknown' };
57
92
  return { kind: 'stale', reading };
58
93
  }
94
+ /**
95
+ * The reading that counts for one window, from the sources the account names in order (§ 3, § 5):
96
+ * a lower source is used only when every higher one is failed, unknown or stale. A check reading
97
+ * counts while it is fresh by § 5's own time and its reset has not passed; a screen reading falls
98
+ * through when it is unknown, and a bare first sight falls through too, so a fresh check below it
99
+ * still counts (§ 4.3 rule 5). A stale reading inside its reserve with its reset ahead is kept as
100
+ * the fallback, whichever source it came from, so rule 4 still refuses when nothing below counts.
101
+ */
102
+ export function countedFor(sources, screen, checks, now, staleAfterMs, reserve) {
103
+ let fallback = null;
104
+ let unconfirmed = null;
105
+ for (const source of sources) {
106
+ if (source === 'check') {
107
+ const reading = checks.find((item) => (item.resetsAt === null || item.resetsAt > now) && now - item.changedAt < staleAfterMs);
108
+ if (reading)
109
+ return { kind: 'counted', reading };
110
+ // Rule 4 is about the reserve, not the source: a stale check figure inside it keeps
111
+ // refusing until its known reset, and a fresh source below still wins over this fallback.
112
+ const inside = checks.find((item) => item.resetsAt !== null && item.resetsAt > now && reserve !== null && item.left <= reserve);
113
+ if (inside)
114
+ fallback ??= inside;
115
+ continue;
116
+ }
117
+ const result = verdict(screen, now, staleAfterMs, reserve);
118
+ if (result.kind === 'unknown')
119
+ continue;
120
+ if (result.kind === 'unconfirmed') {
121
+ unconfirmed ??= result.reading;
122
+ continue;
123
+ }
124
+ if (result.kind === 'fresh')
125
+ return { kind: 'counted', reading: result.reading };
126
+ fallback ??= result.reading;
127
+ }
128
+ if (fallback)
129
+ return { kind: 'counted', reading: fallback };
130
+ if (unconfirmed)
131
+ return { kind: 'unconfirmed', reading: unconfirmed };
132
+ return { kind: 'unknown' };
133
+ }
59
134
  /** Readings whose reset has passed are left out. One with no reset time is kept. */
60
135
  export function remember(list, now) {
61
136
  const out = {};
62
137
  for (const reading of list) {
63
138
  if (reading.resetsAt !== null && reading.resetsAt <= now)
64
139
  continue;
65
- out[`${reading.account}/${reading.window}/${reading.seat}`] = store(reading);
140
+ // A screen reading is kept per seat; a check reading belongs to no seat and is kept alone.
141
+ const key = reading.source === 'check'
142
+ ? `${reading.account}/${reading.window}`
143
+ : `${reading.account}/${reading.window}/${reading.seat}`;
144
+ out[key] = store(reading);
66
145
  }
67
146
  return out;
68
147
  }
@@ -71,16 +150,62 @@ export function recall(stored) {
71
150
  return [];
72
151
  return Object.values(stored).map(revive);
73
152
  }
74
- /** Write the readings that still count. The state file is the per-project cache. */
75
- export function saveReadings(dir, session, list, now = Date.now()) {
153
+ /** Write the readings that still count. The state file is the per-project cache (§ 4.4). */
154
+ export function saveReadings(dir, list, now = Date.now()) {
155
+ updateState(dir, (state) => {
156
+ state.budgets = remember(list, now);
157
+ });
158
+ }
159
+ /**
160
+ * One pass's fold, read and written under the state lock (§ 4.4): `fold` is handed the readings
161
+ * the state holds and returns the list to keep, so a watch folding while another watch writes
162
+ * folds onto what was written, never over a list it read before it. Whatever the fold returns as
163
+ * its `value` is handed back.
164
+ */
165
+ export function updateReadings(dir, now, fold) {
166
+ return updateState(dir, (state) => {
167
+ const folded = fold(recall(state.budgets));
168
+ state.budgets = remember(folded.readings, now);
169
+ return folded.value;
170
+ });
171
+ }
172
+ export function loadReadings(dir) {
173
+ return recall(readState(dir).budgets);
174
+ }
175
+ /**
176
+ * Write the spend readings a pass's checks read, merging by account: an account whose check did
177
+ * not run this pass keeps the reading the state already holds. Nothing read, nothing written.
178
+ */
179
+ export function saveSpendReadings(dir, list) {
180
+ if (!list.length)
181
+ return;
76
182
  updateState(dir, (state) => {
77
- const current = state.sessions[session] ?? emptySession();
78
- current.budgets = remember(list, now);
79
- state.sessions[session] = current;
183
+ const spend = state.spend ?? {};
184
+ for (const reading of list)
185
+ spend[reading.account] = storeSpend(reading);
186
+ state.spend = spend;
80
187
  });
81
188
  }
82
- export function loadReadings(dir, session) {
83
- return recall(readState(dir).sessions[session]?.budgets);
189
+ export function loadSpendReadings(dir) {
190
+ return recallSpend(readState(dir).spend);
191
+ }
192
+ export function storeSpend(reading) {
193
+ return {
194
+ account: reading.account,
195
+ amount: reading.amount,
196
+ currency: reading.currency,
197
+ at: new Date(reading.at).toISOString(),
198
+ };
199
+ }
200
+ export function recallSpend(stored) {
201
+ if (!stored)
202
+ return [];
203
+ return Object.values(stored).map((one) => ({
204
+ account: one.account,
205
+ amount: one.amount,
206
+ currency: one.currency,
207
+ at: Date.parse(one.at),
208
+ }));
84
209
  }
85
210
  export function store(reading) {
86
211
  return {
@@ -91,6 +216,7 @@ export function store(reading) {
91
216
  changedAt: new Date(reading.changedAt).toISOString(),
92
217
  resetsAt: reading.resetsAt === null ? null : new Date(reading.resetsAt).toISOString(),
93
218
  seat: reading.seat,
219
+ source: reading.source,
94
220
  confirmed: reading.confirmed,
95
221
  };
96
222
  }
@@ -103,6 +229,7 @@ export function revive(stored) {
103
229
  changedAt: Date.parse(stored.changedAt),
104
230
  resetsAt: stored.resetsAt === null ? null : Date.parse(stored.resetsAt),
105
231
  seat: stored.seat,
232
+ source: stored.source ?? 'status_line',
106
233
  confirmed: stored.confirmed,
107
234
  };
108
235
  }
@@ -1,6 +1,6 @@
1
1
  import type { TeamFile } from '../file/types.ts';
2
2
  import type { WindowName } from '../profiles/quota.ts';
3
- import { type Seen } from './readings.ts';
3
+ import { type ReadingSource, type Seen } from './readings.ts';
4
4
  export type BudgetState = 'fresh' | 'unconfirmed' | 'stale' | 'refusing' | 'unknown';
5
5
  export type BudgetRow = {
6
6
  account: string;
@@ -10,7 +10,7 @@ export type BudgetRow = {
10
10
  resetsIn: string | null;
11
11
  seat: string | null;
12
12
  age: string | null;
13
- source: 'status_line' | null;
13
+ source: ReadingSource | null;
14
14
  state: BudgetState;
15
15
  /** True when a subscription's left figure is at or inside its reserve. */
16
16
  inside: boolean;
@@ -1,4 +1,4 @@
1
- import { verdict } from "./readings.js";
1
+ import { checkOf, countedFor, screenOf } from "./readings.js";
2
2
  const WINDOWS = ['session', 'daily', 'weekly'];
3
3
  /** Rows for the accounts the budgets in force name, and for any reading still in the state. */
4
4
  export function budgetTable(budgets, list, now) {
@@ -17,7 +17,11 @@ export function budgetTable(budgets, list, now) {
17
17
  const window = group[0]?.window ?? null;
18
18
  named.add(account);
19
19
  const reserve = reserveOf(budgets, account);
20
- rows.push(rowOf(account, window, verdict(group, now, staleAfterMs, reserve), now, reserve));
20
+ // The window's reading from the sources the account names, in order (§ 3, § 5).
21
+ const result = countedFor(sourcesOf(budgets, account), screenOf(group), checkOf(group), now, staleAfterMs, reserve);
22
+ rows.push(result.kind === 'unknown'
23
+ ? { ...blank(account, reserve), window }
24
+ : rowOf(result, now, staleAfterMs, reserve));
21
25
  }
22
26
  for (const account of Object.keys(budgets.accounts)) {
23
27
  if (!named.has(account))
@@ -35,25 +39,34 @@ export function budgetLine(row) {
35
39
  return [row.account, row.window, 'unknown'].filter((part) => part).join(' ');
36
40
  }
37
41
  const reset = row.resetsIn === null ? 'resets unknown' : `resets in ${row.resetsIn}`;
38
- const from = row.source === 'status_line' ? 'status line' : 'unknown source';
42
+ const from = row.source === 'status_line' ? 'status line' : row.source === 'check' ? 'check' : 'unknown source';
43
+ const when = row.source === 'check' ? 'read' : 'changed';
39
44
  const state = row.inside && row.reserve !== null ? `${row.state}, inside reserve ${row.reserve}%` : row.state;
40
- return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat} changed ${row.age} ago ${from} ${state}`;
45
+ return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat ?? '-'} ${when} ${row.age} ago ${from} ${state}`;
41
46
  }
42
- function rowOf(account, window, result, now, reserve) {
43
- if (result.kind === 'unknown')
44
- return { ...blank(account, reserve), window };
47
+ /** The sources the account's figures are read from, in order: a status line when the file is silent. */
48
+ function sourcesOf(budgets, account) {
49
+ return budgets.accounts[account]?.sources ?? ['status_line'];
50
+ }
51
+ function rowOf(result, now, staleAfterMs, reserve) {
45
52
  const reading = result.reading;
53
+ const inside = reserve !== null && reading.left <= reserve;
54
+ const state = result.kind === 'unconfirmed'
55
+ ? 'unconfirmed'
56
+ : now - reading.changedAt < staleAfterMs
57
+ ? 'fresh'
58
+ : inside && reading.resetsAt !== null ? 'refusing' : 'stale';
46
59
  return {
47
- account,
60
+ account: reading.account,
48
61
  window: reading.window,
49
62
  left: reading.left,
50
63
  used: reading.used,
51
64
  resetsIn: reading.resetsAt === null ? null : span(reading.resetsAt - now),
52
65
  seat: reading.seat,
53
66
  age: span(now - reading.changedAt),
54
- source: 'status_line',
55
- state: result.kind,
56
- inside: reserve !== null && reading.left <= reserve,
67
+ source: reading.source,
68
+ state,
69
+ inside,
57
70
  reserve,
58
71
  };
59
72
  }
@@ -1,19 +1,19 @@
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";
8
8
  import { seatBudget } from "../budgets/gate.js";
9
- import { loadReadings } from "../budgets/readings.js";
9
+ import { loadReadings, loadSpendReadings } from "../budgets/readings.js";
10
10
  import { rulesOf } from "../commands/up.js";
11
11
  import { branchPresent, readMerge } from "../end/condition.js";
12
12
  import { clearStopped, hasSeat, restoreSeat, seatIsStopped } from "../file/lines.js";
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
  };
@@ -168,7 +169,10 @@ export async function runAdd(argv, io, sources = realSources) {
168
169
  const doctorTeam = built.temporary
169
170
  ? { ...prepared.team, seats: prepared.team.seats.map((item) => item.name === built.temporary?.like ? { ...item, stopped: false } : item) }
170
171
  : prepared.team;
171
- for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings)) {
172
+ // `team` is the file on disk, already the approved one. `doctorTeam` is the
173
+ // seat about to run, so a stopped seat's CLI is still checked. The digest is
174
+ // recorded with the write, after a refusal has left the file alone.
175
+ for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings, team)) {
172
176
  if (blocksLaunch(finding)) {
173
177
  io.stderr(`team add: ${finding.text}\n`);
174
178
  return 1;
@@ -198,7 +202,7 @@ export async function runAdd(argv, io, sources = realSources) {
198
202
  // The gate reads the approved budgets, never the edited file's: the seat this `add` inserts
199
203
  // changes no section of its own, and no unapproved reserve may unblock a launch (#50).
200
204
  const budgets = budgetsInForce(prepared.team, root, sources.home);
201
- const decision = seatBudget(budgets, loadReadings(dir, session), built.seat, sources.now().getTime());
205
+ const decision = seatBudget(budgets, loadReadings(dir), built.seat, sources.now().getTime(), loadSpendReadings(dir));
202
206
  const stray = unnamedIn(built.seat.label, agents, workspaces);
203
207
  const starting = seatPlan(prepared.team, built.seat, start);
204
208
  const planned = stray
@@ -246,6 +250,9 @@ export async function runAdd(argv, io, sources = realSources) {
246
250
  io.stderr(`team add: ${where(problem)}${problem.message}\n`);
247
251
  return 2;
248
252
  }
253
+ const parsed = validateTeamFile(built.edited);
254
+ if (parsed.ok)
255
+ recordSeatDigest(parsed.team, root, built.name, sources.home);
249
256
  }
250
257
  const plan = upPlan({ root, session, sessionRunning: live === 'running', seats: [seatForPlan], watchAlive: true });
251
258
  const who = describeCaller(caller);
@@ -33,6 +33,8 @@ export default doctor;
33
33
  export declare function doctorFindings(team: TeamFile, root: string, dir: string, session: string, sources: DoctorSources, warnings: {
34
34
  line: number;
35
35
  message: string;
36
- }[]): Finding[];
36
+ }[],
37
+ /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
38
+ approved?: TeamFile): Finding[];
37
39
  export declare function doctorLoginFindings(team: TeamFile, sources: DoctorSources): Finding[];
38
40
  export declare function runDoctor(argv: string[], io: Io, sources: DoctorSources): Promise<number>;
@@ -150,12 +150,14 @@ function watchFindings(watch, dir, session, running, now) {
150
150
  }
151
151
  return [{ level: 'ok', text: 'the watch is running' }];
152
152
  }
153
- export function doctorFindings(team, root, dir, session, sources, warnings) {
153
+ export function doctorFindings(team, root, dir, session, sources, warnings,
154
+ /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
155
+ approved = team) {
154
156
  const findings = warnings.map((warning) => ({
155
157
  level: 'warn',
156
158
  text: `the file, line ${warning.line}: ${warning.message}`,
157
159
  }));
158
- findings.push(...approvalFindings(team, root, sources.home), ...checkFindings(team, root, sources.home));
160
+ findings.push(...approvalFindings(approved, root, sources.home), ...checkFindings(team, root, sources.home));
159
161
  const herdr = sources.herdrVersion();
160
162
  const running = herdr === null ? null : sources.sessionRunning(session);
161
163
  if (herdr === null)
@@ -10,8 +10,8 @@ export type DownSources = {
10
10
  screen(session: string, pane: string, cli: string): Screen;
11
11
  /** Herdr's own status for the pane. The Enter waits for idle or done. */
12
12
  status(session: string, pane: string): string | null;
13
- /** Foreground process names in the pane, or null when the pane can't be read. */
14
- foreground?(session: string, pane: string): string[] | null;
13
+ /** Foreground argv0 names, or null when the pane can't be read. */
14
+ foreground(session: string, pane: string): string[] | null;
15
15
  now(): Date;
16
16
  sleep?(ms: number): Promise<void>;
17
17
  launch?: DownLaunch;
@@ -3,6 +3,7 @@ import { callerOf, describeCaller, mayChangeTeam } from "../caller.js";
3
3
  import { currentTeam } from "../file/current.js";
4
4
  import { agentList, agentStatus, paneForeground, paneRead, 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";
7
8
  import { profileFor } from "../profiles/index.js";
8
9
  import { logLine } from "../log.js";
@@ -204,6 +205,10 @@ export async function runDown(argv, io, sources) {
204
205
  // status is asked both times; Enter waits until it is idle or done and the screen is idle
205
206
  // or holding unsent text.
206
207
  const cli = seats.find((seat) => seat.pane === pane)?.cli ?? '';
208
+ const names = profileFor(cli)?.processNames ?? [];
209
+ const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
210
+ if (!live())
211
+ return 'no-agent';
207
212
  const look = () => sources.screen(sessionName, pane, cli).kind;
208
213
  const resting = () => {
209
214
  const status = sources.status(sessionName, pane);
@@ -213,6 +218,8 @@ export async function runDown(argv, io, sources) {
213
218
  return false;
214
219
  if (!launch.typeText(sessionName, pane, text))
215
220
  return false;
221
+ if (!live())
222
+ return 'no-agent';
216
223
  const after = look();
217
224
  if (!resting() || (after !== 'idle' && after !== 'unsent'))
218
225
  return false;
@@ -232,7 +239,7 @@ export async function runDown(argv, io, sources) {
232
239
  const names = cli ? profileFor(cli)?.processNames : undefined;
233
240
  if (!names)
234
241
  return true;
235
- return paneStillRunning(sources.foreground?.(sessionName, pane) ?? paneForeground(pane, aim(sessionName)), names);
242
+ return paneStillRunning(sources.foreground(sessionName, pane), names);
236
243
  });
237
244
  },
238
245
  classify: () => 'unknown',
@@ -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";
@@ -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,6 +9,7 @@ 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";
@@ -60,7 +63,7 @@ export async function runRemove(argv, io, sources = realSources) {
60
63
  io.stderr(`team remove: only the owner, the coordinator or the operator runs it; this call is ${describeCaller(caller)}\n`);
61
64
  return 1;
62
65
  }
63
- const { team, path } = loaded;
66
+ const { team, path, root } = loaded;
64
67
  const session = args.values.session ?? team.session;
65
68
  if (session === 'default') {
66
69
  io.stderr('team remove: session can\'t be "default", herdr\'s own session\n');
@@ -133,6 +136,7 @@ export async function runRemove(argv, io, sources = realSources) {
133
136
  if (!stopped)
134
137
  return 1;
135
138
  }
139
+ let kept = null;
136
140
  if (!temporary) {
137
141
  const refused = withLock(dir, () => {
138
142
  const text = readFileSync(path, 'utf8');
@@ -140,7 +144,11 @@ export async function runRemove(argv, io, sources = realSources) {
140
144
  if (next === text)
141
145
  return null;
142
146
  const wrote = writeTeamFile(path, next);
143
- return wrote.ok ? null : wrote.errors;
147
+ if (!wrote.ok)
148
+ return wrote.errors;
149
+ if (args.flags.has('keep'))
150
+ kept = next;
151
+ return null;
144
152
  });
145
153
  if (refused) {
146
154
  for (const problem of refused) {
@@ -149,6 +157,11 @@ export async function runRemove(argv, io, sources = realSources) {
149
157
  return 2;
150
158
  }
151
159
  }
160
+ if (kept !== null) {
161
+ const parsed = validateTeamFile(kept);
162
+ if (parsed.ok)
163
+ recordSeatDigest(parsed.team, root, name, sources.home ?? homedir());
164
+ }
152
165
  if (!agent && recorded) {
153
166
  updateState(dir, (file) => {
154
167
  const seats = file.sessions[session]?.seats;
@@ -185,6 +198,10 @@ export async function stopRunning(input) {
185
198
  createWorkspace: () => null,
186
199
  paneRun: () => false,
187
200
  typeLine(sessionName, pane, text) {
201
+ const names = profileFor(seat.cli)?.processNames ?? [];
202
+ const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
203
+ if (!live())
204
+ return 'no-agent';
188
205
  const look = () => sources.screen(sessionName, pane, seat.cli).kind;
189
206
  const resting = () => {
190
207
  const status = sources.status(sessionName, pane);
@@ -194,6 +211,8 @@ export async function stopRunning(input) {
194
211
  return false;
195
212
  if (!launch.typeText(sessionName, pane, text))
196
213
  return false;
214
+ if (!live())
215
+ return 'no-agent';
197
216
  const after = look();
198
217
  if (!resting() || (after !== 'idle' && after !== 'unsent'))
199
218
  return false;
@@ -67,8 +67,9 @@ export async function runStatus(argv, io, sources) {
67
67
  io.stderr('team status: herdr doesn\'t answer; is it installed and running?\n');
68
68
  return 2;
69
69
  }
70
- const state = readState(dir).sessions[session] ?? emptySession();
71
- const budgets = budgetTable(sources.budgetsInForce(team, root), recall(state.budgets), sources.now().getTime());
70
+ const whole = readState(dir);
71
+ const state = whole.sessions[session] ?? emptySession();
72
+ const budgets = budgetTable(sources.budgetsInForce(team, root), recall(whole.budgets), sources.now().getTime());
72
73
  const comparison = compare(team, session, state, live, sources.now(), sources.watchInForce(team, root));
73
74
  if (!live.running)
74
75
  comparison.notes.unshift(`the herdr session "${session}" is not running`);
@@ -35,6 +35,8 @@ export type Launch = {
35
35
  typeText?(session: string, pane: string, text: string): boolean;
36
36
  pressEnter?(session: string, pane: string): boolean;
37
37
  agentStatus?(session: string, pane: string): string | null;
38
+ /** Foreground argv0 names, or null when the pane can't be read. */
39
+ foreground(session: string, pane: string): string[] | null;
38
40
  sleep(ms: number): Promise<void>;
39
41
  now(): Date;
40
42
  };
@@ -44,6 +46,6 @@ export declare const realSources: UpSources;
44
46
  export declare const USAGE = "Usage: team up [--dry-run] [--session <name>] [--file <path>]\n";
45
47
  export declare const up: Command;
46
48
  export default up;
47
- /** The rules one seat gets at launch, with its own signature lines. */
49
+ /** The rules one seat gets at launch, with its own signature lines, as its delivery carries them. */
48
50
  export declare function rulesOf(team: TeamFile, seat: Seat): string;
49
51
  export declare function runUp(argv: string[], io: Io, sources: UpSources): Promise<number>;