@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.11.0",
3
+ "version": "2.13.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",
@@ -25,6 +25,20 @@ export interface SpawnOptions {
25
25
  // true opens the agent's own session picker; an agent session id resumes
26
26
  // that specific session.
27
27
  readonly resume: boolean | AgentSessionID;
28
+
29
+ // The model and effort the session runs with; an absent one leaves the
30
+ // agent's configured default in place.
31
+ readonly model?: string;
32
+ readonly effort?: string;
33
+ }
34
+
35
+ /**
36
+ * The per-session model and effort a spawn asks for, already checked
37
+ * against what the agent advertises.
38
+ */
39
+ export interface SpawnOverrides {
40
+ readonly model?: string;
41
+ readonly effort?: string;
28
42
  }
29
43
 
30
44
  export interface SpawnPlan {
@@ -32,6 +46,24 @@ export interface SpawnPlan {
32
46
  args: string[];
33
47
  }
34
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
+
35
67
  export interface AdapterEvent {
36
68
  kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
37
69
  agentSessionID?: AgentSessionID;
@@ -87,24 +119,24 @@ export interface ResumeCheck {
87
119
  readonly transcriptSource?: string;
88
120
  }
89
121
 
90
- interface HeadlessRunRequest {
122
+ export interface HeadlessRunRequest {
91
123
  readonly cwd: string;
92
124
  readonly prompt: string;
93
125
  readonly resume?: AgentSessionID;
94
- readonly permissionMode?: string;
95
126
 
96
- // Settings file the run's CLI is started with, so a headless turn reaches
97
- // the same backend the session's terminal did.
98
- readonly settings?: string;
99
-
100
- // The atc session the run belongs to; the mod reads it to tap the inbox.
127
+ // The atc session the run belongs to.
101
128
  readonly sessionID?: SessionID;
102
129
 
103
- // Folder of the atc-bridge mod the run's CLI loads, supplied by the adapter.
104
- readonly pluginDir?: string;
130
+ // The session's own model and effort, so a headless turn keeps them.
131
+ readonly model?: string;
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[];
105
137
  }
106
138
 
107
- interface HeadlessRunEvents {
139
+ export interface HeadlessRunEvents {
108
140
  readonly onOutput: (text: string) => void;
109
141
 
110
142
  // The turn's whole final message.
@@ -133,6 +165,49 @@ export interface AgentProfile {
133
165
 
134
166
  // Model names the config sets explicitly, keyed by role; null when it sets none.
135
167
  readonly models: Readonly<Record<string, string>> | null;
168
+
169
+ // The per-session overrides a spawn can pass this agent's CLI.
170
+ readonly spawnOptions: SpawnOptionSpecs;
171
+ }
172
+
173
+ /**
174
+ * A value worth offering for a spawn option, with the provider model it
175
+ * maps to when the config holds that mapping.
176
+ */
177
+ interface SpawnOptionExample {
178
+ readonly value: string;
179
+ readonly resolvesTo: string | null;
180
+ }
181
+
182
+ /**
183
+ * Whether the agent's CLI takes one spawn option, and which values it does.
184
+ * `values` holds the closed set a value must come from; null takes any
185
+ * well-formed value. `backendEffect` is `applied` when the CLI applies the
186
+ * value itself and `unverified` when the backend behind the CLI may ignore
187
+ * it; null for an option the agent does not take.
188
+ */
189
+ export interface SpawnOptionSpec {
190
+ readonly supported: boolean;
191
+ readonly values: readonly string[] | null;
192
+ readonly examples: readonly SpawnOptionExample[];
193
+ readonly default: string | null;
194
+ readonly backendEffect: 'applied' | 'unverified' | null;
195
+ readonly note: string | null;
196
+ }
197
+
198
+ export interface SpawnOptionSpecs {
199
+ readonly model: SpawnOptionSpec;
200
+ readonly effort: SpawnOptionSpec;
201
+ }
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;
136
211
  }
137
212
 
138
213
  /**
@@ -159,6 +234,15 @@ export interface AgentAdapter {
159
234
  // Absent on a stand-in adapter, which `agents.list` reports as not installed.
160
235
  readonly profile?: AgentProfile;
161
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[];
162
246
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
163
247
  readonly loadName: (
164
248
  source: string,
@@ -174,4 +258,8 @@ export interface AgentAdapter {
174
258
  // null for a line that is not one. Absent: atc cannot read this agent's
175
259
  // transcript.
176
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[];
177
265
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * An argument list with every occurrence of the named flags removed, each
3
+ * with its value: the flag followed by its value, or `--flag=value`. Every
4
+ * other argument keeps its place.
5
+ */
6
+ export function buildArgsWithoutFlags(args: readonly string[], names: readonly string[]): string[] {
7
+ const kept: string[] = [];
8
+
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index] ?? '';
11
+
12
+ if (names.includes(arg)) {
13
+ index += 1;
14
+ continue;
15
+ }
16
+
17
+ if (names.some((name) => name.startsWith('--') && arg.startsWith(`${name}=`))) {
18
+ continue;
19
+ }
20
+
21
+ kept.push(arg);
22
+ }
23
+
24
+ return kept;
25
+ }
@@ -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,26 @@
1
+ import type { SpawnOverrides } from './agent-adapter';
2
+ import { buildArgsWithoutFlags } from './build-args-without-flags';
3
+
4
+ /**
5
+ * The configured leading arguments for a Claude CLI spawn with the session's
6
+ * model and effort applied. An override replaces any `--model` or `--effort`
7
+ * the configured arguments carry, value included, so the session never
8
+ * depends on which occurrence the CLI honours. Without overrides the
9
+ * configured arguments pass through unchanged. Each value travels as its own
10
+ * argument, never through a shell.
11
+ */
12
+ export function buildClaudeOverrideArgs(
13
+ configured: readonly string[],
14
+ overrides: SpawnOverrides,
15
+ ): string[] {
16
+ const replaced = [
17
+ ...(overrides.model === undefined ? [] : ['--model']),
18
+ ...(overrides.effort === undefined ? [] : ['--effort']),
19
+ ];
20
+
21
+ return [
22
+ ...buildArgsWithoutFlags(configured, replaced),
23
+ ...(overrides.model === undefined ? [] : ['--model', overrides.model]),
24
+ ...(overrides.effort === undefined ? [] : ['--effort', overrides.effort]),
25
+ ];
26
+ }
@@ -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,13 +11,26 @@ import type {
11
11
  AdapterEvent,
12
12
  AgentAdapter,
13
13
  AgentProfile,
14
+ GuestPaths,
15
+ GuestSpawnPlan,
14
16
  HeadlessRunner,
15
17
  NameUpdate,
16
18
  ResumeCheck,
19
+ SpawnOptionSpecs,
17
20
  SpawnOptions,
18
21
  SpawnPlan,
19
22
  } from './agent-adapter';
23
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
24
+ import { buildClaudeOverrideArgs } from './build-claude-override-args';
25
+ import { buildHookSettings } from './build-hook-settings';
26
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
27
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
28
+ import { findFlagValue } from './find-flag-value';
29
+ import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
30
+ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
20
31
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
32
+ import { planTypedLineInput } from './plan-typed-line-input';
33
+ import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
21
34
  import { truncateDetail } from './truncate-detail';
22
35
  import { writeATCBridge } from './write-atc-bridge';
23
36
  import { writeHookSettings } from './write-hook-settings';
@@ -49,6 +62,9 @@ export class ClaudeAdapter implements AgentAdapter {
49
62
 
50
63
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
51
64
 
65
+ // Claude's TUI submits a line typed with its newline in one write.
66
+ readonly planLineInput = planTypedLineInput;
67
+
52
68
  readonly profile: AgentProfile;
53
69
 
54
70
  readonly takesMessages = true;
@@ -63,15 +79,26 @@ export class ClaudeAdapter implements AgentAdapter {
63
79
 
64
80
  private readonly bridgeTarget: string | undefined;
65
81
 
66
- constructor(config: Config, headlessRunner: HeadlessRunner | null = null, bridgeTarget?: string) {
82
+ constructor(config: Config, headlessRun: ClaudeHeadlessRun | null = null, bridgeTarget?: string) {
67
83
  this.bridgeTarget = bridgeTarget;
68
84
  this.config = config;
69
- this.profile = { label: 'Claude', kind: 'claude', bin: config.claudeBin, models: null };
85
+
86
+ this.profile = {
87
+ label: 'Claude',
88
+ kind: 'claude',
89
+ bin: config.claudeBin,
90
+ models: null,
91
+ spawnOptions: buildClaudeSpawnOptions(config.claudeArgs),
92
+ };
70
93
 
71
94
  this.headlessRunner =
72
- headlessRunner === null
95
+ headlessRun === null
73
96
  ? null
74
- : (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
+ });
75
102
  }
76
103
 
77
104
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -79,19 +106,47 @@ export class ClaudeAdapter implements AgentAdapter {
79
106
 
80
107
  return {
81
108
  bin: this.config.claudeBin,
82
- args: [
83
- ...this.config.claudeArgs,
84
- '--settings',
85
- this.settingsFile,
86
- '--plugin-dir',
87
- this.writeBridge(),
88
- ...(opts.resume === true ? ['--resume'] : []),
89
- ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
90
- ...(opts.prompt === '' ? [] : [opts.prompt]),
91
- ],
109
+ args: this.buildArgs(opts, this.settingsFile, this.writeBridge()),
92
110
  };
93
111
  }
94
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
+
95
150
  private writeBridge(): string {
96
151
  this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
97
152
 
@@ -222,11 +277,50 @@ export class ClaudeAdapter implements AgentAdapter {
222
277
  return existsSync(session.transcriptSource);
223
278
  }
224
279
 
225
- // 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.
226
283
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
227
- const resume =
228
- 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}`;
229
287
 
230
- return `cd ${toShellArg(cwd)} && ${resume}`;
288
+ return `cd ${toShellArg(cwd)} && claude${mode} --resume${resume}`;
231
289
  }
232
290
  }
291
+
292
+ // The aliases Claude Code documents for `--model`, each resolving to a model
293
+ // the account picks. A full model name is accepted as well.
294
+ const CLAUDE_MODEL_ALIASES = [
295
+ 'best',
296
+ 'fable',
297
+ 'opus',
298
+ 'sonnet',
299
+ 'haiku',
300
+ 'opus[1m]',
301
+ 'sonnet[1m]',
302
+ 'opusplan',
303
+ ];
304
+
305
+ // What a Claude spawn can override. Each default is the value the configured
306
+ // arguments pass, or null when the CLI picks its own.
307
+ function buildClaudeSpawnOptions(claudeArgs: readonly string[]): SpawnOptionSpecs {
308
+ return {
309
+ model: {
310
+ supported: true,
311
+ values: null,
312
+ examples: CLAUDE_MODEL_ALIASES.map((value) => ({ value, resolvesTo: null })),
313
+ default: findFlagValue(claudeArgs, ['--model']),
314
+ backendEffect: 'applied',
315
+ note: 'An alias or a full model name, passed as --model.',
316
+ },
317
+ effort: {
318
+ supported: true,
319
+ values: CLAUDE_EFFORT_LEVELS,
320
+ examples: [],
321
+ default: findFlagValue(claudeArgs, ['--effort']),
322
+ backendEffect: 'applied',
323
+ note: 'Passed as --effort. Which levels a session honours depends on its model.',
324
+ },
325
+ };
326
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The effort levels Claude Code's `--effort` flag accepts. Which of them a
3
+ * session honours depends on its model.
4
+ */
5
+ export const CLAUDE_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'];