@detergent-software/conductor 2.1.1 → 2.2.0-dev.2
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 +18 -4
- package/README.md +8 -4
- package/build/code-host.js +11 -0
- package/build/code-host.js.map +1 -1
- package/build/daemon.js +5 -3
- package/build/daemon.js.map +1 -1
- package/build/notes.js +1 -0
- package/build/notes.js.map +1 -1
- package/build/profile.js +4 -0
- package/build/profile.js.map +1 -1
- package/build/queue.js +4 -2
- package/build/queue.js.map +1 -1
- package/build/scheduler.js +85 -27
- package/build/scheduler.js.map +1 -1
- package/build/store.js +7 -3
- package/build/store.js.map +1 -1
- package/mod/README.md +2 -0
- package/mod/hooks/model.ts +369 -218
- package/mod/hooks/register.tsx +321 -314
- package/mod/types/index.d.ts +26 -26
- package/package.json +4 -4
package/mod/hooks/register.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { atom, read, update } from 'claude-code';
|
|
2
1
|
import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code';
|
|
3
2
|
|
|
3
|
+
import { atom, read, update } from 'claude-code';
|
|
4
|
+
|
|
4
5
|
import type { ConductorSnapshot } from '../types';
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
age,
|
|
8
|
-
type ApiAnswer,
|
|
9
9
|
answerText,
|
|
10
|
+
type ApiAnswer,
|
|
10
11
|
bandEntries,
|
|
11
12
|
clearedText,
|
|
12
13
|
CONSOLE_SKILL,
|
|
@@ -14,7 +15,8 @@ import {
|
|
|
14
15
|
errorOf,
|
|
15
16
|
findPlan,
|
|
16
17
|
findWorker,
|
|
17
|
-
|
|
18
|
+
fleetColumns,
|
|
19
|
+
fleetRow,
|
|
18
20
|
homeOf,
|
|
19
21
|
idleAction,
|
|
20
22
|
inboxText,
|
|
@@ -29,7 +31,6 @@ import {
|
|
|
29
31
|
parseLine,
|
|
30
32
|
type PingOutcome,
|
|
31
33
|
pingOutcome,
|
|
32
|
-
prLabel,
|
|
33
34
|
profileText,
|
|
34
35
|
projectGroups,
|
|
35
36
|
type PushOutcome,
|
|
@@ -45,7 +46,6 @@ import {
|
|
|
45
46
|
WARM_AFTER_MS,
|
|
46
47
|
WARM_PROMPT,
|
|
47
48
|
whyText,
|
|
48
|
-
workerDoing,
|
|
49
49
|
} from './model.ts';
|
|
50
50
|
|
|
51
51
|
const PANE = 'conductor';
|
|
@@ -66,11 +66,11 @@ const ATTACH_RUN_MS = 10 * 60_000;
|
|
|
66
66
|
const ANSWER_RUN_MS = 2 * 60_000;
|
|
67
67
|
const NOT_RUNNING = 'the conductor is not running (conduct serve)';
|
|
68
68
|
|
|
69
|
-
const snapshot = atom({
|
|
70
|
-
const confirming = atom({
|
|
71
|
-
const expanded = atom({
|
|
72
|
-
const hasSkill = atom({
|
|
73
|
-
const cleared = atom({
|
|
69
|
+
const snapshot = atom({ key: 'snapshot', plugin: 'conductor' } as const, null);
|
|
70
|
+
const confirming = atom({ key: 'confirming', plugin: 'conductor' } as const, null);
|
|
71
|
+
const expanded = atom({ key: 'expanded', plugin: 'conductor' } as const, null);
|
|
72
|
+
const hasSkill = atom({ key: 'hasSkill', plugin: 'conductor' } as const, false);
|
|
73
|
+
const cleared = atom({ key: 'cleared', plugin: 'conductor' } as const, null);
|
|
74
74
|
|
|
75
75
|
// The console is the session started with CONDUCTOR_CONSOLE=1 (`conduct console` sets it): only it
|
|
76
76
|
// draws the band and the pane, answers the commands, follows the stream and acts on events. Both
|
|
@@ -95,22 +95,70 @@ let isWindows = false;
|
|
|
95
95
|
|
|
96
96
|
type Endpoint = { port: number; token: string };
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
let found: Endpoint | null;
|
|
98
|
+
// Jason's answer to an inbox item, as `conduct approve|revise|done` posts it; the line to show.
|
|
99
|
+
// Approve and revise run `conduct` (ANSWER_RUN_MS), by inbox id. Without the CLI, the API, which
|
|
100
|
+
// $.http.fetch cuts off after 30 s, possibly after the comment was posted. Done doesn't ask Linear.
|
|
101
|
+
async function answer(
|
|
102
|
+
$: EngineInterface,
|
|
103
|
+
id: number,
|
|
104
|
+
body: { action: 'approve' | 'done' } | { action: 'revise'; text: string },
|
|
105
|
+
): Promise<string> {
|
|
107
106
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
const cli = cliOf($);
|
|
108
|
+
if (body.action !== 'done' && (await $.fs.exists(cli))) {
|
|
109
|
+
const failed = `not ${body.action}d`;
|
|
110
|
+
const argv = body.action === 'revise' ? ['revise', `#${id}`, body.text] : ['approve', `#${id}`];
|
|
111
|
+
let run;
|
|
112
|
+
try {
|
|
113
|
+
run = await $.process.run(['node', cli, ...argv], { timeoutMs: ANSWER_RUN_MS });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return `${failed}: ${error instanceof Error ? error.message : String(error)}`;
|
|
116
|
+
}
|
|
117
|
+
refreshSoon($);
|
|
118
|
+
return runText(run, {
|
|
119
|
+
failed,
|
|
120
|
+
ok: `${body.action === 'approve' ? 'approved' : 'asked for a revision of'} #${id}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const at = await endpoint($);
|
|
124
|
+
let result: ApiAnswer;
|
|
125
|
+
try {
|
|
126
|
+
result = await callApi($, 'POST', `/inbox/${id}/resolve`, body, at);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (body.action === 'done') throw error;
|
|
129
|
+
return `not ${body.action}d: ${error instanceof Error ? error.message : String(error)}; ${MAYBE_POSTED}`;
|
|
130
|
+
}
|
|
131
|
+
refreshSoon($);
|
|
132
|
+
return answerText(body.action, id, result);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return error instanceof Error ? error.message : String(error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Starts the console's idle point over: WARM_AFTER_MS from now, unless a turn comes first.
|
|
139
|
+
function armIdle($: EngineInterface): void {
|
|
140
|
+
idleTimer?.cancel();
|
|
141
|
+
idleTimer = $.clock.after(WARM_AFTER_MS, () => void onIdle($));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Through `conduct attach` (ATTACH_RUN_MS), by id: a name is unique only within its project.
|
|
145
|
+
// Without the CLI, the API, which answers within 30 s only when the worker needs no settle time
|
|
146
|
+
// and resumes quickly. The daemon refuses a second attach of a worker while one runs.
|
|
147
|
+
async function attach($: EngineInterface, id: number, name: string): Promise<string> {
|
|
148
|
+
try {
|
|
149
|
+
const cli = cliOf($);
|
|
150
|
+
if (await $.fs.exists(cli)) {
|
|
151
|
+
$.ui.toast(`attaching to ${name}…`);
|
|
152
|
+
const run = await $.process.run(['node', cli, 'attach', `#${id}`], { timeoutMs: ATTACH_RUN_MS });
|
|
153
|
+
refreshSoon($);
|
|
154
|
+
return runText(run, { failed: `could not attach to ${name}`, ok: `attached to ${name}` });
|
|
155
|
+
}
|
|
156
|
+
const result = await callApi($, 'POST', `/workers/${id}/attach`);
|
|
157
|
+
refreshSoon($);
|
|
158
|
+
return result.status === 200 ? `attached to ${name}` : `could not attach to ${name}: ${errorOf(result)}`;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return error instanceof Error ? error.message : String(error);
|
|
111
161
|
}
|
|
112
|
-
if (found === null) throw new Error(NOT_RUNNING);
|
|
113
|
-
return found;
|
|
114
162
|
}
|
|
115
163
|
|
|
116
164
|
// One call on the daemon's API (127.0.0.1, with its token). Throws when none runs.
|
|
@@ -130,7 +178,7 @@ async function callApi(
|
|
|
130
178
|
},
|
|
131
179
|
method,
|
|
132
180
|
});
|
|
133
|
-
let parsed: unknown
|
|
181
|
+
let parsed: unknown;
|
|
134
182
|
try {
|
|
135
183
|
parsed = answer.text ? JSON.parse(answer.text) : null;
|
|
136
184
|
} catch {
|
|
@@ -139,111 +187,24 @@ async function callApi(
|
|
|
139
187
|
return { body: parsed, status: answer.status };
|
|
140
188
|
}
|
|
141
189
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
let next: ConductorSnapshot;
|
|
146
|
-
try {
|
|
147
|
-
const at = await endpoint($);
|
|
148
|
-
const [fleet, inbox, notify] = await Promise.all([
|
|
149
|
-
callApi($, 'GET', '/fleet', undefined, at),
|
|
150
|
-
callApi($, 'GET', '/inbox', undefined, at),
|
|
151
|
-
callApi($, 'GET', '/notify', undefined, at),
|
|
152
|
-
]);
|
|
153
|
-
next = snapshotOf(fleet, inbox, readAt, notify);
|
|
154
|
-
} catch (error) {
|
|
155
|
-
next = {
|
|
156
|
-
error: error instanceof Error ? error.message : String(error),
|
|
157
|
-
inbox: [],
|
|
158
|
-
notify: null,
|
|
159
|
-
readAt,
|
|
160
|
-
workers: [],
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
await update($, snapshot, () => next);
|
|
164
|
-
return next;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Many events land together (a note raises an item and a wait); one read covers them.
|
|
168
|
-
function refreshSoon($: EngineInterface): void {
|
|
169
|
-
if (isRefreshing) return;
|
|
170
|
-
isRefreshing = true;
|
|
171
|
-
$.clock.after(DEBOUNCE_MS, () => {
|
|
172
|
-
isRefreshing = false;
|
|
173
|
-
void refresh($);
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// One line of the follow stream, carried out.
|
|
178
|
-
async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
179
|
-
const line = parseLine(text);
|
|
180
|
-
if (line === null) return;
|
|
181
|
-
if (line.id !== null) lastEventId = line.id;
|
|
182
|
-
const act = reaction(line);
|
|
183
|
-
try {
|
|
184
|
-
switch (act.kind) {
|
|
185
|
-
case 'disconnected': {
|
|
186
|
-
const readAt = await $.clock.now();
|
|
187
|
-
await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
case 'escalate':
|
|
191
|
-
// The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
|
|
192
|
-
await $.prompt.submit({ text: act.text });
|
|
193
|
-
return;
|
|
194
|
-
case 'ignore':
|
|
195
|
-
return;
|
|
196
|
-
case 'push':
|
|
197
|
-
await push($, act.message);
|
|
198
|
-
return;
|
|
199
|
-
case 'refresh':
|
|
200
|
-
if (act.toast !== null) $.ui.toast(act.toast);
|
|
201
|
-
refreshSoon($);
|
|
202
|
-
return;
|
|
203
|
-
case 'send':
|
|
204
|
-
await $.session.send({ text: act.text, to: act.to });
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
} catch (error) {
|
|
208
|
-
$.ui.log(`conductor: ${line.kind} failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
209
|
-
to: 'debug',
|
|
210
|
-
});
|
|
211
|
-
}
|
|
190
|
+
// The conductor's own CLI, beside the mod in its package; a mod checkout without a build has none.
|
|
191
|
+
function cliOf($: EngineInterface): string {
|
|
192
|
+
return `${$.plugin.root}/../build/cli.js`;
|
|
212
193
|
}
|
|
213
194
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
let outcome: PushOutcome;
|
|
219
|
-
try {
|
|
220
|
-
outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
|
|
221
|
-
} catch (error) {
|
|
222
|
-
outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
|
|
223
|
-
}
|
|
224
|
-
const warning = pushWarning(outcome);
|
|
225
|
-
if (outcome.state === 'ok') warnedInactive = false;
|
|
226
|
-
if (warning !== null && !warnedInactive) {
|
|
227
|
-
warnedInactive = true;
|
|
228
|
-
$.ui.toast(warning);
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
await callApi($, 'POST', '/notify/report', {
|
|
232
|
-
channel: 'remote-control',
|
|
233
|
-
result: outcome.detail,
|
|
234
|
-
state: reportedState(outcome),
|
|
235
|
-
});
|
|
236
|
-
} catch (error) {
|
|
237
|
-
$.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
238
|
-
to: 'debug',
|
|
239
|
-
});
|
|
240
|
-
}
|
|
195
|
+
// Stages a band approve for its `y`; it lapses unless pressed within CONFIRM_MS.
|
|
196
|
+
async function confirm($: EngineInterface, id: number): Promise<void> {
|
|
197
|
+
await update($, confirming, () => id);
|
|
198
|
+
$.clock.after(CONFIRM_MS, () => void update($, confirming, (staged) => (staged === id ? null : staged)));
|
|
241
199
|
}
|
|
242
200
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
201
|
+
async function copy(
|
|
202
|
+
$: EngineInterface,
|
|
203
|
+
text: string,
|
|
204
|
+
surface: Parameters<EngineInterface['ui']['copy']>[0]['surface'],
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const copied = await $.ui.copy({ surface, text });
|
|
207
|
+
$.ui.toast(copied.isCopied ? `copied: ${text}` : `not copied: ${text}`);
|
|
247
208
|
}
|
|
248
209
|
|
|
249
210
|
function disarmIdle(): void {
|
|
@@ -251,76 +212,22 @@ function disarmIdle(): void {
|
|
|
251
212
|
idleTimer = null;
|
|
252
213
|
}
|
|
253
214
|
|
|
254
|
-
// The
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const contextTokens = (await $.session.usage()).context.tokens;
|
|
264
|
-
if (!isIdle()) return;
|
|
265
|
-
const act = idleAction({ contextTokens, warms });
|
|
266
|
-
if (act.kind === 'clear') {
|
|
267
|
-
await update($, cleared, () => act.reason);
|
|
268
|
-
if (!isIdle()) {
|
|
269
|
-
await update($, cleared, () => null);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
isClearing = true;
|
|
273
|
-
try {
|
|
274
|
-
await $.command.run({ command: 'clear' });
|
|
275
|
-
} catch (error) {
|
|
276
|
-
await update($, cleared, () => null);
|
|
277
|
-
$.ui.log(`conductor: the idle /clear failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
278
|
-
to: 'debug',
|
|
279
|
-
});
|
|
280
|
-
return;
|
|
281
|
-
} finally {
|
|
282
|
-
isClearing = false;
|
|
283
|
-
}
|
|
284
|
-
await reportCache($, { action: 'clear', contextTokens: contextTokens ?? null, reason: act.reason });
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
let outcome: PingOutcome;
|
|
288
|
-
try {
|
|
289
|
-
outcome = pingOutcome(await $.model.fork({ prompt: WARM_PROMPT }), contextTokens);
|
|
290
|
-
} catch (error) {
|
|
291
|
-
$.ui.log(`conductor: the cache ping failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
292
|
-
to: 'debug',
|
|
293
|
-
});
|
|
294
|
-
outcome = { kind: 'failed', usage: null };
|
|
295
|
-
}
|
|
296
|
-
if (outcome.kind === 'nothing') return;
|
|
297
|
-
if (isIdle()) {
|
|
298
|
-
warms++;
|
|
299
|
-
// A ping that found the cache gone (or failed) stops here: the next real turn arms it again.
|
|
300
|
-
if (outcome.kind === 'warm') armIdle($);
|
|
301
|
-
}
|
|
302
|
-
await reportCache($, { action: outcome.kind, contextTokens: contextTokens ?? null, usage: outcome.usage, warms });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function reportCache($: EngineInterface, body: Record<string, unknown>): Promise<void> {
|
|
306
|
-
try {
|
|
307
|
-
await callApi($, 'POST', '/console/cache', body);
|
|
308
|
-
} catch (error) {
|
|
309
|
-
$.ui.log(`conductor: console cache report failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
310
|
-
to: 'debug',
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
|
|
316
|
-
async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
|
|
215
|
+
// The running daemon's port and token, from its home's files. Throws when none runs.
|
|
216
|
+
async function endpoint($: EngineInterface): Promise<Endpoint> {
|
|
217
|
+
const home = homeOf({
|
|
218
|
+
conductorHome: await $.env.get('CONDUCTOR_HOME'),
|
|
219
|
+
home: await $.env.get('HOME'),
|
|
220
|
+
userProfile: await $.env.get('USERPROFILE'),
|
|
221
|
+
});
|
|
222
|
+
if (home === null) throw new Error(NOT_RUNNING);
|
|
223
|
+
let found: Endpoint | null;
|
|
317
224
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
} catch (error) {
|
|
322
|
-
return error instanceof Error ? error.message : String(error);
|
|
225
|
+
found = endpointOf(await $.fs.read(`${home}/port`), await $.fs.read(`${home}/token`));
|
|
226
|
+
} catch {
|
|
227
|
+
found = null;
|
|
323
228
|
}
|
|
229
|
+
if (found === null) throw new Error(NOT_RUNNING);
|
|
230
|
+
return found;
|
|
324
231
|
}
|
|
325
232
|
|
|
326
233
|
// `conduct events --follow` from this package's own build, so the mod and the CLI are one version
|
|
@@ -356,56 +263,120 @@ async function follow($: EngineInterface): Promise<void> {
|
|
|
356
263
|
}
|
|
357
264
|
}
|
|
358
265
|
} catch (error) {
|
|
359
|
-
$.ui.log(`conductor: conduct events failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
266
|
+
$.ui.log(`conductor: conduct events failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
267
|
+
to: 'debug',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
quickExits = (await $.clock.now()) - startedAt < 10_000 ? quickExits + 1 : 0;
|
|
271
|
+
await new Promise<void>((done) => $.clock.after(RESTART_MS, done));
|
|
272
|
+
}
|
|
273
|
+
$.ui.log('conductor: conduct events keeps exiting; the console polls instead', { to: 'debug' });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
|
|
277
|
+
// Either is told to the daemon, which logs it as a `console.cache` event: a fork is no turn of the
|
|
278
|
+
// transcript, so the event is the only place its cost shows.
|
|
279
|
+
// A turn that starts meanwhile has the console in use again: its end arms the next idle point, so
|
|
280
|
+
// this one stands down (no /clear under it, no ping counted against it).
|
|
281
|
+
async function onIdle($: EngineInterface): Promise<void> {
|
|
282
|
+
idleTimer = null;
|
|
283
|
+
const turnsBefore = turnsStarted;
|
|
284
|
+
const isIdle = (): boolean => turnsStarted === turnsBefore;
|
|
285
|
+
const contextTokens = (await $.session.usage()).context.tokens;
|
|
286
|
+
if (!isIdle()) return;
|
|
287
|
+
const act = idleAction({ contextTokens, warms });
|
|
288
|
+
if (act.kind === 'clear') {
|
|
289
|
+
await update($, cleared, () => act.reason);
|
|
290
|
+
if (!isIdle()) {
|
|
291
|
+
await update($, cleared, () => null);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
isClearing = true;
|
|
295
|
+
try {
|
|
296
|
+
await $.command.run({ command: 'clear' });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await update($, cleared, () => null);
|
|
299
|
+
$.ui.log(`conductor: the idle /clear failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
360
300
|
to: 'debug',
|
|
361
301
|
});
|
|
302
|
+
return;
|
|
303
|
+
} finally {
|
|
304
|
+
isClearing = false;
|
|
362
305
|
}
|
|
363
|
-
|
|
364
|
-
|
|
306
|
+
await reportCache($, { action: 'clear', contextTokens: contextTokens ?? null, reason: act.reason });
|
|
307
|
+
return;
|
|
365
308
|
}
|
|
366
|
-
|
|
309
|
+
let outcome: PingOutcome;
|
|
310
|
+
try {
|
|
311
|
+
outcome = pingOutcome(await $.model.fork({ prompt: WARM_PROMPT }), contextTokens);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
$.ui.log(`conductor: the cache ping failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
314
|
+
to: 'debug',
|
|
315
|
+
});
|
|
316
|
+
outcome = { kind: 'failed', usage: null };
|
|
317
|
+
}
|
|
318
|
+
if (outcome.kind === 'nothing') return;
|
|
319
|
+
if (isIdle()) {
|
|
320
|
+
warms++;
|
|
321
|
+
// A ping that found the cache gone (or failed) stops here: the next real turn arms it again.
|
|
322
|
+
if (outcome.kind === 'warm') armIdle($);
|
|
323
|
+
}
|
|
324
|
+
await reportCache($, { action: outcome.kind, contextTokens: contextTokens ?? null, usage: outcome.usage, warms });
|
|
367
325
|
}
|
|
368
326
|
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
body: { action: 'approve' | 'done' } | { action: 'revise'; text: string },
|
|
376
|
-
): Promise<string> {
|
|
327
|
+
// One line of the follow stream, carried out.
|
|
328
|
+
async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
329
|
+
const line = parseLine(text);
|
|
330
|
+
if (line === null) return;
|
|
331
|
+
if (line.id !== null) lastEventId = line.id;
|
|
332
|
+
const act = reaction(line);
|
|
377
333
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
run = await $.process.run(['node', cli, ...argv], { timeoutMs: ANSWER_RUN_MS });
|
|
385
|
-
} catch (error) {
|
|
386
|
-
return `${failed}: ${error instanceof Error ? error.message : String(error)}`;
|
|
334
|
+
switch (act.kind) {
|
|
335
|
+
case 'disconnected': {
|
|
336
|
+
const readAt = await $.clock.now();
|
|
337
|
+
await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
|
|
338
|
+
return;
|
|
387
339
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
340
|
+
case 'escalate':
|
|
341
|
+
// The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
|
|
342
|
+
await $.prompt.submit({ text: act.text });
|
|
343
|
+
return;
|
|
344
|
+
case 'ignore':
|
|
345
|
+
return;
|
|
346
|
+
case 'push':
|
|
347
|
+
await push($, act.message);
|
|
348
|
+
return;
|
|
349
|
+
case 'refresh':
|
|
350
|
+
if (act.toast !== null) $.ui.toast(act.toast);
|
|
351
|
+
refreshSoon($);
|
|
352
|
+
return;
|
|
353
|
+
case 'send':
|
|
354
|
+
await $.session.send({ text: act.text, to: act.to });
|
|
355
|
+
return;
|
|
401
356
|
}
|
|
402
|
-
refreshSoon($);
|
|
403
|
-
return answerText(body.action, id, result);
|
|
404
357
|
} catch (error) {
|
|
405
|
-
|
|
358
|
+
$.ui.log(`conductor: ${line.kind} failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
359
|
+
to: 'debug',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The Windows terminal's [open]: the default browser, run once (ATK-494). A Link there is an OSC 8
|
|
365
|
+
// span, which a ctrl+click in a pane opened twice: the pane and Windows Terminal each opened it.
|
|
366
|
+
// rundll32 hands the URL over whole (explorer.exe would split it at a comma) and exits 0.
|
|
367
|
+
async function openLink($: EngineInterface, href: string): Promise<void> {
|
|
368
|
+
try {
|
|
369
|
+
const run = await $.process.run(['rundll32.exe', 'url.dll,FileProtocolHandler', href]);
|
|
370
|
+
if (run.exitCode !== 0) $.ui.toast(`not opened: ${href}`);
|
|
371
|
+
} catch {
|
|
372
|
+
$.ui.toast(`not opened: ${href}`);
|
|
406
373
|
}
|
|
407
374
|
}
|
|
408
375
|
|
|
376
|
+
async function openPane($: EngineInterface): Promise<void> {
|
|
377
|
+
await $.ui.open({ focus: true, id: PANE, title: TITLE });
|
|
378
|
+
}
|
|
379
|
+
|
|
409
380
|
// `/pause`, `/unpause`, `/focus-project` and the pane's buttons: an override that ends on its own, as
|
|
410
381
|
// `conduct pause|unpause|focus` sets it; the line to show.
|
|
411
382
|
async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpause', args: string): Promise<string> {
|
|
@@ -420,69 +391,68 @@ async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpaus
|
|
|
420
391
|
}
|
|
421
392
|
}
|
|
422
393
|
|
|
423
|
-
//
|
|
424
|
-
|
|
394
|
+
// Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
|
|
395
|
+
// (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
|
|
396
|
+
// (ATK-255): Remote Control off, pushes off in the config, or the call failing.
|
|
397
|
+
async function push($: EngineInterface, message: string): Promise<void> {
|
|
398
|
+
let outcome: PushOutcome;
|
|
425
399
|
try {
|
|
426
|
-
|
|
400
|
+
outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
|
|
427
401
|
} catch (error) {
|
|
428
|
-
|
|
402
|
+
outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
|
|
403
|
+
}
|
|
404
|
+
const warning = pushWarning(outcome);
|
|
405
|
+
if (outcome.state === 'ok') warnedInactive = false;
|
|
406
|
+
if (warning !== null && !warnedInactive) {
|
|
407
|
+
warnedInactive = true;
|
|
408
|
+
$.ui.toast(warning);
|
|
429
409
|
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// The conductor's own CLI, beside the mod in its package; a mod checkout without a build has none.
|
|
433
|
-
function cliOf($: EngineInterface): string {
|
|
434
|
-
return `${$.plugin.root}/../build/cli.js`;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Through `conduct attach` (ATTACH_RUN_MS), by id: a name is unique only within its project.
|
|
438
|
-
// Without the CLI, the API, which answers within 30 s only when the worker needs no settle time
|
|
439
|
-
// and resumes quickly. The daemon refuses a second attach of a worker while one runs.
|
|
440
|
-
async function attach($: EngineInterface, id: number, name: string): Promise<string> {
|
|
441
410
|
try {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return runText(run, { failed: `could not attach to ${name}`, ok: `attached to ${name}` });
|
|
448
|
-
}
|
|
449
|
-
const result = await callApi($, 'POST', `/workers/${id}/attach`);
|
|
450
|
-
refreshSoon($);
|
|
451
|
-
return result.status === 200 ? `attached to ${name}` : `could not attach to ${name}: ${errorOf(result)}`;
|
|
411
|
+
await callApi($, 'POST', '/notify/report', {
|
|
412
|
+
channel: 'remote-control',
|
|
413
|
+
result: outcome.detail,
|
|
414
|
+
state: reportedState(outcome),
|
|
415
|
+
});
|
|
452
416
|
} catch (error) {
|
|
453
|
-
|
|
417
|
+
$.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
418
|
+
to: 'debug',
|
|
419
|
+
});
|
|
454
420
|
}
|
|
455
421
|
}
|
|
456
422
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
): Promise<void> {
|
|
462
|
-
const copied = await $.ui.copy({ surface, text });
|
|
463
|
-
$.ui.toast(copied.isCopied ? `copied: ${text}` : `not copied: ${text}`);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// The Windows terminal's [open]: the default browser, run once (ATK-494). A Link there is an OSC 8
|
|
467
|
-
// span, which a ctrl+click in a pane opened twice: the pane and Windows Terminal each opened it.
|
|
468
|
-
// rundll32 hands the URL over whole (explorer.exe would split it at a comma) and exits 0.
|
|
469
|
-
async function openLink($: EngineInterface, href: string): Promise<void> {
|
|
423
|
+
// Reads /fleet, /inbox and /notify into the state the drawings read.
|
|
424
|
+
async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
|
|
425
|
+
const readAt = await $.clock.now();
|
|
426
|
+
let next: ConductorSnapshot;
|
|
470
427
|
try {
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
428
|
+
const at = await endpoint($);
|
|
429
|
+
const [fleet, inbox, notify] = await Promise.all([
|
|
430
|
+
callApi($, 'GET', '/fleet', undefined, at),
|
|
431
|
+
callApi($, 'GET', '/inbox', undefined, at),
|
|
432
|
+
callApi($, 'GET', '/notify', undefined, at),
|
|
433
|
+
]);
|
|
434
|
+
next = snapshotOf(fleet, inbox, readAt, notify);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
next = {
|
|
437
|
+
error: error instanceof Error ? error.message : String(error),
|
|
438
|
+
inbox: [],
|
|
439
|
+
notify: null,
|
|
440
|
+
readAt,
|
|
441
|
+
workers: [],
|
|
442
|
+
};
|
|
475
443
|
}
|
|
444
|
+
await update($, snapshot, () => next);
|
|
445
|
+
return next;
|
|
476
446
|
}
|
|
477
447
|
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
448
|
+
// Many events land together (a note raises an item and a wait); one read covers them.
|
|
449
|
+
function refreshSoon($: EngineInterface): void {
|
|
450
|
+
if (isRefreshing) return;
|
|
451
|
+
isRefreshing = true;
|
|
452
|
+
$.clock.after(DEBOUNCE_MS, () => {
|
|
453
|
+
isRefreshing = false;
|
|
454
|
+
void refresh($);
|
|
455
|
+
});
|
|
486
456
|
}
|
|
487
457
|
|
|
488
458
|
async function registerCommands($: EngineInterface): Promise<void> {
|
|
@@ -527,6 +497,36 @@ async function registerCommands($: EngineInterface): Promise<void> {
|
|
|
527
497
|
});
|
|
528
498
|
}
|
|
529
499
|
|
|
500
|
+
async function reportCache($: EngineInterface, body: Record<string, unknown>): Promise<void> {
|
|
501
|
+
try {
|
|
502
|
+
await callApi($, 'POST', '/console/cache', body);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
$.ui.log(`conductor: console cache report failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
505
|
+
to: 'debug',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
|
|
511
|
+
async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
|
|
512
|
+
try {
|
|
513
|
+
const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
|
|
514
|
+
refreshSoon($);
|
|
515
|
+
return profileText(name, result, await $.clock.now());
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return error instanceof Error ? error.message : String(error);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The rule behind an item's newest action (`conduct why <item>`), in one line.
|
|
522
|
+
async function why($: EngineInterface, item: string): Promise<string> {
|
|
523
|
+
try {
|
|
524
|
+
return whyText(item, await callApi($, 'GET', `/why?item=${encodeURIComponent(item)}`));
|
|
525
|
+
} catch (error) {
|
|
526
|
+
return error instanceof Error ? error.message : String(error);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
530
|
export const register: Register = (on) => {
|
|
531
531
|
on('session.start', async ($, e, next) => {
|
|
532
532
|
const started = await next(e);
|
|
@@ -744,10 +744,13 @@ export const register: Register = (on) => {
|
|
|
744
744
|
</Box>
|
|
745
745
|
);
|
|
746
746
|
}
|
|
747
|
+
const columns = fleetColumns(snap.workers);
|
|
747
748
|
return (
|
|
748
749
|
<Box flexDirection='column'>
|
|
749
|
-
<Text bold>
|
|
750
|
-
{TITLE}
|
|
750
|
+
<Text bold wrap='truncate'>
|
|
751
|
+
{TITLE}
|
|
752
|
+
{' '}
|
|
753
|
+
<Text dimColor>{statusText(snap)}</Text>
|
|
751
754
|
</Text>
|
|
752
755
|
{snap.notify !== null && (
|
|
753
756
|
<Box columnGap={2} flexWrap='wrap'>
|
|
@@ -846,36 +849,40 @@ export const register: Register = (on) => {
|
|
|
846
849
|
onPress={async () => $.ui.toast(await override($, 'focus', group.project))}
|
|
847
850
|
/>
|
|
848
851
|
</Box>
|
|
849
|
-
{group.rows.map((w) =>
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
{w.
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
852
|
+
{group.rows.map((w) => {
|
|
853
|
+
// One line in the fleet's columns, cut to the pane's width so it never wraps.
|
|
854
|
+
const row = fleetRow(w, columns, e.props.bodyColumns, now);
|
|
855
|
+
return (
|
|
856
|
+
<Box flexDirection='column' key={`worker-${w.id}`}>
|
|
857
|
+
<Button
|
|
858
|
+
key={`row-${w.id}`}
|
|
859
|
+
onPress={() => update($, expanded, (name) => (name === w.name ? null : w.name))}
|
|
860
|
+
plain
|
|
861
|
+
>
|
|
862
|
+
<Text color={row.color}>{row.glyph}</Text>
|
|
863
|
+
<Text bold>{row.key}</Text>
|
|
864
|
+
<Text dimColor>{row.meta}</Text>
|
|
865
|
+
<Text dimColor={!row.isWait}>{row.doing}</Text>
|
|
866
|
+
<Text dimColor>{row.age}</Text>
|
|
867
|
+
</Button>
|
|
868
|
+
{open === w.name && (
|
|
869
|
+
<Box columnGap={2} flexWrap='wrap' marginLeft={2}>
|
|
870
|
+
<Button
|
|
871
|
+
key={`attach-${w.id}`}
|
|
872
|
+
label='attach'
|
|
873
|
+
onPress={async () => $.ui.toast(await attach($, w.id, w.name))}
|
|
874
|
+
/>
|
|
875
|
+
<Button key={`why-${w.id}`} label='why' onPress={async () => $.ui.toast(await why($, w.item))} />
|
|
876
|
+
<Text dimColor>
|
|
877
|
+
{w.status} · {tokens(w.spendToday)} tok today
|
|
878
|
+
{w.pr === null ? '' : ` · PR ${w.pr}`}
|
|
879
|
+
{w.waits.length > 0 ? ` · waits on ${w.waits.join(', ')}` : ''}
|
|
880
|
+
</Text>
|
|
881
|
+
</Box>
|
|
882
|
+
)}
|
|
883
|
+
</Box>
|
|
884
|
+
);
|
|
885
|
+
})}
|
|
879
886
|
</Box>
|
|
880
887
|
))}
|
|
881
888
|
<Text dimColor wrap='truncate'>
|