@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +54 -8
- package/src/client/plan-overlay-columns.ts +155 -0
- package/src/client/resolve-agent-metadata.ts +75 -0
- package/src/client/spawn-picker.ts +194 -41
- package/src/client/split-to-width.ts +47 -0
- package/src/client/to-mirror-session.ts +44 -0
- package/src/client/ui.ts +118 -28
- package/src/daemon/{make-non-blocking-log.ts → create-non-blocking-log.ts} +66 -8
- package/src/daemon/daemon-connection.ts +25 -12
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/materialize-workspace.ts +1 -1
- package/src/daemon/request-access-classes.ts +54 -0
- package/src/daemon/sessions.ts +209 -47
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/protocol/request-param-schemas.ts +3 -2
- package/src/workspace/resolve-path-source.ts +21 -6
- package/src/client/format-overlay-agent-mark.ts +0 -15
|
@@ -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 {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 ??
|
|
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
|
-
|
|
714
|
+
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (this.step === 'source') {
|
|
640
719
|
this.sourceDraft = typed;
|
|
641
720
|
|
|
642
721
|
this.applySourceCancel();
|
|
643
|
-
|
|
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.
|
|
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
|
|
681
|
-
// has
|
|
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.
|
|
684
|
-
|
|
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
|
|
1002
|
-
//
|
|
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.
|
|
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
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|