@detergent-software/conductor 2.2.0-dev.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,
@@ -65,11 +66,11 @@ const ATTACH_RUN_MS = 10 * 60_000;
65
66
  const ANSWER_RUN_MS = 2 * 60_000;
66
67
  const NOT_RUNNING = 'the conductor is not running (conduct serve)';
67
68
 
68
- const snapshot = atom({ plugin: 'conductor', key: 'snapshot' } as const, null);
69
- const confirming = atom({ plugin: 'conductor', key: 'confirming' } as const, null);
70
- const expanded = atom({ plugin: 'conductor', key: 'expanded' } as const, null);
71
- const hasSkill = atom({ plugin: 'conductor', key: 'hasSkill' } as const, false);
72
- 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);
73
74
 
74
75
  // The console is the session started with CONDUCTOR_CONSOLE=1 (`conduct console` sets it): only it
75
76
  // draws the band and the pane, answers the commands, follows the stream and acts on events. Both
@@ -94,22 +95,70 @@ let isWindows = false;
94
95
 
95
96
  type Endpoint = { port: number; token: string };
96
97
 
97
- // The running daemon's port and token, from its home's files. Throws when none runs.
98
- async function endpoint($: EngineInterface): Promise<Endpoint> {
99
- const home = homeOf({
100
- conductorHome: await $.env.get('CONDUCTOR_HOME'),
101
- home: await $.env.get('HOME'),
102
- userProfile: await $.env.get('USERPROFILE'),
103
- });
104
- if (home === null) throw new Error(NOT_RUNNING);
105
- 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> {
106
106
  try {
107
- found = endpointOf(await $.fs.read(`${home}/port`), await $.fs.read(`${home}/token`));
108
- } catch {
109
- 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);
110
161
  }
111
- if (found === null) throw new Error(NOT_RUNNING);
112
- return found;
113
162
  }
114
163
 
115
164
  // One call on the daemon's API (127.0.0.1, with its token). Throws when none runs.
@@ -129,7 +178,7 @@ async function callApi(
129
178
  },
130
179
  method,
131
180
  });
132
- let parsed: unknown = null;
181
+ let parsed: unknown;
133
182
  try {
134
183
  parsed = answer.text ? JSON.parse(answer.text) : null;
135
184
  } catch {
@@ -138,116 +187,90 @@ async function callApi(
138
187
  return { body: parsed, status: answer.status };
139
188
  }
140
189
 
141
- // Reads /fleet, /inbox and /notify into the state the drawings read.
142
- async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
143
- const readAt = await $.clock.now();
144
- let next: ConductorSnapshot;
145
- try {
146
- const at = await endpoint($);
147
- const [fleet, inbox, notify] = await Promise.all([
148
- callApi($, 'GET', '/fleet', undefined, at),
149
- callApi($, 'GET', '/inbox', undefined, at),
150
- callApi($, 'GET', '/notify', undefined, at),
151
- ]);
152
- next = snapshotOf(fleet, inbox, readAt, notify);
153
- } catch (error) {
154
- next = {
155
- error: error instanceof Error ? error.message : String(error),
156
- inbox: [],
157
- notify: null,
158
- readAt,
159
- workers: [],
160
- };
161
- }
162
- await update($, snapshot, () => next);
163
- return next;
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`;
164
193
  }
165
194
 
166
- // Many events land together (a note raises an item and a wait); one read covers them.
167
- function refreshSoon($: EngineInterface): void {
168
- if (isRefreshing) return;
169
- isRefreshing = true;
170
- $.clock.after(DEBOUNCE_MS, () => {
171
- isRefreshing = false;
172
- void refresh($);
173
- });
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)));
174
199
  }
175
200
 
176
- // One line of the follow stream, carried out.
177
- async function onLine($: EngineInterface, text: string): Promise<void> {
178
- const line = parseLine(text);
179
- if (line === null) return;
180
- if (line.id !== null) lastEventId = line.id;
181
- const act = reaction(line);
182
- try {
183
- switch (act.kind) {
184
- case 'disconnected': {
185
- const readAt = await $.clock.now();
186
- await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
187
- return;
188
- }
189
- case 'escalate':
190
- // The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
191
- await $.prompt.submit({ text: act.text });
192
- return;
193
- case 'ignore':
194
- return;
195
- case 'push':
196
- await push($, act.message);
197
- return;
198
- case 'refresh':
199
- if (act.toast !== null) $.ui.toast(act.toast);
200
- refreshSoon($);
201
- return;
202
- case 'send':
203
- await $.session.send({ text: act.text, to: act.to });
204
- return;
205
- }
206
- } catch (error) {
207
- $.ui.log(`conductor: ${line.kind} failed: ${error instanceof Error ? error.message : String(error)}`, {
208
- to: 'debug',
209
- });
210
- }
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}`);
211
208
  }
212
209
 
213
- // Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
214
- // (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
215
- // (ATK-255): Remote Control off, pushes off in the config, or the call failing.
216
- async function push($: EngineInterface, message: string): Promise<void> {
217
- let outcome: PushOutcome;
218
- try {
219
- outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
220
- } catch (error) {
221
- outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
222
- }
223
- const warning = pushWarning(outcome);
224
- if (outcome.state === 'ok') warnedInactive = false;
225
- if (warning !== null && !warnedInactive) {
226
- warnedInactive = true;
227
- $.ui.toast(warning);
228
- }
229
- try {
230
- await callApi($, 'POST', '/notify/report', {
231
- channel: 'remote-control',
232
- result: outcome.detail,
233
- state: reportedState(outcome),
234
- });
235
- } catch (error) {
236
- $.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, {
237
- to: 'debug',
238
- });
239
- }
210
+ function disarmIdle(): void {
211
+ idleTimer?.cancel();
212
+ idleTimer = null;
240
213
  }
241
214
 
242
- // Starts the console's idle point over: WARM_AFTER_MS from now, unless a turn comes first.
243
- function armIdle($: EngineInterface): void {
244
- idleTimer?.cancel();
245
- idleTimer = $.clock.after(WARM_AFTER_MS, () => void onIdle($));
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;
224
+ try {
225
+ found = endpointOf(await $.fs.read(`${home}/port`), await $.fs.read(`${home}/token`));
226
+ } catch {
227
+ found = null;
228
+ }
229
+ if (found === null) throw new Error(NOT_RUNNING);
230
+ return found;
246
231
  }
247
232
 
248
- function disarmIdle(): void {
249
- idleTimer?.cancel();
250
- idleTimer = null;
233
+ // `conduct events --follow` from this package's own build, so the mod and the CLI are one version
234
+ // and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
235
+ // the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
236
+ // build, no node) is given up on, and the poll carries on alone.
237
+ async function follow($: EngineInterface): Promise<void> {
238
+ const cli = cliOf($);
239
+ if (!(await $.fs.exists(cli))) {
240
+ $.ui.log(`conductor: no ${cli}; the console polls instead of following events`, { to: 'debug' });
241
+ return;
242
+ }
243
+ for (let quickExits = 0; quickExits < 3;) {
244
+ const startedAt = await $.clock.now();
245
+ let buffer = '';
246
+ try {
247
+ // --console: while this stream is open, the daemon knows escalations have a console to wake.
248
+ const argv = [
249
+ 'node',
250
+ cli,
251
+ 'events',
252
+ '--follow',
253
+ '--console',
254
+ ...(lastEventId === null ? [] : ['--since', String(lastEventId)]),
255
+ ];
256
+ for await (const chunk of $.process.spawn({ argv })) {
257
+ if (chunk.stream !== 'stdout') continue;
258
+ buffer += chunk.text;
259
+ for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
260
+ const text = buffer.slice(0, end).trim();
261
+ buffer = buffer.slice(end + 1);
262
+ if (text !== '') await onLine($, text);
263
+ }
264
+ }
265
+ } catch (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' });
251
274
  }
252
275
 
253
276
  // The console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
@@ -301,108 +324,57 @@ async function onIdle($: EngineInterface): Promise<void> {
301
324
  await reportCache($, { action: outcome.kind, contextTokens: contextTokens ?? null, usage: outcome.usage, warms });
302
325
  }
303
326
 
304
- async function reportCache($: EngineInterface, body: Record<string, unknown>): Promise<void> {
305
- try {
306
- await callApi($, 'POST', '/console/cache', body);
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);
333
+ try {
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;
339
+ }
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;
356
+ }
307
357
  } catch (error) {
308
- $.ui.log(`conductor: console cache report failed: ${error instanceof Error ? error.message : String(error)}`, {
358
+ $.ui.log(`conductor: ${line.kind} failed: ${error instanceof Error ? error.message : String(error)}`, {
309
359
  to: 'debug',
310
360
  });
311
361
  }
312
362
  }
313
363
 
314
- // `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
315
- async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
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> {
316
368
  try {
317
- const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
318
- refreshSoon($);
319
- return profileText(name, result, await $.clock.now());
320
- } catch (error) {
321
- return error instanceof Error ? error.message : String(error);
322
- }
323
- }
324
-
325
- // `conduct events --follow` from this package's own build, so the mod and the CLI are one version
326
- // and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
327
- // the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
328
- // build, no node) is given up on, and the poll carries on alone.
329
- async function follow($: EngineInterface): Promise<void> {
330
- const cli = cliOf($);
331
- if (!(await $.fs.exists(cli))) {
332
- $.ui.log(`conductor: no ${cli}; the console polls instead of following events`, { to: 'debug' });
333
- return;
334
- }
335
- for (let quickExits = 0; quickExits < 3;) {
336
- const startedAt = await $.clock.now();
337
- let buffer = '';
338
- try {
339
- // --console: while this stream is open, the daemon knows escalations have a console to wake.
340
- const argv = [
341
- 'node',
342
- cli,
343
- 'events',
344
- '--follow',
345
- '--console',
346
- ...(lastEventId === null ? [] : ['--since', String(lastEventId)]),
347
- ];
348
- for await (const chunk of $.process.spawn({ argv })) {
349
- if (chunk.stream !== 'stdout') continue;
350
- buffer += chunk.text;
351
- for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
352
- const text = buffer.slice(0, end).trim();
353
- buffer = buffer.slice(end + 1);
354
- if (text !== '') await onLine($, text);
355
- }
356
- }
357
- } catch (error) {
358
- $.ui.log(`conductor: conduct events failed: ${error instanceof Error ? error.message : String(error)}`, {
359
- to: 'debug',
360
- });
361
- }
362
- quickExits = (await $.clock.now()) - startedAt < 10_000 ? quickExits + 1 : 0;
363
- await new Promise<void>((done) => $.clock.after(RESTART_MS, done));
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}`);
364
373
  }
365
- $.ui.log('conductor: conduct events keeps exiting; the console polls instead', { to: 'debug' });
366
374
  }
367
375
 
368
- // Jason's answer to an inbox item, as `conduct approve|revise|done` posts it; the line to show.
369
- // Approve and revise run `conduct` (ANSWER_RUN_MS), by inbox id. Without the CLI, the API, which
370
- // $.http.fetch cuts off after 30 s, possibly after the comment was posted. Done doesn't ask Linear.
371
- async function answer(
372
- $: EngineInterface,
373
- id: number,
374
- body: { action: 'approve' | 'done' } | { action: 'revise'; text: string },
375
- ): Promise<string> {
376
- try {
377
- const cli = cliOf($);
378
- if (body.action !== 'done' && (await $.fs.exists(cli))) {
379
- const failed = `not ${body.action}d`;
380
- const argv = body.action === 'approve' ? ['approve', `#${id}`] : ['revise', `#${id}`, body.text];
381
- let run;
382
- try {
383
- run = await $.process.run(['node', cli, ...argv], { timeoutMs: ANSWER_RUN_MS });
384
- } catch (error) {
385
- return `${failed}: ${error instanceof Error ? error.message : String(error)}`;
386
- }
387
- refreshSoon($);
388
- return runText(run, {
389
- failed,
390
- ok: `${body.action === 'approve' ? 'approved' : 'asked for a revision of'} #${id}`,
391
- });
392
- }
393
- const at = await endpoint($);
394
- let result: ApiAnswer;
395
- try {
396
- result = await callApi($, 'POST', `/inbox/${id}/resolve`, body, at);
397
- } catch (error) {
398
- if (body.action === 'done') throw error;
399
- return `not ${body.action}d: ${error instanceof Error ? error.message : String(error)}; ${MAYBE_POSTED}`;
400
- }
401
- refreshSoon($);
402
- return answerText(body.action, id, result);
403
- } catch (error) {
404
- return error instanceof Error ? error.message : String(error);
405
- }
376
+ async function openPane($: EngineInterface): Promise<void> {
377
+ await $.ui.open({ focus: true, id: PANE, title: TITLE });
406
378
  }
407
379
 
408
380
  // `/pause`, `/unpause`, `/focus-project` and the pane's buttons: an override that ends on its own, as
@@ -419,69 +391,68 @@ async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpaus
419
391
  }
420
392
  }
421
393
 
422
- // The rule behind an item's newest action (`conduct why <item>`), in one line.
423
- 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;
424
399
  try {
425
- return whyText(item, await callApi($, 'GET', `/why?item=${encodeURIComponent(item)}`));
400
+ outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
426
401
  } catch (error) {
427
- 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);
428
409
  }
429
- }
430
-
431
- // The conductor's own CLI, beside the mod in its package; a mod checkout without a build has none.
432
- function cliOf($: EngineInterface): string {
433
- return `${$.plugin.root}/../build/cli.js`;
434
- }
435
-
436
- // Through `conduct attach` (ATTACH_RUN_MS), by id: a name is unique only within its project.
437
- // Without the CLI, the API, which answers within 30 s only when the worker needs no settle time
438
- // and resumes quickly. The daemon refuses a second attach of a worker while one runs.
439
- async function attach($: EngineInterface, id: number, name: string): Promise<string> {
440
410
  try {
441
- const cli = cliOf($);
442
- if (await $.fs.exists(cli)) {
443
- $.ui.toast(`attaching to ${name}…`);
444
- const run = await $.process.run(['node', cli, 'attach', `#${id}`], { timeoutMs: ATTACH_RUN_MS });
445
- refreshSoon($);
446
- return runText(run, { failed: `could not attach to ${name}`, ok: `attached to ${name}` });
447
- }
448
- const result = await callApi($, 'POST', `/workers/${id}/attach`);
449
- refreshSoon($);
450
- 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
+ });
451
416
  } catch (error) {
452
- 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
+ });
453
420
  }
454
421
  }
455
422
 
456
- async function copy(
457
- $: EngineInterface,
458
- text: string,
459
- surface: Parameters<EngineInterface['ui']['copy']>[0]['surface'],
460
- ): Promise<void> {
461
- const copied = await $.ui.copy({ surface, text });
462
- $.ui.toast(copied.isCopied ? `copied: ${text}` : `not copied: ${text}`);
463
- }
464
-
465
- // The Windows terminal's [open]: the default browser, run once (ATK-494). A Link there is an OSC 8
466
- // span, which a ctrl+click in a pane opened twice: the pane and Windows Terminal each opened it.
467
- // rundll32 hands the URL over whole (explorer.exe would split it at a comma) and exits 0.
468
- 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;
469
427
  try {
470
- const run = await $.process.run(['rundll32.exe', 'url.dll,FileProtocolHandler', href]);
471
- if (run.exitCode !== 0) $.ui.toast(`not opened: ${href}`);
472
- } catch {
473
- $.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
+ };
474
443
  }
444
+ await update($, snapshot, () => next);
445
+ return next;
475
446
  }
476
447
 
477
- // Stages a band approve for its `y`; it lapses unless pressed within CONFIRM_MS.
478
- async function confirm($: EngineInterface, id: number): Promise<void> {
479
- await update($, confirming, () => id);
480
- $.clock.after(CONFIRM_MS, () => void update($, confirming, (staged) => (staged === id ? null : staged)));
481
- }
482
-
483
- async function openPane($: EngineInterface): Promise<void> {
484
- 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
+ });
485
456
  }
486
457
 
487
458
  async function registerCommands($: EngineInterface): Promise<void> {
@@ -526,6 +497,36 @@ async function registerCommands($: EngineInterface): Promise<void> {
526
497
  });
527
498
  }
528
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
+
529
530
  export const register: Register = (on) => {
530
531
  on('session.start', async ($, e, next) => {
531
532
  const started = await next(e);