@detergent-software/conductor 1.0.0-dev.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +277 -24
  3. package/build/brief-cli.js +68 -0
  4. package/build/brief-cli.js.map +1 -0
  5. package/build/brief.js +211 -0
  6. package/build/brief.js.map +1 -0
  7. package/build/broker-cli.js +126 -0
  8. package/build/broker-cli.js.map +1 -0
  9. package/build/broker.js +154 -0
  10. package/build/broker.js.map +1 -0
  11. package/build/cli.js +438 -132
  12. package/build/cli.js.map +1 -1
  13. package/build/clock.js +17 -0
  14. package/build/clock.js.map +1 -0
  15. package/build/conditions.js +20 -14
  16. package/build/conditions.js.map +1 -1
  17. package/build/config.js +26 -3
  18. package/build/config.js.map +1 -1
  19. package/build/console.js +176 -0
  20. package/build/console.js.map +1 -0
  21. package/build/daemon.js +136 -5
  22. package/build/daemon.js.map +1 -1
  23. package/build/events.js +2 -1
  24. package/build/events.js.map +1 -1
  25. package/build/help.js +447 -0
  26. package/build/help.js.map +1 -0
  27. package/build/history.js +637 -0
  28. package/build/history.js.map +1 -0
  29. package/build/inbox.js +117 -18
  30. package/build/inbox.js.map +1 -1
  31. package/build/manager.js +118 -43
  32. package/build/manager.js.map +1 -1
  33. package/build/notes.js +27 -43
  34. package/build/notes.js.map +1 -1
  35. package/build/notify.js +35 -14
  36. package/build/notify.js.map +1 -1
  37. package/build/overrides.js +83 -0
  38. package/build/overrides.js.map +1 -0
  39. package/build/paths.js +10 -1
  40. package/build/paths.js.map +1 -1
  41. package/build/profile.js +68 -2
  42. package/build/profile.js.map +1 -1
  43. package/build/queue.js +183 -0
  44. package/build/queue.js.map +1 -0
  45. package/build/registry.js +17 -10
  46. package/build/registry.js.map +1 -1
  47. package/build/rule-actions.js +444 -0
  48. package/build/rule-actions.js.map +1 -0
  49. package/build/rule-input.js +17 -0
  50. package/build/rule-input.js.map +1 -0
  51. package/build/rules-service.js +402 -0
  52. package/build/rules-service.js.map +1 -0
  53. package/build/rules.js +850 -0
  54. package/build/rules.js.map +1 -0
  55. package/build/scheduler.js +293 -0
  56. package/build/scheduler.js.map +1 -0
  57. package/build/serve.js +51 -0
  58. package/build/serve.js.map +1 -0
  59. package/build/server.js +230 -7
  60. package/build/server.js.map +1 -1
  61. package/build/spend.js +42 -0
  62. package/build/spend.js.map +1 -1
  63. package/build/store.js +266 -12
  64. package/build/store.js.map +1 -1
  65. package/build/trackers.js +15 -0
  66. package/build/trackers.js.map +1 -0
  67. package/build/upgrade.js +16 -14
  68. package/build/upgrade.js.map +1 -1
  69. package/build/wait.js +3 -9
  70. package/build/wait.js.map +1 -1
  71. package/build/watchers/ado-api.js +55 -0
  72. package/build/watchers/ado-api.js.map +1 -0
  73. package/build/watchers/ado.js +60 -52
  74. package/build/watchers/ado.js.map +1 -1
  75. package/build/watchers/boards-brief.js +63 -0
  76. package/build/watchers/boards-brief.js.map +1 -0
  77. package/build/watchers/boards-queue.js +120 -0
  78. package/build/watchers/boards-queue.js.map +1 -0
  79. package/build/watchers/boards-writes.js +83 -0
  80. package/build/watchers/boards-writes.js.map +1 -0
  81. package/build/watchers/boards.js +271 -0
  82. package/build/watchers/boards.js.map +1 -0
  83. package/build/watchers/conflicts.js +132 -0
  84. package/build/watchers/conflicts.js.map +1 -0
  85. package/build/watchers/github.js +5 -2
  86. package/build/watchers/github.js.map +1 -1
  87. package/build/watchers/index.js +21 -7
  88. package/build/watchers/index.js.map +1 -1
  89. package/build/watchers/linear-api.js +55 -0
  90. package/build/watchers/linear-api.js.map +1 -0
  91. package/build/watchers/linear-labels.js +43 -0
  92. package/build/watchers/linear-labels.js.map +1 -0
  93. package/build/watchers/linear-queue.js +96 -0
  94. package/build/watchers/linear-queue.js.map +1 -0
  95. package/build/watchers/linear.js +18 -16
  96. package/build/watchers/linear.js.map +1 -1
  97. package/build/watchers/source.js +3 -3
  98. package/build/watchers/source.js.map +1 -1
  99. package/build/why.js +78 -0
  100. package/build/why.js.map +1 -0
  101. package/mod/.claude-plugin/plugin.json +1 -1
  102. package/mod/README.md +2 -2
  103. package/mod/hooks/model.ts +135 -8
  104. package/mod/hooks/register.tsx +219 -19
  105. package/mod/types/index.d.ts +6 -0
  106. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "conductor",
3
3
  "version": "1.0.0",
4
- "description": "The conductor console's plumbing: a status line in every session, and in the console the inbox band, the fleet pane, zero-token commands, Remote Control pushes and escalation wake-ups.",
4
+ "description": "The conductor console's plumbing: the inbox band, the fleet pane, zero-token commands, Remote Control pushes and escalation wake-ups.",
5
5
  "author": { "name": "Emergent Software" },
6
6
  "types": "./types/index.d.ts"
7
7
  }
package/mod/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Conductor mod
2
2
 
3
- A Claude Code plugin (a hooks module) that does the conductor console's plumbing in code, so it costs no tokens: a status line in every session and, in the console (`CONDUCTOR_CONSOLE=1`), the inbox band, the fleet pane, `/conductor` `/inbox` `/approve` `/revise` `/attach` `/notify`, toasts, Remote Control pushes (and the one warning when Remote Control is off), and the one model wake-up for an escalation. What it shows and does is in the conductor README's *Mod* section (`apps/conductor/README.md`).
3
+ A Claude Code plugin (a hooks module) that does the conductor console's plumbing in code, so it costs no tokens. Only the console (`CONDUCTOR_CONSOLE=1`) gets anything from it: the inbox band, the fleet pane, `/conductor` `/inbox` `/approve` `/revise` `/attach` `/notify` `/pause` `/unpause` `/focus-project`, toasts, Remote Control pushes (and the one warning when Remote Control is off), the one model wake-up for an escalation, a nudge to load `/conductor-console` in a console started without it, and a warm prompt cache while the console idles (a ping every 55 minutes, or a `/clear` past 150k or a night of pings). What it shows and does is in the conductor README's *Mod* section (`apps/conductor/README.md`).
4
4
 
5
5
  Install it with the daemon's CLI, which ships this folder:
6
6
 
@@ -11,7 +11,7 @@ conduct install-mod
11
11
  | File | Holds |
12
12
  |---|---|
13
13
  | `hooks/register.tsx` | the hooks: session start, commands, the band, the pane, the event stream; only its top-level functions take `$` |
14
- | `hooks/model.ts` | the logic without the engine: parsing the daemon's answers, what the status line, band and pane show, what each event asks for |
14
+ | `hooks/model.ts` | the logic without the engine: parsing the daemon's answers, what the band and pane show, what each event asks for |
15
15
  | `types/index.d.ts` | the `$.state` contract (`conductor.snapshot`, `confirming`, `expanded`) |
16
16
  | `tests/conductor.test.tsx` | `claude plugin test`: the hooks against the engine, the daemon faked beneath them |
17
17
  | `tests/model.spec.ts` | vitest (`pnpm -C apps/conductor test`, in CI): `model.ts` |
@@ -1,5 +1,5 @@
1
1
  // The conductor mod's logic, with nothing of the engine in it: what the daemon's answers mean, what
2
- // the status line, band and pane show, and what each event asks the mod to do. register.tsx does
2
+ // the band and pane show, and what each event asks the mod to do. register.tsx does
3
3
  // the engine's part (only its top-level functions may take `$`); vitest tests this file in CI
4
4
  // (apps/conductor/mod/tests/model.spec.ts).
5
5
  import type { ConductorInboxItem, ConductorNotify, ConductorSnapshot, ConductorWorker } from '../types';
@@ -46,6 +46,70 @@ export function reportedState(outcome: PushOutcome): 'failing' | 'inactive' | 'o
46
46
  return outcome.state === 'failed' ? 'failing' : outcome.state;
47
47
  }
48
48
 
49
+ // The skill a console runs first (ATK-385). `conduct console` starts with it as the first prompt;
50
+ // a console started by hand (`claude -n conductor`) has to be told.
51
+ export const CONSOLE_SKILL = 'conductor-console';
52
+ // What the model reads beside each prompt of a console whose skill hasn't run.
53
+ export const NO_SKILL_CONTEXT = `This session is the conductor console (CONDUCTOR_CONSOLE=1), but /${CONSOLE_SKILL} hasn't run in it, so its rules don't hold. Load the ${CONSOLE_SKILL} skill with the Skill tool and follow it, then answer this prompt.`;
54
+ export const NO_SKILL_WARNING = `console without its skill: run /${CONSOLE_SKILL}`;
55
+
56
+ // ATK-386: the console idles for hours, and its prompt cache lives an hour past its last use, so a
57
+ // wake after that wrote its whole context again (89k in ATK-365's run). A fork reads that context
58
+ // back from the cache for a tenth of a write's price and starts its hour over, so the mod pings
59
+ // just inside the hour; past a size, or after a night of pings, a /clear costs nothing and the
60
+ // next wake starts from a fresh console instead.
61
+ export const WARM_AFTER_MS = 55 * 60_000;
62
+ export const FRESH_AT_TOKENS = 150_000;
63
+ export const MAX_WARMS = 12;
64
+ // The fork's one user message: its answer is thrown away, so the shorter the better.
65
+ export const WARM_PROMPT = 'Cache keep-alive from the conductor mod: answer with the one word ok.';
66
+
67
+ // What the console does at an idle point: ping to keep the cache warm, or start fresh.
68
+ export type IdleAction = { kind: 'clear'; reason: string } | { kind: 'warm' };
69
+
70
+ // `contextTokens` is the context the last response was answered over (absent before the first);
71
+ // `warms` the pings since the last real turn.
72
+ export function idleAction({ contextTokens, warms }: { contextTokens: number | undefined; warms: number }): IdleAction {
73
+ if (contextTokens !== undefined && contextTokens >= FRESH_AT_TOKENS) return { kind: 'clear', reason: `context ${tokens(contextTokens)} ≥ ${tokens(FRESH_AT_TOKENS)}` };
74
+ if (warms >= MAX_WARMS) return { kind: 'clear', reason: `${warms} pings since the last turn` };
75
+ return { kind: 'warm' };
76
+ }
77
+
78
+ // What a ping came to: the cache held (warm), it had lapsed anyway so pinging doesn't work here
79
+ // (cold: a 5-minute TTL, a model change), the call failed, or there was nothing to fork.
80
+ export type PingOutcome = { kind: 'cold' | 'failed' | 'warm'; usage: CacheUsage | null } | { kind: 'nothing' };
81
+ export type CacheUsage = { cacheRead: number; cacheWrite: number; input: number; output: number };
82
+
83
+ // `$.model.fork`'s result. The cache held when it served at least half the context the console's
84
+ // last response was answered over; without that figure, when it served more than it wrote.
85
+ export function pingOutcome(result: unknown, contextTokens: number | undefined): PingOutcome {
86
+ const call = isRecord(result) ? result : {};
87
+ if (call.isAnswered === false && call.reason === 'nothing-to-fork') return { kind: 'nothing' };
88
+ const usage = usageOf(call.usage);
89
+ if (call.isAnswered !== true || usage === null) return { kind: 'failed', usage };
90
+ const held = contextTokens === undefined ? usage.cacheRead > usage.cacheWrite : usage.cacheRead >= contextTokens / 2;
91
+ return { kind: held ? 'warm' : 'cold', usage };
92
+ }
93
+
94
+ // The band's line after the mod cleared an idle console, in place of the no-skill warning.
95
+ export function clearedText(reason: string): string {
96
+ return `fresh console (cleared while idle: ${reason}); /${CONSOLE_SKILL} loads with the next prompt`;
97
+ }
98
+
99
+ function usageOf(value: unknown): CacheUsage | null {
100
+ if (!isRecord(value)) return null;
101
+ const count = (key: string): number => {
102
+ const n = value[key];
103
+ return typeof n === 'number' ? n : 0;
104
+ };
105
+ return {
106
+ cacheRead: count('cache_read_input_tokens'),
107
+ cacheWrite: count('cache_creation_input_tokens'),
108
+ input: count('input_tokens'),
109
+ output: count('output_tokens'),
110
+ };
111
+ }
112
+
49
113
  // A band entry and the act its press does.
50
114
  export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
51
115
 
@@ -56,7 +120,7 @@ export type Found<T> = { error: string; found?: undefined } | { error?: undefine
56
120
 
57
121
  const BAND_LIMIT = 5;
58
122
 
59
- // `conductor 3▶ 2⏸ ✉2`, or undefined (the line cleared) when no daemon answers.
123
+ // `conductor 3▶ 2⏸ ✉2` for the pane's title, or undefined when no daemon answers.
60
124
  export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
61
125
  if (snapshot === null || snapshot.error !== null) return undefined;
62
126
  const running = snapshot.workers.filter((w) => w.status === 'running').length;
@@ -199,7 +263,10 @@ export function reaction(line: StreamLine): Reaction {
199
263
  return { kind: 'disconnected' };
200
264
  case 'console.escalated': {
201
265
  const ask = text('text');
202
- return ask === null ? { kind: 'ignore' } : { kind: 'escalate', text: `[conductor] escalation${line.subject ? ` on ${line.subject}` : ''}: ${ask}` };
266
+ // The id is what the console reports back on (`conduct escalations done <id>`).
267
+ return ask === null
268
+ ? { kind: 'ignore' }
269
+ : { kind: 'escalate', text: `[conductor] escalation${line.id === null ? '' : ` #${line.id}`}${line.subject ? ` on ${line.subject}` : ''}: ${ask}` };
203
270
  }
204
271
  case 'inbox.raised': {
205
272
  const ask = text('text');
@@ -214,6 +281,7 @@ export function reaction(line: StreamLine): Reaction {
214
281
  const message = text('message');
215
282
  return line.data.channel === 'remote-control' && message !== null ? { kind: 'push', message } : { kind: 'ignore' };
216
283
  }
284
+ case 'console.cache':
217
285
  case 'watcher.stats':
218
286
  return { kind: 'ignore' };
219
287
  case 'worker.message-requested': {
@@ -355,17 +423,76 @@ export function notifyArgs(args: string, now: number): { error: string } | { nam
355
423
  if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
356
424
  const when = typed[2];
357
425
  if (when === undefined) return { name: typed[1], until: null };
426
+ const until = untilOf(when, now);
427
+ return typeof until === 'string' ? { name: typed[1], until } : until;
428
+ }
429
+
430
+ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
431
+
432
+ // `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
433
+ // (local midnight), or a date and time, as `conduct` reads them.
434
+ export function untilOf(when: string, now: number): string | { error: string } {
358
435
  const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
359
- let at: Date;
436
+ const lower = when.toLowerCase();
437
+ const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
438
+ const at = new Date(now);
360
439
  if (clock) {
361
- at = new Date(now);
362
440
  at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
363
441
  if (at.getTime() <= now) at.setDate(at.getDate() + 1);
442
+ } else if (lower === 'tomorrow' || day !== -1) {
443
+ at.setHours(0, 0, 0, 0);
444
+ at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
364
445
  } else {
365
- at = new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when);
446
+ at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
366
447
  }
367
- if (Number.isNaN(at.getTime())) return { error: `--until "${when}" is not a time: a local time such as 17:00, or a date and time such as 2026-10-12T09:00` };
368
- return { name: typed[1], until: at.toISOString() };
448
+ if (Number.isNaN(at.getTime())) {
449
+ return { 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` };
450
+ }
451
+ return at.toISOString();
452
+ }
453
+
454
+ // `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
455
+ // <when>]` as POST /overrides' body. A focus with no end lasts until midnight, and puts the
456
+ // project's items first (weight 100).
457
+ export function overrideArgs(command: 'focus' | 'pause' | 'unpause', args: string, now: number): { body: Record<string, unknown> } | { error: string } {
458
+ const typed = /^(?:([a-z][a-z0-9-]*))?(?:\s*--until\s+(\S+))?$/.exec(args.trim());
459
+ const usage = { error: command === 'focus' ? 'usage: /focus-project <project> [--until <when>]' : `usage: /${command} [<project>]${command === 'pause' ? ' [--until <when>]' : ''}` };
460
+ if (!typed) return usage;
461
+ const [, project, when] = typed;
462
+ if (command === 'unpause') return when === undefined ? { body: { action: 'unpause', by: 'conductor mod /unpause', ...(project ? { project } : {}) } } : usage;
463
+ const until = when === undefined ? (command === 'focus' ? untilOf('tomorrow', now) : null) : untilOf(when, now);
464
+ if (until !== null && typeof until !== 'string') return until;
465
+ if (command === 'focus') {
466
+ if (!project) return usage;
467
+ return { body: { action: 'focus', by: 'conductor mod /focus-project', project, until, weight: 100 } };
468
+ }
469
+ return { body: { action: 'pause', by: 'conductor mod /pause', ...(project ? { project } : {}), ...(until === null ? {} : { until }) } };
470
+ }
471
+
472
+ // POST /overrides' answer as the line to show.
473
+ export function overrideText(command: 'focus' | 'pause' | 'unpause', project: null | string, answer: ApiAnswer, now: number): string {
474
+ if (answer.status === 404) return 'the running conductor has no overrides: restart it on this build (conduct upgrade)';
475
+ if (answer.status !== 201) return `not ${command === 'focus' ? 'focused' : `${command}d`}: ${errorOf(answer)}`;
476
+ const set = isRecord(answer.body) && Array.isArray(answer.body.set) ? answer.body.set.filter(isRecord) : [];
477
+ const until = typeof set[0]?.until === 'string' ? ` for ${remaining(set[0].until, now)}` : '';
478
+ const who = project ?? 'every project';
479
+ if (command === 'pause') return `paused launches in ${who}${until || ' until /unpause'}`;
480
+ if (command === 'unpause') return `launches in ${who} are no longer paused`;
481
+ return `${who} goes first${until}`;
482
+ }
483
+
484
+ // GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
485
+ export function whyText(item: string, answer: ApiAnswer): string {
486
+ if (answer.status === 404) return 'the running conductor has no conduct why: restart it on this build (conduct upgrade)';
487
+ if (answer.status !== 200) return `why ${item}: ${errorOf(answer)}`;
488
+ const first = isRecord(answer.body) && Array.isArray(answer.body.items) ? answer.body.items.find(isRecord) : undefined;
489
+ if (!first) return `no action on ${item} was caused by a rule`;
490
+ const rule = isRecord(first.rule) ? first.rule : null;
491
+ const latest = Array.isArray(first.chain) && isRecord(first.chain[0]) ? first.chain[0] : null;
492
+ const what = latest && typeof latest.kind === 'string' ? `${latest.kind}: ` : '';
493
+ if (!rule || typeof rule.ref !== 'string') return `${item}: ${what}no rule`;
494
+ const text = typeof rule.when === 'string' && Array.isArray(rule.do) ? ` (when ${rule.when} → ${strings(rule.do).join('; ')})` : '';
495
+ return `${item}: ${what}${rule.ref}${text}`;
369
496
  }
370
497
 
371
498
  // `/notify` with no profile: the profile, quiet hours, Remote Control.
@@ -1,5 +1,5 @@
1
1
  import { atom, read, update } from 'claude-code';
2
- import type { EngineInterface, Register } from 'claude-code';
2
+ import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code';
3
3
 
4
4
  import type { ConductorSnapshot } from '../types';
5
5
 
@@ -8,16 +8,25 @@ import {
8
8
  type ApiAnswer,
9
9
  answerText,
10
10
  bandEntries,
11
+ clearedText,
12
+ CONSOLE_SKILL,
11
13
  endpointOf,
12
14
  errorOf,
13
15
  findPlan,
14
16
  findWorker,
15
17
  glyph,
16
18
  homeOf,
19
+ idleAction,
17
20
  inboxText,
21
+ NO_SKILL_CONTEXT,
22
+ NO_SKILL_WARNING,
18
23
  notifyArgs,
19
24
  notifyText,
25
+ overrideArgs,
26
+ overrideText,
20
27
  parseLine,
28
+ type PingOutcome,
29
+ pingOutcome,
21
30
  profileText,
22
31
  projectGroups,
23
32
  type PushOutcome,
@@ -29,13 +38,16 @@ import {
29
38
  spendToday,
30
39
  statusText,
31
40
  tokens,
41
+ WARM_AFTER_MS,
42
+ WARM_PROMPT,
43
+ whyText,
32
44
  workerDoing,
33
45
  } from './model.ts';
34
46
 
35
47
  const PANE = 'conductor';
36
48
  const TITLE = 'Conductor';
37
- // Every session reads /fleet and /inbox this often for the status line; the console also follows
38
- // the event stream, so this is its safety net and what re-ages its rows.
49
+ // The console reads /fleet and /inbox this often. It also follows the event stream, so this is its
50
+ // safety net and what re-ages its rows.
39
51
  const POLL_MS = 30_000;
40
52
  const DEBOUNCE_MS = 300;
41
53
  const RESTART_MS = 5_000;
@@ -46,8 +58,10 @@ const NOT_RUNNING = 'the conductor is not running (conduct serve)';
46
58
  const snapshot = atom({ plugin: 'conductor', key: 'snapshot' } as const, null);
47
59
  const confirming = atom({ plugin: 'conductor', key: 'confirming' } as const, null);
48
60
  const expanded = atom({ plugin: 'conductor', key: 'expanded' } as const, null);
61
+ const hasSkill = atom({ plugin: 'conductor', key: 'hasSkill' } as const, false);
62
+ const cleared = atom({ plugin: 'conductor', key: 'cleared' } as const, null);
49
63
 
50
- // The console is the session started with CONDUCTOR_CONSOLE=1 (the console skill sets it): only it
64
+ // The console is the session started with CONDUCTOR_CONSOLE=1 (`conduct console` sets it): only it
51
65
  // draws the band and the pane, answers the commands, follows the stream and acts on events. Both
52
66
  // flags start over with the module, and session.start sets the first again after a reload.
53
67
  let isConsole = false;
@@ -56,6 +70,15 @@ let isRefreshing = false;
56
70
  let lastEventId: null | number = null;
57
71
  // Set once the console has said that pushes no longer reach the phone; a push that does clears it.
58
72
  let warnedInactive = false;
73
+ // ATK-386: the console's idle point, armed by each of its own turns; the pings since the last one;
74
+ // the console's turns so far, so a turn that starts while the idle point runs keeps it from
75
+ // counting a ping or clearing; the turn ids of pings, should the engine raise turns for a fork,
76
+ // which are no turns of the console's; and whether the /clear running is the mod's own.
77
+ let idleTimer: null | Timer = null;
78
+ let warms = 0;
79
+ let turnsStarted = 0;
80
+ const pingTurns = new Set<string>();
81
+ let isClearing = false;
59
82
 
60
83
  type Endpoint = { port: number; token: string };
61
84
 
@@ -94,7 +117,7 @@ async function callApi($: EngineInterface, method: 'GET' | 'POST', path: string,
94
117
  return { body: parsed, status: answer.status };
95
118
  }
96
119
 
97
- // Reads /fleet, /inbox and /notify into the state the drawings read, and the status line.
120
+ // Reads /fleet, /inbox and /notify into the state the drawings read.
98
121
  async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
99
122
  const readAt = await $.clock.now();
100
123
  let next: ConductorSnapshot;
@@ -110,7 +133,6 @@ async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
110
133
  next = { error: error instanceof Error ? error.message : String(error), inbox: [], notify: null, readAt, workers: [] };
111
134
  }
112
135
  await update($, snapshot, () => next);
113
- $.ui.status(statusText(next));
114
136
  return next;
115
137
  }
116
138
 
@@ -135,7 +157,6 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
135
157
  case 'disconnected': {
136
158
  const readAt = await $.clock.now();
137
159
  await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
138
- $.ui.status(undefined);
139
160
  return;
140
161
  }
141
162
  case 'escalate':
@@ -183,6 +204,72 @@ async function push($: EngineInterface, message: string): Promise<void> {
183
204
  }
184
205
  }
185
206
 
207
+ // Starts the console's idle point over: WARM_AFTER_MS from now, unless a turn comes first.
208
+ function armIdle($: EngineInterface): void {
209
+ idleTimer?.cancel();
210
+ idleTimer = $.clock.after(WARM_AFTER_MS, () => void onIdle($));
211
+ }
212
+
213
+ function disarmIdle(): void {
214
+ idleTimer?.cancel();
215
+ idleTimer = null;
216
+ }
217
+
218
+ // The console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
219
+ // Either is told to the daemon, which logs it as a `console.cache` event: a fork is no turn of the
220
+ // transcript, so the event is the only place its cost shows.
221
+ // A turn that starts meanwhile has the console in use again: its end arms the next idle point, so
222
+ // this one stands down (no /clear under it, no ping counted against it).
223
+ async function onIdle($: EngineInterface): Promise<void> {
224
+ idleTimer = null;
225
+ const turnsBefore = turnsStarted;
226
+ const isIdle = (): boolean => turnsStarted === turnsBefore;
227
+ const contextTokens = (await $.session.usage()).context.tokens;
228
+ if (!isIdle()) return;
229
+ const act = idleAction({ contextTokens, warms });
230
+ if (act.kind === 'clear') {
231
+ await update($, cleared, () => act.reason);
232
+ if (!isIdle()) {
233
+ await update($, cleared, () => null);
234
+ return;
235
+ }
236
+ isClearing = true;
237
+ try {
238
+ await $.command.run({ command: 'clear' });
239
+ } catch (error) {
240
+ await update($, cleared, () => null);
241
+ $.ui.log(`conductor: the idle /clear failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
242
+ return;
243
+ } finally {
244
+ isClearing = false;
245
+ }
246
+ await reportCache($, { action: 'clear', contextTokens: contextTokens ?? null, reason: act.reason });
247
+ return;
248
+ }
249
+ let outcome: PingOutcome;
250
+ try {
251
+ outcome = pingOutcome(await $.model.fork({ prompt: WARM_PROMPT }), contextTokens);
252
+ } catch (error) {
253
+ $.ui.log(`conductor: the cache ping failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
254
+ outcome = { kind: 'failed', usage: null };
255
+ }
256
+ if (outcome.kind === 'nothing') return;
257
+ if (isIdle()) {
258
+ warms++;
259
+ // A ping that found the cache gone (or failed) stops here: the next real turn arms it again.
260
+ if (outcome.kind === 'warm') armIdle($);
261
+ }
262
+ await reportCache($, { action: outcome.kind, contextTokens: contextTokens ?? null, usage: outcome.usage, warms });
263
+ }
264
+
265
+ async function reportCache($: EngineInterface, body: Record<string, unknown>): Promise<void> {
266
+ try {
267
+ await callApi($, 'POST', '/console/cache', body);
268
+ } catch (error) {
269
+ $.ui.log(`conductor: console cache report failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
270
+ }
271
+ }
272
+
186
273
  // `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
187
274
  async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
188
275
  try {
@@ -208,7 +295,8 @@ async function follow($: EngineInterface): Promise<void> {
208
295
  const startedAt = await $.clock.now();
209
296
  let buffer = '';
210
297
  try {
211
- const argv = ['node', cli, 'events', '--follow', ...(lastEventId === null ? [] : ['--since', String(lastEventId)])];
298
+ // --console: while this stream is open, the daemon knows escalations have a console to wake.
299
+ const argv = ['node', cli, 'events', '--follow', '--console', ...(lastEventId === null ? [] : ['--since', String(lastEventId)])];
212
300
  for await (const chunk of $.process.spawn({ argv })) {
213
301
  if (chunk.stream !== 'stdout') continue;
214
302
  buffer += chunk.text;
@@ -238,6 +326,29 @@ async function answer($: EngineInterface, id: number, body: { action: 'approve'
238
326
  }
239
327
  }
240
328
 
329
+ // `/pause`, `/unpause`, `/focus-project` and the pane's buttons: an override that ends on its own, as
330
+ // `conduct pause|unpause|focus` sets it; the line to show.
331
+ async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpause', args: string): Promise<string> {
332
+ const now = await $.clock.now();
333
+ const typed = overrideArgs(command, args, now);
334
+ if ('error' in typed) return typed.error;
335
+ try {
336
+ const project = typeof typed.body.project === 'string' ? typed.body.project : null;
337
+ return overrideText(command, project, await callApi($, 'POST', '/overrides', typed.body), now);
338
+ } catch (error) {
339
+ return error instanceof Error ? error.message : String(error);
340
+ }
341
+ }
342
+
343
+ // The rule behind an item's newest action (`conduct why <item>`), in one line.
344
+ async function why($: EngineInterface, item: string): Promise<string> {
345
+ try {
346
+ return whyText(item, await callApi($, 'GET', `/why?item=${encodeURIComponent(item)}`));
347
+ } catch (error) {
348
+ return error instanceof Error ? error.message : String(error);
349
+ }
350
+ }
351
+
241
352
  async function attach($: EngineInterface, id: number, name: string): Promise<string> {
242
353
  try {
243
354
  const result = await callApi($, 'POST', `/workers/${id}/attach`);
@@ -270,6 +381,9 @@ async function registerCommands($: EngineInterface): Promise<void> {
270
381
  await $.command.register({ argumentHint: '<item | #id> <what to change>', description: 'Ask for a revised plan (posts revise: … as you)', name: 'revise' });
271
382
  await $.command.register({ argumentHint: '<item | worker>', description: 'Wake a parked worker and open it in a tab', name: 'attach' });
272
383
  await $.command.register({ argumentHint: '[<profile> [--until <when>]]', description: 'Show or switch the notification profile (default, away, focus …)', name: 'notify' });
384
+ await $.command.register({ argumentHint: '[<project>] [--until <when>]', description: 'No new launches in a project (every project without one)', name: 'pause' });
385
+ await $.command.register({ argumentHint: '[<project>]', description: 'Launch again after /pause', name: 'unpause' });
386
+ await $.command.register({ argumentHint: '<project> [--until <when>]', description: "Put a project's items first (until midnight without --until)", name: 'focus-project' });
273
387
  }
274
388
 
275
389
  export const register: Register = (on) => {
@@ -277,9 +391,9 @@ export const register: Register = (on) => {
277
391
  const started = await next(e);
278
392
  if (!e.isInteractive) return started;
279
393
  isConsole = (await $.env.get('CONDUCTOR_CONSOLE')) === '1';
394
+ if (!isConsole) return started;
280
395
  $.clock.every(POLL_MS, () => void refresh($));
281
396
  await refresh($);
282
- if (!isConsole) return started;
283
397
  await registerCommands($);
284
398
  void follow($);
285
399
  // Docked as a sidebar on a wide fullscreen terminal; below that it waits until opened.
@@ -287,6 +401,60 @@ export const register: Register = (on) => {
287
401
  return started;
288
402
  });
289
403
 
404
+ // A console the skill never ran in (one started by hand as `claude -n conductor`, ATK-385) is
405
+ // told to load it before it answers, each prompt until it has. `/conductor-console` or the Skill
406
+ // tool expands it before its prompt is submitted; a /clear or a resume starts a conversation
407
+ // without it.
408
+ on('skill.prompt', { skill: CONSOLE_SKILL }, async ($, e, next) => {
409
+ const prompt = await next(e);
410
+ if (isConsole) {
411
+ await update($, hasSkill, () => true);
412
+ await update($, cleared, () => null);
413
+ }
414
+ return prompt;
415
+ });
416
+
417
+ // A conversation ending (exit, /clear, resume) ends its idle point too. Only the mod's own /clear
418
+ // keeps the band's "cleared while idle" line (ATK-386).
419
+ on('session.end', async ($, e, next) => {
420
+ if (isConsole) {
421
+ disarmIdle();
422
+ warms = 0;
423
+ await update($, hasSkill, () => false);
424
+ if (!isClearing) await update($, cleared, () => null);
425
+ }
426
+ return next(e);
427
+ });
428
+
429
+ // ATK-386: each of the console's own turns starts its idle point over, and a turn in progress
430
+ // holds it off. A subagent's turn raises no turn.start and its turn.complete carries its agentId;
431
+ // a ping's, should the engine raise one, carries the ping's prompt.
432
+ on('turn.start', async ($, e, next) => {
433
+ if (isConsole) {
434
+ if (e.text === WARM_PROMPT) pingTurns.add(e.turnId);
435
+ else {
436
+ turnsStarted++;
437
+ disarmIdle();
438
+ }
439
+ }
440
+ return next(e);
441
+ });
442
+
443
+ on('turn.complete', async ($, e, next) => {
444
+ const done = await next(e);
445
+ if (isConsole && e.agentId === undefined && !pingTurns.delete(e.turnId)) {
446
+ warms = 0;
447
+ armIdle($);
448
+ }
449
+ return done;
450
+ });
451
+
452
+ on('prompt.submit', async ($, e, next) => {
453
+ if (!isConsole || (await read($, hasSkill))) return next(e);
454
+ $.ui.log(`conductor: /${CONSOLE_SKILL} hasn't run; asking the model to load it`, { to: 'debug' });
455
+ return next({ ...e, context: [...(e.context ?? []), NO_SKILL_CONTEXT] });
456
+ });
457
+
290
458
  on('command.run', { command: 'conductor' }, async ($) => {
291
459
  await openPane($);
292
460
  return { text: 'Conductor pane opened.' };
@@ -336,18 +504,45 @@ export const register: Register = (on) => {
336
504
  return { text: snap.error ?? notifyText(snap.notify, await $.clock.now()) };
337
505
  });
338
506
 
507
+ on('command.run', { command: 'pause' }, async ($, e) => ({ text: await override($, 'pause', e.args) }));
508
+ on('command.run', { command: 'unpause' }, async ($, e) => ({ text: await override($, 'unpause', e.args) }));
509
+ on('command.run', { command: 'focus-project' }, async ($, e) => ({ text: await override($, 'focus', e.args) }));
510
+
339
511
  // The band above the prompt, in the console, while the inbox has items. A bare digit typed into
340
- // an empty prompt presses a band Button, so a plan's approve takes a second press (`y`).
512
+ // an empty prompt presses a band Button, so a plan's approve takes a second press (`y`). Until
513
+ // /conductor-console runs, a warning sits above it, or above the engine's own (ATK-385); after the
514
+ // mod cleared an idle console, a plain line that says so instead (ATK-386).
341
515
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
342
516
  if (!isConsole || e.props.hasSurvey) return next(e);
343
- const snap = await read($, snapshot);
344
- if (snap === null || snap.error !== null || snap.inbox.length === 0) return next(e);
345
517
  const { Box, Button, Text } = $.ui.resolve(e);
518
+ const isWarned = !(await read($, hasSkill));
519
+ const clearedBy = await read($, cleared);
520
+ const withWarning = (row: RenderElement): RenderElement =>
521
+ isWarned ? (
522
+ <Box flexDirection="column">
523
+ {clearedBy === null ? (
524
+ <Box key="no-skill">
525
+ <Text bold color="warning">
526
+ {NO_SKILL_WARNING}
527
+ </Text>
528
+ </Box>
529
+ ) : (
530
+ <Box key="cleared">
531
+ <Text dimColor>{clearedText(clearedBy)}</Text>
532
+ </Box>
533
+ )}
534
+ {row}
535
+ </Box>
536
+ ) : (
537
+ row
538
+ );
539
+ const snap = await read($, snapshot);
540
+ if (snap === null || snap.error !== null || snap.inbox.length === 0) return isWarned ? withWarning(await next(e)) : next(e);
346
541
  const entries = bandEntries(snap.inbox);
347
542
  const confirmId = await read($, confirming);
348
543
  const pending = entries.find((x) => x.act === 'approve' && x.id === confirmId);
349
544
  if (pending) {
350
- return (
545
+ return withWarning(
351
546
  <Box columnGap={2} flexWrap="wrap">
352
547
  <Text bold>conductor</Text>
353
548
  <Text>
@@ -364,10 +559,10 @@ export const register: Register = (on) => {
364
559
  variant="primary"
365
560
  />
366
561
  <Button hotkey="n" key="cancel" label="n cancel" onPress={() => update($, confirming, () => null)} />
367
- </Box>
562
+ </Box>,
368
563
  );
369
564
  }
370
- return (
565
+ return withWarning(
371
566
  <Box columnGap={2} flexWrap="wrap">
372
567
  <Text bold>conductor · inbox {snap.inbox.length}</Text>
373
568
  {entries.map((x) => (
@@ -384,7 +579,7 @@ export const register: Register = (on) => {
384
579
  ))}
385
580
  {snap.inbox.length > entries.length && <Text dimColor>+{snap.inbox.length - entries.length} more</Text>}
386
581
  <Button hotkey="o" key="open-pane" label="o panel" onPress={() => openPane($)} />
387
- </Box>
582
+ </Box>,
388
583
  );
389
584
  });
390
585
 
@@ -472,9 +667,13 @@ export const register: Register = (on) => {
472
667
  ))}
473
668
  {projectGroups(snap.workers).map((group) => (
474
669
  <Box flexDirection="column" key={`project-${group.project}`}>
475
- <Text dimColor wrap="truncate">
476
- {rule(`${group.project} · ${group.running}/${group.rows.length} running`)}
477
- </Text>
670
+ <Box columnGap={1}>
671
+ <Text dimColor wrap="truncate">
672
+ {rule(`${group.project} · ${group.running}/${group.rows.length} running`).slice(0, Math.max(10, e.props.bodyColumns - 18))}
673
+ </Text>
674
+ <Button key={`pause-${group.project}`} label="pause" onPress={async () => $.ui.toast(await override($, 'pause', group.project))} />
675
+ <Button key={`focus-${group.project}`} label="focus" onPress={async () => $.ui.toast(await override($, 'focus', group.project))} />
676
+ </Box>
478
677
  {group.rows.map((w) => (
479
678
  <Box flexDirection="column" key={`worker-${w.id}`}>
480
679
  <Button key={`row-${w.id}`} onPress={() => update($, expanded, (name) => (name === w.name ? null : w.name))} plain>
@@ -486,6 +685,7 @@ export const register: Register = (on) => {
486
685
  {open === w.name && (
487
686
  <Box columnGap={2} flexWrap="wrap" marginLeft={2}>
488
687
  <Button key={`attach-${w.id}`} label="attach" onPress={async () => $.ui.toast(await attach($, w.id, w.name))} />
688
+ <Button key={`why-${w.id}`} label="why" onPress={async () => $.ui.toast(await why($, w.item))} />
489
689
  <Text dimColor>
490
690
  {w.status}
491
691
  {w.waits.length > 0 ? ` · waits on ${w.waits.join(', ')}` : ''}
@@ -51,6 +51,12 @@ declare module 'claude-code' {
51
51
  confirming: number | null;
52
52
  // The worker whose row the pane shows expanded, by name.
53
53
  expanded: string | null;
54
+ // Whether /conductor-console ran in this conversation (ATK-385). Kept in $.state, so a reload
55
+ // of the mod doesn't forget it; a /clear or a resume (session.end) sets it back to false.
56
+ hasSkill: boolean;
57
+ // Why the mod last cleared the idle console (ATK-386), until /conductor-console runs again;
58
+ // null when it didn't. The band says so in place of the no-skill warning.
59
+ cleared: string | null;
54
60
  snapshot: ConductorSnapshot | null;
55
61
  };
56
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "1.0.0-dev.8",
3
+ "version": "1.0.0",
4
4
  "description": "Conductor: the local daemon that runs conducted projects' work loops (conduct serve) and its conduct CLI.",
5
5
  "type": "module",
6
6
  "bin": {