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

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