@detergent-software/conductor 2.1.1-dev.1 → 2.2.0-dev.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Releases are tagged `conductor-v<version>` and staged on npm by `atk-conductor-release` (`pipelines/release-conductor.yml`), which writes each section below. `scripts/check-changelog.mjs` fails when a tag has no section here.
4
4
 
5
+ ## [2.2.0-dev.1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v2.1.1-dev.1&targetVersion=GTconductor-v2.2.0-dev.1) (2026-10-16)
6
+
7
+ ### Features
8
+
9
+ * **conductor:** fleet pane rows in columns with waits in words ([3d79d73](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/3d79d7398e45db3e2a05ea569c594b95dbcfcdc7))
10
+
11
+ ### Bug Fixes
12
+
13
+ * **conductor:** keep the fleet row's wait at narrow widths ([9ccf333](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/9ccf33371347ef43bdd0e6d08f43b6f193eb3200))
14
+
5
15
  ## [2.1.1-dev.1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v2.1.0&targetVersion=GTconductor-v2.1.1-dev.1) (2026-10-16)
6
16
 
7
17
  ### Refactoring
package/README.md CHANGED
@@ -729,7 +729,7 @@ It runs `claude -n conductor --permission-mode auto -- /conductor-console` with
729
729
  - **Other sessions** get nothing from it: no status line, and no calls to the daemon.
730
730
  - **The console**, the session started with `CONDUCTOR_CONSOLE=1` (`conduct console` starts it; see Console), reads `GET /fleet`, `GET /inbox` and `GET /notify` every 30 s and gets:
731
731
  - **the band** above the prompt while the inbox has items: one hotkey per item (a plan approves after a second press, `y`, since a digit typed into an empty prompt presses a band button; an npm OTP copies its `npm stage approve` line; the rest open the pane), and `o` for the pane;
732
- - **the pane** (`/conductor`, `o`, or opened at the start, docked as a sidebar on a fullscreen terminal of 144 columns or more): the notification profile (a picker; buttons on mobile) and Remote Control's state, the inbox with each item's actions (approve, revise, copy a command, open a link, done; on a Windows terminal, open is a button that opens a web link in the default browser once, since a ctrl+click on a link in a pane opened it twice, ATK-494), the workers by project (each project's rule has [pause] and [focus]; a row expands to attach and why), and today's spend;
732
+ - **the pane** (`/conductor`, `o`, or opened at the start, docked as a sidebar on a fullscreen terminal of 144 columns or more): the notification profile (a picker; buttons on mobile) and Remote Control's state, the inbox with each item's actions (approve, revise, copy a command, open a link, done; on a Windows terminal, open is a button that opens a web link in the default browser once, since a ctrl+click on a link in a pane opened it twice, ATK-494), the workers by project (each project's rule has [pause] and [focus]), and today's spend. The header counts the workers running and parked and the inbox in words. Each worker is one line in columns that line up across projects, cut to the pane's width so it never wraps: a glyph, the item, its phase, its PR, what it waits on in words (`PR #557 to merge (agentic-toolkit-registry)`, `npm 8.9.0-dev.1 on dev (atk)`, `login (ado)`) or else its activity, and the age. As the pane narrows, the age goes first, then the wait, then the PR and the phase (ATK-521). The glyphs are one cell wide: `●` in a turn, `◌` up between turns, `!` stuck on a prompt, `○` parked, `×` failed or gone, `·` starting. A row expands to attach and why, its tokens today, its whole PR ref and its raw wait keys;
733
733
  - **commands answered in code**, never by the model: `/conductor`, `/inbox`, `/approve <item | #id>`, `/revise <item | #id> <text>`, `/attach <item | worker>`, `/notify [<profile> [--until <when>]]`, `/pause [<project>] [--until <when>]`, `/unpause [<project>]`, `/focus-project <project> [--until <when>]` (puts its items first, until midnight without `--until`; not `/focus`, which is Claude Code's own);
734
734
  - **a check that `/conductor-console` ran**: a console started without it (by hand, as `claude -n conductor`) shows `console without its skill: run /conductor-console` in the band, and each prompt, Jason's or an escalation's, carries a line telling the model to load the skill before it answers. Running the skill (`/conductor-console`, or the model's Skill tool) clears both, until a `/clear` or a resume starts a conversation without it;
735
735
  - **a warm prompt cache while it idles** (ATK-386). The console's cache lasts an hour after its last use, and a wake after that writes the whole context again. So 55 minutes after the console's last turn, the mod sends one ping (`$.model.fork`, the console's own last request plus a one-word ask, no tools, no turn in the transcript). The API serves the ping from the cache for a tenth of a write's price, and the hour starts over. It pings again every 55 minutes until a turn comes, and each turn starts the timer over. At an idle point it runs `/clear` instead when the context is 150k or more, or after 12 pings in a row (the 13th idle point, about 12 hours after the last turn), and the band says `fresh console (cleared while idle: …)` until the next prompt loads the skill. A ping that read less than half the context from the cache (the cache was gone anyway, as under a 5-minute TTL) stops the pings until the next turn. Each ping and clear goes to `POST /console/cache`, which logs a `console.cache` event with the context size and what the ping cost. A fork isn't in the transcript, so that event is the only place its cost shows;
@@ -121,12 +121,12 @@ export type Found<T> = { error: string; found?: undefined } | { error?: undefine
121
121
 
122
122
  const BAND_LIMIT = 5;
123
123
 
124
- // `conductor 3▶ 2⏸ ✉2` for the pane's title, or undefined when no daemon answers.
124
+ // `3 running · 2 parked · inbox 2` beside the pane's title, or undefined when no daemon answers.
125
125
  export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
126
126
  if (snapshot === null || snapshot.error !== null) return undefined;
127
127
  const running = snapshot.workers.filter((w) => w.status === 'running').length;
128
128
  const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
129
- return `conductor ${running}▶ ${parked}⏸ ✉${snapshot.inbox.length}`;
129
+ return `${running} running · ${parked} parked · inbox ${snapshot.inbox.length}`;
130
130
  }
131
131
 
132
132
  // The band's buttons: a plan approves (behind a confirm), an npm OTP copies its command, the rest
@@ -159,20 +159,172 @@ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup
159
159
  }));
160
160
  }
161
161
 
162
- // A worker row's glyph: ▶ running, ⏸ parked, ✖ failed or stopped, · otherwise (launching, exited).
163
- export function glyph(status: string): string {
164
- if (status === 'running') return '▶';
165
- if (status === 'parked') return '⏸';
166
- if (status === 'failed' || status === 'stopped-unexpectedly') return '✖';
167
- return '·';
168
- }
162
+ // What a worker is up to, by its status and its session's activity (manager.ts's activityOf):
163
+ // in a turn, up between turns, stuck on a prompt (`waiting on permission`), parked on a wait,
164
+ // failed or gone (exited too), or not up yet (launching, starting).
165
+ export type WorkerState = 'failed' | 'idle' | 'parked' | 'prompt' | 'starting' | 'working';
166
+
167
+ export function workerState(w: Pick<ConductorWorker, 'activity' | 'status'>): WorkerState {
168
+ if (w.status === 'failed' || w.status === 'stopped-unexpectedly' || w.status === 'exited') return 'failed';
169
+ if (w.status === 'parked') return 'parked';
170
+ if (w.status !== 'running') return 'starting';
171
+ if (w.activity === 'working') return 'working';
172
+ if (w.activity.startsWith('idle')) return 'idle';
173
+ if (w.activity.startsWith('waiting on ')) return 'prompt';
174
+ if (w.activity === 'stopped' || w.activity === 'no session') return 'failed';
175
+ return 'starting';
176
+ }
177
+
178
+ // A row's glyph and its theme color. None has an emoji form, which Windows Terminal draws two cells
179
+ // wide (⏸ ✖ ✉ did, ATK-521); the shapes are East Asian ambiguous width, one cell outside CJK setups.
180
+ export const GLYPHS: Readonly<Record<WorkerState, { char: string; color: string }>> = {
181
+ failed: { char: '×', color: 'error' },
182
+ idle: { char: '◌', color: 'suggestion' },
183
+ parked: { char: '○', color: 'subtle' },
184
+ prompt: { char: '!', color: 'warning' },
185
+ starting: { char: '·', color: 'subtle' },
186
+ working: { char: '●', color: 'success' },
187
+ };
169
188
 
170
189
  // A worker's PR as its row shows it: `#1850`; the expanded row shows the whole ref.
171
190
  export function prLabel(pr: null | string): string {
172
191
  return pr === null ? '' : `#${pr.slice(pr.lastIndexOf('#') + 1)}`;
173
192
  }
174
193
 
175
- // What a worker waits on or does: its open wait, else the session's activity.
194
+ const PR_EVENTS: Readonly<Record<string, string>> = { merged: 'to merge' };
195
+
196
+ // A wait's condition key (conditions.ts's formatCondition) in words, the telling part first so a
197
+ // truncated row keeps it: `PR #557 to merge (agentic-toolkit-registry)`, `npm 1.2.3 on dev (atk)`.
198
+ // A kind this doesn't know, or a key it can't read, stays as it is.
199
+ export function waitLabel(condition: string, now: number): string {
200
+ const colon = condition.indexOf(':');
201
+ if (colon === -1) return condition;
202
+ const rest = condition.slice(colon + 1);
203
+ const read = (pattern: RegExp): string[] | null => pattern.exec(rest)?.slice(1) ?? null;
204
+ const short = (sha: string): string => sha.slice(0, 7);
205
+ const last = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
206
+ switch (condition.slice(0, colon)) {
207
+ case 'approval':
208
+ return 'plan approval';
209
+ case 'health': {
210
+ const [env = '', field = '', value = ''] = read(/^([^:]+):(commit|version)=(.+)$/) ?? [];
211
+ if (env) return `${env} health at ${field === 'commit' ? short(value) : value}`;
212
+ break;
213
+ }
214
+ case 'item': {
215
+ const [item = '', field = '', value = ''] = read(/^[^:]+:([^:]+):(state|label|note)=(.*)$/) ?? [];
216
+ if (item) return `${item} ${field} ${value}`.trimEnd();
217
+ break;
218
+ }
219
+ case 'login': {
220
+ const [provider = ''] = read(/^([^:]+):/) ?? [];
221
+ if (provider) return `login (${provider})`;
222
+ break;
223
+ }
224
+ case 'merge': {
225
+ const [sha = '', only] = read(/^(?:ado|github):[^@]+@([0-9a-f]+)(?::for=(.+))?$/i) ?? [];
226
+ if (sha) return `runs of ${short(sha)}${only ? ` (${only})` : ''}`;
227
+ break;
228
+ }
229
+ case 'npm': {
230
+ const [pkg = '', version = '', tag = ''] = read(/^(.+)@([^@:]+):([^:]+)$/) ?? [];
231
+ if (pkg) return `npm ${version} on ${tag} (${last(pkg)})`;
232
+ break;
233
+ }
234
+ case 'pr': {
235
+ const [host = '', repo = '', number = '', on = ''] = read(/^(ado|github):([^#]+)#(\d+):(\w+)$/) ?? [];
236
+ // An ADO PR is in the project's own repo; a GitHub one names its repo.
237
+ if (host) return `PR #${number} ${PR_EVENTS[on] ?? on}${host === 'github' ? ` (${last(repo)})` : ''}`;
238
+ break;
239
+ }
240
+ case 'run': {
241
+ const [pipeline = '', id = ''] = read(/^(?:ado|github):(.+)#(\d+):done$/) ?? [];
242
+ if (pipeline) return `run #${id} to finish (${last(pipeline).replace(/\.ya?ml$/, '')})`;
243
+ break;
244
+ }
245
+ case 'time':
246
+ if (!Number.isNaN(Date.parse(rest))) return `timer, ${remaining(rest, now)} left`;
247
+ break;
248
+ }
249
+ return condition;
250
+ }
251
+
252
+ // What a worker waits on, in words (and how many more waits), else the session's activity.
253
+ export function workerDoing(w: ConductorWorker, now: number): string {
254
+ const [first, ...more] = w.waits;
255
+ if (first === undefined) return w.activity;
256
+ return `${waitLabel(first, now)}${more.length > 0 ? ` +${more.length}` : ''}`;
257
+ }
258
+
259
+ // The fleet's column widths: the widest name, phase (at most PHASE_MAX) and PR across every project,
260
+ // so the rows line up under each other wherever they are.
261
+ export type FleetColumns = { key: number; phase: number; pr: number };
262
+
263
+ // One worker's row as the pane draws it, in segments, each with its leading space: together at
264
+ // most the pane's width, so it never wraps. `isWait` says `doing` is a wait (drawn full strength).
265
+ export type FleetRow = {
266
+ age: string;
267
+ color: string;
268
+ doing: string;
269
+ glyph: string;
270
+ isWait: boolean;
271
+ key: string;
272
+ meta: string;
273
+ };
274
+
275
+ const PHASE_MAX = 13;
276
+ const DOING_MIN = 12;
277
+ const DOING_SHORT = 6;
278
+ const AGE_WIDTH = 4;
279
+
280
+ export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns {
281
+ const widest = (texts: string[]): number => Math.max(0, ...texts.map((t) => t.length));
282
+ return {
283
+ key: widest(workers.map((w) => w.name)),
284
+ phase: Math.min(PHASE_MAX, widest(workers.map((w) => w.phase ?? ''))),
285
+ pr: widest(workers.map((w) => prLabel(w.pr))),
286
+ };
287
+ }
288
+
289
+ // The row in `width` cells: glyph, name, phase, PR, what it does or waits on, age. Where they don't
290
+ // all fit, the age goes first, then the doing text, then the PR, then the phase. Which columns show
291
+ // depends only on the fleet's widths, so every row keeps the same ones.
292
+ export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: number, now: number): FleetRow {
293
+ const key = Math.max(1, Math.min(columns.key, width - 2));
294
+ const shown = new Map<'age' | 'doing' | 'phase' | 'pr', number>([
295
+ ['phase', columns.phase],
296
+ ['pr', columns.pr],
297
+ ['doing', DOING_MIN],
298
+ ['age', AGE_WIDTH],
299
+ ]);
300
+ for (const [column, cells] of shown) if (cells === 0 && column !== 'doing') shown.delete(column);
301
+ const need = (): number => 2 + key + [...shown.values()].reduce((sum, cells) => sum + cells + 1, 0);
302
+ for (const column of ['age', 'doing', 'pr', 'phase'] as const) if (need() > width) shown.delete(column);
303
+ // The doing text comes back in what room is left, while that still shows a few words.
304
+ if (!shown.has('doing') && width - need() - 1 >= DOING_SHORT) shown.set('doing', width - need() - 1);
305
+ const cell = (column: 'age' | 'doing' | 'phase' | 'pr', text: string): string => {
306
+ const cells = shown.get(column);
307
+ if (cells === undefined) return '';
308
+ return ` ${column === 'doing' ? fit(text, cells + Math.max(0, width - need())) : fit(text, cells)}`;
309
+ };
310
+ const { char, color } = GLYPHS[workerState(w)];
311
+ return {
312
+ age: shown.has('age') ? ` ${age(w.updatedAt, now).padStart(AGE_WIDTH)}` : '',
313
+ color,
314
+ doing: cell('doing', workerDoing(w, now)),
315
+ glyph: char,
316
+ isWait: w.waits.length > 0,
317
+ key: ` ${fit(w.name, key)}`,
318
+ meta: `${cell('phase', w.phase ?? '')}${cell('pr', prLabel(w.pr))}`,
319
+ };
320
+ }
321
+
322
+ // `text` in exactly `cells` cells: padded, or cut with an ellipsis.
323
+ function fit(text: string, cells: number): string {
324
+ if (text.length <= cells) return text.padEnd(cells);
325
+ return cells <= 1 ? '…'.slice(0, cells) : `${text.slice(0, cells - 1).trimEnd()}…`.padEnd(cells);
326
+ }
327
+
176
328
  // A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
177
329
  // an https: URL or http://localhost. Anything else, such as a path, stays a Link, drawn as text.
178
330
  export function isWebLink(href: string): boolean {
@@ -184,10 +336,6 @@ export function isWebLink(href: string): boolean {
184
336
  }
185
337
  }
186
338
 
187
- export function workerDoing(w: ConductorWorker): string {
188
- return w.waits[0] ?? w.activity;
189
- }
190
-
191
339
  // The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
192
340
  export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
193
341
  const byId = /^#(\d+)$/.exec(ref.trim());
@@ -14,7 +14,8 @@ import {
14
14
  errorOf,
15
15
  findPlan,
16
16
  findWorker,
17
- glyph,
17
+ fleetColumns,
18
+ fleetRow,
18
19
  homeOf,
19
20
  idleAction,
20
21
  inboxText,
@@ -29,7 +30,6 @@ import {
29
30
  parseLine,
30
31
  type PingOutcome,
31
32
  pingOutcome,
32
- prLabel,
33
33
  profileText,
34
34
  projectGroups,
35
35
  type PushOutcome,
@@ -45,7 +45,6 @@ import {
45
45
  WARM_AFTER_MS,
46
46
  WARM_PROMPT,
47
47
  whyText,
48
- workerDoing,
49
48
  } from './model.ts';
50
49
 
51
50
  const PANE = 'conductor';
@@ -744,10 +743,13 @@ export const register: Register = (on) => {
744
743
  </Box>
745
744
  );
746
745
  }
746
+ const columns = fleetColumns(snap.workers);
747
747
  return (
748
748
  <Box flexDirection='column'>
749
- <Text bold>
750
- {TITLE} <Text dimColor>{statusText(snap)}</Text>
749
+ <Text bold wrap='truncate'>
750
+ {TITLE}
751
+ {' '}
752
+ <Text dimColor>{statusText(snap)}</Text>
751
753
  </Text>
752
754
  {snap.notify !== null && (
753
755
  <Box columnGap={2} flexWrap='wrap'>
@@ -846,36 +848,40 @@ export const register: Register = (on) => {
846
848
  onPress={async () => $.ui.toast(await override($, 'focus', group.project))}
847
849
  />
848
850
  </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
- ))}
851
+ {group.rows.map((w) => {
852
+ // One line in the fleet's columns, cut to the pane's width so it never wraps.
853
+ const row = fleetRow(w, columns, e.props.bodyColumns, now);
854
+ return (
855
+ <Box flexDirection='column' key={`worker-${w.id}`}>
856
+ <Button
857
+ key={`row-${w.id}`}
858
+ onPress={() => update($, expanded, (name) => (name === w.name ? null : w.name))}
859
+ plain
860
+ >
861
+ <Text color={row.color}>{row.glyph}</Text>
862
+ <Text bold>{row.key}</Text>
863
+ <Text dimColor>{row.meta}</Text>
864
+ <Text dimColor={!row.isWait}>{row.doing}</Text>
865
+ <Text dimColor>{row.age}</Text>
866
+ </Button>
867
+ {open === w.name && (
868
+ <Box columnGap={2} flexWrap='wrap' marginLeft={2}>
869
+ <Button
870
+ key={`attach-${w.id}`}
871
+ label='attach'
872
+ onPress={async () => $.ui.toast(await attach($, w.id, w.name))}
873
+ />
874
+ <Button key={`why-${w.id}`} label='why' onPress={async () => $.ui.toast(await why($, w.item))} />
875
+ <Text dimColor>
876
+ {w.status} · {tokens(w.spendToday)} tok today
877
+ {w.pr === null ? '' : ` · PR ${w.pr}`}
878
+ {w.waits.length > 0 ? ` · waits on ${w.waits.join(', ')}` : ''}
879
+ </Text>
880
+ </Box>
881
+ )}
882
+ </Box>
883
+ );
884
+ })}
879
885
  </Box>
880
886
  ))}
881
887
  <Text dimColor wrap='truncate'>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "2.1.1-dev.1",
3
+ "version": "2.2.0-dev.1",
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": {