@detergent-software/conductor 1.0.0-dev.9 → 1.0.1-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/README.md +277 -24
- package/build/brief-cli.js +68 -0
- package/build/brief-cli.js.map +1 -0
- package/build/brief.js +211 -0
- package/build/brief.js.map +1 -0
- package/build/broker-cli.js +126 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +442 -132
- package/build/cli.js.map +1 -1
- package/build/conditions.js +15 -8
- package/build/conditions.js.map +1 -1
- package/build/config.js +26 -3
- package/build/config.js.map +1 -1
- package/build/console.js +225 -0
- package/build/console.js.map +1 -0
- package/build/daemon.js +136 -5
- package/build/daemon.js.map +1 -1
- package/build/events.js +2 -1
- package/build/events.js.map +1 -1
- package/build/help.js +448 -0
- package/build/help.js.map +1 -0
- package/build/history.js +637 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +117 -18
- package/build/inbox.js.map +1 -1
- package/build/manager.js +118 -43
- package/build/manager.js.map +1 -1
- package/build/notes.js +27 -43
- package/build/notes.js.map +1 -1
- package/build/notify.js +32 -3
- package/build/notify.js.map +1 -1
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +293 -0
- package/build/scheduler.js.map +1 -0
- package/build/serve.js +51 -0
- package/build/serve.js.map +1 -0
- package/build/server.js +244 -7
- package/build/server.js.map +1 -1
- package/build/spend.js +42 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +266 -12
- package/build/store.js.map +1 -1
- package/build/trackers.js +15 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +16 -14
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +3 -9
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +60 -52
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-brief.js +63 -0
- package/build/watchers/boards-brief.js.map +1 -0
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +271 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/conflicts.js +132 -0
- package/build/watchers/conflicts.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +21 -7
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +18 -16
- package/build/watchers/linear.js.map +1 -1
- package/build/watchers/source.js +3 -3
- package/build/watchers/source.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/README.md +2 -2
- package/mod/hooks/model.ts +135 -8
- package/mod/hooks/register.tsx +219 -19
- package/mod/types/index.d.ts +6 -0
- 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:
|
|
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
|
|
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
|
|
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` |
|
package/mod/hooks/model.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()))
|
|
368
|
-
|
|
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.
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
476
|
-
|
|
477
|
-
|
|
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(', ')}` : ''}
|
package/mod/types/index.d.ts
CHANGED
|
@@ -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