@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.
Files changed (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -1,5 +1,7 @@
1
1
  import { basename } from 'node:path';
2
- import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
2
+ import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
3
+ import { DaemonError } from '../protocol/daemon-error';
4
+ import { DAEMON_FEATURES } from '../protocol/daemon-features';
3
5
  import { decodeCursor } from '../protocol/decode-cursor';
4
6
  import { encodeCursor } from '../protocol/encode-cursor';
5
7
  import { OutboundQueue } from '../protocol/outbound-queue';
@@ -13,18 +15,29 @@ import {
13
15
  encodeMessage,
14
16
  } from '../protocol/protocol';
15
17
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
18
+ import type { TargetConfigError } from '../shared/collect-targets';
19
+ import type { DaemonID } from '../shared/daemon-id';
16
20
  import type { MessageID } from '../shared/message-id';
21
+ import { isRecord } from '../shared/report';
17
22
  import type { SessionID } from '../shared/session-id';
23
+ import { toSessionID } from '../shared/to-session-id';
18
24
  import type { FleetEntry } from '../store/fleet-entry';
19
25
  import type { MessageRecord } from '../store/message-record';
20
26
  import type { Dims } from './attach-registry';
27
+ import type { AgentEntry } from './build-agent-list';
21
28
  import type { FleetEvent } from './build-fleet-events';
29
+ import { buildPayloadHash } from './build-payload-hash';
30
+ import { buildScopedContext } from './build-scoped-context';
31
+ import type { TargetEntry } from './build-target-list';
32
+ import type { KeyedRequest } from './idempotency-ledger';
22
33
  import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
34
+ import { parseSpawnOverrides } from './parse-spawn-overrides';
23
35
  import type { AnswerResult } from './permission-registry';
24
36
  import type { ScreenText } from './screen-model';
25
37
  import type { SessionDescriptor } from './sessions';
38
+ import type { TargetAccess, TargetGrant } from './target-access';
26
39
 
27
- interface SpawnParams {
40
+ export interface SpawnParams {
28
41
  readonly cwd: string;
29
42
  readonly name: string;
30
43
  readonly prompt: string;
@@ -34,6 +47,8 @@ interface SpawnParams {
34
47
  readonly namedBy: 'user' | 'auto';
35
48
  readonly agent: AgentID;
36
49
  readonly parent: SessionID | null;
50
+ readonly overrides: SpawnOverrides;
51
+ readonly target: string;
37
52
  }
38
53
 
39
54
  interface SessionRecord {
@@ -53,12 +68,46 @@ interface SessionTranscriptRead {
53
68
 
54
69
  export interface DaemonContext {
55
70
  readonly build: string;
71
+ readonly daemonID: DaemonID;
56
72
  readonly collectSessions: () => SessionDescriptor[];
57
73
  readonly collectSpawnDirs: () => Promise<string[]>;
74
+ readonly collectAgents: () => AgentList;
58
75
  readonly collectFleet: () => Promise<FleetEntry[]>;
59
76
  readonly loadLastUsedAgent: () => Promise<AgentID>;
60
77
  readonly findAdapter: (id: AgentID) => AgentAdapter | null;
61
- readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
78
+
79
+ // The targets a principal may use.
80
+ readonly buildTargetAccess: (principal: string) => TargetAccess;
81
+
82
+ // The target and identity a session is bound to, or null when no
83
+ // session holds the id.
84
+ readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
85
+
86
+ // The identity a target holds now, or null when the config holds no such
87
+ // target.
88
+ readonly findTargetIdentity: (target: string) => string | null;
89
+
90
+ // The ids of a session's sub-sessions.
91
+ readonly collectChildIDs: (id: SessionID) => SessionID[];
92
+
93
+ // The session a permission request belongs to, answered or not, or null
94
+ // for an unknown request.
95
+ readonly findPermissionSession: (request: string) => SessionID | null;
96
+
97
+ // The target a spawn runs on: the one it names, else the default. Throws
98
+ // the refusal for a target the spawn cannot run on, and for a spawn
99
+ // without a target when the config gives no default.
100
+ readonly resolveSpawnTarget: (requested: string | undefined) => string;
101
+
102
+ // Runs the plan, which throws the refusal for a spawn it refuses, then
103
+ // spawns. Answers with the `session.spawn` ok payload, which a keyed
104
+ // retry replays as the first spawn answered it while the access, when
105
+ // there is one, still reaches the target the spawn's session was bound to.
106
+ readonly spawnSession: (
107
+ plan: () => SpawnParams,
108
+ keyed: KeyedRequest | null,
109
+ access: TargetAccess | null,
110
+ ) => Promise<Readonly<Record<string, unknown>>>;
62
111
  readonly killSession: (id: SessionID) => Promise<boolean>;
63
112
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
64
113
  readonly quitDaemon: () => void;
@@ -97,17 +146,26 @@ export interface DaemonContext {
97
146
  from: TranscriptPosition | null,
98
147
  limit: number,
99
148
  ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
149
+
150
+ // Reads the trail, leaving out each event of a session outside the access
151
+ // when there is one.
100
152
  readonly readEvents: (
101
153
  afterID: number | null,
102
154
  limit: number,
103
155
  waitMs: number,
104
- ) => Promise<FleetEvent[]>;
156
+ sessionID: SessionID | null,
157
+ access: TargetAccess | null,
158
+ ) => Promise<EventsPage>;
159
+
160
+ // Answers with the `session.message` ok payload, which a keyed retry
161
+ // replays with the message's current status, or with the refusal.
105
162
  readonly writeSessionMessage: (
106
163
  sessionID: SessionID,
107
164
  from: string,
108
165
  text: string,
109
- ) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
110
- readonly readMessage: (messageID: MessageID) => Promise<MessageView | null>;
166
+ keyed: KeyedRequest | null,
167
+ ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
168
+ readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
111
169
  readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
112
170
  readonly ackMessage: (
113
171
  client: TapClient,
@@ -122,18 +180,55 @@ export interface OutputClient {
122
180
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
123
181
  }
124
182
 
125
- // One message as `message.get` reports it: the session it belongs to now and
126
- // every field of the record.
183
+ // The `agents.list` answer: the host the daemon runs on, each agent, each
184
+ // execution target, what a spawn without either runs with, a digest of
185
+ // the target config, and the target config problems the daemon started
186
+ // with.
187
+ interface AgentList {
188
+ readonly daemon: {
189
+ readonly hostname: string;
190
+ readonly platform: string;
191
+ readonly arch: string;
192
+ readonly build: string;
193
+ };
194
+ readonly agents: readonly AgentEntry[];
195
+ readonly targets: readonly TargetEntry[];
196
+ readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
197
+ readonly configRevision: string;
198
+ readonly targetErrors: readonly TargetConfigError[];
199
+ }
200
+
201
+ // One `events.read` answer: the events, and whether more follow them.
202
+ interface EventsPage {
203
+ readonly events: readonly FleetEvent[];
204
+ readonly more: boolean;
205
+ }
206
+
207
+ // One message as `message.get` reports it: the session it belongs to now,
208
+ // every field of the record, and the other messages its turn answered.
127
209
  interface MessageView {
128
210
  readonly session: SessionID;
129
211
  readonly record: MessageRecord;
212
+ readonly answeredWith: readonly MessageID[];
130
213
  }
131
214
 
215
+ // Why a session refuses a message before the daemon accepts it.
216
+ export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
217
+
132
218
  // The slice of a connection a tap subscription needs.
133
219
  export interface TapClient {
134
220
  readonly sendEvent: (event: EventMsg) => void;
135
221
  }
136
222
 
223
+ // The targets a request may use and the namespace of its idempotency keys.
224
+ interface RequestScope {
225
+ readonly access: TargetAccess;
226
+ readonly keyNamespace: string;
227
+ }
228
+
229
+ // The requests that act on the whole daemon, which only its owner may make.
230
+ const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
231
+
137
232
  interface PeerSocket extends SocketWriter {
138
233
  readonly end: () => void;
139
234
  }
@@ -158,6 +253,17 @@ export class DaemonConnection {
158
253
 
159
254
  private readonly desynced = new Map<SessionID, number>();
160
255
 
256
+ // The principal the whole connection acts as, from its handshake, and the
257
+ // targets that principal may use; null for the daemon's owner, whose
258
+ // reach has no limit.
259
+ private principal: string | null = null;
260
+
261
+ private access: TargetAccess | null = null;
262
+
263
+ // The sessions and permission requests this limited connection was shown,
264
+ // so the events that end them reach it after the session is gone.
265
+ private readonly shown = new Set<string>();
266
+
161
267
  constructor(peer: PeerSocket, ctx: DaemonContext) {
162
268
  this.peer = peer;
163
269
  this.ctx = ctx;
@@ -173,7 +279,11 @@ export class DaemonConnection {
173
279
  }
174
280
 
175
281
  sendEvent(event: EventMsg): void {
176
- if (this.helloed && !this.queue.send(encodeMessage(event))) {
282
+ if (!this.helloed || !this.canSeeEvent(event)) {
283
+ return;
284
+ }
285
+
286
+ if (!this.queue.send(encodeMessage(event))) {
177
287
  this.peer.end();
178
288
  }
179
289
  }
@@ -276,18 +386,88 @@ export class DaemonConnection {
276
386
  return false;
277
387
  }
278
388
 
279
- this.answerAsync(req.id, () => this.applyRequest(req));
389
+ const scope = this.findRequestScope(req);
390
+
391
+ if (scope !== null && OWNER_METHODS.has(req.m)) {
392
+ this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
393
+
394
+ return true;
395
+ }
396
+
397
+ const ctx =
398
+ scope === null ? this.ctx : buildScopedContext(this.ctx, scope.access, scope.keyNamespace);
399
+
400
+ this.answerAsync(req.id, () => this.applyRequest(req, ctx));
401
+
402
+ return true;
403
+ }
404
+
405
+ // The targets a request may use and the namespace its idempotency keys
406
+ // live in: the request's own principal, limited to what the connection's
407
+ // may use, else the connection's. Null is the daemon's owner. The owner
408
+ // may act as any principal, that principal's keys included; a connection
409
+ // that acts as a principal always keeps its own keys, whatever principal
410
+ // a request acts as.
411
+ private findRequestScope(req: RequestMsg): RequestScope | null {
412
+ if (req.as === undefined) {
413
+ return this.principal === null || this.access === null
414
+ ? null
415
+ : { access: this.access, keyNamespace: `client:${this.principal}` };
416
+ }
417
+
418
+ const access = this.ctx.buildTargetAccess(req.as);
419
+
420
+ if (this.principal === null || this.access === null) {
421
+ return { access, keyNamespace: `client:${req.as}` };
422
+ }
423
+
424
+ return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
425
+ }
426
+
427
+ // Whether this connection may see an event: always for the daemon's
428
+ // owner, and otherwise only for an event of a session the connection may
429
+ // see or was already shown.
430
+ private canSeeEvent(event: EventMsg): boolean {
431
+ if (this.access === null) {
432
+ return true;
433
+ }
434
+
435
+ const sessionID = findEventSession(event);
436
+ const request = typeof event['request'] === 'string' ? event['request'] : null;
437
+
438
+ if (sessionID === null) {
439
+ return request !== null && this.shown.has(`request:${request}`);
440
+ }
441
+
442
+ const grant = this.ctx.findSessionGrant(sessionID);
443
+
444
+ if (grant === null || !this.access.canUse(grant)) {
445
+ return this.shown.has(`session:${sessionID}`);
446
+ }
447
+
448
+ this.shown.add(`session:${sessionID}`);
449
+
450
+ if (request !== null) {
451
+ this.shown.add(`request:${request}`);
452
+ }
280
453
 
281
454
  return true;
282
455
  }
283
456
 
284
457
  // A store query that rejects must end one request, never the daemon: a
285
- // floating promise here would take the whole process down with it.
458
+ // floating promise here would take the whole process down with it. A
459
+ // rejection that carries a protocol error code answers with that code.
286
460
  private answerAsync(id: number, answered: () => Promise<void>): void {
287
461
  void (async () => {
288
462
  try {
289
463
  await answered();
290
464
  } catch (error) {
465
+ if (error instanceof DaemonError) {
466
+ this.sendErr(id, error.code, error.message, error.data);
467
+
468
+ return;
469
+ }
470
+
291
471
  const reason = error instanceof Error ? error.message : String(error);
292
472
 
293
473
  this.sendErr(id, 'internal', reason);
@@ -295,7 +475,7 @@ export class DaemonConnection {
295
475
  })();
296
476
  }
297
477
 
298
- private async applyRequest(req: RequestMsg): Promise<void> {
478
+ private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
299
479
  await this.helloAnswered;
300
480
 
301
481
  switch (req.m) {
@@ -305,28 +485,33 @@ export class DaemonConnection {
305
485
  return;
306
486
  }
307
487
  case 'session.list': {
308
- this.sendOk(req.id, { sessions: this.ctx.collectSessions() });
488
+ this.sendOk(req.id, { sessions: ctx.collectSessions() });
489
+
490
+ return;
491
+ }
492
+ case 'agents.list': {
493
+ this.sendOk(req.id, { ...ctx.collectAgents() });
309
494
 
310
495
  return;
311
496
  }
312
497
  case 'dirs.list': {
313
- this.sendOk(req.id, { dirs: await this.ctx.collectSpawnDirs() });
498
+ this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
314
499
 
315
500
  return;
316
501
  }
317
502
  case 'fleet.list': {
318
- this.sendOk(req.id, { fleet: await this.ctx.collectFleet() });
503
+ this.sendOk(req.id, { fleet: await ctx.collectFleet() });
319
504
 
320
505
  return;
321
506
  }
322
507
  case 'session.spawn': {
323
- this.applySpawn(req);
508
+ await this.applySpawn(req, ctx);
324
509
 
325
510
  return;
326
511
  }
327
512
  case 'daemon.quit': {
328
513
  this.sendOk(req.id, {});
329
- this.ctx.quitDaemon();
514
+ ctx.quitDaemon();
330
515
 
331
516
  return;
332
517
  }
@@ -340,7 +525,7 @@ export class DaemonConnection {
340
525
  }
341
526
 
342
527
  const sessionID = parsed.data.session;
343
- const updated = this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
528
+ const updated = ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
344
529
 
345
530
  if (updated === 'child_pin') {
346
531
  this.sendErr(
@@ -357,12 +542,12 @@ export class DaemonConnection {
357
542
  return;
358
543
  }
359
544
  case 'session.kill': {
360
- await this.applySessionVerb(req, 'session.kill', this.ctx.killSession);
545
+ await this.applySessionVerb(req, 'session.kill', ctx.killSession);
361
546
 
362
547
  return;
363
548
  }
364
549
  case 'session.ack': {
365
- await this.applySessionVerb(req, 'session.ack', this.ctx.ackSession);
550
+ await this.applySessionVerb(req, 'session.ack', ctx.ackSession);
366
551
 
367
552
  return;
368
553
  }
@@ -376,7 +561,7 @@ export class DaemonConnection {
376
561
  }
377
562
 
378
563
  const id = parsed.data.session;
379
- const command = this.ctx.buildResumeCommand(id);
564
+ const command = ctx.buildResumeCommand(id);
380
565
 
381
566
  if (command === null) {
382
567
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -397,7 +582,7 @@ export class DaemonConnection {
397
582
 
398
583
  const id = parsed.data.session;
399
584
 
400
- const screen = await this.ctx.readSessionScreen(id);
585
+ const screen = await ctx.readSessionScreen(id);
401
586
 
402
587
  if (screen === 'missing') {
403
588
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -419,7 +604,7 @@ export class DaemonConnection {
419
604
  }
420
605
 
421
606
  const sessionID = parsed.data.session;
422
- const result = this.ctx.ejectSession(sessionID, parsed.data.prompt);
607
+ const result = ctx.ejectSession(sessionID, parsed.data.prompt);
423
608
 
424
609
  if (result === 'ok') {
425
610
  this.sendOk(req.id, {});
@@ -451,7 +636,7 @@ export class DaemonConnection {
451
636
  }
452
637
 
453
638
  const sessionID = parsed.data.session;
454
- const adoptResult = this.ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
639
+ const adoptResult = ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
455
640
 
456
641
  if (adoptResult === 'ok') {
457
642
  this.sendOk(req.id, {});
@@ -472,7 +657,7 @@ export class DaemonConnection {
472
657
  return;
473
658
  }
474
659
  case 'session.attach': {
475
- this.applyAttach(req);
660
+ this.applyAttach(req, ctx);
476
661
 
477
662
  return;
478
663
  }
@@ -485,43 +670,43 @@ export class DaemonConnection {
485
670
  return;
486
671
  }
487
672
 
488
- this.ctx.detachSession(this, parsed.data.session);
673
+ ctx.detachSession(this, parsed.data.session);
489
674
  this.sendOk(req.id, {});
490
675
 
491
676
  return;
492
677
  }
493
678
  case 'session.input': {
494
- this.applyInput(req);
679
+ this.applyInput(req, ctx);
495
680
 
496
681
  return;
497
682
  }
498
683
  case 'session.resize': {
499
- this.applyResize(req);
684
+ this.applyResize(req, ctx);
500
685
 
501
686
  return;
502
687
  }
503
688
  case 'permission.respond': {
504
- this.applyPermissionRespond(req);
689
+ this.applyPermissionRespond(req, ctx);
505
690
 
506
691
  return;
507
692
  }
508
693
  case 'session.message': {
509
- await this.applySessionMessage(req);
694
+ await this.applySessionMessage(req, ctx);
510
695
 
511
696
  return;
512
697
  }
513
698
  case 'session.tap': {
514
- this.applyTap(req);
699
+ this.applyTap(req, ctx);
515
700
 
516
701
  return;
517
702
  }
518
703
  case 'message.get': {
519
- await this.applyMessageGet(req);
704
+ await this.applyMessageGet(req, ctx);
520
705
 
521
706
  return;
522
707
  }
523
708
  case 'message.ack': {
524
- await this.applyMessageAck(req);
709
+ await this.applyMessageAck(req, ctx);
525
710
 
526
711
  return;
527
712
  }
@@ -535,23 +720,23 @@ export class DaemonConnection {
535
720
  }
536
721
 
537
722
  this.sendOk(req.id, {
538
- restored: await this.ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
723
+ restored: await ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
539
724
  });
540
725
 
541
726
  return;
542
727
  }
543
728
  case 'session.get': {
544
- await this.applySessionGet(req);
729
+ await this.applySessionGet(req, ctx);
545
730
 
546
731
  return;
547
732
  }
548
733
  case 'session.read': {
549
- await this.applySessionRead(req);
734
+ await this.applySessionRead(req, ctx);
550
735
 
551
736
  return;
552
737
  }
553
738
  case 'events.read': {
554
- await this.applyEventsRead(req);
739
+ await this.applyEventsRead(req, ctx);
555
740
 
556
741
  return;
557
742
  }
@@ -561,7 +746,7 @@ export class DaemonConnection {
561
746
  }
562
747
  }
563
748
 
564
- private applySpawn(req: RequestMsg): void {
749
+ private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
565
750
  const parsed = parseRequestParams('session.spawn', req.p);
566
751
 
567
752
  if (!parsed.ok) {
@@ -570,48 +755,78 @@ export class DaemonConnection {
570
755
  return;
571
756
  }
572
757
 
573
- const cwd = parsed.data.cwd;
574
- const name = parsed.data.name;
575
- const agent: AgentID = parsed.data.agent ?? 'claude';
576
-
577
- if (this.ctx.findAdapter(agent) === null) {
578
- this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
758
+ const data = parsed.data;
759
+
760
+ // Every refusal is thrown from the plan, which runs only once a key is
761
+ // claimed: a retry of a held key answers from the key without checking
762
+ // anything again, and a refused spawn drops its claim.
763
+ const plan = (): SpawnParams => {
764
+ // The target goes first: a config or target problem is the cause a
765
+ // spawn reports, ahead of any agent check that problem can skew.
766
+ const target = ctx.resolveSpawnTarget(data.target);
767
+ const agent: AgentID = data.agent ?? 'claude';
768
+ const adapter = ctx.findAdapter(agent);
769
+ const entry = ctx.collectAgents().agents.find((candidate) => candidate.id === agent);
770
+
771
+ if (adapter === null || entry === undefined) {
772
+ throw new DaemonError('unsupported', `no adapter for agent '${agent}'`);
773
+ }
579
774
 
580
- return;
581
- }
775
+ // A stand-in adapter declares no binary to check, so only a profiled
776
+ // agent's missing binary refuses the spawn.
777
+ if (adapter.profile !== undefined && !entry.installed) {
778
+ throw new DaemonError(
779
+ 'unsupported',
780
+ `agent '${agent}' is registered but not installed on this host`,
781
+ );
782
+ }
582
783
 
583
- let parent: SessionID | null = null;
784
+ const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
584
785
 
585
- if (parsed.data.parent !== undefined) {
586
- const owner = this.ctx.collectSessions().find((s) => s.id === parsed.data.parent);
786
+ if (!overrides.ok) {
787
+ throw new DaemonError(overrides.code, overrides.message);
788
+ }
587
789
 
588
- if (owner === undefined) {
589
- this.sendErr(req.id, 'no_such_session', `no session '${parsed.data.parent}'`);
790
+ let parent: SessionID | null = null;
590
791
 
591
- return;
592
- }
792
+ if (data.parent !== undefined) {
793
+ const owner = ctx.collectSessions().find((s) => s.id === data.parent);
593
794
 
594
- // A sub-session spawning a sub-session of its own lands beside it,
595
- // so a set stays one level deep.
596
- parent = owner.parent ?? owner.id;
597
- }
795
+ if (owner === undefined) {
796
+ throw new DaemonError('no_such_session', `no session '${data.parent}'`);
797
+ }
598
798
 
599
- const session = this.ctx.spawnSession({
600
- cwd,
601
- name: name === '' ? basename(cwd) : name,
602
- prompt: parsed.data.prompt,
603
- cols: parsed.data.cols,
604
- rows: parsed.data.rows,
605
- resume: parsed.data.resume,
606
- namedBy: name === '' ? 'auto' : 'user',
607
- agent,
608
- parent,
609
- });
799
+ // A sub-session spawning a sub-session of its own lands beside it,
800
+ // so a set stays one level deep.
801
+ parent = owner.parent ?? owner.id;
802
+ }
610
803
 
611
- this.sendOk(req.id, { session });
804
+ return {
805
+ cwd: data.cwd,
806
+ name: data.name === '' ? basename(data.cwd) : data.name,
807
+ prompt: data.prompt,
808
+ cols: data.cols,
809
+ rows: data.rows,
810
+ resume: data.resume,
811
+ namedBy: data.name === '' ? 'auto' : 'user',
812
+ agent,
813
+ parent,
814
+ overrides: overrides.overrides,
815
+ target,
816
+ };
817
+ };
818
+
819
+ const keyed =
820
+ data.idempotencyKey === undefined
821
+ ? null
822
+ : { key: data.idempotencyKey, payloadHash: buildPayloadHash(data) };
823
+
824
+ const spawned = await ctx.spawnSession(plan, keyed, null);
825
+
826
+ this.sendOk(req.id, spawned);
612
827
  }
613
828
 
614
- private applyAttach(req: RequestMsg): void {
829
+ private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
615
830
  const parsed = parseRequestParams('session.attach', req.p);
616
831
 
617
832
  if (!parsed.ok) {
@@ -622,7 +837,7 @@ export class DaemonConnection {
622
837
 
623
838
  const sessionID = parsed.data.session;
624
839
 
625
- const result = this.ctx.attachSession(this, sessionID, {
840
+ const result = ctx.attachSession(this, sessionID, {
626
841
  cols: parsed.data.cols,
627
842
  rows: parsed.data.rows,
628
843
  });
@@ -639,12 +854,12 @@ export class DaemonConnection {
639
854
  return;
640
855
  }
641
856
 
642
- const dims = this.ctx.getEffectiveDims(sessionID);
857
+ const dims = ctx.getEffectiveDims(sessionID);
643
858
 
644
859
  this.sendOk(req.id, { cols: dims.cols, rows: dims.rows });
645
860
  }
646
861
 
647
- private applyInput(req: RequestMsg): void {
862
+ private applyInput(req: RequestMsg, ctx: DaemonContext): void {
648
863
  const parsed = parseRequestParams('session.input', req.p);
649
864
 
650
865
  if (!parsed.ok) {
@@ -654,7 +869,7 @@ export class DaemonConnection {
654
869
  }
655
870
 
656
871
  const sessionID = parsed.data.session;
657
- const result = this.ctx.writeSessionInput(sessionID, parsed.data.d);
872
+ const result = ctx.writeSessionInput(sessionID, parsed.data.d);
658
873
 
659
874
  if (result === 'missing') {
660
875
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -681,7 +896,7 @@ export class DaemonConnection {
681
896
  this.sendOk(req.id, {});
682
897
  }
683
898
 
684
- private applyResize(req: RequestMsg): void {
899
+ private applyResize(req: RequestMsg, ctx: DaemonContext): void {
685
900
  const parsed = parseRequestParams('session.resize', req.p);
686
901
 
687
902
  if (!parsed.ok) {
@@ -692,9 +907,7 @@ export class DaemonConnection {
692
907
 
693
908
  const sessionID = parsed.data.session;
694
909
 
695
- if (
696
- !this.ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })
697
- ) {
910
+ if (!ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })) {
698
911
  this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
699
912
 
700
913
  return;
@@ -703,7 +916,7 @@ export class DaemonConnection {
703
916
  this.sendOk(req.id, {});
704
917
  }
705
918
 
706
- private applyPermissionRespond(req: RequestMsg): void {
919
+ private applyPermissionRespond(req: RequestMsg, ctx: DaemonContext): void {
707
920
  const parsed = parseRequestParams('permission.respond', req.p);
708
921
 
709
922
  if (!parsed.ok) {
@@ -714,7 +927,7 @@ export class DaemonConnection {
714
927
 
715
928
  const request = parsed.data.request;
716
929
  const decision = parsed.data.decision;
717
- const result = this.ctx.answerPermission(request, decision);
930
+ const result = ctx.answerPermission(request, decision);
718
931
 
719
932
  switch (result) {
720
933
  case 'ok': {
@@ -738,7 +951,7 @@ export class DaemonConnection {
738
951
  }
739
952
  }
740
953
 
741
- private async applySessionGet(req: RequestMsg): Promise<void> {
954
+ private async applySessionGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
742
955
  const parsed = parseRequestParams('session.get', req.p);
743
956
 
744
957
  if (!parsed.ok) {
@@ -749,7 +962,7 @@ export class DaemonConnection {
749
962
 
750
963
  const id = parsed.data.session;
751
964
 
752
- const record = await this.ctx.readSessionRecord(id);
965
+ const record = await ctx.readSessionRecord(id);
753
966
 
754
967
  if (record === 'missing') {
755
968
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -760,7 +973,7 @@ export class DaemonConnection {
760
973
  this.sendOk(req.id, { ...record });
761
974
  }
762
975
 
763
- private async applySessionRead(req: RequestMsg): Promise<void> {
976
+ private async applySessionRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
764
977
  const parsed = parseRequestParams('session.read', req.p);
765
978
 
766
979
  if (!parsed.ok) {
@@ -784,7 +997,7 @@ export class DaemonConnection {
784
997
  from = { path: decoded.path, offset: decoded.offset };
785
998
  }
786
999
 
787
- const read = await this.ctx.loadSessionTranscript(id, from, parsed.data.limit);
1000
+ const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
788
1001
 
789
1002
  if (read === 'missing') {
790
1003
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -803,7 +1016,7 @@ export class DaemonConnection {
803
1016
  }
804
1017
  }
805
1018
 
806
- private async applyEventsRead(req: RequestMsg): Promise<void> {
1019
+ private async applyEventsRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
807
1020
  const parsed = parseRequestParams('events.read', req.p);
808
1021
 
809
1022
  if (!parsed.ok) {
@@ -826,18 +1039,25 @@ export class DaemonConnection {
826
1039
  afterID = decoded.id;
827
1040
  }
828
1041
 
829
- const events = await this.ctx.readEvents(afterID, parsed.data.limit, parsed.data.waitMs);
1042
+ const page = await ctx.readEvents(
1043
+ afterID,
1044
+ parsed.data.limit,
1045
+ parsed.data.waitMs,
1046
+ parsed.data.session ?? null,
1047
+ null,
1048
+ );
830
1049
 
831
- const last = events.at(-1);
1050
+ const last = page.events.at(-1);
832
1051
 
833
1052
  // A cursor always comes back so a client can long-poll from an empty trail.
834
1053
  this.sendOk(req.id, {
835
- events,
1054
+ events: page.events,
836
1055
  cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
1056
+ more: page.more,
837
1057
  });
838
1058
  }
839
1059
 
840
- private async applySessionMessage(req: RequestMsg): Promise<void> {
1060
+ private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
841
1061
  const parsed = parseRequestParams('session.message', req.p);
842
1062
 
843
1063
  if (!parsed.ok) {
@@ -848,10 +1068,16 @@ export class DaemonConnection {
848
1068
 
849
1069
  const sessionID = parsed.data.session;
850
1070
 
851
- const result = await this.ctx.writeSessionMessage(
1071
+ const keyed =
1072
+ parsed.data.idempotencyKey === undefined
1073
+ ? null
1074
+ : { key: parsed.data.idempotencyKey, payloadHash: buildPayloadHash(parsed.data) };
1075
+
1076
+ const result = await ctx.writeSessionMessage(
852
1077
  sessionID,
853
1078
  parsed.data.from,
854
1079
  parsed.data.text,
1080
+ keyed,
855
1081
  );
856
1082
 
857
1083
  if (result === 'missing') {
@@ -882,10 +1108,10 @@ export class DaemonConnection {
882
1108
  return;
883
1109
  }
884
1110
 
885
- this.sendOk(req.id, { message: result.id, status: result.status });
1111
+ this.sendOk(req.id, result);
886
1112
  }
887
1113
 
888
- private applyTap(req: RequestMsg): void {
1114
+ private applyTap(req: RequestMsg, ctx: DaemonContext): void {
889
1115
  const parsed = parseRequestParams('session.tap', req.p);
890
1116
 
891
1117
  if (!parsed.ok) {
@@ -895,7 +1121,7 @@ export class DaemonConnection {
895
1121
  }
896
1122
 
897
1123
  const sessionID = parsed.data.session;
898
- const result = this.ctx.attachTap(this, sessionID);
1124
+ const result = ctx.attachTap(this, sessionID);
899
1125
 
900
1126
  if (result === 'missing') {
901
1127
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -912,7 +1138,7 @@ export class DaemonConnection {
912
1138
  this.sendOk(req.id, {});
913
1139
  }
914
1140
 
915
- private async applyMessageGet(req: RequestMsg): Promise<void> {
1141
+ private async applyMessageGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
916
1142
  const parsed = parseRequestParams('message.get', req.p);
917
1143
 
918
1144
  if (!parsed.ok) {
@@ -923,7 +1149,7 @@ export class DaemonConnection {
923
1149
 
924
1150
  const messageID = parsed.data.message;
925
1151
 
926
- const view = await this.ctx.readMessage(messageID);
1152
+ const view = await ctx.readMessage(messageID, parsed.data.waitMs);
927
1153
 
928
1154
  if (view === null) {
929
1155
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
@@ -943,10 +1169,12 @@ export class DaemonConnection {
943
1169
  ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
944
1170
  ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
945
1171
  ...(record.answer === undefined ? {} : { answer: record.answer }),
1172
+ turn: record.turn ?? null,
1173
+ answeredWith: view.answeredWith,
946
1174
  });
947
1175
  }
948
1176
 
949
- private async applyMessageAck(req: RequestMsg): Promise<void> {
1177
+ private async applyMessageAck(req: RequestMsg, ctx: DaemonContext): Promise<void> {
950
1178
  const parsed = parseRequestParams('message.ack', req.p);
951
1179
 
952
1180
  if (!parsed.ok) {
@@ -958,7 +1186,7 @@ export class DaemonConnection {
958
1186
  const sessionID = parsed.data.session;
959
1187
  const messageID = parsed.data.message;
960
1188
 
961
- const result = await this.ctx.ackMessage(this, sessionID, messageID);
1189
+ const result = await ctx.ackMessage(this, sessionID, messageID);
962
1190
 
963
1191
  if (result === 'not_tapping') {
964
1192
  this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
@@ -1003,6 +1231,21 @@ export class DaemonConnection {
1003
1231
  const parsedHello = parseRequestParams('daemon.hello', req.p);
1004
1232
  const client = parsedHello.ok ? parsedHello.data.client : 'unknown client';
1005
1233
 
1234
+ // A principal the handshake cannot read would otherwise leave the
1235
+ // connection with the owner's reach.
1236
+ if (!parsedHello.ok && req.p?.['principal'] !== undefined) {
1237
+ this.sendErr(req.id, 'bad_args', parsedHello.message);
1238
+
1239
+ return false;
1240
+ }
1241
+
1242
+ const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
1243
+
1244
+ if (principal !== null) {
1245
+ this.principal = principal;
1246
+ this.access = this.ctx.buildTargetAccess(principal);
1247
+ }
1248
+
1006
1249
  if (req.v !== PROTOCOL_V) {
1007
1250
  this.sendErr(
1008
1251
  req.id,
@@ -1024,7 +1267,9 @@ export class DaemonConnection {
1024
1267
  private async sendHelloOk(id: number): Promise<void> {
1025
1268
  this.sendOk(id, {
1026
1269
  daemon: this.ctx.build,
1270
+ daemonID: this.ctx.daemonID,
1027
1271
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
1272
+ features: DAEMON_FEATURES,
1028
1273
  lastUsedAgent: await this.ctx.loadLastUsedAgent(),
1029
1274
  });
1030
1275
  }
@@ -1033,7 +1278,34 @@ export class DaemonConnection {
1033
1278
  this.queue.send(encodeMessage({ v: PROTOCOL_V, id, ok }));
1034
1279
  }
1035
1280
 
1036
- private sendErr(id: number, code: ErrorCode, msg: string): void {
1037
- this.queue.send(encodeMessage({ v: PROTOCOL_V, id, err: { code, msg } }));
1281
+ private sendErr(
1282
+ id: number,
1283
+ code: ErrorCode,
1284
+ msg: string,
1285
+ data?: Readonly<Record<string, unknown>>,
1286
+ ): void {
1287
+ this.queue.send(
1288
+ encodeMessage({
1289
+ v: PROTOCOL_V,
1290
+ id,
1291
+ err: { code, msg, ...(data === undefined ? {} : { data }) },
1292
+ }),
1293
+ );
1294
+ }
1295
+ }
1296
+
1297
+ // The session an event belongs to, from its session id or the session it
1298
+ // adds, or null for an event of no session.
1299
+ function findEventSession(event: EventMsg): SessionID | null {
1300
+ if (typeof event['s'] === 'string') {
1301
+ return toSessionID(event['s']);
1038
1302
  }
1303
+
1304
+ const session = event['session'];
1305
+
1306
+ if (isRecord(session) && typeof session['id'] === 'string') {
1307
+ return toSessionID(session['id']);
1308
+ }
1309
+
1310
+ return null;
1039
1311
  }