@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.20
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 +163 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -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 +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +74 -12
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- 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/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- 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 +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +8 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- 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 +37 -49
- package/build/watchers/ado.js.map +1 -1
- 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 +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- 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-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- 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 +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code';
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code';
|
|
3
|
+
|
|
4
|
+
import type { ConductorSnapshot } from '../types';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
age,
|
|
8
|
+
type ApiAnswer,
|
|
9
|
+
answerText,
|
|
10
|
+
bandEntries,
|
|
11
|
+
endpointOf,
|
|
12
|
+
errorOf,
|
|
13
|
+
findPlan,
|
|
14
|
+
findWorker,
|
|
15
|
+
glyph,
|
|
16
|
+
homeOf,
|
|
17
|
+
inboxText,
|
|
18
|
+
notifyArgs,
|
|
19
|
+
notifyText,
|
|
20
|
+
overrideArgs,
|
|
21
|
+
overrideText,
|
|
22
|
+
parseLine,
|
|
23
|
+
profileText,
|
|
24
|
+
projectGroups,
|
|
25
|
+
type PushOutcome,
|
|
26
|
+
pushOutcome,
|
|
27
|
+
pushWarning,
|
|
28
|
+
reaction,
|
|
29
|
+
reportedState,
|
|
30
|
+
snapshotOf,
|
|
31
|
+
spendToday,
|
|
32
|
+
statusText,
|
|
33
|
+
tokens,
|
|
34
|
+
whyText,
|
|
35
|
+
workerDoing,
|
|
36
|
+
} from './model.ts';
|
|
37
|
+
|
|
38
|
+
const PANE = 'conductor';
|
|
39
|
+
const TITLE = 'Conductor';
|
|
40
|
+
// Every session reads /fleet and /inbox this often for the status line; the console also follows
|
|
41
|
+
// the event stream, so this is its safety net and what re-ages its rows.
|
|
42
|
+
const POLL_MS = 30_000;
|
|
43
|
+
const DEBOUNCE_MS = 300;
|
|
44
|
+
const RESTART_MS = 5_000;
|
|
45
|
+
// A band approve waiting on its `y` lapses after this.
|
|
46
|
+
const CONFIRM_MS = 30_000;
|
|
47
|
+
const NOT_RUNNING = 'the conductor is not running (conduct serve)';
|
|
48
|
+
|
|
49
|
+
const snapshot = atom({ plugin: 'conductor', key: 'snapshot' } as const, null);
|
|
50
|
+
const confirming = atom({ plugin: 'conductor', key: 'confirming' } as const, null);
|
|
51
|
+
const expanded = atom({ plugin: 'conductor', key: 'expanded' } as const, null);
|
|
52
|
+
|
|
53
|
+
// The console is the session started with CONDUCTOR_CONSOLE=1 (`conduct console` sets it): only it
|
|
54
|
+
// draws the band and the pane, answers the commands, follows the stream and acts on events. Both
|
|
55
|
+
// flags start over with the module, and session.start sets the first again after a reload.
|
|
56
|
+
let isConsole = false;
|
|
57
|
+
let isRefreshing = false;
|
|
58
|
+
// The newest event id the stream showed, so a restarted `conduct events` picks up after it.
|
|
59
|
+
let lastEventId: null | number = null;
|
|
60
|
+
// Set once the console has said that pushes no longer reach the phone; a push that does clears it.
|
|
61
|
+
let warnedInactive = false;
|
|
62
|
+
|
|
63
|
+
type Endpoint = { port: number; token: string };
|
|
64
|
+
|
|
65
|
+
// The running daemon's port and token, from its home's files. Throws when none runs.
|
|
66
|
+
async function endpoint($: EngineInterface): Promise<Endpoint> {
|
|
67
|
+
const home = homeOf({
|
|
68
|
+
conductorHome: await $.env.get('CONDUCTOR_HOME'),
|
|
69
|
+
home: await $.env.get('HOME'),
|
|
70
|
+
userProfile: await $.env.get('USERPROFILE'),
|
|
71
|
+
});
|
|
72
|
+
if (home === null) throw new Error(NOT_RUNNING);
|
|
73
|
+
let found: Endpoint | null;
|
|
74
|
+
try {
|
|
75
|
+
found = endpointOf(await $.fs.read(`${home}/port`), await $.fs.read(`${home}/token`));
|
|
76
|
+
} catch {
|
|
77
|
+
found = null;
|
|
78
|
+
}
|
|
79
|
+
if (found === null) throw new Error(NOT_RUNNING);
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One call on the daemon's API (127.0.0.1, with its token). Throws when none runs.
|
|
84
|
+
async function callApi($: EngineInterface, method: 'GET' | 'POST', path: string, body?: unknown, at?: Endpoint): Promise<ApiAnswer> {
|
|
85
|
+
const found = at ?? (await endpoint($));
|
|
86
|
+
const answer = await $.http.fetch(`http://127.0.0.1:${found.port}${path}`, {
|
|
87
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
88
|
+
headers: { authorization: `Bearer ${found.token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
89
|
+
method,
|
|
90
|
+
});
|
|
91
|
+
let parsed: unknown = null;
|
|
92
|
+
try {
|
|
93
|
+
parsed = answer.text ? JSON.parse(answer.text) : null;
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = answer.text;
|
|
96
|
+
}
|
|
97
|
+
return { body: parsed, status: answer.status };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reads /fleet, /inbox and /notify into the state the drawings read, and the status line.
|
|
101
|
+
async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
|
|
102
|
+
const readAt = await $.clock.now();
|
|
103
|
+
let next: ConductorSnapshot;
|
|
104
|
+
try {
|
|
105
|
+
const at = await endpoint($);
|
|
106
|
+
const [fleet, inbox, notify] = await Promise.all([
|
|
107
|
+
callApi($, 'GET', '/fleet', undefined, at),
|
|
108
|
+
callApi($, 'GET', '/inbox', undefined, at),
|
|
109
|
+
callApi($, 'GET', '/notify', undefined, at),
|
|
110
|
+
]);
|
|
111
|
+
next = snapshotOf(fleet, inbox, readAt, notify);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
next = { error: error instanceof Error ? error.message : String(error), inbox: [], notify: null, readAt, workers: [] };
|
|
114
|
+
}
|
|
115
|
+
await update($, snapshot, () => next);
|
|
116
|
+
$.ui.status(statusText(next));
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Many events land together (a note raises an item and a wait); one read covers them.
|
|
121
|
+
function refreshSoon($: EngineInterface): void {
|
|
122
|
+
if (isRefreshing) return;
|
|
123
|
+
isRefreshing = true;
|
|
124
|
+
$.clock.after(DEBOUNCE_MS, () => {
|
|
125
|
+
isRefreshing = false;
|
|
126
|
+
void refresh($);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One line of the follow stream, carried out.
|
|
131
|
+
async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
132
|
+
const line = parseLine(text);
|
|
133
|
+
if (line === null) return;
|
|
134
|
+
if (line.id !== null) lastEventId = line.id;
|
|
135
|
+
const act = reaction(line);
|
|
136
|
+
try {
|
|
137
|
+
switch (act.kind) {
|
|
138
|
+
case 'disconnected': {
|
|
139
|
+
const readAt = await $.clock.now();
|
|
140
|
+
await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
|
|
141
|
+
$.ui.status(undefined);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
case 'escalate':
|
|
145
|
+
// The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
|
|
146
|
+
await $.prompt.submit({ text: act.text });
|
|
147
|
+
return;
|
|
148
|
+
case 'ignore':
|
|
149
|
+
return;
|
|
150
|
+
case 'push':
|
|
151
|
+
await push($, act.message);
|
|
152
|
+
return;
|
|
153
|
+
case 'refresh':
|
|
154
|
+
if (act.toast !== null) $.ui.toast(act.toast);
|
|
155
|
+
refreshSoon($);
|
|
156
|
+
return;
|
|
157
|
+
case 'send':
|
|
158
|
+
await $.session.send({ text: act.text, to: act.to });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
$.ui.log(`conductor: ${line.kind} failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
|
|
167
|
+
// (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
|
|
168
|
+
// (ATK-255): Remote Control off, pushes off in the config, or the call failing.
|
|
169
|
+
async function push($: EngineInterface, message: string): Promise<void> {
|
|
170
|
+
let outcome: PushOutcome;
|
|
171
|
+
try {
|
|
172
|
+
outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
|
|
175
|
+
}
|
|
176
|
+
const warning = pushWarning(outcome);
|
|
177
|
+
if (outcome.state === 'ok') warnedInactive = false;
|
|
178
|
+
if (warning !== null && !warnedInactive) {
|
|
179
|
+
warnedInactive = true;
|
|
180
|
+
$.ui.toast(warning);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await callApi($, 'POST', '/notify/report', { channel: 'remote-control', result: outcome.detail, state: reportedState(outcome) });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
$.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
|
|
190
|
+
async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
|
|
191
|
+
try {
|
|
192
|
+
const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
|
|
193
|
+
refreshSoon($);
|
|
194
|
+
return profileText(name, result, await $.clock.now());
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// `conduct events --follow` from this package's own build, so the mod and the CLI are one version
|
|
201
|
+
// and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
|
|
202
|
+
// the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
|
|
203
|
+
// build, no node) is given up on, and the poll carries on alone.
|
|
204
|
+
async function follow($: EngineInterface): Promise<void> {
|
|
205
|
+
const cli = `${$.plugin.root}/../build/cli.js`;
|
|
206
|
+
if (!(await $.fs.exists(cli))) {
|
|
207
|
+
$.ui.log(`conductor: no ${cli}; the console polls instead of following events`, { to: 'debug' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (let quickExits = 0; quickExits < 3; ) {
|
|
211
|
+
const startedAt = await $.clock.now();
|
|
212
|
+
let buffer = '';
|
|
213
|
+
try {
|
|
214
|
+
const argv = ['node', cli, 'events', '--follow', ...(lastEventId === null ? [] : ['--since', String(lastEventId)])];
|
|
215
|
+
for await (const chunk of $.process.spawn({ argv })) {
|
|
216
|
+
if (chunk.stream !== 'stdout') continue;
|
|
217
|
+
buffer += chunk.text;
|
|
218
|
+
for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
|
|
219
|
+
const text = buffer.slice(0, end).trim();
|
|
220
|
+
buffer = buffer.slice(end + 1);
|
|
221
|
+
if (text !== '') await onLine($, text);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
$.ui.log(`conductor: conduct events failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
|
|
226
|
+
}
|
|
227
|
+
quickExits = (await $.clock.now()) - startedAt < 10_000 ? quickExits + 1 : 0;
|
|
228
|
+
await new Promise<void>((done) => $.clock.after(RESTART_MS, done));
|
|
229
|
+
}
|
|
230
|
+
$.ui.log('conductor: conduct events keeps exiting; the console polls instead', { to: 'debug' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Jason's answer to an inbox item, as `conduct approve|revise|done` posts it; the line to show.
|
|
234
|
+
async function answer($: EngineInterface, id: number, body: { action: 'approve' | 'done' } | { action: 'revise'; text: string }): Promise<string> {
|
|
235
|
+
try {
|
|
236
|
+
const result = await callApi($, 'POST', `/inbox/${id}/resolve`, body);
|
|
237
|
+
refreshSoon($);
|
|
238
|
+
return answerText(body.action, id, result);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return error instanceof Error ? error.message : String(error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// `/pause`, `/unpause`, `/focus-project` and the pane's buttons: an override that ends on its own, as
|
|
245
|
+
// `conduct pause|unpause|focus` sets it; the line to show.
|
|
246
|
+
async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpause', args: string): Promise<string> {
|
|
247
|
+
const now = await $.clock.now();
|
|
248
|
+
const typed = overrideArgs(command, args, now);
|
|
249
|
+
if ('error' in typed) return typed.error;
|
|
250
|
+
try {
|
|
251
|
+
const project = typeof typed.body.project === 'string' ? typed.body.project : null;
|
|
252
|
+
return overrideText(command, project, await callApi($, 'POST', '/overrides', typed.body), now);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return error instanceof Error ? error.message : String(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The rule behind an item's newest action (`conduct why <item>`), in one line.
|
|
259
|
+
async function why($: EngineInterface, item: string): Promise<string> {
|
|
260
|
+
try {
|
|
261
|
+
return whyText(item, await callApi($, 'GET', `/why?item=${encodeURIComponent(item)}`));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return error instanceof Error ? error.message : String(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function attach($: EngineInterface, id: number, name: string): Promise<string> {
|
|
268
|
+
try {
|
|
269
|
+
const result = await callApi($, 'POST', `/workers/${id}/attach`);
|
|
270
|
+
refreshSoon($);
|
|
271
|
+
return result.status === 200 ? `attached to ${name}` : `could not attach to ${name}: ${errorOf(result)}`;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return error instanceof Error ? error.message : String(error);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function copy($: EngineInterface, text: string, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
|
|
278
|
+
const copied = await $.ui.copy({ surface, text });
|
|
279
|
+
$.ui.toast(copied.isCopied ? `copied: ${text}` : `not copied: ${text}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Stages a band approve for its `y`; it lapses unless pressed within CONFIRM_MS.
|
|
283
|
+
async function confirm($: EngineInterface, id: number): Promise<void> {
|
|
284
|
+
await update($, confirming, () => id);
|
|
285
|
+
$.clock.after(CONFIRM_MS, () => void update($, confirming, (staged) => (staged === id ? null : staged)));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function openPane($: EngineInterface): Promise<void> {
|
|
289
|
+
await $.ui.open({ focus: true, id: PANE, title: TITLE });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function registerCommands($: EngineInterface): Promise<void> {
|
|
293
|
+
await $.command.register({ description: 'Open the conductor pane: the inbox, the fleet and spend', name: 'conductor' });
|
|
294
|
+
await $.command.register({ description: 'What waits on you in the conductor, with commands and links', name: 'inbox' });
|
|
295
|
+
await $.command.register({ argumentHint: '<item | #id>', description: "Approve an item's plan (posts approve as you)", name: 'approve' });
|
|
296
|
+
await $.command.register({ argumentHint: '<item | #id> <what to change>', description: 'Ask for a revised plan (posts revise: … as you)', name: 'revise' });
|
|
297
|
+
await $.command.register({ argumentHint: '<item | worker>', description: 'Wake a parked worker and open it in a tab', name: 'attach' });
|
|
298
|
+
await $.command.register({ argumentHint: '[<profile> [--until <when>]]', description: 'Show or switch the notification profile (default, away, focus …)', name: 'notify' });
|
|
299
|
+
await $.command.register({ argumentHint: '[<project>] [--until <when>]', description: 'No new launches in a project (every project without one)', name: 'pause' });
|
|
300
|
+
await $.command.register({ argumentHint: '[<project>]', description: 'Launch again after /pause', name: 'unpause' });
|
|
301
|
+
await $.command.register({ argumentHint: '<project> [--until <when>]', description: "Put a project's items first (until midnight without --until)", name: 'focus-project' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const register: Register = (on) => {
|
|
305
|
+
on('session.start', async ($, e, next) => {
|
|
306
|
+
const started = await next(e);
|
|
307
|
+
if (!e.isInteractive) return started;
|
|
308
|
+
isConsole = (await $.env.get('CONDUCTOR_CONSOLE')) === '1';
|
|
309
|
+
$.clock.every(POLL_MS, () => void refresh($));
|
|
310
|
+
await refresh($);
|
|
311
|
+
if (!isConsole) return started;
|
|
312
|
+
await registerCommands($);
|
|
313
|
+
void follow($);
|
|
314
|
+
// Docked as a sidebar on a wide fullscreen terminal; below that it waits until opened.
|
|
315
|
+
void $.ui.open({ id: PANE, title: TITLE });
|
|
316
|
+
return started;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
on('command.run', { command: 'conductor' }, async ($) => {
|
|
320
|
+
await openPane($);
|
|
321
|
+
return { text: 'Conductor pane opened.' };
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
on('command.run', { command: 'inbox' }, async ($) => {
|
|
325
|
+
const snap = await refresh($);
|
|
326
|
+
return { text: snap.error ?? inboxText(snap.inbox, await $.clock.now()) };
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
on('command.run', { command: 'approve' }, async ($, e) => {
|
|
330
|
+
const ref = e.args.trim();
|
|
331
|
+
if (ref === '') return { text: 'usage: /approve <item | #id>' };
|
|
332
|
+
const snap = await refresh($);
|
|
333
|
+
if (snap.error !== null) return { text: snap.error };
|
|
334
|
+
const plan = findPlan(snap.inbox, ref);
|
|
335
|
+
return { text: plan.found ? await answer($, plan.found.id, { action: 'approve' }) : plan.error };
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
on('command.run', { command: 'revise' }, async ($, e) => {
|
|
339
|
+
// The item, then the text as typed: its lines and spacing are the instructions.
|
|
340
|
+
const typed = /^(\S+)\s+([\s\S]*\S)\s*$/.exec(e.args.trim());
|
|
341
|
+
const ref = typed?.[1] ?? '';
|
|
342
|
+
const text = typed?.[2] ?? '';
|
|
343
|
+
if (ref === '' || text === '') return { text: 'usage: /revise <item | #id> <what to change>' };
|
|
344
|
+
const snap = await refresh($);
|
|
345
|
+
if (snap.error !== null) return { text: snap.error };
|
|
346
|
+
const plan = findPlan(snap.inbox, ref);
|
|
347
|
+
return { text: plan.found ? await answer($, plan.found.id, { action: 'revise', text }) : plan.error };
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
on('command.run', { command: 'attach' }, async ($, e) => {
|
|
351
|
+
const ref = e.args.trim();
|
|
352
|
+
if (ref === '') return { text: 'usage: /attach <item | worker>' };
|
|
353
|
+
const snap = await refresh($);
|
|
354
|
+
if (snap.error !== null) return { text: snap.error };
|
|
355
|
+
const worker = findWorker(snap.workers, ref);
|
|
356
|
+
return { text: worker.found ? await attach($, worker.found.id, worker.found.name) : worker.error };
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
on('command.run', { command: 'notify' }, async ($, e) => {
|
|
360
|
+
if (e.args.trim() !== '') {
|
|
361
|
+
const typed = notifyArgs(e.args, await $.clock.now());
|
|
362
|
+
return { text: 'error' in typed ? typed.error : await switchProfile($, typed.name, typed.until) };
|
|
363
|
+
}
|
|
364
|
+
const snap = await refresh($);
|
|
365
|
+
return { text: snap.error ?? notifyText(snap.notify, await $.clock.now()) };
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
on('command.run', { command: 'pause' }, async ($, e) => ({ text: await override($, 'pause', e.args) }));
|
|
369
|
+
on('command.run', { command: 'unpause' }, async ($, e) => ({ text: await override($, 'unpause', e.args) }));
|
|
370
|
+
on('command.run', { command: 'focus-project' }, async ($, e) => ({ text: await override($, 'focus', e.args) }));
|
|
371
|
+
|
|
372
|
+
// The band above the prompt, in the console, while the inbox has items. A bare digit typed into
|
|
373
|
+
// an empty prompt presses a band Button, so a plan's approve takes a second press (`y`).
|
|
374
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
375
|
+
if (!isConsole || e.props.hasSurvey) return next(e);
|
|
376
|
+
const snap = await read($, snapshot);
|
|
377
|
+
if (snap === null || snap.error !== null || snap.inbox.length === 0) return next(e);
|
|
378
|
+
const { Box, Button, Text } = $.ui.resolve(e);
|
|
379
|
+
const entries = bandEntries(snap.inbox);
|
|
380
|
+
const confirmId = await read($, confirming);
|
|
381
|
+
const pending = entries.find((x) => x.act === 'approve' && x.id === confirmId);
|
|
382
|
+
if (pending) {
|
|
383
|
+
return (
|
|
384
|
+
<Box columnGap={2} flexWrap="wrap">
|
|
385
|
+
<Text bold>conductor</Text>
|
|
386
|
+
<Text>
|
|
387
|
+
{pending.label}? {pending.text}
|
|
388
|
+
</Text>
|
|
389
|
+
<Button
|
|
390
|
+
hotkey="y"
|
|
391
|
+
key="confirm"
|
|
392
|
+
label="y approve"
|
|
393
|
+
onPress={async () => {
|
|
394
|
+
await update($, confirming, () => null);
|
|
395
|
+
$.ui.toast(await answer($, pending.id, { action: 'approve' }));
|
|
396
|
+
}}
|
|
397
|
+
variant="primary"
|
|
398
|
+
/>
|
|
399
|
+
<Button hotkey="n" key="cancel" label="n cancel" onPress={() => update($, confirming, () => null)} />
|
|
400
|
+
</Box>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return (
|
|
404
|
+
<Box columnGap={2} flexWrap="wrap">
|
|
405
|
+
<Text bold>conductor · inbox {snap.inbox.length}</Text>
|
|
406
|
+
{entries.map((x) => (
|
|
407
|
+
<Button
|
|
408
|
+
hotkey={x.hotkey}
|
|
409
|
+
key={`item-${x.id}`}
|
|
410
|
+
label={`${x.hotkey} ${x.label}`}
|
|
411
|
+
onPress={async (press) => {
|
|
412
|
+
if (x.act === 'approve') await confirm($, x.id);
|
|
413
|
+
else if (x.act === 'copy') await copy($, x.text, press.surface);
|
|
414
|
+
else await openPane($);
|
|
415
|
+
}}
|
|
416
|
+
/>
|
|
417
|
+
))}
|
|
418
|
+
{snap.inbox.length > entries.length && <Text dimColor>+{snap.inbox.length - entries.length} more</Text>}
|
|
419
|
+
<Button hotkey="o" key="open-pane" label="o panel" onPress={() => openPane($)} />
|
|
420
|
+
</Box>
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// The pane: the notification profile, the inbox, then the fleet by project, then today's spend.
|
|
425
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
426
|
+
const ui = $.ui.resolve(e);
|
|
427
|
+
const { Box, Button, Link, Text } = ui;
|
|
428
|
+
const snap = await read($, snapshot);
|
|
429
|
+
const open = await read($, expanded);
|
|
430
|
+
const now = await $.clock.now();
|
|
431
|
+
const rule = (title: string): string => `─ ${title} ${'─'.repeat(Math.max(0, e.props.bodyColumns - title.length - 3))}`;
|
|
432
|
+
if (snap === null) return <Text dimColor>Reading the conductor…</Text>;
|
|
433
|
+
if (snap.error !== null) {
|
|
434
|
+
return (
|
|
435
|
+
<Box flexDirection="column">
|
|
436
|
+
<Text bold>{TITLE}</Text>
|
|
437
|
+
<Text>{snap.error}</Text>
|
|
438
|
+
</Box>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return (
|
|
442
|
+
<Box flexDirection="column">
|
|
443
|
+
<Text bold>
|
|
444
|
+
{TITLE} <Text dimColor>{statusText(snap)}</Text>
|
|
445
|
+
</Text>
|
|
446
|
+
{snap.notify !== null && (
|
|
447
|
+
<Box columnGap={2} flexWrap="wrap">
|
|
448
|
+
{e.surface !== 'mobile' && 'Select' in ui ? (
|
|
449
|
+
<ui.Select
|
|
450
|
+
key="notify-profile"
|
|
451
|
+
label="notify "
|
|
452
|
+
onSelect={async (name) => $.ui.toast(await switchProfile($, name))}
|
|
453
|
+
options={snap.notify.profiles.map((p) => ({ value: p }))}
|
|
454
|
+
value={snap.notify.profile}
|
|
455
|
+
/>
|
|
456
|
+
) : (
|
|
457
|
+
snap.notify.profiles.map((p) => (
|
|
458
|
+
<Button
|
|
459
|
+
key={`profile-${p}`}
|
|
460
|
+
label={p === snap.notify?.profile ? `notify: ${p}` : p}
|
|
461
|
+
onPress={async () => $.ui.toast(await switchProfile($, p))}
|
|
462
|
+
variant={p === snap.notify?.profile ? 'primary' : undefined}
|
|
463
|
+
/>
|
|
464
|
+
))
|
|
465
|
+
)}
|
|
466
|
+
<Text dimColor>
|
|
467
|
+
Remote Control: {snap.notify.remoteControl}
|
|
468
|
+
{snap.notify.quietNow ? ` · quiet hours, ${snap.notify.held} held` : ''}
|
|
469
|
+
</Text>
|
|
470
|
+
</Box>
|
|
471
|
+
)}
|
|
472
|
+
<Text dimColor wrap="truncate">
|
|
473
|
+
{rule(`Inbox (${snap.inbox.length})`)}
|
|
474
|
+
</Text>
|
|
475
|
+
{snap.inbox.length === 0 && <Text dimColor>Nothing waits on you.</Text>}
|
|
476
|
+
{snap.inbox.map((item, i) => (
|
|
477
|
+
<Box flexDirection="column" key={`inbox-${item.id}`} marginBottom={1}>
|
|
478
|
+
<Text>
|
|
479
|
+
[{i + 1}] <Text bold>{item.item ?? `#${item.id}`}</Text> {item.kind} <Text dimColor>{age(item.raisedAt, now)}</Text>
|
|
480
|
+
</Text>
|
|
481
|
+
<Text>{item.text}</Text>
|
|
482
|
+
<Box columnGap={2} flexWrap="wrap">
|
|
483
|
+
{item.kind === 'plan' && item.item !== null && (
|
|
484
|
+
<Button
|
|
485
|
+
key={`approve-${item.id}`}
|
|
486
|
+
label="approve"
|
|
487
|
+
onPress={async () => $.ui.toast(await answer($, item.id, { action: 'approve' }))}
|
|
488
|
+
variant="primary"
|
|
489
|
+
/>
|
|
490
|
+
)}
|
|
491
|
+
{item.kind === 'plan' && item.item !== null && (
|
|
492
|
+
<Button key={`revise-${item.id}`} label="revise" onPress={() => $.prompt.fill({ text: `/revise ${item.item ?? ''} ` })} />
|
|
493
|
+
)}
|
|
494
|
+
{item.commands.slice(0, 2).map((command, n) => (
|
|
495
|
+
<Button key={`copy-${item.id}-${n}`} label={`copy: ${command}`} onPress={(press) => copy($, command, press.surface)} />
|
|
496
|
+
))}
|
|
497
|
+
{item.links.slice(0, 2).map((href, n) => (
|
|
498
|
+
<Link href={href} key={`link-${item.id}-${n}`} label="open" />
|
|
499
|
+
))}
|
|
500
|
+
{item.kind !== 'plan' && (
|
|
501
|
+
<Button key={`done-${item.id}`} label="done" onPress={async () => $.ui.toast(await answer($, item.id, { action: 'done' }))} />
|
|
502
|
+
)}
|
|
503
|
+
</Box>
|
|
504
|
+
</Box>
|
|
505
|
+
))}
|
|
506
|
+
{projectGroups(snap.workers).map((group) => (
|
|
507
|
+
<Box flexDirection="column" key={`project-${group.project}`}>
|
|
508
|
+
<Box columnGap={1}>
|
|
509
|
+
<Text dimColor wrap="truncate">
|
|
510
|
+
{rule(`${group.project} · ${group.running}/${group.rows.length} running`).slice(0, Math.max(10, e.props.bodyColumns - 18))}
|
|
511
|
+
</Text>
|
|
512
|
+
<Button key={`pause-${group.project}`} label="pause" onPress={async () => $.ui.toast(await override($, 'pause', group.project))} />
|
|
513
|
+
<Button key={`focus-${group.project}`} label="focus" onPress={async () => $.ui.toast(await override($, 'focus', group.project))} />
|
|
514
|
+
</Box>
|
|
515
|
+
{group.rows.map((w) => (
|
|
516
|
+
<Box flexDirection="column" key={`worker-${w.id}`}>
|
|
517
|
+
<Button key={`row-${w.id}`} onPress={() => update($, expanded, (name) => (name === w.name ? null : w.name))} plain>
|
|
518
|
+
{glyph(w.status)} {w.name} {w.phase ?? ''}{' '}
|
|
519
|
+
<Text dimColor>
|
|
520
|
+
{workerDoing(w)} · {age(w.updatedAt, now)} · {tokens(w.spendToday)} tok
|
|
521
|
+
</Text>
|
|
522
|
+
</Button>
|
|
523
|
+
{open === w.name && (
|
|
524
|
+
<Box columnGap={2} flexWrap="wrap" marginLeft={2}>
|
|
525
|
+
<Button key={`attach-${w.id}`} label="attach" onPress={async () => $.ui.toast(await attach($, w.id, w.name))} />
|
|
526
|
+
<Button key={`why-${w.id}`} label="why" onPress={async () => $.ui.toast(await why($, w.item))} />
|
|
527
|
+
<Text dimColor>
|
|
528
|
+
{w.status}
|
|
529
|
+
{w.waits.length > 0 ? ` · waits on ${w.waits.join(', ')}` : ''}
|
|
530
|
+
</Text>
|
|
531
|
+
</Box>
|
|
532
|
+
)}
|
|
533
|
+
</Box>
|
|
534
|
+
))}
|
|
535
|
+
</Box>
|
|
536
|
+
))}
|
|
537
|
+
<Text dimColor wrap="truncate">
|
|
538
|
+
{rule(`Today: ${tokens(spendToday(snap.workers))} tokens · ${snap.workers.length} workers`)}
|
|
539
|
+
</Text>
|
|
540
|
+
</Box>
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The daemon's answers as the mod reads them: GET /fleet's workers and GET /inbox's items, with
|
|
2
|
+
// only the fields the mod draws.
|
|
3
|
+
export type ConductorWorker = {
|
|
4
|
+
activity: string;
|
|
5
|
+
id: number;
|
|
6
|
+
item: string;
|
|
7
|
+
name: string;
|
|
8
|
+
phase: string | null;
|
|
9
|
+
project: string;
|
|
10
|
+
spendToday: number;
|
|
11
|
+
status: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
waits: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ConductorInboxItem = {
|
|
17
|
+
commands: string[];
|
|
18
|
+
id: number;
|
|
19
|
+
item: string | null;
|
|
20
|
+
kind: string;
|
|
21
|
+
links: string[];
|
|
22
|
+
project: string | null;
|
|
23
|
+
raisedAt: string;
|
|
24
|
+
text: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// GET /notify as the mod draws it: the profile, the others to switch to, Remote Control's state.
|
|
28
|
+
export type ConductorNotify = {
|
|
29
|
+
held: number;
|
|
30
|
+
profile: string;
|
|
31
|
+
profiles: string[];
|
|
32
|
+
profileUntil: string | null;
|
|
33
|
+
quietNow: boolean;
|
|
34
|
+
remoteControl: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// What the mod last read from the daemon; `error` set (and the lists empty) when none answered.
|
|
38
|
+
// `notify` is null from a daemon older than the notifier.
|
|
39
|
+
export type ConductorSnapshot = {
|
|
40
|
+
error: string | null;
|
|
41
|
+
inbox: ConductorInboxItem[];
|
|
42
|
+
notify: ConductorNotify | null;
|
|
43
|
+
readAt: number;
|
|
44
|
+
workers: ConductorWorker[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
declare module 'claude-code' {
|
|
48
|
+
interface PluginState {
|
|
49
|
+
conductor: {
|
|
50
|
+
// The inbox id whose approve waits on a second press in the band.
|
|
51
|
+
confirming: number | null;
|
|
52
|
+
// The worker whose row the pane shows expanded, by name.
|
|
53
|
+
expanded: string | null;
|
|
54
|
+
snapshot: ConductorSnapshot | null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@detergent-software/conductor",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.20",
|
|
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": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"build",
|
|
11
11
|
"!build/__tests__",
|
|
12
|
+
"mod",
|
|
13
|
+
"!mod/tests",
|
|
14
|
+
"!mod/.claude-plugin/types",
|
|
12
15
|
"CHANGELOG.md"
|
|
13
16
|
],
|
|
14
17
|
"scripts": {
|
|
@@ -34,6 +37,7 @@
|
|
|
34
37
|
},
|
|
35
38
|
"dependencies": {
|
|
36
39
|
"jsonc-parser": "^3.3.1",
|
|
40
|
+
"semver": "^7.7.0",
|
|
37
41
|
"zod": "^4.0.0"
|
|
38
42
|
},
|
|
39
43
|
"devDependencies": {
|
|
@@ -43,6 +47,7 @@
|
|
|
43
47
|
"@semantic-release/git": "^10.0.1",
|
|
44
48
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
45
49
|
"@types/node": "^24.0.0",
|
|
50
|
+
"@types/semver": "^7.5.0",
|
|
46
51
|
"@vitest/eslint-plugin": "^1.0.0",
|
|
47
52
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
48
53
|
"eslint": "^9.0.0",
|