@zgeoff/atc 2.36.0 → 2.37.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.
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.0",
3
+ "version": "2.37.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,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';
@@ -31,6 +34,8 @@ import { buildClaudeConfigSeed } from './build-claude-config-seed';
31
34
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
32
35
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
33
36
  import { buildHookSettings } from './build-hook-settings';
37
+ import type { HookSettingsProfile } from './build-hook-settings';
38
+ import { buildRestoreModeArgs } from './build-restore-mode-args';
34
39
  import { CLAUDE_CONFIG_BUNDLE_FOLDER } from './claude-config-bundle-folder';
35
40
  import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
36
41
  import { findClaudePermissionMode } from './find-claude-permission-mode';
@@ -65,7 +70,11 @@ type ClaudeHookPayload = z.infer<typeof CLAUDE_HOOK_PAYLOAD_SCHEMA>;
65
70
  * resume semantics, transcript name-pulling, and statusline chaining.
66
71
  */
67
72
  export class ClaudeAdapter implements AgentAdapter {
68
- readonly id = 'claude';
73
+ readonly id: AgentID;
74
+
75
+ // The agent value the hook lines of a Claude session carry: the settings
76
+ // file atc writes for this id names it on each hook command.
77
+ readonly hookAgent: AgentID;
69
78
 
70
79
  readonly headlessRunner: HeadlessRunner | null;
71
80
 
@@ -82,7 +91,9 @@ export class ClaudeAdapter implements AgentAdapter {
82
91
 
83
92
  readonly takesMessages = true;
84
93
 
85
- private readonly config: Config;
94
+ private readonly entry: AgentEntry;
95
+
96
+ private readonly authProfiles: Config['authProfiles'];
86
97
 
87
98
  // Written on first spawn so constructing the adapter touches no state.
88
99
  private settingsFile: string | undefined;
@@ -92,44 +103,60 @@ export class ClaudeAdapter implements AgentAdapter {
92
103
 
93
104
  private readonly bridgeTarget: string | undefined;
94
105
 
95
- constructor(config: Config, headlessRun: ClaudeHeadlessRun | null = null, bridgeTarget?: string) {
106
+ constructor(
107
+ entry: AgentEntry,
108
+ config: Pick<Config, 'authProfiles'>,
109
+ headlessRun: ClaudeHeadlessRun | null = null,
110
+ bridgeTarget?: string,
111
+ ) {
96
112
  this.bridgeTarget = bridgeTarget;
97
- this.config = config;
113
+ this.entry = entry;
114
+ this.authProfiles = config.authProfiles;
115
+ this.id = entry.id;
116
+ this.hookAgent = entry.id;
98
117
 
99
118
  this.profile = {
100
- label: 'Claude',
119
+ label: entry.label,
101
120
  kind: 'claude',
102
- bin: config.claudeBin,
121
+ bin: entry.bin,
103
122
  models: null,
104
- spawnOptions: buildClaudeSpawnOptions(config.claudeArgs),
123
+ spawnOptions: buildClaudeSpawnOptions(entry.args),
105
124
  };
106
125
 
107
126
  this.headlessRunner =
108
127
  headlessRun === null
109
128
  ? null
110
129
  : makeClaudeHeadlessRunner(headlessRun, {
111
- claudeBin: config.claudeBin,
112
- permissionMode: resolveClaudePermissionMode(config.claudeArgs, undefined),
130
+ claudeBin: entry.bin,
131
+ permissionMode: resolveClaudePermissionMode(entry.args, entry.settings),
113
132
  pluginDir: () => this.writeBridge(),
133
+ settings: () => this.writeSettings(),
114
134
  });
115
135
  }
116
136
 
117
137
  planSpawn(opts: SpawnOptions): SpawnPlan {
118
- this.settingsFile ??= writeHookSettings({ id: this.id });
138
+ const modeArgs =
139
+ opts.resume === false ? [] : buildRestoreModeArgs(this.entry.args, this.entry.settings);
119
140
 
120
141
  return {
121
- bin: this.config.claudeBin,
122
- args: this.buildArgs(this.config.claudeArgs, opts, this.settingsFile, this.writeBridge()),
142
+ bin: this.entry.bin,
143
+ args: this.buildArgs(
144
+ this.entry.args,
145
+ opts,
146
+ modeArgs,
147
+ this.writeSettings(),
148
+ this.writeBridge(),
149
+ ),
123
150
  };
124
151
  }
125
152
 
126
- // With `claudeAuth` configured, Claude signs in with the subscription
153
+ // With `auth` configured, Claude signs in with the subscription
127
154
  // token impd's broker holds on a target that reaches the broker, and
128
155
  // with the sign-in of the host it runs on anywhere else.
129
156
  findAuthSelection(): AuthSelection | null {
130
- const auth = this.config.claudeAuth;
157
+ const auth = this.entry.auth;
131
158
 
132
- if (auth === null) {
159
+ if (auth === undefined) {
133
160
  return null;
134
161
  }
135
162
 
@@ -139,7 +166,7 @@ export class ClaudeAdapter implements AgentAdapter {
139
166
  baseURL: ANTHROPIC_API_URL,
140
167
  auth: { profiles: auth.profiles, placeholderEnv: { [OAUTH_VARIABLE]: PLACEHOLDER } },
141
168
  },
142
- profiles: this.config.authProfiles,
169
+ profiles: this.authProfiles,
143
170
  brokerRequired: false,
144
171
  };
145
172
  }
@@ -156,6 +183,9 @@ export class ClaudeAdapter implements AgentAdapter {
156
183
 
157
184
  const argv = [guest.atc];
158
185
 
186
+ const modeArgs =
187
+ opts.resume === false ? [] : buildRestoreModeArgs(this.entry.args, this.entry.settings);
188
+
159
189
  const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
160
190
  ([path, content]): [string, string] => [`atc-bridge/${path}`, content],
161
191
  );
@@ -165,15 +195,20 @@ export class ClaudeAdapter implements AgentAdapter {
165
195
  }
166
196
 
167
197
  return {
168
- bin: this.config.claudeBin,
198
+ bin: this.entry.bin,
169
199
  args: this.buildArgs(
170
- this.config.claudeArgs,
200
+ this.entry.args,
171
201
  opts,
202
+ modeArgs,
172
203
  `${guest.dir}/settings.json`,
173
204
  `${guest.dir}/atc-bridge`,
174
205
  ),
175
206
  files: {
176
- 'settings.json': JSON.stringify(buildHookSettings({ id: this.id }, 0, argv), null, 2),
207
+ 'settings.json': JSON.stringify(
208
+ buildHookSettings(this.buildSettingsProfile({}), 0, argv),
209
+ null,
210
+ 2,
211
+ ),
177
212
  ...Object.fromEntries(bridge),
178
213
  },
179
214
  };
@@ -183,7 +218,7 @@ export class ClaudeAdapter implements AgentAdapter {
183
218
  // broker with trust for the clone; any other remote session reads the
184
219
  // config of the host's image, which atc never writes.
185
220
  planGuestWorkspaceTrust(root: string): Readonly<Record<string, string>> | null {
186
- if (this.config.claudeAuth === null) {
221
+ if (this.entry.auth === undefined) {
187
222
  return null;
188
223
  }
189
224
 
@@ -217,10 +252,11 @@ export class ClaudeAdapter implements AgentAdapter {
217
252
  const launch = buildClaudeGuestLaunch(
218
253
  dir,
219
254
  [
220
- this.config.claudeBin,
255
+ this.entry.bin,
221
256
  ...this.buildArgs(
222
- buildArgsWithoutFlags(this.config.claudeArgs, ['--permission-mode']),
257
+ buildArgsWithoutFlags(this.entry.args, ['--permission-mode']),
223
258
  opts,
259
+ [],
224
260
  `${dir}/${settingsPath}`,
225
261
  `${dir}/atc-bridge`,
226
262
  ),
@@ -238,7 +274,7 @@ export class ClaudeAdapter implements AgentAdapter {
238
274
  const padding = typeof userSettings === 'string' ? findStatuslinePadding(userSettings) : 0;
239
275
 
240
276
  const settings = buildHookSettings(
241
- { id: this.id, env: { ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } },
277
+ this.buildSettingsProfile({ ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }),
242
278
  padding,
243
279
  argv,
244
280
  );
@@ -261,50 +297,81 @@ export class ClaudeAdapter implements AgentAdapter {
261
297
  };
262
298
  }
263
299
 
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.
300
+ // The refusal for an entry whose environment, settings environment, or
301
+ // inline `--settings` argument sets a variable that overrides the
302
+ // subscription sign-in or routes the CLI around impd's broker, or null
303
+ // when it sets none.
267
304
  private findCredentialOverride(): DaemonError | null {
268
- const inline = findFlagValue(this.config.claudeArgs, ['--settings']);
305
+ const settingsEnv = this.entry.settings?.['env'];
269
306
 
270
- if (inline === null) {
271
- return null;
307
+ const sources: readonly (readonly [string, readonly string[]])[] = [
308
+ ['env', Object.keys(this.entry.env)],
309
+ ['settings.env', isRecord(settingsEnv) ? Object.keys(settingsEnv) : []],
310
+ ['args in --settings', this.findInlineSettingsKeys()],
311
+ ];
312
+
313
+ for (const [source, keys] of sources) {
314
+ const variable = keys.find((key) => isSubscriptionOverrideVariable(key));
315
+
316
+ if (variable !== undefined) {
317
+ return new DaemonError(
318
+ 'auth_target_unsupported',
319
+ `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`,
320
+ { agent: this.id, problem: 'guest_env_conflict', variable },
321
+ );
322
+ }
272
323
  }
273
324
 
274
- let parsed: unknown;
325
+ return null;
326
+ }
327
+
328
+ // The variables an inline `--settings` argument sets in its env block.
329
+ private findInlineSettingsKeys(): string[] {
330
+ const inline = findFlagValue(this.entry.args, ['--settings']);
331
+
332
+ if (inline === null) {
333
+ return [];
334
+ }
275
335
 
276
336
  try {
277
- parsed = JSON.parse(inline);
337
+ const parsed: unknown = JSON.parse(inline);
338
+ const env = isRecord(parsed) ? parsed['env'] : undefined;
339
+
340
+ return isRecord(env) ? Object.keys(env) : [];
278
341
  } catch {
279
- return null;
342
+ return [];
280
343
  }
344
+ }
281
345
 
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
- );
346
+ // What the generated settings file holds for this entry: its hooks and
347
+ // statusline, its settings, and its environment, with `extraEnv` on top.
348
+ // The entry's environment outranks the settings' own env block.
349
+ private buildSettingsProfile(extraEnv: Readonly<Record<string, string>>): HookSettingsProfile {
350
+ const settingsEnv = this.entry.settings?.['env'];
288
351
 
289
- if (variable === undefined) {
290
- return null;
291
- }
352
+ const env = {
353
+ ...(isRecord(settingsEnv) ? toStringEntries(settingsEnv) : {}),
354
+ ...this.entry.env,
355
+ ...extraEnv,
356
+ };
292
357
 
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
- );
358
+ return {
359
+ id: this.id,
360
+ env,
361
+ ...(this.entry.settings === undefined ? {} : { settings: this.entry.settings }),
362
+ };
298
363
  }
299
364
 
300
365
  private buildArgs(
301
366
  configured: readonly string[],
302
367
  opts: SpawnOptions,
368
+ modeArgs: readonly string[],
303
369
  settings: string,
304
370
  pluginDir: string,
305
371
  ): string[] {
306
372
  return [
307
373
  ...buildClaudeOverrideArgs(configured, opts),
374
+ ...modeArgs,
308
375
  '--settings',
309
376
  settings,
310
377
  '--plugin-dir',
@@ -315,6 +382,12 @@ export class ClaudeAdapter implements AgentAdapter {
315
382
  ];
316
383
  }
317
384
 
385
+ private writeSettings(): string {
386
+ this.settingsFile ??= writeHookSettings(this.buildSettingsProfile({}));
387
+
388
+ return this.settingsFile;
389
+ }
390
+
318
391
  private writeBridge(): string {
319
392
  this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
320
393
 
@@ -453,13 +526,21 @@ export class ClaudeAdapter implements AgentAdapter {
453
526
 
454
527
  // Shell command that re-opens this session outside atc (or anywhere). A
455
528
  // permission mode the configured arguments set travels as an explicit
456
- // flag, so it overrides the mode the CLI would restore.
529
+ // flag, so it overrides the mode the CLI would restore. The generated
530
+ // settings file travels only for an entry that sets its own settings or
531
+ // environment, since the file sits on the daemon's machine and an entry
532
+ // without either needs nothing from it.
457
533
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
458
- const configured = findClaudePermissionMode(this.config.claudeArgs, undefined);
534
+ const configured = findClaudePermissionMode(this.entry.args, this.entry.settings);
459
535
  const mode = configured === null ? '' : ` --permission-mode ${toShellArg(configured)}`;
460
536
  const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
461
537
 
462
- return `cd ${toShellArg(cwd)} && claude${mode} --resume${resume}`;
538
+ const settings =
539
+ this.entry.settings === undefined && Object.keys(this.entry.env).length === 0
540
+ ? ''
541
+ : ` --settings ${toShellArg(this.writeSettings())}`;
542
+
543
+ return `cd ${toShellArg(cwd)} && ${formatShellWord(this.entry.bin)}${mode}${settings} --resume${resume}`;
463
544
  }
464
545
  }
465
546
 
@@ -470,21 +551,14 @@ const ANTHROPIC_API_URL = 'https://api.anthropic.com';
470
551
  const OAUTH_VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN';
471
552
  const PLACEHOLDER = 'imp-broker-placeholder';
472
553
 
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
- ]);
554
+ // The string values of an object, which is all a session's environment holds.
555
+ function toStringEntries(value: Readonly<Record<string, unknown>>): Record<string, string> {
556
+ return Object.fromEntries(
557
+ Object.entries(value).filter(
558
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
559
+ ),
560
+ );
561
+ }
488
562
 
489
563
  // The padding of the statusline the bundle's user settings set, which atc's
490
564
  // own statusline mirrors since it renders that one first, or none.
@@ -535,3 +609,9 @@ function buildClaudeSpawnOptions(claudeArgs: readonly string[]): SpawnOptionSpec
535
609
  },
536
610
  };
537
611
  }
612
+
613
+ // A binary name or path that needs no quoting stays bare, so the default
614
+ // entry resumes with plain `claude`; anything else is quoted as one word.
615
+ function formatShellWord(value: string): string {
616
+ return /^[\w./-]+$/.test(value) ? value : toShellArg(value);
617
+ }
@@ -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
@@ -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 type { SessionID } from '../shared/session-id';
11
12
  import { toAgentSessionID } from '../shared/to-agent-session-id';
@@ -65,7 +66,11 @@ const GROK_UNSUPPORTED_OPTION: SpawnOptionSpec = {
65
66
  * resume semantics, and summary.json name-pulling.
66
67
  */
67
68
  export class GrokAdapter implements AgentAdapter {
68
- readonly id = 'grok';
69
+ readonly id: AgentID;
70
+
71
+ // The hook files a user installs print the kind for every entry of it, so
72
+ // the hook lines of every grok entry carry it.
73
+ readonly hookAgent = 'grok';
69
74
 
70
75
  // Grok has no headless handoff.
71
76
  readonly headlessRunner = null;
@@ -81,17 +86,18 @@ export class GrokAdapter implements AgentAdapter {
81
86
  // line is pasted and then submitted.
82
87
  readonly planLineInput = planPastedLineInput;
83
88
 
84
- private readonly config: Config;
89
+ private readonly entry: AgentEntry;
85
90
 
86
91
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();
87
92
 
88
- constructor(config: Config) {
89
- this.config = config;
93
+ constructor(entry: AgentEntry) {
94
+ this.entry = entry;
95
+ this.id = entry.id;
90
96
 
91
97
  this.profile = {
92
- label: 'Grok',
98
+ label: entry.label,
93
99
  kind: 'grok',
94
- bin: config.grokBin,
100
+ bin: entry.bin,
95
101
  models: null,
96
102
  spawnOptions: { model: GROK_UNSUPPORTED_OPTION, effort: GROK_UNSUPPORTED_OPTION },
97
103
  };
@@ -99,9 +105,9 @@ export class GrokAdapter implements AgentAdapter {
99
105
 
100
106
  planSpawn(opts: SpawnOptions): SpawnPlan {
101
107
  return {
102
- bin: this.config.grokBin,
108
+ bin: this.entry.bin,
103
109
  args: [
104
- ...this.config.grokArgs.filter((arg) => arg !== '--leader' && arg !== '--no-leader'),
110
+ ...this.entry.args.filter((arg) => arg !== '--leader' && arg !== '--no-leader'),
105
111
  '--no-leader',
106
112
  ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
107
113
  ...(opts.prompt === '' ? [] : [opts.prompt]),