@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.
- package/README.md +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /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
|
+
}
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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,
|
|
@@ -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
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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' }),
|