@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
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Thrown by an idempotent effect's start when it failed after the effect
3
+ * began and could not confirm taking it back, so the effect may still stand.
4
+ * The ledger keeps the key as outcome_unknown instead of releasing it, and a
5
+ * retry under the key never runs the effect a second time.
6
+ */
7
+ export class EffectRemainsError extends Error {
8
+ constructor(message: string, options?: Readonly<ErrorOptions>) {
9
+ super(message, options);
10
+
11
+ this.name = 'EffectRemainsError';
12
+ }
13
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The host a session's harness runs on: it starts a process in a
3
+ * pseudo-terminal and, as its capabilities declare, unpacks files into its
4
+ * filesystem and runs commands there. The daemon starts every session
5
+ * terminal through this interface, so a new host is a provider, not a
6
+ * change to the session code. The daemon checks a capability before it
7
+ * calls the operation behind it, and a request needing one the provider
8
+ * lacks fails with `unsupported_operation`.
9
+ */
10
+ export interface ExecutionProvider {
11
+ // The provider kind a target's config selects, such as `local-pty`.
12
+ readonly kind: string;
13
+ readonly capabilities: ExecutionCapabilities;
14
+
15
+ // Starts a process in a pseudo-terminal of the given size.
16
+ readonly spawnHarness: (spec: HarnessSpec) => HarnessHandle;
17
+
18
+ // Unpacks a tar archive into a directory, creating the directory first.
19
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
20
+ readonly transferArchive: (archive: Uint8Array, dir: string) => Promise<void>;
21
+
22
+ // Runs a command to completion and returns what it printed.
23
+ readonly runCommand: (spec: CommandSpec) => Promise<CommandResult>;
24
+ }
25
+
26
+ /**
27
+ * What a provider can do. The terminal capabilities split so a host that can
28
+ * show output but not take input, or not resize, says so; `suspend` and
29
+ * `destroy` cover the lifecycle of a host that outlives its process.
30
+ */
31
+ export interface ExecutionCapabilities {
32
+ // Start a harness in a pseudo-terminal.
33
+ readonly spawn: boolean;
34
+
35
+ // Stream a running harness's output to attached clients.
36
+ readonly attach: boolean;
37
+
38
+ // Write keystrokes to a running harness.
39
+ readonly input: boolean;
40
+
41
+ // Change a running harness's terminal size.
42
+ readonly resize: boolean;
43
+
44
+ // End a running harness.
45
+ readonly kill: boolean;
46
+
47
+ // Unpack an archive into the host's filesystem.
48
+ readonly transfer: boolean;
49
+
50
+ // Run a command on the host to completion.
51
+ readonly run: boolean;
52
+
53
+ // Run an agent turn without a terminal, through the agent's own runner.
54
+ readonly headless: boolean;
55
+
56
+ // Pause the host and resume it later with its state intact.
57
+ readonly suspend: boolean;
58
+
59
+ // Delete the host and everything on it.
60
+ readonly destroy: boolean;
61
+ }
62
+
63
+ export type ExecutionCapability = keyof ExecutionCapabilities;
64
+
65
+ export interface HarnessSpec {
66
+ readonly bin: string;
67
+ readonly args: readonly string[];
68
+ readonly cwd: string;
69
+ readonly env: Readonly<Record<string, string>>;
70
+ readonly cols: number;
71
+ readonly rows: number;
72
+ }
73
+
74
+ /**
75
+ * A running harness. Output and exit arrive through listeners, each
76
+ * subscription detachable on its own.
77
+ */
78
+ export interface HarnessHandle {
79
+ readonly onData: (listener: (data: string) => void) => HarnessSubscription;
80
+ readonly onExit: (listener: (exit: HarnessExit) => void) => HarnessSubscription;
81
+ readonly write: (data: string) => void;
82
+ readonly resize: (cols: number, rows: number) => void;
83
+ readonly kill: () => void;
84
+ }
85
+
86
+ interface HarnessSubscription {
87
+ readonly dispose: () => void;
88
+ }
89
+
90
+ interface HarnessExit {
91
+ readonly exitCode: number;
92
+ }
93
+
94
+ export interface CommandSpec {
95
+ readonly argv: readonly string[];
96
+ readonly cwd: string;
97
+ }
98
+
99
+ export interface CommandResult {
100
+ readonly exitCode: number;
101
+ readonly stdout: string;
102
+ readonly stderr: string;
103
+ }
@@ -0,0 +1,104 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { TargetConfigError } from '../shared/collect-targets';
3
+ import type { ExecutionTarget } from './build-execution-targets';
4
+ import type { ExecutionCapability } from './execution-provider';
5
+
6
+ // The target a session runs on and the identity it was bound to there; a
7
+ // null identity is a session not yet bound, which binds to the target as
8
+ // it stands now. A null target is a spawn that names none when the config
9
+ // gives no default.
10
+ interface TargetBinding {
11
+ readonly target: string | null;
12
+ readonly targetIdentity: string | null;
13
+ }
14
+
15
+ /**
16
+ * The refusal for running work of the given capability on a session's
17
+ * target, or null when the target serves it. Every path that starts a
18
+ * harness, writes to one, or runs a headless turn asks this first, so no
19
+ * path runs a session anywhere but the target it is bound to:
20
+ *
21
+ * - `target_config_invalid` when the config file exists but cannot be
22
+ * read or parsed, which refuses every target, `local` included; when the
23
+ * config's `targets` map, or the target's own entry, is malformed; and
24
+ * when there is no target because the config gives no default.
25
+ * - `unknown_target` when no target holds the id.
26
+ * - `target_changed` when the target's identity is not the one the session
27
+ * was bound to: the name now holds another provider or other options.
28
+ * - `target_unavailable` when this daemon has no provider of its kind.
29
+ * - `unsupported_operation` when the provider lacks the capability.
30
+ */
31
+ export function findExecutionRefusal(
32
+ targets: ReadonlyMap<string, ExecutionTarget>,
33
+ errors: readonly TargetConfigError[],
34
+ binding: TargetBinding,
35
+ capability: ExecutionCapability,
36
+ ): DaemonError | null {
37
+ const fileError = errors.find((error) => error.scope === 'config');
38
+
39
+ if (fileError !== undefined) {
40
+ return new DaemonError(
41
+ 'target_config_invalid',
42
+ `config file ${fileError.path} cannot be used (${fileError.problem}: ${fileError.detail}), so no session runs on any target, local included. Fix the file and restart the daemon`,
43
+ { problem: fileError.problem, path: fileError.path, detail: fileError.detail },
44
+ );
45
+ }
46
+
47
+ const id = binding.target;
48
+
49
+ if (id === null) {
50
+ const problem =
51
+ errors.find((error) => error.scope !== 'target')?.problem ??
52
+ 'the targets map holds no local target and no defaultTarget is set';
53
+
54
+ return new DaemonError(
55
+ 'target_config_invalid',
56
+ `no default execution target: ${problem}. Name a target on the spawn, or fix targets in config.json and restart the daemon`,
57
+ { problem },
58
+ );
59
+ }
60
+
61
+ const configError = errors.find(
62
+ (error) => error.scope === 'targets' || (error.scope === 'target' && error.target === id),
63
+ );
64
+
65
+ if (configError !== undefined) {
66
+ return new DaemonError(
67
+ 'target_config_invalid',
68
+ `execution target '${id}' cannot be used: ${configError.problem}. Fix targets in config.json and restart the daemon`,
69
+ { target: id, problem: configError.problem },
70
+ );
71
+ }
72
+
73
+ const target = targets.get(id);
74
+
75
+ if (target === undefined) {
76
+ return new DaemonError('unknown_target', `no execution target '${id}'`, { target: id });
77
+ }
78
+
79
+ if (binding.targetIdentity !== null && binding.targetIdentity !== target.identity) {
80
+ return new DaemonError(
81
+ 'target_changed',
82
+ `execution target '${id}' changed since this session started on it (was ${binding.targetIdentity}, now ${target.identity}). Restore the target's earlier config to use the session, or kill it`,
83
+ { target: id, boundIdentity: binding.targetIdentity, currentIdentity: target.identity },
84
+ );
85
+ }
86
+
87
+ if (target.provider === null) {
88
+ return new DaemonError(
89
+ 'target_unavailable',
90
+ `execution target '${id}' needs a '${target.kind}' provider, which this daemon does not have`,
91
+ { target: id, provider: target.kind },
92
+ );
93
+ }
94
+
95
+ if (!target.provider.capabilities[capability]) {
96
+ return new DaemonError(
97
+ 'unsupported_operation',
98
+ `the ${target.provider.kind} execution provider does not support ${capability}`,
99
+ { provider: target.provider.kind, capability },
100
+ );
101
+ }
102
+
103
+ return null;
104
+ }
@@ -0,0 +1,164 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { EffectTarget, IdempotencyRecord } from '../store/idempotency-record';
3
+ import type { StateStore } from '../store/state-store';
4
+ import { EffectRemainsError } from './effect-remains-error';
5
+
6
+ // A request's idempotency key, the hash of the payload it came with, and
7
+ // the principal the request acts as when it is not the ledger's own.
8
+ export interface KeyedRequest {
9
+ readonly key: string;
10
+ readonly payloadHash: string;
11
+ readonly principal?: string;
12
+ }
13
+
14
+ interface IdempotentCall<T> {
15
+ readonly operation: string;
16
+ readonly keyed: KeyedRequest;
17
+
18
+ // The id the effect runs under, minted before the claim records it.
19
+ readonly effectRef: string;
20
+
21
+ // Starts the effect. A plain throw means nothing took effect, so the claim
22
+ // is dropped for a retry to run fresh; an EffectRemainsError means the
23
+ // effect may still stand, so the claim is kept as outcome_unknown.
24
+ readonly start: () => T | Promise<T>;
25
+
26
+ // Resolves once the effect is durable; the claim completes only after.
27
+ readonly settle: () => Promise<void>;
28
+
29
+ // The answer to a retry of a completed key.
30
+ readonly replay: (record: IdempotencyRecord) => T | Promise<T>;
31
+
32
+ // The target the effect's session is bound to, recorded with the
33
+ // completed key; absent or null for an effect bound to none.
34
+ readonly findEffectTarget?: (result: T) => EffectTarget | null;
35
+ }
36
+
37
+ /**
38
+ * Runs keyed effects at most once per key for each principal: the
39
+ * request's own, else the ledger's. The first
40
+ * request claims the key with its payload's hash and the pre-minted effect
41
+ * id, runs the effect, and completes the key with its answer once the
42
+ * effect is durable. A retry with the same payload replays the completed
43
+ * answer; one with a different payload is `idempotency_conflict`; one whose
44
+ * effect a stopped daemon may or may not have run is `outcome_unknown` with
45
+ * the effect id in `data.effectRef`, and never starts the effect again.
46
+ */
47
+ export class IdempotencyLedger {
48
+ private readonly store: StateStore;
49
+
50
+ private readonly principal: string;
51
+
52
+ // One promise chain per key, so two requests under the same key never
53
+ // interleave their claim and completion on one daemon.
54
+ private readonly locks = new Map<string, Promise<void>>();
55
+
56
+ constructor(store: StateStore, principal: string) {
57
+ this.store = store;
58
+ this.principal = principal;
59
+ }
60
+
61
+ async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
62
+ const lockKey = JSON.stringify([
63
+ call.keyed.principal ?? this.principal,
64
+ call.operation,
65
+ call.keyed.key,
66
+ ]);
67
+
68
+ const previous = this.locks.get(lockKey) ?? Promise.resolve();
69
+ const released = Promise.withResolvers<void>();
70
+
71
+ const chained = (async () => {
72
+ await previous;
73
+ await released.promise;
74
+ })();
75
+
76
+ this.locks.set(lockKey, chained);
77
+
78
+ await previous;
79
+
80
+ try {
81
+ return await this.runClaimed(call);
82
+ } finally {
83
+ released.resolve();
84
+
85
+ if (this.locks.get(lockKey) === chained) {
86
+ this.locks.delete(lockKey);
87
+ }
88
+ }
89
+ }
90
+
91
+ private async runClaimed<T extends Readonly<Record<string, unknown>>>(
92
+ call: IdempotentCall<T>,
93
+ ): Promise<T> {
94
+ const id = {
95
+ principal: call.keyed.principal ?? this.principal,
96
+ operation: call.operation,
97
+ key: call.keyed.key,
98
+ };
99
+
100
+ const held = await this.store.claimIdempotencyKey({
101
+ ...id,
102
+ payloadHash: call.keyed.payloadHash,
103
+ effectRef: call.effectRef,
104
+ at: Date.now(),
105
+ });
106
+
107
+ if (held !== null) {
108
+ return answerHeldKey(call, held);
109
+ }
110
+
111
+ let result: T;
112
+
113
+ try {
114
+ result = await call.start();
115
+ } catch (error) {
116
+ if (error instanceof EffectRemainsError) {
117
+ await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
118
+
119
+ throw new DaemonError(
120
+ 'outcome_unknown',
121
+ `the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
122
+ { effectRef: call.effectRef },
123
+ );
124
+ }
125
+
126
+ await this.store.removeIdempotencyKey(id);
127
+
128
+ throw error;
129
+ }
130
+
131
+ // A settle that fails leaves the claim in progress: the effect started,
132
+ // so the next daemon start marks its outcome unknown rather than letting
133
+ // a retry run it again.
134
+ await call.settle();
135
+
136
+ await this.store.updateIdempotencyCompleted(
137
+ id,
138
+ JSON.stringify(result),
139
+ Date.now(),
140
+ call.findEffectTarget?.(result) ?? null,
141
+ );
142
+
143
+ return result;
144
+ }
145
+ }
146
+
147
+ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
148
+ if (held.payloadHash !== call.keyed.payloadHash) {
149
+ throw new DaemonError(
150
+ 'idempotency_conflict',
151
+ `idempotency key '${held.key}' was first used with a different ${held.operation} payload`,
152
+ );
153
+ }
154
+
155
+ if (held.state === 'completed') {
156
+ return call.replay(held);
157
+ }
158
+
159
+ throw new DaemonError(
160
+ 'outcome_unknown',
161
+ `the ${held.operation} under idempotency key '${held.key}' was interrupted and may or may not have taken effect; check ${held.effectRef} before retrying under a new key`,
162
+ { effectRef: held.effectRef },
163
+ );
164
+ }
@@ -0,0 +1,83 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { spawn } from 'bun-pty';
3
+ import type {
4
+ CommandResult,
5
+ CommandSpec,
6
+ ExecutionCapabilities,
7
+ ExecutionProvider,
8
+ HarnessHandle,
9
+ HarnessSpec,
10
+ } from './execution-provider';
11
+
12
+ /**
13
+ * The `local-pty` provider: harnesses run as child processes of the daemon
14
+ * on a `bun-pty` pseudo-terminal, and files and commands touch the daemon's
15
+ * own host. The host is the daemon's machine, so there is nothing to
16
+ * suspend or destroy.
17
+ */
18
+ export class LocalPTYProvider implements ExecutionProvider {
19
+ readonly kind = 'local-pty';
20
+
21
+ readonly capabilities: ExecutionCapabilities = {
22
+ spawn: true,
23
+ attach: true,
24
+ input: true,
25
+ resize: true,
26
+ kill: true,
27
+ transfer: true,
28
+ run: true,
29
+ headless: true,
30
+ suspend: false,
31
+ destroy: false,
32
+ };
33
+
34
+ readonly spawnHarness = (spec: HarnessSpec): HarnessHandle =>
35
+ spawn(spec.bin, [...spec.args], {
36
+ name: 'xterm-256color',
37
+ cols: spec.cols,
38
+ rows: spec.rows,
39
+ cwd: spec.cwd,
40
+ env: { ...spec.env },
41
+ });
42
+
43
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
44
+ readonly transferArchive = async (archive: Uint8Array, dir: string): Promise<void> => {
45
+ await mkdir(dir, { recursive: true });
46
+
47
+ const result = await this.runCommandWithInput(
48
+ ['tar', '-x', '-f', '-', '-C', dir],
49
+ dir,
50
+ archive,
51
+ );
52
+
53
+ if (result.exitCode !== 0) {
54
+ throw new Error(`tar exited ${result.exitCode} unpacking into ${dir}: ${result.stderr}`);
55
+ }
56
+ };
57
+
58
+ readonly runCommand = (spec: CommandSpec): Promise<CommandResult> =>
59
+ this.runCommandWithInput(spec.argv, spec.cwd, null);
60
+
61
+ private async runCommandWithInput(
62
+ argv: readonly string[],
63
+ cwd: string,
64
+
65
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- input bytes have no readonly form
66
+ input: Uint8Array | null,
67
+ ): Promise<CommandResult> {
68
+ const proc = Bun.spawn([...argv], {
69
+ cwd,
70
+ stdin: input ?? 'ignore',
71
+ stdout: 'pipe',
72
+ stderr: 'pipe',
73
+ });
74
+
75
+ const [stdout, stderr, exitCode] = await Promise.all([
76
+ new Response(proc.stdout).text(),
77
+ new Response(proc.stderr).text(),
78
+ proc.exited,
79
+ ]);
80
+
81
+ return { exitCode, stdout, stderr };
82
+ }
83
+ }
@@ -1,12 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import type { SessionID } from '../shared/session-id';
2
3
 
3
- let counter = 0;
4
-
5
4
  /**
6
- * A fresh atc session id: an incrementing counter joined to the current time
7
- * in base36. Every session the session manager spawns or restores gets one.
5
+ * A fresh atc session id: a random uuid. Every session the session manager
6
+ * spawns gets one, and the fleet row keeps it, so the id stays the same
7
+ * across daemon restarts and fleet restores.
8
8
  */
9
9
  export function mintSessionID(): SessionID {
10
10
  // oxlint-disable-next-line no-unsafe-type-assertion -- this is where atc mints a session id
11
- return `s${++counter}-${Date.now().toString(36)}` as SessionID;
11
+ return randomUUID() as SessionID;
12
12
  }
@@ -0,0 +1,94 @@
1
+ import type { SpawnOverrides } from '../agents/agent-adapter';
2
+ import type { AgentEntry, SpawnOptionEntry } from './build-agent-list';
3
+
4
+ interface RequestedOverrides {
5
+ readonly model?: string | undefined;
6
+ readonly effort?: string | undefined;
7
+ }
8
+
9
+ interface SpawnRefusal {
10
+ readonly ok: false;
11
+ readonly code: 'bad_args' | 'unsupported';
12
+ readonly message: string;
13
+ }
14
+
15
+ type ParsedOverrides = { readonly ok: true; readonly overrides: SpawnOverrides } | SpawnRefusal;
16
+
17
+ /**
18
+ * Checks a spawn's model and effort against the options the agent list
19
+ * shows for that agent, so a spawn accepts exactly what `agents.list`
20
+ * advertises. An option the agent cannot take on this host is unsupported,
21
+ * never ignored. A value outside an option's closed set, or one that could
22
+ * pass for a flag, is bad_args.
23
+ */
24
+ export function parseSpawnOverrides(
25
+ agent: AgentEntry,
26
+ requested: RequestedOverrides,
27
+ ): ParsedOverrides {
28
+ const model = requested.model;
29
+ const effort = requested.effort;
30
+
31
+ const refusal =
32
+ (model === undefined
33
+ ? null
34
+ : findOptionRefusal(agent.id, 'model', agent.spawnOptions.model, model)) ??
35
+ (effort === undefined
36
+ ? null
37
+ : findOptionRefusal(agent.id, 'effort', agent.spawnOptions.effort, effort));
38
+
39
+ if (refusal !== null) {
40
+ return refusal;
41
+ }
42
+
43
+ return {
44
+ ok: true,
45
+ overrides: {
46
+ ...(model === undefined ? {} : { model }),
47
+ ...(effort === undefined ? {} : { effort }),
48
+ },
49
+ };
50
+ }
51
+
52
+ const MAX_VALUE_LENGTH = 200;
53
+
54
+ // oxlint-disable-next-line no-control-regex -- control characters are what a value is refused for
55
+ const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/u;
56
+
57
+ function findOptionRefusal(
58
+ agentID: string,
59
+ name: 'model' | 'effort',
60
+ option: SpawnOptionEntry,
61
+ value: string,
62
+ ): SpawnRefusal | null {
63
+ if (!option.available) {
64
+ const reason = option.supported ? 'is not installed on this host' : `takes no ${name}`;
65
+
66
+ return { ok: false, code: 'unsupported', message: `agent '${agentID}' ${reason}` };
67
+ }
68
+
69
+ if (value === '' || value.length > MAX_VALUE_LENGTH) {
70
+ return {
71
+ ok: false,
72
+ code: 'bad_args',
73
+ message: `${name} must be 1 to ${MAX_VALUE_LENGTH} characters`,
74
+ };
75
+ }
76
+
77
+ if (value.startsWith('-') || CONTROL_CHARACTER.test(value)) {
78
+ return {
79
+ ok: false,
80
+ code: 'bad_args',
81
+ message: `${name} must not start with '-' or hold control characters`,
82
+ };
83
+ }
84
+
85
+ if (option.values !== null && !option.values.includes(value)) {
86
+ return {
87
+ ok: false,
88
+ code: 'bad_args',
89
+ message: `agent '${agentID}' takes ${name} ${option.values.join(', ')}; got '${value}'`,
90
+ };
91
+ }
92
+
93
+ return null;
94
+ }
@@ -30,7 +30,9 @@ export class PermissionRegistry {
30
30
 
31
31
  private readonly pending = new Map<string, PendingRequest>();
32
32
 
33
- private readonly resolved = new Set<string>();
33
+ // Each answered request's session, kept to tell already_answered from
34
+ // unknown.
35
+ private readonly resolved = new Map<string, SessionID>();
34
36
 
35
37
  private counter = 0;
36
38
 
@@ -72,6 +74,14 @@ export class PermissionRegistry {
72
74
  return 'ok';
73
75
  }
74
76
 
77
+ /**
78
+ * The session a request belongs to, answered or not, or null for a
79
+ * request this registry never opened.
80
+ */
81
+ findSessionID(id: string): SessionID | null {
82
+ return this.pending.get(id)?.req.sessionID ?? this.resolved.get(id) ?? null;
83
+ }
84
+
75
85
  answerAll(sessionID: SessionID, decision: string): void {
76
86
  for (const [id, entry] of this.pending) {
77
87
  if (entry.req.sessionID === sessionID) {
@@ -90,12 +100,12 @@ export class PermissionRegistry {
90
100
  clearTimeout(entry.timer);
91
101
 
92
102
  this.pending.delete(id);
93
- this.resolved.add(id);
103
+ this.resolved.set(id, entry.req.sessionID);
94
104
 
95
- // The resolved set only exists to tell already_answered from unknown;
105
+ // The resolved map only exists to tell already_answered from unknown;
96
106
  // cap it so a long-lived daemon cannot grow it without bound.
97
107
  if (this.resolved.size > 1000) {
98
- for (const oldest of this.resolved) {
108
+ for (const oldest of this.resolved.keys()) {
99
109
  this.resolved.delete(oldest);
100
110
  break;
101
111
  }