@zgeoff/atc 0.1.10 → 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 +24 -8
- 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/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/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,7 +54,7 @@ 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
59
|
| `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
|
|
59
60
|
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
@@ -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
|
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}'`);
|
|
@@ -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
|
+
}
|