@zgeoff/atc 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -10
  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/index.ts +50 -1
  23. package/src/client/ui.ts +8 -0
  24. package/src/daemon/build-execution-targets.ts +49 -10
  25. package/src/daemon/build-imp-provider.ts +68 -0
  26. package/src/daemon/build-report-trail-entry.ts +4 -2
  27. package/src/daemon/build-scoped-context.ts +4 -1
  28. package/src/daemon/build-session-lifecycle.ts +52 -0
  29. package/src/daemon/build-tar-archive.ts +85 -0
  30. package/src/daemon/daemon-connection.ts +97 -4
  31. package/src/daemon/daemon.ts +298 -41
  32. package/src/daemon/execution-provider.ts +109 -4
  33. package/src/daemon/hooks.ts +5 -17
  34. package/src/daemon/imp-client-port.ts +343 -0
  35. package/src/daemon/imp-harness.ts +618 -0
  36. package/src/daemon/imp-port-error.ts +18 -0
  37. package/src/daemon/imp-port.ts +246 -0
  38. package/src/daemon/imp-provider.ts +601 -0
  39. package/src/daemon/is-binding-current.ts +43 -0
  40. package/src/daemon/local-pty-provider.ts +62 -3
  41. package/src/daemon/materialize-workspace.ts +571 -0
  42. package/src/daemon/parse-hook-line.ts +31 -0
  43. package/src/daemon/pick-session-state.ts +15 -0
  44. package/src/daemon/restore-fleet.ts +41 -23
  45. package/src/daemon/screen-model.ts +7 -0
  46. package/src/daemon/sessions.ts +574 -61
  47. package/src/daemon/start-headless-turn.ts +1 -1
  48. package/src/daemon/start-session-bridge.ts +294 -0
  49. package/src/mcp/mcp-tools.ts +9 -2
  50. package/src/mcp/require-daemon-features.ts +3 -0
  51. package/src/mcp/run-tool.ts +27 -8
  52. package/src/protocol/daemon-features.ts +11 -0
  53. package/src/protocol/protocol.ts +23 -0
  54. package/src/protocol/request-param-schemas.ts +66 -0
  55. package/src/report.ts +37 -2
  56. package/src/run-bridge-tap.ts +241 -0
  57. package/src/shared/collect-clean-env.ts +9 -1
  58. package/src/shared/open-bridge-socket.ts +86 -0
  59. package/src/shared/send-bridge-request.ts +47 -0
  60. package/src/statusline.ts +34 -3
  61. package/src/store/fleet-entry.ts +19 -0
  62. package/src/store/run-migrations.ts +76 -0
  63. package/src/store/state-store.ts +182 -21
  64. package/src/store/trail-entry.ts +4 -0
  65. package/src/store/workspace-materialization.ts +70 -0
  66. package/src/tap.ts +11 -0
  67. package/src/workspace/check-url-credentials.ts +59 -0
  68. package/src/workspace/create-workspace-clone.ts +13 -1
  69. package/src/workspace/repository-env-vars.ts +22 -0
  70. package/src/workspace/run-git.ts +2 -18
  71. /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()
@@ -353,6 +356,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
353
356
  effort: 'spawn.options',
354
357
  idempotencyKey: 'spawn.idempotency',
355
358
  target: 'spawn.target',
359
+ workspace: 'spawn.workspace',
356
360
  },
357
361
  },
358
362
  },
@@ -361,12 +365,15 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
361
365
  annotations: AGENT_FACING_DESTRUCTIVE,
362
366
  scope: 'spawn',
363
367
  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.',
368
+ "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
369
  inputSchema: {
366
370
  type: 'object',
367
371
  properties: {
368
372
  session: { type: 'string', description: 'The atc session id' },
369
- text: { type: 'string', description: 'The line to type; a newline is appended' },
373
+ text: {
374
+ type: 'string',
375
+ description: "The line to submit; atc adds the submit key the session's agent expects",
376
+ },
370
377
  },
371
378
  required: ['session', 'text'],
372
379
  additionalProperties: false,
@@ -10,11 +10,14 @@ 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",
17
19
  'request.principal': 'the target limits of a remote MCP client',
20
+ 'spawn.workspace': "atc_session_spawn's workspace",
18
21
  };
19
22
 
20
23
  /**
@@ -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
  })
@@ -42,6 +42,17 @@ 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',
45
56
  ] as const;
46
57
 
47
58
  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),
package/src/report.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
1
4
  import { sendReport } from './shared/report';
2
5
  import { REPORT_KINDS } from './shared/report-kinds';
6
+ import { sendBridgeRequest } from './shared/send-bridge-request';
3
7
 
4
8
  interface ReportOptions {
5
9
  readonly message: string;
@@ -19,7 +23,8 @@ interface ReportOptions {
19
23
  * message, or every given message at once, plus that turn's id when one is
20
24
  * given; a `note` carries
21
25
  * it as text for the user under the given label, `progress` when none is
22
- * given. Always exits 0 so it never blocks the session it reports on.
26
+ * given. Inside a remote host it goes to the session bridge instead. Always
27
+ * exits 0 so it never blocks the session it reports on.
23
28
  */
24
29
  export async function runReport(kind: string, options: ReportOptions): Promise<void> {
25
30
  try {
@@ -37,7 +42,9 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
37
42
 
38
43
  const payload = buildReportPayload(kind, options, stdin);
39
44
 
40
- if (payload !== null) {
45
+ if (payload !== null && process.env['ATC_BRIDGE'] === '1') {
46
+ await sendBridgeReport(sock, process.env['ATC_OUTBOX'] ?? '', payload);
47
+ } else if (payload !== null) {
41
48
  const line = `${JSON.stringify({ atcId, event: 'Report', payload })}\n`;
42
49
 
43
50
  await sendReport(sock, line, 2000);
@@ -48,6 +55,34 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
48
55
  process.exit(0);
49
56
  }
50
57
 
58
+ // Inside a remote host, a report goes to the session bridge under an id of
59
+ // its own, and waits in the outbox until the bridge takes or refuses it, so
60
+ // the session's tap sends it again after a dropped connection. A resent
61
+ // report lands once, and a refused one is never resent.
62
+ async function sendBridgeReport(
63
+ sock: string,
64
+ outbox: string,
65
+ payload: Readonly<Record<string, string | readonly string[]>>,
66
+ ): Promise<void> {
67
+ const reportID = randomUUID();
68
+ const file = outbox === '' ? null : join(outbox, `${reportID}.json`);
69
+
70
+ if (file !== null) {
71
+ try {
72
+ mkdirSync(outbox, { recursive: true });
73
+ writeFileSync(file, JSON.stringify({ reportID, payload }));
74
+ } catch {}
75
+ }
76
+
77
+ const answer = await sendBridgeRequest(sock, 'report', { reportID, payload }, 2000);
78
+
79
+ const isFinal = answer?.['ok'] === true || answer?.['code'] === 'forbidden';
80
+
81
+ if (isFinal && file !== null) {
82
+ rmSync(file, { force: true });
83
+ }
84
+ }
85
+
51
86
  function buildReportPayload(
52
87
  kind: string,
53
88
  options: ReportOptions,