@detergent-software/conductor 2.2.0-dev.1 → 2.2.0-dev.3

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 (39) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +9 -5
  3. package/build/code-host.js +11 -0
  4. package/build/code-host.js.map +1 -1
  5. package/build/daemon.js +5 -3
  6. package/build/daemon.js.map +1 -1
  7. package/build/follow-ups.js +0 -3
  8. package/build/follow-ups.js.map +1 -1
  9. package/build/notes.js +1 -0
  10. package/build/notes.js.map +1 -1
  11. package/build/profile.js +11 -3
  12. package/build/profile.js.map +1 -1
  13. package/build/queue.js +25 -2
  14. package/build/queue.js.map +1 -1
  15. package/build/scheduler.js +85 -27
  16. package/build/scheduler.js.map +1 -1
  17. package/build/store.js +7 -3
  18. package/build/store.js.map +1 -1
  19. package/build/watchers/boards-queue.js +43 -2
  20. package/build/watchers/boards-queue.js.map +1 -1
  21. package/build/watchers/boards-writes.js +6 -2
  22. package/build/watchers/boards-writes.js.map +1 -1
  23. package/build/watchers/boards.js +15 -6
  24. package/build/watchers/boards.js.map +1 -1
  25. package/build/watchers/issues-queue.js +47 -5
  26. package/build/watchers/issues-queue.js.map +1 -1
  27. package/build/watchers/issues-writes.js +21 -2
  28. package/build/watchers/issues-writes.js.map +1 -1
  29. package/build/watchers/issues.js +6 -5
  30. package/build/watchers/issues.js.map +1 -1
  31. package/build/watchers/linear-queue.js +6 -20
  32. package/build/watchers/linear-queue.js.map +1 -1
  33. package/build/watchers/release-wait.js +30 -0
  34. package/build/watchers/release-wait.js.map +1 -0
  35. package/mod/README.md +2 -0
  36. package/mod/hooks/model.ts +284 -281
  37. package/mod/hooks/register.tsx +266 -265
  38. package/mod/types/index.d.ts +26 -26
  39. package/package.json +4 -4
@@ -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
- // `3 running · 2 parked · inbox 2` beside 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 `${running} running · ${parked} parked · inbox ${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,10 +156,13 @@ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup
159
156
  }));
160
157
  }
161
158
 
162
- // What a worker is up to, by its status and its session's activity (manager.ts's activityOf):
163
- // in a turn, up between turns, stuck on a prompt (`waiting on permission`), parked on a wait,
164
- // failed or gone (exited too), or not up yet (launching, starting).
165
- export type WorkerState = 'failed' | 'idle' | 'parked' | 'prompt' | 'starting' | 'working';
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
166
 
167
167
  export function workerState(w: Pick<ConductorWorker, 'activity' | 'status'>): WorkerState {
168
168
  if (w.status === 'failed' || w.status === 'stopped-unexpectedly' || w.status === 'exited') return 'failed';
@@ -193,6 +193,22 @@ export function prLabel(pr: null | string): string {
193
193
 
194
194
  const PR_EVENTS: Readonly<Record<string, string>> = { merged: 'to merge' };
195
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
+
196
212
  // A wait's condition key (conditions.ts's formatCondition) in words, the telling part first so a
197
213
  // truncated row keeps it: `PR #557 to merge (agentic-toolkit-registry)`, `npm 1.2.3 on dev (atk)`.
198
214
  // A kind this doesn't know, or a key it can't read, stays as it is.
@@ -200,7 +216,7 @@ export function waitLabel(condition: string, now: number): string {
200
216
  const colon = condition.indexOf(':');
201
217
  if (colon === -1) return condition;
202
218
  const rest = condition.slice(colon + 1);
203
- const read = (pattern: RegExp): string[] | null => pattern.exec(rest)?.slice(1) ?? null;
219
+ const read = (pattern: RegExp): null | string[] => pattern.exec(rest)?.slice(1) ?? null;
204
220
  const short = (sha: string): string => sha.slice(0, 7);
205
221
  const last = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
206
222
  switch (condition.slice(0, colon)) {
@@ -256,27 +272,53 @@ export function workerDoing(w: ConductorWorker, now: number): string {
256
272
  return `${waitLabel(first, now)}${more.length > 0 ? ` +${more.length}` : ''}`;
257
273
  }
258
274
 
259
- // The fleet's column widths: the widest name, phase (at most PHASE_MAX) and PR across every project,
260
- // so the rows line up under each other wherever they are.
261
- export type FleetColumns = { key: number; phase: number; pr: number };
262
-
263
- // One worker's row as the pane draws it, in segments, each with its leading space: together at
264
- // most the pane's width, so it never wraps. `isWait` says `doing` is a wait (drawn full strength).
265
- export type FleetRow = {
266
- age: string;
267
- color: string;
268
- doing: string;
269
- glyph: string;
270
- isWait: boolean;
271
- key: string;
272
- meta: string;
273
- };
274
-
275
275
  const PHASE_MAX = 13;
276
276
  const DOING_MIN = 12;
277
277
  const DOING_SHORT = 6;
278
278
  const AGE_WIDTH = 4;
279
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}`;
294
+ }
295
+
296
+ // The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
297
+ export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
298
+ const byId = /^#(\d+)$/.exec(ref.trim());
299
+ if (byId) {
300
+ const item = inbox.find((i) => i.id === Number(byId[1]));
301
+ return item ? { found: item } : { error: `no open inbox item ${ref}` };
302
+ }
303
+ if (/^\d+$/.test(ref.trim()))
304
+ return {
305
+ error: `"${ref}" is ambiguous: name the item, such as ATK-${ref.trim()}, or the inbox id as #${ref.trim()}`,
306
+ };
307
+ const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
308
+ if (plans.length === 1 && plans[0]) return { found: plans[0] };
309
+ if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
310
+ return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
311
+ }
312
+
313
+ // A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
314
+ export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
315
+ const byName = workers.filter((w) => w.name === ref);
316
+ const matches = byName.length > 0 ? byName : workers.filter((w) => w.item.toUpperCase() === ref.toUpperCase());
317
+ if (matches.length === 1 && matches[0]) return { found: matches[0] };
318
+ if (matches.length === 0) return { error: `no worker "${ref}" in flight` };
319
+ return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
320
+ }
321
+
280
322
  export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns {
281
323
  const widest = (texts: string[]): number => Math.max(0, ...texts.map((t) => t.length));
282
324
  return {
@@ -291,12 +333,15 @@ export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns
291
333
  // depends only on the fleet's widths, so every row keeps the same ones.
292
334
  export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: number, now: number): FleetRow {
293
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 */
294
338
  const shown = new Map<'age' | 'doing' | 'phase' | 'pr', number>([
295
339
  ['phase', columns.phase],
296
340
  ['pr', columns.pr],
297
341
  ['doing', DOING_MIN],
298
342
  ['age', AGE_WIDTH],
299
343
  ]);
344
+ /* eslint-enable perfectionist/sort-maps */
300
345
  for (const [column, cells] of shown) if (cells === 0 && column !== 'doing') shown.delete(column);
301
346
  const need = (): number => 2 + key + [...shown.values()].reduce((sum, cells) => sum + cells + 1, 0);
302
347
  for (const column of ['age', 'doing', 'pr', 'phase'] as const) if (need() > width) shown.delete(column);
@@ -319,10 +364,17 @@ export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: numbe
319
364
  };
320
365
  }
321
366
 
322
- // `text` in exactly `cells` cells: padded, or cut with an ellipsis.
323
- function fit(text: string, cells: number): string {
324
- if (text.length <= cells) return text.padEnd(cells);
325
- return cells <= 1 ? '…'.slice(0, cells) : `${text.slice(0, cells - 1).trimEnd()}…`.padEnd(cells);
367
+ // `/inbox`'s answer: one line per item, then its commands and links.
368
+ export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
369
+ if (inbox.length === 0) return 'The inbox is empty.';
370
+ return inbox
371
+ .flatMap((i) => [
372
+ `#${i.id} ${i.kind} ${i.item ?? ''}${i.project ? ` (${i.project})` : ''} ${age(i.raisedAt, now)}`,
373
+ ` ${i.text}`,
374
+ ...i.commands.map((c) => ` $ ${c}`),
375
+ ...i.links.map((l) => ` ${l.label}: ${l.href}`),
376
+ ])
377
+ .join('\n');
326
378
  }
327
379
 
328
380
  // A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
@@ -336,89 +388,71 @@ export function isWebLink(href: string): boolean {
336
388
  }
337
389
  }
338
390
 
339
- // The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
340
- export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
341
- const byId = /^#(\d+)$/.exec(ref.trim());
342
- if (byId) {
343
- const item = inbox.find((i) => i.id === Number(byId[1]));
344
- return item ? { found: item } : { error: `no open inbox item ${ref}` };
345
- }
346
- if (/^\d+$/.test(ref.trim()))
347
- return {
348
- error: `"${ref}" is ambiguous: name the item, such as ATK-${ref.trim()}, or the inbox id as #${ref.trim()}`,
349
- };
350
- const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
351
- if (plans.length === 1 && plans[0]) return { found: plans[0] };
352
- if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
353
- return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
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
+ });
354
419
  }
355
420
 
356
- // A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
357
- export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
358
- const byName = workers.filter((w) => w.name === ref);
359
- const matches = byName.length > 0 ? byName : workers.filter((w) => w.item.toUpperCase() === ref.toUpperCase());
360
- if (matches.length === 1 && matches[0]) return { found: matches[0] };
361
- if (matches.length === 0) return { error: `no worker "${ref}" in flight` };
362
- return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
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
+ });
363
439
  }
364
440
 
365
- // `/inbox`'s answer: one line per item, then its commands and links.
366
- export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
367
- if (inbox.length === 0) return 'The inbox is empty.';
368
- return inbox
369
- .flatMap((i) => [
370
- `#${i.id} ${i.kind} ${i.item ?? ''}${i.project ? ` (${i.project})` : ''} ${age(i.raisedAt, now)}`,
371
- ` ${i.text}`,
372
- ...i.commands.map((c) => ` $ ${c}`),
373
- ...i.links.map((l) => ` ${l.label}: ${l.href}`),
374
- ])
375
- .join('\n');
376
- }
377
-
378
- // "now", "12m", "3h", "2d".
379
- export function age(iso: string, now: number): string {
380
- const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
381
- if (!(minutes >= 1)) return 'now';
382
- if (minutes < 60) return `${minutes}m`;
383
- if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
384
- return `${Math.floor(minutes / (24 * 60))}d`;
385
- }
386
-
387
- // The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
388
- export function remaining(iso: string, now: number): string {
389
- const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
390
- if (!(minutes >= 1)) return '<1m';
391
- if (minutes < 60) return `${minutes}m`;
392
- if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
393
- return `${Math.floor(minutes / (24 * 60))}d`;
394
- }
395
-
396
- // 950, 42k, 6.1M.
397
- export function tokens(n: number): string {
398
- if (n < 1000) return String(n);
399
- if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
400
- return `${(n / 1_000_000).toFixed(1)}M`;
401
- }
402
-
403
- export function spendToday(workers: readonly ConductorWorker[]): number {
404
- return workers.reduce((sum, w) => sum + w.spendToday, 0);
405
- }
406
-
407
- // One line of the follow stream, or null for anything that isn't an event object.
408
- export function parseLine(line: string): null | StreamLine {
409
- let value: unknown;
410
- try {
411
- value = JSON.parse(line);
412
- } catch {
413
- return null;
414
- }
415
- if (!isRecord(value) || typeof value.kind !== 'string') return null;
416
- return {
417
- data: isRecord(value.data) ? value.data : {},
418
- id: typeof value.id === 'number' ? value.id : null,
419
- kind: value.kind,
420
- subject: typeof value.subject === 'string' ? value.subject : null,
421
- };
441
+ // One line of the follow stream, or null for anything that isn't an event object.
442
+ export function parseLine(line: string): null | StreamLine {
443
+ let value: unknown;
444
+ try {
445
+ value = JSON.parse(line);
446
+ } catch {
447
+ return null;
448
+ }
449
+ if (!isRecord(value) || typeof value.kind !== 'string') return null;
450
+ return {
451
+ data: isRecord(value.data) ? value.data : {},
452
+ id: typeof value.id === 'number' ? value.id : null,
453
+ kind: value.kind,
454
+ subject: typeof value.subject === 'string' ? value.subject : null,
455
+ };
422
456
  }
423
457
 
424
458
  // What an event asks of the mod. Remote Control pushes, escalations and worker messages are
@@ -433,6 +467,9 @@ export function reaction(line: StreamLine): Reaction {
433
467
  return { kind: 'refresh', toast: null };
434
468
  case 'conduct.disconnected':
435
469
  return { kind: 'disconnected' };
470
+ case 'console.cache':
471
+ case 'watcher.stats':
472
+ return { kind: 'ignore' };
436
473
  case 'console.escalated': {
437
474
  const ask = text('text');
438
475
  // The id is what the console reports back on (`conduct escalations done <id>`).
@@ -461,9 +498,6 @@ export function reaction(line: StreamLine): Reaction {
461
498
  ? { kind: 'push', message }
462
499
  : { kind: 'ignore' };
463
500
  }
464
- case 'console.cache':
465
- case 'watcher.stats':
466
- return { kind: 'ignore' };
467
501
  case 'worker.message-requested': {
468
502
  const to = text('to');
469
503
  const message = text('message');
@@ -474,61 +508,30 @@ export function reaction(line: StreamLine): Reaction {
474
508
  }
475
509
  }
476
510
 
477
- // GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
478
- export function parseFleet(body: unknown): ConductorWorker[] | null {
479
- if (!isRecord(body) || !Array.isArray(body.workers)) return null;
480
- return body.workers.flatMap((w): ConductorWorker[] => {
481
- if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string')
482
- return [];
483
- const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
484
- const waits = Array.isArray(w.waits)
485
- ? w.waits.flatMap((x) =>
486
- isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
487
- )
488
- : [];
489
- return [
490
- {
491
- activity: typeof w.activity === 'string' ? w.activity : w.status,
492
- id: w.id,
493
- item: typeof w.item === 'string' ? w.item : w.name,
494
- name: w.name,
495
- phase: typeof w.phase === 'string' ? w.phase : null,
496
- pr: typeof w.pr === 'string' ? w.pr : null,
497
- project: typeof w.project === 'string' ? w.project : '?',
498
- spendToday: spend,
499
- status: w.status,
500
- updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
501
- waits,
502
- },
503
- ];
504
- });
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`;
505
518
  }
506
519
 
507
- // GET /inbox's body; null when it isn't one.
508
- export function parseInbox(body: unknown): ConductorInboxItem[] | null {
509
- if (!isRecord(body) || !Array.isArray(body.items)) return null;
510
- return body.items.flatMap((i): ConductorInboxItem[] => {
511
- if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
512
- return [
513
- {
514
- commands: strings(i.commands),
515
- id: i.id,
516
- item: typeof i.item === 'string' ? i.item : null,
517
- kind: i.kind,
518
- links: links(i.links),
519
- project: typeof i.project === 'string' ? i.project : null,
520
- raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
521
- text: i.text,
522
- },
523
- ];
524
- });
520
+ export function spendToday(workers: readonly ConductorWorker[]): number {
521
+ return workers.reduce((sum, w) => sum + w.spendToday, 0);
525
522
  }
526
523
 
527
- // The error an API answer carries (`{ error, details }`), as one line.
528
- export function errorOf(answer: ApiAnswer): string {
529
- const { body } = answer;
530
- if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
531
- 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);
532
535
  }
533
536
 
534
537
  // What `conduct approve|revise` adds when the daemon can't say whether it posted the comment.
@@ -549,26 +552,11 @@ export function answerText(action: 'approve' | 'done' | 'revise', id: number, an
549
552
  return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
550
553
  }
551
554
 
552
- // A `conduct` run (attach, approve, revise) as the line to show: what it printed when it succeeded
553
- // (`woke …`, `approved …`), else `failed`, then its error and details without the `conduct: `
554
- // prefix, and without anything node wrote before it (a warning).
555
- export function runText(
556
- run: { exitCode: number; stderr: string; stdout: string },
557
- says: { failed: string; ok: string },
558
- ): string {
559
- const lines = (text: string): string[] =>
560
- text
561
- .split('\n')
562
- .map((l) => l.trim())
563
- .filter((l) => l !== '');
564
- if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
565
- const stderr = lines(run.stderr);
566
- const from = stderr.findIndex((l) => l.startsWith('conduct: '));
567
- const error = stderr
568
- .slice(Math.max(from, 0))
569
- .map((l) => l.replace(/^conduct: /, ''))
570
- .join('; ');
571
- 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;
572
560
  }
573
561
 
574
562
  // The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
@@ -579,27 +567,15 @@ export function homeOf(env: { conductorHome?: string; home?: string; userProfile
579
567
  return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
580
568
  }
581
569
 
582
- // The port and token files' text as an endpoint; null unless both are whole.
583
- export function endpointOf(portText: string, tokenText: string): { port: number; token: string } | null {
584
- const port = Number(portText.trim());
585
- const token = tokenText.trim();
586
- return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
587
- }
588
-
589
- // GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
590
- // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
591
- export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
592
- const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
593
- if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
594
- const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
595
- if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
596
- return {
597
- error: null,
598
- inbox: items,
599
- notify: notify?.status === 200 ? parseNotify(notify.body) : null,
600
- readAt,
601
- workers,
602
- };
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;
603
579
  }
604
580
 
605
581
  // GET /notify's body, kept to what the mod draws; null when it isn't one.
@@ -642,41 +618,54 @@ export function pushOutcome(answer: unknown): PushOutcome {
642
618
  return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
643
619
  }
644
620
 
645
- // `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
646
- // midnight) or a date and time, as `conduct notify profile` reads it.
647
- export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
648
- const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
649
- if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
650
- const when = typed[2];
651
- if (when === undefined) return { name: typed[1], until: null };
652
- const until = untilOf(when, now);
653
- 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
+ };
654
657
  }
655
658
 
656
659
  const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
657
660
 
658
- // `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
659
- // (local midnight), or a date and time, as `conduct` reads them.
660
- export function untilOf(when: string, now: number): string | { error: string } {
661
- const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
662
- const lower = when.toLowerCase();
663
- const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
664
- const at = new Date(now);
665
- if (clock) {
666
- at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
667
- if (at.getTime() <= now) at.setDate(at.getDate() + 1);
668
- } else if (lower === 'tomorrow' || day !== -1) {
669
- at.setHours(0, 0, 0, 0);
670
- at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
671
- } else {
672
- at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
673
- }
674
- if (Number.isNaN(at.getTime())) {
675
- return {
676
- 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`,
677
- };
678
- }
679
- 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');
680
669
  }
681
670
 
682
671
  // `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
@@ -734,6 +723,38 @@ export function overrideText(
734
723
  return `${who} goes first${until}`;
735
724
  }
736
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
+
737
758
  // GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
738
759
  export function whyText(item: string, answer: ApiAnswer): string {
739
760
  if (answer.status === 404)
@@ -753,24 +774,6 @@ export function whyText(item: string, answer: ApiAnswer): string {
753
774
  return `${item}: ${what}${rule.ref}${text}`;
754
775
  }
755
776
 
756
- // `/notify` with no profile: the profile, quiet hours, Remote Control.
757
- export function notifyText(notify: ConductorNotify | null, now: number): string {
758
- if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
759
- const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
760
- return [
761
- `notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
762
- `Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
763
- ].join('\n');
764
- }
765
-
766
- // POST /notify/profile's answer as the line to show.
767
- export function profileText(name: string, answer: ApiAnswer, now: number): string {
768
- if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
769
- const notify = parseNotify(answer.body);
770
- const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
771
- return `notify profile: ${notify?.profile ?? name}${until}`;
772
- }
773
-
774
777
  function isRecord(value: unknown): value is Record<string, unknown> {
775
778
  return typeof value === 'object' && value !== null && !Array.isArray(value);
776
779
  }