@zgeoff/atc 0.1.9 → 0.1.11
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 +114 -54
- package/package.json +1 -1
- package/src/agent-adapter.ts +44 -0
- package/src/boot-daemon.ts +7 -1
- package/src/build-cli-command.ts +16 -0
- package/src/claude-adapter.ts +26 -18
- package/src/cli.ts +36 -2
- package/src/codex-adapter.ts +178 -0
- package/src/collect-clean-env.ts +35 -5
- package/src/config.ts +22 -1
- package/src/daemon-connection.ts +28 -1
- package/src/daemon.ts +58 -57
- package/src/format-overlay-agent-mark.ts +17 -0
- package/src/grok-adapter.ts +274 -0
- package/src/hook-report.ts +8 -4
- package/src/index.ts +96 -15
- package/src/mcp-server.ts +20 -1
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +1 -1
- package/src/print-codex-hook-file.ts +26 -0
- package/src/print-grok-hook-file.ts +27 -0
- package/src/protocol.ts +1 -1
- package/src/sessions.ts +133 -41
- package/src/state-store.ts +62 -18
- package/src/ui.ts +22 -10
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
AdapterEvent,
|
|
6
|
+
AgentAdapter,
|
|
7
|
+
NameUpdate,
|
|
8
|
+
ResumeCheck,
|
|
9
|
+
SpawnOptions,
|
|
10
|
+
SpawnPlan,
|
|
11
|
+
} from './agent-adapter';
|
|
12
|
+
import type { Config } from './config';
|
|
13
|
+
import type { HookEvent } from './hooks';
|
|
14
|
+
import { isRecord } from './report';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The Codex CLI adapter: spawn arguments, hook payload mapping, resume
|
|
18
|
+
* semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
|
|
19
|
+
* entry in the Codex hook config (`atc codex-hooks` prints it) that the user
|
|
20
|
+
* trusts once in the Codex TUI; atc never writes into the user's Codex config.
|
|
21
|
+
*/
|
|
22
|
+
export class CodexAdapter implements AgentAdapter {
|
|
23
|
+
readonly kind = 'codex';
|
|
24
|
+
|
|
25
|
+
// Codex has no headless handoff.
|
|
26
|
+
readonly headlessRunner = null;
|
|
27
|
+
|
|
28
|
+
// Codex's hooks are authoritative; no screen heuristics needed.
|
|
29
|
+
readonly screenDetector = null;
|
|
30
|
+
|
|
31
|
+
private readonly config: Config;
|
|
32
|
+
|
|
33
|
+
constructor(config: Config) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
38
|
+
return {
|
|
39
|
+
bin: this.config.codexBin,
|
|
40
|
+
args: [
|
|
41
|
+
...this.config.codexArgs,
|
|
42
|
+
|
|
43
|
+
// Bare resume opens Codex's own session picker; an id resumes that
|
|
44
|
+
// session directly. Both accept a prompt afterwards.
|
|
45
|
+
...(opts.resume === true ? ['resume'] : []),
|
|
46
|
+
...(typeof opts.resume === 'string' ? ['resume', opts.resume] : []),
|
|
47
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
normalizeHook(e: HookEvent): AdapterEvent {
|
|
53
|
+
const sessionID = e.payload['session_id'];
|
|
54
|
+
const transcript = e.payload['transcript_path'];
|
|
55
|
+
|
|
56
|
+
const base: AdapterEvent = {
|
|
57
|
+
kind: 'heartbeat',
|
|
58
|
+
...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const named: AdapterEvent = {
|
|
62
|
+
...base,
|
|
63
|
+
...(typeof sessionID === 'string' ? { nameSource: sessionID } : {}),
|
|
64
|
+
...(typeof transcript === 'string' ? { transcriptSource: transcript } : {}),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
switch (e.event) {
|
|
68
|
+
case 'SessionStart': {
|
|
69
|
+
return { ...named, kind: 'started' };
|
|
70
|
+
}
|
|
71
|
+
case 'PermissionRequest': {
|
|
72
|
+
const toolName = e.payload['tool_name'];
|
|
73
|
+
|
|
74
|
+
const message =
|
|
75
|
+
typeof toolName === 'string' && toolName !== ''
|
|
76
|
+
? `waiting for approval: ${toolName}`
|
|
77
|
+
: 'waiting for approval';
|
|
78
|
+
|
|
79
|
+
return { ...base, kind: 'needs-input', message, detail: message };
|
|
80
|
+
}
|
|
81
|
+
case 'UserPromptSubmit': {
|
|
82
|
+
const prompt = e.payload['prompt'];
|
|
83
|
+
const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...named,
|
|
87
|
+
kind: 'prompt-submitted',
|
|
88
|
+
...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
case 'Stop': {
|
|
92
|
+
const lastMessage = e.payload['last_assistant_message'];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...named,
|
|
96
|
+
kind: 'turn-done',
|
|
97
|
+
...(typeof lastMessage === 'string' && lastMessage !== ''
|
|
98
|
+
? { detail: truncateDetail(lastMessage) }
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
case 'SessionEnd': {
|
|
103
|
+
return { ...base, kind: 'ended' };
|
|
104
|
+
}
|
|
105
|
+
default: {
|
|
106
|
+
return base;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Codex records session titles in session_index.jsonl; the index does not
|
|
112
|
+
// say whether a title was set by /rename or auto-generated, so a title
|
|
113
|
+
// never overrides a user-typed name.
|
|
114
|
+
loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
|
|
115
|
+
if (namedBy === 'user') {
|
|
116
|
+
return Promise.resolve(null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const index = join(resolveCodexHome(), 'session_index.jsonl');
|
|
120
|
+
let text: string;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
text = readFileSync(index, 'utf8');
|
|
124
|
+
} catch {
|
|
125
|
+
return Promise.resolve(null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let name: string | undefined;
|
|
129
|
+
|
|
130
|
+
for (const line of text.split('\n')) {
|
|
131
|
+
if (line.trim() === '' || !line.includes(source)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const parsed: unknown = JSON.parse(line);
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
isRecord(parsed) &&
|
|
140
|
+
parsed['id'] === source &&
|
|
141
|
+
typeof parsed['thread_name'] === 'string'
|
|
142
|
+
) {
|
|
143
|
+
name = parsed['thread_name'];
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const update = name === undefined || name === '' ? null : { name };
|
|
149
|
+
|
|
150
|
+
return Promise.resolve(update);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
canResume(session: ResumeCheck): boolean {
|
|
154
|
+
if (session.transcriptSource === undefined) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return existsSync(session.transcriptSource);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Shell command that re-opens this session outside atc (or anywhere).
|
|
162
|
+
buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
|
|
163
|
+
const resume = agentSessionID === undefined ? 'codex resume' : `codex resume ${agentSessionID}`;
|
|
164
|
+
const quoted = cwd.replaceAll("'", String.raw`'\''`);
|
|
165
|
+
|
|
166
|
+
return `cd '${quoted}' && ${resume}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveCodexHome(): string {
|
|
171
|
+
const home = process.env['CODEX_HOME'];
|
|
172
|
+
|
|
173
|
+
return home !== undefined && home !== '' ? home : join(homedir(), '.codex');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function truncateDetail(text: string): string {
|
|
177
|
+
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
178
|
+
}
|
package/src/collect-clean-env.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The process environment with any enclosing Claude
|
|
3
|
-
* out. A
|
|
4
|
-
* the
|
|
5
|
-
*
|
|
2
|
+
* The process environment with any enclosing Claude or Grok session
|
|
3
|
+
* scrubbed out. A child that inherits those variables behaves as part of
|
|
4
|
+
* the parent session — Claude transcripts nest, and Grok joins the
|
|
5
|
+
* parent's in-process dashboard — which breaks resume and isolation.
|
|
6
6
|
*/
|
|
7
7
|
export function collectCleanEnv(
|
|
8
8
|
extra: Readonly<Record<string, string>> = {},
|
|
@@ -10,7 +10,7 @@ export function collectCleanEnv(
|
|
|
10
10
|
const env: Record<string, string> = {};
|
|
11
11
|
|
|
12
12
|
for (const [key, value] of Object.entries(process.env)) {
|
|
13
|
-
if (value === undefined || key
|
|
13
|
+
if (value === undefined || isParentSessionKey(key)) {
|
|
14
14
|
continue;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -19,3 +19,33 @@ export function collectCleanEnv(
|
|
|
19
19
|
|
|
20
20
|
return { ...env, ...extra };
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
const GROK_PARENT_KEYS = new Set([
|
|
24
|
+
'GROK_SESSION_ID',
|
|
25
|
+
'GROK_LEADER_SOCKET',
|
|
26
|
+
'GROK_LEADER_LOG',
|
|
27
|
+
'GROK_HOOK_EVENT',
|
|
28
|
+
'GROK_HOOK_NAME',
|
|
29
|
+
'GROK_WORKSPACE_ROOT',
|
|
30
|
+
'GROK_EVENT',
|
|
31
|
+
'GROK_MESSAGE',
|
|
32
|
+
'GROK_PLUGIN_ROOT',
|
|
33
|
+
'GROK_PLUGIN_DATA',
|
|
34
|
+
'GROK_SESSION_RESTORED',
|
|
35
|
+
'GROK_AGENT_METADATA',
|
|
36
|
+
'GROK_INIT_STATE_MARKER__',
|
|
37
|
+
'GROK_BASH_STATE_START__',
|
|
38
|
+
'GROK_BASH_STATE_END__',
|
|
39
|
+
'GROK_ZSH_STATE_START__',
|
|
40
|
+
'GROK_ZSH_STATE_END__',
|
|
41
|
+
'GROK_INSIDE_BWRAP',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function isParentSessionKey(key: string): boolean {
|
|
45
|
+
return (
|
|
46
|
+
key === 'CLAUDECODE' ||
|
|
47
|
+
key.startsWith('CLAUDE_CODE_') ||
|
|
48
|
+
key.startsWith('CODEX_SANDBOX') ||
|
|
49
|
+
GROK_PARENT_KEYS.has(key)
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { isRecord } from './report';
|
|
|
6
6
|
export interface Config {
|
|
7
7
|
claudeBin: string;
|
|
8
8
|
claudeArgs: string[];
|
|
9
|
+
grokBin: string;
|
|
10
|
+
grokArgs: string[];
|
|
11
|
+
codexBin: string;
|
|
12
|
+
codexArgs: string[];
|
|
9
13
|
leader: LeaderKey;
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -17,6 +21,10 @@ interface LeaderKey {
|
|
|
17
21
|
const DEFAULTS: Config = {
|
|
18
22
|
claudeBin: 'claude',
|
|
19
23
|
claudeArgs: [],
|
|
24
|
+
grokBin: 'grok',
|
|
25
|
+
grokArgs: [],
|
|
26
|
+
codexBin: 'codex',
|
|
27
|
+
codexArgs: [],
|
|
20
28
|
leader: { code: 0, label: '^Space' },
|
|
21
29
|
};
|
|
22
30
|
|
|
@@ -57,11 +65,24 @@ export function loadConfig(): Config {
|
|
|
57
65
|
? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
|
|
58
66
|
: DEFAULTS.claudeArgs;
|
|
59
67
|
|
|
68
|
+
const grokBin = typeof parsed['grokBin'] === 'string' ? parsed['grokBin'] : DEFAULTS.grokBin;
|
|
69
|
+
|
|
70
|
+
const grokArgs = Array.isArray(parsed['grokArgs'])
|
|
71
|
+
? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
|
|
72
|
+
: DEFAULTS.grokArgs;
|
|
73
|
+
|
|
74
|
+
const codexBin =
|
|
75
|
+
typeof parsed['codexBin'] === 'string' ? parsed['codexBin'] : DEFAULTS.codexBin;
|
|
76
|
+
|
|
77
|
+
const codexArgs = Array.isArray(parsed['codexArgs'])
|
|
78
|
+
? parsed['codexArgs'].filter((a): a is string => typeof a === 'string')
|
|
79
|
+
: DEFAULTS.codexArgs;
|
|
80
|
+
|
|
60
81
|
const leader =
|
|
61
82
|
(typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
|
|
62
83
|
DEFAULTS.leader;
|
|
63
84
|
|
|
64
|
-
return { claudeBin, claudeArgs, leader };
|
|
85
|
+
return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, leader };
|
|
65
86
|
} catch {
|
|
66
87
|
return { ...DEFAULTS };
|
|
67
88
|
}
|
package/src/daemon-connection.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import type { AgentAdapter, AgentKind } from './agent-adapter';
|
|
2
3
|
import type { Dims } from './attach-registry';
|
|
3
4
|
import { OutboundQueue } from './outbound-queue';
|
|
4
5
|
import type { SocketWriter } from './outbound-queue';
|
|
@@ -15,6 +16,7 @@ interface SpawnParams {
|
|
|
15
16
|
readonly rows: number;
|
|
16
17
|
readonly resume: boolean | string;
|
|
17
18
|
readonly namedBy: 'user' | 'auto';
|
|
19
|
+
readonly agent: AgentKind;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface DaemonContext {
|
|
@@ -22,6 +24,8 @@ export interface DaemonContext {
|
|
|
22
24
|
readonly collectSessions: () => SessionDescriptor[];
|
|
23
25
|
readonly collectSpawnDirs: () => string[];
|
|
24
26
|
readonly collectFleet: () => FleetEntry[];
|
|
27
|
+
readonly loadLastUsedAgent: () => AgentKind;
|
|
28
|
+
readonly findAdapter: (kind: AgentKind) => AgentAdapter | null;
|
|
25
29
|
readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
|
|
26
30
|
readonly killSession: (id: string) => boolean;
|
|
27
31
|
readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
|
|
@@ -269,7 +273,7 @@ export class DaemonConnection {
|
|
|
269
273
|
if (result === 'ok') {
|
|
270
274
|
this.sendOk(req.id, {});
|
|
271
275
|
} else if (result === 'unsupported') {
|
|
272
|
-
this.sendErr(req.id, 'unsupported',
|
|
276
|
+
this.sendErr(req.id, 'unsupported', "this session's agent has no headless handoff");
|
|
273
277
|
} else if (result === 'no_transcript') {
|
|
274
278
|
this.sendErr(
|
|
275
279
|
req.id,
|
|
@@ -369,6 +373,27 @@ export class DaemonConnection {
|
|
|
369
373
|
resume = rawResume;
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
const rawAgent = req.p?.['agent'];
|
|
377
|
+
|
|
378
|
+
if (
|
|
379
|
+
rawAgent !== undefined &&
|
|
380
|
+
rawAgent !== 'claude' &&
|
|
381
|
+
rawAgent !== 'grok' &&
|
|
382
|
+
rawAgent !== 'codex'
|
|
383
|
+
) {
|
|
384
|
+
this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude', 'grok', or 'codex'");
|
|
385
|
+
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const agent: AgentKind = rawAgent === 'grok' || rawAgent === 'codex' ? rawAgent : 'claude';
|
|
390
|
+
|
|
391
|
+
if (this.ctx.findAdapter(agent) === null) {
|
|
392
|
+
this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
|
|
393
|
+
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
372
397
|
const session = this.ctx.spawnSession({
|
|
373
398
|
cwd,
|
|
374
399
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -377,6 +402,7 @@ export class DaemonConnection {
|
|
|
377
402
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
378
403
|
resume,
|
|
379
404
|
namedBy: name === '' ? 'auto' : 'user',
|
|
405
|
+
agent,
|
|
380
406
|
});
|
|
381
407
|
|
|
382
408
|
this.sendOk(req.id, { session });
|
|
@@ -517,6 +543,7 @@ export class DaemonConnection {
|
|
|
517
543
|
this.sendOk(req.id, {
|
|
518
544
|
daemon: this.ctx.build,
|
|
519
545
|
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
546
|
+
lastUsedAgent: this.ctx.loadLastUsedAgent(),
|
|
520
547
|
});
|
|
521
548
|
|
|
522
549
|
return true;
|
package/src/daemon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { AgentAdapter } from './agent-adapter';
|
|
1
|
+
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { AgentAdapter, AgentKind } from './agent-adapter';
|
|
3
3
|
import { AttachRegistry } from './attach-registry';
|
|
4
4
|
import type { Dims } from './attach-registry';
|
|
5
5
|
import { DaemonConnection } from './daemon-connection';
|
|
@@ -21,6 +21,10 @@ export interface DaemonOptions {
|
|
|
21
21
|
readonly build: string;
|
|
22
22
|
readonly adapter: AgentAdapter;
|
|
23
23
|
|
|
24
|
+
// Optional per-kind adapters. Lookup never falls back across kinds: a
|
|
25
|
+
// grok session with no grok adapter is unsupported, not a Claude spawn.
|
|
26
|
+
readonly adapters?: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
|
|
27
|
+
|
|
24
28
|
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
25
29
|
// fleet table once so upgrading keeps the restorable fleet.
|
|
26
30
|
readonly dbPath: string;
|
|
@@ -35,11 +39,6 @@ export interface DaemonOptions {
|
|
|
35
39
|
// Outbound queue capacity per client; small values force desync in tests.
|
|
36
40
|
readonly queueBytes?: number;
|
|
37
41
|
|
|
38
|
-
// Runs one headless turn over a session; the real one drives the Agent
|
|
39
|
-
// SDK, which spawns real claude — a boundary tests never cross. Absent
|
|
40
|
-
// means eject is unsupported.
|
|
41
|
-
readonly headlessRunner?: HeadlessRunner;
|
|
42
|
-
|
|
43
42
|
// How long an eject waits for the dying terminal to report SessionEnd
|
|
44
43
|
// before starting the headless run anyway.
|
|
45
44
|
readonly ejectSettleMs?: number;
|
|
@@ -56,24 +55,6 @@ export interface DaemonOptions {
|
|
|
56
55
|
readonly onQuit?: () => void;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
interface HeadlessRunRequest {
|
|
60
|
-
readonly cwd: string;
|
|
61
|
-
readonly prompt: string;
|
|
62
|
-
readonly resume?: string;
|
|
63
|
-
readonly permissionMode?: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface HeadlessRunEvents {
|
|
67
|
-
readonly onOutput: (text: string) => void;
|
|
68
|
-
readonly onDone: (summary: string) => void;
|
|
69
|
-
readonly onNeedsYou: (msg: string) => void;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type HeadlessRunner = (
|
|
73
|
-
opts: HeadlessRunRequest,
|
|
74
|
-
hooks: HeadlessRunEvents,
|
|
75
|
-
) => { readonly stop: () => void };
|
|
76
|
-
|
|
77
58
|
export interface DaemonHandle {
|
|
78
59
|
readonly stop: () => void;
|
|
79
60
|
}
|
|
@@ -95,12 +76,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
95
76
|
}
|
|
96
77
|
|
|
97
78
|
const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
|
|
98
|
-
|
|
99
|
-
const mgr =
|
|
100
|
-
opts.statusPath === undefined
|
|
101
|
-
? new SessionManager(opts.adapter, store)
|
|
102
|
-
: new SessionManager(opts.adapter, store, opts.statusPath);
|
|
103
|
-
|
|
79
|
+
const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? {});
|
|
104
80
|
const clients = new Set<DaemonConnection>();
|
|
105
81
|
|
|
106
82
|
const emitEvent = (event: EventMsg) => {
|
|
@@ -165,10 +141,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
165
141
|
};
|
|
166
142
|
|
|
167
143
|
const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
|
|
168
|
-
const runner = opts.headlessRunner;
|
|
169
144
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
145
|
+
const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
|
|
170
146
|
|
|
171
|
-
if (
|
|
147
|
+
if (s === undefined || runner === null || headlessRuns.has(sessionID)) {
|
|
172
148
|
return false;
|
|
173
149
|
}
|
|
174
150
|
|
|
@@ -178,7 +154,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
178
154
|
{
|
|
179
155
|
cwd: s.cwd,
|
|
180
156
|
prompt,
|
|
181
|
-
...(s.
|
|
157
|
+
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
182
158
|
permissionMode: 'auto',
|
|
183
159
|
},
|
|
184
160
|
{
|
|
@@ -211,11 +187,13 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
211
187
|
const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
212
188
|
const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
213
189
|
const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
190
|
+
const pendingLastUsed = new Set<string>();
|
|
214
191
|
|
|
215
192
|
// The screen tier of the detector stack: once a session's output has
|
|
216
193
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
217
194
|
const scheduleDetect = (sessionID: string) => {
|
|
218
|
-
const
|
|
195
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
196
|
+
const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
|
|
219
197
|
|
|
220
198
|
if (detector === null) {
|
|
221
199
|
return;
|
|
@@ -306,7 +284,8 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
306
284
|
};
|
|
307
285
|
|
|
308
286
|
const applyScreenJudgment = async (sessionID: string) => {
|
|
309
|
-
const
|
|
287
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
288
|
+
const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
|
|
310
289
|
const model = screens.get(sessionID);
|
|
311
290
|
|
|
312
291
|
if (detector === null || model === undefined) {
|
|
@@ -416,8 +395,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
416
395
|
unread: s.unread,
|
|
417
396
|
lastMsg: s.lastMsg,
|
|
418
397
|
kind: s.kind,
|
|
419
|
-
alive: s.pty !== null || (s.kind === '
|
|
420
|
-
...(s.
|
|
398
|
+
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
399
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
400
|
+
agent: s.agent,
|
|
421
401
|
pinned: s.pinned,
|
|
422
402
|
lastAttachedAt: s.lastAttachedAt,
|
|
423
403
|
}),
|
|
@@ -439,17 +419,22 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
439
419
|
store.recordEvent(e);
|
|
440
420
|
}
|
|
441
421
|
|
|
442
|
-
|
|
422
|
+
const kind = mgr.applyHook(e);
|
|
423
|
+
|
|
424
|
+
if (kind === 'ended') {
|
|
443
425
|
pendingEjects.get(e.atcId)?.();
|
|
444
426
|
}
|
|
445
427
|
|
|
446
428
|
// A revived session announcing itself is the cue a staggered restore
|
|
447
429
|
// waits on before booting the next one.
|
|
448
|
-
if (
|
|
430
|
+
if (kind === 'started') {
|
|
449
431
|
bootWaiters.get(e.atcId)?.();
|
|
450
|
-
|
|
432
|
+
const started = mgr.sessions.find((s) => s.id === e.atcId);
|
|
451
433
|
|
|
452
|
-
|
|
434
|
+
if (started !== undefined && pendingLastUsed.delete(started.id)) {
|
|
435
|
+
store.writeLastUsedAgent(started.agent);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
453
438
|
}, opts.reporterSocketPath);
|
|
454
439
|
|
|
455
440
|
const ctx: DaemonContext = {
|
|
@@ -457,9 +442,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
457
442
|
collectSessions: () => mgr.collectDescriptors(),
|
|
458
443
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
459
444
|
collectFleet: () => store.loadFleet(),
|
|
445
|
+
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
446
|
+
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
460
447
|
spawnSession: (p) => {
|
|
461
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
448
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
|
|
462
449
|
|
|
450
|
+
pendingLastUsed.add(s.id);
|
|
463
451
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
464
452
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
465
453
|
store.recordSpawnDir(p.cwd);
|
|
@@ -487,7 +475,15 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
487
475
|
return true;
|
|
488
476
|
},
|
|
489
477
|
ejectSession: (id, prompt) => {
|
|
490
|
-
|
|
478
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
479
|
+
|
|
480
|
+
if (s === undefined) {
|
|
481
|
+
return 'missing';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
485
|
+
|
|
486
|
+
if (adapter === null || adapter.headlessRunner === null) {
|
|
491
487
|
return 'unsupported';
|
|
492
488
|
}
|
|
493
489
|
|
|
@@ -589,7 +585,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
589
585
|
return 'missing';
|
|
590
586
|
}
|
|
591
587
|
|
|
592
|
-
if (s.kind === '
|
|
588
|
+
if (s.kind === 'headless') {
|
|
593
589
|
if (headlessRuns.has(sessionID)) {
|
|
594
590
|
return 'busy';
|
|
595
591
|
}
|
|
@@ -619,11 +615,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
619
615
|
getEffectiveDims: (sessionID) =>
|
|
620
616
|
attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
|
|
621
617
|
restoreFleet: (cols, rows) => {
|
|
622
|
-
const hasSession = (
|
|
618
|
+
const hasSession = (agentSessionID: string) =>
|
|
623
619
|
mgr.sessions.some(
|
|
624
620
|
(s) =>
|
|
625
|
-
s.
|
|
626
|
-
(s.pty !== null || (s.kind === '
|
|
621
|
+
s.agentSessionID === agentSessionID &&
|
|
622
|
+
(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
|
|
627
623
|
);
|
|
628
624
|
|
|
629
625
|
const recency = store.collectFleetRecency();
|
|
@@ -633,9 +629,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
633
629
|
// reported an event keep their stored order at the end.
|
|
634
630
|
const entries = store
|
|
635
631
|
.loadFleet()
|
|
636
|
-
.filter((entry) => !hasSession(entry.
|
|
632
|
+
.filter((entry) => !hasSession(entry.agentSessionID))
|
|
637
633
|
.toSorted((a, b) =>
|
|
638
|
-
(recency.get(b.
|
|
634
|
+
(recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
|
|
639
635
|
);
|
|
640
636
|
|
|
641
637
|
// The whole fleet registers as terminal-less sessions up front, so the
|
|
@@ -749,18 +745,23 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
749
745
|
return { stop: stopDaemon };
|
|
750
746
|
}
|
|
751
747
|
|
|
752
|
-
// A session reporting a transcript path that does not exist on disk yet has
|
|
753
|
-
// nothing to resume — claude exits immediately on such a resume, which
|
|
754
|
-
// reads as the revive silently failing. A session that never reported a
|
|
755
|
-
// path (foreign adapters) passes; the spawn itself will tell the truth.
|
|
756
748
|
function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
|
|
757
749
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
758
750
|
|
|
759
|
-
if (s === undefined
|
|
760
|
-
return
|
|
751
|
+
if (s === undefined) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
756
|
+
|
|
757
|
+
if (adapter === null) {
|
|
758
|
+
return false;
|
|
761
759
|
}
|
|
762
760
|
|
|
763
|
-
return
|
|
761
|
+
return adapter.canResume({
|
|
762
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
763
|
+
...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
|
|
764
|
+
});
|
|
764
765
|
}
|
|
765
766
|
|
|
766
767
|
function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentKind } from './agent-adapter';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The reserved overlay column after unread: a `g` for Grok, a space for
|
|
5
|
+
* Claude, so session names stay aligned across a mixed fleet.
|
|
6
|
+
*/
|
|
7
|
+
export function formatOverlayAgentMark(agent: AgentKind): string {
|
|
8
|
+
if (agent === 'grok') {
|
|
9
|
+
return 'g';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (agent === 'codex') {
|
|
13
|
+
return 'x';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ' ';
|
|
17
|
+
}
|