@detergent-software/conductor 3.5.0-dev.16 → 3.5.0-dev.18

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,7 +1,9 @@
1
1
  {
2
2
  "name": "conductor",
3
- "version": "2.0.0",
3
+ "version": "3.5.0-dev.18",
4
4
  "description": "The conductor's plugin, loaded only into the sessions it starts: the mod (the console's band, pane and commands, and each session's report to the daemon), the console skill, the reference work skill and the verification-skill pair.",
5
- "author": { "name": "Detergent Software" },
5
+ "author": {
6
+ "name": "Detergent Software"
7
+ },
6
8
  "types": "./types/index.d.ts"
7
9
  }
@@ -655,15 +655,30 @@ export function approvedPlanItem(inbox: readonly ConductorInboxItem[], ref: stri
655
655
  // of a daemon kept in CONDUCTOR_HOME would otherwise report to ~/.conductor (ATK-525). The package's
656
656
  // own plugin folder (a `claude plugin test`, a `--plugin-dir` on a checkout) has none: its CLI sits
657
657
  // beside it, and its home is the environment's (homeOf).
658
- export function daemonOf(root: string, conductorJson: null | string): { cli: string; home: null | string } {
659
- let named: { cli?: unknown; home?: unknown } = {};
660
- try {
661
- if (conductorJson !== null) named = JSON.parse(conductorJson) as typeof named;
662
- } catch {
663
- // Not JSON: as if there were none.
664
- }
658
+ // The version is the build's whose CLI the mod runs, which the pane shows (ATK-882): conductor.json
659
+ // names it in the daemon's copy, and elsewhere the folder's .claude-plugin/plugin.json does, which
660
+ // the release keeps equal to the package's. Null when neither says.
661
+ export function daemonOf(
662
+ root: string,
663
+ conductorJson: null | string,
664
+ manifestJson: null | string = null,
665
+ ): { cli: string; home: null | string; version: null | string } {
666
+ const parsed = (json: null | string): Record<string, unknown> => {
667
+ try {
668
+ const value: unknown = json === null ? null : JSON.parse(json);
669
+ return isRecord(value) ? value : {};
670
+ } catch {
671
+ // Not JSON: as if there were none.
672
+ return {};
673
+ }
674
+ };
675
+ const named = parsed(conductorJson);
665
676
  const text = (value: unknown): null | string => (typeof value === 'string' && value !== '' ? value : null);
666
- return { cli: text(named.cli) ?? `${root}/../build/cli.js`, home: text(named.home) };
677
+ return {
678
+ cli: text(named.cli) ?? `${root}/../build/cli.js`,
679
+ home: text(named.home),
680
+ version: text(named.version) ?? text(parsed(manifestJson).version),
681
+ };
667
682
  }
668
683
 
669
684
  // The port and token files' text as an endpoint; null unless both are whole.
@@ -784,15 +799,20 @@ export function runText(
784
799
  }
785
800
 
786
801
  // GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
787
- // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
802
+ // the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null,
803
+ // and one older than ATK-882 sends no version (or an empty one), which leaves `daemonVersion` null.
804
+ // `modVersion` is the mod's to fill in (register.tsx, refresh).
788
805
  export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
789
806
  const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
790
- if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
807
+ if (workers === null) return unanswered(errorOf(fleet), readAt);
791
808
  const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
792
- if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
809
+ if (items === null) return unanswered(errorOf(inbox), readAt);
810
+ const version = isRecord(fleet.body) ? fleet.body.version : null;
793
811
  return {
812
+ daemonVersion: typeof version === 'string' && version !== '' ? version : null,
794
813
  error: null,
795
814
  inbox: items,
815
+ modVersion: null,
796
816
  notify: notify?.status === 200 ? parseNotify(notify.body) : null,
797
817
  readAt,
798
818
  workers,
@@ -813,6 +833,20 @@ export function triageText(item: string, action: 'hold' | 'queue', answer: ApiAn
813
833
  return `${action === 'queue' ? 'queued' : 'held'} ${key}: its inbox item resolves on the next tick, and ${then}`;
814
834
  }
815
835
 
836
+ // The snapshot when no daemon answered: the error, and nothing else.
837
+ export function unanswered(error: string, readAt: number): ConductorSnapshot {
838
+ return { daemonVersion: null, error, inbox: [], modVersion: null, notify: null, readAt, workers: [] };
839
+ }
840
+
841
+ // The pane header's versions (ATK-882): the mod's alone when the daemon runs the same build; else
842
+ // `mod <a>` and `daemon <b>` (`daemon ?` from a daemon too old to say), the daemon's to highlight.
843
+ // Null when the mod doesn't know its own version.
844
+ export function versionParts(mod: null | string, daemon: null | string): null | { daemon: null | string; mod: string } {
845
+ if (mod === null) return null;
846
+ if (daemon === mod) return { daemon: null, mod };
847
+ return { daemon: `daemon ${daemon ?? '?'}`, mod: `mod ${mod}` };
848
+ }
849
+
816
850
  const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
817
851
 
818
852
  // `/notify` with no profile: the profile, quiet hours, Remote Control.
@@ -53,6 +53,8 @@ import {
53
53
  takesAnswer,
54
54
  tokens,
55
55
  triageText,
56
+ unanswered,
57
+ versionParts,
56
58
  WARM_AFTER_MS,
57
59
  WARM_PROMPT,
58
60
  whyText,
@@ -238,18 +240,10 @@ async function copy(
238
240
  $.ui.toast(copied.isCopied ? `copied: ${text}` : `not copied: ${text}`);
239
241
  }
240
242
 
241
- // The conductor this plugin folder belongs to (model.ts, daemonOf). Read once per load: the file
242
- // changes only with the folder, and that reloads this module.
243
+ // The conductor this plugin folder belongs to (model.ts, daemonOf). Its CLI and home are read once
244
+ // per load: the file changes only with the folder, and that reloads this module.
243
245
  async function daemon($: EngineInterface): Promise<ReturnType<typeof daemonOf>> {
244
- named ??= (async () => {
245
- let text: null | string = null;
246
- try {
247
- text = await $.fs.read(`${$.plugin.root}/conductor.json`);
248
- } catch {
249
- // The package's own plugin folder has none.
250
- }
251
- return daemonOf($.plugin.root, text);
252
- })();
246
+ named ??= pluginFolder($);
253
247
  return named;
254
248
  }
255
249
 
@@ -359,6 +353,13 @@ async function markSkillLoaded($: EngineInterface): Promise<void> {
359
353
  await update($, cleared, () => null);
360
354
  }
361
355
 
356
+ // The mod's version, read again on each refresh (ATK-882): a refresh of the daemon's copy that
357
+ // changes only conductor.json and plugin.json may not reload this module, and a version the load
358
+ // kept would show as a mismatch with a daemon on the new build.
359
+ async function modVersion($: EngineInterface): Promise<null | string> {
360
+ return (await pluginFolder($)).version;
361
+ }
362
+
362
363
  // The console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
363
364
  // Either is told to the daemon, which logs it as a `console.cache` event: a fork is no turn of the
364
365
  // transcript, so the event is the only place its cost shows.
@@ -420,7 +421,8 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
420
421
  switch (act.kind) {
421
422
  case 'disconnected': {
422
423
  const readAt = await $.clock.now();
423
- await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
424
+ const mod = await modVersion($);
425
+ await update($, snapshot, () => ({ ...unanswered(NOT_RUNNING, readAt), modVersion: mod }));
424
426
  return;
425
427
  }
426
428
  case 'escalate':
@@ -478,6 +480,23 @@ async function override($: EngineInterface, command: 'focus' | 'pause' | 'unpaus
478
480
  }
479
481
  }
480
482
 
483
+ // The plugin folder's conductor.json and, for the package's own folder, which has none, its
484
+ // manifest's version.
485
+ async function pluginFolder($: EngineInterface): Promise<ReturnType<typeof daemonOf>> {
486
+ const readOrNull = async (path: string): Promise<null | string> => {
487
+ try {
488
+ return await $.fs.read(path);
489
+ } catch {
490
+ return null;
491
+ }
492
+ };
493
+ const [conductorJson, manifestJson] = await Promise.all([
494
+ readOrNull(`${$.plugin.root}/conductor.json`),
495
+ readOrNull(`${$.plugin.root}/.claude-plugin/plugin.json`),
496
+ ]);
497
+ return daemonOf($.plugin.root, conductorJson, manifestJson);
498
+ }
499
+
481
500
  // Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
482
501
  // (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
483
502
  // (ATK-255): Remote Control off, pushes off in the config, or the call failing.
@@ -507,9 +526,10 @@ async function push($: EngineInterface, message: string): Promise<void> {
507
526
  }
508
527
  }
509
528
 
510
- // Reads /fleet, /inbox and /notify into the state the drawings read.
529
+ // Reads /fleet, /inbox and /notify, and the mod's own version, into the state the drawings read.
511
530
  async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
512
531
  const readAt = await $.clock.now();
532
+ const mod = modVersion($);
513
533
  let next: ConductorSnapshot;
514
534
  try {
515
535
  const at = await endpoint($);
@@ -520,14 +540,9 @@ async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
520
540
  ]);
521
541
  next = snapshotOf(fleet, inbox, readAt, notify);
522
542
  } catch (error) {
523
- next = {
524
- error: error instanceof Error ? error.message : String(error),
525
- inbox: [],
526
- notify: null,
527
- readAt,
528
- workers: [],
529
- };
543
+ next = unanswered(error instanceof Error ? error.message : String(error), readAt);
530
544
  }
545
+ next = { ...next, modVersion: await mod };
531
546
  await update($, snapshot, () => next);
532
547
  return next;
533
548
  }
@@ -971,22 +986,39 @@ export const register: Register = (on) => {
971
986
  <Link href={href} key={key} label={`open ${label}`} />
972
987
  );
973
988
  if (snap === null) return <Text dimColor>Reading the conductor…</Text>;
989
+ // The mod's version after the counts, so a narrow pane cuts it before them; a daemon on another
990
+ // build gets a line of its own, highlighted, so nothing cuts it (ATK-882). With no daemon
991
+ // answering, the mod's alone.
974
992
  if (snap.error !== null) {
975
993
  return (
976
994
  <Box flexDirection='column'>
977
- <Text bold>{TITLE}</Text>
995
+ <Text bold wrap='truncate'>
996
+ {TITLE}
997
+ {snap.modVersion !== null && <Text dimColor>{` ${snap.modVersion}`}</Text>}
998
+ </Text>
978
999
  <Text>{snap.error}</Text>
979
1000
  </Box>
980
1001
  );
981
1002
  }
1003
+ const versions = versionParts(snap.modVersion, snap.daemonVersion);
1004
+ const stale = versions?.daemon ?? null;
982
1005
  const columns = fleetColumns(snap.workers);
983
1006
  return (
984
1007
  <Box flexDirection='column'>
985
1008
  <Text bold wrap='truncate'>
986
1009
  {TITLE}
987
1010
  {' '}
988
- <Text dimColor>{statusText(snap)}</Text>
1011
+ <Text dimColor>
1012
+ {statusText(snap)}
1013
+ {versions !== null && stale === null ? ` · ${versions.mod}` : ''}
1014
+ </Text>
989
1015
  </Text>
1016
+ {versions !== null && stale !== null && (
1017
+ <Text wrap='truncate'>
1018
+ <Text dimColor>{`${versions.mod} · `}</Text>
1019
+ <Text color='warning'>{stale}</Text>
1020
+ </Text>
1021
+ )}
990
1022
  {snap.notify !== null && (
991
1023
  <Box columnGap={2} flexWrap='wrap'>
992
1024
  {e.surface !== 'mobile' && 'Select' in ui ? (
@@ -28,10 +28,14 @@ export type ConductorNotify = {
28
28
  };
29
29
 
30
30
  // What the mod last read from the daemon; `error` set (and the lists empty) when none answered.
31
- // `notify` is null from a daemon older than the notifier.
31
+ // `notify` is null from a daemon older than the notifier, `daemonVersion` from one older than ATK-882
32
+ // (GET /fleet's `version`). `modVersion` is the mod's own, read from its plugin folder with each
33
+ // refresh (ATK-882); null when the folder doesn't say.
32
34
  export type ConductorSnapshot = {
35
+ daemonVersion: null | string;
33
36
  error: null | string;
34
37
  inbox: ConductorInboxItem[];
38
+ modVersion: null | string;
35
39
  notify: ConductorNotify | null;
36
40
  readAt: number;
37
41
  workers: ConductorWorker[];