@zgeoff/atc 2.27.0 → 2.29.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.
@@ -19,9 +19,19 @@ import { KEY, planTextEdit } from './keys';
19
19
  import { pickRefusalStep } from './pick-refusal-step';
20
20
  import { resolvePathInput } from './resolve-path-input';
21
21
  import { resolveWorkspaceRoot } from './resolve-workspace-root';
22
- import { ansi, cols, drawPicker } from './ui';
23
-
24
- type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
22
+ import { splitToWidth } from './split-to-width';
23
+ import { ansi, cols, drawPicker, getPickerWidth } from './ui';
24
+
25
+ type PickerStep =
26
+ | 'agent'
27
+ | 'dir'
28
+ | 'source'
29
+ | 'ref'
30
+ | 'target'
31
+ | 'confirm'
32
+ | 'name'
33
+ | 'prompt'
34
+ | 'spawned';
25
35
 
26
36
  // What the flow borrows from the client that owns the screen, the daemon
27
37
  // connection and the fleet mirror.
@@ -114,10 +124,14 @@ type SourceStepAction =
114
124
  * optional first prompt. The sources are the ones the daemon offers, in
115
125
  * its order, and Tab cycles them; a daemon that offers none gets the local
116
126
  * directory flow alone. A source of directories is followed by the
117
- * execution target when the daemon has more than one. A source of git
127
+ * execution target when more than one target can run it. A source of git
118
128
  * repositories opens the target first, then its candidates, the ref, and a
119
129
  * confirm screen with the destination the checkout lands in, so every
120
130
  * request about the repository is made for the target the spawn runs on.
131
+ * A choice of agent or target with one row the source can use is no
132
+ * choice: the flow takes that row and goes on, and Esc from the step after
133
+ * it goes back past it. A daemon that lists targets none of which can run
134
+ * the source shows the target step with the reason.
121
135
  * Typed input a source does not match against its candidates is read by
122
136
  * the source, then by the others in order. Every path out of the flow
123
137
  * either attaches the new session or returns the client to the screen it
@@ -145,6 +159,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
145
159
  // edit or a fresh install lands without a client restart.
146
160
  private picks: AgentPick[] = [];
147
161
 
162
+ // Whether the agent step waits on the daemon's targets and sources once
163
+ // an agent is chosen.
164
+ private sourcesPending = false;
165
+
148
166
  // The directories the directory step lists, and the label each shows.
149
167
  private dirs: string[] = [];
150
168
 
@@ -170,6 +188,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
170
188
 
171
189
  private target: TargetPick | null = null;
172
190
 
191
+ // Why the flow offers no target although the daemon lists some, or null
192
+ // when it offers some or the daemon lists none.
193
+ private targetGap: string | null = null;
194
+
173
195
  // The git source's candidates and the scope they were listed under, or
174
196
  // the notice that stands in for a list the daemon could not give, with
175
197
  // the source and target they were listed for. A listing runs behind the
@@ -226,6 +248,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
226
248
 
227
249
  private pending: PendingRequest | null = null;
228
250
 
251
+ // A session the daemon started with warnings, and those warnings, shown
252
+ // before the flow attaches it.
253
+ private spawned: TMirror | null = null;
254
+
255
+ private spawnWarnings: readonly string[] = [];
256
+
229
257
  private requestSeq = 0;
230
258
 
231
259
  // The flow's generation. Opening the flow and every way out of it moves
@@ -249,7 +277,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
249
277
  this.input = '';
250
278
  this.sources = null;
251
279
  this.sourceIndex = -1;
280
+ this.sourcesPending = false;
252
281
  this.target = null;
282
+ this.targetGap = null;
253
283
  this.repo = null;
254
284
  this.ref = null;
255
285
  this.sourceDraft = '';
@@ -275,12 +305,26 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
275
305
  this.pending = null;
276
306
 
277
307
  process.stdout.write(ansi.clear);
308
+
309
+ // One installed agent is no choice to make, so the flow goes on with it.
310
+ if (this.picks.length === 1) {
311
+ void this.openFirstSource();
312
+
313
+ return;
314
+ }
315
+
278
316
  this.render();
279
317
  }
280
318
 
281
319
  applyKey(buf: Buffer) {
282
320
  this.refusal = null;
283
321
 
322
+ if (this.step === 'spawned') {
323
+ this.applySpawnedKey(buf);
324
+
325
+ return;
326
+ }
327
+
284
328
  const edit = planTextEdit(buf, this.input, {
285
329
  isLeaderKey: this.deps.isLeaderKey,
286
330
  moves:
@@ -303,6 +347,17 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
303
347
  return;
304
348
  }
305
349
 
350
+ // The agent step waiting on the daemon's targets and sources takes Esc
351
+ // and the leader, and every other key waits for the answer.
352
+ if (
353
+ this.step === 'agent' &&
354
+ this.sourcesPending &&
355
+ edit.kind !== 'cancel' &&
356
+ edit.kind !== 'leader'
357
+ ) {
358
+ return;
359
+ }
360
+
306
361
  const tab = buf.length === 1 && buf[0] === KEY.tab;
307
362
 
308
363
  // A tab to a source of directories waits for its listing on the step it
@@ -365,10 +420,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
365
420
 
366
421
  // An empty menu means every configured binary is missing, so the hint
367
422
  // carries the fix instead of the movement keys.
368
- const hint =
369
- this.picks.length === 0
370
- ? 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel'
371
- : '↑↓ move · ⏎ select · esc cancel';
423
+ let hint = '↑↓ move · ⏎ select · esc cancel';
424
+
425
+ if (this.picks.length === 0) {
426
+ hint =
427
+ 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel';
428
+ } else if (this.sourcesPending) {
429
+ hint = 'reading targets and sources… · esc cancel';
430
+ }
372
431
 
373
432
  drawPicker({
374
433
  title: `${verb}: agent`,
@@ -387,6 +446,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
387
446
  this.renderTargetStep(verb);
388
447
  } else if (this.step === 'confirm') {
389
448
  this.renderConfirmStep();
449
+ } else if (this.step === 'spawned') {
450
+ drawPicker({
451
+ title: `${verb}: started`,
452
+ items: this.spawnWarnings.flatMap((w) => splitToWidth(w, getPickerWidth() - 4)),
453
+ selected: -1,
454
+ input: '',
455
+ hint: '⏎ attach · esc back',
456
+ });
390
457
  } else if (this.step === 'name') {
391
458
  const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
392
459
 
@@ -458,13 +525,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
458
525
 
459
526
  const git = this.isGitFlow();
460
527
  const what = git ? 'the repository' : formatDir(this.dir);
528
+ let hint = `where ${what} runs · ↑↓ move · ⏎ select · esc back`;
529
+
530
+ // A step with no target the source can run on says what is missing.
531
+ if (this.collectEligibleTargets().length === 0) {
532
+ const gap = this.targetGap ?? `none of these targets can run ${what}`;
533
+
534
+ hint = `${gap} · set targets in config.json · esc back`;
535
+ }
461
536
 
462
537
  drawPicker({
463
538
  title: `${verb}: target`,
464
539
  items: this.targets.map((t) => formatTargetPick(t)),
465
540
  selected: this.selected,
466
541
  input: '',
467
- hint: this.refusal ?? `where ${what} runs · ↑↓ move · ⏎ select · esc back`,
542
+ hint: this.refusal ?? hint,
468
543
  dimmed: new Set(
469
544
  this.targets.flatMap((t, i) =>
470
545
  t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
@@ -636,11 +711,19 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
636
711
 
637
712
  if (this.step === 'dir') {
638
713
  this.openAgentStep();
639
- } else if (this.step === 'source') {
714
+
715
+ return;
716
+ }
717
+
718
+ if (this.step === 'source') {
640
719
  this.sourceDraft = typed;
641
720
 
642
721
  this.applySourceCancel();
643
- } else if (this.step === 'ref') {
722
+
723
+ return;
724
+ }
725
+
726
+ if (this.step === 'ref') {
644
727
  this.input = this.repo?.label ?? '';
645
728
  this.repo = null;
646
729
  this.step = 'source';
@@ -654,10 +737,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
654
737
  if (this.isGitFlow()) {
655
738
  this.input = this.buildDestination();
656
739
  this.step = 'confirm';
657
- } else {
658
- this.openTargetStep(() => {
659
- this.step = 'dir';
660
- });
740
+ } else if (!this.openTargetStep()) {
741
+ this.step = 'dir';
661
742
  }
662
743
  } else {
663
744
  this.input = this.name;
@@ -668,20 +749,33 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
668
749
  this.render();
669
750
  }
670
751
 
752
+ // Goes back to the agent step, or leaves the flow when one installed
753
+ // agent left no agent step to go back to.
671
754
  private openAgentStep() {
755
+ if (this.picks.length === 1) {
756
+ this.quitFlow();
757
+
758
+ return;
759
+ }
760
+
672
761
  this.selected = Math.max(
673
762
  0,
674
763
  this.picks.findIndex((p) => p.agent === this.agent),
675
764
  );
676
765
 
766
+ this.sourcesPending = false;
677
767
  this.step = 'agent';
768
+
769
+ process.stdout.write(ansi.clear);
770
+ this.render();
678
771
  }
679
772
 
680
- // Leaves a git source step backwards: to the target step when the daemon
681
- // has more than one target, else to the agent.
773
+ // Leaves a git source step backwards: to the target step when the spawn
774
+ // has one, else to the agent.
682
775
  private applySourceCancel() {
683
- if (this.targets.length >= 2) {
684
- this.openTargetStep(() => {});
776
+ if (this.openTargetStep()) {
777
+ process.stdout.write(ansi.clear);
778
+ this.render();
685
779
 
686
780
  return;
687
781
  }
@@ -707,8 +801,6 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
707
801
 
708
802
  if (index === -1) {
709
803
  this.openAgentStep();
710
- process.stdout.write(ansi.clear);
711
- this.render();
712
804
 
713
805
  return;
714
806
  }
@@ -811,9 +903,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
811
903
  this.dir = dir;
812
904
  this.input = '';
813
905
 
814
- this.openTargetStep(() => {
906
+ if (!this.openTargetStep()) {
815
907
  this.step = 'name';
816
- });
908
+ }
817
909
 
818
910
  process.stdout.write(ansi.clear);
819
911
  this.render();
@@ -998,8 +1090,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
998
1090
  }
999
1091
 
1000
1092
  // Moves the flow to a source of git repositories, carrying the typed text
1001
- // across: the target step first when the daemon has more than one target
1002
- // and the flow had no repository target yet, then the source step, which
1093
+ // across: the target step first when the spawn has one and the flow had
1094
+ // no repository target yet, then the source step, which
1003
1095
  // acts as asked once it opens.
1004
1096
  private openGitSource(index: number, draft: string, action: SourceStepAction) {
1005
1097
  const fromGit = this.isGitFlow();
@@ -1015,16 +1107,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1015
1107
  return;
1016
1108
  }
1017
1109
 
1018
- if (this.targets.length >= 2) {
1019
- this.openTargetStep(() => {});
1110
+ if (this.openTargetStep()) {
1020
1111
  process.stdout.write(ansi.clear);
1021
1112
  this.render();
1022
1113
 
1023
1114
  return;
1024
1115
  }
1025
1116
 
1026
- this.target = this.targets[0] ?? null;
1027
-
1028
1117
  this.openSourceStep();
1029
1118
  }
1030
1119
 
@@ -1569,19 +1658,22 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1569
1658
  return this.target ?? this.targets.find((t) => t.isDefault) ?? null;
1570
1659
  }
1571
1660
 
1572
- // Opens the target step when the daemon has more than one target, and
1573
- // the fallback step otherwise. The chosen target is selected, else for a
1574
- // repository the default when it takes a workspace and the first target
1575
- // that does when it does not, else the default.
1576
- private openTargetStep(fallback: () => void) {
1577
- if (this.targets.length < 2) {
1578
- // The one target is the spawn's, default or not, so the spawn names it
1579
- // and its provider decides whether a directory runs in place.
1580
- this.target = this.targets[0] ?? null;
1661
+ // Opens the target step when more than one target can run the flow's
1662
+ // source, or none can although the daemon lists targets, and answers
1663
+ // whether it did. The chosen target is selected, else for a repository
1664
+ // the default when it takes a workspace and the first target that does
1665
+ // when it does not, else the default.
1666
+ private openTargetStep(): boolean {
1667
+ const eligible = this.collectEligibleTargets();
1581
1668
 
1582
- fallback();
1669
+ if (eligible.length === 1 || (this.targets.length === 0 && this.targetGap === null)) {
1670
+ // The one target that can run the source is the spawn's, default or
1671
+ // not, so the spawn names it and its provider decides whether a
1672
+ // directory runs in place. A daemon that lists no targets runs the
1673
+ // spawn on its default.
1674
+ this.target = eligible[0] ?? null;
1583
1675
 
1584
- return;
1676
+ return false;
1585
1677
  }
1586
1678
 
1587
1679
  const preferred =
@@ -1599,6 +1691,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1599
1691
 
1600
1692
  this.input = '';
1601
1693
  this.step = 'target';
1694
+
1695
+ return true;
1696
+ }
1697
+
1698
+ // The targets the flow's source can run on.
1699
+ private collectEligibleTargets(): TargetPick[] {
1700
+ const git = this.isGitFlow();
1701
+
1702
+ return this.targets.filter((t) => findTargetRefusal(t, git) === null);
1602
1703
  }
1603
1704
 
1604
1705
  // Reads the daemon's targets and sources once the agent is chosen, and
@@ -1608,6 +1709,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1608
1709
  const generation = this.generation;
1609
1710
  let listed: Readonly<Record<string, unknown>> = {};
1610
1711
 
1712
+ this.sourcesPending = true;
1713
+
1714
+ this.render();
1715
+
1611
1716
  try {
1612
1717
  listed = await this.deps.sendRequest('agents.list');
1613
1718
  } catch {}
@@ -1619,12 +1724,20 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1619
1724
  // An agent that takes its credential from impd's broker runs only on a
1620
1725
  // target that reaches the broker.
1621
1726
  const brokered = isBrokerAgent(listed['agents'], this.agent);
1622
- const picks = collectTargetPicks(listed).filter((t) => !brokered || t.brokerAuth);
1727
+ const listedTargets = collectTargetPicks(listed);
1728
+ const picks = listedTargets.filter((t) => !brokered || t.brokerAuth);
1623
1729
 
1624
1730
  // An adopt resumes a session from its history on this host, which a
1625
1731
  // fresh checkout elsewhere does not hold, so it is offered only the
1626
1732
  // targets that run here.
1627
1733
  this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
1734
+ this.targetGap = null;
1735
+
1736
+ if (listedTargets.length > 0 && picks.length === 0) {
1737
+ this.targetGap = `no target reaches the credential broker ${this.agent} needs`;
1738
+ } else if (listedTargets.length > 0 && this.targets.length === 0) {
1739
+ this.targetGap = 'no target on this host can adopt a session';
1740
+ }
1628
1741
 
1629
1742
  const announced = parseAnnouncedSources(listed['sources']);
1630
1743
  const index = this.resume ? announced.findIndex((source) => source.kind === 'path') : 0;
@@ -1714,9 +1827,49 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1714
1827
  this.stopFlow();
1715
1828
  this.deps.upsertMirror(session);
1716
1829
 
1830
+ const warnings = spawned.answer['warnings'];
1831
+
1832
+ // A workspace that left changes behind says so before the session's
1833
+ // screen takes over.
1834
+ if (Array.isArray(warnings) && warnings.length > 0) {
1835
+ this.spawned = session;
1836
+ this.spawnWarnings = warnings.filter((w): w is string => typeof w === 'string');
1837
+ this.step = 'spawned';
1838
+
1839
+ process.stdout.write(ansi.clear);
1840
+ this.render();
1841
+
1842
+ return;
1843
+ }
1844
+
1717
1845
  await this.deps.attach(session.id);
1718
1846
  }
1719
1847
 
1848
+ // The started step takes Enter to attach the session and Esc or the
1849
+ // leader to return to the screen the flow came from; the session runs
1850
+ // either way.
1851
+ private applySpawnedKey(buf: Buffer) {
1852
+ const edit = planTextEdit(buf, '', { isLeaderKey: this.deps.isLeaderKey, moves: false });
1853
+
1854
+ if (edit.kind !== 'submit' && edit.kind !== 'cancel' && edit.kind !== 'leader') {
1855
+ return;
1856
+ }
1857
+
1858
+ const session = this.spawned;
1859
+
1860
+ this.spawned = null;
1861
+ this.spawnWarnings = [];
1862
+ this.step = 'agent';
1863
+
1864
+ if (edit.kind === 'submit' && session !== null) {
1865
+ void this.deps.attach(session.id);
1866
+
1867
+ return;
1868
+ }
1869
+
1870
+ this.deps.toBase();
1871
+ }
1872
+
1720
1873
  // A refused git workspace spawn returns to the step that can fix it. Any
1721
1874
  // other refusal keeps the picker on the step that sent it, with the
1722
1875
  // entered text back in the input, so the user can fix the cause and retry
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Splits text into rows of at most `width` characters, breaking at spaces
3
+ * and counting a grapheme cluster as one character. A word wider than a
4
+ * row breaks at the row's edge, never inside a character.
5
+ */
6
+ export function splitToWidth(text: string, width: number): string[] {
7
+ if (width <= 0) {
8
+ return [text];
9
+ }
10
+
11
+ const segmenter = new Intl.Segmenter();
12
+
13
+ const rows: string[] = [];
14
+ let row: string[] = [];
15
+
16
+ for (const word of text.split(' ')) {
17
+ let rest = Array.from(segmenter.segment(word), (part) => part.segment);
18
+
19
+ while (rest.length > width) {
20
+ if (row.length > 0) {
21
+ rows.push(row.join(''));
22
+
23
+ row = [];
24
+ }
25
+
26
+ rows.push(rest.slice(0, width).join(''));
27
+
28
+ rest = rest.slice(width);
29
+ }
30
+
31
+ if (row.length === 0) {
32
+ row = rest;
33
+ } else if (row.length + 1 + rest.length <= width) {
34
+ row = [...row, ' ', ...rest];
35
+ } else {
36
+ rows.push(row.join(''));
37
+
38
+ row = rest;
39
+ }
40
+ }
41
+
42
+ if (row.length > 0) {
43
+ rows.push(row.join(''));
44
+ }
45
+
46
+ return rows;
47
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { SessionState } from '../protocol/session-state';
3
3
  import type { AgentID } from '../shared/agent-id';
4
+ import { isRecord } from '../shared/report';
4
5
  import { toAgentID } from '../shared/to-agent-id';
5
6
 
6
7
  export interface MirrorSession {
@@ -20,8 +21,24 @@ export interface MirrorSession {
20
21
  canEject: boolean;
21
22
  agent: AgentID;
22
23
  parent: string | null;
24
+
25
+ // The execution target the session runs on, from the descriptor's
26
+ // locator; 'local' when the descriptor carries none.
27
+ target: string;
28
+
29
+ // The model the session was spawned with, as the daemon stores it: null
30
+ // when it runs the agent's default. An alias a gateway's models map
31
+ // resolves is resolved at draw time, where the map is at hand.
32
+ model: string | null;
33
+
34
+ // Where the session's harness stands: running, suspended inside a
35
+ // sleeping host, or exited. Distinct from `state`, which carries the
36
+ // attention a live harness last reported.
37
+ harness: HarnessLifecycle;
23
38
  }
24
39
 
40
+ export type HarnessLifecycle = 'running' | 'suspended' | 'exited';
41
+
25
42
  // Only the fields a mirror can't function without; an unparseable descriptor
26
43
  // is skipped rather than thrown into the event loop. Unknown keys pass
27
44
  // through so the lenient fallbacks below can still read them.
@@ -63,5 +80,32 @@ export function toMirrorSession(value: unknown): MirrorSession | null {
63
80
  canEject: record['canEject'] === true,
64
81
  agent: toAgentID(record['agent']),
65
82
  parent: typeof record['parent'] === 'string' ? record['parent'] : null,
83
+ target: readTargetID(record['locator']),
84
+ model: typeof record['model'] === 'string' ? record['model'] : null,
85
+ harness: readHarnessLifecycle(record['lifecycle'], record.alive),
66
86
  };
67
87
  }
88
+
89
+ // The locator's target id, with 'local' for a descriptor that carries no
90
+ // locator at all.
91
+ function readTargetID(locator: unknown): string {
92
+ if (isRecord(locator) && typeof locator['targetID'] === 'string') {
93
+ return locator['targetID'];
94
+ }
95
+
96
+ return 'local';
97
+ }
98
+
99
+ // The harness layer of a descriptor's lifecycle, falling back to what the
100
+ // alive flag says when the lifecycle is missing.
101
+ function readHarnessLifecycle(lifecycle: unknown, alive: boolean): HarnessLifecycle {
102
+ if (isRecord(lifecycle)) {
103
+ const harness = lifecycle['harness'];
104
+
105
+ if (harness === 'running' || harness === 'suspended' || harness === 'exited') {
106
+ return harness;
107
+ }
108
+ }
109
+
110
+ return alive ? 'running' : 'exited';
111
+ }