@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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -1,5 +1,6 @@
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';
3
4
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
4
5
  import { decodeCursor } from '../protocol/decode-cursor';
5
6
  import { encodeCursor } from '../protocol/encode-cursor';
@@ -14,19 +15,29 @@ import {
14
15
  encodeMessage,
15
16
  } from '../protocol/protocol';
16
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';
17
20
  import type { MessageID } from '../shared/message-id';
21
+ import { isRecord } from '../shared/report';
18
22
  import type { SessionID } from '../shared/session-id';
23
+ import { toSessionID } from '../shared/to-session-id';
19
24
  import type { FleetEntry } from '../store/fleet-entry';
20
25
  import type { MessageRecord } from '../store/message-record';
21
26
  import type { Dims } from './attach-registry';
22
27
  import type { AgentEntry } from './build-agent-list';
23
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';
24
33
  import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
34
+ import { parseSpawnOverrides } from './parse-spawn-overrides';
25
35
  import type { AnswerResult } from './permission-registry';
26
36
  import type { ScreenText } from './screen-model';
27
37
  import type { SessionDescriptor } from './sessions';
38
+ import type { TargetAccess, TargetGrant } from './target-access';
28
39
 
29
- interface SpawnParams {
40
+ export interface SpawnParams {
30
41
  readonly cwd: string;
31
42
  readonly name: string;
32
43
  readonly prompt: string;
@@ -36,6 +47,8 @@ interface SpawnParams {
36
47
  readonly namedBy: 'user' | 'auto';
37
48
  readonly agent: AgentID;
38
49
  readonly parent: SessionID | null;
50
+ readonly overrides: SpawnOverrides;
51
+ readonly target: string;
39
52
  }
40
53
 
41
54
  interface SessionRecord {
@@ -55,13 +68,46 @@ interface SessionTranscriptRead {
55
68
 
56
69
  export interface DaemonContext {
57
70
  readonly build: string;
71
+ readonly daemonID: DaemonID;
58
72
  readonly collectSessions: () => SessionDescriptor[];
59
73
  readonly collectSpawnDirs: () => Promise<string[]>;
60
74
  readonly collectAgents: () => AgentList;
61
75
  readonly collectFleet: () => Promise<FleetEntry[]>;
62
76
  readonly loadLastUsedAgent: () => Promise<AgentID>;
63
77
  readonly findAdapter: (id: AgentID) => AgentAdapter | null;
64
- 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>>>;
65
111
  readonly killSession: (id: SessionID) => Promise<boolean>;
66
112
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
67
113
  readonly quitDaemon: () => void;
@@ -100,17 +146,25 @@ export interface DaemonContext {
100
146
  from: TranscriptPosition | null,
101
147
  limit: number,
102
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.
103
152
  readonly readEvents: (
104
153
  afterID: number | null,
105
154
  limit: number,
106
155
  waitMs: number,
107
156
  sessionID: SessionID | null,
157
+ access: TargetAccess | null,
108
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.
109
162
  readonly writeSessionMessage: (
110
163
  sessionID: SessionID,
111
164
  from: string,
112
165
  text: string,
113
- ) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
166
+ keyed: KeyedRequest | null,
167
+ ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
114
168
  readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
115
169
  readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
116
170
  readonly ackMessage: (
@@ -126,7 +180,10 @@ export interface OutputClient {
126
180
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
127
181
  }
128
182
 
129
- // The `agents.list` answer: the host the daemon runs on, and each agent.
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.
130
187
  interface AgentList {
131
188
  readonly daemon: {
132
189
  readonly hostname: string;
@@ -135,6 +192,10 @@ interface AgentList {
135
192
  readonly build: string;
136
193
  };
137
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[];
138
199
  }
139
200
 
140
201
  // One `events.read` answer: the events, and whether more follow them.
@@ -151,11 +212,23 @@ interface MessageView {
151
212
  readonly answeredWith: readonly MessageID[];
152
213
  }
153
214
 
215
+ // Why a session refuses a message before the daemon accepts it.
216
+ export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
217
+
154
218
  // The slice of a connection a tap subscription needs.
155
219
  export interface TapClient {
156
220
  readonly sendEvent: (event: EventMsg) => void;
157
221
  }
158
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
+
159
232
  interface PeerSocket extends SocketWriter {
160
233
  readonly end: () => void;
161
234
  }
@@ -180,6 +253,17 @@ export class DaemonConnection {
180
253
 
181
254
  private readonly desynced = new Map<SessionID, number>();
182
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
+
183
267
  constructor(peer: PeerSocket, ctx: DaemonContext) {
184
268
  this.peer = peer;
185
269
  this.ctx = ctx;
@@ -195,7 +279,11 @@ export class DaemonConnection {
195
279
  }
196
280
 
197
281
  sendEvent(event: EventMsg): void {
198
- 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))) {
199
287
  this.peer.end();
200
288
  }
201
289
  }
@@ -298,18 +386,88 @@ export class DaemonConnection {
298
386
  return false;
299
387
  }
300
388
 
301
- 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
+ }
302
453
 
303
454
  return true;
304
455
  }
305
456
 
306
457
  // A store query that rejects must end one request, never the daemon: a
307
- // 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.
308
460
  private answerAsync(id: number, answered: () => Promise<void>): void {
309
461
  void (async () => {
310
462
  try {
311
463
  await answered();
312
464
  } catch (error) {
465
+ if (error instanceof DaemonError) {
466
+ this.sendErr(id, error.code, error.message, error.data);
467
+
468
+ return;
469
+ }
470
+
313
471
  const reason = error instanceof Error ? error.message : String(error);
314
472
 
315
473
  this.sendErr(id, 'internal', reason);
@@ -317,7 +475,7 @@ export class DaemonConnection {
317
475
  })();
318
476
  }
319
477
 
320
- private async applyRequest(req: RequestMsg): Promise<void> {
478
+ private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
321
479
  await this.helloAnswered;
322
480
 
323
481
  switch (req.m) {
@@ -327,33 +485,33 @@ export class DaemonConnection {
327
485
  return;
328
486
  }
329
487
  case 'session.list': {
330
- this.sendOk(req.id, { sessions: this.ctx.collectSessions() });
488
+ this.sendOk(req.id, { sessions: ctx.collectSessions() });
331
489
 
332
490
  return;
333
491
  }
334
492
  case 'agents.list': {
335
- this.sendOk(req.id, { ...this.ctx.collectAgents() });
493
+ this.sendOk(req.id, { ...ctx.collectAgents() });
336
494
 
337
495
  return;
338
496
  }
339
497
  case 'dirs.list': {
340
- this.sendOk(req.id, { dirs: await this.ctx.collectSpawnDirs() });
498
+ this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
341
499
 
342
500
  return;
343
501
  }
344
502
  case 'fleet.list': {
345
- this.sendOk(req.id, { fleet: await this.ctx.collectFleet() });
503
+ this.sendOk(req.id, { fleet: await ctx.collectFleet() });
346
504
 
347
505
  return;
348
506
  }
349
507
  case 'session.spawn': {
350
- this.applySpawn(req);
508
+ await this.applySpawn(req, ctx);
351
509
 
352
510
  return;
353
511
  }
354
512
  case 'daemon.quit': {
355
513
  this.sendOk(req.id, {});
356
- this.ctx.quitDaemon();
514
+ ctx.quitDaemon();
357
515
 
358
516
  return;
359
517
  }
@@ -367,7 +525,7 @@ export class DaemonConnection {
367
525
  }
368
526
 
369
527
  const sessionID = parsed.data.session;
370
- const updated = this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
528
+ const updated = ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
371
529
 
372
530
  if (updated === 'child_pin') {
373
531
  this.sendErr(
@@ -384,12 +542,12 @@ export class DaemonConnection {
384
542
  return;
385
543
  }
386
544
  case 'session.kill': {
387
- await this.applySessionVerb(req, 'session.kill', this.ctx.killSession);
545
+ await this.applySessionVerb(req, 'session.kill', ctx.killSession);
388
546
 
389
547
  return;
390
548
  }
391
549
  case 'session.ack': {
392
- await this.applySessionVerb(req, 'session.ack', this.ctx.ackSession);
550
+ await this.applySessionVerb(req, 'session.ack', ctx.ackSession);
393
551
 
394
552
  return;
395
553
  }
@@ -403,7 +561,7 @@ export class DaemonConnection {
403
561
  }
404
562
 
405
563
  const id = parsed.data.session;
406
- const command = this.ctx.buildResumeCommand(id);
564
+ const command = ctx.buildResumeCommand(id);
407
565
 
408
566
  if (command === null) {
409
567
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -424,7 +582,7 @@ export class DaemonConnection {
424
582
 
425
583
  const id = parsed.data.session;
426
584
 
427
- const screen = await this.ctx.readSessionScreen(id);
585
+ const screen = await ctx.readSessionScreen(id);
428
586
 
429
587
  if (screen === 'missing') {
430
588
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -446,7 +604,7 @@ export class DaemonConnection {
446
604
  }
447
605
 
448
606
  const sessionID = parsed.data.session;
449
- const result = this.ctx.ejectSession(sessionID, parsed.data.prompt);
607
+ const result = ctx.ejectSession(sessionID, parsed.data.prompt);
450
608
 
451
609
  if (result === 'ok') {
452
610
  this.sendOk(req.id, {});
@@ -478,7 +636,7 @@ export class DaemonConnection {
478
636
  }
479
637
 
480
638
  const sessionID = parsed.data.session;
481
- const adoptResult = this.ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
639
+ const adoptResult = ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
482
640
 
483
641
  if (adoptResult === 'ok') {
484
642
  this.sendOk(req.id, {});
@@ -499,7 +657,7 @@ export class DaemonConnection {
499
657
  return;
500
658
  }
501
659
  case 'session.attach': {
502
- this.applyAttach(req);
660
+ this.applyAttach(req, ctx);
503
661
 
504
662
  return;
505
663
  }
@@ -512,43 +670,43 @@ export class DaemonConnection {
512
670
  return;
513
671
  }
514
672
 
515
- this.ctx.detachSession(this, parsed.data.session);
673
+ ctx.detachSession(this, parsed.data.session);
516
674
  this.sendOk(req.id, {});
517
675
 
518
676
  return;
519
677
  }
520
678
  case 'session.input': {
521
- this.applyInput(req);
679
+ this.applyInput(req, ctx);
522
680
 
523
681
  return;
524
682
  }
525
683
  case 'session.resize': {
526
- this.applyResize(req);
684
+ this.applyResize(req, ctx);
527
685
 
528
686
  return;
529
687
  }
530
688
  case 'permission.respond': {
531
- this.applyPermissionRespond(req);
689
+ this.applyPermissionRespond(req, ctx);
532
690
 
533
691
  return;
534
692
  }
535
693
  case 'session.message': {
536
- await this.applySessionMessage(req);
694
+ await this.applySessionMessage(req, ctx);
537
695
 
538
696
  return;
539
697
  }
540
698
  case 'session.tap': {
541
- this.applyTap(req);
699
+ this.applyTap(req, ctx);
542
700
 
543
701
  return;
544
702
  }
545
703
  case 'message.get': {
546
- await this.applyMessageGet(req);
704
+ await this.applyMessageGet(req, ctx);
547
705
 
548
706
  return;
549
707
  }
550
708
  case 'message.ack': {
551
- await this.applyMessageAck(req);
709
+ await this.applyMessageAck(req, ctx);
552
710
 
553
711
  return;
554
712
  }
@@ -562,23 +720,23 @@ export class DaemonConnection {
562
720
  }
563
721
 
564
722
  this.sendOk(req.id, {
565
- restored: await this.ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
723
+ restored: await ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
566
724
  });
567
725
 
568
726
  return;
569
727
  }
570
728
  case 'session.get': {
571
- await this.applySessionGet(req);
729
+ await this.applySessionGet(req, ctx);
572
730
 
573
731
  return;
574
732
  }
575
733
  case 'session.read': {
576
- await this.applySessionRead(req);
734
+ await this.applySessionRead(req, ctx);
577
735
 
578
736
  return;
579
737
  }
580
738
  case 'events.read': {
581
- await this.applyEventsRead(req);
739
+ await this.applyEventsRead(req, ctx);
582
740
 
583
741
  return;
584
742
  }
@@ -588,7 +746,7 @@ export class DaemonConnection {
588
746
  }
589
747
  }
590
748
 
591
- private applySpawn(req: RequestMsg): void {
749
+ private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
592
750
  const parsed = parseRequestParams('session.spawn', req.p);
593
751
 
594
752
  if (!parsed.ok) {
@@ -597,48 +755,78 @@ export class DaemonConnection {
597
755
  return;
598
756
  }
599
757
 
600
- const cwd = parsed.data.cwd;
601
- const name = parsed.data.name;
602
- const agent: AgentID = parsed.data.agent ?? 'claude';
603
-
604
- if (this.ctx.findAdapter(agent) === null) {
605
- 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
+ }
606
774
 
607
- return;
608
- }
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
+ }
609
783
 
610
- let parent: SessionID | null = null;
784
+ const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
611
785
 
612
- if (parsed.data.parent !== undefined) {
613
- 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
+ }
614
789
 
615
- if (owner === undefined) {
616
- this.sendErr(req.id, 'no_such_session', `no session '${parsed.data.parent}'`);
790
+ let parent: SessionID | null = null;
617
791
 
618
- return;
619
- }
792
+ if (data.parent !== undefined) {
793
+ const owner = ctx.collectSessions().find((s) => s.id === data.parent);
620
794
 
621
- // A sub-session spawning a sub-session of its own lands beside it,
622
- // so a set stays one level deep.
623
- parent = owner.parent ?? owner.id;
624
- }
795
+ if (owner === undefined) {
796
+ throw new DaemonError('no_such_session', `no session '${data.parent}'`);
797
+ }
625
798
 
626
- const session = this.ctx.spawnSession({
627
- cwd,
628
- name: name === '' ? basename(cwd) : name,
629
- prompt: parsed.data.prompt,
630
- cols: parsed.data.cols,
631
- rows: parsed.data.rows,
632
- resume: parsed.data.resume,
633
- namedBy: name === '' ? 'auto' : 'user',
634
- agent,
635
- parent,
636
- });
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
+ }
637
803
 
638
- 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);
639
827
  }
640
828
 
641
- private applyAttach(req: RequestMsg): void {
829
+ private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
642
830
  const parsed = parseRequestParams('session.attach', req.p);
643
831
 
644
832
  if (!parsed.ok) {
@@ -649,7 +837,7 @@ export class DaemonConnection {
649
837
 
650
838
  const sessionID = parsed.data.session;
651
839
 
652
- const result = this.ctx.attachSession(this, sessionID, {
840
+ const result = ctx.attachSession(this, sessionID, {
653
841
  cols: parsed.data.cols,
654
842
  rows: parsed.data.rows,
655
843
  });
@@ -666,12 +854,12 @@ export class DaemonConnection {
666
854
  return;
667
855
  }
668
856
 
669
- const dims = this.ctx.getEffectiveDims(sessionID);
857
+ const dims = ctx.getEffectiveDims(sessionID);
670
858
 
671
859
  this.sendOk(req.id, { cols: dims.cols, rows: dims.rows });
672
860
  }
673
861
 
674
- private applyInput(req: RequestMsg): void {
862
+ private applyInput(req: RequestMsg, ctx: DaemonContext): void {
675
863
  const parsed = parseRequestParams('session.input', req.p);
676
864
 
677
865
  if (!parsed.ok) {
@@ -681,7 +869,7 @@ export class DaemonConnection {
681
869
  }
682
870
 
683
871
  const sessionID = parsed.data.session;
684
- const result = this.ctx.writeSessionInput(sessionID, parsed.data.d);
872
+ const result = ctx.writeSessionInput(sessionID, parsed.data.d);
685
873
 
686
874
  if (result === 'missing') {
687
875
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -708,7 +896,7 @@ export class DaemonConnection {
708
896
  this.sendOk(req.id, {});
709
897
  }
710
898
 
711
- private applyResize(req: RequestMsg): void {
899
+ private applyResize(req: RequestMsg, ctx: DaemonContext): void {
712
900
  const parsed = parseRequestParams('session.resize', req.p);
713
901
 
714
902
  if (!parsed.ok) {
@@ -719,9 +907,7 @@ export class DaemonConnection {
719
907
 
720
908
  const sessionID = parsed.data.session;
721
909
 
722
- if (
723
- !this.ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })
724
- ) {
910
+ if (!ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })) {
725
911
  this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
726
912
 
727
913
  return;
@@ -730,7 +916,7 @@ export class DaemonConnection {
730
916
  this.sendOk(req.id, {});
731
917
  }
732
918
 
733
- private applyPermissionRespond(req: RequestMsg): void {
919
+ private applyPermissionRespond(req: RequestMsg, ctx: DaemonContext): void {
734
920
  const parsed = parseRequestParams('permission.respond', req.p);
735
921
 
736
922
  if (!parsed.ok) {
@@ -741,7 +927,7 @@ export class DaemonConnection {
741
927
 
742
928
  const request = parsed.data.request;
743
929
  const decision = parsed.data.decision;
744
- const result = this.ctx.answerPermission(request, decision);
930
+ const result = ctx.answerPermission(request, decision);
745
931
 
746
932
  switch (result) {
747
933
  case 'ok': {
@@ -765,7 +951,7 @@ export class DaemonConnection {
765
951
  }
766
952
  }
767
953
 
768
- private async applySessionGet(req: RequestMsg): Promise<void> {
954
+ private async applySessionGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
769
955
  const parsed = parseRequestParams('session.get', req.p);
770
956
 
771
957
  if (!parsed.ok) {
@@ -776,7 +962,7 @@ export class DaemonConnection {
776
962
 
777
963
  const id = parsed.data.session;
778
964
 
779
- const record = await this.ctx.readSessionRecord(id);
965
+ const record = await ctx.readSessionRecord(id);
780
966
 
781
967
  if (record === 'missing') {
782
968
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -787,7 +973,7 @@ export class DaemonConnection {
787
973
  this.sendOk(req.id, { ...record });
788
974
  }
789
975
 
790
- private async applySessionRead(req: RequestMsg): Promise<void> {
976
+ private async applySessionRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
791
977
  const parsed = parseRequestParams('session.read', req.p);
792
978
 
793
979
  if (!parsed.ok) {
@@ -811,7 +997,7 @@ export class DaemonConnection {
811
997
  from = { path: decoded.path, offset: decoded.offset };
812
998
  }
813
999
 
814
- const read = await this.ctx.loadSessionTranscript(id, from, parsed.data.limit);
1000
+ const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
815
1001
 
816
1002
  if (read === 'missing') {
817
1003
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -830,7 +1016,7 @@ export class DaemonConnection {
830
1016
  }
831
1017
  }
832
1018
 
833
- private async applyEventsRead(req: RequestMsg): Promise<void> {
1019
+ private async applyEventsRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
834
1020
  const parsed = parseRequestParams('events.read', req.p);
835
1021
 
836
1022
  if (!parsed.ok) {
@@ -853,11 +1039,12 @@ export class DaemonConnection {
853
1039
  afterID = decoded.id;
854
1040
  }
855
1041
 
856
- const page = await this.ctx.readEvents(
1042
+ const page = await ctx.readEvents(
857
1043
  afterID,
858
1044
  parsed.data.limit,
859
1045
  parsed.data.waitMs,
860
1046
  parsed.data.session ?? null,
1047
+ null,
861
1048
  );
862
1049
 
863
1050
  const last = page.events.at(-1);
@@ -870,7 +1057,7 @@ export class DaemonConnection {
870
1057
  });
871
1058
  }
872
1059
 
873
- private async applySessionMessage(req: RequestMsg): Promise<void> {
1060
+ private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
874
1061
  const parsed = parseRequestParams('session.message', req.p);
875
1062
 
876
1063
  if (!parsed.ok) {
@@ -881,10 +1068,16 @@ export class DaemonConnection {
881
1068
 
882
1069
  const sessionID = parsed.data.session;
883
1070
 
884
- 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(
885
1077
  sessionID,
886
1078
  parsed.data.from,
887
1079
  parsed.data.text,
1080
+ keyed,
888
1081
  );
889
1082
 
890
1083
  if (result === 'missing') {
@@ -915,10 +1108,10 @@ export class DaemonConnection {
915
1108
  return;
916
1109
  }
917
1110
 
918
- this.sendOk(req.id, { message: result.id, status: result.status });
1111
+ this.sendOk(req.id, result);
919
1112
  }
920
1113
 
921
- private applyTap(req: RequestMsg): void {
1114
+ private applyTap(req: RequestMsg, ctx: DaemonContext): void {
922
1115
  const parsed = parseRequestParams('session.tap', req.p);
923
1116
 
924
1117
  if (!parsed.ok) {
@@ -928,7 +1121,7 @@ export class DaemonConnection {
928
1121
  }
929
1122
 
930
1123
  const sessionID = parsed.data.session;
931
- const result = this.ctx.attachTap(this, sessionID);
1124
+ const result = ctx.attachTap(this, sessionID);
932
1125
 
933
1126
  if (result === 'missing') {
934
1127
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -945,7 +1138,7 @@ export class DaemonConnection {
945
1138
  this.sendOk(req.id, {});
946
1139
  }
947
1140
 
948
- private async applyMessageGet(req: RequestMsg): Promise<void> {
1141
+ private async applyMessageGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
949
1142
  const parsed = parseRequestParams('message.get', req.p);
950
1143
 
951
1144
  if (!parsed.ok) {
@@ -956,7 +1149,7 @@ export class DaemonConnection {
956
1149
 
957
1150
  const messageID = parsed.data.message;
958
1151
 
959
- const view = await this.ctx.readMessage(messageID, parsed.data.waitMs);
1152
+ const view = await ctx.readMessage(messageID, parsed.data.waitMs);
960
1153
 
961
1154
  if (view === null) {
962
1155
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
@@ -981,7 +1174,7 @@ export class DaemonConnection {
981
1174
  });
982
1175
  }
983
1176
 
984
- private async applyMessageAck(req: RequestMsg): Promise<void> {
1177
+ private async applyMessageAck(req: RequestMsg, ctx: DaemonContext): Promise<void> {
985
1178
  const parsed = parseRequestParams('message.ack', req.p);
986
1179
 
987
1180
  if (!parsed.ok) {
@@ -993,7 +1186,7 @@ export class DaemonConnection {
993
1186
  const sessionID = parsed.data.session;
994
1187
  const messageID = parsed.data.message;
995
1188
 
996
- const result = await this.ctx.ackMessage(this, sessionID, messageID);
1189
+ const result = await ctx.ackMessage(this, sessionID, messageID);
997
1190
 
998
1191
  if (result === 'not_tapping') {
999
1192
  this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
@@ -1038,6 +1231,21 @@ export class DaemonConnection {
1038
1231
  const parsedHello = parseRequestParams('daemon.hello', req.p);
1039
1232
  const client = parsedHello.ok ? parsedHello.data.client : 'unknown client';
1040
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
+
1041
1249
  if (req.v !== PROTOCOL_V) {
1042
1250
  this.sendErr(
1043
1251
  req.id,
@@ -1059,6 +1267,7 @@ export class DaemonConnection {
1059
1267
  private async sendHelloOk(id: number): Promise<void> {
1060
1268
  this.sendOk(id, {
1061
1269
  daemon: this.ctx.build,
1270
+ daemonID: this.ctx.daemonID,
1062
1271
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
1063
1272
  features: DAEMON_FEATURES,
1064
1273
  lastUsedAgent: await this.ctx.loadLastUsedAgent(),
@@ -1069,7 +1278,34 @@ export class DaemonConnection {
1069
1278
  this.queue.send(encodeMessage({ v: PROTOCOL_V, id, ok }));
1070
1279
  }
1071
1280
 
1072
- private sendErr(id: number, code: ErrorCode, msg: string): void {
1073
- 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']);
1074
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;
1075
1311
  }