@zgeoff/atc 2.36.1 → 2.38.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 (33) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +6 -1
  4. package/src/agents/build-agent-adapters.ts +32 -0
  5. package/src/agents/build-claude-config-seed.ts +7 -4
  6. package/src/agents/build-claude-mcp-config.ts +27 -0
  7. package/src/agents/claude-adapter.ts +157 -66
  8. package/src/agents/codex-adapter.ts +17 -11
  9. package/src/agents/gateway-adapter.ts +17 -1
  10. package/src/agents/grok-adapter.ts +15 -9
  11. package/src/build-migrated-config.ts +196 -0
  12. package/src/cli.ts +49 -17
  13. package/src/client/collect-agent-picks.ts +3 -10
  14. package/src/client/index.ts +1 -1
  15. package/src/client/resolve-agent-metadata.ts +4 -10
  16. package/src/client/spawn-picker.ts +1 -2
  17. package/src/daemon/build-auth-binding.ts +2 -1
  18. package/src/daemon/daemon-connection.ts +1 -1
  19. package/src/daemon/daemon.ts +12 -3
  20. package/src/daemon/sessions.ts +8 -2
  21. package/src/mcp/build-spawn-descriptions.ts +2 -2
  22. package/src/run-config-migrate.ts +85 -0
  23. package/src/shared/check-gateway-auth.ts +173 -0
  24. package/src/shared/collect-agents.ts +349 -0
  25. package/src/shared/collect-claude-auth.ts +125 -10
  26. package/src/shared/collect-gateways.ts +2 -168
  27. package/src/shared/collect-legacy-agents.ts +80 -0
  28. package/src/shared/config.ts +70 -60
  29. package/src/shared/format-mixed-agent-keys.ts +10 -0
  30. package/src/shared/is-subscription-override-variable.ts +14 -0
  31. package/src/shared/legacy-agent-keys.ts +15 -0
  32. package/src/shared/outranking-variables.ts +17 -0
  33. package/src/shared/pick-default-agent.ts +12 -0
package/README.md CHANGED
@@ -151,7 +151,8 @@ installs in detail.
151
151
 
152
152
  atc creates `~/.config/atc/config.json` on first run. The
153
153
  [configuration guide](./docs/guides/configuration.md) covers every field: agent binaries and
154
- arguments, the leader key, gateways, and hooks.
154
+ arguments, the leader key, gateways, and hooks, and how `atc config migrate` moves an old config to
155
+ the `agents` map.
155
156
 
156
157
  ## Documentation
157
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.36.1",
3
+ "version": "2.38.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",
@@ -3,8 +3,9 @@ import type { DaemonError } from '../protocol/daemon-error';
3
3
  import type { HookEvent } from '../protocol/hook-event';
4
4
  import type { AgentID } from '../shared/agent-id';
5
5
  import type { AgentSessionID } from '../shared/agent-session-id';
6
+ import type { GatewayAuth } from '../shared/check-gateway-auth';
6
7
  import type { AuthProfile } from '../shared/collect-auth-profiles';
7
- import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
8
+ import type { GatewayConfig } from '../shared/collect-gateways';
8
9
  import type { SessionID } from '../shared/session-id';
9
10
 
10
11
  export interface SpawnOptions {
@@ -221,6 +222,10 @@ export interface AgentAdapter {
221
222
  // registered adapters.
222
223
  readonly id: AgentID;
223
224
 
225
+ // The agent value this harness's own hook lines carry, which a hook line
226
+ // must match for the session to act on it. Absent: the adapter's id.
227
+ readonly hookAgent?: AgentID;
228
+
224
229
  // Runs one headless turn over a session; null means eject is unsupported
225
230
  // for this agent.
226
231
  readonly headlessRunner: HeadlessRunner | null;
@@ -0,0 +1,32 @@
1
+ import type { Config } from '../shared/config';
2
+ import type { AgentAdapter } from './agent-adapter';
3
+ import { ClaudeAdapter } from './claude-adapter';
4
+ import { CodexAdapter } from './codex-adapter';
5
+ import { GatewayAdapter } from './gateway-adapter';
6
+ import { GrokAdapter } from './grok-adapter';
7
+ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
8
+
9
+ /**
10
+ * One adapter per registry entry, in registry order. A Claude entry with a
11
+ * base URL is a gateway, and one without is stock Claude.
12
+ */
13
+ export function buildAgentAdapters(
14
+ config: Config,
15
+ headlessRun: ClaudeHeadlessRun | null = null,
16
+ ): AgentAdapter[] {
17
+ return config.agents.map((entry): AgentAdapter => {
18
+ if (entry.kind === 'codex') {
19
+ return new CodexAdapter(entry);
20
+ }
21
+
22
+ if (entry.kind === 'grok') {
23
+ return new GrokAdapter(entry);
24
+ }
25
+
26
+ if (entry.baseURL === undefined) {
27
+ return new ClaudeAdapter(entry, config, headlessRun);
28
+ }
29
+
30
+ return new GatewayAdapter({ ...entry, baseURL: entry.baseURL }, config, headlessRun);
31
+ });
32
+ }
@@ -4,10 +4,12 @@ import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
4
4
  * The guest file that seeds a session's own Claude config folder with the
5
5
  * state the CLI reads from `.claude.json` to start without its first-run
6
6
  * onboarding, and with folder trust for `trustedRoot`, the exact root of a
7
- * verified clone, when one is given. It holds no account: the placeholder
7
+ * verified clone, when one is given. Trust also approves every MCP server
8
+ * the clone's own `.mcp.json` declares, so an unattended session never
9
+ * waits on the CLI's approval prompt. It holds no account: the placeholder
8
10
  * credential is what keeps the CLI from asking for a login. Without a
9
- * trusted root the CLI asks a person to trust the workspace on its first
10
- * start.
11
+ * trusted root the CLI asks a person to trust the workspace, and to approve
12
+ * the clone's MCP servers, on its first start.
11
13
  */
12
14
  export function buildClaudeConfigSeed(
13
15
  trustedRoot: string | null,
@@ -15,9 +17,10 @@ export function buildClaudeConfigSeed(
15
17
  const seed =
16
18
  trustedRoot === null
17
19
  ? ONBOARDED_CONFIG
18
- : { ...ONBOARDED_CONFIG, projects: { [trustedRoot]: { hasTrustDialogAccepted: true } } };
20
+ : { ...ONBOARDED_CONFIG, projects: { [trustedRoot]: TRUSTED_PROJECT } };
19
21
 
20
22
  return { [CLAUDE_CONFIG_SEED_FILE]: JSON.stringify(seed, null, 2) };
21
23
  }
22
24
 
23
25
  const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
26
+ const TRUSTED_PROJECT = { hasTrustDialogAccepted: true, enableAllProjectMcpServers: true };
@@ -0,0 +1,27 @@
1
+ import type { ClaudeMCPServer } from '../shared/collect-claude-auth';
2
+
3
+ /**
4
+ * The MCP config file that gives a session each server over HTTP with the
5
+ * broker's placeholder as a bearer credential in the server's header, so
6
+ * impd's broker swaps in the credential it holds for the server's host and
7
+ * the session never holds it.
8
+ */
9
+ export function buildClaudeMCPConfig(servers: readonly ClaudeMCPServer[]): {
10
+ readonly mcpServers: Readonly<Record<string, unknown>>;
11
+ } {
12
+ return {
13
+ mcpServers: Object.fromEntries(
14
+ servers.map((server) => [
15
+ server.name,
16
+ {
17
+ type: 'http',
18
+ url: server.url,
19
+ headers: { [server.header]: `Bearer ${BROKER_PLACEHOLDER}` },
20
+ },
21
+ ]),
22
+ ),
23
+ };
24
+ }
25
+
26
+ // The value impd's broker replaces with the credential on the host's side.
27
+ const BROKER_PLACEHOLDER = 'imp-broker-placeholder';
@@ -4,10 +4,13 @@ import { z } from 'zod';
4
4
  import type { AdapterEvent } from '../protocol/adapter-event';
5
5
  import { DaemonError } from '../protocol/daemon-error';
6
6
  import type { HookEvent } from '../protocol/hook-event';
7
+ import type { AgentID } from '../shared/agent-id';
7
8
  import type { AgentSessionID } from '../shared/agent-session-id';
8
9
  import { buildOptionalString } from '../shared/build-optional-string';
10
+ import type { AgentEntry } from '../shared/collect-agents';
9
11
  import type { Config } from '../shared/config';
10
- import { isBrokerVariable } from '../shared/is-broker-variable';
12
+ import { isSubscriptionOverrideVariable } from '../shared/is-subscription-override-variable';
13
+ import { OUTRANKING_VARIABLES } from '../shared/outranking-variables';
11
14
  import { isRecord } from '../shared/report';
12
15
  import { toAgentSessionID } from '../shared/to-agent-session-id';
13
16
  import { toShellArg } from '../shared/to-shell-arg';
@@ -29,8 +32,11 @@ import { buildArgsWithoutFlags } from './build-args-without-flags';
29
32
  import { buildATCBridgeFiles } from './build-atc-bridge-files';
30
33
  import { buildClaudeConfigSeed } from './build-claude-config-seed';
31
34
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
35
+ import { buildClaudeMCPConfig } from './build-claude-mcp-config';
32
36
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
33
37
  import { buildHookSettings } from './build-hook-settings';
38
+ import type { HookSettingsProfile } from './build-hook-settings';
39
+ import { buildRestoreModeArgs } from './build-restore-mode-args';
34
40
  import { CLAUDE_CONFIG_BUNDLE_FOLDER } from './claude-config-bundle-folder';
35
41
  import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
36
42
  import { findClaudePermissionMode } from './find-claude-permission-mode';
@@ -65,7 +71,11 @@ type ClaudeHookPayload = z.infer<typeof CLAUDE_HOOK_PAYLOAD_SCHEMA>;
65
71
  * resume semantics, transcript name-pulling, and statusline chaining.
66
72
  */
67
73
  export class ClaudeAdapter implements AgentAdapter {
68
- readonly id = 'claude';
74
+ readonly id: AgentID;
75
+
76
+ // The agent value the hook lines of a Claude session carry: the settings
77
+ // file atc writes for this id names it on each hook command.
78
+ readonly hookAgent: AgentID;
69
79
 
70
80
  readonly headlessRunner: HeadlessRunner | null;
71
81
 
@@ -82,7 +92,9 @@ export class ClaudeAdapter implements AgentAdapter {
82
92
 
83
93
  readonly takesMessages = true;
84
94
 
85
- private readonly config: Config;
95
+ private readonly entry: AgentEntry;
96
+
97
+ private readonly authProfiles: Config['authProfiles'];
86
98
 
87
99
  // Written on first spawn so constructing the adapter touches no state.
88
100
  private settingsFile: string | undefined;
@@ -92,44 +104,60 @@ export class ClaudeAdapter implements AgentAdapter {
92
104
 
93
105
  private readonly bridgeTarget: string | undefined;
94
106
 
95
- constructor(config: Config, headlessRun: ClaudeHeadlessRun | null = null, bridgeTarget?: string) {
107
+ constructor(
108
+ entry: AgentEntry,
109
+ config: Pick<Config, 'authProfiles'>,
110
+ headlessRun: ClaudeHeadlessRun | null = null,
111
+ bridgeTarget?: string,
112
+ ) {
96
113
  this.bridgeTarget = bridgeTarget;
97
- this.config = config;
114
+ this.entry = entry;
115
+ this.authProfiles = config.authProfiles;
116
+ this.id = entry.id;
117
+ this.hookAgent = entry.id;
98
118
 
99
119
  this.profile = {
100
- label: 'Claude',
120
+ label: entry.label,
101
121
  kind: 'claude',
102
- bin: config.claudeBin,
122
+ bin: entry.bin,
103
123
  models: null,
104
- spawnOptions: buildClaudeSpawnOptions(config.claudeArgs),
124
+ spawnOptions: buildClaudeSpawnOptions(entry.args),
105
125
  };
106
126
 
107
127
  this.headlessRunner =
108
128
  headlessRun === null
109
129
  ? null
110
130
  : makeClaudeHeadlessRunner(headlessRun, {
111
- claudeBin: config.claudeBin,
112
- permissionMode: resolveClaudePermissionMode(config.claudeArgs, undefined),
131
+ claudeBin: entry.bin,
132
+ permissionMode: resolveClaudePermissionMode(entry.args, entry.settings),
113
133
  pluginDir: () => this.writeBridge(),
134
+ settings: () => this.writeSettings(),
114
135
  });
115
136
  }
116
137
 
117
138
  planSpawn(opts: SpawnOptions): SpawnPlan {
118
- this.settingsFile ??= writeHookSettings({ id: this.id });
139
+ const modeArgs =
140
+ opts.resume === false ? [] : buildRestoreModeArgs(this.entry.args, this.entry.settings);
119
141
 
120
142
  return {
121
- bin: this.config.claudeBin,
122
- args: this.buildArgs(this.config.claudeArgs, opts, this.settingsFile, this.writeBridge()),
143
+ bin: this.entry.bin,
144
+ args: this.buildArgs(
145
+ this.entry.args,
146
+ opts,
147
+ modeArgs,
148
+ this.writeSettings(),
149
+ this.writeBridge(),
150
+ ),
123
151
  };
124
152
  }
125
153
 
126
- // With `claudeAuth` configured, Claude signs in with the subscription
154
+ // With `auth` configured, Claude signs in with the subscription
127
155
  // token impd's broker holds on a target that reaches the broker, and
128
156
  // with the sign-in of the host it runs on anywhere else.
129
157
  findAuthSelection(): AuthSelection | null {
130
- const auth = this.config.claudeAuth;
158
+ const auth = this.entry.auth;
131
159
 
132
- if (auth === null) {
160
+ if (auth === undefined) {
133
161
  return null;
134
162
  }
135
163
 
@@ -139,7 +167,7 @@ export class ClaudeAdapter implements AgentAdapter {
139
167
  baseURL: ANTHROPIC_API_URL,
140
168
  auth: { profiles: auth.profiles, placeholderEnv: { [OAUTH_VARIABLE]: PLACEHOLDER } },
141
169
  },
142
- profiles: this.config.authProfiles,
170
+ profiles: this.authProfiles,
143
171
  brokerRequired: false,
144
172
  };
145
173
  }
@@ -156,6 +184,9 @@ export class ClaudeAdapter implements AgentAdapter {
156
184
 
157
185
  const argv = [guest.atc];
158
186
 
187
+ const modeArgs =
188
+ opts.resume === false ? [] : buildRestoreModeArgs(this.entry.args, this.entry.settings);
189
+
159
190
  const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
160
191
  ([path, content]): [string, string] => [`atc-bridge/${path}`, content],
161
192
  );
@@ -165,15 +196,20 @@ export class ClaudeAdapter implements AgentAdapter {
165
196
  }
166
197
 
167
198
  return {
168
- bin: this.config.claudeBin,
199
+ bin: this.entry.bin,
169
200
  args: this.buildArgs(
170
- this.config.claudeArgs,
201
+ this.entry.args,
171
202
  opts,
203
+ modeArgs,
172
204
  `${guest.dir}/settings.json`,
173
205
  `${guest.dir}/atc-bridge`,
174
206
  ),
175
207
  files: {
176
- 'settings.json': JSON.stringify(buildHookSettings({ id: this.id }, 0, argv), null, 2),
208
+ 'settings.json': JSON.stringify(
209
+ buildHookSettings(this.buildSettingsProfile({}), 0, argv),
210
+ null,
211
+ 2,
212
+ ),
177
213
  ...Object.fromEntries(bridge),
178
214
  },
179
215
  };
@@ -183,7 +219,7 @@ export class ClaudeAdapter implements AgentAdapter {
183
219
  // broker with trust for the clone; any other remote session reads the
184
220
  // config of the host's image, which atc never writes.
185
221
  planGuestWorkspaceTrust(root: string): Readonly<Record<string, string>> | null {
186
- if (this.config.claudeAuth === null) {
222
+ if (this.entry.auth === undefined) {
187
223
  return null;
188
224
  }
189
225
 
@@ -197,7 +233,11 @@ export class ClaudeAdapter implements AgentAdapter {
197
233
  // config bundle, come from the host's own Claude config folder as it is
198
234
  // at this launch, staged under a key of this launch's own. A credential, endpoint, or provider variable in the
199
235
  // configured settings, or in the host's environment, would keep the CLI
200
- // from sending the placeholder, so either refuses the start.
236
+ // from sending the placeholder, so either refuses the start. The entry's
237
+ // MCP servers reach the CLI through an MCP config file of the binding
238
+ // revision, each with the placeholder in its header. Their flag sits
239
+ // ahead of the settings flag, so the variadic flag never takes the
240
+ // prompt as one of its values.
201
241
  private planSubscriptionGuestSpawn(
202
242
  opts: SpawnOptions,
203
243
  dir: string,
@@ -212,15 +252,19 @@ export class ClaudeAdapter implements AgentAdapter {
212
252
  }
213
253
 
214
254
  const settingsPath = `auth-r${auth.revision}/settings.json`;
255
+ const mcpServers = this.entry.mcpServers ?? [];
256
+ const mcpPath = `auth-r${auth.revision}/mcp.json`;
257
+ const mcpArgs = mcpServers.length === 0 ? [] : ['--mcp-config', `${dir}/${mcpPath}`];
215
258
  const bundleKey = randomUUID();
216
259
 
217
260
  const launch = buildClaudeGuestLaunch(
218
261
  dir,
219
262
  [
220
- this.config.claudeBin,
263
+ this.entry.bin,
221
264
  ...this.buildArgs(
222
- buildArgsWithoutFlags(this.config.claudeArgs, ['--permission-mode']),
265
+ buildArgsWithoutFlags(this.entry.args, ['--permission-mode']),
223
266
  opts,
267
+ mcpArgs,
224
268
  `${dir}/${settingsPath}`,
225
269
  `${dir}/atc-bridge`,
226
270
  ),
@@ -238,7 +282,7 @@ export class ClaudeAdapter implements AgentAdapter {
238
282
  const padding = typeof userSettings === 'string' ? findStatuslinePadding(userSettings) : 0;
239
283
 
240
284
  const settings = buildHookSettings(
241
- { id: this.id, env: { ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } },
285
+ this.buildSettingsProfile({ ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }),
242
286
  padding,
243
287
  argv,
244
288
  );
@@ -248,6 +292,9 @@ export class ClaudeAdapter implements AgentAdapter {
248
292
  args: launch.args,
249
293
  files: {
250
294
  [settingsPath]: JSON.stringify(settings, null, 2),
295
+ ...(mcpServers.length === 0
296
+ ? {}
297
+ : { [mcpPath]: JSON.stringify(buildClaudeMCPConfig(mcpServers), null, 2) }),
251
298
  ...buildClaudeConfigSeed(null),
252
299
  ...Object.fromEntries(
253
300
  Object.entries(bundle).map(([path, content]) => [
@@ -261,50 +308,81 @@ export class ClaudeAdapter implements AgentAdapter {
261
308
  };
262
309
  }
263
310
 
264
- // The refusal for configured arguments whose inline `--settings` sets a
265
- // variable that overrides the subscription sign-in or routes the CLI
266
- // around impd's broker, or null when they set none.
311
+ // The refusal for an entry whose environment, settings environment, or
312
+ // inline `--settings` argument sets a variable that overrides the
313
+ // subscription sign-in or routes the CLI around impd's broker, or null
314
+ // when it sets none.
267
315
  private findCredentialOverride(): DaemonError | null {
268
- const inline = findFlagValue(this.config.claudeArgs, ['--settings']);
316
+ const settingsEnv = this.entry.settings?.['env'];
269
317
 
270
- if (inline === null) {
271
- return null;
318
+ const sources: readonly (readonly [string, readonly string[]])[] = [
319
+ ['env', Object.keys(this.entry.env)],
320
+ ['settings.env', isRecord(settingsEnv) ? Object.keys(settingsEnv) : []],
321
+ ['args in --settings', this.findInlineSettingsKeys()],
322
+ ];
323
+
324
+ for (const [source, keys] of sources) {
325
+ const variable = keys.find((key) => isSubscriptionOverrideVariable(key));
326
+
327
+ if (variable !== undefined) {
328
+ return new DaemonError(
329
+ 'auth_target_unsupported',
330
+ `claude signs in through impd's broker on this target, but agents.${this.id}.${source === 'args in --settings' ? 'args set' : `${source} sets`} ${variable}${source === 'args in --settings' ? ' in --settings' : ''}, which would override or route around that sign-in`,
331
+ { agent: this.id, problem: 'guest_env_conflict', variable },
332
+ );
333
+ }
272
334
  }
273
335
 
274
- let parsed: unknown;
336
+ return null;
337
+ }
338
+
339
+ // The variables an inline `--settings` argument sets in its env block.
340
+ private findInlineSettingsKeys(): string[] {
341
+ const inline = findFlagValue(this.entry.args, ['--settings']);
342
+
343
+ if (inline === null) {
344
+ return [];
345
+ }
275
346
 
276
347
  try {
277
- parsed = JSON.parse(inline);
348
+ const parsed: unknown = JSON.parse(inline);
349
+ const env = isRecord(parsed) ? parsed['env'] : undefined;
350
+
351
+ return isRecord(env) ? Object.keys(env) : [];
278
352
  } catch {
279
- return null;
353
+ return [];
280
354
  }
355
+ }
281
356
 
282
- const env = isRecord(parsed) ? parsed['env'] : undefined;
283
- const keys = isRecord(env) ? Object.keys(env) : [];
284
-
285
- const variable = keys.find(
286
- (key) => OUTRANKING_VARIABLES.has(key) || key === OAUTH_VARIABLE || isBrokerVariable(key),
287
- );
357
+ // What the generated settings file holds for this entry: its hooks and
358
+ // statusline, its settings, and its environment, with `extraEnv` on top.
359
+ // The entry's environment outranks the settings' own env block.
360
+ private buildSettingsProfile(extraEnv: Readonly<Record<string, string>>): HookSettingsProfile {
361
+ const settingsEnv = this.entry.settings?.['env'];
288
362
 
289
- if (variable === undefined) {
290
- return null;
291
- }
363
+ const env = {
364
+ ...(isRecord(settingsEnv) ? toStringEntries(settingsEnv) : {}),
365
+ ...this.entry.env,
366
+ ...extraEnv,
367
+ };
292
368
 
293
- return new DaemonError(
294
- 'auth_target_unsupported',
295
- `claude signs in through impd's broker on this target, but claudeArgs set ${variable} in --settings, which would override or route around that sign-in`,
296
- { agent: this.id, problem: 'guest_env_conflict', variable },
297
- );
369
+ return {
370
+ id: this.id,
371
+ env,
372
+ ...(this.entry.settings === undefined ? {} : { settings: this.entry.settings }),
373
+ };
298
374
  }
299
375
 
300
376
  private buildArgs(
301
377
  configured: readonly string[],
302
378
  opts: SpawnOptions,
379
+ leadArgs: readonly string[],
303
380
  settings: string,
304
381
  pluginDir: string,
305
382
  ): string[] {
306
383
  return [
307
384
  ...buildClaudeOverrideArgs(configured, opts),
385
+ ...leadArgs,
308
386
  '--settings',
309
387
  settings,
310
388
  '--plugin-dir',
@@ -315,6 +393,12 @@ export class ClaudeAdapter implements AgentAdapter {
315
393
  ];
316
394
  }
317
395
 
396
+ private writeSettings(): string {
397
+ this.settingsFile ??= writeHookSettings(this.buildSettingsProfile({}));
398
+
399
+ return this.settingsFile;
400
+ }
401
+
318
402
  private writeBridge(): string {
319
403
  this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
320
404
 
@@ -453,13 +537,21 @@ export class ClaudeAdapter implements AgentAdapter {
453
537
 
454
538
  // Shell command that re-opens this session outside atc (or anywhere). A
455
539
  // permission mode the configured arguments set travels as an explicit
456
- // flag, so it overrides the mode the CLI would restore.
540
+ // flag, so it overrides the mode the CLI would restore. The generated
541
+ // settings file travels only for an entry that sets its own settings or
542
+ // environment, since the file sits on the daemon's machine and an entry
543
+ // without either needs nothing from it.
457
544
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
458
- const configured = findClaudePermissionMode(this.config.claudeArgs, undefined);
545
+ const configured = findClaudePermissionMode(this.entry.args, this.entry.settings);
459
546
  const mode = configured === null ? '' : ` --permission-mode ${toShellArg(configured)}`;
460
547
  const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
461
548
 
462
- return `cd ${toShellArg(cwd)} && claude${mode} --resume${resume}`;
549
+ const settings =
550
+ this.entry.settings === undefined && Object.keys(this.entry.env).length === 0
551
+ ? ''
552
+ : ` --settings ${toShellArg(this.writeSettings())}`;
553
+
554
+ return `cd ${toShellArg(cwd)} && ${formatShellWord(this.entry.bin)}${mode}${settings} --resume${resume}`;
463
555
  }
464
556
  }
465
557
 
@@ -470,21 +562,14 @@ const ANTHROPIC_API_URL = 'https://api.anthropic.com';
470
562
  const OAUTH_VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN';
471
563
  const PLACEHOLDER = 'imp-broker-placeholder';
472
564
 
473
- // The variables that keep Claude Code from sending the subscription token
474
- // to the Anthropic API: a credential it takes ahead of that token, another
475
- // endpoint, or a cloud provider it signs in to instead.
476
- const OUTRANKING_VARIABLES: ReadonlySet<string> = new Set([
477
- 'ANTHROPIC_AUTH_TOKEN',
478
- 'ANTHROPIC_API_KEY',
479
- 'ANTHROPIC_BASE_URL',
480
- 'CLAUDE_CODE_USE_BEDROCK',
481
- 'CLAUDE_CODE_USE_VERTEX',
482
- 'CLAUDE_CODE_USE_FOUNDRY',
483
- 'CLAUDE_CODE_USE_MANTLE',
484
- 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
485
- 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
486
- 'CLAUDE_CODE_USE_GATEWAY',
487
- ]);
565
+ // The string values of an object, which is all a session's environment holds.
566
+ function toStringEntries(value: Readonly<Record<string, unknown>>): Record<string, string> {
567
+ return Object.fromEntries(
568
+ Object.entries(value).filter(
569
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
570
+ ),
571
+ );
572
+ }
488
573
 
489
574
  // The padding of the statusline the bundle's user settings set, which atc's
490
575
  // own statusline mirrors since it renders that one first, or none.
@@ -535,3 +620,9 @@ function buildClaudeSpawnOptions(claudeArgs: readonly string[]): SpawnOptionSpec
535
620
  },
536
621
  };
537
622
  }
623
+
624
+ // A binary name or path that needs no quoting stays bare, so the default
625
+ // entry resumes with plain `claude`; anything else is quoted as one word.
626
+ function formatShellWord(value: string): string {
627
+ return /^[\w./-]+$/.test(value) ? value : toShellArg(value);
628
+ }
@@ -3,9 +3,10 @@ import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import type { AdapterEvent } from '../protocol/adapter-event';
5
5
  import type { HookEvent } from '../protocol/hook-event';
6
+ import type { AgentID } from '../shared/agent-id';
6
7
  import type { AgentSessionID } from '../shared/agent-session-id';
7
8
  import { buildOptionalString } from '../shared/build-optional-string';
8
- import type { Config } from '../shared/config';
9
+ import type { AgentEntry } from '../shared/collect-agents';
9
10
  import { isRecord } from '../shared/report';
10
11
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
12
  import { toShellArg } from '../shared/to-shell-arg';
@@ -47,7 +48,11 @@ const CODEX_MODEL_FLAGS = ['-m', '--model'];
47
48
  * trusts once in the Codex TUI; atc never writes into the user's Codex config.
48
49
  */
49
50
  export class CodexAdapter implements AgentAdapter {
50
- readonly id = 'codex';
51
+ readonly id: AgentID;
52
+
53
+ // The hook files a user installs print the kind for every entry of it, so
54
+ // the hook lines of every codex entry carry it.
55
+ readonly hookAgent = 'codex';
51
56
 
52
57
  // Codex has no headless handoff.
53
58
  readonly headlessRunner = null;
@@ -63,29 +68,30 @@ export class CodexAdapter implements AgentAdapter {
63
68
  // line is pasted and then submitted.
64
69
  readonly planLineInput = planPastedLineInput;
65
70
 
66
- private readonly config: Config;
71
+ private readonly entry: AgentEntry;
67
72
 
68
- constructor(config: Config) {
69
- this.config = config;
73
+ constructor(entry: AgentEntry) {
74
+ this.entry = entry;
75
+ this.id = entry.id;
70
76
 
71
77
  this.profile = {
72
- label: 'Codex',
78
+ label: entry.label,
73
79
  kind: 'codex',
74
- bin: config.codexBin,
80
+ bin: entry.bin,
75
81
  models: null,
76
- spawnOptions: buildCodexSpawnOptions(config.codexArgs),
82
+ spawnOptions: buildCodexSpawnOptions(entry.args),
77
83
  };
78
84
  }
79
85
 
80
86
  planSpawn(opts: SpawnOptions): SpawnPlan {
81
87
  return {
82
- bin: this.config.codexBin,
88
+ bin: this.entry.bin,
83
89
  args: [
84
90
  // A model override replaces any model flag the configured arguments
85
91
  // carry, and travels as its own argument.
86
92
  ...(opts.model === undefined
87
- ? this.config.codexArgs
88
- : [...buildArgsWithoutFlags(this.config.codexArgs, CODEX_MODEL_FLAGS), '-m', opts.model]),
93
+ ? this.entry.args
94
+ : [...buildArgsWithoutFlags(this.entry.args, CODEX_MODEL_FLAGS), '-m', opts.model]),
89
95
 
90
96
  // Bare resume opens Codex's own session picker; an id resumes that
91
97
  // session directly. Both accept a prompt afterwards.
@@ -50,6 +50,9 @@ import { writeHookSettings } from './write-hook-settings';
50
50
  export class GatewayAdapter implements AgentAdapter {
51
51
  readonly id: AgentID;
52
52
 
53
+ // The settings file names the gateway's id on every hook command.
54
+ readonly hookAgent: AgentID;
55
+
53
56
  // A headless turn carries the same settings file the terminal spawn does,
54
57
  // so it reaches this backend rather than the default one. A gateway whose
55
58
  // credential comes through impd's broker runs no headless turn.
@@ -103,7 +106,20 @@ export class GatewayAdapter implements AgentAdapter {
103
106
  spawnOptions: buildGatewaySpawnOptions(gateway.args, models),
104
107
  };
105
108
 
106
- this.claude = new ClaudeAdapter(config);
109
+ this.hookAgent = gateway.id;
110
+
111
+ this.claude = new ClaudeAdapter(
112
+ {
113
+ id: gateway.id,
114
+ kind: 'claude',
115
+ label: gateway.label,
116
+ mark: gateway.mark,
117
+ bin: gateway.bin,
118
+ args: gateway.args,
119
+ env: {},
120
+ },
121
+ config,
122
+ );
107
123
 
108
124
  this.headlessRunner =
109
125
  headlessRun === null || gateway.auth !== undefined