@zgeoff/atc 2.12.0 → 2.14.0

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.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/README.md CHANGED
@@ -74,16 +74,16 @@ mark:
74
74
 
75
75
  Sessions that need you sort to the top, so the one you want is nearly always first.
76
76
 
77
- | Key | Action |
78
- | ------------ | -------------------------------------------- |
79
- | `Ctrl-Space` | open or close the list |
80
- | `Tab` | attach the session that needs you most |
81
- | `Enter` | attach the selected session |
82
- | `/` | filter by name or directory |
83
- | `a` | acknowledge a notification without attaching |
84
- | `K` | kill the selected session |
85
- | `q` | quit the client; sessions keep running |
86
- | `?` | every other key |
77
+ | Key | Action |
78
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------- |
79
+ | `Ctrl-Space` | open or close the list |
80
+ | `Tab` | attach the session that needs you most |
81
+ | `Enter` | attach the selected session |
82
+ | `/` | filter by name or directory |
83
+ | `a` | acknowledge a notification without attaching |
84
+ | `K` | kill the selected session; on a host atc can destroy, a dead session asks once more before forgetting destroys the host |
85
+ | `q` | quit the client; sessions keep running |
86
+ | `?` | every other key |
87
87
 
88
88
  The directory picker lists the directory you ran `atc` from, atc's own spawn history, every project
89
89
  under the roots in `config.json`, and your [zoxide](https://github.com/ajeetdsouza/zoxide) list when
@@ -94,7 +94,8 @@ list.
94
94
  If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
95
95
  After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
96
96
  restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
97
- finishes.
97
+ finishes. When the running daemon speaks another protocol version, `atc` asks before it restarts the
98
+ daemon, since the restart ends every session the daemon hosts.
98
99
 
99
100
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
100
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -42,6 +42,7 @@
42
42
  "@better-auth/oauth-provider": "1.7.6",
43
43
  "@xterm/addon-serialize": "0.14.0",
44
44
  "@xterm/headless": "6.0.0",
45
+ "@zgeoff/imp-client": "0.24.0",
45
46
  "better-auth": "1.7.6",
46
47
  "bun-pty": "0.4.10",
47
48
  "citty": "0.2.2",
@@ -46,6 +46,24 @@ export interface SpawnPlan {
46
46
  args: string[];
47
47
  }
48
48
 
49
+ /**
50
+ * Where a session on a remote host finds atc: the atc binary inside the
51
+ * host, null when the host has none, and the folder the session's own
52
+ * files unpack into.
53
+ */
54
+ export interface GuestPaths {
55
+ readonly atc: string | null;
56
+ readonly dir: string;
57
+ }
58
+
59
+ /**
60
+ * A spawn on a remote host, with the files the harness reads there, keyed
61
+ * by their path inside the session's guest folder.
62
+ */
63
+ export interface GuestSpawnPlan extends SpawnPlan {
64
+ readonly files: Readonly<Record<string, string>>;
65
+ }
66
+
49
67
  export interface AdapterEvent {
50
68
  kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
51
69
  agentSessionID?: AgentSessionID;
@@ -101,28 +119,24 @@ export interface ResumeCheck {
101
119
  readonly transcriptSource?: string;
102
120
  }
103
121
 
104
- interface HeadlessRunRequest {
122
+ export interface HeadlessRunRequest {
105
123
  readonly cwd: string;
106
124
  readonly prompt: string;
107
125
  readonly resume?: AgentSessionID;
108
- readonly permissionMode?: string;
109
-
110
- // Settings file the run's CLI is started with, so a headless turn reaches
111
- // the same backend the session's terminal did.
112
- readonly settings?: string;
113
126
 
114
- // The atc session the run belongs to; the mod reads it to tap the inbox.
127
+ // The atc session the run belongs to.
115
128
  readonly sessionID?: SessionID;
116
129
 
117
- // Folder of the atc-bridge mod the run's CLI loads, supplied by the adapter.
118
- readonly pluginDir?: string;
119
-
120
130
  // The session's own model and effort, so a headless turn keeps them.
121
131
  readonly model?: string;
122
132
  readonly effort?: string;
133
+
134
+ // Environment variable names the run's process goes without, such as the
135
+ // credential a session's workspace was cloned with.
136
+ readonly withheldEnv?: readonly string[];
123
137
  }
124
138
 
125
- interface HeadlessRunEvents {
139
+ export interface HeadlessRunEvents {
126
140
  readonly onOutput: (text: string) => void;
127
141
 
128
142
  // The turn's whole final message.
@@ -186,6 +200,16 @@ export interface SpawnOptionSpecs {
186
200
  readonly effort: SpawnOptionSpec;
187
201
  }
188
202
 
203
+ /**
204
+ * The input modes the agent's TUI has switched on, as the session's screen
205
+ * model last saw them.
206
+ */
207
+ export interface TerminalInputModes {
208
+ // Whether the TUI asked for pasted text to arrive between bracketed paste
209
+ // markers (DEC mode 2004).
210
+ readonly bracketedPaste: boolean;
211
+ }
212
+
189
213
  /**
190
214
  * Everything specific to one agent CLI: how to spawn it, how to read its
191
215
  * hook payloads, where its session names come from, and how to resume a
@@ -210,6 +234,15 @@ export interface AgentAdapter {
210
234
  // Absent on a stand-in adapter, which `agents.list` reports as not installed.
211
235
  readonly profile?: AgentProfile;
212
236
  readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
237
+
238
+ // Plans a spawn on a remote host; null when this agent cannot run there,
239
+ // such as one whose instrumentation needs atc on a host without it.
240
+ // Absent: the agent runs there as a local spawn plans it, with no files.
241
+ readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
242
+
243
+ // The command that exits 0 inside a remote host when the agent there can
244
+ // sign in without a person. Absent: atc runs no check.
245
+ readonly planAuthCheck?: () => readonly string[];
213
246
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
214
247
  readonly loadName: (
215
248
  source: string,
@@ -225,4 +258,8 @@ export interface AgentAdapter {
225
258
  // null for a line that is not one. Absent: atc cannot read this agent's
226
259
  // transcript.
227
260
  readonly parseTranscriptLine?: (line: string) => TranscriptRow | null;
261
+
262
+ // The PTY writes, in order, that type a line into the agent's TUI and
263
+ // submit it. Absent: the line and a trailing newline go as one write.
264
+ readonly planLineInput?: (text: string, modes: TerminalInputModes) => readonly string[];
228
265
  }
@@ -0,0 +1,13 @@
1
+ import { ATC_BRIDGE_FILES } from './atc-bridge-files';
2
+
3
+ /**
4
+ * The files of the atc-bridge mod, keyed by their path inside the mod's
5
+ * folder, with the atc command the mod runs rendered from `argv`.
6
+ */
7
+ export function buildATCBridgeFiles(argv: readonly string[]): Readonly<Record<string, string>> {
8
+ return { ...ATC_BRIDGE_FILES, 'hooks/atc-cli.ts': renderATCCLIModule(argv) };
9
+ }
10
+
11
+ function renderATCCLIModule(argv: readonly string[]): string {
12
+ return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
13
+ }
@@ -0,0 +1,72 @@
1
+ import type { Options } from '@anthropic-ai/claude-agent-sdk';
2
+ import { socketPath } from '../shared/config';
3
+ import type { HeadlessRunRequest } from './agent-adapter';
4
+ import { buildHeadlessEnv } from './build-headless-env';
5
+ import { resolveHeadlessExecutable } from './resolve-headless-executable';
6
+
7
+ /**
8
+ * One headless turn as the Claude CLI runs it: the neutral turn request plus
9
+ * the binary, permission mode, mod folder, and settings file the Claude and
10
+ * gateway adapters supply.
11
+ */
12
+ export interface ClaudeHeadlessRunRequest extends HeadlessRunRequest {
13
+ // The Claude Code binary a compiled atc hands the Agent SDK.
14
+ readonly claudeBin: string;
15
+
16
+ // An SDK permission mode; any other value leaves the SDK's default.
17
+ readonly permissionMode?: string;
18
+
19
+ // Settings file the run's CLI is started with, so a headless turn reaches
20
+ // the same backend the session's terminal did.
21
+ readonly settings?: string;
22
+
23
+ // Folder of the atc-bridge mod the run's CLI loads.
24
+ readonly pluginDir?: string;
25
+ }
26
+
27
+ const PERMISSION_MODES = [
28
+ 'default',
29
+ 'acceptEdits',
30
+ 'bypassPermissions',
31
+ 'plan',
32
+ 'auto',
33
+ 'dontAsk',
34
+ ] as const;
35
+
36
+ // The effort levels the Agent SDK accepts.
37
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
38
+
39
+ /**
40
+ * The Agent SDK options one headless turn runs with, apart from the abort
41
+ * controller and stderr sink the live run owns. A permission mode or effort
42
+ * the SDK does not accept is left out, so the SDK applies its own default.
43
+ */
44
+ export function buildClaudeQueryOptions(
45
+ opts: ClaudeHeadlessRunRequest,
46
+ compiled: boolean,
47
+ ): Options {
48
+ const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
49
+ const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
50
+
51
+ return {
52
+ cwd: opts.cwd,
53
+ env: buildHeadlessEnv({
54
+ socketPath,
55
+ ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
56
+ ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
57
+ ...(opts.withheldEnv === undefined ? {} : { withheldEnv: opts.withheldEnv }),
58
+ }),
59
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
60
+ ...(mode === undefined ? {} : { permissionMode: mode }),
61
+
62
+ // The session's own model and effort, so the turn runs as its terminal
63
+ // did.
64
+ ...(opts.model === undefined ? {} : { model: opts.model }),
65
+ ...(effort === undefined ? {} : { effort }),
66
+
67
+ // The same generated file the terminal spawn passes, so the turn runs
68
+ // against the session's own backend and instrumentation.
69
+ ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
70
+ ...resolveHeadlessExecutable(opts.claudeBin, compiled),
71
+ };
72
+ }
@@ -3,10 +3,12 @@ import { buildCLIArgv } from './build-cli-argv';
3
3
  /**
4
4
  * Command line that wrangled sessions invoke for atc subcommands: under bun
5
5
  * the CLI entry path is part of the command; a compiled binary is itself
6
- * the entry.
6
+ * the entry. A session on a remote host passes the argv of the atc inside
7
+ * that host.
7
8
  */
8
- export function buildCLICommand(subcommand: string): string {
9
- return `${buildCLIArgv()
10
- .map((part) => `"${part}"`)
11
- .join(' ')} ${subcommand}`;
9
+ export function buildCLICommand(
10
+ subcommand: string,
11
+ argv: readonly string[] = buildCLIArgv(),
12
+ ): string {
13
+ return `${argv.map((part) => `"${part}"`).join(' ')} ${subcommand}`;
12
14
  }
@@ -5,6 +5,9 @@ interface HeadlessEnvParams {
5
5
  readonly pluginDir?: string;
6
6
  readonly sessionID?: SessionID;
7
7
  readonly socketPath: string;
8
+
9
+ // Variables the run goes without, such as a workspace credential.
10
+ readonly withheldEnv?: readonly string[];
8
11
  }
9
12
 
10
13
  /**
@@ -14,10 +17,13 @@ interface HeadlessEnvParams {
14
17
  * since the mod stays off outside atc.
15
18
  */
16
19
  export function buildHeadlessEnv(params: HeadlessEnvParams): Record<string, string> {
17
- return collectCleanEnv({
18
- ...(params.pluginDir === undefined ? {} : { CLAUDE_CODE_PLUGIN_DIRS: params.pluginDir }),
19
- ...(params.sessionID === undefined
20
- ? {}
21
- : { ATC_SESSION_ID: params.sessionID, ATC_SOCKET: params.socketPath }),
22
- });
20
+ return collectCleanEnv(
21
+ {
22
+ ...(params.pluginDir === undefined ? {} : { CLAUDE_CODE_PLUGIN_DIRS: params.pluginDir }),
23
+ ...(params.sessionID === undefined
24
+ ? {}
25
+ : { ATC_SESSION_ID: params.sessionID, ATC_SOCKET: params.socketPath }),
26
+ },
27
+ params.withheldEnv,
28
+ );
23
29
  }
@@ -26,13 +26,19 @@ export interface HookSettingsProfile {
26
26
  * a session's backend is decided here rather than by whatever the terminal
27
27
  * happened to carry. The credential is never part of it: the helper command
28
28
  * supplies that at run time, so it never reaches a file atc writes.
29
+ *
30
+ * A session on a remote host gets the argv of the atc inside that host,
31
+ * whose statusline renders that session's own state alone.
29
32
  */
30
33
  export function buildHookSettings(
31
34
  profile: HookSettingsProfile,
32
35
  statuslinePadding: number,
36
+ cliArgv?: readonly string[],
33
37
  ): Record<string, unknown> {
34
38
  const entry = [
35
- { hooks: [{ type: 'command', command: buildCLICommand('hook-report'), timeout: 5 }] },
39
+ {
40
+ hooks: [{ type: 'command', command: buildCLICommand('hook-report', cliArgv), timeout: 5 }],
41
+ },
36
42
  ];
37
43
 
38
44
  const own = {
@@ -55,7 +61,7 @@ export function buildHookSettings(
55
61
  // padding.
56
62
  statusLine: {
57
63
  type: 'command',
58
- command: buildCLICommand('statusline'),
64
+ command: buildCLICommand('statusline', cliArgv),
59
65
  padding: statuslinePadding,
60
66
  },
61
67
  ...(profile.env === undefined || Object.keys(profile.env).length === 0
@@ -0,0 +1,23 @@
1
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
2
+ import { findFlagValue } from './find-flag-value';
3
+
4
+ /**
5
+ * The arguments that restore a Claude CLI session in its configured
6
+ * permission mode. A resumed session otherwise takes back the mode it was
7
+ * saved in, and only an explicit `--permission-mode` overrides that, so a
8
+ * mode the settings alone set travels as the flag. Nothing is added when
9
+ * the arguments already carry the flag or no mode is configured, so an
10
+ * unconfigured session restores the mode it was saved in.
11
+ */
12
+ export function buildRestoreModeArgs(
13
+ args: readonly string[],
14
+ settings: Readonly<Record<string, unknown>> | undefined,
15
+ ): string[] {
16
+ if (findFlagValue(args, ['--permission-mode']) !== null) {
17
+ return [];
18
+ }
19
+
20
+ const mode = findClaudePermissionMode([], settings);
21
+
22
+ return mode === null ? [] : ['--permission-mode', mode];
23
+ }
@@ -11,6 +11,8 @@ import type {
11
11
  AdapterEvent,
12
12
  AgentAdapter,
13
13
  AgentProfile,
14
+ GuestPaths,
15
+ GuestSpawnPlan,
14
16
  HeadlessRunner,
15
17
  NameUpdate,
16
18
  ResumeCheck,
@@ -18,10 +20,17 @@ import type {
18
20
  SpawnOptions,
19
21
  SpawnPlan,
20
22
  } from './agent-adapter';
23
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
21
24
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
25
+ import { buildHookSettings } from './build-hook-settings';
22
26
  import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
27
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
23
28
  import { findFlagValue } from './find-flag-value';
29
+ import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
30
+ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
24
31
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
32
+ import { planTypedLineInput } from './plan-typed-line-input';
33
+ import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
25
34
  import { truncateDetail } from './truncate-detail';
26
35
  import { writeATCBridge } from './write-atc-bridge';
27
36
  import { writeHookSettings } from './write-hook-settings';
@@ -53,6 +62,9 @@ export class ClaudeAdapter implements AgentAdapter {
53
62
 
54
63
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
55
64
 
65
+ // Claude's TUI submits a line typed with its newline in one write.
66
+ readonly planLineInput = planTypedLineInput;
67
+
56
68
  readonly profile: AgentProfile;
57
69
 
58
70
  readonly takesMessages = true;
@@ -67,7 +79,7 @@ export class ClaudeAdapter implements AgentAdapter {
67
79
 
68
80
  private readonly bridgeTarget: string | undefined;
69
81
 
70
- constructor(config: Config, headlessRunner: HeadlessRunner | null = null, bridgeTarget?: string) {
82
+ constructor(config: Config, headlessRun: ClaudeHeadlessRun | null = null, bridgeTarget?: string) {
71
83
  this.bridgeTarget = bridgeTarget;
72
84
  this.config = config;
73
85
 
@@ -80,9 +92,13 @@ export class ClaudeAdapter implements AgentAdapter {
80
92
  };
81
93
 
82
94
  this.headlessRunner =
83
- headlessRunner === null
95
+ headlessRun === null
84
96
  ? null
85
- : (opts, hooks) => headlessRunner({ ...opts, pluginDir: this.writeBridge() }, hooks);
97
+ : makeClaudeHeadlessRunner(headlessRun, {
98
+ claudeBin: config.claudeBin,
99
+ permissionMode: resolveClaudePermissionMode(config.claudeArgs, undefined),
100
+ pluginDir: () => this.writeBridge(),
101
+ });
86
102
  }
87
103
 
88
104
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -90,19 +106,47 @@ export class ClaudeAdapter implements AgentAdapter {
90
106
 
91
107
  return {
92
108
  bin: this.config.claudeBin,
93
- args: [
94
- ...buildClaudeOverrideArgs(this.config.claudeArgs, opts),
95
- '--settings',
96
- this.settingsFile,
97
- '--plugin-dir',
98
- this.writeBridge(),
99
- ...(opts.resume === true ? ['--resume'] : []),
100
- ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
101
- ...(opts.prompt === '' ? [] : [opts.prompt]),
102
- ],
109
+ args: this.buildArgs(opts, this.settingsFile, this.writeBridge()),
103
110
  };
104
111
  }
105
112
 
113
+ // A remote session reports through the atc inside its host, so without
114
+ // one it has no instrumentation and cannot run there. Its settings and
115
+ // its copy of the mod travel with it, and it has no statusline.
116
+ planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
117
+ if (guest.atc === null) {
118
+ return null;
119
+ }
120
+
121
+ const argv = [guest.atc];
122
+
123
+ const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
124
+ ([path, content]): [string, string] => [`atc-bridge/${path}`, content],
125
+ );
126
+
127
+ return {
128
+ bin: this.config.claudeBin,
129
+ args: this.buildArgs(opts, `${guest.dir}/settings.json`, `${guest.dir}/atc-bridge`),
130
+ files: {
131
+ 'settings.json': JSON.stringify(buildHookSettings({ id: this.id }, 0, argv), null, 2),
132
+ ...Object.fromEntries(bridge),
133
+ },
134
+ };
135
+ }
136
+
137
+ private buildArgs(opts: SpawnOptions, settings: string, pluginDir: string): string[] {
138
+ return [
139
+ ...buildClaudeOverrideArgs(this.config.claudeArgs, opts),
140
+ '--settings',
141
+ settings,
142
+ '--plugin-dir',
143
+ pluginDir,
144
+ ...(opts.resume === true ? ['--resume'] : []),
145
+ ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
146
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
147
+ ];
148
+ }
149
+
106
150
  private writeBridge(): string {
107
151
  this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
108
152
 
@@ -233,12 +277,15 @@ export class ClaudeAdapter implements AgentAdapter {
233
277
  return existsSync(session.transcriptSource);
234
278
  }
235
279
 
236
- // Shell command that re-opens this session outside atc (or anywhere).
280
+ // Shell command that re-opens this session outside atc (or anywhere). A
281
+ // permission mode the configured arguments set travels as an explicit
282
+ // flag, so it overrides the mode the CLI would restore.
237
283
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
238
- const resume =
239
- agentSessionID === undefined ? 'claude --resume' : `claude --resume ${agentSessionID}`;
284
+ const configured = findClaudePermissionMode(this.config.claudeArgs, undefined);
285
+ const mode = configured === null ? '' : ` --permission-mode ${toShellArg(configured)}`;
286
+ const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
240
287
 
241
- return `cd ${toShellArg(cwd)} && ${resume}`;
288
+ return `cd ${toShellArg(cwd)} && claude${mode} --resume${resume}`;
242
289
  }
243
290
  }
244
291
 
@@ -20,6 +20,7 @@ import type {
20
20
  } from './agent-adapter';
21
21
  import { buildArgsWithoutFlags } from './build-args-without-flags';
22
22
  import { findFlagValue } from './find-flag-value';
23
+ import { planPastedLineInput } from './plan-pasted-line-input';
23
24
  import { resolveAgentHome } from './resolve-agent-home';
24
25
  import { truncateDetail } from './truncate-detail';
25
26
 
@@ -58,6 +59,10 @@ export class CodexAdapter implements AgentAdapter {
58
59
 
59
60
  readonly takesMessages = false;
60
61
 
62
+ // Codex's TUI keeps a newline that arrives inside a burst of input, so a
63
+ // line is pasted and then submitted.
64
+ readonly planLineInput = planPastedLineInput;
65
+
61
66
  private readonly config: Config;
62
67
 
63
68
  constructor(config: Config) {
@@ -0,0 +1,24 @@
1
+ import { isRecord } from '../shared/report';
2
+ import { findFlagValue } from './find-flag-value';
3
+
4
+ /**
5
+ * The permission mode a Claude CLI agent's configuration sets: an explicit
6
+ * `--permission-mode` in its arguments, else the `permissions.defaultMode`
7
+ * its settings hold, or null when neither sets one. The argument wins, as it
8
+ * does for the CLI itself.
9
+ */
10
+ export function findClaudePermissionMode(
11
+ args: readonly string[],
12
+ settings: Readonly<Record<string, unknown>> | undefined,
13
+ ): string | null {
14
+ const flag = findFlagValue(args, ['--permission-mode']);
15
+
16
+ if (flag !== null) {
17
+ return flag;
18
+ }
19
+
20
+ const permissions = settings?.['permissions'];
21
+ const defaultMode = isRecord(permissions) ? permissions['defaultMode'] : undefined;
22
+
23
+ return typeof defaultMode === 'string' && defaultMode !== '' ? defaultMode : null;
24
+ }
@@ -16,10 +16,15 @@ import type {
16
16
  SpawnPlan,
17
17
  } from './agent-adapter';
18
18
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
19
+ import { buildRestoreModeArgs } from './build-restore-mode-args';
19
20
  import { ClaudeAdapter } from './claude-adapter';
20
21
  import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
21
22
  import { findFlagValue } from './find-flag-value';
23
+ import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
24
+ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
22
25
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
26
+ import { planTypedLineInput } from './plan-typed-line-input';
27
+ import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
23
28
  import { writeATCBridge } from './write-atc-bridge';
24
29
  import { writeHookSettings } from './write-hook-settings';
25
30
 
@@ -43,6 +48,9 @@ export class GatewayAdapter implements AgentAdapter {
43
48
  // The gateway runs the Claude CLI, which writes the same transcript.
44
49
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
45
50
 
51
+ // The gateway runs the Claude CLI, whose TUI takes a line the same way.
52
+ readonly planLineInput = planTypedLineInput;
53
+
46
54
  readonly takesMessages = true;
47
55
 
48
56
  readonly profile: AgentProfile;
@@ -62,7 +70,7 @@ export class GatewayAdapter implements AgentAdapter {
62
70
  constructor(
63
71
  gateway: GatewayConfig,
64
72
  config: Config,
65
- headlessRunner: HeadlessRunner | null = null,
73
+ headlessRun: ClaudeHeadlessRun | null = null,
66
74
  bridgeTarget?: string,
67
75
  ) {
68
76
  this.bridgeTarget = bridgeTarget;
@@ -82,13 +90,14 @@ export class GatewayAdapter implements AgentAdapter {
82
90
  this.claude = new ClaudeAdapter(config);
83
91
 
84
92
  this.headlessRunner =
85
- headlessRunner === null
93
+ headlessRun === null
86
94
  ? null
87
- : (opts, hooks) =>
88
- headlessRunner(
89
- { ...opts, settings: this.writeSettings(), pluginDir: this.writeBridge() },
90
- hooks,
91
- );
95
+ : makeClaudeHeadlessRunner(headlessRun, {
96
+ claudeBin: gateway.bin,
97
+ permissionMode: resolveClaudePermissionMode(gateway.args, gateway.settings),
98
+ pluginDir: () => this.writeBridge(),
99
+ settings: () => this.writeSettings(),
100
+ });
92
101
  }
93
102
 
94
103
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -96,6 +105,9 @@ export class GatewayAdapter implements AgentAdapter {
96
105
  bin: this.gateway.bin,
97
106
  args: [
98
107
  ...buildClaudeOverrideArgs(this.gateway.args, opts),
108
+ ...(opts.resume === false
109
+ ? []
110
+ : buildRestoreModeArgs(this.gateway.args, this.gateway.settings)),
99
111
  '--settings',
100
112
  this.writeSettings(),
101
113
  '--plugin-dir',
@@ -107,6 +119,12 @@ export class GatewayAdapter implements AgentAdapter {
107
119
  };
108
120
  }
109
121
 
122
+ // The credential helper runs on the daemon's machine, so a gateway
123
+ // session never runs on a remote host.
124
+ planGuestSpawn(): null {
125
+ return null;
126
+ }
127
+
110
128
  normalizeHook(e: HookEvent): AdapterEvent {
111
129
  return this.claude.normalizeHook(e);
112
130
  }
@@ -119,14 +137,23 @@ export class GatewayAdapter implements AgentAdapter {
119
137
  return this.claude.canResume(session);
120
138
  }
121
139
 
122
- // Shell command that re-opens this session outside atc. It names the
123
- // generated settings file, because without it the CLI would resume the
124
- // session against the default backend.
140
+ // Shell command that re-opens this session outside atc. It carries the
141
+ // gateway's configured arguments and its configured permission mode as an
142
+ // explicit flag, so that mode overrides the one the CLI would restore, and
143
+ // the generated settings file, because
144
+ // without it the CLI would resume the session against the default backend.
125
145
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
146
+ const args = [
147
+ ...this.gateway.args,
148
+ ...buildRestoreModeArgs(this.gateway.args, this.gateway.settings),
149
+ ]
150
+ .map((arg) => ` ${toShellArg(arg)}`)
151
+ .join('');
152
+
126
153
  const settings = toShellArg(this.writeSettings());
127
154
  const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
128
155
 
129
- return `cd ${toShellArg(cwd)} && ${this.gateway.bin} --settings ${settings} --resume${resume}`;
156
+ return `cd ${toShellArg(cwd)} && ${this.gateway.bin}${args} --settings ${settings} --resume${resume}`;
130
157
  }
131
158
 
132
159
  private writeSettings(): string {
@@ -20,6 +20,7 @@ import type {
20
20
  SpawnPlan,
21
21
  } from './agent-adapter';
22
22
  import { normalizeHookEventName } from './normalize-hook-event';
23
+ import { planPastedLineInput } from './plan-pasted-line-input';
23
24
  import { resolveAgentHome } from './resolve-agent-home';
24
25
  import { truncateDetail } from './truncate-detail';
25
26
 
@@ -76,6 +77,10 @@ export class GrokAdapter implements AgentAdapter {
76
77
 
77
78
  readonly takesMessages = false;
78
79
 
80
+ // Grok's TUI keeps a newline that arrives inside a burst of input, so a
81
+ // line is pasted and then submitted.
82
+ readonly planLineInput = planPastedLineInput;
83
+
79
84
  private readonly config: Config;
80
85
 
81
86
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();