@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.
- package/README.md +50 -12
- package/package.json +7 -2
- package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
- package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
- package/src/agents/build-hook-settings.ts +59 -0
- package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
- package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
- package/src/agents/gateway-adapter.ts +106 -0
- package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
- package/src/agents/resolve-agent-home.ts +13 -0
- package/src/agents/truncate-detail.ts +7 -0
- package/src/agents/write-hook-settings.ts +37 -0
- package/src/cli.ts +25 -14
- package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
- package/src/client/build-client-machine.ts +119 -0
- package/src/client/collect-agent-picks.ts +31 -0
- package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
- package/src/{dirs.ts → client/dirs.ts} +8 -0
- package/src/client/format-overlay-agent-mark.ts +15 -0
- package/src/{index.ts → client/index.ts} +181 -550
- package/src/client/keys.ts +111 -0
- package/src/client/parse-daemon-event.ts +114 -0
- package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
- package/src/client/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +13 -8
- package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
- package/src/daemon/build-session-event.ts +43 -0
- package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
- package/src/{daemon.ts → daemon/daemon.ts} +142 -312
- package/src/{hooks.ts → daemon/hooks.ts} +6 -4
- package/src/daemon/mint-session-id.ts +12 -0
- package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
- package/src/daemon/restore-fleet.ts +141 -0
- package/src/daemon/run-eject-handoff.ts +33 -0
- package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
- package/src/daemon/session-runtime.ts +64 -0
- package/src/{sessions.ts → daemon/sessions.ts} +77 -133
- package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
- package/src/daemon/start-headless-turn.ts +57 -0
- package/src/hook-report.ts +2 -2
- package/src/mcp-server.ts +33 -44
- package/src/protocol/parse-request-params.ts +31 -0
- package/src/{protocol.ts → protocol/protocol.ts} +1 -1
- package/src/protocol/request-param-schemas.ts +109 -0
- package/src/shared/agent-session-id.ts +8 -0
- package/src/shared/build-optional-boolean.ts +10 -0
- package/src/shared/build-optional-string-array.ts +14 -0
- package/src/shared/build-optional-string.ts +10 -0
- package/src/shared/collect-gateways.ts +113 -0
- package/src/{config.ts → shared/config.ts} +47 -31
- package/src/{get-build.ts → shared/get-build.ts} +21 -6
- package/src/shared/get-record.ts +20 -0
- package/src/shared/session-id.ts +8 -0
- package/src/shared/to-agent-session-id.ts +12 -0
- package/src/shared/to-session-id.ts +11 -0
- package/src/shared/to-shell-arg.ts +7 -0
- package/src/statusline.ts +2 -2
- package/src/store/bun-sqlite-driver.ts +96 -0
- package/src/store/fleet-entry.ts +72 -0
- package/src/store/run-migrations.ts +242 -0
- package/src/store/state-store.ts +226 -0
- package/src/format-overlay-agent-mark.ts +0 -17
- package/src/state-store.ts +0 -236
- /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
- /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
- /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
- /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
- /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
- /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
- /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
- /package/src/{report.ts → shared/report.ts} +0 -0
- /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
- /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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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 === '
|
|
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.
|
|
277
|
+
if (s.canEject) {
|
|
273
278
|
actions.push('H headless');
|
|
274
279
|
}
|
|
275
280
|
|