@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
@@ -14,9 +14,13 @@ import type {
14
14
  AgentProfile,
15
15
  NameUpdate,
16
16
  ResumeCheck,
17
+ SpawnOptionSpecs,
17
18
  SpawnOptions,
18
19
  SpawnPlan,
19
20
  } from './agent-adapter';
21
+ import { buildArgsWithoutFlags } from './build-args-without-flags';
22
+ import { findFlagValue } from './find-flag-value';
23
+ import { planPastedLineInput } from './plan-pasted-line-input';
20
24
  import { resolveAgentHome } from './resolve-agent-home';
21
25
  import { truncateDetail } from './truncate-detail';
22
26
 
@@ -33,6 +37,9 @@ const CODEX_HOOK_PAYLOAD_SCHEMA = z.object({
33
37
 
34
38
  type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
35
39
 
40
+ // The spellings of the Codex CLI's model flag.
41
+ const CODEX_MODEL_FLAGS = ['-m', '--model'];
42
+
36
43
  /**
37
44
  * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
38
45
  * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
@@ -52,18 +59,33 @@ export class CodexAdapter implements AgentAdapter {
52
59
 
53
60
  readonly takesMessages = false;
54
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
+
55
66
  private readonly config: Config;
56
67
 
57
68
  constructor(config: Config) {
58
69
  this.config = config;
59
- this.profile = { label: 'Codex', kind: 'codex', bin: config.codexBin, models: null };
70
+
71
+ this.profile = {
72
+ label: 'Codex',
73
+ kind: 'codex',
74
+ bin: config.codexBin,
75
+ models: null,
76
+ spawnOptions: buildCodexSpawnOptions(config.codexArgs),
77
+ };
60
78
  }
61
79
 
62
80
  planSpawn(opts: SpawnOptions): SpawnPlan {
63
81
  return {
64
82
  bin: this.config.codexBin,
65
83
  args: [
66
- ...this.config.codexArgs,
84
+ // A model override replaces any model flag the configured arguments
85
+ // carry, and travels as its own argument.
86
+ ...(opts.model === undefined
87
+ ? this.config.codexArgs
88
+ : [...buildArgsWithoutFlags(this.config.codexArgs, CODEX_MODEL_FLAGS), '-m', opts.model]),
67
89
 
68
90
  // Bare resume opens Codex's own session picker; an id resumes that
69
91
  // session directly. Both accept a prompt afterwards.
@@ -193,3 +215,27 @@ export class CodexAdapter implements AgentAdapter {
193
215
  return `cd ${toShellArg(cwd)} && ${resume}`;
194
216
  }
195
217
  }
218
+
219
+ // What a Codex spawn can override. Codex documents its reasoning effort as
220
+ // whatever the selected model advertises, with no closed list of levels, so
221
+ // atc takes no effort for it.
222
+ function buildCodexSpawnOptions(codexArgs: readonly string[]): SpawnOptionSpecs {
223
+ return {
224
+ model: {
225
+ supported: true,
226
+ values: null,
227
+ examples: [],
228
+ default: findFlagValue(codexArgs, CODEX_MODEL_FLAGS),
229
+ backendEffect: 'applied',
230
+ note: 'A model name, passed as -m.',
231
+ },
232
+ effort: {
233
+ supported: false,
234
+ values: null,
235
+ examples: [],
236
+ default: null,
237
+ backendEffect: null,
238
+ note: 'Codex documents its reasoning effort levels as depending on the model, with no closed list, so atc does not pass one.',
239
+ },
240
+ };
241
+ }
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The value a CLI flag takes in an argument list, written either as the flag
3
+ * followed by its value or as `--flag=value`. The last occurrence wins, as it
4
+ * does for the CLIs atc runs; null when the list never sets the flag.
5
+ */
6
+ export function findFlagValue(args: readonly string[], names: readonly string[]): string | null {
7
+ let value: string | null = null;
8
+
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index] ?? '';
11
+ const next = args[index + 1];
12
+
13
+ if (names.includes(arg) && next !== undefined) {
14
+ value = next;
15
+ index += 1;
16
+ continue;
17
+ }
18
+
19
+ const inline = names.find((name) => name.startsWith('--') && arg.startsWith(`${name}=`));
20
+
21
+ if (inline !== undefined) {
22
+ value = arg.slice(inline.length + 1);
23
+ }
24
+ }
25
+
26
+ return value;
27
+ }
@@ -11,11 +11,20 @@ import type {
11
11
  HeadlessRunner,
12
12
  NameUpdate,
13
13
  ResumeCheck,
14
+ SpawnOptionSpecs,
14
15
  SpawnOptions,
15
16
  SpawnPlan,
16
17
  } from './agent-adapter';
18
+ import { buildClaudeOverrideArgs } from './build-claude-override-args';
19
+ import { buildRestoreModeArgs } from './build-restore-mode-args';
17
20
  import { ClaudeAdapter } from './claude-adapter';
21
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
22
+ import { findFlagValue } from './find-flag-value';
23
+ import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
24
+ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
18
25
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
26
+ import { planTypedLineInput } from './plan-typed-line-input';
27
+ import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
19
28
  import { writeATCBridge } from './write-atc-bridge';
20
29
  import { writeHookSettings } from './write-hook-settings';
21
30
 
@@ -39,6 +48,9 @@ export class GatewayAdapter implements AgentAdapter {
39
48
  // The gateway runs the Claude CLI, which writes the same transcript.
40
49
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
41
50
 
51
+ // The gateway runs the Claude CLI, whose TUI takes a line the same way.
52
+ readonly planLineInput = planTypedLineInput;
53
+
42
54
  readonly takesMessages = true;
43
55
 
44
56
  readonly profile: AgentProfile;
@@ -58,37 +70,44 @@ export class GatewayAdapter implements AgentAdapter {
58
70
  constructor(
59
71
  gateway: GatewayConfig,
60
72
  config: Config,
61
- headlessRunner: HeadlessRunner | null = null,
73
+ headlessRun: ClaudeHeadlessRun | null = null,
62
74
  bridgeTarget?: string,
63
75
  ) {
64
76
  this.bridgeTarget = bridgeTarget;
65
77
  this.gateway = gateway;
66
78
  this.id = gateway.id;
67
79
 
80
+ const models = pickModels(gateway.env);
81
+
68
82
  this.profile = {
69
83
  label: gateway.label,
70
84
  kind: 'gateway',
71
85
  bin: gateway.bin,
72
- models: pickModels(gateway.env),
86
+ models,
87
+ spawnOptions: buildGatewaySpawnOptions(gateway.args, models),
73
88
  };
74
89
 
75
90
  this.claude = new ClaudeAdapter(config);
76
91
 
77
92
  this.headlessRunner =
78
- headlessRunner === null
93
+ headlessRun === null
79
94
  ? null
80
- : (opts, hooks) =>
81
- headlessRunner(
82
- { ...opts, settings: this.writeSettings(), pluginDir: this.writeBridge() },
83
- hooks,
84
- );
95
+ : makeClaudeHeadlessRunner(headlessRun, {
96
+ claudeBin: gateway.bin,
97
+ permissionMode: resolveClaudePermissionMode(gateway.args, gateway.settings),
98
+ pluginDir: () => this.writeBridge(),
99
+ settings: () => this.writeSettings(),
100
+ });
85
101
  }
86
102
 
87
103
  planSpawn(opts: SpawnOptions): SpawnPlan {
88
104
  return {
89
105
  bin: this.gateway.bin,
90
106
  args: [
91
- ...this.gateway.args,
107
+ ...buildClaudeOverrideArgs(this.gateway.args, opts),
108
+ ...(opts.resume === false
109
+ ? []
110
+ : buildRestoreModeArgs(this.gateway.args, this.gateway.settings)),
92
111
  '--settings',
93
112
  this.writeSettings(),
94
113
  '--plugin-dir',
@@ -100,6 +119,12 @@ export class GatewayAdapter implements AgentAdapter {
100
119
  };
101
120
  }
102
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
+
103
128
  normalizeHook(e: HookEvent): AdapterEvent {
104
129
  return this.claude.normalizeHook(e);
105
130
  }
@@ -112,14 +137,23 @@ export class GatewayAdapter implements AgentAdapter {
112
137
  return this.claude.canResume(session);
113
138
  }
114
139
 
115
- // Shell command that re-opens this session outside atc. It names the
116
- // generated settings file, because without it the CLI would resume the
117
- // 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.
118
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
+
119
153
  const settings = toShellArg(this.writeSettings());
120
154
  const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
121
155
 
122
- return `cd ${toShellArg(cwd)} && ${this.gateway.bin} --settings ${settings} --resume${resume}`;
156
+ return `cd ${toShellArg(cwd)} && ${this.gateway.bin}${args} --settings ${settings} --resume${resume}`;
123
157
  }
124
158
 
125
159
  private writeSettings(): string {
@@ -162,3 +196,39 @@ function pickModels(
162
196
 
163
197
  return Object.keys(models).length === 0 ? null : models;
164
198
  }
199
+
200
+ // The Claude tiers a gateway's env can map to a provider model.
201
+ const GATEWAY_TIERS = ['opus', 'sonnet', 'haiku'];
202
+
203
+ // What a gateway spawn can override. The model examples are the tier aliases
204
+ // the gateway's env maps, each with the provider model it reaches. Effort
205
+ // goes to the CLI unchanged, and whether the provider acts on it is unknown.
206
+ function buildGatewaySpawnOptions(
207
+ args: readonly string[],
208
+ models: Readonly<Record<string, string>> | null,
209
+ ): SpawnOptionSpecs {
210
+ const examples = GATEWAY_TIERS.flatMap((tier) => {
211
+ const mapped = models?.[tier];
212
+
213
+ return mapped === undefined ? [] : [{ value: tier, resolvesTo: mapped }];
214
+ });
215
+
216
+ return {
217
+ model: {
218
+ supported: true,
219
+ values: null,
220
+ examples,
221
+ default: findFlagValue(args, ['--model']) ?? models?.['default'] ?? null,
222
+ backendEffect: 'applied',
223
+ note: "A tier alias the gateway's env maps, or a model name the provider accepts, passed as --model.",
224
+ },
225
+ effort: {
226
+ supported: true,
227
+ values: CLAUDE_EFFORT_LEVELS,
228
+ examples: [],
229
+ default: findFlagValue(args, ['--effort']),
230
+ backendEffect: 'unverified',
231
+ note: "Passed as --effort; the gateway's provider may ignore it.",
232
+ },
233
+ };
234
+ }
@@ -15,10 +15,12 @@ import type {
15
15
  AgentProfile,
16
16
  NameUpdate,
17
17
  ResumeCheck,
18
+ SpawnOptionSpec,
18
19
  SpawnOptions,
19
20
  SpawnPlan,
20
21
  } from './agent-adapter';
21
22
  import { normalizeHookEventName } from './normalize-hook-event';
23
+ import { planPastedLineInput } from './plan-pasted-line-input';
22
24
  import { resolveAgentHome } from './resolve-agent-home';
23
25
  import { truncateDetail } from './truncate-detail';
24
26
 
@@ -48,6 +50,16 @@ const GROK_HOOK_PAYLOAD_SCHEMA = z.object({
48
50
 
49
51
  type GrokHookPayload = z.infer<typeof GROK_HOOK_PAYLOAD_SCHEMA>;
50
52
 
53
+ // atc passes Grok neither a model nor an effort; the CLI's own config picks both.
54
+ const GROK_UNSUPPORTED_OPTION: SpawnOptionSpec = {
55
+ supported: false,
56
+ values: null,
57
+ examples: [],
58
+ default: null,
59
+ backendEffect: null,
60
+ note: 'atc does not pass this option to Grok.',
61
+ };
62
+
51
63
  /**
52
64
  * The Grok Build adapter: spawn arguments, hook payload mapping,
53
65
  * resume semantics, and summary.json name-pulling.
@@ -65,13 +77,24 @@ export class GrokAdapter implements AgentAdapter {
65
77
 
66
78
  readonly takesMessages = false;
67
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
+
68
84
  private readonly config: Config;
69
85
 
70
86
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();
71
87
 
72
88
  constructor(config: Config) {
73
89
  this.config = config;
74
- this.profile = { label: 'Grok', kind: 'grok', bin: config.grokBin, models: null };
90
+
91
+ this.profile = {
92
+ label: 'Grok',
93
+ kind: 'grok',
94
+ bin: config.grokBin,
95
+ models: null,
96
+ spawnOptions: { model: GROK_UNSUPPORTED_OPTION, effort: GROK_UNSUPPORTED_OPTION },
97
+ };
75
98
  }
76
99
 
77
100
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -0,0 +1,46 @@
1
+ import type { HeadlessRunEvents, HeadlessRunner } from './agent-adapter';
2
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
3
+
4
+ /**
5
+ * Runs one headless turn through the Claude CLI; the daemon command supplies
6
+ * the Agent SDK implementation, and tests supply a recorder.
7
+ */
8
+ export type ClaudeHeadlessRun = (
9
+ opts: ClaudeHeadlessRunRequest,
10
+ hooks: HeadlessRunEvents,
11
+ ) => { readonly stop: () => void };
12
+
13
+ /**
14
+ * Where and how a Claude CLI headless turn runs: the binary, the permission
15
+ * mode its adapter resolved, the mod folder, and, for a gateway, the
16
+ * settings file that carries its backend. The folder and file are written
17
+ * on first use.
18
+ */
19
+ interface ClaudeHeadlessTarget {
20
+ readonly claudeBin: string;
21
+ readonly permissionMode: string;
22
+ readonly pluginDir: () => string;
23
+ readonly settings?: () => string;
24
+ }
25
+
26
+ /**
27
+ * Turns the Claude CLI's headless run into the neutral runner the daemon
28
+ * calls. Every headless turn runs under the permission mode its adapter
29
+ * resolved for the agent's sessions.
30
+ */
31
+ export function makeClaudeHeadlessRunner(
32
+ run: ClaudeHeadlessRun,
33
+ target: ClaudeHeadlessTarget,
34
+ ): HeadlessRunner {
35
+ return (opts, hooks) =>
36
+ run(
37
+ {
38
+ ...opts,
39
+ claudeBin: target.claudeBin,
40
+ permissionMode: target.permissionMode,
41
+ pluginDir: target.pluginDir(),
42
+ ...(target.settings === undefined ? {} : { settings: target.settings() }),
43
+ },
44
+ hooks,
45
+ );
46
+ }
@@ -0,0 +1,22 @@
1
+ import type { TerminalInputModes } from './agent-adapter';
2
+
3
+ const PASTE_START = '\u001B[200~';
4
+ const PASTE_END = '\u001B[201~';
5
+
6
+ /**
7
+ * Pastes a line into a TUI that tells a paste from typing, then submits it
8
+ * with a carriage return as its own write. Such a TUI takes a newline inside
9
+ * a burst of input as part of the text, so a line typed with its newline
10
+ * stays unsent in the composer. Between bracketed paste markers the whole
11
+ * text is one paste event, newlines included, and the carriage return after
12
+ * the closing marker is a separate Enter key event however the two writes
13
+ * arrive. Paste markers inside the text are dropped, so the text can neither
14
+ * end the paste early nor start one of its own. A TUI that has not turned
15
+ * bracketed paste on gets the text unmarked, and may still read both writes
16
+ * as one burst.
17
+ */
18
+ export function planPastedLineInput(text: string, modes: TerminalInputModes): readonly string[] {
19
+ const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
20
+
21
+ return modes.bracketedPaste ? [`${PASTE_START}${unmarked}${PASTE_END}`, '\r'] : [unmarked, '\r'];
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Types a line the way a terminal user does: the text and a newline in one
3
+ * write. A TUI that reads input a line at a time takes the newline as the
4
+ * submit key.
5
+ */
6
+ export function planTypedLineInput(text: string): readonly string[] {
7
+ return [`${text}\n`];
8
+ }
@@ -0,0 +1,13 @@
1
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
2
+
3
+ /**
4
+ * The permission mode a Claude CLI agent's headless turns run in: the mode
5
+ * its configuration sets, else auto, since no human is at a terminal to
6
+ * answer a prompt.
7
+ */
8
+ export function resolveClaudePermissionMode(
9
+ args: readonly string[],
10
+ settings: Readonly<Record<string, unknown>> | undefined,
11
+ ): string {
12
+ return findClaudePermissionMode(args, settings) ?? 'auto';
13
+ }
@@ -1,31 +1,8 @@
1
- import type { AgentSessionID } from '../shared/agent-session-id';
2
- import { socketPath } from '../shared/config';
1
+ import { truncateSummary } from '../daemon/truncate-summary';
3
2
  import { isCompiledBinary } from '../shared/is-compiled-binary';
4
3
  import { isRecord } from '../shared/report';
5
- import type { SessionID } from '../shared/session-id';
6
- import { buildHeadlessEnv } from './build-headless-env';
7
- import { resolveHeadlessExecutable } from './resolve-headless-executable';
8
- import { truncateSummary } from './truncate-summary';
9
-
10
- const PERMISSION_MODES = [
11
- 'default',
12
- 'acceptEdits',
13
- 'bypassPermissions',
14
- 'plan',
15
- 'auto',
16
- 'dontAsk',
17
- ] as const;
18
-
19
- interface HeadlessRunOptions {
20
- readonly claudeBin: string;
21
- readonly cwd: string;
22
- readonly prompt: string;
23
- readonly resume?: AgentSessionID;
24
- readonly permissionMode?: string;
25
- readonly settings?: string;
26
- readonly sessionID?: SessionID;
27
- readonly pluginDir?: string;
28
- }
4
+ import { buildClaudeQueryOptions } from './build-claude-query-options';
5
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
29
6
 
30
7
  interface HeadlessRunHooks {
31
8
  readonly onOutput: (text: string) => void;
@@ -43,15 +20,14 @@ interface HeadlessRunHandle {
43
20
  * done (with the turn's whole result) or needs_you (errors, turn limits,
44
21
  * anything a human must look at).
45
22
  */
46
- export function startHeadlessRun(
47
- opts: HeadlessRunOptions,
23
+ export function startClaudeHeadlessRun(
24
+ opts: ClaudeHeadlessRunRequest,
48
25
  hooks: HeadlessRunHooks,
49
26
  ): HeadlessRunHandle {
50
27
  const controller = new AbortController();
51
28
 
52
29
  void (async () => {
53
30
  try {
54
- const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
55
31
  let stderrTail = '';
56
32
 
57
33
  // The SDK is the heaviest module in the daemon's graph, and only a
@@ -62,23 +38,11 @@ export function startHeadlessRun(
62
38
  const stream = sdk.query({
63
39
  prompt: opts.prompt,
64
40
  options: {
65
- cwd: opts.cwd,
66
- env: buildHeadlessEnv({
67
- socketPath,
68
- ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
69
- ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
70
- }),
41
+ ...buildClaudeQueryOptions(opts, isCompiledBinary()),
71
42
  abortController: controller,
72
43
  stderr: (data: string) => {
73
44
  stderrTail = `${stderrTail}${data}`.slice(-2000);
74
45
  },
75
- ...(opts.resume === undefined ? {} : { resume: opts.resume }),
76
- ...(mode === undefined ? {} : { permissionMode: mode }),
77
-
78
- // The same generated file the terminal spawn passes, so the turn
79
- // runs against the session's own backend and instrumentation.
80
- ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
81
- ...resolveHeadlessExecutable(opts.claudeBin, isCompiledBinary()),
82
46
  },
83
47
  });
84
48
 
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { stateDir } from '../shared/config';
4
- import { ATC_BRIDGE_FILES } from './atc-bridge-files';
4
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
5
5
  import { buildCLIArgv } from './build-cli-argv';
6
6
 
7
7
  /**
@@ -13,7 +13,7 @@ import { buildCLIArgv } from './build-cli-argv';
13
13
  * there.
14
14
  */
15
15
  export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
16
- const files = { ...ATC_BRIDGE_FILES, 'hooks/atc-cli.ts': renderATCCLIModule(buildCLIArgv()) };
16
+ const files = buildATCBridgeFiles(buildCLIArgv());
17
17
 
18
18
  for (const [path, content] of Object.entries(files)) {
19
19
  const file = join(dir, path);
@@ -29,10 +29,6 @@ export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): stri
29
29
  return dir;
30
30
  }
31
31
 
32
- function renderATCCLIModule(argv: readonly string[]): string {
33
- return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
34
- }
35
-
36
32
  function tryReadFileText(file: string): string | null {
37
33
  try {
38
34
  return readFileSync(file, 'utf8');
package/src/cli.ts CHANGED
@@ -159,12 +159,28 @@ const main = defineCommand({
159
159
  const grok = await import('./agents/grok-adapter');
160
160
  const codex = await import('./agents/codex-adapter');
161
161
  const gateway = await import('./agents/gateway-adapter');
162
- const headless = await import('./daemon/start-headless-run');
162
+ const headless = await import('./agents/start-claude-headless-run');
163
+ const targets = await import('./daemon/build-execution-targets');
163
164
 
164
165
  // Test harnesses shrink the outbound queue to force overflow
165
166
  // deterministically; unset means the production default.
166
167
  const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
167
168
  const cfg = config.loadConfig();
169
+ const built = targets.buildExecutionTargets(cfg.targets);
170
+ const targetErrors = [...cfg.targetErrors, ...built.errors];
171
+
172
+ for (const error of targetErrors) {
173
+ const line =
174
+ error.scope === 'config'
175
+ ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
176
+ : error.problem;
177
+
178
+ console.error(`atc daemon: config: ${line}`);
179
+ }
180
+
181
+ for (const problem of cfg.principalErrors) {
182
+ console.error(`atc daemon: config: ${problem}`);
183
+ }
168
184
 
169
185
  // Cap on how long a fleet restore waits for one revived session to
170
186
  // report it has booted before moving to the next. Tests pin it to
@@ -178,18 +194,12 @@ const main = defineCommand({
178
194
  // to it is refused. Tests pin it to 0 to reach the refusal at once.
179
195
  const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
180
196
 
181
- const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
182
- headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
183
- );
184
-
197
+ const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
185
198
  const grokAdapter = new grok.GrokAdapter(cfg);
186
199
  const codexAdapter = new codex.CodexAdapter(cfg);
187
200
 
188
201
  const gatewayAdapters = cfg.gateways.map(
189
- (entry) =>
190
- new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
191
- headless.startHeadlessRun({ ...runOpts, claudeBin: entry.bin }, hooks),
192
- ),
202
+ (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
193
203
  );
194
204
 
195
205
  let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
@@ -206,6 +216,10 @@ const main = defineCommand({
206
216
  legacyFleetPath: config.legacyFleetFile,
207
217
  pidPath: config.daemonPidFile,
208
218
  hooks: cfg.hooks,
219
+ targets: built.targets,
220
+ defaultTarget: cfg.defaultTarget,
221
+ targetErrors,
222
+ principals: cfg.principals,
209
223
  restoreBootTimeoutMs,
210
224
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
211
225
  ? { tapGraceMs: graceOverride }
@@ -65,9 +65,12 @@ export class DaemonClient {
65
65
  return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
66
66
  }
67
67
 
68
+ // A request with a principal acts as that principal, within what the
69
+ // connection may reach.
68
70
  sendRequest(
69
71
  m: string,
70
72
  p?: Readonly<Record<string, unknown>>,
73
+ as?: string,
71
74
  ): Promise<Readonly<Record<string, unknown>>> {
72
75
  if (this.closedReason !== null) {
73
76
  return Promise.reject(new DaemonError('internal', this.closedReason));
@@ -77,7 +80,16 @@ export class DaemonClient {
77
80
  const resolvers = Promise.withResolvers<Readonly<Record<string, unknown>>>();
78
81
 
79
82
  this.pending.set(id, { resolve: resolvers.resolve, reject: resolvers.reject });
80
- this.queue?.send(encodeMessage({ v: PROTOCOL_V, id, m, ...(p === undefined ? {} : { p }) }));
83
+
84
+ this.queue?.send(
85
+ encodeMessage({
86
+ v: PROTOCOL_V,
87
+ id,
88
+ m,
89
+ ...(p === undefined ? {} : { p }),
90
+ ...(as === undefined ? {} : { as }),
91
+ }),
92
+ );
81
93
 
82
94
  return resolvers.promise;
83
95
  }
@@ -138,7 +150,7 @@ export class DaemonClient {
138
150
  if (msg.err === undefined) {
139
151
  waiter.resolve(msg.ok ?? {});
140
152
  } else {
141
- waiter.reject(new DaemonError(msg.err.code, msg.err.msg));
153
+ waiter.reject(new DaemonError(msg.err.code, msg.err.msg, msg.err.data));
142
154
  }
143
155
  }
144
156