@zgeoff/atc 0.1.8 → 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 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> instances in PTYs behind a
6
- keyboard-driven session list with hook-driven attention routing — no panes, no tiling, no
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,7 +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.
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.
31
34
 
32
35
  The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
33
36
  debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
@@ -35,47 +38,90 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
35
38
 
36
39
  ## Keys
37
40
 
38
- | Key | Where | Action |
39
- | --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
40
- | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
41
- | `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional group → optional first prompt |
42
- | `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
43
- | `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
44
- | `j`/`k`/`↑`/`↓` | overlay/picker | move |
45
- | `Enter` | overlay | attach (auto-acks) |
46
- | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
47
- | `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
48
- | `a` | overlay | ack notification without attaching |
49
- | `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
50
- | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
51
- | `y` | overlay | yank `cd <dir> && claude --resume <id>` to clipboard (OSC 52 + clip.exe/wl-copy/xclip) |
52
- | `Y` | overlay | eject: yank the resume command, then kill the session here — paste it in any pane to take over |
53
- | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
54
- | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
55
- | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
56
- | `q` | home/overlay | quit the client — sessions keep running in the daemon |
57
-
58
- The overlay clusters sessions under dim headers when more than one group exists: a session's
59
- explicit group wins, and sessions without one fall back to the directory they were spawned from.
60
- Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
61
- an agent can organise the fleet for you.
62
-
63
- Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
64
- to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
65
- so in its message column instead of resuming.
66
-
67
- Everything else is passed through to the focused Claude session, which owns the full screen. Fleet
68
- state renders inside Claude Code's own status line (injected via the same `--settings` file): your
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
+
63
+ The overlay orders sessions by pinned first, then attention state, then most recently attached, so
64
+ the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
65
+ clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
66
+ git worktree clusters with its main repository, and a directory outside any repository stands alone.
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.
70
+
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.
74
+
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
69
77
  configured statusline runs first, and atc appends `▏● 2 need you: auth-bug`. atc draws its own
70
78
  status bar only on the home and overlay screens.
71
79
 
72
80
  ## How state tracking works
73
81
 
74
- Spawned sessions get a `--settings` file injecting `Notification`, `Stop`, `UserPromptSubmit`, and
75
- `SessionEnd` hooks that report to a unix socket (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude
76
- settings are untouched; sessions you start outside atc are unaffected. States: red `●` needs you,
77
- cyan `◐` running, green `✓` turn done, gray `✗` exited. The overlay sorts needs-you first; the
78
- status bar turns red and names the most urgent session.
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.
79
125
 
80
126
  ## Config
81
127
 
@@ -85,30 +131,35 @@ status bar turns red and names the most urgent session.
85
131
  {
86
132
  "claudeBin": "claude",
87
133
  "claudeArgs": [],
134
+ "grokBin": "grok",
135
+ "grokArgs": [],
88
136
  "leader": "ctrl-space"
89
137
  }
90
138
  ```
91
139
 
92
- | Field | Default | Meaning |
93
- | ------------ | -------------- | ------------------------------------------------------------------------------------- |
94
- | `claudeBin` | `"claude"` | The binary spawned for every session. |
95
- | `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`. |
96
- | `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
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-]"`. |
97
147
 
98
148
  Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
99
149
  `ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
100
150
  unknown or reserved value falls back to the default.
101
151
 
102
152
  `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
103
- 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.
104
155
 
105
156
  ```sh
106
157
  claude mcp add --scope user atc -- atc mcp
107
158
  ```
108
159
 
109
- Daemon state — the restorable fleet, spawn-dir history, and the hook-event trail — lives in
110
- `~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected statusline); the
111
- 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.
112
163
 
113
164
  ## Crash safety
114
165
 
@@ -117,13 +168,13 @@ A client crash or closed window costs nothing: the daemon keeps hosting the flee
117
168
  would kill every hosted session — and shows `⟳ update ready` in the status bar; `u` in the overlay
118
169
  restarts the daemon and restores the fleet at a moment you choose. Only a protocol mismatch, where
119
170
  the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
120
- live fleet (name, cwd, Claude session id) to its SQLite store. If the daemon itself dies — crash,
121
- SIGKILL, reboot — the child claude processes die with it, but every session's transcript is already
122
- on disk. Start atc and press `R`: the whole fleet respawns via `claude --resume`. Only deliberate
123
- kills (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
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.
124
175
 
125
176
  Restoring shows the whole fleet immediately — every incoming session appears in the list marked
126
- "waiting to restore" — and revives one at a time, most recently active first: the next
127
- `claude --resume` starts only once the previous one has reported it is up (its `SessionStart` hook),
128
- so bringing back a dozen sessions no longer launches a dozen Claude processes at the same instant
129
- and pins the machine. Each row flips live as its session comes back.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -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
  }
@@ -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 { client, stale: hello['daemon'] !== build };
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
+ }
@@ -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 { basename, join } from 'node:path';
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
- private readonly settingsFile: string;
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.settingsFile = writeHookSettings();
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' ? { nameSource: transcript } : {}),
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: new claude.ClaudeAdapter(cfg),
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: {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The process environment with any enclosing Claude Code session scrubbed
3
- * out. A claude spawned with those variables intact behaves as a child of
4
- * the enclosing session — its transcript lands inside the parent's instead
5
- * of its own project directory, which breaks resume and handoff.
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 === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE_')) {
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
  }