@zgeoff/atc 2.15.0 → 2.15.2

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 (67) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +2 -1
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-fleet-events.ts +10 -4
  19. package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
  20. package/src/daemon/build-message-trail-entry.ts +1 -1
  21. package/src/daemon/build-report-trail-entry.ts +1 -1
  22. package/src/daemon/build-report-view.ts +10 -4
  23. package/src/daemon/build-scoped-context.ts +64 -77
  24. package/src/daemon/build-session-message-event.ts +1 -1
  25. package/src/daemon/daemon-connection.ts +206 -288
  26. package/src/daemon/daemon-context.ts +291 -0
  27. package/src/daemon/daemon.ts +250 -94
  28. package/src/daemon/drain-inbox.ts +85 -0
  29. package/src/daemon/execution-provider.ts +12 -0
  30. package/src/daemon/hooks.ts +1 -7
  31. package/src/daemon/idempotency-ledger.ts +78 -18
  32. package/src/daemon/imp-harness.ts +41 -0
  33. package/src/daemon/is-tree-in-reach.ts +32 -0
  34. package/src/daemon/local-pty-provider.ts +45 -0
  35. package/src/daemon/parse-hook-line.ts +1 -1
  36. package/src/daemon/sessions.ts +175 -123
  37. package/src/daemon/start-headless-turn.ts +3 -3
  38. package/src/daemon/start-session-bridge.ts +2 -2
  39. package/src/daemon/tap-registry.ts +43 -5
  40. package/src/mcp/collect-clients.ts +1 -1
  41. package/src/mcp/collect-grants.ts +1 -1
  42. package/src/mcp/find-client-name.ts +1 -1
  43. package/src/mcp/mcp-auth-schema.ts +37 -0
  44. package/src/mcp/open-mcp-auth.ts +2 -2
  45. package/src/mcp/reconnecting-caller.ts +15 -6
  46. package/src/mcp/remove-client.ts +1 -1
  47. package/src/mcp/revoke-grant.ts +1 -1
  48. package/src/mcp/types.ts +0 -38
  49. package/src/mcp-http-server.ts +2 -1
  50. package/src/protocol/adapter-event.ts +25 -0
  51. package/src/protocol/count-session-states.ts +16 -0
  52. package/src/protocol/daemon-channel.ts +17 -0
  53. package/src/protocol/hook-event.ts +11 -0
  54. package/src/protocol/pinned-group-key.ts +2 -0
  55. package/src/protocol/session-state.ts +5 -0
  56. package/src/protocol/sort-grouped-session-views.ts +29 -0
  57. package/src/protocol/sort-session-views.ts +41 -0
  58. package/src/protocol/sortable-session-view.ts +13 -0
  59. package/src/shared/agent-id.ts +6 -0
  60. package/src/shared/collect-gateways.ts +1 -1
  61. package/src/shared/to-agent-id.ts +11 -0
  62. package/src/store/fleet-entry.ts +2 -2
  63. package/src/store/run-migrations.ts +55 -4
  64. package/src/store/state-store.ts +49 -13
  65. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  66. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  67. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -1,5 +1,4 @@
1
1
  import { basename, isAbsolute } from 'node:path';
2
- import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
3
2
  import { DaemonError } from '../protocol/daemon-error';
4
3
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
5
4
  import { decodeCursor } from '../protocol/decode-cursor';
@@ -16,248 +15,17 @@ import {
16
15
  encodeMessage,
17
16
  } from '../protocol/protocol';
18
17
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
19
- import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
20
- import type { TargetConfigError } from '../shared/collect-targets';
21
- import type { DaemonID } from '../shared/daemon-id';
22
- import type { MessageID } from '../shared/message-id';
18
+ import type { AgentID } from '../shared/agent-id';
23
19
  import { isRecord } from '../shared/report';
24
20
  import type { SessionID } from '../shared/session-id';
25
21
  import { toSessionID } from '../shared/to-session-id';
26
- import type { FleetEntry } from '../store/fleet-entry';
27
- import type { MessageRecord } from '../store/message-record';
28
- import type { Dims } from './attach-registry';
29
- import type { AgentEntry } from './build-agent-list';
30
- import type { FleetEvent } from './build-fleet-events';
31
22
  import { buildPayloadHash } from './build-payload-hash';
32
- import type { ReportView } from './build-report-view';
33
23
  import { buildScopedContext } from './build-scoped-context';
34
- import type { TargetEntry } from './build-target-list';
35
- import type { KeyedRequest } from './idempotency-ledger';
36
- import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
24
+ import { buildTargetForbiddenError } from './build-target-forbidden-error';
25
+ import type { DaemonContext, SpawnParams } from './daemon-context';
26
+ import type { TranscriptPosition } from './load-transcript-page';
37
27
  import { parseSpawnOverrides } from './parse-spawn-overrides';
38
- import type { AnswerResult } from './permission-registry';
39
- import type { ScreenText } from './screen-model';
40
- import type { SessionDescriptor } from './sessions';
41
- import type { TargetAccess, TargetGrant } from './target-access';
42
-
43
- export interface SpawnParams {
44
- readonly cwd: string;
45
- readonly name: string;
46
- readonly prompt: string;
47
- readonly cols: number;
48
- readonly rows: number;
49
- readonly resume: SpawnOptions['resume'];
50
- readonly namedBy: 'user' | 'auto';
51
- readonly agent: AgentID;
52
- readonly parent: SessionID | null;
53
- readonly overrides: SpawnOverrides;
54
- readonly target: string;
55
-
56
- // Where the session's working directory comes from; null runs the
57
- // session in cwd as it stands.
58
- readonly workspace: SpawnWorkspaceSource | null;
59
- }
60
-
61
- interface SessionRecord {
62
- readonly session: SessionDescriptor;
63
- readonly prompt: string | null;
64
- readonly lastActivityAt: number;
65
- readonly pending: { readonly message: string } | null;
66
- readonly result: string | null;
67
- }
68
-
69
- // A transcript page with the file it came from, so a cursor into a replaced
70
- // transcript is distinguishable from one into a grown transcript.
71
- /**
72
- * What `session.forget` answers: the token a forget that destroys a host
73
- * must carry, with the time it stops being taken; or the forgotten session,
74
- * with whether its host was destroyed.
75
- */
76
- type ForgetResult =
77
- | { readonly confirmToken: string; readonly expiresAt: number }
78
- | { readonly forgotten: true; readonly destroyed: boolean };
79
-
80
- interface SessionTranscriptRead {
81
- readonly path: string;
82
- readonly page: TranscriptPage;
83
- }
84
-
85
- export interface DaemonContext {
86
- readonly build: string;
87
- readonly daemonID: DaemonID;
88
- readonly collectSessions: () => SessionDescriptor[];
89
- readonly collectSpawnDirs: () => Promise<string[]>;
90
- readonly collectAgents: () => AgentList;
91
- readonly collectFleet: () => Promise<FleetEntry[]>;
92
- readonly loadLastUsedAgent: () => Promise<AgentID>;
93
- readonly findAdapter: (id: AgentID) => AgentAdapter | null;
94
-
95
- // The targets a principal may use.
96
- readonly buildTargetAccess: (principal: string) => TargetAccess;
97
-
98
- // The target and identity a session is bound to, or null when no
99
- // session holds the id.
100
- readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
101
-
102
- // The identity a target holds now, or null when the config holds no such
103
- // target.
104
- readonly findTargetIdentity: (target: string) => string | null;
105
-
106
- // The ids of a session's sub-sessions.
107
- readonly collectChildIDs: (id: SessionID) => SessionID[];
108
-
109
- // The session a permission request belongs to, answered or not, or null
110
- // for an unknown request.
111
- readonly findPermissionSession: (request: string) => SessionID | null;
112
-
113
- // The target a spawn runs on: the one it names, else the default. Throws
114
- // the refusal for a target the spawn cannot run on, and for a spawn
115
- // without a target when the config gives no default.
116
- readonly resolveSpawnTarget: (requested: string | undefined) => string;
117
-
118
- // Throws the refusal for a target that cannot materialize a workspace:
119
- // one whose provider cannot both transfer an archive and run a command.
120
- readonly requireWorkspaceTarget: (target: string) => void;
121
-
122
- // Runs the plan, which throws the refusal for a spawn it refuses, then
123
- // spawns. Answers with the `session.spawn` ok payload, which a keyed
124
- // retry replays as the first spawn answered it while the access, when
125
- // there is one, still reaches the target the spawn's session was bound to.
126
- readonly spawnSession: (
127
- plan: () => SpawnParams,
128
- keyed: KeyedRequest | null,
129
- access: TargetAccess | null,
130
- ) => Promise<Readonly<Record<string, unknown>>>;
131
- readonly killSession: (id: SessionID) => Promise<boolean>;
132
-
133
- // Forgets a session, or answers with the token a forget that destroys a
134
- // host must carry. Throws the refusal for a token it does not take.
135
- readonly forgetSession: (
136
- id: SessionID,
137
- confirmToken: string | undefined,
138
- ) => Promise<ForgetResult | 'missing'>;
139
- readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
140
- readonly quitDaemon: () => void;
141
- readonly ackSession: (id: SessionID) => boolean;
142
- readonly buildResumeCommand: (id: SessionID) => string | null;
143
- readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
144
- readonly answerPermission: (request: string, decision: string) => AnswerResult;
145
- readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
146
- readonly attachSession: (
147
- client: OutputClient,
148
- sessionID: SessionID,
149
- dims: Dims,
150
- ) => 'ok' | 'missing' | 'dead';
151
- readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
152
- readonly detachClient: (client: OutputClient) => void;
153
- readonly writeSessionInput: (
154
- sessionID: SessionID,
155
- data: string,
156
- ) => 'busy' | 'ok' | 'missing' | 'dead';
157
-
158
- // Types a line into the session and submits it the way the session's
159
- // agent accepts a line, as opposed to the raw bytes the input write takes.
160
- readonly writeSessionLine: (
161
- sessionID: SessionID,
162
- text: string,
163
- ) => 'busy' | 'ok' | 'missing' | 'dead';
164
- readonly ejectSession: (
165
- id: SessionID,
166
- prompt: string,
167
- ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
168
- readonly adoptSession: (
169
- id: SessionID,
170
- cols: number,
171
- rows: number,
172
- ) => Promise<'ok' | 'missing' | 'no_transcript'>;
173
- readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
174
- readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
175
- readonly queueBytes?: number;
176
- readonly getEffectiveDims: (sessionID: SessionID) => Dims;
177
- readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
178
- readonly loadSessionTranscript: (
179
- id: SessionID,
180
- from: TranscriptPosition | null,
181
- limit: number,
182
- ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
183
-
184
- // Reads the trail, leaving out each event of a session outside the access
185
- // when there is one.
186
- readonly readEvents: (
187
- afterID: number | null,
188
- limit: number,
189
- waitMs: number,
190
- sessionID: SessionID | null,
191
- access: TargetAccess | null,
192
- ) => Promise<EventsPage>;
193
-
194
- // One report by the trail id of its event, or null for a trail id that
195
- // holds no report, or whose report's session is outside the access when
196
- // there is one.
197
- readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
198
-
199
- // Answers with the `session.message` ok payload, which a keyed retry
200
- // replays with the message's current status, or with the refusal.
201
- readonly writeSessionMessage: (
202
- sessionID: SessionID,
203
- from: string,
204
- text: string,
205
- keyed: KeyedRequest | null,
206
- ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
207
- readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
208
- readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
209
- readonly ackMessage: (
210
- client: TapClient,
211
- sessionID: SessionID,
212
- messageID: MessageID,
213
- ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
214
- }
215
-
216
- // The slice of a connection the attach bookkeeping needs: identity plus the
217
- // ability to receive output events.
218
- export interface OutputClient {
219
- readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
220
- }
221
-
222
- // The `agents.list` answer: the host the daemon runs on, each agent, each
223
- // execution target, what a spawn without either runs with, a digest of
224
- // the target config, and the target config problems the daemon started
225
- // with.
226
- interface AgentList {
227
- readonly daemon: {
228
- readonly hostname: string;
229
- readonly platform: string;
230
- readonly arch: string;
231
- readonly build: string;
232
- };
233
- readonly agents: readonly AgentEntry[];
234
- readonly targets: readonly TargetEntry[];
235
- readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
236
- readonly configRevision: string;
237
- readonly targetErrors: readonly TargetConfigError[];
238
- }
239
-
240
- // One `events.read` answer: the events, and whether more follow them.
241
- interface EventsPage {
242
- readonly events: readonly FleetEvent[];
243
- readonly more: boolean;
244
- }
245
-
246
- // One message as `message.get` reports it: the session it belongs to now,
247
- // every field of the record, and the other messages its turn answered.
248
- interface MessageView {
249
- readonly session: SessionID;
250
- readonly record: MessageRecord;
251
- readonly answeredWith: readonly MessageID[];
252
- }
253
-
254
- // Why a session refuses a message before the daemon accepts it.
255
- export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
256
-
257
- // The slice of a connection a tap subscription needs.
258
- export interface TapClient {
259
- readonly sendEvent: (event: EventMsg) => void;
260
- }
28
+ import type { TargetAccess } from './target-access';
261
29
 
262
30
  // The targets a request may use and the namespace of its idempotency keys.
263
31
  interface RequestScope {
@@ -268,6 +36,13 @@ interface RequestScope {
268
36
  // The requests that act on the whole daemon, which only its owner may make.
269
37
  const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
270
38
 
39
+ // What a limited connection is sent for one event, and the sessions that
40
+ // event moved out of its view.
41
+ interface ViewUpdate {
42
+ readonly events: readonly EventMsg[];
43
+ readonly withdrawn: readonly SessionID[];
44
+ }
45
+
271
46
  interface PeerSocket extends SocketWriter {
272
47
  readonly end: () => void;
273
48
  }
@@ -297,9 +72,15 @@ export class DaemonConnection {
297
72
 
298
73
  private access: TargetAccess | null = null;
299
74
 
300
- // The sessions and permission requests this limited connection was shown,
301
- // so the events that end them reach it after the session is gone.
302
- private readonly shown = new Set<string>();
75
+ // The sessions in this limited connection's view, the sessions the daemon
76
+ // holds that the view leaves out, and the permission requests the
77
+ // connection was shown, so the events that end them reach it after the
78
+ // session is gone.
79
+ private readonly shownSessions = new Set<SessionID>();
80
+
81
+ private readonly withheldSessions = new Set<SessionID>();
82
+
83
+ private readonly shownRequests = new Set<string>();
303
84
 
304
85
  constructor(peer: PeerSocket, ctx: DaemonContext) {
305
86
  this.peer = peer;
@@ -316,12 +97,30 @@ export class DaemonConnection {
316
97
  }
317
98
 
318
99
  sendEvent(event: EventMsg): void {
319
- if (!this.helloed || !this.canSeeEvent(event)) {
100
+ if (!this.helloed) {
320
101
  return;
321
102
  }
322
103
 
323
- if (!this.queue.send(encodeMessage(event))) {
324
- this.peer.end();
104
+ const view =
105
+ this.access === null
106
+ ? { events: [event], withdrawn: [] }
107
+ : this.updateView(event, this.access);
108
+
109
+ for (const visible of view.events) {
110
+ if (!this.queue.send(encodeMessage(visible))) {
111
+ this.peer.end();
112
+
113
+ return;
114
+ }
115
+ }
116
+
117
+ // Output and inbox messages of a session that left the view stop with
118
+ // it. The detach runs once the view is settled, since it emits an event
119
+ // of its own.
120
+ for (const id of view.withdrawn) {
121
+ this.desynced.delete(id);
122
+ this.ctx.detachSession(this, id);
123
+ this.ctx.detachTap(this, id);
325
124
  }
326
125
  }
327
126
 
@@ -330,7 +129,7 @@ export class DaemonConnection {
330
129
  // intermediate chunk is never dropped without that resync, because a byte
331
130
  // stream cut mid-escape corrupts the client's terminal state.
332
131
  sendOutput(sessionID: SessionID, event: EventMsg, byteLength: number): void {
333
- if (!this.helloed) {
132
+ if (!this.helloed || !this.canSeeOutput(sessionID)) {
334
133
  return;
335
134
  }
336
135
 
@@ -380,11 +179,21 @@ export class DaemonConnection {
380
179
 
381
180
  for (const [sessionID, dropped] of this.desynced) {
382
181
  this.desynced.delete(sessionID);
383
- this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
384
- void this.ctx.resyncClient(sessionID, this);
182
+
183
+ if (this.canSeeOutput(sessionID)) {
184
+ this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
185
+ void this.ctx.resyncClient(sessionID, this);
186
+ }
385
187
  }
386
188
  }
387
189
 
190
+ // Whether this connection may receive the session's output: always for
191
+ // the daemon's owner, and otherwise only while its view holds the
192
+ // session's whole tree.
193
+ private canSeeOutput(sessionID: SessionID): boolean {
194
+ return this.access === null || this.ctx.canSeeSession(sessionID, this.access);
195
+ }
196
+
388
197
  // false: the connection is beyond recovery and gets closed.
389
198
  private applyLine(line: string): boolean {
390
199
  const decoded = decodeMessage(line);
@@ -451,34 +260,99 @@ export class DaemonConnection {
451
260
  return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
452
261
  }
453
262
 
454
- // Whether this connection may see an event: always for the daemon's
455
- // owner, and otherwise only for an event of a session the connection may
456
- // see or was already shown.
457
- private canSeeEvent(event: EventMsg): boolean {
458
- if (this.access === null) {
459
- return true;
460
- }
461
-
263
+ // Brings this limited connection's view up to date for an event and
264
+ // returns what the connection is sent for it, and the sessions that left
265
+ // the view. A session whose tree moved out of reach leaves the view as a
266
+ // removed session does; one whose tree came back into reach joins it as
267
+ // an added session does. The event itself reaches the connection when its
268
+ // session is in the view, or is gone from the daemon after the connection
269
+ // was shown it.
270
+ private updateView(event: EventMsg, access: TargetAccess): ViewUpdate {
462
271
  const sessionID = findEventSession(event);
463
272
  const request = typeof event['request'] === 'string' ? event['request'] : null;
273
+ const sent: EventMsg[] = [];
274
+ const withdrawn: SessionID[] = [];
275
+
276
+ for (const id of this.shownSessions) {
277
+ if (id !== sessionID && this.isWithheld(id, access)) {
278
+ this.shownSessions.delete(id);
279
+ this.withheldSessions.add(id);
280
+ withdrawn.push(id);
281
+ sent.push({ v: PROTOCOL_V, ev: 'SessionRemoved', s: id });
282
+ }
283
+ }
284
+
285
+ for (const id of this.withheldSessions) {
286
+ if (this.ctx.findSessionGrant(id) === null) {
287
+ this.withheldSessions.delete(id);
288
+ } else if (id !== sessionID && this.ctx.canSeeSession(id, access)) {
289
+ this.withheldSessions.delete(id);
290
+ this.shownSessions.add(id);
291
+ sent.push(...this.buildSessionAdded(id));
292
+ }
293
+ }
464
294
 
465
295
  if (sessionID === null) {
466
- return request !== null && this.shown.has(`request:${request}`);
296
+ if (request !== null && this.shownRequests.has(request)) {
297
+ sent.push(event);
298
+ }
299
+
300
+ return { events: sent, withdrawn };
301
+ }
302
+
303
+ if (this.ctx.canSeeSession(sessionID, access)) {
304
+ if (this.withheldSessions.delete(sessionID) && event.ev !== 'SessionAdded') {
305
+ sent.push(...this.buildSessionAdded(sessionID));
306
+ }
307
+
308
+ this.shownSessions.add(sessionID);
309
+
310
+ if (request !== null) {
311
+ this.shownRequests.add(request);
312
+ }
313
+
314
+ sent.push(event);
315
+
316
+ return { events: sent, withdrawn };
467
317
  }
468
318
 
469
- const grant = this.ctx.findSessionGrant(sessionID);
319
+ if (this.isWithheld(sessionID, access)) {
320
+ this.withheldSessions.add(sessionID);
470
321
 
471
- if (grant === null || !this.access.canUse(grant)) {
472
- return this.shown.has(`session:${sessionID}`);
322
+ if (this.shownSessions.delete(sessionID)) {
323
+ withdrawn.push(sessionID);
324
+ sent.push({ v: PROTOCOL_V, ev: 'SessionRemoved', s: sessionID });
325
+ }
326
+
327
+ return { events: sent, withdrawn };
473
328
  }
474
329
 
475
- this.shown.add(`session:${sessionID}`);
330
+ // The session is gone from the daemon.
331
+ this.withheldSessions.delete(sessionID);
476
332
 
477
- if (request !== null) {
478
- this.shown.add(`request:${request}`);
333
+ if (this.shownSessions.has(sessionID)) {
334
+ sent.push(event);
479
335
  }
480
336
 
481
- return true;
337
+ if (event.ev === 'SessionRemoved') {
338
+ this.shownSessions.delete(sessionID);
339
+ }
340
+
341
+ return { events: sent, withdrawn };
342
+ }
343
+
344
+ // Whether the daemon holds the session but the access does not reach its
345
+ // whole tree.
346
+ private isWithheld(id: SessionID, access: TargetAccess): boolean {
347
+ return this.ctx.findSessionGrant(id) !== null && !this.ctx.canSeeSession(id, access);
348
+ }
349
+
350
+ // The event that adds the session to a view, or none when the daemon no
351
+ // longer lists it.
352
+ private buildSessionAdded(id: SessionID): EventMsg[] {
353
+ const session = this.ctx.collectSessions().find((x) => x.id === id);
354
+
355
+ return session === undefined ? [] : [{ v: PROTOCOL_V, ev: 'SessionAdded', session }];
482
356
  }
483
357
 
484
358
  // A store query that rejects must end one request, never the daemon: a
@@ -522,12 +396,16 @@ export class DaemonConnection {
522
396
  return;
523
397
  }
524
398
  case 'dirs.list': {
525
- this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
399
+ this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs(null) });
526
400
 
527
401
  return;
528
402
  }
529
403
  case 'fleet.list': {
530
- this.sendOk(req.id, { fleet: await ctx.collectFleet() });
404
+ const fleet = await ctx.collectFleet();
405
+
406
+ this.sendOk(req.id, {
407
+ fleet: fleet.filter((entry) => ctx.isSessionVisible(entry.sessionID)),
408
+ });
531
409
 
532
410
  return;
533
411
  }
@@ -589,7 +467,7 @@ export class DaemonConnection {
589
467
 
590
468
  const id = parsed.data.session;
591
469
 
592
- const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
470
+ const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken);
593
471
 
594
472
  if (forgotten === 'missing') {
595
473
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -632,7 +510,7 @@ export class DaemonConnection {
632
510
 
633
511
  const screen = await ctx.readSessionScreen(id);
634
512
 
635
- if (screen === 'missing') {
513
+ if (screen === 'missing' || !ctx.isSessionVisible(id)) {
636
514
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
637
515
  } else if (screen === 'no_screen') {
638
516
  this.sendErr(req.id, 'session_dead', `session '${id}' has no captured screen`);
@@ -685,7 +563,12 @@ export class DaemonConnection {
685
563
 
686
564
  const sessionID = parsed.data.session;
687
565
 
688
- const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
566
+ const adoptResult = await ctx.adoptSession(
567
+ sessionID,
568
+ parsed.data.cols,
569
+ parsed.data.rows,
570
+ null,
571
+ );
689
572
 
690
573
  if (adoptResult === 'ok') {
691
574
  this.sendOk(req.id, {});
@@ -849,7 +732,7 @@ export class DaemonConnection {
849
732
  // Checked before the spawn starts, so a target that cannot take a
850
733
  // workspace refuses it before any git command runs.
851
734
  if (data.workspace !== undefined) {
852
- this.ctx.requireWorkspaceTarget(target);
735
+ ctx.requireWorkspaceTarget(target);
853
736
  }
854
737
 
855
738
  // A workspace is materialized at cwd on the target, which only an
@@ -861,15 +744,13 @@ export class DaemonConnection {
861
744
  let parent: SessionID | null = null;
862
745
 
863
746
  if (data.parent !== undefined) {
864
- const owner = ctx.collectSessions().find((s) => s.id === data.parent);
747
+ const resolved = ctx.resolveSpawnParent(data.parent);
865
748
 
866
- if (owner === undefined) {
749
+ if (resolved === 'missing') {
867
750
  throw new DaemonError('no_such_session', `no session '${data.parent}'`);
868
751
  }
869
752
 
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;
753
+ parent = resolved;
873
754
  }
874
755
 
875
756
  return {
@@ -895,6 +776,21 @@ export class DaemonConnection {
895
776
 
896
777
  const spawned = await ctx.spawnSession(plan, keyed, null);
897
778
 
779
+ const session = spawned['session'];
780
+
781
+ const sessionID =
782
+ isRecord(session) && typeof session['id'] === 'string' ? toSessionID(session['id']) : null;
783
+
784
+ const grant = sessionID === null ? null : ctx.findSessionGrant(sessionID);
785
+
786
+ // A live session whose tree left the view before the answer goes out
787
+ // gets the refusal a replay out of reach gets, holding no part of it.
788
+ // A replayed session no longer live was checked against its fleet row
789
+ // by the replay itself.
790
+ if (sessionID !== null && grant !== null && !ctx.isSessionVisible(sessionID)) {
791
+ throw buildTargetForbiddenError(grant.target);
792
+ }
793
+
898
794
  this.sendOk(req.id, spawned);
899
795
  }
900
796
 
@@ -1055,9 +951,9 @@ export class DaemonConnection {
1055
951
 
1056
952
  const id = parsed.data.session;
1057
953
 
1058
- const record = await ctx.readSessionRecord(id);
954
+ const record = await ctx.readSessionRecord(id, null);
1059
955
 
1060
- if (record === 'missing') {
956
+ if (record === 'missing' || !ctx.isSessionVisible(id)) {
1061
957
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
1062
958
 
1063
959
  return;
@@ -1092,7 +988,7 @@ export class DaemonConnection {
1092
988
 
1093
989
  const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
1094
990
 
1095
- if (read === 'missing') {
991
+ if (read === 'missing' || !ctx.isSessionVisible(id)) {
1096
992
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
1097
993
  } else if (read === 'unsupported') {
1098
994
  this.sendErr(
@@ -1140,11 +1036,14 @@ export class DaemonConnection {
1140
1036
  null,
1141
1037
  );
1142
1038
 
1143
- const last = page.events.at(-1);
1039
+ // An event of a session that left the view while the read waited is
1040
+ // left out, as the read leaves out every event out of reach.
1041
+ const events = page.events.filter((event) => ctx.isSessionVisible(toSessionID(event.session)));
1042
+ const last = events.at(-1);
1144
1043
 
1145
1044
  // A cursor always comes back so a client can long-poll from an empty trail.
1146
1045
  this.sendOk(req.id, {
1147
- events: page.events,
1046
+ events,
1148
1047
  cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
1149
1048
  more: page.more,
1150
1049
  });
@@ -1164,16 +1063,18 @@ export class DaemonConnection {
1164
1063
 
1165
1064
  const decoded = decodeCursor(parsed.data.report);
1166
1065
 
1167
- const view =
1066
+ const read =
1168
1067
  decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
1169
1068
 
1170
- if (view === null) {
1069
+ // Reach is checked against the session that sent the report, never the
1070
+ // session its view is named by.
1071
+ if (read === null || !ctx.isSessionVisible(read.owner)) {
1171
1072
  this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
1172
1073
 
1173
1074
  return;
1174
1075
  }
1175
1076
 
1176
- this.sendOk(req.id, { ...view });
1077
+ this.sendOk(req.id, { ...read.view });
1177
1078
  }
1178
1079
 
1179
1080
  private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
@@ -1197,9 +1098,10 @@ export class DaemonConnection {
1197
1098
  parsed.data.from,
1198
1099
  parsed.data.text,
1199
1100
  keyed,
1101
+ null,
1200
1102
  );
1201
1103
 
1202
- if (result === 'missing') {
1104
+ if (result === 'missing' || !ctx.isSessionVisible(sessionID)) {
1203
1105
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
1204
1106
 
1205
1107
  return;
@@ -1240,7 +1142,7 @@ export class DaemonConnection {
1240
1142
  }
1241
1143
 
1242
1144
  const sessionID = parsed.data.session;
1243
- const result = ctx.attachTap(this, sessionID);
1145
+ const result = ctx.attachTap(this, sessionID, null);
1244
1146
 
1245
1147
  if (result === 'missing') {
1246
1148
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -1270,7 +1172,7 @@ export class DaemonConnection {
1270
1172
 
1271
1173
  const view = await ctx.readMessage(messageID, parsed.data.waitMs);
1272
1174
 
1273
- if (view === null) {
1175
+ if (view === null || !ctx.isSessionVisible(view.session)) {
1274
1176
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
1275
1177
 
1276
1178
  return;
@@ -1278,6 +1180,12 @@ export class DaemonConnection {
1278
1180
 
1279
1181
  const record = view.record;
1280
1182
 
1183
+ // A message the turn also answered is listed only while the session it
1184
+ // was sent to is in view, checked in the step that sends.
1185
+ const answeredWith = view.answeredWith
1186
+ .filter((sibling) => ctx.isSessionVisible(sibling.atcID))
1187
+ .map((sibling) => sibling.id);
1188
+
1281
1189
  this.sendOk(req.id, {
1282
1190
  message: record.id,
1283
1191
  session: view.session,
@@ -1289,7 +1197,7 @@ export class DaemonConnection {
1289
1197
  ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
1290
1198
  ...(record.answer === undefined ? {} : { answer: record.answer }),
1291
1199
  turn: record.turn ?? null,
1292
- answeredWith: view.answeredWith,
1200
+ answeredWith,
1293
1201
  });
1294
1202
  }
1295
1203
 
@@ -1305,7 +1213,7 @@ export class DaemonConnection {
1305
1213
  const sessionID = parsed.data.session;
1306
1214
  const messageID = parsed.data.message;
1307
1215
 
1308
- const result = await ctx.ackMessage(this, sessionID, messageID);
1216
+ const result = await ctx.ackMessage(this, sessionID, messageID, null);
1309
1217
 
1310
1218
  if (result === 'not_tapping') {
1311
1219
  this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
@@ -1313,7 +1221,7 @@ export class DaemonConnection {
1313
1221
  return;
1314
1222
  }
1315
1223
 
1316
- if (result === 'unknown') {
1224
+ if (result === 'unknown' || !ctx.isSessionVisible(sessionID)) {
1317
1225
  this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
1318
1226
 
1319
1227
  return;
@@ -1361,8 +1269,18 @@ export class DaemonConnection {
1361
1269
  const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
1362
1270
 
1363
1271
  if (principal !== null) {
1272
+ const access = this.ctx.buildTargetAccess(principal);
1273
+
1364
1274
  this.principal = principal;
1365
- this.access = this.ctx.buildTargetAccess(principal);
1275
+ this.access = access;
1276
+
1277
+ for (const session of this.ctx.collectSessions()) {
1278
+ const view = this.ctx.canSeeSession(session.id, access)
1279
+ ? this.shownSessions
1280
+ : this.withheldSessions;
1281
+
1282
+ view.add(session.id);
1283
+ }
1366
1284
  }
1367
1285
 
1368
1286
  if (req.v !== PROTOCOL_V) {