@zgeoff/atc 0.1.12 → 1.0.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.
Files changed (74) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -312
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -236
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -0,0 +1,111 @@
1
+ export const KEY = {
2
+ tab: 0x09,
3
+ ctrlC: 0x03,
4
+ ctrlU: 0x15,
5
+ esc: 0x1b,
6
+ enter: 0x0d,
7
+ backspace: 0x7f,
8
+ } as const;
9
+
10
+ export function isUp(buf: Buffer): boolean {
11
+ return buf.toString() === '\u001B[A' || buf.toString() === '\u001BOA';
12
+ }
13
+
14
+ export function isDown(buf: Buffer): boolean {
15
+ return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
16
+ }
17
+
18
+ export interface TextEditOptions {
19
+ readonly isLeaderKey: (buf: Buffer) => boolean;
20
+
21
+ // Only a screen with a list under its input answers the arrow keys; a
22
+ // plain prompt treats them as unprintable noise.
23
+ readonly moves: boolean;
24
+ }
25
+
26
+ export type TextEdit =
27
+ | { readonly kind: 'none' }
28
+ | { readonly kind: 'submit'; readonly value: string }
29
+ | { readonly kind: 'cancel' }
30
+ | { readonly kind: 'leader' }
31
+ | { readonly kind: 'move'; readonly delta: 1 | -1 }
32
+ | { readonly kind: 'input'; readonly value: string };
33
+
34
+ /**
35
+ * What one stdin chunk does to a text-entry screen, decided without touching
36
+ * the screen. The caller owns the input string and applies the answer.
37
+ */
38
+ export function planTextEdit(
39
+ buf: Buffer,
40
+ input: string,
41
+ opts: Readonly<TextEditOptions>,
42
+ ): TextEdit {
43
+ // Pasted chunks arrive as one buffer: read it character by character so a
44
+ // trailing newline still submits. Escape sequences stay intact.
45
+ if (buf.length > 1 && buf[0] !== KEY.esc) {
46
+ let value = input;
47
+
48
+ for (const ch of buf.toString()) {
49
+ const edit = planKeyEdit(Buffer.from(ch), value, opts);
50
+
51
+ if (edit.kind === 'input') {
52
+ value = edit.value;
53
+ continue;
54
+ }
55
+
56
+ if (edit.kind !== 'none') {
57
+ return edit;
58
+ }
59
+ }
60
+
61
+ return value === input ? { kind: 'none' } : { kind: 'input', value };
62
+ }
63
+
64
+ return planKeyEdit(buf, input, opts);
65
+ }
66
+
67
+ function planKeyEdit(buf: Buffer, input: string, opts: Readonly<TextEditOptions>): TextEdit {
68
+ if (buf[0] === KEY.esc && buf.length === 1) {
69
+ return { kind: 'cancel' };
70
+ }
71
+
72
+ if (opts.isLeaderKey(buf)) {
73
+ return { kind: 'leader' };
74
+ }
75
+
76
+ if (buf[0] === KEY.enter) {
77
+ return { kind: 'submit', value: input };
78
+ }
79
+
80
+ if (buf[0] === KEY.backspace) {
81
+ return { kind: 'input', value: input.slice(0, -1) };
82
+ }
83
+
84
+ if (buf[0] === KEY.ctrlU) {
85
+ return { kind: 'input', value: '' };
86
+ }
87
+
88
+ if (opts.moves) {
89
+ if (isDown(buf)) {
90
+ return { kind: 'move', delta: 1 };
91
+ }
92
+
93
+ if (isUp(buf)) {
94
+ return { kind: 'move', delta: -1 };
95
+ }
96
+ }
97
+
98
+ const text = buf.toString();
99
+
100
+ return isPrintable(text) ? { kind: 'input', value: input + text } : { kind: 'none' };
101
+ }
102
+
103
+ function isPrintable(text: string): boolean {
104
+ for (const c of text) {
105
+ if (c < ' ' || c === '\u007F') {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ return true;
111
+ }
@@ -0,0 +1,114 @@
1
+ import { match } from 'ts-pattern';
2
+ import { z } from 'zod';
3
+ import type { EventMsg } from '../protocol/protocol';
4
+ import { toMirrorSession } from './to-mirror-session';
5
+ import type { MirrorSession } from './to-mirror-session';
6
+
7
+ export type DaemonEvent =
8
+ | { readonly ev: 'session.added'; readonly session: MirrorSession }
9
+ | { readonly ev: 'session.state'; readonly session: MirrorSession }
10
+ | {
11
+ readonly ev: 'session.renamed';
12
+ readonly s: string;
13
+ readonly name: string;
14
+ readonly namedBy: 'user' | 'auto' | 'agent';
15
+ }
16
+ | { readonly ev: 'session.removed'; readonly s: string }
17
+ | {
18
+ readonly ev: 'session.resized';
19
+ readonly s: string;
20
+ readonly cols: number;
21
+ readonly rows: number;
22
+ }
23
+ | { readonly ev: 'session.output'; readonly s: string; readonly seq: number; readonly d: string }
24
+ | { readonly ev: 'session.desync'; readonly s: string; readonly dropped: number }
25
+ | {
26
+ readonly ev: 'permission.requested';
27
+ readonly request: string;
28
+ readonly s: string;
29
+ readonly message: string;
30
+ readonly respondable: boolean;
31
+ }
32
+ | { readonly ev: 'permission.resolved'; readonly request: string; readonly decision: string };
33
+
34
+ // Every variant is a looseObject so unrecognized keys never fail a match —
35
+ // additive evolution on a known event still parses.
36
+ const DAEMON_EVENT_SCHEMA = z.discriminatedUnion('ev', [
37
+ z.looseObject({ ev: z.literal('session.added'), session: z.unknown() }),
38
+ z.looseObject({ ev: z.literal('session.state'), session: z.unknown() }),
39
+ z.looseObject({
40
+ ev: z.literal('session.renamed'),
41
+ s: z.string(),
42
+ name: z.string(),
43
+ namedBy: z.enum(['user', 'auto', 'agent']),
44
+ }),
45
+ z.looseObject({ ev: z.literal('session.removed'), s: z.string() }),
46
+ z.looseObject({
47
+ ev: z.literal('session.resized'),
48
+ s: z.string(),
49
+ cols: z.number(),
50
+ rows: z.number(),
51
+ }),
52
+ z.looseObject({
53
+ ev: z.literal('session.output'),
54
+ s: z.string(),
55
+ seq: z.number(),
56
+ d: z.string(),
57
+ }),
58
+ z.looseObject({ ev: z.literal('session.desync'), s: z.string(), dropped: z.number() }),
59
+ z.looseObject({
60
+ ev: z.literal('permission.requested'),
61
+ request: z.string(),
62
+ s: z.string(),
63
+ message: z.string(),
64
+ respondable: z.boolean(),
65
+ }),
66
+ z.looseObject({
67
+ ev: z.literal('permission.resolved'),
68
+ request: z.string(),
69
+ decision: z.string(),
70
+ }),
71
+ ]);
72
+
73
+ /**
74
+ * Turns a raw event line into the typed union, or null when the kind is
75
+ * unrecognized or a known kind's payload is malformed. Total by design: a
76
+ * miss is dropped by the caller, never thrown.
77
+ */
78
+ export function parseDaemonEvent(raw: EventMsg): DaemonEvent | null {
79
+ const parsed = DAEMON_EVENT_SCHEMA.safeParse(raw);
80
+
81
+ if (!parsed.success) {
82
+ return null;
83
+ }
84
+
85
+ return match(parsed.data)
86
+ .with({ ev: 'session.added' }, { ev: 'session.state' }, (d) => {
87
+ const session = toMirrorSession(d.session);
88
+
89
+ return session === null ? null : { ev: d.ev, session };
90
+ })
91
+ .with({ ev: 'session.renamed' }, (d) => ({
92
+ ev: d.ev,
93
+ s: d.s,
94
+ name: d.name,
95
+ namedBy: d.namedBy,
96
+ }))
97
+ .with({ ev: 'session.removed' }, (d) => ({ ev: d.ev, s: d.s }))
98
+ .with({ ev: 'session.resized' }, (d) => ({ ev: d.ev, s: d.s, cols: d.cols, rows: d.rows }))
99
+ .with({ ev: 'session.output' }, (d) => ({ ev: d.ev, s: d.s, seq: d.seq, d: d.d }))
100
+ .with({ ev: 'session.desync' }, (d) => ({ ev: d.ev, s: d.s, dropped: d.dropped }))
101
+ .with({ ev: 'permission.requested' }, (d) => ({
102
+ ev: d.ev,
103
+ request: d.request,
104
+ s: d.s,
105
+ message: d.message,
106
+ respondable: d.respondable,
107
+ }))
108
+ .with({ ev: 'permission.resolved' }, (d) => ({
109
+ ev: d.ev,
110
+ request: d.request,
111
+ decision: d.decision,
112
+ }))
113
+ .exhaustive();
114
+ }
@@ -1,5 +1,5 @@
1
- import type { SessionState } from './sessions';
2
- import { sortSessionViews } from './sessions';
1
+ import type { SessionState } from '../daemon/sessions';
2
+ import { sortSessionViews } from '../daemon/sessions';
3
3
 
4
4
  interface TabCandidate {
5
5
  readonly id: string;
@@ -0,0 +1,305 @@
1
+ import type { AgentID } from '../agents/agent-adapter';
2
+ import { loadConfig } from '../shared/config';
3
+ import { collectAgentPicks } from './collect-agent-picks';
4
+ import type { AgentPick } from './collect-agent-picks';
5
+ import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
6
+ import { planTextEdit } from './keys';
7
+ import { ansi, cols, drawPicker } from './ui';
8
+
9
+ type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
10
+
11
+ // What the flow borrows from the client that owns the screen, the daemon
12
+ // connection and the fleet mirror.
13
+ export interface SpawnPickerDeps<TMirror> {
14
+ readonly sendRequest: (
15
+ m: string,
16
+ p?: Readonly<Record<string, unknown>>,
17
+ ) => Promise<Readonly<Record<string, unknown>>>;
18
+ readonly ptyRows: () => number;
19
+ readonly isLeaderKey: (buf: Buffer) => boolean;
20
+ readonly getLastUsedAgent: () => AgentID;
21
+ readonly scheduleStatus: () => void;
22
+ readonly toBase: () => void;
23
+ readonly attach: (sessionID: string) => Promise<void>;
24
+ readonly toMirrorSession: (value: unknown) => TMirror | null;
25
+ readonly upsertMirror: (session: Readonly<TMirror>) => void;
26
+ }
27
+
28
+ /**
29
+ * The modal flow behind n and r: agent, then directory, then name, then an
30
+ * optional first prompt. Every path out of it either attaches the new
31
+ * session or returns the client to the screen it came from.
32
+ */
33
+ export class SpawnPicker<TMirror extends { readonly id: string }> {
34
+ private readonly deps: SpawnPickerDeps<TMirror>;
35
+
36
+ private step: PickerStep = 'agent';
37
+
38
+ private input = '';
39
+
40
+ private selected = 0;
41
+
42
+ // The installed agents, collected each time the menu opens so a config
43
+ // edit or a fresh install lands without a client restart.
44
+ private picks: AgentPick[] = [];
45
+
46
+ private dirs: string[] = [];
47
+
48
+ private dir = '';
49
+
50
+ private name = '';
51
+
52
+ private resume = false;
53
+
54
+ private agent: AgentID = 'claude';
55
+
56
+ constructor(deps: SpawnPickerDeps<TMirror>) {
57
+ this.deps = deps;
58
+ }
59
+
60
+ open(resume = false) {
61
+ this.resume = resume;
62
+ this.picks = collectAgentPicks(loadConfig());
63
+ this.input = '';
64
+
65
+ // A last-used agent that is no longer installed is not in the menu, so
66
+ // the selection falls to the first one that is.
67
+ this.selected = Math.max(
68
+ 0,
69
+ this.picks.findIndex((p) => p.agent === this.deps.getLastUsedAgent()),
70
+ );
71
+
72
+ this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
73
+ this.step = 'agent';
74
+
75
+ process.stdout.write(ansi.clear);
76
+ this.render();
77
+ }
78
+
79
+ applyKey(buf: Buffer) {
80
+ const edit = planTextEdit(buf, this.input, {
81
+ isLeaderKey: this.deps.isLeaderKey,
82
+ moves: this.step === 'agent' || this.step === 'dir',
83
+ });
84
+
85
+ switch (edit.kind) {
86
+ case 'none': {
87
+ return;
88
+ }
89
+ case 'cancel': {
90
+ this.applyCancel();
91
+
92
+ return;
93
+ }
94
+ case 'leader': {
95
+ this.deps.toBase();
96
+
97
+ return;
98
+ }
99
+ case 'submit': {
100
+ this.input = edit.value;
101
+
102
+ this.applySubmit();
103
+
104
+ return;
105
+ }
106
+ case 'move': {
107
+ this.selected = edit.delta === 1 ? this.selected + 1 : Math.max(0, this.selected - 1);
108
+
109
+ this.render();
110
+
111
+ return;
112
+ }
113
+ case 'input': {
114
+ this.input = edit.value;
115
+
116
+ this.render();
117
+ }
118
+ }
119
+ }
120
+
121
+ render() {
122
+ const verb = this.resume ? 'adopt' : 'spawn';
123
+
124
+ if (this.step === 'agent') {
125
+ this.selected = Math.min(this.selected, this.picks.length - 1);
126
+
127
+ // An empty menu means every configured binary is missing, so the hint
128
+ // carries the fix instead of the movement keys.
129
+ const hint =
130
+ this.picks.length === 0
131
+ ? 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel'
132
+ : '↑↓ move · ⏎ select · esc cancel';
133
+
134
+ drawPicker({
135
+ title: `${verb}: agent`,
136
+ items: this.picks.map((p) => p.label),
137
+ selected: this.selected,
138
+ input: this.input,
139
+ hint,
140
+ });
141
+ } else if (this.step === 'dir') {
142
+ const items = pickMatches(this.dirs, this.input).map((d) => formatDir(d));
143
+
144
+ this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
145
+
146
+ drawPicker({
147
+ title: `${verb}: directory`,
148
+ items,
149
+ selected: this.selected,
150
+ input: this.input,
151
+ hint: 'type to filter · ↑↓ move · ⏎ select · esc cancel',
152
+ });
153
+ } else if (this.step === 'name') {
154
+ drawPicker({
155
+ title: `${verb}: name`,
156
+ items: [],
157
+ selected: -1,
158
+ input: this.input,
159
+ placeholder: formatDirName(this.dir),
160
+ hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
161
+ });
162
+ } else {
163
+ drawPicker({
164
+ title: 'spawn: initial prompt',
165
+ items: [],
166
+ selected: -1,
167
+ input: this.input,
168
+ placeholder: 'optional — ⏎ to start interactive',
169
+ hint: 'first message for the session · ⏎ spawn · esc back',
170
+ });
171
+ }
172
+
173
+ this.deps.scheduleStatus();
174
+ }
175
+
176
+ private applyCancel() {
177
+ if (this.step === 'agent') {
178
+ this.deps.toBase();
179
+
180
+ return;
181
+ }
182
+
183
+ this.input = '';
184
+
185
+ if (this.step === 'dir') {
186
+ this.selected = Math.max(
187
+ 0,
188
+ this.picks.findIndex((p) => p.agent === this.agent),
189
+ );
190
+
191
+ this.step = 'agent';
192
+ } else if (this.step === 'name') {
193
+ this.step = 'dir';
194
+ } else {
195
+ this.step = 'name';
196
+ }
197
+
198
+ process.stdout.write(ansi.clear);
199
+ this.render();
200
+ }
201
+
202
+ private applySubmit() {
203
+ if (this.step === 'agent') {
204
+ const pick = this.picks[this.selected];
205
+
206
+ // With no agent installed the menu is empty and there is nothing to
207
+ // spawn.
208
+ if (pick === undefined) {
209
+ return;
210
+ }
211
+
212
+ this.agent = pick.agent;
213
+ void this.openDirStep();
214
+
215
+ return;
216
+ }
217
+
218
+ if (this.step === 'dir') {
219
+ const items = pickMatches(this.dirs, this.input);
220
+ const raw = this.input.trim();
221
+ let chosen = items[this.selected] ?? null;
222
+
223
+ if (chosen === null && (raw.startsWith('/') || raw.startsWith('~'))) {
224
+ chosen = raw.replace(/^~/u, process.env['HOME'] ?? '~');
225
+ }
226
+
227
+ if (chosen === null) {
228
+ return;
229
+ }
230
+
231
+ this.dir = chosen;
232
+ this.input = '';
233
+ this.step = 'name';
234
+ } else if (this.step === 'name') {
235
+ this.name = this.input.trim();
236
+ this.input = '';
237
+
238
+ // Adopt skips the prompt step: the agent's own resume picker opens
239
+ // inside the new PTY.
240
+ if (this.resume) {
241
+ void this.spawn('');
242
+
243
+ return;
244
+ }
245
+
246
+ this.step = 'prompt';
247
+ } else {
248
+ void this.spawn(this.input.trim());
249
+
250
+ return;
251
+ }
252
+
253
+ process.stdout.write(ansi.clear);
254
+ this.render();
255
+ }
256
+
257
+ private async openDirStep() {
258
+ let recent: string[] = [];
259
+
260
+ try {
261
+ const answer = await this.deps.sendRequest('dirs.list');
262
+
263
+ const dirs = answer['dirs'];
264
+
265
+ if (Array.isArray(dirs)) {
266
+ recent = dirs.filter((d): d is string => typeof d === 'string');
267
+ }
268
+ } catch {}
269
+
270
+ this.dirs = await collectDirs(recent);
271
+
272
+ this.input = '';
273
+ this.selected = 0;
274
+ this.step = 'dir';
275
+
276
+ process.stdout.write(ansi.clear);
277
+ this.render();
278
+ }
279
+
280
+ private async spawn(prompt: string) {
281
+ try {
282
+ const ok = await this.deps.sendRequest('session.spawn', {
283
+ cwd: this.dir,
284
+ name: this.name,
285
+ prompt,
286
+ cols: cols(),
287
+ rows: this.deps.ptyRows(),
288
+ ...(this.resume ? { resume: true } : {}),
289
+ agent: this.agent,
290
+ });
291
+
292
+ const spawned = this.deps.toMirrorSession(ok['session']);
293
+
294
+ if (spawned !== null) {
295
+ this.deps.upsertMirror(spawned);
296
+
297
+ await this.deps.attach(spawned.id);
298
+
299
+ return;
300
+ }
301
+ } catch {}
302
+
303
+ this.deps.toBase();
304
+ }
305
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ import { toAgentID } from '../agents/agent-adapter';
3
+ import type { AgentID } from '../agents/agent-adapter';
4
+ import type { SessionState } from '../daemon/sessions';
5
+
6
+ export interface MirrorSession {
7
+ id: string;
8
+ name: string;
9
+ cwd: string;
10
+ pinned: boolean;
11
+ lastAttachedAt: number;
12
+ repoRoot: string;
13
+ state: SessionState;
14
+ unread: boolean;
15
+ lastMsg: string;
16
+ createdAt: number;
17
+ kind: 'pty' | 'headless';
18
+ alive: boolean;
19
+ resumable: boolean;
20
+ canEject: boolean;
21
+ agent: AgentID;
22
+ }
23
+
24
+ // Only the fields a mirror can't function without; an unparseable descriptor
25
+ // is skipped rather than thrown into the event loop. Unknown keys pass
26
+ // through so the lenient fallbacks below can still read them.
27
+ const REQUIRED_MIRROR_FIELDS_SCHEMA = z.looseObject({
28
+ id: z.string(),
29
+ name: z.string(),
30
+ cwd: z.string(),
31
+ state: z.enum(['running', 'needs_you', 'done', 'exited']),
32
+ unread: z.boolean(),
33
+ lastMsg: z.string(),
34
+ createdAt: z.number(),
35
+ alive: z.boolean(),
36
+ });
37
+
38
+ export function toMirrorSession(value: unknown): MirrorSession | null {
39
+ const parsed = REQUIRED_MIRROR_FIELDS_SCHEMA.safeParse(value);
40
+
41
+ if (!parsed.success) {
42
+ return null;
43
+ }
44
+
45
+ const record = parsed.data;
46
+
47
+ return {
48
+ id: record.id,
49
+ name: record.name,
50
+ cwd: record.cwd,
51
+ pinned: record['pinned'] === true,
52
+ lastAttachedAt:
53
+ typeof record['lastAttachedAt'] === 'number' ? record['lastAttachedAt'] : record.createdAt,
54
+ repoRoot: typeof record['repoRoot'] === 'string' ? record['repoRoot'] : record.cwd,
55
+ state: record.state,
56
+ unread: record.unread,
57
+ lastMsg: record.lastMsg,
58
+ createdAt: record.createdAt,
59
+ kind: record['kind'] === 'headless' ? 'headless' : 'pty',
60
+ alive: record.alive,
61
+ resumable: typeof record['agentSessionID'] === 'string',
62
+ canEject: record['canEject'] === true,
63
+ agent: toAgentID(record['agent']),
64
+ };
65
+ }
@@ -1,9 +1,9 @@
1
- import type { AgentKind } from './agent-adapter';
1
+ import type { AgentID } from '../agents/agent-adapter';
2
+ import { PINNED_GROUP_KEY } from '../daemon/sessions';
3
+ import type { SessionState } from '../daemon/sessions';
4
+ import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
2
5
  import { formatDir } from './dirs';
3
6
  import { formatOverlayAgentMark } from './format-overlay-agent-mark';
4
- import { RESET_INPUT_MODES } from './reset-input-modes';
5
- import { PINNED_GROUP_KEY } from './sessions';
6
- import type { SessionState } from './sessions';
7
7
 
8
8
  // The slice of a session the drawing layer needs; satisfied by both the
9
9
  // daemon's sessions and the wire descriptors a client mirrors.
@@ -163,13 +163,18 @@ export interface OverlaySessionView extends SessionView {
163
163
  readonly alive: boolean;
164
164
  readonly kind: 'pty' | 'headless';
165
165
  readonly resumable: boolean;
166
- readonly agent: AgentKind;
166
+ readonly canEject: boolean;
167
+ readonly agent: AgentID;
167
168
  readonly pinned: boolean;
168
169
  readonly repoRoot: string;
169
170
  }
170
171
 
171
172
  export interface OverlayView {
172
173
  sessions: readonly OverlaySessionView[];
174
+
175
+ // The overlay column character per agent id, for the ids that do not use
176
+ // the built-in one.
177
+ agentMarks: Readonly<Record<AgentID, string>>;
173
178
  selected: number;
174
179
  confirmKill: boolean;
175
180
  filter: string | null;
@@ -209,8 +214,8 @@ export function drawOverlay(view: OverlayView) {
209
214
  const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
210
215
  const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
211
216
  const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
212
- const mark = formatOverlayAgentMark(s.agent);
213
- const styledMark = mark === 'g' ? `${ESC}[90m${mark}${ESC}[0m` : mark;
217
+ const mark = formatOverlayAgentMark(s.agent, view.agentMarks);
218
+ const styledMark = mark === ' ' ? mark : `${ESC}[90m${mark}${ESC}[0m`;
214
219
  const body = `${name} ${state}${dir} ${msg}`;
215
220
  const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
216
221
 
@@ -269,7 +274,7 @@ export function buildOverlayHint(s: OverlaySessionView | undefined): string {
269
274
  actions.push('a ack');
270
275
  }
271
276
 
272
- if (s.agent === 'claude') {
277
+ if (s.canEject) {
273
278
  actions.push('H headless');
274
279
  }
275
280