@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.
@@ -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
- glyph,
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({ plugin: 'conductor', key: 'snapshot' } as const, null);
70
- const confirming = atom({ plugin: 'conductor', key: 'confirming' } as const, null);
71
- const expanded = atom({ plugin: 'conductor', key: 'expanded' } as const, null);
72
- const hasSkill = atom({ plugin: 'conductor', key: 'hasSkill' } as const, false);
73
- const cleared = atom({ plugin: 'conductor', key: 'cleared' } as const, null);
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
- // The running daemon's port and token, from its home's files. Throws when none runs.
99
- async function endpoint($: EngineInterface): Promise<Endpoint> {
100
- const home = homeOf({
101
- conductorHome: await $.env.get('CONDUCTOR_HOME'),
102
- home: await $.env.get('HOME'),
103
- userProfile: await $.env.get('USERPROFILE'),
104
- });
105
- if (home === null) throw new Error(NOT_RUNNING);
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
- found = endpointOf(await $.fs.read(`${home}/port`), await $.fs.read(`${home}/token`));
109
- } catch {
110
- found = null;
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 = null;
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
- // Reads /fleet, /inbox and /notify into the state the drawings read.
143
- async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
144
- const readAt = await $.clock.now();
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
- // Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
215
- // (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
216
- // (ATK-255): Remote Control off, pushes off in the config, or the call failing.
217
- async function push($: EngineInterface, message: string): Promise<void> {
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
- // Starts the console's idle point over: WARM_AFTER_MS from now, unless a turn comes first.
244
- function armIdle($: EngineInterface): void {
245
- idleTimer?.cancel();
246
- idleTimer = $.clock.after(WARM_AFTER_MS, () => void onIdle($));
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 console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
255
- // Either is told to the daemon, which logs it as a `console.cache` event: a fork is no turn of the
256
- // transcript, so the event is the only place its cost shows.
257
- // A turn that starts meanwhile has the console in use again: its end arms the next idle point, so
258
- // this one stands down (no /clear under it, no ping counted against it).
259
- async function onIdle($: EngineInterface): Promise<void> {
260
- idleTimer = null;
261
- const turnsBefore = turnsStarted;
262
- const isIdle = (): boolean => turnsStarted === turnsBefore;
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
- const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
319
- refreshSoon($);
320
- return profileText(name, result, await $.clock.now());
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
- quickExits = (await $.clock.now()) - startedAt < 10_000 ? quickExits + 1 : 0;
364
- await new Promise<void>((done) => $.clock.after(RESTART_MS, done));
306
+ await reportCache($, { action: 'clear', contextTokens: contextTokens ?? null, reason: act.reason });
307
+ return;
365
308
  }
366
- $.ui.log('conductor: conduct events keeps exiting; the console polls instead', { to: 'debug' });
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
- // Jason's answer to an inbox item, as `conduct approve|revise|done` posts it; the line to show.
370
- // Approve and revise run `conduct` (ANSWER_RUN_MS), by inbox id. Without the CLI, the API, which
371
- // $.http.fetch cuts off after 30 s, possibly after the comment was posted. Done doesn't ask Linear.
372
- async function answer(
373
- $: EngineInterface,
374
- id: number,
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
- const cli = cliOf($);
379
- if (body.action !== 'done' && (await $.fs.exists(cli))) {
380
- const failed = `not ${body.action}d`;
381
- const argv = body.action === 'approve' ? ['approve', `#${id}`] : ['revise', `#${id}`, body.text];
382
- let run;
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
- refreshSoon($);
389
- return runText(run, {
390
- failed,
391
- ok: `${body.action === 'approve' ? 'approved' : 'asked for a revision of'} #${id}`,
392
- });
393
- }
394
- const at = await endpoint($);
395
- let result: ApiAnswer;
396
- try {
397
- result = await callApi($, 'POST', `/inbox/${id}/resolve`, body, at);
398
- } catch (error) {
399
- if (body.action === 'done') throw error;
400
- return `not ${body.action}d: ${error instanceof Error ? error.message : String(error)}; ${MAYBE_POSTED}`;
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
- return error instanceof Error ? error.message : String(error);
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
- // The rule behind an item's newest action (`conduct why <item>`), in one line.
424
- async function why($: EngineInterface, item: string): Promise<string> {
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
- return whyText(item, await callApi($, 'GET', `/why?item=${encodeURIComponent(item)}`));
400
+ outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
427
401
  } catch (error) {
428
- return error instanceof Error ? error.message : String(error);
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
- const cli = cliOf($);
443
- if (await $.fs.exists(cli)) {
444
- $.ui.toast(`attaching to ${name}…`);
445
- const run = await $.process.run(['node', cli, 'attach', `#${id}`], { timeoutMs: ATTACH_RUN_MS });
446
- refreshSoon($);
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
- return error instanceof Error ? error.message : String(error);
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
- async function copy(
458
- $: EngineInterface,
459
- text: string,
460
- surface: Parameters<EngineInterface['ui']['copy']>[0]['surface'],
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 run = await $.process.run(['rundll32.exe', 'url.dll,FileProtocolHandler', href]);
472
- if (run.exitCode !== 0) $.ui.toast(`not opened: ${href}`);
473
- } catch {
474
- $.ui.toast(`not opened: ${href}`);
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
- // Stages a band approve for its `y`; it lapses unless pressed within CONFIRM_MS.
479
- async function confirm($: EngineInterface, id: number): Promise<void> {
480
- await update($, confirming, () => id);
481
- $.clock.after(CONFIRM_MS, () => void update($, confirming, (staged) => (staged === id ? null : staged)));
482
- }
483
-
484
- async function openPane($: EngineInterface): Promise<void> {
485
- await $.ui.open({ focus: true, id: PANE, title: TITLE });
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} <Text dimColor>{statusText(snap)}</Text>
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
- <Box flexDirection='column' key={`worker-${w.id}`}>
851
- <Button
852
- key={`row-${w.id}`}
853
- onPress={() => update($, expanded, (name) => (name === w.name ? null : w.name))}
854
- plain
855
- >
856
- {glyph(w.status)} {w.name} {w.phase ?? ''}
857
- {w.pr === null ? '' : ` ${prLabel(w.pr)}`}{' '}
858
- <Text dimColor>
859
- {workerDoing(w)} · {age(w.updatedAt, now)} · {tokens(w.spendToday)} tok
860
- </Text>
861
- </Button>
862
- {open === w.name && (
863
- <Box columnGap={2} flexWrap='wrap' marginLeft={2}>
864
- <Button
865
- key={`attach-${w.id}`}
866
- label='attach'
867
- onPress={async () => $.ui.toast(await attach($, w.id, w.name))}
868
- />
869
- <Button key={`why-${w.id}`} label='why' onPress={async () => $.ui.toast(await why($, w.item))} />
870
- <Text dimColor>
871
- {w.status}
872
- {w.pr === null ? '' : ` · PR ${w.pr}`}
873
- {w.waits.length > 0 ? ` · waits on ${w.waits.join(', ')}` : ''}
874
- </Text>
875
- </Box>
876
- )}
877
- </Box>
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'>