@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 +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 +121 -38
- package/src/client/to-mirror-session.ts +44 -0
- package/src/client/ui.ts +109 -27
- 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/local-pty-provider.ts +18 -1
- package/src/daemon/request-access-classes.ts +54 -0
- package/src/daemon/sessions.ts +209 -47
- package/src/mcp/mcp-tools.ts +15 -2
- package/src/mcp/read-report-texts.ts +127 -0
- package/src/mcp/run-tool.ts +9 -0
- package/src/client/format-overlay-agent-mark.ts +0 -15
|
@@ -124,10 +124,14 @@ type SourceStepAction =
|
|
|
124
124
|
* optional first prompt. The sources are the ones the daemon offers, in
|
|
125
125
|
* its order, and Tab cycles them; a daemon that offers none gets the local
|
|
126
126
|
* directory flow alone. A source of directories is followed by the
|
|
127
|
-
* execution target when
|
|
127
|
+
* execution target when more than one target can run it. A source of git
|
|
128
128
|
* repositories opens the target first, then its candidates, the ref, and a
|
|
129
129
|
* confirm screen with the destination the checkout lands in, so every
|
|
130
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.
|
|
131
135
|
* Typed input a source does not match against its candidates is read by
|
|
132
136
|
* the source, then by the others in order. Every path out of the flow
|
|
133
137
|
* either attaches the new session or returns the client to the screen it
|
|
@@ -155,6 +159,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
155
159
|
// edit or a fresh install lands without a client restart.
|
|
156
160
|
private picks: AgentPick[] = [];
|
|
157
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
|
+
|
|
158
166
|
// The directories the directory step lists, and the label each shows.
|
|
159
167
|
private dirs: string[] = [];
|
|
160
168
|
|
|
@@ -180,6 +188,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
180
188
|
|
|
181
189
|
private target: TargetPick | null = null;
|
|
182
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
|
+
|
|
183
195
|
// The git source's candidates and the scope they were listed under, or
|
|
184
196
|
// the notice that stands in for a list the daemon could not give, with
|
|
185
197
|
// the source and target they were listed for. A listing runs behind the
|
|
@@ -265,7 +277,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
265
277
|
this.input = '';
|
|
266
278
|
this.sources = null;
|
|
267
279
|
this.sourceIndex = -1;
|
|
280
|
+
this.sourcesPending = false;
|
|
268
281
|
this.target = null;
|
|
282
|
+
this.targetGap = null;
|
|
269
283
|
this.repo = null;
|
|
270
284
|
this.ref = null;
|
|
271
285
|
this.sourceDraft = '';
|
|
@@ -291,6 +305,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
291
305
|
this.pending = null;
|
|
292
306
|
|
|
293
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
|
+
|
|
294
316
|
this.render();
|
|
295
317
|
}
|
|
296
318
|
|
|
@@ -325,6 +347,17 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
325
347
|
return;
|
|
326
348
|
}
|
|
327
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
|
+
|
|
328
361
|
const tab = buf.length === 1 && buf[0] === KEY.tab;
|
|
329
362
|
|
|
330
363
|
// A tab to a source of directories waits for its listing on the step it
|
|
@@ -387,10 +420,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
387
420
|
|
|
388
421
|
// An empty menu means every configured binary is missing, so the hint
|
|
389
422
|
// carries the fix instead of the movement keys.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|
|
394
431
|
|
|
395
432
|
drawPicker({
|
|
396
433
|
title: `${verb}: agent`,
|
|
@@ -488,13 +525,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
488
525
|
|
|
489
526
|
const git = this.isGitFlow();
|
|
490
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
|
+
}
|
|
491
536
|
|
|
492
537
|
drawPicker({
|
|
493
538
|
title: `${verb}: target`,
|
|
494
539
|
items: this.targets.map((t) => formatTargetPick(t)),
|
|
495
540
|
selected: this.selected,
|
|
496
541
|
input: '',
|
|
497
|
-
hint: this.refusal ??
|
|
542
|
+
hint: this.refusal ?? hint,
|
|
498
543
|
dimmed: new Set(
|
|
499
544
|
this.targets.flatMap((t, i) =>
|
|
500
545
|
t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
|
|
@@ -666,11 +711,19 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
666
711
|
|
|
667
712
|
if (this.step === 'dir') {
|
|
668
713
|
this.openAgentStep();
|
|
669
|
-
|
|
714
|
+
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (this.step === 'source') {
|
|
670
719
|
this.sourceDraft = typed;
|
|
671
720
|
|
|
672
721
|
this.applySourceCancel();
|
|
673
|
-
|
|
722
|
+
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (this.step === 'ref') {
|
|
674
727
|
this.input = this.repo?.label ?? '';
|
|
675
728
|
this.repo = null;
|
|
676
729
|
this.step = 'source';
|
|
@@ -684,10 +737,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
684
737
|
if (this.isGitFlow()) {
|
|
685
738
|
this.input = this.buildDestination();
|
|
686
739
|
this.step = 'confirm';
|
|
687
|
-
} else {
|
|
688
|
-
this.
|
|
689
|
-
this.step = 'dir';
|
|
690
|
-
});
|
|
740
|
+
} else if (!this.openTargetStep()) {
|
|
741
|
+
this.step = 'dir';
|
|
691
742
|
}
|
|
692
743
|
} else {
|
|
693
744
|
this.input = this.name;
|
|
@@ -698,20 +749,33 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
698
749
|
this.render();
|
|
699
750
|
}
|
|
700
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.
|
|
701
754
|
private openAgentStep() {
|
|
755
|
+
if (this.picks.length === 1) {
|
|
756
|
+
this.quitFlow();
|
|
757
|
+
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
702
761
|
this.selected = Math.max(
|
|
703
762
|
0,
|
|
704
763
|
this.picks.findIndex((p) => p.agent === this.agent),
|
|
705
764
|
);
|
|
706
765
|
|
|
766
|
+
this.sourcesPending = false;
|
|
707
767
|
this.step = 'agent';
|
|
768
|
+
|
|
769
|
+
process.stdout.write(ansi.clear);
|
|
770
|
+
this.render();
|
|
708
771
|
}
|
|
709
772
|
|
|
710
|
-
// Leaves a git source step backwards: to the target step when the
|
|
711
|
-
// has
|
|
773
|
+
// Leaves a git source step backwards: to the target step when the spawn
|
|
774
|
+
// has one, else to the agent.
|
|
712
775
|
private applySourceCancel() {
|
|
713
|
-
if (this.
|
|
714
|
-
|
|
776
|
+
if (this.openTargetStep()) {
|
|
777
|
+
process.stdout.write(ansi.clear);
|
|
778
|
+
this.render();
|
|
715
779
|
|
|
716
780
|
return;
|
|
717
781
|
}
|
|
@@ -737,8 +801,6 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
737
801
|
|
|
738
802
|
if (index === -1) {
|
|
739
803
|
this.openAgentStep();
|
|
740
|
-
process.stdout.write(ansi.clear);
|
|
741
|
-
this.render();
|
|
742
804
|
|
|
743
805
|
return;
|
|
744
806
|
}
|
|
@@ -841,9 +903,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
841
903
|
this.dir = dir;
|
|
842
904
|
this.input = '';
|
|
843
905
|
|
|
844
|
-
this.openTargetStep(
|
|
906
|
+
if (!this.openTargetStep()) {
|
|
845
907
|
this.step = 'name';
|
|
846
|
-
}
|
|
908
|
+
}
|
|
847
909
|
|
|
848
910
|
process.stdout.write(ansi.clear);
|
|
849
911
|
this.render();
|
|
@@ -1028,8 +1090,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1028
1090
|
}
|
|
1029
1091
|
|
|
1030
1092
|
// Moves the flow to a source of git repositories, carrying the typed text
|
|
1031
|
-
// across: the target step first when the
|
|
1032
|
-
//
|
|
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
|
|
1033
1095
|
// acts as asked once it opens.
|
|
1034
1096
|
private openGitSource(index: number, draft: string, action: SourceStepAction) {
|
|
1035
1097
|
const fromGit = this.isGitFlow();
|
|
@@ -1045,16 +1107,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1045
1107
|
return;
|
|
1046
1108
|
}
|
|
1047
1109
|
|
|
1048
|
-
if (this.
|
|
1049
|
-
this.openTargetStep(() => {});
|
|
1110
|
+
if (this.openTargetStep()) {
|
|
1050
1111
|
process.stdout.write(ansi.clear);
|
|
1051
1112
|
this.render();
|
|
1052
1113
|
|
|
1053
1114
|
return;
|
|
1054
1115
|
}
|
|
1055
1116
|
|
|
1056
|
-
this.target = this.targets[0] ?? null;
|
|
1057
|
-
|
|
1058
1117
|
this.openSourceStep();
|
|
1059
1118
|
}
|
|
1060
1119
|
|
|
@@ -1599,19 +1658,22 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1599
1658
|
return this.target ?? this.targets.find((t) => t.isDefault) ?? null;
|
|
1600
1659
|
}
|
|
1601
1660
|
|
|
1602
|
-
// Opens the target step when
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
// and its provider decides whether a directory runs in place.
|
|
1610
|
-
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();
|
|
1611
1668
|
|
|
1612
|
-
|
|
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;
|
|
1613
1675
|
|
|
1614
|
-
return;
|
|
1676
|
+
return false;
|
|
1615
1677
|
}
|
|
1616
1678
|
|
|
1617
1679
|
const preferred =
|
|
@@ -1629,6 +1691,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1629
1691
|
|
|
1630
1692
|
this.input = '';
|
|
1631
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);
|
|
1632
1703
|
}
|
|
1633
1704
|
|
|
1634
1705
|
// Reads the daemon's targets and sources once the agent is chosen, and
|
|
@@ -1638,6 +1709,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1638
1709
|
const generation = this.generation;
|
|
1639
1710
|
let listed: Readonly<Record<string, unknown>> = {};
|
|
1640
1711
|
|
|
1712
|
+
this.sourcesPending = true;
|
|
1713
|
+
|
|
1714
|
+
this.render();
|
|
1715
|
+
|
|
1641
1716
|
try {
|
|
1642
1717
|
listed = await this.deps.sendRequest('agents.list');
|
|
1643
1718
|
} catch {}
|
|
@@ -1649,12 +1724,20 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1649
1724
|
// An agent that takes its credential from impd's broker runs only on a
|
|
1650
1725
|
// target that reaches the broker.
|
|
1651
1726
|
const brokered = isBrokerAgent(listed['agents'], this.agent);
|
|
1652
|
-
const
|
|
1727
|
+
const listedTargets = collectTargetPicks(listed);
|
|
1728
|
+
const picks = listedTargets.filter((t) => !brokered || t.brokerAuth);
|
|
1653
1729
|
|
|
1654
1730
|
// An adopt resumes a session from its history on this host, which a
|
|
1655
1731
|
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
1656
1732
|
// targets that run here.
|
|
1657
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
|
+
}
|
|
1658
1741
|
|
|
1659
1742
|
const announced = parseAnnouncedSources(listed['sources']);
|
|
1660
1743
|
const index = this.resume ? announced.findIndex((source) => source.kind === 'path') : 0;
|
|
@@ -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
|
+
}
|
package/src/client/ui.ts
CHANGED
|
@@ -3,9 +3,11 @@ import type { SessionState } from '../protocol/session-state';
|
|
|
3
3
|
import type { AgentID } from '../shared/agent-id';
|
|
4
4
|
import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
|
|
5
5
|
import { formatDir } from './dirs';
|
|
6
|
-
import {
|
|
6
|
+
import { planOverlayColumns } from './plan-overlay-columns';
|
|
7
|
+
import type { OverlayColumnPlan } from './plan-overlay-columns';
|
|
7
8
|
import { planVacatedRows } from './plan-vacated-rows';
|
|
8
9
|
import type { BoxExtent } from './plan-vacated-rows';
|
|
10
|
+
import type { HarnessLifecycle } from './to-mirror-session';
|
|
9
11
|
|
|
10
12
|
// The slice of a session the drawing layer needs; satisfied by both the
|
|
11
13
|
// daemon's sessions and the wire descriptors a client mirrors.
|
|
@@ -39,13 +41,6 @@ const GLYPH: Record<SessionState, string> = {
|
|
|
39
41
|
exited: `${ESC}[90m✗${ESC}[0m`,
|
|
40
42
|
};
|
|
41
43
|
|
|
42
|
-
const STATE_LABEL: Record<SessionState, string> = {
|
|
43
|
-
needs_you: 'NEEDS YOU',
|
|
44
|
-
running: 'running',
|
|
45
|
-
done: 'done',
|
|
46
|
-
exited: 'exited',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
44
|
function out(s: string) {
|
|
50
45
|
process.stdout.write(s);
|
|
51
46
|
}
|
|
@@ -194,14 +189,25 @@ export interface OverlaySessionView extends SessionView {
|
|
|
194
189
|
readonly agent: AgentID;
|
|
195
190
|
readonly pinned: boolean;
|
|
196
191
|
readonly repoRoot: string;
|
|
192
|
+
readonly target: string;
|
|
193
|
+
readonly model: string | null;
|
|
194
|
+
|
|
195
|
+
// Where the session's harness stands, apart from the attention its state
|
|
196
|
+
// carries: running, suspended inside a sleeping host, or exited.
|
|
197
|
+
readonly harness: HarnessLifecycle;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
export interface OverlayView {
|
|
200
201
|
sessions: readonly OverlaySessionView[];
|
|
201
202
|
|
|
202
|
-
// The
|
|
203
|
-
// the
|
|
204
|
-
|
|
203
|
+
// The readable name per agent id, and each agent's tier-alias map, from
|
|
204
|
+
// the daemon's `agents.list` answer over the config.
|
|
205
|
+
agentLabels: Readonly<Record<AgentID, string>>;
|
|
206
|
+
agentModels: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
|
|
207
|
+
|
|
208
|
+
// Whether more than one execution target is available to spawn on, which
|
|
209
|
+
// is what brings the target column in.
|
|
210
|
+
showTarget: boolean;
|
|
205
211
|
selected: number;
|
|
206
212
|
confirmKill: boolean;
|
|
207
213
|
|
|
@@ -213,6 +219,67 @@ export interface OverlayView {
|
|
|
213
219
|
grouped: boolean;
|
|
214
220
|
}
|
|
215
221
|
|
|
222
|
+
// A daemon-supplied id or alias can name an inherited property, so map
|
|
223
|
+
// reads go through an own-property check instead of indexing blindly.
|
|
224
|
+
function readMapValue<V>(map: Readonly<Record<string, V>>, key: string): V | undefined {
|
|
225
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A session's model as the row draws it: the daemon answer's alias target,
|
|
229
|
+
// or the raw model string when the answer maps nothing for it.
|
|
230
|
+
function resolveModelAlias(
|
|
231
|
+
models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>,
|
|
232
|
+
agent: AgentID,
|
|
233
|
+
model: string,
|
|
234
|
+
): string {
|
|
235
|
+
const aliases = readMapValue(models, agent);
|
|
236
|
+
|
|
237
|
+
return aliases === undefined ? model : (readMapValue(aliases, model) ?? model);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A session row draws status (the attention glyph plus the unread mark),
|
|
241
|
+
// pin, name, the flat view's directory, then the target, harness, model,
|
|
242
|
+
// harness lifecycle, and last event columns the width plan allows.
|
|
243
|
+
export function buildSessionRow(
|
|
244
|
+
s: Readonly<OverlaySessionView>,
|
|
245
|
+
plan: Readonly<OverlayColumnPlan>,
|
|
246
|
+
view: Readonly<OverlayView>,
|
|
247
|
+
sel: boolean,
|
|
248
|
+
): Row {
|
|
249
|
+
const sub = s.parent !== null && view.sessions.some((x) => x.id === s.parent);
|
|
250
|
+
|
|
251
|
+
const name =
|
|
252
|
+
plan.nameWidth >= 4 && sub
|
|
253
|
+
? `↳ ${truncate(s.name, plan.nameWidth - 2)}`.padEnd(plan.nameWidth)
|
|
254
|
+
: truncate(s.name, plan.nameWidth).padEnd(plan.nameWidth);
|
|
255
|
+
|
|
256
|
+
const dir =
|
|
257
|
+
plan.dirWidth > 0 ? truncate(formatDir(s.cwd), plan.dirWidth).padEnd(plan.dirWidth) : '';
|
|
258
|
+
|
|
259
|
+
const target =
|
|
260
|
+
plan.targetWidth > 0 ? truncate(s.target, plan.targetWidth).padEnd(plan.targetWidth) : '';
|
|
261
|
+
|
|
262
|
+
const label = readMapValue(view.agentLabels, s.agent) ?? s.agent;
|
|
263
|
+
const harness = truncate(label, plan.harnessWidth).padEnd(plan.harnessWidth);
|
|
264
|
+
const resolved = s.model === null ? '' : resolveModelAlias(view.agentModels, s.agent, s.model);
|
|
265
|
+
|
|
266
|
+
const model =
|
|
267
|
+
plan.modelWidth > 0 ? truncate(resolved, plan.modelWidth).padEnd(plan.modelWidth) : '';
|
|
268
|
+
|
|
269
|
+
const lifecycle = truncate(s.harness, plan.lifecycleWidth).padEnd(plan.lifecycleWidth);
|
|
270
|
+
const event = truncate(s.lastMsg, plan.eventWidth).padEnd(plan.eventWidth);
|
|
271
|
+
const cells = [name, dir, target, harness, model, lifecycle, event].filter((cell) => cell !== '');
|
|
272
|
+
const body = cells.join(' ');
|
|
273
|
+
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
274
|
+
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
275
|
+
const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
styled: `${GLYPH[s.state]}${unread}${pin} ${styledBody}`,
|
|
279
|
+
width: 4 + body.length,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
216
283
|
export function drawOverlay(view: OverlayView) {
|
|
217
284
|
const width = Math.min(cols() - 4, 90);
|
|
218
285
|
const rowsList: Row[] = [boxTop(width, 'sessions')];
|
|
@@ -223,6 +290,35 @@ export function drawOverlay(view: OverlayView) {
|
|
|
223
290
|
rowsList.push(dimRow(width, empty));
|
|
224
291
|
}
|
|
225
292
|
|
|
293
|
+
// The harness label and the model each row would draw, so the columns
|
|
294
|
+
// take the width of what is actually on screen.
|
|
295
|
+
const harnessMax = Math.max(
|
|
296
|
+
0,
|
|
297
|
+
...view.sessions.map((s) => (readMapValue(view.agentLabels, s.agent) ?? s.agent).length),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const modelMax = Math.max(
|
|
301
|
+
0,
|
|
302
|
+
...view.sessions.map((s) => {
|
|
303
|
+
if (s.model === null) {
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return resolveModelAlias(view.agentModels, s.agent, s.model).length;
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const targetMax = Math.max(0, ...view.sessions.map((s) => s.target.length));
|
|
312
|
+
|
|
313
|
+
const plan = planOverlayColumns({
|
|
314
|
+
innerWidth: width - 4,
|
|
315
|
+
grouped: view.grouped,
|
|
316
|
+
showTarget: view.showTarget,
|
|
317
|
+
targetMax,
|
|
318
|
+
harnessMax,
|
|
319
|
+
modelMax,
|
|
320
|
+
});
|
|
321
|
+
|
|
226
322
|
// The grouped view clusters sessions under dim repository headers, with
|
|
227
323
|
// pinned sessions leading in their own cluster; the flat view shows a
|
|
228
324
|
// directory column instead. The rows keep the sorted order either way. A
|
|
@@ -239,22 +335,9 @@ export function drawOverlay(view: OverlayView) {
|
|
|
239
335
|
rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
|
|
240
336
|
}
|
|
241
337
|
|
|
242
|
-
const
|
|
243
|
-
const name = (sub ? `↳ ${truncate(s.name, 14)}` : truncate(s.name, 16)).padEnd(16);
|
|
244
|
-
const state = STATE_LABEL[s.state].padEnd(9);
|
|
245
|
-
const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
246
|
-
const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
|
|
247
|
-
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
248
|
-
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
249
|
-
const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
|
|
250
|
-
const mark = formatOverlayAgentMark(s.agent, view.agentMarks);
|
|
251
|
-
const styledMark = mark === ' ' ? mark : `${ESC}[90m${mark}${ESC}[0m`;
|
|
252
|
-
const body = `${name} ${state}${dir} ${msg}`;
|
|
253
|
-
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
338
|
+
const row = buildSessionRow(s, plan, view, i === view.selected);
|
|
254
339
|
|
|
255
|
-
rowsList.push(
|
|
256
|
-
boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledMark}${styledBody}`, 4 + body.length),
|
|
257
|
-
);
|
|
340
|
+
rowsList.push(boxRow(width, row.styled, row.width));
|
|
258
341
|
}
|
|
259
342
|
|
|
260
343
|
rowsList.push(boxDivider(width));
|
|
@@ -371,7 +454,6 @@ export function drawHelp() {
|
|
|
371
454
|
'p pin or unpin — pinned sessions stay on top',
|
|
372
455
|
' a sub-session pins with its parent',
|
|
373
456
|
'g toggle grouping by repository',
|
|
374
|
-
' Grok rows show a dim g after the pin mark',
|
|
375
457
|
'n new session',
|
|
376
458
|
'r adopt an external session',
|
|
377
459
|
'/ filter · ↑↓/jk move · q quit',
|