@zgeoff/atc 2.11.0 → 2.12.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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.11.0",
3
+ "version": "2.12.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",
@@ -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 {
@@ -102,6 +116,10 @@ interface HeadlessRunRequest {
102
116
 
103
117
  // Folder of the atc-bridge mod the run's CLI loads, supplied by the adapter.
104
118
  readonly pluginDir?: string;
119
+
120
+ // The session's own model and effort, so a headless turn keeps them.
121
+ readonly model?: string;
122
+ readonly effort?: string;
105
123
  }
106
124
 
107
125
  interface HeadlessRunEvents {
@@ -133,6 +151,39 @@ export interface AgentProfile {
133
151
 
134
152
  // Model names the config sets explicitly, keyed by role; null when it sets none.
135
153
  readonly models: Readonly<Record<string, string>> | null;
154
+
155
+ // The per-session overrides a spawn can pass this agent's CLI.
156
+ readonly spawnOptions: SpawnOptionSpecs;
157
+ }
158
+
159
+ /**
160
+ * A value worth offering for a spawn option, with the provider model it
161
+ * maps to when the config holds that mapping.
162
+ */
163
+ interface SpawnOptionExample {
164
+ readonly value: string;
165
+ readonly resolvesTo: string | null;
166
+ }
167
+
168
+ /**
169
+ * Whether the agent's CLI takes one spawn option, and which values it does.
170
+ * `values` holds the closed set a value must come from; null takes any
171
+ * well-formed value. `backendEffect` is `applied` when the CLI applies the
172
+ * value itself and `unverified` when the backend behind the CLI may ignore
173
+ * it; null for an option the agent does not take.
174
+ */
175
+ export interface SpawnOptionSpec {
176
+ readonly supported: boolean;
177
+ readonly values: readonly string[] | null;
178
+ readonly examples: readonly SpawnOptionExample[];
179
+ readonly default: string | null;
180
+ readonly backendEffect: 'applied' | 'unverified' | null;
181
+ readonly note: string | null;
182
+ }
183
+
184
+ export interface SpawnOptionSpecs {
185
+ readonly model: SpawnOptionSpec;
186
+ readonly effort: SpawnOptionSpec;
136
187
  }
137
188
 
138
189
  /**
@@ -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,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
+ }
@@ -14,9 +14,13 @@ import type {
14
14
  HeadlessRunner,
15
15
  NameUpdate,
16
16
  ResumeCheck,
17
+ SpawnOptionSpecs,
17
18
  SpawnOptions,
18
19
  SpawnPlan,
19
20
  } from './agent-adapter';
21
+ import { buildClaudeOverrideArgs } from './build-claude-override-args';
22
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
23
+ import { findFlagValue } from './find-flag-value';
20
24
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
21
25
  import { truncateDetail } from './truncate-detail';
22
26
  import { writeATCBridge } from './write-atc-bridge';
@@ -66,7 +70,14 @@ export class ClaudeAdapter implements AgentAdapter {
66
70
  constructor(config: Config, headlessRunner: HeadlessRunner | null = null, bridgeTarget?: string) {
67
71
  this.bridgeTarget = bridgeTarget;
68
72
  this.config = config;
69
- this.profile = { label: 'Claude', kind: 'claude', bin: config.claudeBin, models: null };
73
+
74
+ this.profile = {
75
+ label: 'Claude',
76
+ kind: 'claude',
77
+ bin: config.claudeBin,
78
+ models: null,
79
+ spawnOptions: buildClaudeSpawnOptions(config.claudeArgs),
80
+ };
70
81
 
71
82
  this.headlessRunner =
72
83
  headlessRunner === null
@@ -80,7 +91,7 @@ export class ClaudeAdapter implements AgentAdapter {
80
91
  return {
81
92
  bin: this.config.claudeBin,
82
93
  args: [
83
- ...this.config.claudeArgs,
94
+ ...buildClaudeOverrideArgs(this.config.claudeArgs, opts),
84
95
  '--settings',
85
96
  this.settingsFile,
86
97
  '--plugin-dir',
@@ -230,3 +241,39 @@ export class ClaudeAdapter implements AgentAdapter {
230
241
  return `cd ${toShellArg(cwd)} && ${resume}`;
231
242
  }
232
243
  }
244
+
245
+ // The aliases Claude Code documents for `--model`, each resolving to a model
246
+ // the account picks. A full model name is accepted as well.
247
+ const CLAUDE_MODEL_ALIASES = [
248
+ 'best',
249
+ 'fable',
250
+ 'opus',
251
+ 'sonnet',
252
+ 'haiku',
253
+ 'opus[1m]',
254
+ 'sonnet[1m]',
255
+ 'opusplan',
256
+ ];
257
+
258
+ // What a Claude spawn can override. Each default is the value the configured
259
+ // arguments pass, or null when the CLI picks its own.
260
+ function buildClaudeSpawnOptions(claudeArgs: readonly string[]): SpawnOptionSpecs {
261
+ return {
262
+ model: {
263
+ supported: true,
264
+ values: null,
265
+ examples: CLAUDE_MODEL_ALIASES.map((value) => ({ value, resolvesTo: null })),
266
+ default: findFlagValue(claudeArgs, ['--model']),
267
+ backendEffect: 'applied',
268
+ note: 'An alias or a full model name, passed as --model.',
269
+ },
270
+ effort: {
271
+ supported: true,
272
+ values: CLAUDE_EFFORT_LEVELS,
273
+ examples: [],
274
+ default: findFlagValue(claudeArgs, ['--effort']),
275
+ backendEffect: 'applied',
276
+ note: 'Passed as --effort. Which levels a session honours depends on its model.',
277
+ },
278
+ };
279
+ }
@@ -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'];
@@ -14,9 +14,12 @@ 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';
20
23
  import { resolveAgentHome } from './resolve-agent-home';
21
24
  import { truncateDetail } from './truncate-detail';
22
25
 
@@ -33,6 +36,9 @@ const CODEX_HOOK_PAYLOAD_SCHEMA = z.object({
33
36
 
34
37
  type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
35
38
 
39
+ // The spellings of the Codex CLI's model flag.
40
+ const CODEX_MODEL_FLAGS = ['-m', '--model'];
41
+
36
42
  /**
37
43
  * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
38
44
  * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
@@ -56,14 +62,25 @@ export class CodexAdapter implements AgentAdapter {
56
62
 
57
63
  constructor(config: Config) {
58
64
  this.config = config;
59
- this.profile = { label: 'Codex', kind: 'codex', bin: config.codexBin, models: null };
65
+
66
+ this.profile = {
67
+ label: 'Codex',
68
+ kind: 'codex',
69
+ bin: config.codexBin,
70
+ models: null,
71
+ spawnOptions: buildCodexSpawnOptions(config.codexArgs),
72
+ };
60
73
  }
61
74
 
62
75
  planSpawn(opts: SpawnOptions): SpawnPlan {
63
76
  return {
64
77
  bin: this.config.codexBin,
65
78
  args: [
66
- ...this.config.codexArgs,
79
+ // A model override replaces any model flag the configured arguments
80
+ // carry, and travels as its own argument.
81
+ ...(opts.model === undefined
82
+ ? this.config.codexArgs
83
+ : [...buildArgsWithoutFlags(this.config.codexArgs, CODEX_MODEL_FLAGS), '-m', opts.model]),
67
84
 
68
85
  // Bare resume opens Codex's own session picker; an id resumes that
69
86
  // session directly. Both accept a prompt afterwards.
@@ -193,3 +210,27 @@ export class CodexAdapter implements AgentAdapter {
193
210
  return `cd ${toShellArg(cwd)} && ${resume}`;
194
211
  }
195
212
  }
213
+
214
+ // What a Codex spawn can override. Codex documents its reasoning effort as
215
+ // whatever the selected model advertises, with no closed list of levels, so
216
+ // atc takes no effort for it.
217
+ function buildCodexSpawnOptions(codexArgs: readonly string[]): SpawnOptionSpecs {
218
+ return {
219
+ model: {
220
+ supported: true,
221
+ values: null,
222
+ examples: [],
223
+ default: findFlagValue(codexArgs, CODEX_MODEL_FLAGS),
224
+ backendEffect: 'applied',
225
+ note: 'A model name, passed as -m.',
226
+ },
227
+ effort: {
228
+ supported: false,
229
+ values: null,
230
+ examples: [],
231
+ default: null,
232
+ backendEffect: null,
233
+ note: 'Codex documents its reasoning effort levels as depending on the model, with no closed list, so atc does not pass one.',
234
+ },
235
+ };
236
+ }
@@ -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,10 +11,14 @@ 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';
17
19
  import { ClaudeAdapter } from './claude-adapter';
20
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
21
+ import { findFlagValue } from './find-flag-value';
18
22
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
19
23
  import { writeATCBridge } from './write-atc-bridge';
20
24
  import { writeHookSettings } from './write-hook-settings';
@@ -65,11 +69,14 @@ export class GatewayAdapter implements AgentAdapter {
65
69
  this.gateway = gateway;
66
70
  this.id = gateway.id;
67
71
 
72
+ const models = pickModels(gateway.env);
73
+
68
74
  this.profile = {
69
75
  label: gateway.label,
70
76
  kind: 'gateway',
71
77
  bin: gateway.bin,
72
- models: pickModels(gateway.env),
78
+ models,
79
+ spawnOptions: buildGatewaySpawnOptions(gateway.args, models),
73
80
  };
74
81
 
75
82
  this.claude = new ClaudeAdapter(config);
@@ -88,7 +95,7 @@ export class GatewayAdapter implements AgentAdapter {
88
95
  return {
89
96
  bin: this.gateway.bin,
90
97
  args: [
91
- ...this.gateway.args,
98
+ ...buildClaudeOverrideArgs(this.gateway.args, opts),
92
99
  '--settings',
93
100
  this.writeSettings(),
94
101
  '--plugin-dir',
@@ -162,3 +169,39 @@ function pickModels(
162
169
 
163
170
  return Object.keys(models).length === 0 ? null : models;
164
171
  }
172
+
173
+ // The Claude tiers a gateway's env can map to a provider model.
174
+ const GATEWAY_TIERS = ['opus', 'sonnet', 'haiku'];
175
+
176
+ // What a gateway spawn can override. The model examples are the tier aliases
177
+ // the gateway's env maps, each with the provider model it reaches. Effort
178
+ // goes to the CLI unchanged, and whether the provider acts on it is unknown.
179
+ function buildGatewaySpawnOptions(
180
+ args: readonly string[],
181
+ models: Readonly<Record<string, string>> | null,
182
+ ): SpawnOptionSpecs {
183
+ const examples = GATEWAY_TIERS.flatMap((tier) => {
184
+ const mapped = models?.[tier];
185
+
186
+ return mapped === undefined ? [] : [{ value: tier, resolvesTo: mapped }];
187
+ });
188
+
189
+ return {
190
+ model: {
191
+ supported: true,
192
+ values: null,
193
+ examples,
194
+ default: findFlagValue(args, ['--model']) ?? models?.['default'] ?? null,
195
+ backendEffect: 'applied',
196
+ note: "A tier alias the gateway's env maps, or a model name the provider accepts, passed as --model.",
197
+ },
198
+ effort: {
199
+ supported: true,
200
+ values: CLAUDE_EFFORT_LEVELS,
201
+ examples: [],
202
+ default: findFlagValue(args, ['--effort']),
203
+ backendEffect: 'unverified',
204
+ note: "Passed as --effort; the gateway's provider may ignore it.",
205
+ },
206
+ };
207
+ }
@@ -15,6 +15,7 @@ import type {
15
15
  AgentProfile,
16
16
  NameUpdate,
17
17
  ResumeCheck,
18
+ SpawnOptionSpec,
18
19
  SpawnOptions,
19
20
  SpawnPlan,
20
21
  } from './agent-adapter';
@@ -48,6 +49,16 @@ const GROK_HOOK_PAYLOAD_SCHEMA = z.object({
48
49
 
49
50
  type GrokHookPayload = z.infer<typeof GROK_HOOK_PAYLOAD_SCHEMA>;
50
51
 
52
+ // atc passes Grok neither a model nor an effort; the CLI's own config picks both.
53
+ const GROK_UNSUPPORTED_OPTION: SpawnOptionSpec = {
54
+ supported: false,
55
+ values: null,
56
+ examples: [],
57
+ default: null,
58
+ backendEffect: null,
59
+ note: 'atc does not pass this option to Grok.',
60
+ };
61
+
51
62
  /**
52
63
  * The Grok Build adapter: spawn arguments, hook payload mapping,
53
64
  * resume semantics, and summary.json name-pulling.
@@ -71,7 +82,14 @@ export class GrokAdapter implements AgentAdapter {
71
82
 
72
83
  constructor(config: Config) {
73
84
  this.config = config;
74
- this.profile = { label: 'Grok', kind: 'grok', bin: config.grokBin, models: null };
85
+
86
+ this.profile = {
87
+ label: 'Grok',
88
+ kind: 'grok',
89
+ bin: config.grokBin,
90
+ models: null,
91
+ spawnOptions: { model: GROK_UNSUPPORTED_OPTION, effort: GROK_UNSUPPORTED_OPTION },
92
+ };
75
93
  }
76
94
 
77
95
  planSpawn(opts: SpawnOptions): SpawnPlan {
package/src/cli.ts CHANGED
@@ -160,12 +160,26 @@ const main = defineCommand({
160
160
  const codex = await import('./agents/codex-adapter');
161
161
  const gateway = await import('./agents/gateway-adapter');
162
162
  const headless = await import('./daemon/start-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();
168
169
 
170
+ for (const error of cfg.targetErrors) {
171
+ const line =
172
+ error.scope === 'config'
173
+ ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
174
+ : error.problem;
175
+
176
+ console.error(`atc daemon: config: ${line}`);
177
+ }
178
+
179
+ for (const problem of cfg.principalErrors) {
180
+ console.error(`atc daemon: config: ${problem}`);
181
+ }
182
+
169
183
  // Cap on how long a fleet restore waits for one revived session to
170
184
  // report it has booted before moving to the next. Tests pin it to
171
185
  // keep timing deterministic; unset means the production default.
@@ -206,6 +220,10 @@ const main = defineCommand({
206
220
  legacyFleetPath: config.legacyFleetFile,
207
221
  pidPath: config.daemonPidFile,
208
222
  hooks: cfg.hooks,
223
+ targets: targets.buildExecutionTargets(cfg.targets),
224
+ defaultTarget: cfg.defaultTarget,
225
+ targetErrors: cfg.targetErrors,
226
+ principals: cfg.principals,
209
227
  restoreBootTimeoutMs,
210
228
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
211
229
  ? { 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
 
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import type { AgentID } from '../agents/agent-adapter';
3
+ import { DaemonError } from '../protocol/daemon-error';
3
4
  import { loadConfig } from '../shared/config';
4
5
  import { collectAgentPicks } from './collect-agent-picks';
5
6
  import type { AgentPick } from './collect-agent-picks';
@@ -32,7 +33,8 @@ export interface SpawnPickerDeps<TMirror> {
32
33
  /**
33
34
  * The modal flow behind n and r: agent, then directory, then name, then an
34
35
  * optional first prompt. Every path out of it either attaches the new
35
- * session or returns the client to the screen it came from.
36
+ * session or returns the client to the screen it came from. A spawn the
37
+ * daemon refuses is no path out: the flow stays open and shows why.
36
38
  */
37
39
  export class SpawnPicker<TMirror extends { readonly id: string }> {
38
40
  private readonly deps: SpawnPickerDeps<TMirror>;
@@ -60,6 +62,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
60
62
 
61
63
  private agent: AgentID = 'claude';
62
64
 
65
+ // The daemon's refusal of the last spawn, shown in place of the hint until
66
+ // the next key.
67
+ private refusal: string | null = null;
68
+
63
69
  constructor(deps: SpawnPickerDeps<TMirror>) {
64
70
  this.deps = deps;
65
71
  }
@@ -82,12 +88,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
82
88
 
83
89
  this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
84
90
  this.step = 'agent';
91
+ this.refusal = null;
85
92
 
86
93
  process.stdout.write(ansi.clear);
87
94
  this.render();
88
95
  }
89
96
 
90
97
  applyKey(buf: Buffer) {
98
+ this.refusal = null;
99
+
91
100
  const edit = planTextEdit(buf, this.input, {
92
101
  isLeaderKey: this.deps.isLeaderKey,
93
102
  moves: this.step === 'agent' || this.step === 'dir',
@@ -168,7 +177,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
168
177
  selected: -1,
169
178
  input: this.input,
170
179
  placeholder: formatDirName(this.dir),
171
- hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
180
+ hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
172
181
  });
173
182
  } else {
174
183
  drawPicker({
@@ -177,7 +186,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
177
186
  selected: -1,
178
187
  input: this.input,
179
188
  placeholder: 'optional — ⏎ to start interactive',
180
- hint: 'first message for the session · ⏎ spawn · esc back',
189
+ hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
181
190
  });
182
191
  }
183
192
 
@@ -320,7 +329,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
320
329
 
321
330
  return;
322
331
  }
323
- } catch {}
332
+ } catch (error) {
333
+ // A refused spawn keeps the picker on the step that sent it, with the
334
+ // entered text back in the input, so the user can fix the cause and
335
+ // retry or back out.
336
+ const reason =
337
+ error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
338
+
339
+ this.refusal = `${reason} · ⏎ retry · esc back`;
340
+ this.input = this.resume ? this.name : prompt;
341
+
342
+ process.stdout.write(ansi.clear);
343
+ this.render();
344
+
345
+ return;
346
+ }
324
347
 
325
348
  this.deps.toBase();
326
349
  }
@@ -1,4 +1,4 @@
1
- import type { AgentAdapter } from '../agents/agent-adapter';
1
+ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
2
 
3
3
  interface AgentCapabilities {
4
4
  // Only an installed agent can start a session.
@@ -21,6 +21,19 @@ export interface AgentEntry {
21
21
  readonly installed: boolean;
22
22
  readonly capabilities: AgentCapabilities;
23
23
  readonly models: Readonly<Record<string, string>> | null;
24
+ readonly spawnOptions: SpawnOptionEntries;
25
+ }
26
+
27
+ // A spawn option as `agents.list` shows it: the agent's own spec, plus
28
+ // whether a spawn on this host can pass it now. Only an installed agent's
29
+ // supported option is available.
30
+ export interface SpawnOptionEntry extends SpawnOptionSpec {
31
+ readonly available: boolean;
32
+ }
33
+
34
+ interface SpawnOptionEntries {
35
+ readonly model: SpawnOptionEntry;
36
+ readonly effort: SpawnOptionEntry;
24
37
  }
25
38
 
26
39
  /**
@@ -53,6 +66,29 @@ export function buildAgentList(
53
66
  input: true,
54
67
  },
55
68
  models: profile?.models ?? null,
69
+ spawnOptions: {
70
+ model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
71
+ effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
72
+ },
56
73
  };
57
74
  });
58
75
  }
76
+
77
+ // A stand-in adapter declares no spawn options, so it takes none.
78
+ const NO_SPAWN_OPTION: SpawnOptionSpec = {
79
+ supported: false,
80
+ values: null,
81
+ examples: [],
82
+ default: null,
83
+ backendEffect: null,
84
+ note: null,
85
+ };
86
+
87
+ function buildSpawnOptionEntry(
88
+ spec: SpawnOptionSpec | undefined,
89
+ installed: boolean,
90
+ ): SpawnOptionEntry {
91
+ const resolved = spec ?? NO_SPAWN_OPTION;
92
+
93
+ return { ...resolved, available: installed && resolved.supported };
94
+ }