@zgeoff/atc 2.11.0 → 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/package.json +1 -1
- package/src/agents/agent-adapter.ts +51 -0
- 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 +49 -2
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +43 -2
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +45 -2
- package/src/agents/grok-adapter.ts +19 -1
- package/src/cli.ts +18 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +37 -1
- 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 +331 -95
- package/src/daemon/daemon.ts +452 -75
- 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-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 +282 -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 +28 -1
- 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 +32 -4
- package/src/mcp/mcp-tools.ts +98 -8
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +7 -0
- package/src/mcp/run-tool.ts +63 -11
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +24 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +60 -10
- 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/run-migrations.ts +254 -5
- package/src/store/state-store.ts +433 -31
- 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
|
/**
|
|
@@ -162,26 +268,42 @@ export class SessionManager {
|
|
|
162
268
|
return s;
|
|
163
269
|
}
|
|
164
270
|
|
|
165
|
-
// Registers a fleet entry as a session with no terminal yet,
|
|
166
|
-
// fleet-wide restore can show every
|
|
167
|
-
//
|
|
168
|
-
// 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.
|
|
169
277
|
restore(entry: FleetEntry): Session {
|
|
170
|
-
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;
|
|
171
287
|
|
|
172
288
|
// An entry whose agent is no longer registered still gets its row, so a
|
|
173
289
|
// backend dropped from the config shows as itself instead of vanishing or
|
|
174
290
|
// reviving under another agent. Adopting a terminal for it is refused.
|
|
175
291
|
let lastMsg = 'waiting to restore';
|
|
176
292
|
|
|
177
|
-
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) {
|
|
178
298
|
lastMsg = 'killed';
|
|
179
299
|
} else if (this.findAdapter(entry.agent) === null) {
|
|
180
300
|
lastMsg = `no adapter for '${entry.agent}'`;
|
|
181
301
|
}
|
|
182
302
|
|
|
303
|
+
const parent = entry.parent ?? null;
|
|
304
|
+
|
|
183
305
|
const session: Session = {
|
|
184
|
-
id:
|
|
306
|
+
id: entry.sessionID,
|
|
185
307
|
name: entry.name,
|
|
186
308
|
cwd: entry.cwd,
|
|
187
309
|
kind: 'headless',
|
|
@@ -189,17 +311,21 @@ export class SessionManager {
|
|
|
189
311
|
state: exited ? 'exited' : 'running',
|
|
190
312
|
unread: false,
|
|
191
313
|
lastMsg,
|
|
192
|
-
agentSessionID: entry.agentSessionID,
|
|
314
|
+
...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
|
|
193
315
|
agent: entry.agent,
|
|
194
316
|
pinned: entry.pinned ?? false,
|
|
195
317
|
lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
|
|
196
318
|
repoRoot: resolveRepoRoot(entry.cwd),
|
|
197
319
|
namedBy: 'auto',
|
|
198
320
|
createdAt: Date.now(),
|
|
199
|
-
parent: this.
|
|
321
|
+
parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
|
|
200
322
|
...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
|
|
201
323
|
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
202
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,
|
|
203
329
|
};
|
|
204
330
|
|
|
205
331
|
this.sessions.push(session);
|
|
@@ -209,14 +335,6 @@ export class SessionManager {
|
|
|
209
335
|
return session;
|
|
210
336
|
}
|
|
211
337
|
|
|
212
|
-
private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
|
|
213
|
-
if (agentSessionID === undefined) {
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return this.sessions.find((s) => s.agentSessionID === agentSessionID);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
338
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
221
339
|
// same agent session id.
|
|
222
340
|
adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
|
|
@@ -232,14 +350,22 @@ export class SessionManager {
|
|
|
232
350
|
return null;
|
|
233
351
|
}
|
|
234
352
|
|
|
235
|
-
const
|
|
353
|
+
const provider = this.requireExecution(s, 'spawn').provider;
|
|
236
354
|
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
241
365
|
cwd: s.cwd,
|
|
242
366
|
env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
|
|
367
|
+
cols,
|
|
368
|
+
rows,
|
|
243
369
|
});
|
|
244
370
|
|
|
245
371
|
s.pty = pty;
|
|
@@ -264,7 +390,7 @@ export class SessionManager {
|
|
|
264
390
|
this.emitChange();
|
|
265
391
|
});
|
|
266
392
|
|
|
267
|
-
void this.
|
|
393
|
+
void this.tryWriteFleet(s.id);
|
|
268
394
|
this.onEvent('state', s);
|
|
269
395
|
this.emitChange();
|
|
270
396
|
|
|
@@ -301,7 +427,7 @@ export class SessionManager {
|
|
|
301
427
|
this.onEvent('state', s);
|
|
302
428
|
}
|
|
303
429
|
|
|
304
|
-
void this.
|
|
430
|
+
void this.tryWriteFleet(s.id);
|
|
305
431
|
this.writeStatus();
|
|
306
432
|
this.emitChange();
|
|
307
433
|
|
|
@@ -324,10 +450,7 @@ export class SessionManager {
|
|
|
324
450
|
if (result !== undefined) {
|
|
325
451
|
s.result = truncateToBytes(result, 16_384);
|
|
326
452
|
s.lastDetail = truncateDetail(result);
|
|
327
|
-
|
|
328
|
-
if (s.agentSessionID !== undefined) {
|
|
329
|
-
void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
|
|
330
|
-
}
|
|
453
|
+
void this.tryUpdateFleetEntry(s.id, { result: s.result });
|
|
331
454
|
}
|
|
332
455
|
|
|
333
456
|
this.onEvent('state', s);
|
|
@@ -364,7 +487,11 @@ export class SessionManager {
|
|
|
364
487
|
|
|
365
488
|
// resume: true opens the agent's own session picker; an agent session id
|
|
366
489
|
// resumes that specific session (fleet restore). parent makes the new
|
|
367
|
-
// 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.
|
|
368
495
|
spawn(
|
|
369
496
|
cwd: string,
|
|
370
497
|
name: string,
|
|
@@ -375,6 +502,9 @@ export class SessionManager {
|
|
|
375
502
|
namedBy: 'user' | 'auto' = 'auto',
|
|
376
503
|
agent: AgentID = 'claude',
|
|
377
504
|
parent: SessionID | null = null,
|
|
505
|
+
overrides: SpawnOverrides = {},
|
|
506
|
+
id: SessionID = mintSessionID(),
|
|
507
|
+
target = 'local',
|
|
378
508
|
): Session {
|
|
379
509
|
const adapter = this.findAdapter(agent);
|
|
380
510
|
|
|
@@ -382,15 +512,21 @@ export class SessionManager {
|
|
|
382
512
|
throw new Error(`no adapter for agent '${agent}'`);
|
|
383
513
|
}
|
|
384
514
|
|
|
385
|
-
const
|
|
386
|
-
const
|
|
515
|
+
const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
|
|
516
|
+
const provider = execution.provider;
|
|
387
517
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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,
|
|
392
526
|
cwd,
|
|
393
527
|
env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
|
|
528
|
+
cols,
|
|
529
|
+
rows,
|
|
394
530
|
});
|
|
395
531
|
|
|
396
532
|
let initialMsg = prompt;
|
|
@@ -412,11 +548,15 @@ export class SessionManager {
|
|
|
412
548
|
agent,
|
|
413
549
|
pinned: false,
|
|
414
550
|
lastAttachedAt: Date.now(),
|
|
415
|
-
repoRoot
|
|
551
|
+
repoRoot,
|
|
416
552
|
namedBy,
|
|
417
553
|
createdAt: Date.now(),
|
|
418
554
|
parent,
|
|
419
555
|
...(prompt === '' ? {} : { prompt }),
|
|
556
|
+
...(overrides.model === undefined ? {} : { model: overrides.model }),
|
|
557
|
+
...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
|
|
558
|
+
target,
|
|
559
|
+
targetIdentity: execution.identity,
|
|
420
560
|
};
|
|
421
561
|
|
|
422
562
|
pty.onData((d) => {
|
|
@@ -439,13 +579,34 @@ export class SessionManager {
|
|
|
439
579
|
});
|
|
440
580
|
|
|
441
581
|
this.sessions.push(session);
|
|
442
|
-
void this.
|
|
582
|
+
void this.tryWriteFleet(session.id);
|
|
443
583
|
this.writeStatus();
|
|
444
584
|
this.onEvent('added', session);
|
|
445
585
|
|
|
446
586
|
return session;
|
|
447
587
|
}
|
|
448
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
|
+
|
|
449
610
|
collectDescriptors(): SessionDescriptor[] {
|
|
450
611
|
return this.sessions.map((s) => ({
|
|
451
612
|
id: s.id,
|
|
@@ -466,6 +627,7 @@ export class SessionManager {
|
|
|
466
627
|
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
467
628
|
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
468
629
|
...(s.parent === null ? {} : { parent: s.parent }),
|
|
630
|
+
locator: { daemonID: this.store.daemonID, targetID: s.target },
|
|
469
631
|
}));
|
|
470
632
|
}
|
|
471
633
|
|
|
@@ -584,12 +746,12 @@ export class SessionManager {
|
|
|
584
746
|
}
|
|
585
747
|
|
|
586
748
|
// A fleet rewrite marks every session without a PTY as exited, so only a
|
|
587
|
-
// new agent session id
|
|
588
|
-
//
|
|
749
|
+
// new agent session id takes it. Anything else touches this session's
|
|
750
|
+
// own row.
|
|
589
751
|
if (persist) {
|
|
590
|
-
void this.
|
|
591
|
-
} else
|
|
592
|
-
void this.
|
|
752
|
+
void this.tryWriteFleet(s.id);
|
|
753
|
+
} else {
|
|
754
|
+
void this.tryUpdateFleetEntry(s.id, rowUpdate);
|
|
593
755
|
}
|
|
594
756
|
|
|
595
757
|
if (dirty) {
|
|
@@ -619,7 +781,7 @@ export class SessionManager {
|
|
|
619
781
|
s.namedBy = update.namedBy;
|
|
620
782
|
}
|
|
621
783
|
|
|
622
|
-
void this.
|
|
784
|
+
void this.tryWriteFleet(s.id);
|
|
623
785
|
this.onEvent('renamed', s);
|
|
624
786
|
this.emitChange();
|
|
625
787
|
}
|
|
@@ -644,7 +806,7 @@ export class SessionManager {
|
|
|
644
806
|
|
|
645
807
|
s.unread = false;
|
|
646
808
|
s.lastAttachedAt = Date.now();
|
|
647
|
-
void this.
|
|
809
|
+
void this.tryWriteFleet(s.id);
|
|
648
810
|
|
|
649
811
|
// Attaching answers the attention request: a still-pending prompt
|
|
650
812
|
// re-flags it via the next notification.
|
|
@@ -759,42 +921,76 @@ export class SessionManager {
|
|
|
759
921
|
// Deliberate kills rewrite the file; unexpected session/atc deaths do not,
|
|
760
922
|
// so the last known fleet survives for `R` restore. Sessions whose
|
|
761
923
|
// terminal is gone persist as exited entries, so the killed archive
|
|
762
|
-
// 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.
|
|
763
926
|
async writeFleet(): Promise<void> {
|
|
764
927
|
const fleet: FleetEntry[] = [];
|
|
765
928
|
|
|
766
929
|
for (const s of this.sessions) {
|
|
767
|
-
if (s.agentSessionID === undefined) {
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
930
|
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
772
931
|
|
|
773
|
-
// The link persists by the parent's agent session id, since atc ids
|
|
774
|
-
// are minted afresh on restore.
|
|
775
|
-
const parent =
|
|
776
|
-
s.parent === null
|
|
777
|
-
? undefined
|
|
778
|
-
: this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
|
|
779
|
-
|
|
780
932
|
fleet.push({
|
|
933
|
+
sessionID: s.id,
|
|
781
934
|
name: s.name,
|
|
782
935
|
cwd: s.cwd,
|
|
783
|
-
agentSessionID: s.agentSessionID,
|
|
936
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
784
937
|
agent: s.agent,
|
|
785
938
|
...(s.pinned ? { pinned: true } : {}),
|
|
786
939
|
lastAttachedAt: s.lastAttachedAt,
|
|
787
940
|
...(live ? {} : { exited: true }),
|
|
788
|
-
...(parent ===
|
|
941
|
+
...(s.parent === null ? {} : { parent: s.parent }),
|
|
789
942
|
...(s.prompt === undefined ? {} : { prompt: s.prompt }),
|
|
790
943
|
...(s.result === undefined ? {} : { result: s.result }),
|
|
791
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,
|
|
792
949
|
});
|
|
793
950
|
}
|
|
794
951
|
|
|
795
952
|
await this.store.writeFleet(fleet);
|
|
796
953
|
}
|
|
797
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
|
+
|
|
798
994
|
countStates() {
|
|
799
995
|
return countSessionStates(this.sessions);
|
|
800
996
|
}
|
|
@@ -892,3 +1088,24 @@ export function sortGroupedSessionViews<
|
|
|
892
1088
|
|
|
893
1089
|
return [...buckets.values()].flat();
|
|
894
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) => {
|