@detergent-software/conductor 2.1.1 → 2.2.0-dev.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.
@@ -7,8 +7,11 @@ import type { ConductorInboxItem, ConductorLink, ConductorNotify, ConductorSnaps
7
7
  // A daemon API answer: its status and parsed JSON body.
8
8
  export type ApiAnswer = { body: unknown; status: number };
9
9
 
10
- // One line of `conduct events --follow`: an event of the log (with its id), or the stream's state.
11
- export type StreamLine = { data: Record<string, unknown>; id: number | null; kind: string; subject: string | null };
10
+ // What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
11
+ // (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
12
+ // purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
13
+ // call failed (failed: it threw, was denied, or answered an error).
14
+ export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
12
15
 
13
16
  // What an event asks of the mod. Only `escalate` wakes the console's model.
14
17
  export type Reaction =
@@ -16,14 +19,11 @@ export type Reaction =
16
19
  | { kind: 'escalate'; text: string }
17
20
  | { kind: 'ignore' }
18
21
  | { kind: 'push'; message: string }
19
- | { kind: 'refresh'; toast: string | null }
22
+ | { kind: 'refresh'; toast: null | string }
20
23
  | { kind: 'send'; text: string; to: string };
21
24
 
22
- // What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
23
- // (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
24
- // purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
25
- // call failed (failed: it threw, was denied, or answered an error).
26
- export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
25
+ // One line of `conduct events --follow`: an event of the log (with its id), or the stream's state.
26
+ export type StreamLine = { data: Record<string, unknown>; id: null | number; kind: string; subject: null | string };
27
27
 
28
28
  // Said once in the console when pushes stop reaching the phone (ATK-255), by why.
29
29
  export function pushWarning(outcome: PushOutcome): null | string {
@@ -64,9 +64,27 @@ export const MAX_WARMS = 12;
64
64
  // The fork's one user message: its answer is thrown away, so the shorter the better.
65
65
  export const WARM_PROMPT = 'Cache keep-alive from the conductor mod: answer with the one word ok.';
66
66
 
67
+ // A band entry and the act its press does.
68
+ export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
69
+
70
+ export type CacheUsage = { cacheRead: number; cacheWrite: number; input: number; output: number };
71
+
72
+ // A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
73
+ export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
67
74
  // What the console does at an idle point: ping to keep the cache warm, or start fresh.
68
75
  export type IdleAction = { kind: 'clear'; reason: string } | { kind: 'warm' };
69
76
 
77
+ // What a ping came to: the cache held (warm), it had lapsed anyway so pinging doesn't work here
78
+ // (cold: a 5-minute TTL, a model change), the call failed, or there was nothing to fork.
79
+ export type PingOutcome = { kind: 'cold' | 'failed' | 'warm'; usage: CacheUsage | null } | { kind: 'nothing' };
80
+
81
+ export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
82
+
83
+ // The band's line after the mod cleared an idle console, in place of the no-skill warning.
84
+ export function clearedText(reason: string): string {
85
+ return `fresh console (cleared while idle: ${reason}); /${CONSOLE_SKILL} loads with the next prompt`;
86
+ }
87
+
70
88
  // `contextTokens` is the context the last response was answered over (absent before the first);
71
89
  // `warms` the pings since the last real turn.
72
90
  export function idleAction({ contextTokens, warms }: { contextTokens: number | undefined; warms: number }): IdleAction {
@@ -76,11 +94,6 @@ export function idleAction({ contextTokens, warms }: { contextTokens: number | u
76
94
  return { kind: 'warm' };
77
95
  }
78
96
 
79
- // What a ping came to: the cache held (warm), it had lapsed anyway so pinging doesn't work here
80
- // (cold: a 5-minute TTL, a model change), the call failed, or there was nothing to fork.
81
- export type PingOutcome = { kind: 'cold' | 'failed' | 'warm'; usage: CacheUsage | null } | { kind: 'nothing' };
82
- export type CacheUsage = { cacheRead: number; cacheWrite: number; input: number; output: number };
83
-
84
97
  // `$.model.fork`'s result. The cache held when it served at least half the context the console's
85
98
  // last response was answered over; without that figure, when it served more than it wrote.
86
99
  export function pingOutcome(result: unknown, contextTokens: number | undefined): PingOutcome {
@@ -92,11 +105,6 @@ export function pingOutcome(result: unknown, contextTokens: number | undefined):
92
105
  return { kind: held ? 'warm' : 'cold', usage };
93
106
  }
94
107
 
95
- // The band's line after the mod cleared an idle console, in place of the no-skill warning.
96
- export function clearedText(reason: string): string {
97
- return `fresh console (cleared while idle: ${reason}); /${CONSOLE_SKILL} loads with the next prompt`;
98
- }
99
-
100
108
  function usageOf(value: unknown): CacheUsage | null {
101
109
  if (!isRecord(value)) return null;
102
110
  const count = (key: string): number => {
@@ -111,23 +119,12 @@ function usageOf(value: unknown): CacheUsage | null {
111
119
  };
112
120
  }
113
121
 
114
- // A band entry and the act its press does.
115
- export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
116
-
117
- export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
118
-
119
- // A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
120
- export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
121
-
122
122
  const BAND_LIMIT = 5;
123
123
 
124
- // `conductor 3▶ 2⏸ ✉2` for the pane's title, or undefined when no daemon answers.
125
- export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
126
- if (snapshot === null || snapshot.error !== null) return undefined;
127
- const running = snapshot.workers.filter((w) => w.status === 'running').length;
128
- const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
129
- return `conductor ${running}▶ ${parked}⏸ ✉${snapshot.inbox.length}`;
130
- }
124
+ // What a worker is up to, by its status and its session's activity (manager.ts's activityOf):
125
+ // in a turn, up between turns, stuck on a prompt (`waiting on permission`), parked on a wait,
126
+ // failed or gone (exited too), or not up yet (launching, starting).
127
+ export type WorkerState = 'failed' | 'idle' | 'parked' | 'prompt' | 'starting' | 'working';
131
128
 
132
129
  // The band's buttons: a plan approves (behind a confirm), an npm OTP copies its command, the rest
133
130
  // open the pane, where each item's commands and links are.
@@ -159,33 +156,141 @@ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup
159
156
  }));
160
157
  }
161
158
 
162
- // A worker row's glyph: ▶ running, ⏸ parked, ✖ failed or stopped, · otherwise (launching, exited).
163
- export function glyph(status: string): string {
164
- if (status === 'running') return '▶';
165
- if (status === 'parked') return '⏸';
166
- if (status === 'failed' || status === 'stopped-unexpectedly') return '✖';
167
- return '·';
168
- }
159
+ // `3 running · 2 parked · inbox 2` beside the pane's title, or undefined when no daemon answers.
160
+ export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
161
+ if (snapshot === null || snapshot.error !== null) return undefined;
162
+ const running = snapshot.workers.filter((w) => w.status === 'running').length;
163
+ const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
164
+ return `${running} running · ${parked} parked · inbox ${snapshot.inbox.length}`;
165
+ }
166
+
167
+ export function workerState(w: Pick<ConductorWorker, 'activity' | 'status'>): WorkerState {
168
+ if (w.status === 'failed' || w.status === 'stopped-unexpectedly' || w.status === 'exited') return 'failed';
169
+ if (w.status === 'parked') return 'parked';
170
+ if (w.status !== 'running') return 'starting';
171
+ if (w.activity === 'working') return 'working';
172
+ if (w.activity.startsWith('idle')) return 'idle';
173
+ if (w.activity.startsWith('waiting on ')) return 'prompt';
174
+ if (w.activity === 'stopped' || w.activity === 'no session') return 'failed';
175
+ return 'starting';
176
+ }
177
+
178
+ // A row's glyph and its theme color. None has an emoji form, which Windows Terminal draws two cells
179
+ // wide (⏸ ✖ ✉ did, ATK-521); the shapes are East Asian ambiguous width, one cell outside CJK setups.
180
+ export const GLYPHS: Readonly<Record<WorkerState, { char: string; color: string }>> = {
181
+ failed: { char: '×', color: 'error' },
182
+ idle: { char: '◌', color: 'suggestion' },
183
+ parked: { char: '○', color: 'subtle' },
184
+ prompt: { char: '!', color: 'warning' },
185
+ starting: { char: '·', color: 'subtle' },
186
+ working: { char: '●', color: 'success' },
187
+ };
169
188
 
170
189
  // A worker's PR as its row shows it: `#1850`; the expanded row shows the whole ref.
171
190
  export function prLabel(pr: null | string): string {
172
191
  return pr === null ? '' : `#${pr.slice(pr.lastIndexOf('#') + 1)}`;
173
192
  }
174
193
 
175
- // What a worker waits on or does: its open wait, else the session's activity.
176
- // A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
177
- // an https: URL or http://localhost. Anything else, such as a path, stays a Link, drawn as text.
178
- export function isWebLink(href: string): boolean {
179
- try {
180
- const url = new URL(href);
181
- return url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost');
182
- } catch {
183
- return false;
194
+ const PR_EVENTS: Readonly<Record<string, string>> = { merged: 'to merge' };
195
+
196
+ // The fleet's column widths: the widest name, phase (at most PHASE_MAX) and PR across every project,
197
+ // so the rows line up under each other wherever they are.
198
+ export type FleetColumns = { key: number; phase: number; pr: number };
199
+
200
+ // One worker's row as the pane draws it, in segments, each with its leading space: together at
201
+ // most the pane's width, so it never wraps. `isWait` says `doing` is a wait (drawn full strength).
202
+ export type FleetRow = {
203
+ age: string;
204
+ color: string;
205
+ doing: string;
206
+ glyph: string;
207
+ isWait: boolean;
208
+ key: string;
209
+ meta: string;
210
+ };
211
+
212
+ // A wait's condition key (conditions.ts's formatCondition) in words, the telling part first so a
213
+ // truncated row keeps it: `PR #557 to merge (agentic-toolkit-registry)`, `npm 1.2.3 on dev (atk)`.
214
+ // A kind this doesn't know, or a key it can't read, stays as it is.
215
+ export function waitLabel(condition: string, now: number): string {
216
+ const colon = condition.indexOf(':');
217
+ if (colon === -1) return condition;
218
+ const rest = condition.slice(colon + 1);
219
+ const read = (pattern: RegExp): null | string[] => pattern.exec(rest)?.slice(1) ?? null;
220
+ const short = (sha: string): string => sha.slice(0, 7);
221
+ const last = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
222
+ switch (condition.slice(0, colon)) {
223
+ case 'approval':
224
+ return 'plan approval';
225
+ case 'health': {
226
+ const [env = '', field = '', value = ''] = read(/^([^:]+):(commit|version)=(.+)$/) ?? [];
227
+ if (env) return `${env} health at ${field === 'commit' ? short(value) : value}`;
228
+ break;
229
+ }
230
+ case 'item': {
231
+ const [item = '', field = '', value = ''] = read(/^[^:]+:([^:]+):(state|label|note)=(.*)$/) ?? [];
232
+ if (item) return `${item} ${field} ${value}`.trimEnd();
233
+ break;
234
+ }
235
+ case 'login': {
236
+ const [provider = ''] = read(/^([^:]+):/) ?? [];
237
+ if (provider) return `login (${provider})`;
238
+ break;
239
+ }
240
+ case 'merge': {
241
+ const [sha = '', only] = read(/^(?:ado|github):[^@]+@([0-9a-f]+)(?::for=(.+))?$/i) ?? [];
242
+ if (sha) return `runs of ${short(sha)}${only ? ` (${only})` : ''}`;
243
+ break;
244
+ }
245
+ case 'npm': {
246
+ const [pkg = '', version = '', tag = ''] = read(/^(.+)@([^@:]+):([^:]+)$/) ?? [];
247
+ if (pkg) return `npm ${version} on ${tag} (${last(pkg)})`;
248
+ break;
249
+ }
250
+ case 'pr': {
251
+ const [host = '', repo = '', number = '', on = ''] = read(/^(ado|github):([^#]+)#(\d+):(\w+)$/) ?? [];
252
+ // An ADO PR is in the project's own repo; a GitHub one names its repo.
253
+ if (host) return `PR #${number} ${PR_EVENTS[on] ?? on}${host === 'github' ? ` (${last(repo)})` : ''}`;
254
+ break;
255
+ }
256
+ case 'run': {
257
+ const [pipeline = '', id = ''] = read(/^(?:ado|github):(.+)#(\d+):done$/) ?? [];
258
+ if (pipeline) return `run #${id} to finish (${last(pipeline).replace(/\.ya?ml$/, '')})`;
259
+ break;
260
+ }
261
+ case 'time':
262
+ if (!Number.isNaN(Date.parse(rest))) return `timer, ${remaining(rest, now)} left`;
263
+ break;
184
264
  }
265
+ return condition;
185
266
  }
186
267
 
187
- export function workerDoing(w: ConductorWorker): string {
188
- return w.waits[0] ?? w.activity;
268
+ // What a worker waits on, in words (and how many more waits), else the session's activity.
269
+ export function workerDoing(w: ConductorWorker, now: number): string {
270
+ const [first, ...more] = w.waits;
271
+ if (first === undefined) return w.activity;
272
+ return `${waitLabel(first, now)}${more.length > 0 ? ` +${more.length}` : ''}`;
273
+ }
274
+
275
+ const PHASE_MAX = 13;
276
+ const DOING_MIN = 12;
277
+ const DOING_SHORT = 6;
278
+ const AGE_WIDTH = 4;
279
+
280
+ // "now", "12m", "3h", "2d".
281
+ export function age(iso: string, now: number): string {
282
+ const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
283
+ if (!(minutes >= 1)) return 'now';
284
+ if (minutes < 60) return `${minutes}m`;
285
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
286
+ return `${Math.floor(minutes / (24 * 60))}d`;
287
+ }
288
+
289
+ // The error an API answer carries (`{ error, details }`), as one line.
290
+ export function errorOf(answer: ApiAnswer): string {
291
+ const { body } = answer;
292
+ if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
293
+ return `the conductor answered ${answer.status}`;
189
294
  }
190
295
 
191
296
  // The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
@@ -214,6 +319,51 @@ export function findWorker(workers: readonly ConductorWorker[], ref: string): Fo
214
319
  return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
215
320
  }
216
321
 
322
+ export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns {
323
+ const widest = (texts: string[]): number => Math.max(0, ...texts.map((t) => t.length));
324
+ return {
325
+ key: widest(workers.map((w) => w.name)),
326
+ phase: Math.min(PHASE_MAX, widest(workers.map((w) => w.phase ?? ''))),
327
+ pr: widest(workers.map((w) => prLabel(w.pr))),
328
+ };
329
+ }
330
+
331
+ // The row in `width` cells: glyph, name, phase, PR, what it does or waits on, age. Where they don't
332
+ // all fit, the age goes first, then the doing text, then the PR, then the phase. Which columns show
333
+ // depends only on the fleet's widths, so every row keeps the same ones.
334
+ export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: number, now: number): FleetRow {
335
+ const key = Math.max(1, Math.min(columns.key, width - 2));
336
+ // In the order the row draws them.
337
+ /* eslint-disable perfectionist/sort-maps */
338
+ const shown = new Map<'age' | 'doing' | 'phase' | 'pr', number>([
339
+ ['phase', columns.phase],
340
+ ['pr', columns.pr],
341
+ ['doing', DOING_MIN],
342
+ ['age', AGE_WIDTH],
343
+ ]);
344
+ /* eslint-enable perfectionist/sort-maps */
345
+ for (const [column, cells] of shown) if (cells === 0 && column !== 'doing') shown.delete(column);
346
+ const need = (): number => 2 + key + [...shown.values()].reduce((sum, cells) => sum + cells + 1, 0);
347
+ for (const column of ['age', 'doing', 'pr', 'phase'] as const) if (need() > width) shown.delete(column);
348
+ // The doing text comes back in what room is left, while that still shows a few words.
349
+ if (!shown.has('doing') && width - need() - 1 >= DOING_SHORT) shown.set('doing', width - need() - 1);
350
+ const cell = (column: 'age' | 'doing' | 'phase' | 'pr', text: string): string => {
351
+ const cells = shown.get(column);
352
+ if (cells === undefined) return '';
353
+ return ` ${column === 'doing' ? fit(text, cells + Math.max(0, width - need())) : fit(text, cells)}`;
354
+ };
355
+ const { char, color } = GLYPHS[workerState(w)];
356
+ return {
357
+ age: shown.has('age') ? ` ${age(w.updatedAt, now).padStart(AGE_WIDTH)}` : '',
358
+ color,
359
+ doing: cell('doing', workerDoing(w, now)),
360
+ glyph: char,
361
+ isWait: w.waits.length > 0,
362
+ key: ` ${fit(w.name, key)}`,
363
+ meta: `${cell('phase', w.phase ?? '')}${cell('pr', prLabel(w.pr))}`,
364
+ };
365
+ }
366
+
217
367
  // `/inbox`'s answer: one line per item, then its commands and links.
218
368
  export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
219
369
  if (inbox.length === 0) return 'The inbox is empty.';
@@ -227,33 +377,65 @@ export function inboxText(inbox: readonly ConductorInboxItem[], now: number): st
227
377
  .join('\n');
228
378
  }
229
379
 
230
- // "now", "12m", "3h", "2d".
231
- export function age(iso: string, now: number): string {
232
- const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
233
- if (!(minutes >= 1)) return 'now';
234
- if (minutes < 60) return `${minutes}m`;
235
- if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
236
- return `${Math.floor(minutes / (24 * 60))}d`;
237
- }
238
-
239
- // The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
240
- export function remaining(iso: string, now: number): string {
241
- const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
242
- if (!(minutes >= 1)) return '<1m';
243
- if (minutes < 60) return `${minutes}m`;
244
- if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
245
- return `${Math.floor(minutes / (24 * 60))}d`;
380
+ // A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
381
+ // an https: URL or http://localhost. Anything else, such as a path, stays a Link, drawn as text.
382
+ export function isWebLink(href: string): boolean {
383
+ try {
384
+ const url = new URL(href);
385
+ return url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost');
386
+ } catch {
387
+ return false;
388
+ }
246
389
  }
247
390
 
248
- // 950, 42k, 6.1M.
249
- export function tokens(n: number): string {
250
- if (n < 1000) return String(n);
251
- if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
252
- return `${(n / 1_000_000).toFixed(1)}M`;
391
+ // GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
392
+ export function parseFleet(body: unknown): ConductorWorker[] | null {
393
+ if (!isRecord(body) || !Array.isArray(body.workers)) return null;
394
+ return body.workers.flatMap((w): ConductorWorker[] => {
395
+ if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string')
396
+ return [];
397
+ const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
398
+ const waits = Array.isArray(w.waits)
399
+ ? w.waits.flatMap((x) =>
400
+ isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
401
+ )
402
+ : [];
403
+ return [
404
+ {
405
+ activity: typeof w.activity === 'string' ? w.activity : w.status,
406
+ id: w.id,
407
+ item: typeof w.item === 'string' ? w.item : w.name,
408
+ name: w.name,
409
+ phase: typeof w.phase === 'string' ? w.phase : null,
410
+ pr: typeof w.pr === 'string' ? w.pr : null,
411
+ project: typeof w.project === 'string' ? w.project : '?',
412
+ spendToday: spend,
413
+ status: w.status,
414
+ updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
415
+ waits,
416
+ },
417
+ ];
418
+ });
253
419
  }
254
420
 
255
- export function spendToday(workers: readonly ConductorWorker[]): number {
256
- return workers.reduce((sum, w) => sum + w.spendToday, 0);
421
+ // GET /inbox's body; null when it isn't one.
422
+ export function parseInbox(body: unknown): ConductorInboxItem[] | null {
423
+ if (!isRecord(body) || !Array.isArray(body.items)) return null;
424
+ return body.items.flatMap((i): ConductorInboxItem[] => {
425
+ if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
426
+ return [
427
+ {
428
+ commands: strings(i.commands),
429
+ id: i.id,
430
+ item: typeof i.item === 'string' ? i.item : null,
431
+ kind: i.kind,
432
+ links: links(i.links),
433
+ project: typeof i.project === 'string' ? i.project : null,
434
+ raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
435
+ text: i.text,
436
+ },
437
+ ];
438
+ });
257
439
  }
258
440
 
259
441
  // One line of the follow stream, or null for anything that isn't an event object.
@@ -285,6 +467,9 @@ export function reaction(line: StreamLine): Reaction {
285
467
  return { kind: 'refresh', toast: null };
286
468
  case 'conduct.disconnected':
287
469
  return { kind: 'disconnected' };
470
+ case 'console.cache':
471
+ case 'watcher.stats':
472
+ return { kind: 'ignore' };
288
473
  case 'console.escalated': {
289
474
  const ask = text('text');
290
475
  // The id is what the console reports back on (`conduct escalations done <id>`).
@@ -313,9 +498,6 @@ export function reaction(line: StreamLine): Reaction {
313
498
  ? { kind: 'push', message }
314
499
  : { kind: 'ignore' };
315
500
  }
316
- case 'console.cache':
317
- case 'watcher.stats':
318
- return { kind: 'ignore' };
319
501
  case 'worker.message-requested': {
320
502
  const to = text('to');
321
503
  const message = text('message');
@@ -326,61 +508,30 @@ export function reaction(line: StreamLine): Reaction {
326
508
  }
327
509
  }
328
510
 
329
- // GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
330
- export function parseFleet(body: unknown): ConductorWorker[] | null {
331
- if (!isRecord(body) || !Array.isArray(body.workers)) return null;
332
- return body.workers.flatMap((w): ConductorWorker[] => {
333
- if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string')
334
- return [];
335
- const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
336
- const waits = Array.isArray(w.waits)
337
- ? w.waits.flatMap((x) =>
338
- isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
339
- )
340
- : [];
341
- return [
342
- {
343
- activity: typeof w.activity === 'string' ? w.activity : w.status,
344
- id: w.id,
345
- item: typeof w.item === 'string' ? w.item : w.name,
346
- name: w.name,
347
- phase: typeof w.phase === 'string' ? w.phase : null,
348
- pr: typeof w.pr === 'string' ? w.pr : null,
349
- project: typeof w.project === 'string' ? w.project : '?',
350
- spendToday: spend,
351
- status: w.status,
352
- updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
353
- waits,
354
- },
355
- ];
356
- });
511
+ // The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
512
+ export function remaining(iso: string, now: number): string {
513
+ const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
514
+ if (!(minutes >= 1)) return '<1m';
515
+ if (minutes < 60) return `${minutes}m`;
516
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
517
+ return `${Math.floor(minutes / (24 * 60))}d`;
357
518
  }
358
519
 
359
- // GET /inbox's body; null when it isn't one.
360
- export function parseInbox(body: unknown): ConductorInboxItem[] | null {
361
- if (!isRecord(body) || !Array.isArray(body.items)) return null;
362
- return body.items.flatMap((i): ConductorInboxItem[] => {
363
- if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
364
- return [
365
- {
366
- commands: strings(i.commands),
367
- id: i.id,
368
- item: typeof i.item === 'string' ? i.item : null,
369
- kind: i.kind,
370
- links: links(i.links),
371
- project: typeof i.project === 'string' ? i.project : null,
372
- raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
373
- text: i.text,
374
- },
375
- ];
376
- });
520
+ export function spendToday(workers: readonly ConductorWorker[]): number {
521
+ return workers.reduce((sum, w) => sum + w.spendToday, 0);
377
522
  }
378
523
 
379
- // The error an API answer carries (`{ error, details }`), as one line.
380
- export function errorOf(answer: ApiAnswer): string {
381
- const { body } = answer;
382
- if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
383
- return `the conductor answered ${answer.status}`;
524
+ // 950, 42k, 6.1M.
525
+ export function tokens(n: number): string {
526
+ if (n < 1000) return String(n);
527
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
528
+ return `${(n / 1_000_000).toFixed(1)}M`;
529
+ }
530
+
531
+ // `text` in exactly `cells` cells: padded, or cut with an ellipsis.
532
+ function fit(text: string, cells: number): string {
533
+ if (text.length <= cells) return text.padEnd(cells);
534
+ return cells <= 1 ? '…'.slice(0, cells) : `${text.slice(0, cells - 1).trimEnd()}…`.padEnd(cells);
384
535
  }
385
536
 
386
537
  // What `conduct approve|revise` adds when the daemon can't say whether it posted the comment.
@@ -401,26 +552,11 @@ export function answerText(action: 'approve' | 'done' | 'revise', id: number, an
401
552
  return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
402
553
  }
403
554
 
404
- // A `conduct` run (attach, approve, revise) as the line to show: what it printed when it succeeded
405
- // (`woke …`, `approved …`), else `failed`, then its error and details without the `conduct: `
406
- // prefix, and without anything node wrote before it (a warning).
407
- export function runText(
408
- run: { exitCode: number; stderr: string; stdout: string },
409
- says: { failed: string; ok: string },
410
- ): string {
411
- const lines = (text: string): string[] =>
412
- text
413
- .split('\n')
414
- .map((l) => l.trim())
415
- .filter((l) => l !== '');
416
- if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
417
- const stderr = lines(run.stderr);
418
- const from = stderr.findIndex((l) => l.startsWith('conduct: '));
419
- const error = stderr
420
- .slice(Math.max(from, 0))
421
- .map((l) => l.replace(/^conduct: /, ''))
422
- .join('; ');
423
- return `${says.failed}: ${error || `conduct exited ${run.exitCode}`}`;
555
+ // The port and token files' text as an endpoint; null unless both are whole.
556
+ export function endpointOf(portText: string, tokenText: string): null | { port: number; token: string } {
557
+ const port = Number(portText.trim());
558
+ const token = tokenText.trim();
559
+ return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
424
560
  }
425
561
 
426
562
  // The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
@@ -431,27 +567,15 @@ export function homeOf(env: { conductorHome?: string; home?: string; userProfile
431
567
  return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
432
568
  }
433
569
 
434
- // The port and token files' text as an endpoint; null unless both are whole.
435
- export function endpointOf(portText: string, tokenText: string): { port: number; token: string } | null {
436
- const port = Number(portText.trim());
437
- const token = tokenText.trim();
438
- return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
439
- }
440
-
441
- // GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
442
- // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
443
- export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
444
- const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
445
- if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
446
- const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
447
- if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
448
- return {
449
- error: null,
450
- inbox: items,
451
- notify: notify?.status === 200 ? parseNotify(notify.body) : null,
452
- readAt,
453
- workers,
454
- };
570
+ // `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
571
+ // midnight) or a date and time, as `conduct notify profile` reads it.
572
+ export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
573
+ const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
574
+ if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
575
+ const when = typed[2];
576
+ if (when === undefined) return { name: typed[1], until: null };
577
+ const until = untilOf(when, now);
578
+ return typeof until === 'string' ? { name: typed[1], until } : until;
455
579
  }
456
580
 
457
581
  // GET /notify's body, kept to what the mod draws; null when it isn't one.
@@ -494,41 +618,54 @@ export function pushOutcome(answer: unknown): PushOutcome {
494
618
  return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
495
619
  }
496
620
 
497
- // `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
498
- // midnight) or a date and time, as `conduct notify profile` reads it.
499
- export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
500
- const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
501
- if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
502
- const when = typed[2];
503
- if (when === undefined) return { name: typed[1], until: null };
504
- const until = untilOf(when, now);
505
- return typeof until === 'string' ? { name: typed[1], until } : until;
621
+ // A `conduct` run (attach, approve, revise) as the line to show: what it printed when it succeeded
622
+ // (`woke …`, `approved …`), else `failed`, then its error and details without the `conduct: `
623
+ // prefix, and without anything node wrote before it (a warning).
624
+ export function runText(
625
+ run: { exitCode: number; stderr: string; stdout: string },
626
+ says: { failed: string; ok: string },
627
+ ): string {
628
+ const lines = (text: string): string[] =>
629
+ text
630
+ .split('\n')
631
+ .map((l) => l.trim())
632
+ .filter((l) => l !== '');
633
+ if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
634
+ const stderr = lines(run.stderr);
635
+ const from = stderr.findIndex((l) => l.startsWith('conduct: '));
636
+ const error = stderr
637
+ .slice(Math.max(from, 0))
638
+ .map((l) => l.replace(/^conduct: /, ''))
639
+ .join('; ');
640
+ return `${says.failed}: ${error || `conduct exited ${run.exitCode}`}`;
641
+ }
642
+
643
+ // GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
644
+ // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
645
+ export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
646
+ const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
647
+ if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
648
+ const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
649
+ if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
650
+ return {
651
+ error: null,
652
+ inbox: items,
653
+ notify: notify?.status === 200 ? parseNotify(notify.body) : null,
654
+ readAt,
655
+ workers,
656
+ };
506
657
  }
507
658
 
508
659
  const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
509
660
 
510
- // `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
511
- // (local midnight), or a date and time, as `conduct` reads them.
512
- export function untilOf(when: string, now: number): string | { error: string } {
513
- const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
514
- const lower = when.toLowerCase();
515
- const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
516
- const at = new Date(now);
517
- if (clock) {
518
- at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
519
- if (at.getTime() <= now) at.setDate(at.getDate() + 1);
520
- } else if (lower === 'tomorrow' || day !== -1) {
521
- at.setHours(0, 0, 0, 0);
522
- at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
523
- } else {
524
- at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
525
- }
526
- if (Number.isNaN(at.getTime())) {
527
- return {
528
- error: `--until "${when}" is not a time: 17:00, tomorrow, a day such as monday, or a date and time such as 2026-10-12T09:00`,
529
- };
530
- }
531
- return at.toISOString();
661
+ // `/notify` with no profile: the profile, quiet hours, Remote Control.
662
+ export function notifyText(notify: ConductorNotify | null, now: number): string {
663
+ if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
664
+ const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
665
+ return [
666
+ `notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
667
+ `Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
668
+ ].join('\n');
532
669
  }
533
670
 
534
671
  // `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
@@ -586,6 +723,38 @@ export function overrideText(
586
723
  return `${who} goes first${until}`;
587
724
  }
588
725
 
726
+ // POST /notify/profile's answer as the line to show.
727
+ export function profileText(name: string, answer: ApiAnswer, now: number): string {
728
+ if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
729
+ const notify = parseNotify(answer.body);
730
+ const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
731
+ return `notify profile: ${notify?.profile ?? name}${until}`;
732
+ }
733
+
734
+ // `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
735
+ // (local midnight), or a date and time, as `conduct` reads them.
736
+ export function untilOf(when: string, now: number): string | { error: string } {
737
+ const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
738
+ const lower = when.toLowerCase();
739
+ const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
740
+ const at = new Date(now);
741
+ if (clock) {
742
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
743
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1);
744
+ } else if (lower === 'tomorrow' || day !== -1) {
745
+ at.setHours(0, 0, 0, 0);
746
+ at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
747
+ } else {
748
+ at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
749
+ }
750
+ if (Number.isNaN(at.getTime())) {
751
+ return {
752
+ error: `--until "${when}" is not a time: 17:00, tomorrow, a day such as monday, or a date and time such as 2026-10-12T09:00`,
753
+ };
754
+ }
755
+ return at.toISOString();
756
+ }
757
+
589
758
  // GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
590
759
  export function whyText(item: string, answer: ApiAnswer): string {
591
760
  if (answer.status === 404)
@@ -605,24 +774,6 @@ export function whyText(item: string, answer: ApiAnswer): string {
605
774
  return `${item}: ${what}${rule.ref}${text}`;
606
775
  }
607
776
 
608
- // `/notify` with no profile: the profile, quiet hours, Remote Control.
609
- export function notifyText(notify: ConductorNotify | null, now: number): string {
610
- if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
611
- const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
612
- return [
613
- `notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
614
- `Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
615
- ].join('\n');
616
- }
617
-
618
- // POST /notify/profile's answer as the line to show.
619
- export function profileText(name: string, answer: ApiAnswer, now: number): string {
620
- if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
621
- const notify = parseNotify(answer.body);
622
- const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
623
- return `notify profile: ${notify?.profile ?? name}${until}`;
624
- }
625
-
626
777
  function isRecord(value: unknown): value is Record<string, unknown> {
627
778
  return typeof value === 'object' && value !== null && !Array.isArray(value);
628
779
  }