@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
package/README.md
CHANGED
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
+
}
|