@zgeoff/atc 0.1.9 → 0.1.10
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 +101 -57
- 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 +21 -2
- package/src/collect-clean-env.ts +30 -5
- package/src/config.ts +11 -1
- package/src/daemon-connection.ts +23 -1
- package/src/daemon.ts +58 -57
- package/src/format-overlay-agent-mark.ts +9 -0
- package/src/grok-adapter.ts +274 -0
- package/src/hook-report.ts +8 -4
- package/src/index.ts +79 -15
- package/src/mcp-server.ts +13 -1
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +1 -1
- 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
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
<h1>atc</h1>
|
|
3
3
|
|
|
4
4
|
<p>
|
|
5
|
-
Control tower for Claude Code sessions: stock <code>claude</code>
|
|
6
|
-
keyboard-driven session list with hook-driven
|
|
7
|
-
mouse.
|
|
5
|
+
Control tower for Claude Code and Grok Build sessions: stock <code>claude</code> and
|
|
6
|
+
<code>grok</code> instances in PTYs behind a keyboard-driven session list with hook-driven
|
|
7
|
+
attention routing — no panes, no tiling, no mouse.
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p>
|
|
@@ -27,10 +27,10 @@ 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
|
-
From a checkout, `bun src/cli.ts` runs the same thing. atc
|
|
31
|
-
[zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
|
|
32
|
-
list, so with zoxide installed every directory you visit is two keystrokes
|
|
33
|
-
it the picker falls back to atc's own spawn history.
|
|
30
|
+
Grok sessions also need the `grok` CLI. From a checkout, `bun src/cli.ts` runs the same thing. atc
|
|
31
|
+
is built to pair with [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
|
|
32
|
+
feeds on its frecency list, so with zoxide installed every directory you visit is two keystrokes
|
|
33
|
+
from a session. Without it the picker falls back to atc's own spawn history.
|
|
34
34
|
|
|
35
35
|
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
36
36
|
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
@@ -38,51 +38,90 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
38
38
|
|
|
39
39
|
## Keys
|
|
40
40
|
|
|
41
|
-
| Key | Where | Action
|
|
42
|
-
| --------------- | -------------- |
|
|
43
|
-
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config)
|
|
44
|
-
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt
|
|
45
|
-
| `r` | home/overlay | adopt: pick dir → name
|
|
46
|
-
| `R` | home | restore last fleet after a daemon death —
|
|
47
|
-
| `j`/`k`/`↑`/`↓` | overlay/picker | move
|
|
48
|
-
| `Enter` | overlay | attach (auto-acks)
|
|
49
|
-
| `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one
|
|
50
|
-
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear
|
|
51
|
-
| `a` | overlay | ack notification without attaching
|
|
52
|
-
| `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list
|
|
53
|
-
| `g` | overlay | toggle the grouped view: sessions cluster under repository headers
|
|
54
|
-
| `H` | overlay | eject to headless
|
|
55
|
-
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place
|
|
56
|
-
| `y` | overlay | yank
|
|
57
|
-
| `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`; a second `K` forgets it
|
|
59
|
-
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session
|
|
60
|
-
| `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows
|
|
61
|
-
| `q` | home/overlay | quit the client — sessions keep running in the daemon
|
|
41
|
+
| Key | Where | Action |
|
|
42
|
+
| --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
|
|
44
|
+
| `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 | restore last fleet after a daemon death — each session with its matching CLI |
|
|
47
|
+
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
48
|
+
| `Enter` | overlay | attach (auto-acks) |
|
|
49
|
+
| `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
|
|
50
|
+
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
|
|
51
|
+
| `a` | overlay | ack notification without attaching |
|
|
52
|
+
| `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list |
|
|
53
|
+
| `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
|
|
54
|
+
| `H` | overlay | eject to headless (Claude only). Hidden and ignored on a Grok row. |
|
|
55
|
+
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
56
|
+
| `y` | overlay | yank the resume command (`claude --resume <id>` or `grok --resume <id>`) |
|
|
57
|
+
| `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`; a second `K` forgets it |
|
|
59
|
+
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
60
|
+
| `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
|
|
61
|
+
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
62
62
|
|
|
63
63
|
The overlay orders sessions by pinned first, then attention state, then most recently attached, so
|
|
64
64
|
the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
|
|
65
65
|
clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
|
|
66
66
|
git worktree clusters with its main repository, and a directory outside any repository stands alone.
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
A reserved column after the pin mark shows a dim `g` on Grok rows; Claude rows keep a space so names
|
|
68
|
+
stay aligned. The `atc_session_update` MCP tool renames and pins sessions, so an agent can organise
|
|
69
|
+
the fleet for you.
|
|
69
70
|
|
|
70
|
-
Revive (`P`)
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
Revive (`P`) resumes the session from its saved transcript, so a session killed before its first
|
|
72
|
+
exchange has nothing on disk yet, and the overlay says so in its message column instead of resuming.
|
|
73
|
+
Headless eject (`H`) is Claude-only and uses the same transcript; Grok has no headless handoff.
|
|
73
74
|
|
|
74
|
-
Everything else is passed through to the focused
|
|
75
|
-
|
|
75
|
+
Everything else is passed through to the focused session, which owns the full screen. Fleet state
|
|
76
|
+
renders inside Claude Code's own status line (injected via the same `--settings` file): your
|
|
76
77
|
configured statusline runs first, and atc appends `▏● 2 need you: auth-bug`. atc draws its own
|
|
77
78
|
status bar only on the home and overlay screens.
|
|
78
79
|
|
|
79
80
|
## How state tracking works
|
|
80
81
|
|
|
81
|
-
Spawned sessions get a `--settings` file injecting `Notification`, `Stop`,
|
|
82
|
-
`SessionEnd` hooks that report to a unix socket
|
|
83
|
-
settings are untouched; sessions you start outside
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
Spawned Claude sessions get a `--settings` file injecting `Notification`, `Stop`,
|
|
83
|
+
`UserPromptSubmit`, and `SessionEnd` hooks that report to a unix socket
|
|
84
|
+
(`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude settings are untouched; sessions you start outside
|
|
85
|
+
atc are unaffected. Grok attention comes from a dedicated hook file at
|
|
86
|
+
`$GROK_HOME/hooks/atc-reporter.json` (`~/.grok` when `GROK_HOME` is unset). atc never writes that
|
|
87
|
+
path. Install it yourself:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
mkdir -p ~/.grok/hooks
|
|
91
|
+
atc grok-hooks > ~/.grok/hooks/atc-reporter.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`atc grok-hooks` prints this file, with the `hook-report` command resolved for this install:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"hooks": {
|
|
99
|
+
"SessionStart": [
|
|
100
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
101
|
+
],
|
|
102
|
+
"SessionEnd": [
|
|
103
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
104
|
+
],
|
|
105
|
+
"UserPromptSubmit": [
|
|
106
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
107
|
+
],
|
|
108
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }],
|
|
109
|
+
"StopFailure": [
|
|
110
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
111
|
+
],
|
|
112
|
+
"StopCancelled": [
|
|
113
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
114
|
+
],
|
|
115
|
+
"Notification": [
|
|
116
|
+
{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A missing file is a Grok PTY without hook-driven attention. States: red `●` needs you, cyan `◐`
|
|
123
|
+
running, green `✓` turn done, gray `✗` exited. The status bar turns red and names the most urgent
|
|
124
|
+
session.
|
|
86
125
|
|
|
87
126
|
## Config
|
|
88
127
|
|
|
@@ -92,30 +131,35 @@ urgent session.
|
|
|
92
131
|
{
|
|
93
132
|
"claudeBin": "claude",
|
|
94
133
|
"claudeArgs": [],
|
|
134
|
+
"grokBin": "grok",
|
|
135
|
+
"grokArgs": [],
|
|
95
136
|
"leader": "ctrl-space"
|
|
96
137
|
}
|
|
97
138
|
```
|
|
98
139
|
|
|
99
|
-
| Field | Default | Meaning
|
|
100
|
-
| ------------ | -------------- |
|
|
101
|
-
| `claudeBin` | `"claude"` | The binary spawned for
|
|
102
|
-
| `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`.
|
|
103
|
-
| `
|
|
140
|
+
| Field | Default | Meaning |
|
|
141
|
+
| ------------ | -------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `claudeBin` | `"claude"` | The binary spawned for Claude sessions. |
|
|
143
|
+
| `claudeArgs` | `[]` | Prepended to every Claude spawn, e.g. `["--model", "opus"]`. |
|
|
144
|
+
| `grokBin` | `"grok"` | The binary spawned for Grok sessions. |
|
|
145
|
+
| `grokArgs` | `[]` | Prepended to every Grok spawn. A user `--leader` in this list is dropped; atc always appends `--no-leader`. |
|
|
146
|
+
| `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
|
|
104
147
|
|
|
105
148
|
Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
|
|
106
149
|
`ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
|
|
107
150
|
unknown or reserved value falls back to the default.
|
|
108
151
|
|
|
109
152
|
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
|
|
110
|
-
sessions included
|
|
153
|
+
sessions included. `atc_session_spawn` takes an optional `agent` (`claude` or `grok`) and defaults
|
|
154
|
+
to Claude; it never reads the TUI last-used value.
|
|
111
155
|
|
|
112
156
|
```sh
|
|
113
157
|
claude mcp add --scope user atc -- atc mcp
|
|
114
158
|
```
|
|
115
159
|
|
|
116
|
-
Daemon state — the restorable fleet, spawn-dir history, and the hook-event trail —
|
|
117
|
-
`~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected
|
|
118
|
-
daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
160
|
+
Daemon state — the restorable fleet, spawn-dir history, last-used agent, and the hook-event trail —
|
|
161
|
+
lives in `~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected
|
|
162
|
+
statusline); the daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
119
163
|
|
|
120
164
|
## Crash safety
|
|
121
165
|
|
|
@@ -124,13 +168,13 @@ A client crash or closed window costs nothing: the daemon keeps hosting the flee
|
|
|
124
168
|
would kill every hosted session — and shows `⟳ update ready` in the status bar; `u` in the overlay
|
|
125
169
|
restarts the daemon and restores the fleet at a moment you choose. Only a protocol mismatch, where
|
|
126
170
|
the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
|
|
127
|
-
live fleet (name, cwd,
|
|
128
|
-
SIGKILL, reboot — the child
|
|
129
|
-
|
|
130
|
-
|
|
171
|
+
live fleet (name, cwd, agent, session id) to its SQLite store. If the daemon itself dies — crash,
|
|
172
|
+
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. Only deliberate kills
|
|
174
|
+
(`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
|
|
131
175
|
|
|
132
176
|
Restoring shows the whole fleet immediately — every incoming session appears in the list marked
|
|
133
|
-
"waiting to restore" — and revives one at a time, most recently active first: the next
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
"waiting to restore" — and revives one at a time, most recently active first: the next resume starts
|
|
178
|
+
only once the previous one has reported it is up (its `SessionStart` hook), so bringing back a dozen
|
|
179
|
+
sessions no longer launches a dozen agent processes at the same instant and pins the machine. Each
|
|
180
|
+
row flips live as its session comes back.
|
package/package.json
CHANGED
package/src/agent-adapter.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { HookEvent } from './hooks';
|
|
2
2
|
|
|
3
|
+
export type AgentKind = 'claude' | 'grok';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Missing, empty, and unknown values become Claude so a fleet written
|
|
7
|
+
* before the agent column still restores as Claude.
|
|
8
|
+
*/
|
|
9
|
+
export function toAgentKind(raw: unknown): AgentKind {
|
|
10
|
+
return raw === 'grok' ? 'grok' : 'claude';
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export interface SpawnOptions {
|
|
4
14
|
readonly prompt: string;
|
|
5
15
|
|
|
@@ -24,6 +34,10 @@ export interface AdapterEvent {
|
|
|
24
34
|
|
|
25
35
|
// Opaque handle the adapter can later pull a session name from.
|
|
26
36
|
nameSource?: string;
|
|
37
|
+
|
|
38
|
+
// Claude resume-existence path. Distinct from nameSource: a naming
|
|
39
|
+
// handle is not a resume gate.
|
|
40
|
+
transcriptSource?: string;
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
export interface NameUpdate {
|
|
@@ -42,12 +56,41 @@ interface ScreenDetector {
|
|
|
42
56
|
readonly detectAttention: (screen: string) => AttentionJudgment | null;
|
|
43
57
|
}
|
|
44
58
|
|
|
59
|
+
export interface ResumeCheck {
|
|
60
|
+
readonly agentSessionID?: string;
|
|
61
|
+
readonly transcriptSource?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface HeadlessRunRequest {
|
|
65
|
+
readonly cwd: string;
|
|
66
|
+
readonly prompt: string;
|
|
67
|
+
readonly resume?: string;
|
|
68
|
+
readonly permissionMode?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface HeadlessRunEvents {
|
|
72
|
+
readonly onOutput: (text: string) => void;
|
|
73
|
+
readonly onDone: (summary: string) => void;
|
|
74
|
+
readonly onNeedsYou: (msg: string) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type HeadlessRunner = (
|
|
78
|
+
opts: HeadlessRunRequest,
|
|
79
|
+
hooks: HeadlessRunEvents,
|
|
80
|
+
) => { readonly stop: () => void };
|
|
81
|
+
|
|
45
82
|
/**
|
|
46
83
|
* Everything specific to one agent CLI: how to spawn it, how to read its
|
|
47
84
|
* hook payloads, where its session names come from, and how to resume a
|
|
48
85
|
* session outside atc. The session core never sees past this interface.
|
|
49
86
|
*/
|
|
50
87
|
export interface AgentAdapter {
|
|
88
|
+
readonly kind: AgentKind;
|
|
89
|
+
|
|
90
|
+
// Runs one headless turn over a session; null means eject is unsupported
|
|
91
|
+
// for this agent.
|
|
92
|
+
readonly headlessRunner: HeadlessRunner | null;
|
|
93
|
+
|
|
51
94
|
// The detector stack's screen tier; null when hooks are authoritative.
|
|
52
95
|
readonly screenDetector: ScreenDetector | null;
|
|
53
96
|
readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
|
|
@@ -56,5 +99,6 @@ export interface AgentAdapter {
|
|
|
56
99
|
source: string,
|
|
57
100
|
namedBy: 'user' | 'auto' | 'agent',
|
|
58
101
|
) => Promise<NameUpdate | null>;
|
|
102
|
+
readonly canResume: (session: ResumeCheck) => boolean;
|
|
59
103
|
readonly buildResumeCommand: (cwd: string, agentSessionID: string | undefined) => string | null;
|
|
60
104
|
}
|
package/src/boot-daemon.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
+
import type { AgentKind } from './agent-adapter';
|
|
4
5
|
import { daemonPidFile, daemonSocketPath } from './config';
|
|
5
6
|
import { DaemonClient } from './daemon-client';
|
|
6
7
|
import { getBuild } from './get-build';
|
|
@@ -9,6 +10,7 @@ import { isRecord } from './report';
|
|
|
9
10
|
export interface DaemonBoot {
|
|
10
11
|
readonly client: DaemonClient;
|
|
11
12
|
readonly stale: boolean;
|
|
13
|
+
readonly lastUsedAgent: AgentKind;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -30,7 +32,11 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
30
32
|
try {
|
|
31
33
|
const hello = await client.sendHello(build);
|
|
32
34
|
|
|
33
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
client,
|
|
37
|
+
stale: hello['daemon'] !== build,
|
|
38
|
+
lastUsedAgent: hello['lastUsedAgent'] === 'grok' ? 'grok' : 'claude',
|
|
39
|
+
};
|
|
34
40
|
} catch (error) {
|
|
35
41
|
client.stop();
|
|
36
42
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command line that wrangled sessions invoke for atc subcommands: under bun
|
|
5
|
+
* the CLI entry path is part of the command; a compiled binary is itself
|
|
6
|
+
* the entry.
|
|
7
|
+
*/
|
|
8
|
+
export function buildCLICommand(subcommand: string): string {
|
|
9
|
+
const exec = process.execPath;
|
|
10
|
+
|
|
11
|
+
if (basename(exec) === 'bun' || basename(exec) === 'bun.exe') {
|
|
12
|
+
return `"${exec}" "${join(import.meta.dir, 'cli.ts')}" ${subcommand}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `"${exec}" ${subcommand}`;
|
|
16
|
+
}
|
package/src/claude-adapter.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
import type {
|
|
5
5
|
AdapterEvent,
|
|
6
6
|
AgentAdapter,
|
|
7
|
+
HeadlessRunner,
|
|
7
8
|
NameUpdate,
|
|
9
|
+
ResumeCheck,
|
|
8
10
|
SpawnOptions,
|
|
9
11
|
SpawnPlan,
|
|
10
12
|
} from './agent-adapter';
|
|
13
|
+
import { buildCLICommand } from './build-cli-command';
|
|
11
14
|
import type { Config } from './config';
|
|
12
15
|
import { stateDir } from './config';
|
|
13
16
|
import type { HookEvent } from './hooks';
|
|
@@ -18,19 +21,26 @@ import { isRecord } from './report';
|
|
|
18
21
|
* resume semantics, transcript name-pulling, and statusline chaining.
|
|
19
22
|
*/
|
|
20
23
|
export class ClaudeAdapter implements AgentAdapter {
|
|
24
|
+
readonly kind = 'claude';
|
|
25
|
+
|
|
26
|
+
readonly headlessRunner: HeadlessRunner | null;
|
|
27
|
+
|
|
21
28
|
// Claude's hooks are authoritative; no screen heuristics needed.
|
|
22
29
|
readonly screenDetector = null;
|
|
23
30
|
|
|
24
31
|
private readonly config: Config;
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
// Written on first spawn so constructing the adapter touches no state.
|
|
34
|
+
private settingsFile: string | undefined;
|
|
27
35
|
|
|
28
|
-
constructor(config: Config) {
|
|
36
|
+
constructor(config: Config, headlessRunner: HeadlessRunner | null = null) {
|
|
29
37
|
this.config = config;
|
|
30
|
-
this.
|
|
38
|
+
this.headlessRunner = headlessRunner;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
42
|
+
this.settingsFile ??= writeHookSettings();
|
|
43
|
+
|
|
34
44
|
return {
|
|
35
45
|
bin: this.config.claudeBin,
|
|
36
46
|
args: [
|
|
@@ -55,7 +65,9 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
55
65
|
|
|
56
66
|
const named: AdapterEvent = {
|
|
57
67
|
...base,
|
|
58
|
-
...(typeof transcript === 'string'
|
|
68
|
+
...(typeof transcript === 'string'
|
|
69
|
+
? { nameSource: transcript, transcriptSource: transcript }
|
|
70
|
+
: {}),
|
|
59
71
|
};
|
|
60
72
|
|
|
61
73
|
switch (e.event) {
|
|
@@ -157,6 +169,14 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
|
|
172
|
+
canResume(session: ResumeCheck): boolean {
|
|
173
|
+
if (session.transcriptSource === undefined) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return existsSync(session.transcriptSource);
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
// Shell command that re-opens this session outside atc (or anywhere).
|
|
161
181
|
buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
|
|
162
182
|
const resume =
|
|
@@ -221,15 +241,3 @@ function writeHookSettings(): string {
|
|
|
221
241
|
function truncateDetail(text: string): string {
|
|
222
242
|
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
223
243
|
}
|
|
224
|
-
|
|
225
|
-
// Wrangled sessions invoke atc subcommands: under bun the CLI entry path is
|
|
226
|
-
// part of the command; a compiled binary is itself the entry.
|
|
227
|
-
function buildCLICommand(subcommand: string): string {
|
|
228
|
-
const exec = process.execPath;
|
|
229
|
-
|
|
230
|
-
if (basename(exec) === 'bun' || basename(exec) === 'bun.exe') {
|
|
231
|
-
return `"${exec}" "${join(import.meta.dir, 'cli.ts')}" ${subcommand}`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return `"${exec}" ${subcommand}`;
|
|
235
|
-
}
|
package/src/cli.ts
CHANGED
|
@@ -45,6 +45,7 @@ const main = defineCommand({
|
|
|
45
45
|
const daemon = await import('./daemon');
|
|
46
46
|
const config = await import('./config');
|
|
47
47
|
const claude = await import('./claude-adapter');
|
|
48
|
+
const grok = await import('./grok-adapter');
|
|
48
49
|
const headless = await import('./start-headless-run');
|
|
49
50
|
|
|
50
51
|
// Test harnesses shrink the outbound queue to force overflow
|
|
@@ -60,12 +61,18 @@ const main = defineCommand({
|
|
|
60
61
|
const restoreBootTimeoutMs =
|
|
61
62
|
Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
|
|
62
63
|
|
|
64
|
+
const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
|
|
65
|
+
headless.startHeadlessRun(runOpts, hooks),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
69
|
+
|
|
63
70
|
const handle = daemon.startDaemon({
|
|
64
|
-
headlessRunner: (runOpts, hooks) => headless.startHeadlessRun(runOpts, hooks),
|
|
65
71
|
socketPath: config.daemonSocketPath,
|
|
66
72
|
reporterSocketPath: config.socketPath,
|
|
67
73
|
build: getBuild(),
|
|
68
|
-
adapter:
|
|
74
|
+
adapter: claudeAdapter,
|
|
75
|
+
adapters: { claude: claudeAdapter, grok: grokAdapter },
|
|
69
76
|
dbPath: config.dbFile,
|
|
70
77
|
legacyFleetPath: config.legacyFleetFile,
|
|
71
78
|
pidPath: config.daemonPidFile,
|
|
@@ -80,6 +87,18 @@ const main = defineCommand({
|
|
|
80
87
|
});
|
|
81
88
|
},
|
|
82
89
|
}),
|
|
90
|
+
'grok-hooks': () =>
|
|
91
|
+
defineCommand({
|
|
92
|
+
meta: {
|
|
93
|
+
name: 'grok-hooks',
|
|
94
|
+
description: 'Print the Grok hook file to install at $GROK_HOME/hooks/atc-reporter.json',
|
|
95
|
+
},
|
|
96
|
+
async run() {
|
|
97
|
+
const hook = await import('./print-grok-hook-file');
|
|
98
|
+
|
|
99
|
+
hook.printGrokHookFile();
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
83
102
|
'hook-report': () =>
|
|
84
103
|
defineCommand({
|
|
85
104
|
meta: {
|
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,28 @@ 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 key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE_') || GROK_PARENT_KEYS.has(key);
|
|
46
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { isRecord } from './report';
|
|
|
6
6
|
export interface Config {
|
|
7
7
|
claudeBin: string;
|
|
8
8
|
claudeArgs: string[];
|
|
9
|
+
grokBin: string;
|
|
10
|
+
grokArgs: string[];
|
|
9
11
|
leader: LeaderKey;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -17,6 +19,8 @@ interface LeaderKey {
|
|
|
17
19
|
const DEFAULTS: Config = {
|
|
18
20
|
claudeBin: 'claude',
|
|
19
21
|
claudeArgs: [],
|
|
22
|
+
grokBin: 'grok',
|
|
23
|
+
grokArgs: [],
|
|
20
24
|
leader: { code: 0, label: '^Space' },
|
|
21
25
|
};
|
|
22
26
|
|
|
@@ -57,11 +61,17 @@ export function loadConfig(): Config {
|
|
|
57
61
|
? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
|
|
58
62
|
: DEFAULTS.claudeArgs;
|
|
59
63
|
|
|
64
|
+
const grokBin = typeof parsed['grokBin'] === 'string' ? parsed['grokBin'] : DEFAULTS.grokBin;
|
|
65
|
+
|
|
66
|
+
const grokArgs = Array.isArray(parsed['grokArgs'])
|
|
67
|
+
? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
|
|
68
|
+
: DEFAULTS.grokArgs;
|
|
69
|
+
|
|
60
70
|
const leader =
|
|
61
71
|
(typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
|
|
62
72
|
DEFAULTS.leader;
|
|
63
73
|
|
|
64
|
-
return { claudeBin, claudeArgs, leader };
|
|
74
|
+
return { claudeBin, claudeArgs, grokBin, grokArgs, leader };
|
|
65
75
|
} catch {
|
|
66
76
|
return { ...DEFAULTS };
|
|
67
77
|
}
|
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,22 @@ export class DaemonConnection {
|
|
|
369
373
|
resume = rawResume;
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
const rawAgent = req.p?.['agent'];
|
|
377
|
+
|
|
378
|
+
if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
|
|
379
|
+
this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude' or 'grok'");
|
|
380
|
+
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const agent: AgentKind = rawAgent === 'grok' ? 'grok' : 'claude';
|
|
385
|
+
|
|
386
|
+
if (this.ctx.findAdapter(agent) === null) {
|
|
387
|
+
this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
|
|
388
|
+
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
372
392
|
const session = this.ctx.spawnSession({
|
|
373
393
|
cwd,
|
|
374
394
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -377,6 +397,7 @@ export class DaemonConnection {
|
|
|
377
397
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
378
398
|
resume,
|
|
379
399
|
namedBy: name === '' ? 'auto' : 'user',
|
|
400
|
+
agent,
|
|
380
401
|
});
|
|
381
402
|
|
|
382
403
|
this.sendOk(req.id, { session });
|
|
@@ -517,6 +538,7 @@ export class DaemonConnection {
|
|
|
517
538
|
this.sendOk(req.id, {
|
|
518
539
|
daemon: this.ctx.build,
|
|
519
540
|
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
541
|
+
lastUsedAgent: this.ctx.loadLastUsedAgent(),
|
|
520
542
|
});
|
|
521
543
|
|
|
522
544
|
return true;
|