@zgeoff/atc 2.28.0 → 2.30.0

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/README.md CHANGED
@@ -50,7 +50,7 @@ bun add -g @zgeoff/atc
50
50
  ```
51
51
 
52
52
  atc needs at least one of the `claude`, `grok`, or `codex` CLIs on your PATH. The agent picker lists
53
- the ones it finds.
53
+ the ones it finds, and opens only when it finds more than one.
54
54
 
55
55
  ## Use
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.28.0",
3
+ "version": "2.30.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -11,11 +11,13 @@ import { makeSingleFlight } from '../shared/make-single-flight';
11
11
  import { bootDaemonClient } from './boot-daemon';
12
12
  import { buildClientMachine } from './build-client-machine';
13
13
  import { buildLeaderChords } from './build-leader-chords';
14
+ import { collectTargetPicks } from './collect-target-picks';
14
15
  import { findFuzzyScore, formatDir } from './dirs';
15
16
  import type { ProtocolMismatch } from './format-protocol-mismatch';
16
17
  import { KEY, isDown, isUp, planTextEdit } from './keys';
17
18
  import { parseDaemonEvent } from './parse-daemon-event';
18
19
  import { pickTabTarget } from './pick-tab-target';
20
+ import { resolveAgentMetadata } from './resolve-agent-metadata';
19
21
  import { SpawnPicker } from './spawn-picker';
20
22
  import { toMirrorSession } from './to-mirror-session';
21
23
  import type { MirrorSession } from './to-mirror-session';
@@ -40,12 +42,17 @@ let focusedID: string | null = null;
40
42
  let fleetCount = 0;
41
43
  let lastUsedAgent: AgentID = 'claude';
42
44
 
43
- // Read once at start, like the leader key: a newly configured backend gets
44
- // its overlay letter on the next client run.
45
- const agentMarks: Readonly<Record<AgentID, string>> = Object.fromEntries(
46
- loadConfig().gateways.map((g) => [g.id, g.mark]),
47
- );
48
-
45
+ // The readable harness label and tier-alias map per agent: the config alone
46
+ // until the daemon's `agents.list` answer arrives and re-resolves them
47
+ // whenever the overlay opens, so a backend configured after this client
48
+ // started still lists under its name.
49
+ const bootMetadata = resolveAgentMetadata(loadConfig(), {});
50
+ let agentLabels: Readonly<Record<AgentID, string>> = bootMetadata.labels;
51
+ let agentModels: Readonly<Record<AgentID, Readonly<Record<string, string>>>> = bootMetadata.models;
52
+
53
+ // How many execution targets the daemon could spawn on right now; the
54
+ // target column appears once more than one is available.
55
+ let availableTargets = 0;
49
56
  const stdout = process.stdout;
50
57
 
51
58
  // Full height: the atc status bar only exists on home/overlay screens;
@@ -250,7 +257,9 @@ function renderOverlay() {
250
257
 
251
258
  drawOverlay({
252
259
  sessions,
253
- agentMarks,
260
+ agentLabels,
261
+ agentModels,
262
+ showTarget: availableTargets >= 2,
254
263
  selected: overlaySelected,
255
264
  confirmKill,
256
265
  confirmDestroy: confirmDestroyID !== null,
@@ -262,11 +271,33 @@ function renderOverlay() {
262
271
  scheduleStatus();
263
272
  }
264
273
 
274
+ // Rereads what the overlay labels rows with: each agent's readable name and
275
+ // tier aliases, and which execution targets can take a spawn. Target
276
+ // availability moves as remote hosts wake and sleep, so the overlay refresh
277
+ // keeps the column honest rather than trusting the boot-time answer.
278
+ async function refreshAgents() {
279
+ try {
280
+ const answer = await client.sendRequest('agents.list');
281
+
282
+ const meta = resolveAgentMetadata(loadConfig(), answer);
283
+
284
+ agentLabels = meta.labels;
285
+ agentModels = meta.models;
286
+ availableTargets = collectTargetPicks(answer).filter((t) => t.available).length;
287
+ } catch {
288
+ return;
289
+ }
290
+
291
+ if (service.getSnapshot().value === 'overlay') {
292
+ renderOverlay();
293
+ }
294
+ }
295
+
265
296
  function openOverlay() {
266
297
  confirmKill = false;
267
298
  confirmDestroyID = null;
268
299
  overlayFilter = null;
269
-
300
+ void refreshAgents();
270
301
  const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
271
302
 
272
303
  overlaySelected = Math.max(0, focusedIndex);
@@ -367,6 +398,9 @@ function upsertMirror(d: Readonly<MirrorSession>) {
367
398
  existing.resumable = d.resumable;
368
399
  existing.canEject = d.canEject;
369
400
  existing.agent = d.agent;
401
+ existing.target = d.target;
402
+ existing.model = d.model;
403
+ existing.harness = d.harness;
370
404
  }
371
405
  }
372
406
 
@@ -808,6 +842,7 @@ if (restartedOnBoot) {
808
842
  }
809
843
 
810
844
  await refreshMirror();
845
+ await refreshAgents();
811
846
 
812
847
  /**
813
848
  * Asks on the terminal whether to restart a daemon that speaks another
@@ -918,8 +953,19 @@ async function restartDaemon() {
918
953
  lastUsedAgent = next.lastUsedAgent;
919
954
  client.onEvent = applyDaemonEvent;
920
955
 
956
+ // A new daemon may map aliases differently, so rows fall back to the
957
+ // config baseline until its own answer arrives; the target count goes
958
+ // back to none for the same reason, hiding the column until the new
959
+ // daemon reports what it can spawn on.
960
+ const baseline = resolveAgentMetadata(loadConfig(), {});
961
+
962
+ agentLabels = baseline.labels;
963
+ agentModels = baseline.models;
964
+ availableTargets = 0;
965
+
921
966
  await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
922
967
  await refreshMirror().catch(() => {});
968
+ await refreshAgents();
923
969
 
924
970
  service.send({ type: 'OVERLAY' });
925
971
  }
@@ -0,0 +1,155 @@
1
+ // The glyph, unread, and pin cells that lead every row, plus the one space
2
+ // before the first body column.
3
+ const PREFIX_WIDTH = 4;
4
+
5
+ // The last-event column's soft width once the rest has claimed its share;
6
+ // whatever width remains after shrinking goes back to it.
7
+ const EVENT_BASE_WIDTH = 4;
8
+
9
+ export interface OverlayColumnPlan {
10
+ readonly nameWidth: number;
11
+
12
+ // The directory column of the flat view; 0 in the grouped view and
13
+ // whenever width pressure crowds it out.
14
+ readonly dirWidth: number;
15
+
16
+ // 0 while only one target is available, so the column stays hidden.
17
+ readonly targetWidth: number;
18
+ readonly harnessWidth: number;
19
+
20
+ // 0 when no visible session runs an explicit model.
21
+ readonly modelWidth: number;
22
+ readonly lifecycleWidth: number;
23
+ readonly eventWidth: number;
24
+ }
25
+
26
+ export interface OverlayColumnInput {
27
+ // The width inside the overlay box: the box width less its borders and
28
+ // padding.
29
+ readonly innerWidth: number;
30
+
31
+ readonly grouped: boolean;
32
+
33
+ // Whether more than one execution target is available to spawn on.
34
+ readonly showTarget: boolean;
35
+
36
+ // The longest label each column would draw over the visible sessions;
37
+ // 0 for a column with nothing to show.
38
+ readonly targetMax: number;
39
+ readonly harnessMax: number;
40
+ readonly modelMax: number;
41
+ }
42
+
43
+ type ColumnKey = 'dir' | 'model' | 'event' | 'harness' | 'target' | 'name' | 'lifecycle';
44
+
45
+ // How much pressure it takes to crowd a column out, from first to go to
46
+ // last: the flat view's directory, then the model and the last event, then
47
+ // the harness, the target, and the name, with the lifecycle text held
48
+ // longest.
49
+ const SHRINK_ORDER: readonly ColumnKey[] = [
50
+ 'dir',
51
+ 'model',
52
+ 'event',
53
+ 'harness',
54
+ 'target',
55
+ 'name',
56
+ 'lifecycle',
57
+ ];
58
+
59
+ const PREFERRED: Readonly<Record<ColumnKey, number>> = {
60
+ dir: 18,
61
+ model: 14,
62
+ event: EVENT_BASE_WIDTH,
63
+ harness: 14,
64
+ target: 14,
65
+ name: 16,
66
+ lifecycle: 9,
67
+ };
68
+
69
+ // The least each column keeps while any width remains to take: the name,
70
+ // target, and harness stay legible after the directory, model, and last
71
+ // event have given way.
72
+ const FLOOR: Readonly<Record<ColumnKey, number>> = {
73
+ dir: 0,
74
+ model: 0,
75
+ event: 0,
76
+ harness: 3,
77
+ target: 3,
78
+ name: 6,
79
+ lifecycle: 9,
80
+ };
81
+
82
+ /**
83
+ * The column widths a session row draws with. Each column starts at the
84
+ * shorter of its preferred width and the longest value it would draw; while
85
+ * the row outgrows the box, the column under the least pressure that still
86
+ * holds its floor gives up a cell at a time in the order above, so the
87
+ * model and the last event shrink first and the name, target, and harness
88
+ * last. Whatever width is left over after the row fits goes to the last
89
+ * event.
90
+ */
91
+ export function planOverlayColumns(input: Readonly<OverlayColumnInput>): OverlayColumnPlan {
92
+ const widths: Record<ColumnKey, number> = {
93
+ dir: input.grouped ? 0 : PREFERRED.dir,
94
+ model: pickWidth(input.modelMax, PREFERRED.model),
95
+ event: PREFERRED.event,
96
+ harness: pickWidth(input.harnessMax, PREFERRED.harness),
97
+ target: input.showTarget ? pickWidth(input.targetMax, PREFERRED.target) : 0,
98
+ name: PREFERRED.name,
99
+ lifecycle: PREFERRED.lifecycle,
100
+ };
101
+
102
+ truncateWidthsToFit(widths, input.innerWidth, (key) => FLOOR[key]);
103
+ truncateWidthsToFit(widths, input.innerWidth, () => 0);
104
+
105
+ // Whatever width the other columns leave, less the event column's own
106
+ // gap, becomes the last event's width; a single spare cell is not enough
107
+ // to bring the column back for.
108
+ const spare = input.innerWidth - getTotalWidth({ ...widths, event: 0 });
109
+
110
+ widths.event = spare >= 2 ? spare - 1 : 0;
111
+
112
+ return {
113
+ nameWidth: widths.name,
114
+ dirWidth: widths.dir,
115
+ targetWidth: widths.target,
116
+ harnessWidth: widths.harness,
117
+ modelWidth: widths.model,
118
+ lifecycleWidth: widths.lifecycle,
119
+ eventWidth: widths.event,
120
+ };
121
+ }
122
+
123
+ // A column with nothing to draw keeps no width; otherwise it takes the
124
+ // shorter of its preferred width and its longest value.
125
+ function pickWidth(contentMax: number, preferred: number): number {
126
+ return contentMax <= 0 ? 0 : Math.min(contentMax, preferred);
127
+ }
128
+
129
+ function truncateWidthsToFit(
130
+ widths: Record<ColumnKey, number>,
131
+ innerWidth: number,
132
+ floorOf: (key: ColumnKey) => number,
133
+ ): void {
134
+ while (getTotalWidth(widths) > innerWidth) {
135
+ const giver = SHRINK_ORDER.find((key) => widths[key] > floorOf(key));
136
+
137
+ if (giver === undefined) {
138
+ return;
139
+ }
140
+
141
+ widths[giver] -= 1;
142
+ }
143
+ }
144
+
145
+ // The prefix plus every column and one space between the columns that still
146
+ // draw, so a column crowded to nothing takes its gap with it.
147
+ function getTotalWidth(widths: Readonly<Record<ColumnKey, number>>): number {
148
+ const visible = SHRINK_ORDER.filter((key) => widths[key] > 0);
149
+
150
+ return (
151
+ PREFIX_WIDTH +
152
+ visible.reduce((sum, key) => sum + widths[key], 0) +
153
+ Math.max(0, visible.length - 1)
154
+ );
155
+ }
@@ -0,0 +1,75 @@
1
+ import type { AgentID } from '../shared/agent-id';
2
+ import type { Config } from '../shared/config';
3
+ import { isRecord } from '../shared/report';
4
+
5
+ // What the overlay needs to know per agent to label a row: the readable
6
+ // name, and the tier-alias map a gateway's env configures.
7
+ export interface AgentMetadata {
8
+ readonly labels: Readonly<Record<AgentID, string>>;
9
+
10
+ // Per agent id, the alias-to-model mapping an `agents.list` entry
11
+ // carries; absent for agents that take models by name only.
12
+ readonly models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
13
+ }
14
+
15
+ const BUILT_IN_LABELS: Readonly<Record<AgentID, string>> = {
16
+ claude: 'Claude',
17
+ grok: 'Grok',
18
+ codex: 'Codex',
19
+ };
20
+
21
+ /**
22
+ * The labels and model aliases the overlay draws with, resolved from the
23
+ * config a client reads at start and the daemon's `agents.list` answer once
24
+ * it arrives. The answer wins, so a backend the daemon registers under a
25
+ * stand-in adapter still lists under its own name; the config alone covers
26
+ * the window before the answer lands.
27
+ */
28
+ export function resolveAgentMetadata(config: Config, listed: unknown): AgentMetadata {
29
+ const labels: Record<AgentID, string> = { ...BUILT_IN_LABELS };
30
+ const models: Record<AgentID, Readonly<Record<string, string>>> = {};
31
+
32
+ for (const gateway of config.gateways) {
33
+ if (isSafeKey(gateway.id)) {
34
+ labels[gateway.id] = gateway.label;
35
+ }
36
+ }
37
+
38
+ if (isRecord(listed) && Array.isArray(listed['agents'])) {
39
+ for (const entry of listed['agents']) {
40
+ if (!isRecord(entry) || typeof entry['id'] !== 'string' || !isSafeKey(entry['id'])) {
41
+ continue;
42
+ }
43
+
44
+ if (typeof entry['label'] === 'string' && entry['label'] !== '') {
45
+ labels[entry['id']] = entry['label'];
46
+ }
47
+
48
+ if (isRecord(entry['models'])) {
49
+ models[entry['id']] = pickModelAliases(entry['models']);
50
+ }
51
+ }
52
+ }
53
+
54
+ return { labels, models };
55
+ }
56
+
57
+ // The alias-to-model entries of an `agents.list` model map, keeping the
58
+ // string values a row can resolve and dropping anything else.
59
+ function pickModelAliases(values: Readonly<Record<string, unknown>>): Record<string, string> {
60
+ const aliases: Record<string, string> = {};
61
+
62
+ for (const [alias, model] of Object.entries(values)) {
63
+ if (typeof model === 'string' && isSafeKey(alias)) {
64
+ aliases[alias] = model;
65
+ }
66
+ }
67
+
68
+ return aliases;
69
+ }
70
+
71
+ // A daemon-supplied id or alias can name `__proto__`, which would change a
72
+ // map's prototype instead of adding an entry, so such keys stay out.
73
+ function isSafeKey(key: string): boolean {
74
+ return key !== '__proto__';
75
+ }