@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -1,5 +1,6 @@
1
- import { basename } from 'node:path';
2
- import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
1
+ import { basename, isAbsolute } from 'node:path';
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,30 @@ import {
14
15
  encodeMessage,
15
16
  } from '../protocol/protocol';
16
17
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
18
+ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
19
+ import type { TargetConfigError } from '../shared/collect-targets';
20
+ import type { DaemonID } from '../shared/daemon-id';
17
21
  import type { MessageID } from '../shared/message-id';
22
+ import { isRecord } from '../shared/report';
18
23
  import type { SessionID } from '../shared/session-id';
24
+ import { toSessionID } from '../shared/to-session-id';
19
25
  import type { FleetEntry } from '../store/fleet-entry';
20
26
  import type { MessageRecord } from '../store/message-record';
21
27
  import type { Dims } from './attach-registry';
22
28
  import type { AgentEntry } from './build-agent-list';
23
29
  import type { FleetEvent } from './build-fleet-events';
30
+ import { buildPayloadHash } from './build-payload-hash';
31
+ import { buildScopedContext } from './build-scoped-context';
32
+ import type { TargetEntry } from './build-target-list';
33
+ import type { KeyedRequest } from './idempotency-ledger';
24
34
  import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
35
+ import { parseSpawnOverrides } from './parse-spawn-overrides';
25
36
  import type { AnswerResult } from './permission-registry';
26
37
  import type { ScreenText } from './screen-model';
27
38
  import type { SessionDescriptor } from './sessions';
39
+ import type { TargetAccess, TargetGrant } from './target-access';
28
40
 
29
- interface SpawnParams {
41
+ export interface SpawnParams {
30
42
  readonly cwd: string;
31
43
  readonly name: string;
32
44
  readonly prompt: string;
@@ -36,6 +48,12 @@ interface SpawnParams {
36
48
  readonly namedBy: 'user' | 'auto';
37
49
  readonly agent: AgentID;
38
50
  readonly parent: SessionID | null;
51
+ readonly overrides: SpawnOverrides;
52
+ readonly target: string;
53
+
54
+ // Where the session's working directory comes from; null runs the
55
+ // session in cwd as it stands.
56
+ readonly workspace: SpawnWorkspaceSource | null;
39
57
  }
40
58
 
41
59
  interface SessionRecord {
@@ -48,6 +66,15 @@ interface SessionRecord {
48
66
 
49
67
  // A transcript page with the file it came from, so a cursor into a replaced
50
68
  // transcript is distinguishable from one into a grown transcript.
69
+ /**
70
+ * What `session.forget` answers: the token a forget that destroys a host
71
+ * must carry, with the time it stops being taken; or the forgotten session,
72
+ * with whether its host was destroyed.
73
+ */
74
+ type ForgetResult =
75
+ | { readonly confirmToken: string; readonly expiresAt: number }
76
+ | { readonly forgotten: true; readonly destroyed: boolean };
77
+
51
78
  interface SessionTranscriptRead {
52
79
  readonly path: string;
53
80
  readonly page: TranscriptPage;
@@ -55,14 +82,58 @@ interface SessionTranscriptRead {
55
82
 
56
83
  export interface DaemonContext {
57
84
  readonly build: string;
85
+ readonly daemonID: DaemonID;
58
86
  readonly collectSessions: () => SessionDescriptor[];
59
87
  readonly collectSpawnDirs: () => Promise<string[]>;
60
88
  readonly collectAgents: () => AgentList;
61
89
  readonly collectFleet: () => Promise<FleetEntry[]>;
62
90
  readonly loadLastUsedAgent: () => Promise<AgentID>;
63
91
  readonly findAdapter: (id: AgentID) => AgentAdapter | null;
64
- readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
92
+
93
+ // The targets a principal may use.
94
+ readonly buildTargetAccess: (principal: string) => TargetAccess;
95
+
96
+ // The target and identity a session is bound to, or null when no
97
+ // session holds the id.
98
+ readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
99
+
100
+ // The identity a target holds now, or null when the config holds no such
101
+ // target.
102
+ readonly findTargetIdentity: (target: string) => string | null;
103
+
104
+ // The ids of a session's sub-sessions.
105
+ readonly collectChildIDs: (id: SessionID) => SessionID[];
106
+
107
+ // The session a permission request belongs to, answered or not, or null
108
+ // for an unknown request.
109
+ readonly findPermissionSession: (request: string) => SessionID | null;
110
+
111
+ // The target a spawn runs on: the one it names, else the default. Throws
112
+ // the refusal for a target the spawn cannot run on, and for a spawn
113
+ // without a target when the config gives no default.
114
+ readonly resolveSpawnTarget: (requested: string | undefined) => string;
115
+
116
+ // Throws the refusal for a target that cannot materialize a workspace:
117
+ // one whose provider cannot both transfer an archive and run a command.
118
+ readonly requireWorkspaceTarget: (target: string) => void;
119
+
120
+ // Runs the plan, which throws the refusal for a spawn it refuses, then
121
+ // spawns. Answers with the `session.spawn` ok payload, which a keyed
122
+ // retry replays as the first spawn answered it while the access, when
123
+ // there is one, still reaches the target the spawn's session was bound to.
124
+ readonly spawnSession: (
125
+ plan: () => SpawnParams,
126
+ keyed: KeyedRequest | null,
127
+ access: TargetAccess | null,
128
+ ) => Promise<Readonly<Record<string, unknown>>>;
65
129
  readonly killSession: (id: SessionID) => Promise<boolean>;
130
+
131
+ // Forgets a session, or answers with the token a forget that destroys a
132
+ // host must carry. Throws the refusal for a token it does not take.
133
+ readonly forgetSession: (
134
+ id: SessionID,
135
+ confirmToken: string | undefined,
136
+ ) => Promise<ForgetResult | 'missing'>;
66
137
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
67
138
  readonly quitDaemon: () => void;
68
139
  readonly ackSession: (id: SessionID) => boolean;
@@ -81,6 +152,13 @@ export interface DaemonContext {
81
152
  sessionID: SessionID,
82
153
  data: string,
83
154
  ) => 'busy' | 'ok' | 'missing' | 'dead';
155
+
156
+ // Types a line into the session and submits it the way the session's
157
+ // agent accepts a line, as opposed to the raw bytes the input write takes.
158
+ readonly writeSessionLine: (
159
+ sessionID: SessionID,
160
+ text: string,
161
+ ) => 'busy' | 'ok' | 'missing' | 'dead';
84
162
  readonly ejectSession: (
85
163
  id: SessionID,
86
164
  prompt: string,
@@ -89,7 +167,7 @@ export interface DaemonContext {
89
167
  id: SessionID,
90
168
  cols: number,
91
169
  rows: number,
92
- ) => 'ok' | 'missing' | 'no_transcript';
170
+ ) => Promise<'ok' | 'missing' | 'no_transcript'>;
93
171
  readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
94
172
  readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
95
173
  readonly queueBytes?: number;
@@ -100,17 +178,25 @@ export interface DaemonContext {
100
178
  from: TranscriptPosition | null,
101
179
  limit: number,
102
180
  ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
181
+
182
+ // Reads the trail, leaving out each event of a session outside the access
183
+ // when there is one.
103
184
  readonly readEvents: (
104
185
  afterID: number | null,
105
186
  limit: number,
106
187
  waitMs: number,
107
188
  sessionID: SessionID | null,
189
+ access: TargetAccess | null,
108
190
  ) => Promise<EventsPage>;
191
+
192
+ // Answers with the `session.message` ok payload, which a keyed retry
193
+ // replays with the message's current status, or with the refusal.
109
194
  readonly writeSessionMessage: (
110
195
  sessionID: SessionID,
111
196
  from: string,
112
197
  text: string,
113
- ) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
198
+ keyed: KeyedRequest | null,
199
+ ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
114
200
  readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
115
201
  readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
116
202
  readonly ackMessage: (
@@ -126,7 +212,10 @@ export interface OutputClient {
126
212
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
127
213
  }
128
214
 
129
- // The `agents.list` answer: the host the daemon runs on, and each agent.
215
+ // The `agents.list` answer: the host the daemon runs on, each agent, each
216
+ // execution target, what a spawn without either runs with, a digest of
217
+ // the target config, and the target config problems the daemon started
218
+ // with.
130
219
  interface AgentList {
131
220
  readonly daemon: {
132
221
  readonly hostname: string;
@@ -135,6 +224,10 @@ interface AgentList {
135
224
  readonly build: string;
136
225
  };
137
226
  readonly agents: readonly AgentEntry[];
227
+ readonly targets: readonly TargetEntry[];
228
+ readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
229
+ readonly configRevision: string;
230
+ readonly targetErrors: readonly TargetConfigError[];
138
231
  }
139
232
 
140
233
  // One `events.read` answer: the events, and whether more follow them.
@@ -151,11 +244,23 @@ interface MessageView {
151
244
  readonly answeredWith: readonly MessageID[];
152
245
  }
153
246
 
247
+ // Why a session refuses a message before the daemon accepts it.
248
+ export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
249
+
154
250
  // The slice of a connection a tap subscription needs.
155
251
  export interface TapClient {
156
252
  readonly sendEvent: (event: EventMsg) => void;
157
253
  }
158
254
 
255
+ // The targets a request may use and the namespace of its idempotency keys.
256
+ interface RequestScope {
257
+ readonly access: TargetAccess;
258
+ readonly keyNamespace: string;
259
+ }
260
+
261
+ // The requests that act on the whole daemon, which only its owner may make.
262
+ const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
263
+
159
264
  interface PeerSocket extends SocketWriter {
160
265
  readonly end: () => void;
161
266
  }
@@ -180,6 +285,17 @@ export class DaemonConnection {
180
285
 
181
286
  private readonly desynced = new Map<SessionID, number>();
182
287
 
288
+ // The principal the whole connection acts as, from its handshake, and the
289
+ // targets that principal may use; null for the daemon's owner, whose
290
+ // reach has no limit.
291
+ private principal: string | null = null;
292
+
293
+ private access: TargetAccess | null = null;
294
+
295
+ // The sessions and permission requests this limited connection was shown,
296
+ // so the events that end them reach it after the session is gone.
297
+ private readonly shown = new Set<string>();
298
+
183
299
  constructor(peer: PeerSocket, ctx: DaemonContext) {
184
300
  this.peer = peer;
185
301
  this.ctx = ctx;
@@ -195,7 +311,11 @@ export class DaemonConnection {
195
311
  }
196
312
 
197
313
  sendEvent(event: EventMsg): void {
198
- if (this.helloed && !this.queue.send(encodeMessage(event))) {
314
+ if (!this.helloed || !this.canSeeEvent(event)) {
315
+ return;
316
+ }
317
+
318
+ if (!this.queue.send(encodeMessage(event))) {
199
319
  this.peer.end();
200
320
  }
201
321
  }
@@ -298,18 +418,88 @@ export class DaemonConnection {
298
418
  return false;
299
419
  }
300
420
 
301
- this.answerAsync(req.id, () => this.applyRequest(req));
421
+ const scope = this.findRequestScope(req);
422
+
423
+ if (scope !== null && OWNER_METHODS.has(req.m)) {
424
+ this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
425
+
426
+ return true;
427
+ }
428
+
429
+ const ctx =
430
+ scope === null ? this.ctx : buildScopedContext(this.ctx, scope.access, scope.keyNamespace);
431
+
432
+ this.answerAsync(req.id, () => this.applyRequest(req, ctx));
433
+
434
+ return true;
435
+ }
436
+
437
+ // The targets a request may use and the namespace its idempotency keys
438
+ // live in: the request's own principal, limited to what the connection's
439
+ // may use, else the connection's. Null is the daemon's owner. The owner
440
+ // may act as any principal, that principal's keys included; a connection
441
+ // that acts as a principal always keeps its own keys, whatever principal
442
+ // a request acts as.
443
+ private findRequestScope(req: RequestMsg): RequestScope | null {
444
+ if (req.as === undefined) {
445
+ return this.principal === null || this.access === null
446
+ ? null
447
+ : { access: this.access, keyNamespace: `client:${this.principal}` };
448
+ }
449
+
450
+ const access = this.ctx.buildTargetAccess(req.as);
451
+
452
+ if (this.principal === null || this.access === null) {
453
+ return { access, keyNamespace: `client:${req.as}` };
454
+ }
455
+
456
+ return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
457
+ }
458
+
459
+ // Whether this connection may see an event: always for the daemon's
460
+ // owner, and otherwise only for an event of a session the connection may
461
+ // see or was already shown.
462
+ private canSeeEvent(event: EventMsg): boolean {
463
+ if (this.access === null) {
464
+ return true;
465
+ }
466
+
467
+ const sessionID = findEventSession(event);
468
+ const request = typeof event['request'] === 'string' ? event['request'] : null;
469
+
470
+ if (sessionID === null) {
471
+ return request !== null && this.shown.has(`request:${request}`);
472
+ }
473
+
474
+ const grant = this.ctx.findSessionGrant(sessionID);
475
+
476
+ if (grant === null || !this.access.canUse(grant)) {
477
+ return this.shown.has(`session:${sessionID}`);
478
+ }
479
+
480
+ this.shown.add(`session:${sessionID}`);
481
+
482
+ if (request !== null) {
483
+ this.shown.add(`request:${request}`);
484
+ }
302
485
 
303
486
  return true;
304
487
  }
305
488
 
306
489
  // 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.
490
+ // floating promise here would take the whole process down with it. A
491
+ // rejection that carries a protocol error code answers with that code.
308
492
  private answerAsync(id: number, answered: () => Promise<void>): void {
309
493
  void (async () => {
310
494
  try {
311
495
  await answered();
312
496
  } catch (error) {
497
+ if (error instanceof DaemonError) {
498
+ this.sendErr(id, error.code, error.message, error.data);
499
+
500
+ return;
501
+ }
502
+
313
503
  const reason = error instanceof Error ? error.message : String(error);
314
504
 
315
505
  this.sendErr(id, 'internal', reason);
@@ -317,7 +507,7 @@ export class DaemonConnection {
317
507
  })();
318
508
  }
319
509
 
320
- private async applyRequest(req: RequestMsg): Promise<void> {
510
+ private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
321
511
  await this.helloAnswered;
322
512
 
323
513
  switch (req.m) {
@@ -327,33 +517,33 @@ export class DaemonConnection {
327
517
  return;
328
518
  }
329
519
  case 'session.list': {
330
- this.sendOk(req.id, { sessions: this.ctx.collectSessions() });
520
+ this.sendOk(req.id, { sessions: ctx.collectSessions() });
331
521
 
332
522
  return;
333
523
  }
334
524
  case 'agents.list': {
335
- this.sendOk(req.id, { ...this.ctx.collectAgents() });
525
+ this.sendOk(req.id, { ...ctx.collectAgents() });
336
526
 
337
527
  return;
338
528
  }
339
529
  case 'dirs.list': {
340
- this.sendOk(req.id, { dirs: await this.ctx.collectSpawnDirs() });
530
+ this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
341
531
 
342
532
  return;
343
533
  }
344
534
  case 'fleet.list': {
345
- this.sendOk(req.id, { fleet: await this.ctx.collectFleet() });
535
+ this.sendOk(req.id, { fleet: await ctx.collectFleet() });
346
536
 
347
537
  return;
348
538
  }
349
539
  case 'session.spawn': {
350
- this.applySpawn(req);
540
+ await this.applySpawn(req, ctx);
351
541
 
352
542
  return;
353
543
  }
354
544
  case 'daemon.quit': {
355
545
  this.sendOk(req.id, {});
356
- this.ctx.quitDaemon();
546
+ ctx.quitDaemon();
357
547
 
358
548
  return;
359
549
  }
@@ -367,7 +557,7 @@ export class DaemonConnection {
367
557
  }
368
558
 
369
559
  const sessionID = parsed.data.session;
370
- const updated = this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
560
+ const updated = ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
371
561
 
372
562
  if (updated === 'child_pin') {
373
563
  this.sendErr(
@@ -384,12 +574,33 @@ export class DaemonConnection {
384
574
  return;
385
575
  }
386
576
  case 'session.kill': {
387
- await this.applySessionVerb(req, 'session.kill', this.ctx.killSession);
577
+ await this.applySessionVerb(req, 'session.kill', ctx.killSession);
388
578
 
389
579
  return;
390
580
  }
391
581
  case 'session.ack': {
392
- await this.applySessionVerb(req, 'session.ack', this.ctx.ackSession);
582
+ await this.applySessionVerb(req, 'session.ack', ctx.ackSession);
583
+
584
+ return;
585
+ }
586
+ case 'session.forget': {
587
+ const parsed = parseRequestParams('session.forget', req.p);
588
+
589
+ if (!parsed.ok) {
590
+ this.sendErr(req.id, 'bad_args', parsed.message);
591
+
592
+ return;
593
+ }
594
+
595
+ const id = parsed.data.session;
596
+
597
+ const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
598
+
599
+ if (forgotten === 'missing') {
600
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
601
+ } else {
602
+ this.sendOk(req.id, { ...forgotten });
603
+ }
393
604
 
394
605
  return;
395
606
  }
@@ -403,7 +614,7 @@ export class DaemonConnection {
403
614
  }
404
615
 
405
616
  const id = parsed.data.session;
406
- const command = this.ctx.buildResumeCommand(id);
617
+ const command = ctx.buildResumeCommand(id);
407
618
 
408
619
  if (command === null) {
409
620
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -424,7 +635,7 @@ export class DaemonConnection {
424
635
 
425
636
  const id = parsed.data.session;
426
637
 
427
- const screen = await this.ctx.readSessionScreen(id);
638
+ const screen = await ctx.readSessionScreen(id);
428
639
 
429
640
  if (screen === 'missing') {
430
641
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -446,7 +657,7 @@ export class DaemonConnection {
446
657
  }
447
658
 
448
659
  const sessionID = parsed.data.session;
449
- const result = this.ctx.ejectSession(sessionID, parsed.data.prompt);
660
+ const result = ctx.ejectSession(sessionID, parsed.data.prompt);
450
661
 
451
662
  if (result === 'ok') {
452
663
  this.sendOk(req.id, {});
@@ -478,7 +689,8 @@ export class DaemonConnection {
478
689
  }
479
690
 
480
691
  const sessionID = parsed.data.session;
481
- const adoptResult = this.ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
692
+
693
+ const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
482
694
 
483
695
  if (adoptResult === 'ok') {
484
696
  this.sendOk(req.id, {});
@@ -499,7 +711,7 @@ export class DaemonConnection {
499
711
  return;
500
712
  }
501
713
  case 'session.attach': {
502
- this.applyAttach(req);
714
+ this.applyAttach(req, ctx);
503
715
 
504
716
  return;
505
717
  }
@@ -512,43 +724,48 @@ export class DaemonConnection {
512
724
  return;
513
725
  }
514
726
 
515
- this.ctx.detachSession(this, parsed.data.session);
727
+ ctx.detachSession(this, parsed.data.session);
516
728
  this.sendOk(req.id, {});
517
729
 
518
730
  return;
519
731
  }
520
732
  case 'session.input': {
521
- this.applyInput(req);
733
+ this.applyInput(req, ctx);
734
+
735
+ return;
736
+ }
737
+ case 'session.submit': {
738
+ this.applySubmit(req, ctx);
522
739
 
523
740
  return;
524
741
  }
525
742
  case 'session.resize': {
526
- this.applyResize(req);
743
+ this.applyResize(req, ctx);
527
744
 
528
745
  return;
529
746
  }
530
747
  case 'permission.respond': {
531
- this.applyPermissionRespond(req);
748
+ this.applyPermissionRespond(req, ctx);
532
749
 
533
750
  return;
534
751
  }
535
752
  case 'session.message': {
536
- await this.applySessionMessage(req);
753
+ await this.applySessionMessage(req, ctx);
537
754
 
538
755
  return;
539
756
  }
540
757
  case 'session.tap': {
541
- this.applyTap(req);
758
+ this.applyTap(req, ctx);
542
759
 
543
760
  return;
544
761
  }
545
762
  case 'message.get': {
546
- await this.applyMessageGet(req);
763
+ await this.applyMessageGet(req, ctx);
547
764
 
548
765
  return;
549
766
  }
550
767
  case 'message.ack': {
551
- await this.applyMessageAck(req);
768
+ await this.applyMessageAck(req, ctx);
552
769
 
553
770
  return;
554
771
  }
@@ -562,23 +779,23 @@ export class DaemonConnection {
562
779
  }
563
780
 
564
781
  this.sendOk(req.id, {
565
- restored: await this.ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
782
+ restored: await ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
566
783
  });
567
784
 
568
785
  return;
569
786
  }
570
787
  case 'session.get': {
571
- await this.applySessionGet(req);
788
+ await this.applySessionGet(req, ctx);
572
789
 
573
790
  return;
574
791
  }
575
792
  case 'session.read': {
576
- await this.applySessionRead(req);
793
+ await this.applySessionRead(req, ctx);
577
794
 
578
795
  return;
579
796
  }
580
797
  case 'events.read': {
581
- await this.applyEventsRead(req);
798
+ await this.applyEventsRead(req, ctx);
582
799
 
583
800
  return;
584
801
  }
@@ -588,7 +805,7 @@ export class DaemonConnection {
588
805
  }
589
806
  }
590
807
 
591
- private applySpawn(req: RequestMsg): void {
808
+ private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
592
809
  const parsed = parseRequestParams('session.spawn', req.p);
593
810
 
594
811
  if (!parsed.ok) {
@@ -597,48 +814,91 @@ export class DaemonConnection {
597
814
  return;
598
815
  }
599
816
 
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}'`);
817
+ const data = parsed.data;
818
+
819
+ // Every refusal is thrown from the plan, which runs only once a key is
820
+ // claimed: a retry of a held key answers from the key without checking
821
+ // anything again, and a refused spawn drops its claim.
822
+ const plan = (): SpawnParams => {
823
+ // The target goes first: a config or target problem is the cause a
824
+ // spawn reports, ahead of any agent check that problem can skew.
825
+ const target = ctx.resolveSpawnTarget(data.target);
826
+ const agent: AgentID = data.agent ?? 'claude';
827
+ const adapter = ctx.findAdapter(agent);
828
+ const entry = ctx.collectAgents().agents.find((candidate) => candidate.id === agent);
829
+
830
+ if (adapter === null || entry === undefined) {
831
+ throw new DaemonError('unsupported', `no adapter for agent '${agent}'`);
832
+ }
606
833
 
607
- return;
608
- }
834
+ // A stand-in adapter declares no binary to check, so only a profiled
835
+ // agent's missing binary refuses the spawn.
836
+ if (adapter.profile !== undefined && !entry.installed) {
837
+ throw new DaemonError(
838
+ 'unsupported',
839
+ `agent '${agent}' is registered but not installed on this host`,
840
+ );
841
+ }
609
842
 
610
- let parent: SessionID | null = null;
843
+ const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
611
844
 
612
- if (parsed.data.parent !== undefined) {
613
- const owner = this.ctx.collectSessions().find((s) => s.id === parsed.data.parent);
845
+ if (!overrides.ok) {
846
+ throw new DaemonError(overrides.code, overrides.message);
847
+ }
614
848
 
615
- if (owner === undefined) {
616
- this.sendErr(req.id, 'no_such_session', `no session '${parsed.data.parent}'`);
849
+ // Checked before the spawn starts, so a target that cannot take a
850
+ // workspace refuses it before any git command runs.
851
+ if (data.workspace !== undefined) {
852
+ this.ctx.requireWorkspaceTarget(target);
853
+ }
617
854
 
618
- return;
855
+ // A workspace is materialized at cwd on the target, which only an
856
+ // absolute path names.
857
+ if (data.workspace !== undefined && !isAbsolute(data.cwd)) {
858
+ throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
619
859
  }
620
860
 
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
- }
861
+ let parent: SessionID | null = null;
625
862
 
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
- });
863
+ if (data.parent !== undefined) {
864
+ const owner = ctx.collectSessions().find((s) => s.id === data.parent);
865
+
866
+ if (owner === undefined) {
867
+ throw new DaemonError('no_such_session', `no session '${data.parent}'`);
868
+ }
637
869
 
638
- this.sendOk(req.id, { session });
870
+ // A sub-session spawning a sub-session of its own lands beside it,
871
+ // so a set stays one level deep.
872
+ parent = owner.parent ?? owner.id;
873
+ }
874
+
875
+ return {
876
+ cwd: data.cwd,
877
+ name: data.name === '' ? basename(data.cwd) : data.name,
878
+ prompt: data.prompt,
879
+ cols: data.cols,
880
+ rows: data.rows,
881
+ resume: data.resume,
882
+ namedBy: data.name === '' ? 'auto' : 'user',
883
+ agent,
884
+ parent,
885
+ overrides: overrides.overrides,
886
+ target,
887
+ workspace: data.workspace ?? null,
888
+ };
889
+ };
890
+
891
+ const keyed =
892
+ data.idempotencyKey === undefined
893
+ ? null
894
+ : { key: data.idempotencyKey, payloadHash: buildPayloadHash(data) };
895
+
896
+ const spawned = await ctx.spawnSession(plan, keyed, null);
897
+
898
+ this.sendOk(req.id, spawned);
639
899
  }
640
900
 
641
- private applyAttach(req: RequestMsg): void {
901
+ private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
642
902
  const parsed = parseRequestParams('session.attach', req.p);
643
903
 
644
904
  if (!parsed.ok) {
@@ -649,7 +909,7 @@ export class DaemonConnection {
649
909
 
650
910
  const sessionID = parsed.data.session;
651
911
 
652
- const result = this.ctx.attachSession(this, sessionID, {
912
+ const result = ctx.attachSession(this, sessionID, {
653
913
  cols: parsed.data.cols,
654
914
  rows: parsed.data.rows,
655
915
  });
@@ -666,12 +926,12 @@ export class DaemonConnection {
666
926
  return;
667
927
  }
668
928
 
669
- const dims = this.ctx.getEffectiveDims(sessionID);
929
+ const dims = ctx.getEffectiveDims(sessionID);
670
930
 
671
931
  this.sendOk(req.id, { cols: dims.cols, rows: dims.rows });
672
932
  }
673
933
 
674
- private applyInput(req: RequestMsg): void {
934
+ private applyInput(req: RequestMsg, ctx: DaemonContext): void {
675
935
  const parsed = parseRequestParams('session.input', req.p);
676
936
 
677
937
  if (!parsed.ok) {
@@ -681,8 +941,29 @@ export class DaemonConnection {
681
941
  }
682
942
 
683
943
  const sessionID = parsed.data.session;
684
- const result = this.ctx.writeSessionInput(sessionID, parsed.data.d);
685
944
 
945
+ this.sendInputResult(req, sessionID, ctx.writeSessionInput(sessionID, parsed.data.d));
946
+ }
947
+
948
+ private applySubmit(req: RequestMsg, ctx: DaemonContext): void {
949
+ const parsed = parseRequestParams('session.submit', req.p);
950
+
951
+ if (!parsed.ok) {
952
+ this.sendErr(req.id, 'bad_args', parsed.message);
953
+
954
+ return;
955
+ }
956
+
957
+ const sessionID = parsed.data.session;
958
+
959
+ this.sendInputResult(req, sessionID, ctx.writeSessionLine(sessionID, parsed.data.text));
960
+ }
961
+
962
+ private sendInputResult(
963
+ req: RequestMsg,
964
+ sessionID: SessionID,
965
+ result: 'busy' | 'ok' | 'missing' | 'dead',
966
+ ): void {
686
967
  if (result === 'missing') {
687
968
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
688
969
 
@@ -708,7 +989,7 @@ export class DaemonConnection {
708
989
  this.sendOk(req.id, {});
709
990
  }
710
991
 
711
- private applyResize(req: RequestMsg): void {
992
+ private applyResize(req: RequestMsg, ctx: DaemonContext): void {
712
993
  const parsed = parseRequestParams('session.resize', req.p);
713
994
 
714
995
  if (!parsed.ok) {
@@ -719,9 +1000,7 @@ export class DaemonConnection {
719
1000
 
720
1001
  const sessionID = parsed.data.session;
721
1002
 
722
- if (
723
- !this.ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })
724
- ) {
1003
+ if (!ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })) {
725
1004
  this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
726
1005
 
727
1006
  return;
@@ -730,7 +1009,7 @@ export class DaemonConnection {
730
1009
  this.sendOk(req.id, {});
731
1010
  }
732
1011
 
733
- private applyPermissionRespond(req: RequestMsg): void {
1012
+ private applyPermissionRespond(req: RequestMsg, ctx: DaemonContext): void {
734
1013
  const parsed = parseRequestParams('permission.respond', req.p);
735
1014
 
736
1015
  if (!parsed.ok) {
@@ -741,7 +1020,7 @@ export class DaemonConnection {
741
1020
 
742
1021
  const request = parsed.data.request;
743
1022
  const decision = parsed.data.decision;
744
- const result = this.ctx.answerPermission(request, decision);
1023
+ const result = ctx.answerPermission(request, decision);
745
1024
 
746
1025
  switch (result) {
747
1026
  case 'ok': {
@@ -765,7 +1044,7 @@ export class DaemonConnection {
765
1044
  }
766
1045
  }
767
1046
 
768
- private async applySessionGet(req: RequestMsg): Promise<void> {
1047
+ private async applySessionGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
769
1048
  const parsed = parseRequestParams('session.get', req.p);
770
1049
 
771
1050
  if (!parsed.ok) {
@@ -776,7 +1055,7 @@ export class DaemonConnection {
776
1055
 
777
1056
  const id = parsed.data.session;
778
1057
 
779
- const record = await this.ctx.readSessionRecord(id);
1058
+ const record = await ctx.readSessionRecord(id);
780
1059
 
781
1060
  if (record === 'missing') {
782
1061
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -787,7 +1066,7 @@ export class DaemonConnection {
787
1066
  this.sendOk(req.id, { ...record });
788
1067
  }
789
1068
 
790
- private async applySessionRead(req: RequestMsg): Promise<void> {
1069
+ private async applySessionRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
791
1070
  const parsed = parseRequestParams('session.read', req.p);
792
1071
 
793
1072
  if (!parsed.ok) {
@@ -811,7 +1090,7 @@ export class DaemonConnection {
811
1090
  from = { path: decoded.path, offset: decoded.offset };
812
1091
  }
813
1092
 
814
- const read = await this.ctx.loadSessionTranscript(id, from, parsed.data.limit);
1093
+ const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
815
1094
 
816
1095
  if (read === 'missing') {
817
1096
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -830,7 +1109,7 @@ export class DaemonConnection {
830
1109
  }
831
1110
  }
832
1111
 
833
- private async applyEventsRead(req: RequestMsg): Promise<void> {
1112
+ private async applyEventsRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
834
1113
  const parsed = parseRequestParams('events.read', req.p);
835
1114
 
836
1115
  if (!parsed.ok) {
@@ -853,11 +1132,12 @@ export class DaemonConnection {
853
1132
  afterID = decoded.id;
854
1133
  }
855
1134
 
856
- const page = await this.ctx.readEvents(
1135
+ const page = await ctx.readEvents(
857
1136
  afterID,
858
1137
  parsed.data.limit,
859
1138
  parsed.data.waitMs,
860
1139
  parsed.data.session ?? null,
1140
+ null,
861
1141
  );
862
1142
 
863
1143
  const last = page.events.at(-1);
@@ -870,7 +1150,7 @@ export class DaemonConnection {
870
1150
  });
871
1151
  }
872
1152
 
873
- private async applySessionMessage(req: RequestMsg): Promise<void> {
1153
+ private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
874
1154
  const parsed = parseRequestParams('session.message', req.p);
875
1155
 
876
1156
  if (!parsed.ok) {
@@ -881,10 +1161,16 @@ export class DaemonConnection {
881
1161
 
882
1162
  const sessionID = parsed.data.session;
883
1163
 
884
- const result = await this.ctx.writeSessionMessage(
1164
+ const keyed =
1165
+ parsed.data.idempotencyKey === undefined
1166
+ ? null
1167
+ : { key: parsed.data.idempotencyKey, payloadHash: buildPayloadHash(parsed.data) };
1168
+
1169
+ const result = await ctx.writeSessionMessage(
885
1170
  sessionID,
886
1171
  parsed.data.from,
887
1172
  parsed.data.text,
1173
+ keyed,
888
1174
  );
889
1175
 
890
1176
  if (result === 'missing') {
@@ -915,10 +1201,10 @@ export class DaemonConnection {
915
1201
  return;
916
1202
  }
917
1203
 
918
- this.sendOk(req.id, { message: result.id, status: result.status });
1204
+ this.sendOk(req.id, result);
919
1205
  }
920
1206
 
921
- private applyTap(req: RequestMsg): void {
1207
+ private applyTap(req: RequestMsg, ctx: DaemonContext): void {
922
1208
  const parsed = parseRequestParams('session.tap', req.p);
923
1209
 
924
1210
  if (!parsed.ok) {
@@ -928,7 +1214,7 @@ export class DaemonConnection {
928
1214
  }
929
1215
 
930
1216
  const sessionID = parsed.data.session;
931
- const result = this.ctx.attachTap(this, sessionID);
1217
+ const result = ctx.attachTap(this, sessionID);
932
1218
 
933
1219
  if (result === 'missing') {
934
1220
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -945,7 +1231,7 @@ export class DaemonConnection {
945
1231
  this.sendOk(req.id, {});
946
1232
  }
947
1233
 
948
- private async applyMessageGet(req: RequestMsg): Promise<void> {
1234
+ private async applyMessageGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
949
1235
  const parsed = parseRequestParams('message.get', req.p);
950
1236
 
951
1237
  if (!parsed.ok) {
@@ -956,7 +1242,7 @@ export class DaemonConnection {
956
1242
 
957
1243
  const messageID = parsed.data.message;
958
1244
 
959
- const view = await this.ctx.readMessage(messageID, parsed.data.waitMs);
1245
+ const view = await ctx.readMessage(messageID, parsed.data.waitMs);
960
1246
 
961
1247
  if (view === null) {
962
1248
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
@@ -981,7 +1267,7 @@ export class DaemonConnection {
981
1267
  });
982
1268
  }
983
1269
 
984
- private async applyMessageAck(req: RequestMsg): Promise<void> {
1270
+ private async applyMessageAck(req: RequestMsg, ctx: DaemonContext): Promise<void> {
985
1271
  const parsed = parseRequestParams('message.ack', req.p);
986
1272
 
987
1273
  if (!parsed.ok) {
@@ -993,7 +1279,7 @@ export class DaemonConnection {
993
1279
  const sessionID = parsed.data.session;
994
1280
  const messageID = parsed.data.message;
995
1281
 
996
- const result = await this.ctx.ackMessage(this, sessionID, messageID);
1282
+ const result = await ctx.ackMessage(this, sessionID, messageID);
997
1283
 
998
1284
  if (result === 'not_tapping') {
999
1285
  this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
@@ -1038,6 +1324,21 @@ export class DaemonConnection {
1038
1324
  const parsedHello = parseRequestParams('daemon.hello', req.p);
1039
1325
  const client = parsedHello.ok ? parsedHello.data.client : 'unknown client';
1040
1326
 
1327
+ // A principal the handshake cannot read would otherwise leave the
1328
+ // connection with the owner's reach.
1329
+ if (!parsedHello.ok && req.p?.['principal'] !== undefined) {
1330
+ this.sendErr(req.id, 'bad_args', parsedHello.message);
1331
+
1332
+ return false;
1333
+ }
1334
+
1335
+ const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
1336
+
1337
+ if (principal !== null) {
1338
+ this.principal = principal;
1339
+ this.access = this.ctx.buildTargetAccess(principal);
1340
+ }
1341
+
1041
1342
  if (req.v !== PROTOCOL_V) {
1042
1343
  this.sendErr(
1043
1344
  req.id,
@@ -1059,6 +1360,7 @@ export class DaemonConnection {
1059
1360
  private async sendHelloOk(id: number): Promise<void> {
1060
1361
  this.sendOk(id, {
1061
1362
  daemon: this.ctx.build,
1363
+ daemonID: this.ctx.daemonID,
1062
1364
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
1063
1365
  features: DAEMON_FEATURES,
1064
1366
  lastUsedAgent: await this.ctx.loadLastUsedAgent(),
@@ -1069,7 +1371,34 @@ export class DaemonConnection {
1069
1371
  this.queue.send(encodeMessage({ v: PROTOCOL_V, id, ok }));
1070
1372
  }
1071
1373
 
1072
- private sendErr(id: number, code: ErrorCode, msg: string): void {
1073
- this.queue.send(encodeMessage({ v: PROTOCOL_V, id, err: { code, msg } }));
1374
+ private sendErr(
1375
+ id: number,
1376
+ code: ErrorCode,
1377
+ msg: string,
1378
+ data?: Readonly<Record<string, unknown>>,
1379
+ ): void {
1380
+ this.queue.send(
1381
+ encodeMessage({
1382
+ v: PROTOCOL_V,
1383
+ id,
1384
+ err: { code, msg, ...(data === undefined ? {} : { data }) },
1385
+ }),
1386
+ );
1074
1387
  }
1075
1388
  }
1389
+
1390
+ // The session an event belongs to, from its session id or the session it
1391
+ // adds, or null for an event of no session.
1392
+ function findEventSession(event: EventMsg): SessionID | null {
1393
+ if (typeof event['s'] === 'string') {
1394
+ return toSessionID(event['s']);
1395
+ }
1396
+
1397
+ const session = event['session'];
1398
+
1399
+ if (isRecord(session) && typeof session['id'] === 'string') {
1400
+ return toSessionID(session['id']);
1401
+ }
1402
+
1403
+ return null;
1404
+ }