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

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.
@@ -2,7 +2,7 @@
2
2
  // the status line, band and pane show, and what each event asks the mod to do. register.tsx does
3
3
  // the engine's part (only its top-level functions may take `$`); vitest tests this file in CI
4
4
  // (apps/conductor/mod/tests/model.spec.ts).
5
- import type { ConductorInboxItem, ConductorSnapshot, ConductorWorker } from '../types';
5
+ import type { ConductorInboxItem, ConductorNotify, ConductorSnapshot, ConductorWorker } from '../types';
6
6
 
7
7
  // A daemon API answer: its status and parsed JSON body.
8
8
  export type ApiAnswer = { body: unknown; status: number };
@@ -19,6 +19,33 @@ export type Reaction =
19
19
  | { kind: 'refresh'; toast: string | null }
20
20
  | { kind: 'send'; text: string; to: string };
21
21
 
22
+ // What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
23
+ // (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
24
+ // purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
25
+ // call failed (failed: it threw, was denied, or answered an error).
26
+ export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
27
+
28
+ // Said once in the console when pushes stop reaching the phone (ATK-255), by why.
29
+ export function pushWarning(outcome: PushOutcome): null | string {
30
+ switch (outcome.state) {
31
+ case 'disabled':
32
+ return "conductor: mobile pushes are off in Claude Code's config, so pushes reach this terminal only. Turn them on, or /notify away for Pushover.";
33
+ case 'failed':
34
+ return `conductor: a Remote Control push failed (${outcome.detail}). /notify away sends by Pushover instead.`;
35
+ case 'inactive':
36
+ return 'conductor: Remote Control is off, so pushes reach this terminal only. Connect Remote Control, or /notify away for Pushover.';
37
+ case 'ok':
38
+ case 'skipped':
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // The state the daemon records for a push's outcome (POST /notify/report).
44
+ export function reportedState(outcome: PushOutcome): 'failing' | 'inactive' | 'ok' | 'skipped' {
45
+ if (outcome.state === 'disabled') return 'inactive';
46
+ return outcome.state === 'failed' ? 'failing' : outcome.state;
47
+ }
48
+
22
49
  // A band entry and the act its press does.
23
50
  export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
24
51
 
@@ -121,6 +148,15 @@ export function age(iso: string, now: number): string {
121
148
  return `${Math.floor(minutes / (24 * 60))}d`;
122
149
  }
123
150
 
151
+ // The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
152
+ export function remaining(iso: string, now: number): string {
153
+ const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
154
+ if (!(minutes >= 1)) return '<1m';
155
+ if (minutes < 60) return `${minutes}m`;
156
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
157
+ return `${Math.floor(minutes / (24 * 60))}d`;
158
+ }
159
+
124
160
  // 950, 42k, 6.1M.
125
161
  export function tokens(n: number): string {
126
162
  if (n < 1000) return String(n);
@@ -169,6 +205,11 @@ export function reaction(line: StreamLine): Reaction {
169
205
  const ask = text('text');
170
206
  return { kind: 'refresh', toast: ask === null ? null : `conductor: ${line.subject ? `${line.subject} · ` : ''}${ask}` };
171
207
  }
208
+ case 'notify.failed':
209
+ case 'notify.routed':
210
+ case 'notify.sent':
211
+ // Nothing the mod draws: only notify.profile and notify.channel change the pane.
212
+ return { kind: 'ignore' };
172
213
  case 'notify.requested': {
173
214
  const message = text('message');
174
215
  return line.data.channel === 'remote-control' && message !== null ? { kind: 'push', message } : { kind: 'ignore' };
@@ -264,13 +305,85 @@ export function endpointOf(portText: string, tokenText: string): { port: number;
264
305
  return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
265
306
  }
266
307
 
267
- // GET /fleet and GET /inbox as one snapshot; `error` set when the daemon didn't answer both.
268
- export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number): ConductorSnapshot {
308
+ // GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
309
+ // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
310
+ export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
269
311
  const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
270
- if (workers === null) return { error: errorOf(fleet), inbox: [], readAt, workers: [] };
312
+ if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
271
313
  const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
272
- if (items === null) return { error: errorOf(inbox), inbox: [], readAt, workers: [] };
273
- return { error: null, inbox: items, readAt, workers };
314
+ if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
315
+ return { error: null, inbox: items, notify: notify?.status === 200 ? parseNotify(notify.body) : null, readAt, workers };
316
+ }
317
+
318
+ // GET /notify's body, kept to what the mod draws; null when it isn't one.
319
+ export function parseNotify(body: unknown): ConductorNotify | null {
320
+ if (!isRecord(body) || typeof body.profile !== 'string') return null;
321
+ const channels = isRecord(body.channels) ? body.channels : {};
322
+ const remote = isRecord(channels['remote-control']) ? channels['remote-control'] : {};
323
+ return {
324
+ held: typeof body.held === 'number' ? body.held : 0,
325
+ profile: body.profile,
326
+ profiles: strings(body.profiles),
327
+ profileUntil: typeof body.profileUntil === 'string' ? body.profileUntil : null,
328
+ quietNow: body.quietNow === true,
329
+ remoteControl: typeof remote.state === 'string' ? remote.state : 'unknown',
330
+ };
331
+ }
332
+
333
+ // What `$.tool.call` of PushNotification answered: `{ result: { pushSent, disabledReason }, text }`.
334
+ // Only a push that reached the phone is ok; Remote Control off (no_transport, or the text saying
335
+ // so) is inactive; pushes turned off in the config are disabled; the rest say nothing of the phone.
336
+ export function pushOutcome(answer: unknown): PushOutcome {
337
+ const call = isRecord(answer) ? answer : {};
338
+ if (typeof call.deny === 'string') return { detail: `refused: ${call.deny}`, state: 'failed' };
339
+ const result = isRecord(call.result) ? call.result : {};
340
+ const detail = typeof call.text === 'string' && call.text.trim() !== '' ? call.text.trim() : typeof result.message === 'string' ? result.message : '';
341
+ if (call.isError === true) return { detail: detail || 'PushNotification answered an error', state: 'failed' };
342
+ if (result.pushSent === true) return { detail, state: 'ok' };
343
+ if (result.disabledReason === 'config_off') return { detail: "Mobile push not sent (off in Claude Code's config)", state: 'disabled' };
344
+ if (result.disabledReason === 'no_transport' || /remote control inactive/i.test(detail)) {
345
+ return { detail: detail || 'Mobile push not sent (Remote Control inactive)', state: 'inactive' };
346
+ }
347
+ if (result.disabledReason === 'user_present') return { detail: 'Mobile push not sent: you were at the terminal (user_present)', state: 'skipped' };
348
+ return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
349
+ }
350
+
351
+ // `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
352
+ // midnight) or a date and time, as `conduct notify profile` reads it.
353
+ export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
354
+ const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
355
+ if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
356
+ const when = typed[2];
357
+ if (when === undefined) return { name: typed[1], until: null };
358
+ const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
359
+ let at: Date;
360
+ if (clock) {
361
+ at = new Date(now);
362
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
363
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1);
364
+ } else {
365
+ at = new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when);
366
+ }
367
+ if (Number.isNaN(at.getTime())) return { error: `--until "${when}" is not a time: a local time such as 17:00, or a date and time such as 2026-10-12T09:00` };
368
+ return { name: typed[1], until: at.toISOString() };
369
+ }
370
+
371
+ // `/notify` with no profile: the profile, quiet hours, Remote Control.
372
+ export function notifyText(notify: ConductorNotify | null, now: number): string {
373
+ if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
374
+ const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
375
+ return [
376
+ `notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
377
+ `Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
378
+ ].join('\n');
379
+ }
380
+
381
+ // POST /notify/profile's answer as the line to show.
382
+ export function profileText(name: string, answer: ApiAnswer, now: number): string {
383
+ if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
384
+ const notify = parseNotify(answer.body);
385
+ const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
386
+ return `notify profile: ${notify?.profile ?? name}${until}`;
274
387
  }
275
388
 
276
389
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -15,9 +15,16 @@ import {
15
15
  glyph,
16
16
  homeOf,
17
17
  inboxText,
18
+ notifyArgs,
19
+ notifyText,
18
20
  parseLine,
21
+ profileText,
19
22
  projectGroups,
23
+ type PushOutcome,
24
+ pushOutcome,
25
+ pushWarning,
20
26
  reaction,
27
+ reportedState,
21
28
  snapshotOf,
22
29
  spendToday,
23
30
  statusText,
@@ -47,6 +54,8 @@ let isConsole = false;
47
54
  let isRefreshing = false;
48
55
  // The newest event id the stream showed, so a restarted `conduct events` picks up after it.
49
56
  let lastEventId: null | number = null;
57
+ // Set once the console has said that pushes no longer reach the phone; a push that does clears it.
58
+ let warnedInactive = false;
50
59
 
51
60
  type Endpoint = { port: number; token: string };
52
61
 
@@ -85,16 +94,20 @@ async function callApi($: EngineInterface, method: 'GET' | 'POST', path: string,
85
94
  return { body: parsed, status: answer.status };
86
95
  }
87
96
 
88
- // Reads /fleet and /inbox into the state the drawings read, and the status line.
97
+ // Reads /fleet, /inbox and /notify into the state the drawings read, and the status line.
89
98
  async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
90
99
  const readAt = await $.clock.now();
91
100
  let next: ConductorSnapshot;
92
101
  try {
93
102
  const at = await endpoint($);
94
- const [fleet, inbox] = await Promise.all([callApi($, 'GET', '/fleet', undefined, at), callApi($, 'GET', '/inbox', undefined, at)]);
95
- next = snapshotOf(fleet, inbox, readAt);
103
+ const [fleet, inbox, notify] = await Promise.all([
104
+ callApi($, 'GET', '/fleet', undefined, at),
105
+ callApi($, 'GET', '/inbox', undefined, at),
106
+ callApi($, 'GET', '/notify', undefined, at),
107
+ ]);
108
+ next = snapshotOf(fleet, inbox, readAt, notify);
96
109
  } catch (error) {
97
- next = { error: error instanceof Error ? error.message : String(error), inbox: [], readAt, workers: [] };
110
+ next = { error: error instanceof Error ? error.message : String(error), inbox: [], notify: null, readAt, workers: [] };
98
111
  }
99
112
  await update($, snapshot, () => next);
100
113
  $.ui.status(statusText(next));
@@ -121,7 +134,7 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
121
134
  switch (act.kind) {
122
135
  case 'disconnected': {
123
136
  const readAt = await $.clock.now();
124
- await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], readAt, workers: [] }));
137
+ await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
125
138
  $.ui.status(undefined);
126
139
  return;
127
140
  }
@@ -132,7 +145,7 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
132
145
  case 'ignore':
133
146
  return;
134
147
  case 'push':
135
- await $.tool.call({ message: act.message, status: 'proactive', tool: 'PushNotification' });
148
+ await push($, act.message);
136
149
  return;
137
150
  case 'refresh':
138
151
  if (act.toast !== null) $.ui.toast(act.toast);
@@ -147,6 +160,40 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
147
160
  }
148
161
  }
149
162
 
163
+ // Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
164
+ // (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
165
+ // (ATK-255): Remote Control off, pushes off in the config, or the call failing.
166
+ async function push($: EngineInterface, message: string): Promise<void> {
167
+ let outcome: PushOutcome;
168
+ try {
169
+ outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
170
+ } catch (error) {
171
+ outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
172
+ }
173
+ const warning = pushWarning(outcome);
174
+ if (outcome.state === 'ok') warnedInactive = false;
175
+ if (warning !== null && !warnedInactive) {
176
+ warnedInactive = true;
177
+ $.ui.toast(warning);
178
+ }
179
+ try {
180
+ await callApi($, 'POST', '/notify/report', { channel: 'remote-control', result: outcome.detail, state: reportedState(outcome) });
181
+ } catch (error) {
182
+ $.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
183
+ }
184
+ }
185
+
186
+ // `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
187
+ async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
188
+ try {
189
+ const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
190
+ refreshSoon($);
191
+ return profileText(name, result, await $.clock.now());
192
+ } catch (error) {
193
+ return error instanceof Error ? error.message : String(error);
194
+ }
195
+ }
196
+
150
197
  // `conduct events --follow` from this package's own build, so the mod and the CLI are one version
151
198
  // and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
152
199
  // the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
@@ -222,6 +269,7 @@ async function registerCommands($: EngineInterface): Promise<void> {
222
269
  await $.command.register({ argumentHint: '<item | #id>', description: "Approve an item's plan (posts approve as you)", name: 'approve' });
223
270
  await $.command.register({ argumentHint: '<item | #id> <what to change>', description: 'Ask for a revised plan (posts revise: … as you)', name: 'revise' });
224
271
  await $.command.register({ argumentHint: '<item | worker>', description: 'Wake a parked worker and open it in a tab', name: 'attach' });
272
+ await $.command.register({ argumentHint: '[<profile> [--until <when>]]', description: 'Show or switch the notification profile (default, away, focus …)', name: 'notify' });
225
273
  }
226
274
 
227
275
  export const register: Register = (on) => {
@@ -279,6 +327,15 @@ export const register: Register = (on) => {
279
327
  return { text: worker.found ? await attach($, worker.found.id, worker.found.name) : worker.error };
280
328
  });
281
329
 
330
+ on('command.run', { command: 'notify' }, async ($, e) => {
331
+ if (e.args.trim() !== '') {
332
+ const typed = notifyArgs(e.args, await $.clock.now());
333
+ return { text: 'error' in typed ? typed.error : await switchProfile($, typed.name, typed.until) };
334
+ }
335
+ const snap = await refresh($);
336
+ return { text: snap.error ?? notifyText(snap.notify, await $.clock.now()) };
337
+ });
338
+
282
339
  // The band above the prompt, in the console, while the inbox has items. A bare digit typed into
283
340
  // an empty prompt presses a band Button, so a plan's approve takes a second press (`y`).
284
341
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
@@ -331,9 +388,10 @@ export const register: Register = (on) => {
331
388
  );
332
389
  });
333
390
 
334
- // The pane: the inbox first, then the fleet by project, then today's spend.
391
+ // The pane: the notification profile, the inbox, then the fleet by project, then today's spend.
335
392
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
336
- const { Box, Button, Link, Text } = $.ui.resolve(e);
393
+ const ui = $.ui.resolve(e);
394
+ const { Box, Button, Link, Text } = ui;
337
395
  const snap = await read($, snapshot);
338
396
  const open = await read($, expanded);
339
397
  const now = await $.clock.now();
@@ -352,6 +410,32 @@ export const register: Register = (on) => {
352
410
  <Text bold>
353
411
  {TITLE} <Text dimColor>{statusText(snap)}</Text>
354
412
  </Text>
413
+ {snap.notify !== null && (
414
+ <Box columnGap={2} flexWrap="wrap">
415
+ {e.surface !== 'mobile' && 'Select' in ui ? (
416
+ <ui.Select
417
+ key="notify-profile"
418
+ label="notify "
419
+ onSelect={async (name) => $.ui.toast(await switchProfile($, name))}
420
+ options={snap.notify.profiles.map((p) => ({ value: p }))}
421
+ value={snap.notify.profile}
422
+ />
423
+ ) : (
424
+ snap.notify.profiles.map((p) => (
425
+ <Button
426
+ key={`profile-${p}`}
427
+ label={p === snap.notify?.profile ? `notify: ${p}` : p}
428
+ onPress={async () => $.ui.toast(await switchProfile($, p))}
429
+ variant={p === snap.notify?.profile ? 'primary' : undefined}
430
+ />
431
+ ))
432
+ )}
433
+ <Text dimColor>
434
+ Remote Control: {snap.notify.remoteControl}
435
+ {snap.notify.quietNow ? ` · quiet hours, ${snap.notify.held} held` : ''}
436
+ </Text>
437
+ </Box>
438
+ )}
355
439
  <Text dimColor wrap="truncate">
356
440
  {rule(`Inbox (${snap.inbox.length})`)}
357
441
  </Text>
@@ -24,10 +24,22 @@ export type ConductorInboxItem = {
24
24
  text: string;
25
25
  };
26
26
 
27
+ // GET /notify as the mod draws it: the profile, the others to switch to, Remote Control's state.
28
+ export type ConductorNotify = {
29
+ held: number;
30
+ profile: string;
31
+ profiles: string[];
32
+ profileUntil: string | null;
33
+ quietNow: boolean;
34
+ remoteControl: string;
35
+ };
36
+
27
37
  // What the mod last read from the daemon; `error` set (and the lists empty) when none answered.
38
+ // `notify` is null from a daemon older than the notifier.
28
39
  export type ConductorSnapshot = {
29
40
  error: string | null;
30
41
  inbox: ConductorInboxItem[];
42
+ notify: ConductorNotify | null;
31
43
  readAt: number;
32
44
  workers: ConductorWorker[];
33
45
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "1.0.0-dev.6",
3
+ "version": "1.0.0-dev.8",
4
4
  "description": "Conductor: the local daemon that runs conducted projects' work loops (conduct serve) and its conduct CLI.",
5
5
  "type": "module",
6
6
  "bin": {