@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 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,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 is built to pair with
31
- [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker feeds on its frecency
32
- list, so with zoxide installed every directory you visit is two keystrokes from a session. Without
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 → `claude --resume` (Claude's session picker opens in the new PTY) |
46
- | `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
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: the terminal dies, a headless Agent SDK run resumes the same session |
55
- | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
56
- | `y` | overlay | yank `cd <dir> && claude --resume <id>` to clipboard (OSC 52 + clip.exe/wl-copy/xclip) |
57
- | `Y` | overlay | eject: yank the resume command, then kill the session here — paste it in any pane to take over |
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
- The `atc_session_update` MCP tool renames and pins sessions, so an agent can organise the fleet for
68
- you.
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`) and headless eject (`H`) resume the session from its saved transcript, so both need one
71
- to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
72
- so in its message column instead of resuming.
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 Claude session, which owns the full screen. Fleet
75
- state renders inside Claude Code's own status line (injected via the same `--settings` file): your
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`, `UserPromptSubmit`, and
82
- `SessionEnd` hooks that report to a unix socket (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude
83
- settings are untouched; sessions you start outside atc are unaffected. States: red `●` needs you,
84
- cyan `◐` running, green `✓` turn done, gray `✗` exited. The status bar turns red and names the most
85
- 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.
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 every session. |
102
- | `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`. |
103
- | `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-]"`. |
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 — lives in
117
- `~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected statusline); the
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, Claude session id) to its SQLite store. If the daemon itself dies — crash,
128
- SIGKILL, reboot — the child claude processes die with it, but every session's transcript is already
129
- on disk. Start atc and press `R`: the whole fleet respawns via `claude --resume`. Only deliberate
130
- 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.
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
- `claude --resume` starts only once the previous one has reported it is up (its `SessionStart` hook),
135
- so bringing back a dozen sessions no longer launches a dozen Claude processes at the same instant
136
- 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.9",
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
  }
@@ -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', 'this daemon has no headless runner');
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;