@zgeoff/atc 2.10.4 → 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 (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { buildOptionalString } from './build-optional-string';
5
5
  import { buildOptionalStringArray } from './build-optional-string-array';
@@ -8,6 +8,11 @@ import { collectGateways } from './collect-gateways';
8
8
  import type { GatewayConfig } from './collect-gateways';
9
9
  import { collectHooks } from './collect-hooks';
10
10
  import type { HooksConfig } from './collect-hooks';
11
+ import { collectPrincipals } from './collect-principals';
12
+ import { collectTargets } from './collect-targets';
13
+ import type { TargetConfig, TargetConfigError } from './collect-targets';
14
+ import { formatJSONKind } from './format-json-kind';
15
+ import { isRecord } from './report';
11
16
  import { resolveHomeDir } from './resolve-home-dir';
12
17
 
13
18
  export interface Config {
@@ -21,6 +26,20 @@ export interface Config {
21
26
  gateways: GatewayConfig[];
22
27
  hooks: HooksConfig;
23
28
  leader: LeaderKey;
29
+
30
+ // Where sessions run, and the one a spawn without a target runs on: null
31
+ // when the config gives none it can use.
32
+ targets: readonly TargetConfig[];
33
+ defaultTarget: string | null;
34
+
35
+ // The target config problems that leave a target, or every target,
36
+ // unusable.
37
+ targetErrors: readonly TargetConfigError[];
38
+
39
+ // The targets each principal may use: null when the config has no
40
+ // principals, which leaves every principal the implicit local target.
41
+ principals: ReadonlyMap<string, readonly string[]> | null;
42
+ principalErrors: readonly string[];
24
43
  }
25
44
 
26
45
  /**
@@ -47,6 +66,11 @@ const DEFAULTS: Config = {
47
66
  gateways: [],
48
67
  hooks: {},
49
68
  leader: { code: 0, label: '^Space' },
69
+ targets: [{ id: 'local', provider: 'local-pty', options: {} }],
70
+ defaultTarget: 'local',
71
+ targetErrors: [],
72
+ principals: null,
73
+ principalErrors: [],
50
74
  };
51
75
 
52
76
  const configDir = join(resolveHomeDir(), '.config', 'atc');
@@ -80,38 +104,132 @@ const CONFIG_SCHEMA = z.object({
80
104
  gateways: z.unknown().optional(),
81
105
  hooks: z.unknown().optional(),
82
106
  leader: buildOptionalString(),
107
+ targets: z.unknown().optional(),
108
+ defaultTarget: z.unknown().optional(),
109
+ principals: z.unknown().optional(),
83
110
  });
84
111
 
85
- export function loadConfig(): Config {
86
- mkdirSync(configDir, { recursive: true });
87
- mkdirSync(stateDir, { recursive: true });
112
+ /**
113
+ * Reads and parses config.json. Only an absent file means every default,
114
+ * the implicit `local` target included, and a first run writes those
115
+ * defaults out. A file that exists but cannot be read or parsed loads as
116
+ * unusable: every default but the targets, which it leaves empty with the
117
+ * problem as the one target error, so nothing runs until the file is fixed.
118
+ * Never throws.
119
+ */
120
+ export function loadConfig(file: string = configFile): Config {
121
+ // Every atc process writes under the state directory; a failure here
122
+ // comes back from the first write into it.
123
+ try {
124
+ mkdirSync(stateDir, { recursive: true });
125
+ } catch {}
126
+
127
+ let text: string;
128
+
129
+ try {
130
+ text = readFileSync(file, 'utf8');
131
+ } catch (error) {
132
+ const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : undefined;
133
+
134
+ if (code !== 'ENOENT') {
135
+ const detail = typeof code === 'string' ? code : 'the file could not be read';
88
136
 
89
- const file = configFile;
137
+ return buildUnusableConfig('config_unreadable', file, detail);
138
+ }
90
139
 
91
- if (!existsSync(file)) {
92
- writeFileSync(file, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
140
+ tryWriteDefaultConfig(file);
93
141
 
94
142
  return { ...DEFAULTS };
95
143
  }
96
144
 
145
+ let raw: unknown;
146
+
97
147
  try {
98
- return parseConfig(JSON.parse(readFileSync(file, 'utf8')));
148
+ raw = JSON.parse(text);
99
149
  } catch {
100
- return { ...DEFAULTS };
150
+ // The parser's own message can quote the file's text, so the detail is
151
+ // a fixed phrase instead.
152
+ return buildUnusableConfig('config_malformed', file, 'the file is not valid JSON');
101
153
  }
154
+
155
+ return parseConfig(raw, file);
156
+ }
157
+
158
+ /**
159
+ * A config for a file that exists but cannot be used: every default but the
160
+ * targets, which are empty, with no default target and the file's problem
161
+ * as the one target error, and the principals, which get no target, since
162
+ * the file's own principals cannot be read.
163
+ */
164
+ function buildUnusableConfig(
165
+ problem: 'config_malformed' | 'config_unreadable',
166
+ path: string,
167
+ detail: string,
168
+ ): Config {
169
+ return {
170
+ ...DEFAULTS,
171
+ targets: [],
172
+ defaultTarget: null,
173
+ targetErrors: [{ scope: 'config', problem, path, detail }],
174
+ principals: new Map(),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Writes the first-run config, never over a file that appeared since the
180
+ * read. A failure leaves the defaults in effect for this run, so it is not
181
+ * an error.
182
+ */
183
+ function tryWriteDefaultConfig(file: string): void {
184
+ try {
185
+ mkdirSync(dirname(file), { recursive: true });
186
+ writeFileSync(file, renderDefaultConfig(), { flag: 'wx' });
187
+ } catch {}
188
+ }
189
+
190
+ /**
191
+ * The config.json text a first run writes. It leaves out the targets and
192
+ * principals, so the file holds the one implicit `local` target and no
193
+ * principals until the user sets their own, and the errors a parse
194
+ * reports, which belong to no file.
195
+ */
196
+ export function renderDefaultConfig(): string {
197
+ const {
198
+ targets: _targets,
199
+ defaultTarget: _default,
200
+ targetErrors: _errors,
201
+ principals: _principals,
202
+ principalErrors: _principalErrors,
203
+ ...written
204
+ } = DEFAULTS;
205
+
206
+ return `${JSON.stringify(written, null, 2)}\n`;
102
207
  }
103
208
 
104
209
  /**
105
210
  * Parses a user-written config.json's already-decoded JSON value into a
106
211
  * Config, applying every default a malformed or absent field falls back to.
107
- * Total: no shape of `raw` throws, so a hand-edited config never stops atc
108
- * starting.
212
+ * A root that is not an object leaves the config unusable, with no targets,
213
+ * and `file` is the path its error holds. Total: no shape of `raw` throws,
214
+ * so a hand-edited config never stops atc starting.
109
215
  */
110
- export function parseConfig(raw: unknown): Config {
216
+ export function parseConfig(raw: unknown, file: string = configFile): Config {
217
+ if (!isRecord(raw) || Array.isArray(raw)) {
218
+ return buildUnusableConfig(
219
+ 'config_malformed',
220
+ file,
221
+ `the root is ${formatJSONKind(raw)}, not an object`,
222
+ );
223
+ }
224
+
111
225
  const parsed = CONFIG_SCHEMA.safeParse(raw);
112
226
 
113
227
  if (!parsed.success) {
114
- return { ...DEFAULTS };
228
+ return buildUnusableConfig(
229
+ 'config_malformed',
230
+ file,
231
+ 'the file does not match the config schema',
232
+ );
115
233
  }
116
234
 
117
235
  const claudeBin = parsed.data.claudeBin ?? DEFAULTS.claudeBin;
@@ -123,6 +241,8 @@ export function parseConfig(raw: unknown): Config {
123
241
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
124
242
  const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
125
243
  const hooks = collectHooks(parsed.data.hooks);
244
+ const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
245
+ const principals = collectPrincipals(parsed.data.principals);
126
246
 
127
247
  const leader =
128
248
  (parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
@@ -138,6 +258,11 @@ export function parseConfig(raw: unknown): Config {
138
258
  gateways,
139
259
  hooks,
140
260
  leader,
261
+ targets: targets.targets,
262
+ defaultTarget: targets.defaultTarget,
263
+ targetErrors: targets.errors,
264
+ principals: principals.principals,
265
+ principalErrors: principals.errors,
141
266
  };
142
267
  }
143
268
 
@@ -0,0 +1,8 @@
1
+ import type { Tagged } from 'type-fest';
2
+
3
+ /**
4
+ * The id a daemon holds for its whole life, minted once into its state
5
+ * store. It names the daemon in `daemon.hello`, in every session locator,
6
+ * and in the ownership row of each session the daemon persists.
7
+ */
8
+ export type DaemonID = Tagged<string, 'DaemonID'>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The kind of a decoded JSON value, as an error detail: "null", "an array",
3
+ * "an object", "a string", "a number", or "a boolean". It never includes
4
+ * the value itself, so a config diagnostic built from it holds nothing the
5
+ * file holds.
6
+ */
7
+ export function formatJSONKind(raw: unknown): string {
8
+ if (raw === null) {
9
+ return 'null';
10
+ }
11
+
12
+ if (Array.isArray(raw)) {
13
+ return 'an array';
14
+ }
15
+
16
+ if (typeof raw === 'object') {
17
+ return 'an object';
18
+ }
19
+
20
+ return `a ${typeof raw}`;
21
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns a JSON value with every object's keys in sorted order, at every
3
+ * depth, so two values that differ only in key order serialize the same.
4
+ */
5
+ export function sortJSONKeys(value: unknown): unknown {
6
+ if (Array.isArray(value)) {
7
+ return value.map((item) => sortJSONKeys(item));
8
+ }
9
+
10
+ if (typeof value === 'object' && value !== null) {
11
+ return Object.fromEntries(
12
+ Object.entries(value)
13
+ .toSorted(([a], [b]) => a.localeCompare(b))
14
+ .map(([key, item]) => [key, sortJSONKeys(item)]),
15
+ );
16
+ }
17
+
18
+ return value;
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { DaemonID } from './daemon-id';
2
+
3
+ /**
4
+ * Trusts a string as a daemon id. This is the one point in the codebase
5
+ * where a plain string becomes a `DaemonID`, and it adds no runtime check:
6
+ * the caller asserts the string is the id a daemon minted for itself.
7
+ */
8
+ export function toDaemonID(id: string): DaemonID {
9
+ // oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a DaemonID
10
+ return id as DaemonID;
11
+ }
@@ -4,20 +4,28 @@ import type { AgentID } from '../agents/agent-adapter';
4
4
  import type { AgentSessionID } from '../shared/agent-session-id';
5
5
  import { buildOptionalBoolean } from '../shared/build-optional-boolean';
6
6
  import { buildOptionalString } from '../shared/build-optional-string';
7
+ import type { DaemonID } from '../shared/daemon-id';
7
8
  import { isRecord } from '../shared/report';
9
+ import type { SessionID } from '../shared/session-id';
8
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
9
11
 
12
+ // One fleet row. The atc session id keys it and stays the same for the
13
+ // session's whole life, across daemon restarts and fleet restores.
10
14
  export interface FleetEntry {
15
+ readonly sessionID: SessionID;
11
16
  readonly name: string;
12
17
  readonly cwd: string;
13
- readonly agentSessionID: AgentSessionID;
18
+
19
+ // Absent until the agent reports its own session id; a row without one
20
+ // restores as an exited session, since there is nothing to resume.
21
+ readonly agentSessionID?: AgentSessionID;
14
22
  readonly agent: AgentID;
15
23
  readonly pinned?: boolean;
16
24
  readonly lastAttachedAt?: number;
17
25
  readonly exited?: boolean;
18
26
 
19
- // The agent session id of the session this one is a sub-session of.
20
- readonly parent?: AgentSessionID;
27
+ // The atc session id of the session this one is a sub-session of.
28
+ readonly parent?: SessionID;
21
29
 
22
30
  // The prompt the session was spawned with.
23
31
  readonly prompt?: string;
@@ -27,15 +35,26 @@ export interface FleetEntry {
27
35
 
28
36
  // The transcript file the agent's hooks last reported for the session.
29
37
  readonly transcriptPath?: string;
38
+
39
+ // The model and effort the session was spawned with; absent for a session
40
+ // that runs on the agent's configured default.
41
+ readonly model?: string;
42
+ readonly effort?: string;
43
+
44
+ // The execution target the session runs on; a row without one restores
45
+ // on the `local` target.
46
+ readonly target?: string;
47
+
48
+ // The identity the target had when the session started on it; a row
49
+ // without one is bound to the implicit `local` target's identity.
50
+ readonly targetIdentity?: string;
30
51
  }
31
52
 
32
53
  export interface FleetStore {
54
+ readonly daemonID: DaemonID;
33
55
  readonly loadFleet: () => Promise<FleetEntry[]>;
34
56
  readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
35
- readonly updateFleetEntry: (
36
- agentSessionID: AgentSessionID,
37
- fields: FleetEntryUpdate,
38
- ) => Promise<void>;
57
+ readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
39
58
  }
40
59
 
41
60
  // The fields a session rewrites on its own row while it runs, without
@@ -45,7 +64,21 @@ export interface FleetEntryUpdate {
45
64
  readonly transcriptPath?: string;
46
65
  }
47
66
 
48
- // A stored fleet row's keys. name, cwd, and the resolved agentSessionID are
67
+ // One entry of a legacy fleet.json, from before the fleet moved into the
68
+ // state store: keyed by the agent session id, with the parent link held as
69
+ // the parent's agent session id.
70
+ export interface LegacyFleetEntry {
71
+ readonly name: string;
72
+ readonly cwd: string;
73
+ readonly agentSessionID: AgentSessionID;
74
+ readonly agent: AgentID;
75
+ readonly pinned?: boolean;
76
+ readonly lastAttachedAt?: number;
77
+ readonly exited?: boolean;
78
+ readonly parent?: AgentSessionID;
79
+ }
80
+
81
+ // A legacy fleet.json entry's keys. name, cwd, and the resolved agentSessionID are
49
82
  // required: a row missing any of them cannot restore a session, so the whole
50
83
  // row parses to undefined rather than a half-built entry. Fleet files
51
84
  // written before the id key was agent-neutral carry it under its Claude-era
@@ -71,7 +104,7 @@ const FLEET_ENTRY_SCHEMA = z.preprocess(
71
104
  }),
72
105
  );
73
106
 
74
- export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
107
+ export function parseFleetEntry(raw: unknown): LegacyFleetEntry | undefined {
75
108
  const parsed = FLEET_ENTRY_SCHEMA.safeParse(raw);
76
109
 
77
110
  if (!parsed.success) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where a keyed effect stands. `in_progress` holds while the daemon that
3
+ * claimed the key runs the effect; `completed` once the effect is durable;
4
+ * `outcome_unknown` when a daemon stopped mid-effect and its start-up
5
+ * reconciliation found no trace of the effect.
6
+ */
7
+ export type IdempotencyState = 'in_progress' | 'completed' | 'outcome_unknown';
8
+
9
+ // One idempotency key and the effect it names.
10
+ export interface IdempotencyRecord {
11
+ readonly principal: string;
12
+ readonly operation: string;
13
+ readonly key: string;
14
+ readonly payloadHash: string;
15
+ readonly state: IdempotencyState;
16
+
17
+ // The id the effect was minted before it ran: the session a spawn
18
+ // created, or the message a send wrote.
19
+ readonly effectRef: string;
20
+
21
+ // The effect's answer as JSON, once completed by the daemon that ran it.
22
+ readonly result: string | null;
23
+
24
+ // The target the completed effect's session was bound to, as it stood
25
+ // then; null for a key completed without one.
26
+ readonly effectTarget: EffectTarget | null;
27
+ readonly createdAt: number;
28
+ readonly updatedAt: number;
29
+ }
30
+
31
+ /**
32
+ * A target name and the identity it held, which a replay of the key is
33
+ * authorized against.
34
+ */
35
+ export interface EffectTarget {
36
+ readonly target: string;
37
+ readonly targetIdentity: string;
38
+ }
39
+
40
+ // The claim a keyed request makes before its effect runs.
41
+ export interface IdempotencyClaim {
42
+ readonly principal: string;
43
+ readonly operation: string;
44
+ readonly key: string;
45
+ readonly payloadHash: string;
46
+ readonly effectRef: string;
47
+ readonly at: number;
48
+ }
@@ -2,7 +2,7 @@ import type { AgentSessionID } from '../shared/agent-session-id';
2
2
  import type { SessionID } from '../shared/session-id';
3
3
 
4
4
  // Which session a message belongs to: the atc id it was sent to, or the
5
- // agent session id that survives a restore's re-minted atc id.
5
+ // agent session id, which links rows written under an earlier atc id.
6
6
  export interface MessageOwner {
7
7
  readonly atcID: SessionID;
8
8
  readonly agentSessionID?: AgentSessionID;
@@ -15,4 +15,7 @@ export interface MessageRecord {
15
15
  readonly deliveredAt?: number;
16
16
  readonly answeredAt?: number;
17
17
  readonly answer?: string;
18
+
19
+ // The turn whose final reply the answer is, when the reporter gave one.
20
+ readonly turn?: string;
18
21
  }