@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +371 -99
- package/src/daemon/daemon.ts +537 -90
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +287 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +40 -6
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import type { IPty } from 'bun-pty';
|
|
4
|
-
import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
|
|
2
|
+
import type { AdapterEvent, AgentAdapter, AgentID, SpawnOverrides } from '../agents/agent-adapter';
|
|
5
3
|
import { truncateDetail } from '../agents/truncate-detail';
|
|
4
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
5
|
+
import type { ErrorCode } from '../protocol/protocol';
|
|
6
6
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
7
7
|
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
8
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
8
9
|
import { socketPath, statusFile } from '../shared/config';
|
|
10
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
9
11
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
10
12
|
import type { SessionID } from '../shared/session-id';
|
|
11
13
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
12
|
-
import type { FleetEntry, FleetStore } from '../store/fleet-entry';
|
|
14
|
+
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
15
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
16
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
17
|
+
import type { ExecutionCapability, ExecutionProvider, HarnessHandle } from './execution-provider';
|
|
18
|
+
import { findExecutionRefusal } from './find-execution-refusal';
|
|
13
19
|
import type { HookEvent } from './hooks';
|
|
20
|
+
import { LocalPTYProvider } from './local-pty-provider';
|
|
14
21
|
import { mintSessionID } from './mint-session-id';
|
|
15
22
|
|
|
16
23
|
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
17
24
|
|
|
18
25
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
19
26
|
|
|
20
|
-
// The over-the-wire view of a session: everything but the
|
|
21
|
-
// the surface kind.
|
|
27
|
+
// The over-the-wire view of a session: everything but the harness handle,
|
|
28
|
+
// plus the surface kind.
|
|
22
29
|
export interface SessionDescriptor {
|
|
23
30
|
readonly id: SessionID;
|
|
24
31
|
readonly name: string;
|
|
@@ -44,6 +51,15 @@ export interface SessionDescriptor {
|
|
|
44
51
|
// The session this one is a sub-session of, when it has one: it lists
|
|
45
52
|
// under that session and takes its pin from it.
|
|
46
53
|
readonly parent?: SessionID;
|
|
54
|
+
|
|
55
|
+
// Where the session runs: the daemon hosting it, and the execution target
|
|
56
|
+
// on that daemon's host.
|
|
57
|
+
readonly locator: SessionLocator;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface SessionLocator {
|
|
61
|
+
readonly daemonID: DaemonID;
|
|
62
|
+
readonly targetID: string;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
export interface Session {
|
|
@@ -51,7 +67,10 @@ export interface Session {
|
|
|
51
67
|
name: string;
|
|
52
68
|
cwd: string;
|
|
53
69
|
kind: 'pty' | 'headless';
|
|
54
|
-
|
|
70
|
+
|
|
71
|
+
// The running harness its execution provider handed back, or null when
|
|
72
|
+
// the session has no terminal.
|
|
73
|
+
pty: HarnessHandle | null;
|
|
55
74
|
state: SessionState;
|
|
56
75
|
unread: boolean;
|
|
57
76
|
lastMsg: string;
|
|
@@ -87,6 +106,28 @@ export interface Session {
|
|
|
87
106
|
// the transcript file the agent's hooks last reported. Kept apart from the
|
|
88
107
|
// resume check so a restored path never changes whether a revive is allowed.
|
|
89
108
|
transcriptPath?: string;
|
|
109
|
+
|
|
110
|
+
// the model and effort the session was spawned with; absent leaves the
|
|
111
|
+
// agent's configured default, and every revive passes them again.
|
|
112
|
+
model?: string;
|
|
113
|
+
effort?: string;
|
|
114
|
+
|
|
115
|
+
// the execution target the session runs on, and the identity it had
|
|
116
|
+
// when the session started there; every revive runs there too, and only
|
|
117
|
+
// while the target keeps that identity.
|
|
118
|
+
target: string;
|
|
119
|
+
targetIdentity: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The identity of the implicit `local` target, which a fleet row without a
|
|
123
|
+
// stored identity ran on.
|
|
124
|
+
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
125
|
+
|
|
126
|
+
// The target a session runs on and the identity it is bound to there; null
|
|
127
|
+
// binds a new session to the target as it stands now.
|
|
128
|
+
interface TargetBinding {
|
|
129
|
+
readonly target: string;
|
|
130
|
+
readonly targetIdentity: string | null;
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
export class SessionManager {
|
|
@@ -100,6 +141,12 @@ export class SessionManager {
|
|
|
100
141
|
|
|
101
142
|
onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
|
|
102
143
|
|
|
144
|
+
// Where a background failure is reported, one line at a time: stderr,
|
|
145
|
+
// like the daemon's other background failures.
|
|
146
|
+
log: (line: string) => void = (line) => {
|
|
147
|
+
console.error(line);
|
|
148
|
+
};
|
|
149
|
+
|
|
103
150
|
// Whether any registered adapter has a screen detector, decided once at
|
|
104
151
|
// construction since the registry never changes afterward. Lets a hot path
|
|
105
152
|
// that only cares about this answer skip walking live sessions to find it.
|
|
@@ -111,11 +158,25 @@ export class SessionManager {
|
|
|
111
158
|
|
|
112
159
|
private readonly statusPath: string;
|
|
113
160
|
|
|
161
|
+
private readonly targets: ReadonlyMap<string, ExecutionTarget>;
|
|
162
|
+
|
|
163
|
+
private readonly targetErrors: readonly TargetConfigError[];
|
|
164
|
+
|
|
114
165
|
constructor(
|
|
115
166
|
fallback: AgentAdapter,
|
|
116
167
|
store: FleetStore,
|
|
117
168
|
statusPath: string | undefined = statusFile,
|
|
118
169
|
adapters: readonly AgentAdapter[] = [],
|
|
170
|
+
targets: readonly ExecutionTarget[] = [
|
|
171
|
+
{
|
|
172
|
+
id: 'local',
|
|
173
|
+
kind: 'local-pty',
|
|
174
|
+
options: {},
|
|
175
|
+
identity: LOCAL_TARGET_IDENTITY,
|
|
176
|
+
provider: new LocalPTYProvider(),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
targetErrors: readonly TargetConfigError[] = [],
|
|
119
180
|
) {
|
|
120
181
|
// Each adapter names the id it answers to, so a registry key can never
|
|
121
182
|
// disagree with the adapter behind it. A later one wins the id.
|
|
@@ -123,6 +184,51 @@ export class SessionManager {
|
|
|
123
184
|
this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
|
|
124
185
|
this.store = store;
|
|
125
186
|
this.statusPath = statusPath ?? statusFile;
|
|
187
|
+
|
|
188
|
+
this.targets = new Map(targets.map((t) => [t.id, t]));
|
|
189
|
+
|
|
190
|
+
this.targetErrors = targetErrors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The provider a binding's work runs on, after every target check: throws
|
|
195
|
+
* the refusal for a target the config leaves unusable, one that is gone,
|
|
196
|
+
* one whose identity is not the binding's, one with no provider here, and
|
|
197
|
+
* a provider that lacks the capability. Returns the identity a new session
|
|
198
|
+
* binds to as well.
|
|
199
|
+
*/
|
|
200
|
+
requireExecution(
|
|
201
|
+
binding: Readonly<TargetBinding>,
|
|
202
|
+
capability: ExecutionCapability,
|
|
203
|
+
): { readonly provider: ExecutionProvider; readonly identity: string } {
|
|
204
|
+
const refusal = this.findExecutionRefusal(binding, capability);
|
|
205
|
+
const target = this.targets.get(binding.target);
|
|
206
|
+
|
|
207
|
+
if (refusal !== null) {
|
|
208
|
+
throw refusal;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (target === undefined || target.provider === null) {
|
|
212
|
+
throw new Error(`execution target '${binding.target}' passed its checks without a provider`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { provider: target.provider, identity: target.identity };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The refusal for running work of a capability on a binding's target, or
|
|
219
|
+
// null when the target serves it. A null target is a spawn that names
|
|
220
|
+
// none when the config gives no default, which is always refused.
|
|
221
|
+
findExecutionRefusal(
|
|
222
|
+
binding: Readonly<{ target: string | null; targetIdentity: string | null }>,
|
|
223
|
+
capability: ExecutionCapability,
|
|
224
|
+
): DaemonError | null {
|
|
225
|
+
return findExecutionRefusal(this.targets, this.targetErrors, binding, capability);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The provider a session's harness runs on, or null when its target is
|
|
229
|
+
// gone from the config or has no provider here.
|
|
230
|
+
findProvider(s: Session): ExecutionProvider | null {
|
|
231
|
+
return this.targets.get(s.target)?.provider ?? null;
|
|
126
232
|
}
|
|
127
233
|
|
|
128
234
|
/**
|
|
@@ -134,6 +240,11 @@ export class SessionManager {
|
|
|
134
240
|
return this.adapters[id] ?? null;
|
|
135
241
|
}
|
|
136
242
|
|
|
243
|
+
// Every registered adapter, one per id, in registration order.
|
|
244
|
+
collectAdapters(): AgentAdapter[] {
|
|
245
|
+
return Object.values(this.adapters);
|
|
246
|
+
}
|
|
247
|
+
|
|
137
248
|
// Hands a live terminal session off to a headless run: the terminal dies,
|
|
138
249
|
// the record lives on as a headless session and keeps its screen history.
|
|
139
250
|
yankHeadless(id: SessionID): Session | null {
|
|
@@ -157,26 +268,42 @@ export class SessionManager {
|
|
|
157
268
|
return s;
|
|
158
269
|
}
|
|
159
270
|
|
|
160
|
-
// Registers a fleet entry as a session with no terminal yet,
|
|
161
|
-
// fleet-wide restore can show every
|
|
162
|
-
//
|
|
163
|
-
// sessions: still listed and revivable,
|
|
271
|
+
// Registers a fleet entry as a session with no terminal yet, under the
|
|
272
|
+
// atc session id its row holds, so a fleet-wide restore can show every
|
|
273
|
+
// incoming session at once; adopting it later attaches the terminal.
|
|
274
|
+
// Exited entries come back as killed sessions: still listed and revivable,
|
|
275
|
+
// never auto-adopted. An entry without an agent session id has nothing to
|
|
276
|
+
// resume, so it comes back exited too.
|
|
164
277
|
restore(entry: FleetEntry): Session {
|
|
165
|
-
const
|
|
278
|
+
const target = entry.target ?? 'local';
|
|
279
|
+
const targetIdentity = entry.targetIdentity ?? LOCAL_TARGET_IDENTITY;
|
|
280
|
+
const refusal = this.findExecutionRefusal({ target, targetIdentity }, 'spawn');
|
|
281
|
+
const targetRefusal = refusal === null ? null : formatTargetRefusal(refusal.code, target);
|
|
282
|
+
|
|
283
|
+
// A session whose target this daemon cannot use comes back exited, so
|
|
284
|
+
// nothing runs it anywhere else: neither a terminal nor a headless turn.
|
|
285
|
+
const exited =
|
|
286
|
+
entry.exited === true || entry.agentSessionID === undefined || targetRefusal !== null;
|
|
166
287
|
|
|
167
288
|
// An entry whose agent is no longer registered still gets its row, so a
|
|
168
289
|
// backend dropped from the config shows as itself instead of vanishing or
|
|
169
290
|
// reviving under another agent. Adopting a terminal for it is refused.
|
|
170
291
|
let lastMsg = 'waiting to restore';
|
|
171
292
|
|
|
172
|
-
if (exited) {
|
|
293
|
+
if (entry.exited !== true && entry.agentSessionID === undefined) {
|
|
294
|
+
lastMsg = 'nothing to resume';
|
|
295
|
+
} else if (entry.exited !== true && targetRefusal !== null) {
|
|
296
|
+
lastMsg = targetRefusal;
|
|
297
|
+
} else if (exited) {
|
|
173
298
|
lastMsg = 'killed';
|
|
174
299
|
} else if (this.findAdapter(entry.agent) === null) {
|
|
175
300
|
lastMsg = `no adapter for '${entry.agent}'`;
|
|
176
301
|
}
|
|
177
302
|
|
|
303
|
+
const parent = entry.parent ?? null;
|
|
304
|
+
|
|
178
305
|
const session: Session = {
|
|
179
|
-
id:
|
|
306
|
+
id: entry.sessionID,
|
|
180
307
|
name: entry.name,
|
|
181
308
|
cwd: entry.cwd,
|
|
182
309
|
kind: 'headless',
|
|
@@ -184,17 +311,21 @@ export class SessionManager {
|
|
|
184
311
|
state: exited ? 'exited' : 'running',
|
|
185
312
|
unread: false,
|
|
186
313
|
lastMsg,
|
|
187
|
-
agentSessionID: entry.agentSessionID,
|
|
314
|
+
...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
|
|
188
315
|
agent: entry.agent,
|
|
189
316
|
pinned: entry.pinned ?? false,
|
|
190
317
|
lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
|
|
191
318
|
repoRoot: resolveRepoRoot(entry.cwd),
|
|
192
319
|
namedBy: 'auto',
|
|
193
320
|
createdAt: Date.now(),
|
|
194
|
-
parent: this.
|
|
321
|
+
parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
|
|
195
322
|
...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
|
|
196
323
|
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
197
324
|
...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
|
|
325
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
326
|
+
...(entry.effort === undefined ? {} : { effort: entry.effort }),
|
|
327
|
+
target,
|
|
328
|
+
targetIdentity,
|
|
198
329
|
};
|
|
199
330
|
|
|
200
331
|
this.sessions.push(session);
|
|
@@ -204,14 +335,6 @@ export class SessionManager {
|
|
|
204
335
|
return session;
|
|
205
336
|
}
|
|
206
337
|
|
|
207
|
-
private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
|
|
208
|
-
if (agentSessionID === undefined) {
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return this.sessions.find((s) => s.agentSessionID === agentSessionID);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
338
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
216
339
|
// same agent session id.
|
|
217
340
|
adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
|
|
@@ -227,14 +350,22 @@ export class SessionManager {
|
|
|
227
350
|
return null;
|
|
228
351
|
}
|
|
229
352
|
|
|
230
|
-
const
|
|
353
|
+
const provider = this.requireExecution(s, 'spawn').provider;
|
|
231
354
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
355
|
+
const plan = adapter.planSpawn({
|
|
356
|
+
prompt: '',
|
|
357
|
+
resume: s.agentSessionID,
|
|
358
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
359
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const pty = provider.spawnHarness({
|
|
363
|
+
bin: plan.bin,
|
|
364
|
+
args: plan.args,
|
|
236
365
|
cwd: s.cwd,
|
|
237
366
|
env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
|
|
367
|
+
cols,
|
|
368
|
+
rows,
|
|
238
369
|
});
|
|
239
370
|
|
|
240
371
|
s.pty = pty;
|
|
@@ -259,7 +390,7 @@ export class SessionManager {
|
|
|
259
390
|
this.emitChange();
|
|
260
391
|
});
|
|
261
392
|
|
|
262
|
-
void this.
|
|
393
|
+
void this.tryWriteFleet(s.id);
|
|
263
394
|
this.onEvent('state', s);
|
|
264
395
|
this.emitChange();
|
|
265
396
|
|
|
@@ -296,7 +427,7 @@ export class SessionManager {
|
|
|
296
427
|
this.onEvent('state', s);
|
|
297
428
|
}
|
|
298
429
|
|
|
299
|
-
void this.
|
|
430
|
+
void this.tryWriteFleet(s.id);
|
|
300
431
|
this.writeStatus();
|
|
301
432
|
this.emitChange();
|
|
302
433
|
|
|
@@ -319,10 +450,7 @@ export class SessionManager {
|
|
|
319
450
|
if (result !== undefined) {
|
|
320
451
|
s.result = truncateToBytes(result, 16_384);
|
|
321
452
|
s.lastDetail = truncateDetail(result);
|
|
322
|
-
|
|
323
|
-
if (s.agentSessionID !== undefined) {
|
|
324
|
-
void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
|
|
325
|
-
}
|
|
453
|
+
void this.tryUpdateFleetEntry(s.id, { result: s.result });
|
|
326
454
|
}
|
|
327
455
|
|
|
328
456
|
this.onEvent('state', s);
|
|
@@ -359,7 +487,11 @@ export class SessionManager {
|
|
|
359
487
|
|
|
360
488
|
// resume: true opens the agent's own session picker; an agent session id
|
|
361
489
|
// resumes that specific session (fleet restore). parent makes the new
|
|
362
|
-
// session a sub-session of that one.
|
|
490
|
+
// session a sub-session of that one. overrides hold the model and effort
|
|
491
|
+
// the new process runs with, and the session keeps them for every revive.
|
|
492
|
+
// id is minted here unless the caller minted it ahead of the spawn. target
|
|
493
|
+
// is the execution target the harness runs on; one this daemon cannot use
|
|
494
|
+
// refuses the spawn before anything starts.
|
|
363
495
|
spawn(
|
|
364
496
|
cwd: string,
|
|
365
497
|
name: string,
|
|
@@ -370,6 +502,9 @@ export class SessionManager {
|
|
|
370
502
|
namedBy: 'user' | 'auto' = 'auto',
|
|
371
503
|
agent: AgentID = 'claude',
|
|
372
504
|
parent: SessionID | null = null,
|
|
505
|
+
overrides: SpawnOverrides = {},
|
|
506
|
+
id: SessionID = mintSessionID(),
|
|
507
|
+
target = 'local',
|
|
373
508
|
): Session {
|
|
374
509
|
const adapter = this.findAdapter(agent);
|
|
375
510
|
|
|
@@ -377,15 +512,21 @@ export class SessionManager {
|
|
|
377
512
|
throw new Error(`no adapter for agent '${agent}'`);
|
|
378
513
|
}
|
|
379
514
|
|
|
380
|
-
const
|
|
381
|
-
const
|
|
515
|
+
const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
|
|
516
|
+
const provider = execution.provider;
|
|
382
517
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
518
|
+
// The repository root resolves before the process starts: resolving it
|
|
519
|
+
// can throw, and a spawn that throws must leave nothing running.
|
|
520
|
+
const repoRoot = resolveRepoRoot(cwd);
|
|
521
|
+
const plan = adapter.planSpawn({ prompt, resume, ...overrides });
|
|
522
|
+
|
|
523
|
+
const pty = provider.spawnHarness({
|
|
524
|
+
bin: plan.bin,
|
|
525
|
+
args: plan.args,
|
|
387
526
|
cwd,
|
|
388
527
|
env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
|
|
528
|
+
cols,
|
|
529
|
+
rows,
|
|
389
530
|
});
|
|
390
531
|
|
|
391
532
|
let initialMsg = prompt;
|
|
@@ -407,11 +548,15 @@ export class SessionManager {
|
|
|
407
548
|
agent,
|
|
408
549
|
pinned: false,
|
|
409
550
|
lastAttachedAt: Date.now(),
|
|
410
|
-
repoRoot
|
|
551
|
+
repoRoot,
|
|
411
552
|
namedBy,
|
|
412
553
|
createdAt: Date.now(),
|
|
413
554
|
parent,
|
|
414
555
|
...(prompt === '' ? {} : { prompt }),
|
|
556
|
+
...(overrides.model === undefined ? {} : { model: overrides.model }),
|
|
557
|
+
...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
|
|
558
|
+
target,
|
|
559
|
+
targetIdentity: execution.identity,
|
|
415
560
|
};
|
|
416
561
|
|
|
417
562
|
pty.onData((d) => {
|
|
@@ -434,13 +579,34 @@ export class SessionManager {
|
|
|
434
579
|
});
|
|
435
580
|
|
|
436
581
|
this.sessions.push(session);
|
|
437
|
-
void this.
|
|
582
|
+
void this.tryWriteFleet(session.id);
|
|
438
583
|
this.writeStatus();
|
|
439
584
|
this.onEvent('added', session);
|
|
440
585
|
|
|
441
586
|
return session;
|
|
442
587
|
}
|
|
443
588
|
|
|
589
|
+
// Takes back a spawn that failed after its process started: the process
|
|
590
|
+
// dies and the session leaves the list and the fleet, so the spawn leaves
|
|
591
|
+
// nothing behind. It resolves only once the fleet without the session is
|
|
592
|
+
// durable, and throws when the kill or that write fails. A session that
|
|
593
|
+
// never registered is left alone.
|
|
594
|
+
async removeFailedSpawn(id: SessionID): Promise<void> {
|
|
595
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
596
|
+
|
|
597
|
+
if (s === undefined) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
s.pty?.kill();
|
|
602
|
+
s.pty = null;
|
|
603
|
+
|
|
604
|
+
this.remove(s);
|
|
605
|
+
this.emitChange();
|
|
606
|
+
|
|
607
|
+
await this.writeFleet();
|
|
608
|
+
}
|
|
609
|
+
|
|
444
610
|
collectDescriptors(): SessionDescriptor[] {
|
|
445
611
|
return this.sessions.map((s) => ({
|
|
446
612
|
id: s.id,
|
|
@@ -461,6 +627,7 @@ export class SessionManager {
|
|
|
461
627
|
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
462
628
|
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
463
629
|
...(s.parent === null ? {} : { parent: s.parent }),
|
|
630
|
+
locator: { daemonID: this.store.daemonID, targetID: s.target },
|
|
464
631
|
}));
|
|
465
632
|
}
|
|
466
633
|
|
|
@@ -579,12 +746,12 @@ export class SessionManager {
|
|
|
579
746
|
}
|
|
580
747
|
|
|
581
748
|
// A fleet rewrite marks every session without a PTY as exited, so only a
|
|
582
|
-
// new agent session id
|
|
583
|
-
//
|
|
749
|
+
// new agent session id takes it. Anything else touches this session's
|
|
750
|
+
// own row.
|
|
584
751
|
if (persist) {
|
|
585
|
-
void this.
|
|
586
|
-
} else
|
|
587
|
-
void this.
|
|
752
|
+
void this.tryWriteFleet(s.id);
|
|
753
|
+
} else {
|
|
754
|
+
void this.tryUpdateFleetEntry(s.id, rowUpdate);
|
|
588
755
|
}
|
|
589
756
|
|
|
590
757
|
if (dirty) {
|
|
@@ -614,7 +781,7 @@ export class SessionManager {
|
|
|
614
781
|
s.namedBy = update.namedBy;
|
|
615
782
|
}
|
|
616
783
|
|
|
617
|
-
void this.
|
|
784
|
+
void this.tryWriteFleet(s.id);
|
|
618
785
|
this.onEvent('renamed', s);
|
|
619
786
|
this.emitChange();
|
|
620
787
|
}
|
|
@@ -639,7 +806,7 @@ export class SessionManager {
|
|
|
639
806
|
|
|
640
807
|
s.unread = false;
|
|
641
808
|
s.lastAttachedAt = Date.now();
|
|
642
|
-
void this.
|
|
809
|
+
void this.tryWriteFleet(s.id);
|
|
643
810
|
|
|
644
811
|
// Attaching answers the attention request: a still-pending prompt
|
|
645
812
|
// re-flags it via the next notification.
|
|
@@ -754,42 +921,76 @@ export class SessionManager {
|
|
|
754
921
|
// Deliberate kills rewrite the file; unexpected session/atc deaths do not,
|
|
755
922
|
// so the last known fleet survives for `R` restore. Sessions whose
|
|
756
923
|
// terminal is gone persist as exited entries, so the killed archive
|
|
757
|
-
// survives a daemon restart too.
|
|
924
|
+
// survives a daemon restart too. A session the agent has not yet given
|
|
925
|
+
// its own session id persists as well, under its atc session id alone.
|
|
758
926
|
async writeFleet(): Promise<void> {
|
|
759
927
|
const fleet: FleetEntry[] = [];
|
|
760
928
|
|
|
761
929
|
for (const s of this.sessions) {
|
|
762
|
-
if (s.agentSessionID === undefined) {
|
|
763
|
-
continue;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
930
|
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
767
931
|
|
|
768
|
-
// The link persists by the parent's agent session id, since atc ids
|
|
769
|
-
// are minted afresh on restore.
|
|
770
|
-
const parent =
|
|
771
|
-
s.parent === null
|
|
772
|
-
? undefined
|
|
773
|
-
: this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
|
|
774
|
-
|
|
775
932
|
fleet.push({
|
|
933
|
+
sessionID: s.id,
|
|
776
934
|
name: s.name,
|
|
777
935
|
cwd: s.cwd,
|
|
778
|
-
agentSessionID: s.agentSessionID,
|
|
936
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
779
937
|
agent: s.agent,
|
|
780
938
|
...(s.pinned ? { pinned: true } : {}),
|
|
781
939
|
lastAttachedAt: s.lastAttachedAt,
|
|
782
940
|
...(live ? {} : { exited: true }),
|
|
783
|
-
...(parent ===
|
|
941
|
+
...(s.parent === null ? {} : { parent: s.parent }),
|
|
784
942
|
...(s.prompt === undefined ? {} : { prompt: s.prompt }),
|
|
785
943
|
...(s.result === undefined ? {} : { result: s.result }),
|
|
786
944
|
...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
|
|
945
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
946
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
947
|
+
target: s.target,
|
|
948
|
+
targetIdentity: s.targetIdentity,
|
|
787
949
|
});
|
|
788
950
|
}
|
|
789
951
|
|
|
790
952
|
await this.store.writeFleet(fleet);
|
|
791
953
|
}
|
|
792
954
|
|
|
955
|
+
// A write nobody awaits that fails leaves the stored rows as they were,
|
|
956
|
+
// and must not take the daemon down unhandled. A stale ownership epoch is
|
|
957
|
+
// expected and surfaces on the next write a request awaits; any other
|
|
958
|
+
// failure is logged with the session whose change it was writing.
|
|
959
|
+
private async tryWriteFleet(sessionID: SessionID): Promise<boolean> {
|
|
960
|
+
try {
|
|
961
|
+
await this.writeFleet();
|
|
962
|
+
} catch (error) {
|
|
963
|
+
this.logStoreFailure(sessionID, error);
|
|
964
|
+
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private async tryUpdateFleetEntry(id: SessionID, fields: FleetEntryUpdate): Promise<boolean> {
|
|
972
|
+
try {
|
|
973
|
+
await this.store.updateFleetEntry(id, fields);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
this.logStoreFailure(id, error);
|
|
976
|
+
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private logStoreFailure(sessionID: SessionID, error: unknown): void {
|
|
984
|
+
if (error instanceof DaemonError && error.code === 'stale_epoch') {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const code = error instanceof DaemonError ? error.code : 'internal';
|
|
989
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
990
|
+
|
|
991
|
+
this.log(`atc fleet write for session ${sessionID} failed (${code}): ${message}`);
|
|
992
|
+
}
|
|
993
|
+
|
|
793
994
|
countStates() {
|
|
794
995
|
return countSessionStates(this.sessions);
|
|
795
996
|
}
|
|
@@ -887,3 +1088,24 @@ export function sortGroupedSessionViews<
|
|
|
887
1088
|
|
|
888
1089
|
return [...buckets.values()].flat();
|
|
889
1090
|
}
|
|
1091
|
+
|
|
1092
|
+
// A target refusal as a session's last message, short enough for a list row.
|
|
1093
|
+
function formatTargetRefusal(code: ErrorCode, target: string): string {
|
|
1094
|
+
if (code === 'target_config_invalid') {
|
|
1095
|
+
return `target '${target}' misconfigured`;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (code === 'unknown_target') {
|
|
1099
|
+
return `no target '${target}'`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (code === 'target_changed') {
|
|
1103
|
+
return `target '${target}' changed`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (code === 'target_unavailable') {
|
|
1107
|
+
return `target '${target}' unavailable`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return `target '${target}' cannot start a terminal`;
|
|
1111
|
+
}
|
|
@@ -16,6 +16,9 @@ const PERMISSION_MODES = [
|
|
|
16
16
|
'dontAsk',
|
|
17
17
|
] as const;
|
|
18
18
|
|
|
19
|
+
// The effort levels the Agent SDK accepts.
|
|
20
|
+
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
21
|
+
|
|
19
22
|
interface HeadlessRunOptions {
|
|
20
23
|
readonly claudeBin: string;
|
|
21
24
|
readonly cwd: string;
|
|
@@ -25,6 +28,8 @@ interface HeadlessRunOptions {
|
|
|
25
28
|
readonly settings?: string;
|
|
26
29
|
readonly sessionID?: SessionID;
|
|
27
30
|
readonly pluginDir?: string;
|
|
31
|
+
readonly model?: string;
|
|
32
|
+
readonly effort?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
interface HeadlessRunHooks {
|
|
@@ -52,6 +57,7 @@ export function startHeadlessRun(
|
|
|
52
57
|
void (async () => {
|
|
53
58
|
try {
|
|
54
59
|
const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
|
|
60
|
+
const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
|
|
55
61
|
let stderrTail = '';
|
|
56
62
|
|
|
57
63
|
// The SDK is the heaviest module in the daemon's graph, and only a
|
|
@@ -75,6 +81,11 @@ export function startHeadlessRun(
|
|
|
75
81
|
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
76
82
|
...(mode === undefined ? {} : { permissionMode: mode }),
|
|
77
83
|
|
|
84
|
+
// The session's own model and effort, so the turn runs as its
|
|
85
|
+
// terminal did.
|
|
86
|
+
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
87
|
+
...(effort === undefined ? {} : { effort }),
|
|
88
|
+
|
|
78
89
|
// The same generated file the terminal spawn passes, so the turn
|
|
79
90
|
// runs against the session's own backend and instrumentation.
|
|
80
91
|
...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
|
|
@@ -10,8 +10,9 @@ import { truncateSummary } from './truncate-summary';
|
|
|
10
10
|
* runner, wires the run's output and completion into the session manager,
|
|
11
11
|
* passes the turn's start and end to the event trail the way a terminal
|
|
12
12
|
* turn's hooks do, and records the live handle on the session's runtime. Returns false
|
|
13
|
-
* without starting anything when the session is unknown, its
|
|
14
|
-
* headless runner,
|
|
13
|
+
* without starting anything when the session is unknown or exited, its
|
|
14
|
+
* agent has no headless runner, its target does not serve headless turns,
|
|
15
|
+
* or a turn is already running.
|
|
15
16
|
*/
|
|
16
17
|
export function startHeadlessTurn(
|
|
17
18
|
mgr: SessionManager,
|
|
@@ -28,6 +29,12 @@ export function startHeadlessTurn(
|
|
|
28
29
|
return false;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// The runner runs on the daemon's own host, so a session whose target
|
|
33
|
+
// does not serve headless turns never reaches it.
|
|
34
|
+
if (s.state === 'exited' || mgr.findExecutionRefusal(s, 'headless') !== null) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
|
|
32
39
|
|
|
33
40
|
recordTurnEvent(sessionID, { kind: 'prompt-submitted', detail: truncateDetail(prompt) });
|
|
@@ -39,6 +46,8 @@ export function startHeadlessTurn(
|
|
|
39
46
|
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
40
47
|
permissionMode: 'auto',
|
|
41
48
|
sessionID,
|
|
49
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
50
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
42
51
|
},
|
|
43
52
|
{
|
|
44
53
|
onOutput: (text) => {
|