@detergent-software/conductor 1.0.0-dev.5 → 1.0.0-dev.6

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.
@@ -1 +1 @@
1
- {"version":3,"file":"upgrade.js","sourceRoot":"","sources":["../src/upgrade.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"upgrade.js","sourceRoot":"","sources":["../src/upgrade.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "conductor",
3
+ "description": "The conductor mod, shipped inside @detergent-software/conductor and installed by `conduct install-mod`.",
4
+ "owner": { "name": "Emergent Software" },
5
+ "plugins": [{ "name": "conductor", "source": "./" }]
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "conductor",
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.",
5
+ "author": { "name": "Emergent Software" },
6
+ "types": "./types/index.d.ts"
7
+ }
package/mod/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Conductor mod
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`, toasts, Remote Control pushes, 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`).
4
+
5
+ Install it with the daemon's CLI, which ships this folder:
6
+
7
+ ```text
8
+ conduct install-mod
9
+ ```
10
+
11
+ | File | Holds |
12
+ |---|---|
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 |
15
+ | `types/index.d.ts` | the `$.state` contract (`conductor.snapshot`, `confirming`, `expanded`) |
16
+ | `tests/conductor.test.tsx` | `claude plugin test`: the hooks against the engine, the daemon faked beneath them |
17
+ | `tests/model.spec.ts` | vitest (`pnpm -C apps/conductor test`, in CI): `model.ts` |
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,282 @@
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
3
+ // the engine's part (only its top-level functions may take `$`); vitest tests this file in CI
4
+ // (apps/conductor/mod/tests/model.spec.ts).
5
+ import type { ConductorInboxItem, ConductorSnapshot, ConductorWorker } from '../types';
6
+
7
+ // A daemon API answer: its status and parsed JSON body.
8
+ export type ApiAnswer = { body: unknown; status: number };
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 };
12
+
13
+ // What an event asks of the mod. Only `escalate` wakes the console's model.
14
+ export type Reaction =
15
+ | { kind: 'disconnected' }
16
+ | { kind: 'escalate'; text: string }
17
+ | { kind: 'ignore' }
18
+ | { kind: 'push'; message: string }
19
+ | { kind: 'refresh'; toast: string | null }
20
+ | { kind: 'send'; text: string; to: string };
21
+
22
+ // A band entry and the act its press does.
23
+ export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
24
+
25
+ export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
26
+
27
+ // A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
28
+ export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
29
+
30
+ const BAND_LIMIT = 5;
31
+
32
+ // `conductor 3▶ 2⏸ ✉2`, or undefined (the line cleared) when no daemon answers.
33
+ export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
34
+ if (snapshot === null || snapshot.error !== null) return undefined;
35
+ const running = snapshot.workers.filter((w) => w.status === 'running').length;
36
+ const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
37
+ return `conductor ${running}▶ ${parked}⏸ ✉${snapshot.inbox.length}`;
38
+ }
39
+
40
+ // The band's buttons: a plan approves (behind a confirm), an npm OTP copies its command, the rest
41
+ // open the pane, where each item's commands and links are.
42
+ export function bandEntries(inbox: readonly ConductorInboxItem[]): BandEntry[] {
43
+ return inbox.slice(0, BAND_LIMIT).map((item, i) => {
44
+ const key = item.item ?? `#${item.id}`;
45
+ const hotkey = String(i + 1);
46
+ if (item.kind === 'plan' && item.item !== null) return { act: 'approve', hotkey, id: item.id, label: `approve ${key}`, text: item.text };
47
+ const command = item.commands[0];
48
+ if (item.kind === 'npm-otp' && command !== undefined) return { act: 'copy', hotkey, id: item.id, label: `copy OTP line ${key}`, text: command };
49
+ return { act: 'pane', hotkey, id: item.id, label: `${item.kind} ${key}`, text: item.text };
50
+ });
51
+ }
52
+
53
+ // The fleet by project, running workers first within each, projects by name.
54
+ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup[] {
55
+ const byProject = new Map<string, ConductorWorker[]>();
56
+ for (const w of workers) byProject.set(w.project, [...(byProject.get(w.project) ?? []), w]);
57
+ return [...byProject.entries()]
58
+ .sort(([a], [b]) => a.localeCompare(b))
59
+ .map(([project, rows]) => ({
60
+ project,
61
+ rows: [...rows].sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || a.name.localeCompare(b.name)),
62
+ running: rows.filter((w) => w.status === 'running').length,
63
+ }));
64
+ }
65
+
66
+ // A worker row's glyph: ▶ running, ⏸ parked, ✖ failed or stopped, · otherwise (launching, exited).
67
+ export function glyph(status: string): string {
68
+ if (status === 'running') return '▶';
69
+ if (status === 'parked') return '⏸';
70
+ if (status === 'failed' || status === 'stopped-unexpectedly') return '✖';
71
+ return '·';
72
+ }
73
+
74
+ // What a worker waits on or does: its open wait, else the session's activity.
75
+ export function workerDoing(w: ConductorWorker): string {
76
+ return w.waits[0] ?? w.activity;
77
+ }
78
+
79
+ // The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
80
+ export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
81
+ const byId = /^#(\d+)$/.exec(ref.trim());
82
+ if (byId) {
83
+ const item = inbox.find((i) => i.id === Number(byId[1]));
84
+ return item ? { found: item } : { error: `no open inbox item ${ref}` };
85
+ }
86
+ if (/^\d+$/.test(ref.trim())) return { error: `"${ref}" is ambiguous: name the item, such as ATK-${ref.trim()}, or the inbox id as #${ref.trim()}` };
87
+ const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
88
+ if (plans.length === 1 && plans[0]) return { found: plans[0] };
89
+ if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
90
+ return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
91
+ }
92
+
93
+ // A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
94
+ export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
95
+ const byName = workers.filter((w) => w.name === ref);
96
+ const matches = byName.length > 0 ? byName : workers.filter((w) => w.item.toUpperCase() === ref.toUpperCase());
97
+ if (matches.length === 1 && matches[0]) return { found: matches[0] };
98
+ if (matches.length === 0) return { error: `no worker "${ref}" in flight` };
99
+ return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
100
+ }
101
+
102
+ // `/inbox`'s answer: one line per item, then its commands and links.
103
+ export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
104
+ if (inbox.length === 0) return 'The inbox is empty.';
105
+ return inbox
106
+ .flatMap((i) => [
107
+ `#${i.id} ${i.kind} ${i.item ?? ''}${i.project ? ` (${i.project})` : ''} ${age(i.raisedAt, now)}`,
108
+ ` ${i.text}`,
109
+ ...i.commands.map((c) => ` $ ${c}`),
110
+ ...i.links.map((l) => ` ${l}`),
111
+ ])
112
+ .join('\n');
113
+ }
114
+
115
+ // "now", "12m", "3h", "2d".
116
+ export function age(iso: string, now: number): string {
117
+ const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
118
+ if (!(minutes >= 1)) return 'now';
119
+ if (minutes < 60) return `${minutes}m`;
120
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
121
+ return `${Math.floor(minutes / (24 * 60))}d`;
122
+ }
123
+
124
+ // 950, 42k, 6.1M.
125
+ export function tokens(n: number): string {
126
+ if (n < 1000) return String(n);
127
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
128
+ return `${(n / 1_000_000).toFixed(1)}M`;
129
+ }
130
+
131
+ export function spendToday(workers: readonly ConductorWorker[]): number {
132
+ return workers.reduce((sum, w) => sum + w.spendToday, 0);
133
+ }
134
+
135
+ // One line of the follow stream, or null for anything that isn't an event object.
136
+ export function parseLine(line: string): null | StreamLine {
137
+ let value: unknown;
138
+ try {
139
+ value = JSON.parse(line);
140
+ } catch {
141
+ return null;
142
+ }
143
+ if (!isRecord(value) || typeof value.kind !== 'string') return null;
144
+ return {
145
+ data: isRecord(value.data) ? value.data : {},
146
+ id: typeof value.id === 'number' ? value.id : null,
147
+ kind: value.kind,
148
+ subject: typeof value.subject === 'string' ? value.subject : null,
149
+ };
150
+ }
151
+
152
+ // What an event asks of the mod. Remote Control pushes, escalations and worker messages are
153
+ // contracts the daemon's notifier (ATK-313) and rules (ATK-315) emit; the mod only carries them out.
154
+ export function reaction(line: StreamLine): Reaction {
155
+ const text = (key: string): null | string => {
156
+ const value = line.data[key];
157
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
158
+ };
159
+ switch (line.kind) {
160
+ case 'conduct.connected':
161
+ return { kind: 'refresh', toast: null };
162
+ case 'conduct.disconnected':
163
+ return { kind: 'disconnected' };
164
+ case 'console.escalated': {
165
+ const ask = text('text');
166
+ return ask === null ? { kind: 'ignore' } : { kind: 'escalate', text: `[conductor] escalation${line.subject ? ` on ${line.subject}` : ''}: ${ask}` };
167
+ }
168
+ case 'inbox.raised': {
169
+ const ask = text('text');
170
+ return { kind: 'refresh', toast: ask === null ? null : `conductor: ${line.subject ? `${line.subject} · ` : ''}${ask}` };
171
+ }
172
+ case 'notify.requested': {
173
+ const message = text('message');
174
+ return line.data.channel === 'remote-control' && message !== null ? { kind: 'push', message } : { kind: 'ignore' };
175
+ }
176
+ case 'watcher.stats':
177
+ return { kind: 'ignore' };
178
+ case 'worker.message-requested': {
179
+ const to = text('to');
180
+ const message = text('message');
181
+ return to === null || message === null ? { kind: 'ignore' } : { kind: 'send', text: message, to };
182
+ }
183
+ default:
184
+ return { kind: 'refresh', toast: null };
185
+ }
186
+ }
187
+
188
+ // GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
189
+ export function parseFleet(body: unknown): ConductorWorker[] | null {
190
+ if (!isRecord(body) || !Array.isArray(body.workers)) return null;
191
+ return body.workers.flatMap((w): ConductorWorker[] => {
192
+ if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string') return [];
193
+ const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
194
+ const waits = Array.isArray(w.waits)
195
+ ? w.waits.flatMap((x) => (isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : []))
196
+ : [];
197
+ return [
198
+ {
199
+ activity: typeof w.activity === 'string' ? w.activity : w.status,
200
+ id: w.id,
201
+ item: typeof w.item === 'string' ? w.item : w.name,
202
+ name: w.name,
203
+ phase: typeof w.phase === 'string' ? w.phase : null,
204
+ project: typeof w.project === 'string' ? w.project : '?',
205
+ spendToday: spend,
206
+ status: w.status,
207
+ updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
208
+ waits,
209
+ },
210
+ ];
211
+ });
212
+ }
213
+
214
+ // GET /inbox's body; null when it isn't one.
215
+ export function parseInbox(body: unknown): ConductorInboxItem[] | null {
216
+ if (!isRecord(body) || !Array.isArray(body.items)) return null;
217
+ return body.items.flatMap((i): ConductorInboxItem[] => {
218
+ if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
219
+ return [
220
+ {
221
+ commands: strings(i.commands),
222
+ id: i.id,
223
+ item: typeof i.item === 'string' ? i.item : null,
224
+ kind: i.kind,
225
+ links: strings(i.links),
226
+ project: typeof i.project === 'string' ? i.project : null,
227
+ raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
228
+ text: i.text,
229
+ },
230
+ ];
231
+ });
232
+ }
233
+
234
+ // The error an API answer carries (`{ error, details }`), as one line.
235
+ export function errorOf(answer: ApiAnswer): string {
236
+ const { body } = answer;
237
+ if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
238
+ return `the conductor answered ${answer.status}`;
239
+ }
240
+
241
+ // POST /inbox/:id/resolve's answer as the line to show, as `conduct approve|revise|done` says it.
242
+ export function answerText(action: 'approve' | 'done' | 'revise', id: number, answer: ApiAnswer): string {
243
+ if (answer.status !== 200) return `not ${action === 'done' ? 'done' : `${action}d`}: ${errorOf(answer)}`;
244
+ const body = isRecord(answer.body) ? answer.body : {};
245
+ const key = isRecord(body.item) && typeof body.item.item === 'string' ? body.item.item : `#${id}`;
246
+ if (action === 'done') return `done: ${key}`;
247
+ const comment = isRecord(body.comment) ? body.comment : {};
248
+ const where = typeof comment.url === 'string' ? comment.url : typeof comment.id === 'string' ? comment.id : 'comment posted';
249
+ return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
250
+ }
251
+
252
+ // The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
253
+ // .conductor under USERPROFILE or HOME.
254
+ export function homeOf(env: { conductorHome?: string; home?: string; userProfile?: string }): null | string {
255
+ if (env.conductorHome) return env.conductorHome;
256
+ const user = env.userProfile || env.home;
257
+ return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
258
+ }
259
+
260
+ // The port and token files' text as an endpoint; null unless both are whole.
261
+ export function endpointOf(portText: string, tokenText: string): { port: number; token: string } | null {
262
+ const port = Number(portText.trim());
263
+ const token = tokenText.trim();
264
+ return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
265
+ }
266
+
267
+ // GET /fleet and GET /inbox as one snapshot; `error` set when the daemon didn't answer both.
268
+ export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number): ConductorSnapshot {
269
+ const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
270
+ if (workers === null) return { error: errorOf(fleet), inbox: [], readAt, workers: [] };
271
+ const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
272
+ if (items === null) return { error: errorOf(inbox), inbox: [], readAt, workers: [] };
273
+ return { error: null, inbox: items, readAt, workers };
274
+ }
275
+
276
+ function isRecord(value: unknown): value is Record<string, unknown> {
277
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
278
+ }
279
+
280
+ function strings(value: unknown): string[] {
281
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
282
+ }