@zgeoff/atc 0.1.10 → 0.1.12
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 +28 -11
- package/package.json +1 -1
- package/src/agent-adapter.ts +2 -2
- package/src/cli.ts +16 -1
- package/src/codex-adapter.ts +178 -0
- package/src/collect-clean-env.ts +6 -1
- package/src/config.ts +12 -1
- package/src/daemon-connection.ts +8 -3
- package/src/daemon.ts +18 -7
- package/src/format-overlay-agent-mark.ts +9 -1
- package/src/index.ts +23 -6
- package/src/mcp-server.ts +12 -5
- package/src/print-codex-hook-file.ts +26 -0
- package/src/sessions.ts +24 -14
- package/src/state-store.ts +12 -3
- package/src/ui.ts +1 -1
package/README.md
CHANGED
|
@@ -27,10 +27,11 @@ atc
|
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
|
|
30
|
-
Grok sessions also need the `grok` CLI
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
Grok sessions also need the `grok` CLI, and Codex sessions the `codex` CLI. From a checkout,
|
|
31
|
+
`bun src/cli.ts` runs the same thing. atc is built to pair with
|
|
32
|
+
[zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker feeds on its frecency
|
|
33
|
+
list, so with zoxide installed every directory you visit is two keystrokes from a session. Without
|
|
34
|
+
it the picker falls back to atc's own spawn history.
|
|
34
35
|
|
|
35
36
|
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
36
37
|
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
@@ -42,7 +43,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
42
43
|
| --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
44
|
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
|
|
44
45
|
| `n` | home/overlay | spawn: pick agent → dir (zoxide + history, fuzzy) → name → optional first prompt. Fresh clients default to Claude; last-used is the last deliberate-spawn SessionStart. |
|
|
45
|
-
| `r` | home/overlay | adopt: pick agent → dir → name. Claude opens `claude --resume`. Grok opens plain `grok`.
|
|
46
|
+
| `r` | home/overlay | adopt: pick agent → dir → name. Claude opens `claude --resume`, Codex `codex resume`. Grok opens plain `grok`. |
|
|
46
47
|
| `R` | home | restore last fleet after a daemon death — each session with its matching CLI |
|
|
47
48
|
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
48
49
|
| `Enter` | overlay | attach (auto-acks) |
|
|
@@ -53,9 +54,9 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
53
54
|
| `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
|
|
54
55
|
| `H` | overlay | eject to headless (Claude only). Hidden and ignored on a Grok row. |
|
|
55
56
|
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
56
|
-
| `y` | overlay | yank the resume command (`claude --resume <id
|
|
57
|
+
| `y` | overlay | yank the resume command (`claude --resume <id>`, `grok --resume <id>`, or `codex resume <id>`) |
|
|
57
58
|
| `Y` | overlay | eject: yank the resume command, then kill the session here |
|
|
58
|
-
| `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P
|
|
59
|
+
| `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`, even across daemon restarts; a second `K` forgets it |
|
|
59
60
|
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
60
61
|
| `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
|
|
61
62
|
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
@@ -91,6 +92,17 @@ mkdir -p ~/.grok/hooks
|
|
|
91
92
|
atc grok-hooks > ~/.grok/hooks/atc-reporter.json
|
|
92
93
|
```
|
|
93
94
|
|
|
95
|
+
Codex attention comes from hook entries in `$CODEX_HOME/hooks.json` (`~/.codex` when `CODEX_HOME` is
|
|
96
|
+
unset). atc never writes that path either — `atc codex-hooks` prints the entries to merge in:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
atc codex-hooks
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Codex parses new hooks but never runs them until you trust them once: open `codex`, review the atc
|
|
103
|
+
hooks in its hooks list, and approve them. Sessions you start outside atc report events too; the
|
|
104
|
+
reporter exits immediately when no atc session id is present.
|
|
105
|
+
|
|
94
106
|
`atc grok-hooks` prints this file, with the `hook-report` command resolved for this install:
|
|
95
107
|
|
|
96
108
|
```json
|
|
@@ -133,6 +145,8 @@ session.
|
|
|
133
145
|
"claudeArgs": [],
|
|
134
146
|
"grokBin": "grok",
|
|
135
147
|
"grokArgs": [],
|
|
148
|
+
"codexBin": "codex",
|
|
149
|
+
"codexArgs": [],
|
|
136
150
|
"leader": "ctrl-space"
|
|
137
151
|
}
|
|
138
152
|
```
|
|
@@ -143,6 +157,8 @@ session.
|
|
|
143
157
|
| `claudeArgs` | `[]` | Prepended to every Claude spawn, e.g. `["--model", "opus"]`. |
|
|
144
158
|
| `grokBin` | `"grok"` | The binary spawned for Grok sessions. |
|
|
145
159
|
| `grokArgs` | `[]` | Prepended to every Grok spawn. A user `--leader` in this list is dropped; atc always appends `--no-leader`. |
|
|
160
|
+
| `codexBin` | `"codex"` | The binary spawned for Codex sessions. |
|
|
161
|
+
| `codexArgs` | `[]` | Prepended to every Codex spawn. |
|
|
146
162
|
| `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
|
|
147
163
|
|
|
148
164
|
Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
|
|
@@ -150,8 +166,8 @@ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast o
|
|
|
150
166
|
unknown or reserved value falls back to the default.
|
|
151
167
|
|
|
152
168
|
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
|
|
153
|
-
sessions included. `atc_session_spawn` takes an optional `agent` (`claude` or `
|
|
154
|
-
to Claude; it never reads the TUI last-used value.
|
|
169
|
+
sessions included. `atc_session_spawn` takes an optional `agent` (`claude`, `grok`, or `codex`) and
|
|
170
|
+
defaults to Claude; it never reads the TUI last-used value.
|
|
155
171
|
|
|
156
172
|
```sh
|
|
157
173
|
claude mcp add --scope user atc -- atc mcp
|
|
@@ -170,8 +186,9 @@ restarts the daemon and restores the fleet at a moment you choose. Only a protoc
|
|
|
170
186
|
the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
|
|
171
187
|
live fleet (name, cwd, agent, session id) to its SQLite store. If the daemon itself dies — crash,
|
|
172
188
|
SIGKILL, reboot — the child processes die with it, but every session's transcript is already on
|
|
173
|
-
disk. Start atc and press `R`: the whole fleet respawns with the matching CLI.
|
|
174
|
-
|
|
189
|
+
disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Killed sessions (`K`,
|
|
190
|
+
`Y` eject) stay in the fleet as exited entries — restore lists them as killed without booting a
|
|
191
|
+
terminal, and `P` still revives them. A second `K` forgets an entry for good.
|
|
175
192
|
|
|
176
193
|
Restoring shows the whole fleet immediately — every incoming session appears in the list marked
|
|
177
194
|
"waiting to restore" — and revives one at a time, most recently active first: the next resume starts
|
package/package.json
CHANGED
package/src/agent-adapter.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { HookEvent } from './hooks';
|
|
2
2
|
|
|
3
|
-
export type AgentKind = 'claude' | 'grok';
|
|
3
|
+
export type AgentKind = 'claude' | 'grok' | 'codex';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Missing, empty, and unknown values become Claude so a fleet written
|
|
7
7
|
* before the agent column still restores as Claude.
|
|
8
8
|
*/
|
|
9
9
|
export function toAgentKind(raw: unknown): AgentKind {
|
|
10
|
-
return raw === 'grok'
|
|
10
|
+
return raw === 'grok' || raw === 'codex' ? raw : 'claude';
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface SpawnOptions {
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,7 @@ const main = defineCommand({
|
|
|
46
46
|
const config = await import('./config');
|
|
47
47
|
const claude = await import('./claude-adapter');
|
|
48
48
|
const grok = await import('./grok-adapter');
|
|
49
|
+
const codex = await import('./codex-adapter');
|
|
49
50
|
const headless = await import('./start-headless-run');
|
|
50
51
|
|
|
51
52
|
// Test harnesses shrink the outbound queue to force overflow
|
|
@@ -66,13 +67,14 @@ const main = defineCommand({
|
|
|
66
67
|
);
|
|
67
68
|
|
|
68
69
|
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
70
|
+
const codexAdapter = new codex.CodexAdapter(cfg);
|
|
69
71
|
|
|
70
72
|
const handle = daemon.startDaemon({
|
|
71
73
|
socketPath: config.daemonSocketPath,
|
|
72
74
|
reporterSocketPath: config.socketPath,
|
|
73
75
|
build: getBuild(),
|
|
74
76
|
adapter: claudeAdapter,
|
|
75
|
-
adapters: { claude: claudeAdapter, grok: grokAdapter },
|
|
77
|
+
adapters: { claude: claudeAdapter, grok: grokAdapter, codex: codexAdapter },
|
|
76
78
|
dbPath: config.dbFile,
|
|
77
79
|
legacyFleetPath: config.legacyFleetFile,
|
|
78
80
|
pidPath: config.daemonPidFile,
|
|
@@ -87,6 +89,19 @@ const main = defineCommand({
|
|
|
87
89
|
});
|
|
88
90
|
},
|
|
89
91
|
}),
|
|
92
|
+
'codex-hooks': () =>
|
|
93
|
+
defineCommand({
|
|
94
|
+
meta: {
|
|
95
|
+
name: 'codex-hooks',
|
|
96
|
+
description:
|
|
97
|
+
'Print the Codex hook entries to merge into $CODEX_HOME/hooks.json (trust them once in the Codex TUI)',
|
|
98
|
+
},
|
|
99
|
+
async run() {
|
|
100
|
+
const hook = await import('./print-codex-hook-file');
|
|
101
|
+
|
|
102
|
+
hook.printCodexHookFile();
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
90
105
|
'grok-hooks': () =>
|
|
91
106
|
defineCommand({
|
|
92
107
|
meta: {
|
|
@@ -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
|
@@ -42,5 +42,10 @@ const GROK_PARENT_KEYS = new Set([
|
|
|
42
42
|
]);
|
|
43
43
|
|
|
44
44
|
function isParentSessionKey(key: string): boolean {
|
|
45
|
-
return
|
|
45
|
+
return (
|
|
46
|
+
key === 'CLAUDECODE' ||
|
|
47
|
+
key.startsWith('CLAUDE_CODE_') ||
|
|
48
|
+
key.startsWith('CODEX_SANDBOX') ||
|
|
49
|
+
GROK_PARENT_KEYS.has(key)
|
|
50
|
+
);
|
|
46
51
|
}
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface Config {
|
|
|
8
8
|
claudeArgs: string[];
|
|
9
9
|
grokBin: string;
|
|
10
10
|
grokArgs: string[];
|
|
11
|
+
codexBin: string;
|
|
12
|
+
codexArgs: string[];
|
|
11
13
|
leader: LeaderKey;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -21,6 +23,8 @@ const DEFAULTS: Config = {
|
|
|
21
23
|
claudeArgs: [],
|
|
22
24
|
grokBin: 'grok',
|
|
23
25
|
grokArgs: [],
|
|
26
|
+
codexBin: 'codex',
|
|
27
|
+
codexArgs: [],
|
|
24
28
|
leader: { code: 0, label: '^Space' },
|
|
25
29
|
};
|
|
26
30
|
|
|
@@ -67,11 +71,18 @@ export function loadConfig(): Config {
|
|
|
67
71
|
? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
|
|
68
72
|
: DEFAULTS.grokArgs;
|
|
69
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
|
+
|
|
70
81
|
const leader =
|
|
71
82
|
(typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
|
|
72
83
|
DEFAULTS.leader;
|
|
73
84
|
|
|
74
|
-
return { claudeBin, claudeArgs, grokBin, grokArgs, leader };
|
|
85
|
+
return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, leader };
|
|
75
86
|
} catch {
|
|
76
87
|
return { ...DEFAULTS };
|
|
77
88
|
}
|
package/src/daemon-connection.ts
CHANGED
|
@@ -375,13 +375,18 @@ export class DaemonConnection {
|
|
|
375
375
|
|
|
376
376
|
const rawAgent = req.p?.['agent'];
|
|
377
377
|
|
|
378
|
-
if (
|
|
379
|
-
|
|
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'");
|
|
380
385
|
|
|
381
386
|
return;
|
|
382
387
|
}
|
|
383
388
|
|
|
384
|
-
const agent: AgentKind = rawAgent === 'grok'
|
|
389
|
+
const agent: AgentKind = rawAgent === 'grok' || rawAgent === 'codex' ? rawAgent : 'claude';
|
|
385
390
|
|
|
386
391
|
if (this.ctx.findAdapter(agent) === null) {
|
|
387
392
|
this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
|
package/src/daemon.ts
CHANGED
|
@@ -615,29 +615,40 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
615
615
|
getEffectiveDims: (sessionID) =>
|
|
616
616
|
attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
|
|
617
617
|
restoreFleet: (cols, rows) => {
|
|
618
|
-
const
|
|
618
|
+
const hasLiveSession = (agentSessionID: string) =>
|
|
619
619
|
mgr.sessions.some(
|
|
620
620
|
(s) =>
|
|
621
621
|
s.agentSessionID === agentSessionID &&
|
|
622
622
|
(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
|
|
623
623
|
);
|
|
624
624
|
|
|
625
|
+
const hasAnySession = (agentSessionID: string) =>
|
|
626
|
+
mgr.sessions.some((s) => s.agentSessionID === agentSessionID);
|
|
627
|
+
|
|
625
628
|
const recency = store.collectFleetRecency();
|
|
626
629
|
|
|
627
630
|
// Most recently active sessions revive first, so the ones the user was
|
|
628
631
|
// just working in come back before long-idle ones; entries that never
|
|
629
|
-
// reported an event keep their stored order at the end.
|
|
632
|
+
// reported an event keep their stored order at the end. Exited entries
|
|
633
|
+
// dedupe against every listed session, so repeated restores never
|
|
634
|
+
// double up the killed archive.
|
|
630
635
|
const entries = store
|
|
631
636
|
.loadFleet()
|
|
632
|
-
.filter((entry) =>
|
|
637
|
+
.filter((entry) =>
|
|
638
|
+
entry.exited === true
|
|
639
|
+
? !hasAnySession(entry.agentSessionID)
|
|
640
|
+
: !hasLiveSession(entry.agentSessionID),
|
|
641
|
+
)
|
|
633
642
|
.toSorted((a, b) =>
|
|
634
643
|
(recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
|
|
635
644
|
);
|
|
636
645
|
|
|
637
646
|
// The whole fleet registers as terminal-less sessions up front, so the
|
|
638
647
|
// list shows every incoming session immediately instead of revealing
|
|
639
|
-
// them one boot at a time.
|
|
640
|
-
|
|
648
|
+
// them one boot at a time. Exited entries only register — they stay
|
|
649
|
+
// killed until revived by hand, so no terminal is adopted for them.
|
|
650
|
+
const registered = entries.map((entry) => mgr.restore(entry));
|
|
651
|
+
const queued = registered.filter((s) => s.state !== 'exited');
|
|
641
652
|
|
|
642
653
|
const adoptQueued = (s: Session): boolean => {
|
|
643
654
|
if (mgr.adoptTerminal(s.id, cols, rows) === null) {
|
|
@@ -656,7 +667,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
656
667
|
const [first, ...rest] = queued;
|
|
657
668
|
|
|
658
669
|
if (first === undefined) {
|
|
659
|
-
return
|
|
670
|
+
return registered.length;
|
|
660
671
|
}
|
|
661
672
|
|
|
662
673
|
// The first terminal attaches synchronously so a caller can attach at
|
|
@@ -682,7 +693,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
682
693
|
|
|
683
694
|
void adoptRest(firstBooted);
|
|
684
695
|
|
|
685
|
-
return
|
|
696
|
+
return registered.length;
|
|
686
697
|
},
|
|
687
698
|
};
|
|
688
699
|
|
|
@@ -5,5 +5,13 @@ import type { AgentKind } from './agent-adapter';
|
|
|
5
5
|
* Claude, so session names stay aligned across a mixed fleet.
|
|
6
6
|
*/
|
|
7
7
|
export function formatOverlayAgentMark(agent: AgentKind): string {
|
|
8
|
-
|
|
8
|
+
if (agent === 'grok') {
|
|
9
|
+
return 'g';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (agent === 'codex') {
|
|
13
|
+
return 'x';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ' ';
|
|
9
17
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '.
|
|
|
10
10
|
import type { SessionState } from './sessions';
|
|
11
11
|
import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
|
|
12
12
|
|
|
13
|
-
type AgentKind = 'claude' | 'grok';
|
|
13
|
+
type AgentKind = 'claude' | 'grok' | 'codex';
|
|
14
14
|
|
|
15
15
|
type Mode =
|
|
16
16
|
| 'home'
|
|
@@ -26,6 +26,7 @@ type Mode =
|
|
|
26
26
|
const AGENT_PICKS = [
|
|
27
27
|
{ agent: 'claude' as const, label: 'Claude' },
|
|
28
28
|
{ agent: 'grok' as const, label: 'Grok' },
|
|
29
|
+
{ agent: 'codex' as const, label: 'Codex' },
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
interface MirrorSession {
|
|
@@ -320,7 +321,12 @@ function openAgentPicker(resume = false) {
|
|
|
320
321
|
spawnResume = resume;
|
|
321
322
|
spawnAgent = lastUsedAgent;
|
|
322
323
|
pickerInput = '';
|
|
323
|
-
|
|
324
|
+
|
|
325
|
+
pickerSelected = Math.max(
|
|
326
|
+
0,
|
|
327
|
+
AGENT_PICKS.findIndex((p) => p.agent === lastUsedAgent),
|
|
328
|
+
);
|
|
329
|
+
|
|
324
330
|
mode = 'picker-agent';
|
|
325
331
|
|
|
326
332
|
stdout.write(ansi.clear);
|
|
@@ -442,7 +448,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
442
448
|
kind: value['kind'] === 'headless' ? 'headless' : 'pty',
|
|
443
449
|
alive: value['alive'],
|
|
444
450
|
resumable: typeof value['agentSessionID'] === 'string',
|
|
445
|
-
agent: value['agent'] === 'grok' ? '
|
|
451
|
+
agent: value['agent'] === 'grok' || value['agent'] === 'codex' ? value['agent'] : 'claude',
|
|
446
452
|
};
|
|
447
453
|
}
|
|
448
454
|
|
|
@@ -541,7 +547,7 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
541
547
|
s.lastMsg = e['lastMsg'];
|
|
542
548
|
}
|
|
543
549
|
|
|
544
|
-
if (e['agent'] === 'grok' || e['agent'] === 'claude') {
|
|
550
|
+
if (e['agent'] === 'grok' || e['agent'] === 'codex' || e['agent'] === 'claude') {
|
|
545
551
|
s.agent = e['agent'];
|
|
546
552
|
}
|
|
547
553
|
|
|
@@ -814,7 +820,13 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
814
820
|
return;
|
|
815
821
|
}
|
|
816
822
|
|
|
817
|
-
if (
|
|
823
|
+
if (
|
|
824
|
+
ch === 'H' &&
|
|
825
|
+
sel !== undefined &&
|
|
826
|
+
sel.kind === 'pty' &&
|
|
827
|
+
sel.alive &&
|
|
828
|
+
sel.agent === 'claude'
|
|
829
|
+
) {
|
|
818
830
|
ejectTarget = sel.id;
|
|
819
831
|
pickerInput = '';
|
|
820
832
|
mode = 'picker-eject';
|
|
@@ -1149,7 +1161,12 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1149
1161
|
},
|
|
1150
1162
|
() => {
|
|
1151
1163
|
pickerInput = '';
|
|
1152
|
-
|
|
1164
|
+
|
|
1165
|
+
pickerSelected = Math.max(
|
|
1166
|
+
0,
|
|
1167
|
+
AGENT_PICKS.findIndex((p) => p.agent === spawnAgent),
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1153
1170
|
mode = 'picker-agent';
|
|
1154
1171
|
|
|
1155
1172
|
stdout.write(ansi.clear);
|
package/src/mcp-server.ts
CHANGED
|
@@ -43,7 +43,7 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
43
43
|
{
|
|
44
44
|
name: 'atc_session_spawn',
|
|
45
45
|
description:
|
|
46
|
-
'Spawn a new session in a directory. Optional agent is claude or
|
|
46
|
+
'Spawn a new session in a directory. Optional agent is claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
|
|
47
47
|
inputSchema: {
|
|
48
48
|
type: 'object',
|
|
49
49
|
properties: {
|
|
@@ -52,7 +52,7 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
52
52
|
prompt: { type: 'string', description: 'First message for the session' },
|
|
53
53
|
agent: {
|
|
54
54
|
type: 'string',
|
|
55
|
-
enum: ['claude', 'grok'],
|
|
55
|
+
enum: ['claude', 'grok', 'codex'],
|
|
56
56
|
description: 'Which agent CLI to spawn; defaults to claude',
|
|
57
57
|
},
|
|
58
58
|
},
|
|
@@ -243,15 +243,22 @@ async function runTool(
|
|
|
243
243
|
case 'atc_session_spawn': {
|
|
244
244
|
const rawAgent = args['agent'];
|
|
245
245
|
|
|
246
|
-
if (
|
|
247
|
-
|
|
246
|
+
if (
|
|
247
|
+
rawAgent !== undefined &&
|
|
248
|
+
rawAgent !== 'claude' &&
|
|
249
|
+
rawAgent !== 'grok' &&
|
|
250
|
+
rawAgent !== 'codex'
|
|
251
|
+
) {
|
|
252
|
+
throw new Error("agent must be 'claude', 'grok', or 'codex'");
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
const ok = await client.sendRequest('session.spawn', {
|
|
251
256
|
cwd: args['cwd'],
|
|
252
257
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
253
258
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
254
|
-
...(rawAgent === 'claude' || rawAgent === 'grok'
|
|
259
|
+
...(rawAgent === 'claude' || rawAgent === 'grok' || rawAgent === 'codex'
|
|
260
|
+
? { agent: rawAgent }
|
|
261
|
+
: {}),
|
|
255
262
|
cols: 100,
|
|
256
263
|
rows: 30,
|
|
257
264
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { buildCLICommand } from './build-cli-command';
|
|
2
|
+
|
|
3
|
+
const CODEX_HOOK_EVENTS = [
|
|
4
|
+
'SessionStart',
|
|
5
|
+
'UserPromptSubmit',
|
|
6
|
+
'PermissionRequest',
|
|
7
|
+
'Stop',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Print the Codex hook entries to stdout. The operator merges them into
|
|
12
|
+
* `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
|
|
13
|
+
* parses untrusted hooks but never runs them. atc never writes that path.
|
|
14
|
+
*/
|
|
15
|
+
export function printCodexHookFile(): void {
|
|
16
|
+
process.stdout.write(buildCodexHookFile());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildCodexHookFile(): string {
|
|
20
|
+
const cmd = buildCLICommand('hook-report');
|
|
21
|
+
const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
|
|
22
|
+
const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
|
|
23
|
+
|
|
24
|
+
// Codex caps SessionEnd hooks at three seconds.
|
|
25
|
+
return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
|
|
26
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface FleetEntry {
|
|
|
73
73
|
readonly agent: AgentKind;
|
|
74
74
|
readonly pinned?: boolean;
|
|
75
75
|
readonly lastAttachedAt?: number;
|
|
76
|
+
readonly exited?: boolean;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
const fleetFile = join(stateDir, 'fleet.json');
|
|
@@ -111,6 +112,7 @@ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
|
|
|
111
112
|
agent: toAgentKind(raw['agent']),
|
|
112
113
|
...(raw['pinned'] === true ? { pinned: true } : {}),
|
|
113
114
|
...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
|
|
115
|
+
...(raw['exited'] === true ? { exited: true } : {}),
|
|
114
116
|
};
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -206,17 +208,20 @@ export class SessionManager {
|
|
|
206
208
|
|
|
207
209
|
// Registers a fleet entry as a session with no terminal yet, so a
|
|
208
210
|
// fleet-wide restore can show every incoming session at once; adopting it
|
|
209
|
-
// later attaches the terminal.
|
|
211
|
+
// later attaches the terminal. Exited entries come back as killed
|
|
212
|
+
// sessions: still listed and revivable, never auto-adopted.
|
|
210
213
|
restore(entry: FleetEntry): Session {
|
|
214
|
+
const exited = entry.exited === true;
|
|
215
|
+
|
|
211
216
|
const session: Session = {
|
|
212
217
|
id: `s${++counter}-${Date.now().toString(36)}`,
|
|
213
218
|
name: entry.name,
|
|
214
219
|
cwd: entry.cwd,
|
|
215
220
|
kind: 'headless',
|
|
216
221
|
pty: null,
|
|
217
|
-
state: 'running',
|
|
222
|
+
state: exited ? 'exited' : 'running',
|
|
218
223
|
unread: false,
|
|
219
|
-
lastMsg: 'waiting to restore',
|
|
224
|
+
lastMsg: exited ? 'killed' : 'waiting to restore',
|
|
220
225
|
agentSessionID: entry.agentSessionID,
|
|
221
226
|
agent: entry.agent,
|
|
222
227
|
pinned: entry.pinned ?? false,
|
|
@@ -691,23 +696,28 @@ export class SessionManager {
|
|
|
691
696
|
|
|
692
697
|
// Persisted continuously so a crash (or quit) leaves a restorable fleet.
|
|
693
698
|
// Deliberate kills rewrite the file; unexpected session/atc deaths do not,
|
|
694
|
-
// so the last known fleet survives for `R` restore.
|
|
699
|
+
// so the last known fleet survives for `R` restore. Sessions whose
|
|
700
|
+
// terminal is gone persist as exited entries, so the killed archive
|
|
701
|
+
// survives a daemon restart too.
|
|
695
702
|
writeFleet() {
|
|
696
703
|
const fleet: FleetEntry[] = [];
|
|
697
704
|
|
|
698
705
|
for (const s of this.sessions) {
|
|
706
|
+
if (s.agentSessionID === undefined) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
|
|
699
710
|
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
700
711
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
712
|
+
fleet.push({
|
|
713
|
+
name: s.name,
|
|
714
|
+
cwd: s.cwd,
|
|
715
|
+
agentSessionID: s.agentSessionID,
|
|
716
|
+
agent: s.agent,
|
|
717
|
+
...(s.pinned ? { pinned: true } : {}),
|
|
718
|
+
lastAttachedAt: s.lastAttachedAt,
|
|
719
|
+
...(live ? {} : { exited: true }),
|
|
720
|
+
});
|
|
711
721
|
}
|
|
712
722
|
|
|
713
723
|
this.store.writeFleet(fleet);
|
package/src/state-store.ts
CHANGED
|
@@ -28,7 +28,8 @@ export class StateStore {
|
|
|
28
28
|
cwd TEXT NOT NULL,
|
|
29
29
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
30
30
|
last_attached INTEGER,
|
|
31
|
-
agent TEXT NOT NULL DEFAULT 'claude'
|
|
31
|
+
agent TEXT NOT NULL DEFAULT 'claude',
|
|
32
|
+
exited INTEGER NOT NULL DEFAULT 0
|
|
32
33
|
);
|
|
33
34
|
CREATE TABLE IF NOT EXISTS events (
|
|
34
35
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -75,6 +76,10 @@ export class StateStore {
|
|
|
75
76
|
this.db.run("ALTER TABLE fleet ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
if (!fleetColumns.has('exited')) {
|
|
80
|
+
this.db.run('ALTER TABLE fleet ADD COLUMN exited INTEGER NOT NULL DEFAULT 0');
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
if (legacyFleetPath !== undefined) {
|
|
79
84
|
this.adoptLegacyFleet(legacyFleetPath);
|
|
80
85
|
}
|
|
@@ -90,9 +95,10 @@ export class StateStore {
|
|
|
90
95
|
pinned: number;
|
|
91
96
|
last_attached: number | null;
|
|
92
97
|
agent: string | null;
|
|
98
|
+
exited: number;
|
|
93
99
|
},
|
|
94
100
|
[]
|
|
95
|
-
>('SELECT agent_session_id, name, cwd, pinned, last_attached, agent FROM fleet')
|
|
101
|
+
>('SELECT agent_session_id, name, cwd, pinned, last_attached, agent, exited FROM fleet')
|
|
96
102
|
.all();
|
|
97
103
|
|
|
98
104
|
const entries: FleetEntry[] = [];
|
|
@@ -105,6 +111,7 @@ export class StateStore {
|
|
|
105
111
|
agent: toAgentKind(row.agent),
|
|
106
112
|
...(row.pinned === 0 ? {} : { pinned: true }),
|
|
107
113
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
114
|
+
...(row.exited === 0 ? {} : { exited: true }),
|
|
108
115
|
});
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -128,11 +135,12 @@ export class StateStore {
|
|
|
128
135
|
this.db.run('DELETE FROM fleet');
|
|
129
136
|
|
|
130
137
|
const insert = this.db.query(
|
|
131
|
-
'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
|
|
138
|
+
'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent, exited) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)',
|
|
132
139
|
);
|
|
133
140
|
|
|
134
141
|
for (const entry of all) {
|
|
135
142
|
const pinned = entry.pinned === true ? 1 : 0;
|
|
143
|
+
const exited = entry.exited === true ? 1 : 0;
|
|
136
144
|
|
|
137
145
|
insert.run(
|
|
138
146
|
entry.agentSessionID,
|
|
@@ -141,6 +149,7 @@ export class StateStore {
|
|
|
141
149
|
pinned,
|
|
142
150
|
entry.lastAttachedAt ?? null,
|
|
143
151
|
entry.agent,
|
|
152
|
+
exited,
|
|
144
153
|
);
|
|
145
154
|
}
|
|
146
155
|
});
|