@zgeoff/atc 2.12.0 → 2.14.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 (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -44,10 +44,10 @@ export function startHeadlessTurn(
44
44
  cwd: s.cwd,
45
45
  prompt,
46
46
  ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
47
- permissionMode: 'auto',
48
47
  sessionID,
49
48
  ...(s.model === undefined ? {} : { model: s.model }),
50
49
  ...(s.effort === undefined ? {} : { effort: s.effort }),
50
+ ...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
51
51
  },
52
52
  {
53
53
  onOutput: (text) => {
@@ -0,0 +1,294 @@
1
+ import { z } from 'zod';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import type { MessageID } from '../shared/message-id';
4
+ import type { SessionID } from '../shared/session-id';
5
+ import { toMessageID } from '../shared/to-message-id';
6
+ import type { MessageRecord } from '../store/message-record';
7
+ import type { TapClient } from './daemon-connection';
8
+ import type { HarnessRelay } from './execution-provider';
9
+ import type { HookEvent } from './hooks';
10
+ import { isBindingCurrent } from './is-binding-current';
11
+ import type { BridgeBinding } from './is-binding-current';
12
+ import { parseHookLine } from './parse-hook-line';
13
+
14
+ /**
15
+ * The fields of a live session the bridge checks its binding against and
16
+ * reads the status from.
17
+ */
18
+ export interface BridgeSession {
19
+ readonly id: SessionID;
20
+ readonly target: string;
21
+ readonly targetIdentity: string;
22
+ readonly hostKey: SessionID;
23
+ readonly bridgeEpoch: number;
24
+ readonly state: string;
25
+ readonly lastMsg: string;
26
+ }
27
+
28
+ /**
29
+ * What the bridge needs from the daemon. Each operation acts on the one
30
+ * session the caller names, which is always the bridge's own.
31
+ */
32
+ export interface BridgeContext {
33
+ readonly findSession: (sessionID: SessionID) => BridgeSession | undefined;
34
+ readonly applyHookEvent: (e: HookEvent) => void;
35
+
36
+ // Applies a report under the id its reporter gave it, and settles once
37
+ // the report is recorded; false when the payload is no report.
38
+ readonly applyReport: (
39
+ sessionID: SessionID,
40
+ payload: Readonly<Record<string, unknown>>,
41
+ reportID: string,
42
+ ) => Promise<boolean>;
43
+ readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
44
+ readonly ackMessage: (
45
+ client: TapClient,
46
+ sessionID: SessionID,
47
+ messageID: MessageID,
48
+ ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
49
+ readonly detachTap: (client: TapClient) => void;
50
+ }
51
+
52
+ const REQUEST_SCHEMA = z.looseObject({ v: z.literal(1), id: z.string().min(1), op: z.string() });
53
+
54
+ const REPORT_SCHEMA = z.looseObject({
55
+ reportID: z.string().min(1).max(128),
56
+ payload: z.record(z.string(), z.unknown()),
57
+ });
58
+
59
+ const ACK_SCHEMA = z.looseObject({ message: z.string().min(1) });
60
+
61
+ type BridgeRefusal = 'forbidden' | 'stale_binding' | 'not_tapping' | 'unknown_message';
62
+
63
+ /**
64
+ * Serves one connection a remote harness's processes opened to the daemon:
65
+ * the session bridge. A line without an `op` is a hook line, as the local
66
+ * reporter socket takes it. A request line `{ v: 1, id, op }` gets one
67
+ * answer line `{ id, ok }`, and the bridge's own inbox events go out as
68
+ * protocol event lines.
69
+ *
70
+ * The bridge takes hooks, reports, its session's tap and acks, and its
71
+ * session's own status, for the one session it was bound to and nothing
72
+ * else: an unknown op, a malformed request, or a report or hook for any
73
+ * other session answers `forbidden` and closes the connection. Every line
74
+ * checks the binding against the live session first, and one that no
75
+ * longer matches answers `stale_binding` and closes.
76
+ *
77
+ * Trust: the binding holds no secret. A host serves the bridge's socket
78
+ * inside itself alone, so reaching the socket is the proof of being inside
79
+ * that host. One host is one trust domain: a sub-session in its parent's
80
+ * host runs as the same user and can reach the parent's socket too. The
81
+ * per-session socket and binding stop processes in the host from crossing
82
+ * sessions by accident, never a hostile process inside the same host.
83
+ */
84
+ export function startSessionBridge(
85
+ relay: HarnessRelay,
86
+ binding: Readonly<BridgeBinding>,
87
+ ctx: BridgeContext,
88
+ ): void {
89
+ let closed = false;
90
+ let writing: Promise<void> = Promise.resolve();
91
+
92
+ // Lines go out one at a time, in the order they were written, and none
93
+ // after the bridge closed.
94
+ const writeLine = async (value: Readonly<Record<string, unknown>>) => {
95
+ const line = JSON.stringify(value);
96
+ const previous = writing;
97
+
98
+ writing = (async () => {
99
+ await previous;
100
+
101
+ if (!closed) {
102
+ try {
103
+ await relay.writeLine(line);
104
+ } catch {}
105
+ }
106
+ })();
107
+
108
+ await writing;
109
+ };
110
+
111
+ const stopBridge = () => {
112
+ if (closed) {
113
+ return;
114
+ }
115
+
116
+ closed = true;
117
+
118
+ ctx.detachTap(tap);
119
+ relay.close();
120
+ };
121
+
122
+ // Answers a refusal, then closes once the answer has gone out.
123
+ const answerRefusal = async (id: string | null, code: BridgeRefusal) => {
124
+ await writeLine({ id, ok: false, code });
125
+
126
+ stopBridge();
127
+ };
128
+
129
+ const tap: TapClient = {
130
+ sendEvent: (event: EventMsg) => {
131
+ void (async () => {
132
+ await writeLine(event);
133
+
134
+ if (event.ev === 'InboxClosed') {
135
+ stopBridge();
136
+ }
137
+ })();
138
+ },
139
+ };
140
+
141
+ const isCurrent = () => isBindingCurrent(binding, ctx.findSession(binding.sessionID));
142
+
143
+ const applyRequest = async (
144
+ request: Readonly<Record<string, unknown>>,
145
+ id: string,
146
+ op: string,
147
+ ) => {
148
+ if (op === 'report') {
149
+ const parsed = REPORT_SCHEMA.safeParse(request);
150
+
151
+ if (
152
+ !parsed.success ||
153
+ !(await ctx.applyReport(binding.sessionID, parsed.data.payload, parsed.data.reportID))
154
+ ) {
155
+ void answerRefusal(id, 'forbidden');
156
+
157
+ return;
158
+ }
159
+
160
+ void writeLine({ id, ok: true });
161
+
162
+ return;
163
+ }
164
+
165
+ if (op === 'tap.open') {
166
+ if (ctx.attachTap(tap, binding.sessionID) !== 'ok') {
167
+ void answerRefusal(id, 'forbidden');
168
+
169
+ return;
170
+ }
171
+
172
+ void writeLine({ id, ok: true });
173
+
174
+ return;
175
+ }
176
+
177
+ if (op === 'tap.ack') {
178
+ const parsed = ACK_SCHEMA.safeParse(request);
179
+
180
+ if (!parsed.success) {
181
+ void answerRefusal(id, 'forbidden');
182
+
183
+ return;
184
+ }
185
+
186
+ const acked = await ctx.ackMessage(tap, binding.sessionID, toMessageID(parsed.data.message));
187
+
188
+ if (acked === 'not_tapping' || acked === 'unknown') {
189
+ const code: BridgeRefusal = acked === 'unknown' ? 'unknown_message' : 'not_tapping';
190
+
191
+ void writeLine({ id, ok: false, code });
192
+
193
+ return;
194
+ }
195
+
196
+ void writeLine({ id, ok: true, status: acked.status });
197
+
198
+ return;
199
+ }
200
+
201
+ if (op === 'status.read') {
202
+ const s = ctx.findSession(binding.sessionID);
203
+
204
+ void writeLine({ id, ok: true, state: s?.state ?? 'exited', lastMsg: s?.lastMsg ?? '' });
205
+
206
+ return;
207
+ }
208
+
209
+ void answerRefusal(id, 'forbidden');
210
+ };
211
+
212
+ // Lines are applied one at a time, in the order they arrived.
213
+ let applying: Promise<void> = Promise.resolve();
214
+
215
+ const applyLine = async (line: string) => {
216
+ if (closed) {
217
+ return;
218
+ }
219
+
220
+ let parsed: unknown;
221
+
222
+ try {
223
+ parsed = JSON.parse(line);
224
+ } catch {
225
+ void answerRefusal(null, 'forbidden');
226
+
227
+ return;
228
+ }
229
+
230
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
231
+ void answerRefusal(null, 'forbidden');
232
+
233
+ return;
234
+ }
235
+
236
+ const record: Readonly<Record<string, unknown>> = Object.fromEntries(Object.entries(parsed));
237
+
238
+ if (!('op' in record)) {
239
+ const e = parseHookLine(line);
240
+
241
+ if (!isCurrent()) {
242
+ void answerRefusal(null, 'stale_binding');
243
+
244
+ return;
245
+ }
246
+
247
+ if (e === null || e.atcId !== binding.sessionID) {
248
+ void answerRefusal(null, 'forbidden');
249
+
250
+ return;
251
+ }
252
+
253
+ ctx.applyHookEvent(e);
254
+
255
+ return;
256
+ }
257
+
258
+ const request = REQUEST_SCHEMA.safeParse(record);
259
+
260
+ if (!request.success) {
261
+ const id = typeof record['id'] === 'string' ? record['id'] : null;
262
+
263
+ void answerRefusal(id, 'forbidden');
264
+
265
+ return;
266
+ }
267
+
268
+ if (!isCurrent()) {
269
+ void answerRefusal(request.data.id, 'stale_binding');
270
+
271
+ return;
272
+ }
273
+
274
+ await applyRequest(record, request.data.id, request.data.op);
275
+ };
276
+
277
+ relay.onLine((line) => {
278
+ const previous = applying;
279
+
280
+ applying = (async () => {
281
+ await previous;
282
+
283
+ try {
284
+ await applyLine(line);
285
+ } catch {}
286
+ })();
287
+ });
288
+
289
+ relay.onClose(() => {
290
+ closed = true;
291
+
292
+ ctx.detachTap(tap);
293
+ });
294
+ }
@@ -42,6 +42,9 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
42
42
  target: SPAWN_SCHEMA.shape.target.describe(
43
43
  'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
44
44
  ),
45
+ workspace: SPAWN_SCHEMA.shape.workspace.describe(
46
+ "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, refusing uncommitted changes unless allowDirty is 'warn'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
47
+ ),
45
48
  detached: z
46
49
  .boolean()
47
50
  .optional()
@@ -112,6 +115,13 @@ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
112
115
  { io: 'input' },
113
116
  );
114
117
 
118
+ const REPORT_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
119
+ z.strictObject({
120
+ report: z.string().describe("The cursor of the report's event, from atc_events_read"),
121
+ }),
122
+ { io: 'input' },
123
+ );
124
+
115
125
  // Output schemas leave further properties open, so a field the daemon adds
116
126
  // later never fails a client that validates results against them.
117
127
  const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
@@ -274,6 +284,20 @@ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
274
284
  required: ['events', 'cursor', 'more'],
275
285
  };
276
286
 
287
+ const REPORT_OUTPUT: Readonly<Record<string, unknown>> = {
288
+ type: 'object',
289
+ properties: {
290
+ report: { type: 'string' },
291
+ at: { type: 'number' },
292
+ session: { type: 'string' },
293
+ name: { type: ['string', 'null'] },
294
+ label: { type: 'string' },
295
+ text: { type: 'string' },
296
+ complete: { type: 'boolean' },
297
+ },
298
+ required: ['report', 'at', 'session', 'name', 'label', 'text', 'complete'],
299
+ };
300
+
277
301
  interface MCPToolAnnotations {
278
302
  readonly readOnlyHint: boolean;
279
303
  readonly destructiveHint: boolean;
@@ -353,6 +377,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
353
377
  effort: 'spawn.options',
354
378
  idempotencyKey: 'spawn.idempotency',
355
379
  target: 'spawn.target',
380
+ workspace: 'spawn.workspace',
356
381
  },
357
382
  },
358
383
  },
@@ -361,12 +386,15 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
361
386
  annotations: AGENT_FACING_DESTRUCTIVE,
362
387
  scope: 'spawn',
363
388
  description:
364
- 'Type a line of text into a running session, as if the operator typed it and pressed enter. Use it to answer a session that is waiting on input.',
389
+ "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
365
390
  inputSchema: {
366
391
  type: 'object',
367
392
  properties: {
368
393
  session: { type: 'string', description: 'The atc session id' },
369
- text: { type: 'string', description: 'The line to type; a newline is appended' },
394
+ text: {
395
+ type: 'string',
396
+ description: "The line to submit; atc adds the submit key the session's agent expects",
397
+ },
370
398
  },
371
399
  required: ['session', 'text'],
372
400
  additionalProperties: false,
@@ -457,11 +485,21 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
457
485
  annotations: READ_ONLY,
458
486
  scope: 'read',
459
487
  description:
460
- 'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
488
+ 'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the cursor of that event. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
461
489
  inputSchema: EVENTS_READ_INPUT,
462
490
  outputSchema: EVENTS_OUTPUT,
463
491
  requires: { output: 'events.more', inputs: { session: 'events.session' } },
464
492
  },
493
+ {
494
+ name: 'atc_report_get',
495
+ annotations: READ_ONLY,
496
+ scope: 'read',
497
+ description:
498
+ "Read one report's full text without messaging the session that sent it. Pass the cursor of the report's event from atc_events_read. Returns the report cursor, at, the session id and name, the label, the text (up to 64 KiB, as the session sent it), and complete, which is false for a report recorded before atc kept full texts: its text is then only the preview the event held. A cursor of an event that is not a report answers as an unknown report.",
499
+ inputSchema: REPORT_GET_INPUT,
500
+ outputSchema: REPORT_OUTPUT,
501
+ requires: { tool: 'report.get' },
502
+ },
465
503
  {
466
504
  name: 'atc_session_message',
467
505
  annotations: AGENT_FACING,
@@ -12,6 +12,7 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
12
12
  'dirs.list',
13
13
  'events.read',
14
14
  'message.get',
15
+ 'report.get',
15
16
  'session.get',
16
17
  'session.list',
17
18
  'session.read',
@@ -10,11 +10,15 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
10
10
  'message.idempotency': "atc_session_message's idempotencyKey",
11
11
  'message.turn': "atc_message_get's turn and answeredWith",
12
12
  'message.wait': "atc_message_get's waitMs",
13
+ 'session.forget': 'session.forget',
13
14
  'session.locator': "a session's locator",
15
+ 'session.submit': 'atc_session_input',
14
16
  'spawn.idempotency': "atc_session_spawn's idempotencyKey",
15
17
  'spawn.options': "atc_session_spawn's model and effort",
16
18
  'spawn.target': "atc_session_spawn's target",
19
+ 'report.get': 'atc_report_get',
17
20
  'request.principal': 'the target limits of a remote MCP client',
21
+ 'spawn.workspace': "atc_session_spawn's workspace",
18
22
  };
19
23
 
20
24
  /**
@@ -46,12 +46,13 @@ export function runTool(
46
46
  ...(args['effort'] === undefined ? {} : { effort: args['effort'] }),
47
47
  ...(key === undefined ? {} : { idempotencyKey: key }),
48
48
  ...(args['target'] === undefined ? {} : { target: args['target'] }),
49
+ ...(args['workspace'] === undefined ? {} : { workspace: args['workspace'] }),
49
50
  cols: 100,
50
51
  rows: 30,
51
52
  };
52
53
 
53
- // A model, effort, key, or target needs a daemon that takes them; the check
54
- // runs on every connection the spawn rides.
54
+ // A model, effort, key, target, or workspace needs a daemon that takes
55
+ // them; the check runs on every connection the spawn rides.
55
56
  const optionFeatures: readonly DaemonFeature[] =
56
57
  args['model'] === undefined && args['effort'] === undefined ? [] : ['spawn.options'];
57
58
 
@@ -60,20 +61,38 @@ export function runTool(
60
61
  const targetFeatures: readonly DaemonFeature[] =
61
62
  args['target'] === undefined ? [] : ['spawn.target'];
62
63
 
63
- const required = [...optionFeatures, ...keyFeatures, ...targetFeatures];
64
+ const workspaceFeatures: readonly DaemonFeature[] =
65
+ args['workspace'] === undefined ? [] : ['spawn.workspace'];
66
+
67
+ const required = [...optionFeatures, ...keyFeatures, ...targetFeatures, ...workspaceFeatures];
64
68
 
65
69
  const ok =
66
70
  nested && ctx.callerSessionID !== null
67
71
  ? await sendNestedSpawn(caller, params, ctx.callerSessionID, required)
68
72
  : await caller.sendRequest('session.spawn', params, required);
69
73
 
70
- return buildObjectResult(ok['session']);
74
+ // A spawn whose workspace left changes behind returns its warnings
75
+ // beside the session's own fields.
76
+ const warnings = ok['warnings'];
77
+
78
+ const session =
79
+ warnings === undefined || !isRecord(ok['session'])
80
+ ? ok['session']
81
+ : { ...ok['session'], warnings };
82
+
83
+ return buildObjectResult(session);
71
84
  })
72
85
  .with('atc_session_input', async () => {
73
- await caller.sendRequest('session.input', {
74
- session: args['session'],
75
- d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
76
- });
86
+ // An older daemon would take the line as raw input, which some agents
87
+ // never submit.
88
+ await caller.sendRequest(
89
+ 'session.submit',
90
+ {
91
+ session: args['session'],
92
+ text: typeof args['text'] === 'string' ? args['text'] : '',
93
+ },
94
+ ['session.submit'],
95
+ );
77
96
 
78
97
  return { text: 'sent', structured: null };
79
98
  })
@@ -153,6 +172,11 @@ export function runTool(
153
172
 
154
173
  return buildObjectResult(ok);
155
174
  })
175
+ .with('atc_report_get', async () => {
176
+ const ok = await caller.sendRequest('report.get', { report: args['report'] }, ['report.get']);
177
+
178
+ return buildObjectResult(ok);
179
+ })
156
180
  .with('atc_session_message', async () => {
157
181
  const given = args['from'];
158
182
 
@@ -42,6 +42,20 @@ export const DAEMON_FEATURES = [
42
42
  // A request takes `as`, the principal it acts as, and `daemon.hello`
43
43
  // takes `principal`, the principal the whole connection acts as.
44
44
  'request.principal',
45
+
46
+ // `session.spawn` takes `workspace`, and a session descriptor holds the
47
+ // `workspace` its checkout was materialized from.
48
+ 'spawn.workspace',
49
+
50
+ // `session.forget` exists, and a kill of a session asleep on a target that
51
+ // can destroy its host answers `confirmation_required`.
52
+ 'session.forget',
53
+
54
+ // `session.submit` exists.
55
+ 'session.submit',
56
+
57
+ // `report.get` exists.
58
+ 'report.get',
45
59
  ] as const;
46
60
 
47
61
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -26,6 +26,29 @@ const ERROR_CODES = [
26
26
  'target_changed',
27
27
  'target_config_invalid',
28
28
  'target_forbidden',
29
+ 'not_a_git_repo',
30
+ 'no_commits',
31
+ 'unreadable_tree',
32
+ 'has_submodules',
33
+ 'lfs_unsupported',
34
+ 'workspace_dirty',
35
+ 'no_origin',
36
+ 'invalid_git_url',
37
+ 'unpushed_head',
38
+ 'credential_in_url',
39
+ 'credential_missing',
40
+ 'ref_not_found',
41
+ 'clone_failed',
42
+ 'sanitize_failed',
43
+ 'tar_failed',
44
+ 'workspace_exists',
45
+ 'transfer_failed',
46
+ 'workspace_mismatch',
47
+ 'host_unavailable',
48
+ 'auth_not_configured',
49
+ 'host_leased',
50
+ 'confirmation_required',
51
+ 'confirm_token_invalid',
29
52
  'already_answered',
30
53
  'too_slow',
31
54
  'stale_epoch',
@@ -23,6 +23,55 @@ const IDEMPOTENCY_KEY = z
23
23
  .max(200, 'idempotencyKey must be at most 200 characters')
24
24
  .optional();
25
25
 
26
+ // A full commit id, SHA-1 or SHA-256.
27
+ const COMMIT_ID = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
28
+
29
+ // The daemon environment variable a git workspace's token is read from.
30
+ const CREDENTIAL_REF = z.strictObject({
31
+ kind: z.literal('env'),
32
+ name: z.string().regex(/^[A-Za-z_]\w*$/u, 'a credentialRef names an environment variable'),
33
+ });
34
+
35
+ /**
36
+ * Where a spawn's working directory comes from, materialized as a clean
37
+ * checkout into the spawn's `cwd` on its execution target. A `path` source
38
+ * is a directory on the daemon's host, resolved to its origin URL and
39
+ * pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
40
+ * changes to HEAD and leaves the changes behind with a warning. A `git`
41
+ * source is a repository URL with exactly one of a branch or tag `ref` or
42
+ * a full commit `sha`, and an optional `credentialRef` naming the daemon
43
+ * environment variable that holds its token.
44
+ */
45
+ const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
46
+ z.strictObject({
47
+ kind: z.literal('path'),
48
+ path: z
49
+ .string({ error: 'a path workspace requires an absolute path' })
50
+ .startsWith('/', 'a path workspace requires an absolute path'),
51
+ allowDirty: z.enum(['refuse', 'warn']).optional(),
52
+ }),
53
+ z
54
+ .strictObject({
55
+ kind: z.literal('git'),
56
+ url: z
57
+ .string({ error: 'a git workspace requires a url' })
58
+ .min(1, 'a git workspace requires a url')
59
+ .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -'),
60
+ ref: z
61
+ .string()
62
+ .min(1, 'a git workspace ref must not be empty')
63
+ .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -')
64
+ .optional(),
65
+ sha: z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id').optional(),
66
+ credentialRef: CREDENTIAL_REF.optional(),
67
+ })
68
+ .refine((source) => (source.ref === undefined) !== (source.sha === undefined), {
69
+ message: 'a git workspace takes exactly one of ref or sha',
70
+ }),
71
+ ]);
72
+
73
+ export type SpawnWorkspaceSource = z.infer<typeof WORKSPACE_SOURCE>;
74
+
26
75
  // The refusal of a terminal size outside the range a terminal takes.
27
76
  const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
28
77
 
@@ -75,6 +124,11 @@ export const REQUEST_PARAM_SCHEMAS = {
75
124
  .min(1, 'session.spawn target must be a non-empty target id')
76
125
  .optional(),
77
126
 
127
+ // Where the session's working directory comes from: absent runs the
128
+ // session in cwd as it stands, and a source materializes a clean
129
+ // checkout into cwd first.
130
+ workspace: WORKSPACE_SOURCE.optional(),
131
+
78
132
  // The session the new one is a sub-session of; absent or empty spawns a
79
133
  // top-level session.
80
134
  parent: z.preprocess(
@@ -84,6 +138,15 @@ export const REQUEST_PARAM_SCHEMAS = {
84
138
  }),
85
139
  'session.kill': SESSION_DEFAULTED,
86
140
  'session.ack': SESSION_DEFAULTED,
141
+
142
+ // Without a token, a forget that destroys a host answers with one; the
143
+ // forget that carries it destroys the host.
144
+ 'session.forget': SESSION_DEFAULTED.extend({
145
+ confirmToken: z
146
+ .string({ error: 'session.forget confirmToken must be a string' })
147
+ .min(1, 'session.forget confirmToken must not be empty')
148
+ .optional(),
149
+ }),
87
150
  'session.update': SESSION_DEFAULTED.extend({
88
151
  name: buildOptionalString(),
89
152
  pinned: buildOptionalBoolean(),
@@ -96,6 +159,9 @@ export const REQUEST_PARAM_SCHEMAS = {
96
159
  'session.input': SESSION_DEFAULTED.extend({
97
160
  d: buildDefaultedString(''),
98
161
  }),
162
+ 'session.submit': SESSION_DEFAULTED.extend({
163
+ text: buildDefaultedString(''),
164
+ }),
99
165
  'session.resize': SESSION_DEFAULTED.extend({
100
166
  cols: buildTerminalSize(0),
101
167
  rows: buildTerminalSize(0),
@@ -150,6 +216,12 @@ export const REQUEST_PARAM_SCHEMAS = {
150
216
  waitMs: buildDefaultedWait(),
151
217
  })
152
218
  .refine((v) => v.message !== '', { message: 'message.get requires a message' }),
219
+ 'report.get': z
220
+ .object({
221
+ // The cursor events.read returned with the report's event.
222
+ report: buildDefaultedString(''),
223
+ })
224
+ .refine((v) => v.report !== '', { message: 'report.get requires a report' }),
153
225
  'message.ack': SESSION_DEFAULTED.extend({
154
226
  message: buildDefaultedString('').transform(toMessageID),
155
227
  }).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),