@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -24,6 +24,7 @@
24
24
  "build:atc-bridge": "bun scripts/build-atc-bridge.ts && oxfmt src/agents/atc-bridge-files.ts && format-codemod --quiet src/agents/atc-bridge-files.ts",
25
25
  "build:binaries": "bash scripts/build-binaries.sh",
26
26
  "audit": "bun audit",
27
+ "check:imports": "bun scripts/check-imports.ts",
27
28
  "deadcode": "knip",
28
29
  "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
29
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
@@ -1,24 +1,9 @@
1
- import type { HookEvent } from '../daemon/hooks';
1
+ import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { HookEvent } from '../protocol/hook-event';
3
+ import type { AgentID } from '../shared/agent-id';
2
4
  import type { AgentSessionID } from '../shared/agent-session-id';
3
5
  import type { SessionID } from '../shared/session-id';
4
6
 
5
- /**
6
- * Which agent a session runs under: the key the adapter registry is looked
7
- * up by. Every agent CLI supplies one, and so does every configured backend
8
- * that drives a CLI it does not own, so two ids can share one kind.
9
- */
10
- export type AgentID = string;
11
-
12
- /**
13
- * Missing and empty values become Claude so a fleet written before the agent
14
- * column still restores as Claude. Any other string is returned as it stands,
15
- * registered or not: an id whose adapter is gone must reach the caller intact
16
- * so the session can be shown and refused, never quietly run as Claude.
17
- */
18
- export function toAgentID(raw: unknown): AgentID {
19
- return typeof raw === 'string' && raw !== '' ? raw : 'claude';
20
- }
21
-
22
7
  export interface SpawnOptions {
23
8
  readonly prompt: string;
24
9
 
@@ -64,26 +49,6 @@ export interface GuestSpawnPlan extends SpawnPlan {
64
49
  readonly files: Readonly<Record<string, string>>;
65
50
  }
66
51
 
67
- export interface AdapterEvent {
68
- kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
69
- agentSessionID?: AgentSessionID;
70
- message?: string;
71
-
72
- // Fuller activity text than message: what the agent last said or was
73
- // asked, for briefing. Bounded by the adapter.
74
- detail?: string;
75
-
76
- // Opaque handle the adapter can later pull a session name from.
77
- nameSource?: string;
78
-
79
- // Claude resume-existence path. Distinct from nameSource: a naming
80
- // handle is not a resume gate.
81
- transcriptSource?: string;
82
-
83
- // The agent's whole final message for a finished turn; detail holds a bounded preview of it.
84
- result?: string;
85
- }
86
-
87
52
  export interface TranscriptToolUse {
88
53
  readonly name: string;
89
54
  readonly input: string;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { AgentID } from './agent-adapter';
2
+ import type { AgentID } from '../shared/agent-id';
3
3
  import { buildCLICommand } from './build-cli-command';
4
4
 
5
5
  /**
@@ -1,14 +1,15 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { z } from 'zod';
3
- import type { HookEvent } from '../daemon/hooks';
3
+ import type { AdapterEvent } from '../protocol/adapter-event';
4
+ import type { HookEvent } from '../protocol/hook-event';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
5
6
  import { buildOptionalString } from '../shared/build-optional-string';
6
7
  import type { Config } from '../shared/config';
7
8
  import { isRecord } from '../shared/report';
8
9
  import { toAgentSessionID } from '../shared/to-agent-session-id';
9
10
  import { toShellArg } from '../shared/to-shell-arg';
11
+ import { truncateDetail } from '../shared/truncate-detail';
10
12
  import type {
11
- AdapterEvent,
12
13
  AgentAdapter,
13
14
  AgentProfile,
14
15
  GuestPaths,
@@ -31,7 +32,6 @@ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
31
32
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
32
33
  import { planTypedLineInput } from './plan-typed-line-input';
33
34
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
34
- import { truncateDetail } from './truncate-detail';
35
35
  import { writeATCBridge } from './write-atc-bridge';
36
36
  import { writeHookSettings } from './write-hook-settings';
37
37
 
@@ -1,15 +1,16 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import type { HookEvent } from '../daemon/hooks';
4
+ import type { AdapterEvent } from '../protocol/adapter-event';
5
+ import type { HookEvent } from '../protocol/hook-event';
5
6
  import type { AgentSessionID } from '../shared/agent-session-id';
6
7
  import { buildOptionalString } from '../shared/build-optional-string';
7
8
  import type { Config } from '../shared/config';
8
9
  import { isRecord } from '../shared/report';
9
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
10
11
  import { toShellArg } from '../shared/to-shell-arg';
12
+ import { truncateDetail } from '../shared/truncate-detail';
11
13
  import type {
12
- AdapterEvent,
13
14
  AgentAdapter,
14
15
  AgentProfile,
15
16
  NameUpdate,
@@ -22,7 +23,6 @@ import { buildArgsWithoutFlags } from './build-args-without-flags';
22
23
  import { findFlagValue } from './find-flag-value';
23
24
  import { planPastedLineInput } from './plan-pasted-line-input';
24
25
  import { resolveAgentHome } from './resolve-agent-home';
25
- import { truncateDetail } from './truncate-detail';
26
26
 
27
27
  // Codex's hook payload keys, snake_case. An absent or wrong-typed field
28
28
  // parses to undefined rather than failing the payload, so a broken reporter
@@ -1,12 +1,12 @@
1
- import type { HookEvent } from '../daemon/hooks';
1
+ import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { HookEvent } from '../protocol/hook-event';
3
+ import type { AgentID } from '../shared/agent-id';
2
4
  import type { AgentSessionID } from '../shared/agent-session-id';
3
5
  import type { GatewayConfig } from '../shared/collect-gateways';
4
6
  import type { Config } from '../shared/config';
5
7
  import { toShellArg } from '../shared/to-shell-arg';
6
8
  import type {
7
- AdapterEvent,
8
9
  AgentAdapter,
9
- AgentID,
10
10
  AgentProfile,
11
11
  HeadlessRunner,
12
12
  NameUpdate,
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import type { HookEvent } from '../daemon/hooks';
4
+ import type { AdapterEvent } from '../protocol/adapter-event';
5
+ import type { HookEvent } from '../protocol/hook-event';
5
6
  import type { AgentSessionID } from '../shared/agent-session-id';
6
7
  import { buildOptionalString } from '../shared/build-optional-string';
7
8
  import type { Config } from '../shared/config';
@@ -9,8 +10,8 @@ import { isRecord } from '../shared/report';
9
10
  import type { SessionID } from '../shared/session-id';
10
11
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
12
  import { toShellArg } from '../shared/to-shell-arg';
13
+ import { truncateDetail } from '../shared/truncate-detail';
12
14
  import type {
13
- AdapterEvent,
14
15
  AgentAdapter,
15
16
  AgentProfile,
16
17
  NameUpdate,
@@ -22,7 +23,6 @@ import type {
22
23
  import { normalizeHookEventName } from './normalize-hook-event';
23
24
  import { planPastedLineInput } from './plan-pasted-line-input';
24
25
  import { resolveAgentHome } from './resolve-agent-home';
25
- import { truncateDetail } from './truncate-detail';
26
26
 
27
27
  interface GrokSessionHookState {
28
28
  latestPromptID?: string;
@@ -1,6 +1,6 @@
1
- import { truncateSummary } from '../daemon/truncate-summary';
2
1
  import { isCompiledBinary } from '../shared/is-compiled-binary';
3
2
  import { isRecord } from '../shared/report';
3
+ import { truncateSummary } from '../shared/truncate-summary';
4
4
  import { buildClaudeQueryOptions } from './build-claude-query-options';
5
5
  import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
6
6
 
@@ -2,17 +2,17 @@ import { spawn as spawnChild } from 'node:child_process';
2
2
  import type { ChildProcess } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { toAgentID } from '../agents/agent-adapter';
6
- import type { AgentID } from '../agents/agent-adapter';
7
5
  import { DaemonError } from '../protocol/daemon-error';
8
6
  import type { DaemonFeature } from '../protocol/daemon-features';
9
7
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
10
8
  import { PROTOCOL_V } from '../protocol/protocol';
9
+ import type { AgentID } from '../shared/agent-id';
11
10
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
12
11
  import { findDaemonRecord } from '../shared/find-daemon-record';
13
12
  import { getBuild } from '../shared/get-build';
14
13
  import { isCompiledBinary } from '../shared/is-compiled-binary';
15
14
  import { makeSingleFlight } from '../shared/make-single-flight';
15
+ import { toAgentID } from '../shared/to-agent-id';
16
16
  import { DaemonClient } from './daemon-client';
17
17
  import { formatProtocolMismatch } from './format-protocol-mismatch';
18
18
  import type { ProtocolMismatch } from './format-protocol-mismatch';
@@ -1,4 +1,4 @@
1
- import type { AgentID } from '../agents/agent-adapter';
1
+ import type { AgentID } from '../shared/agent-id';
2
2
  import type { Config } from '../shared/config';
3
3
 
4
4
  export interface AgentPick {
@@ -1,3 +1,4 @@
1
+ import type { DaemonChannel } from '../protocol/daemon-channel';
1
2
  import { DaemonError } from '../protocol/daemon-error';
2
3
  import { LineDecoder } from '../protocol/line-decoder';
3
4
  import { OutboundQueue } from '../protocol/outbound-queue';
@@ -14,7 +15,7 @@ interface Pending {
14
15
  * event callback. `open` connects the socket; `sendHello` completes the
15
16
  * handshake and must come first.
16
17
  */
17
- export class DaemonClient {
18
+ export class DaemonClient implements DaemonChannel {
18
19
  onEvent: (event: EventMsg) => void = () => {};
19
20
 
20
21
  onClose: () => void = () => {};
@@ -1,4 +1,4 @@
1
- import type { AgentID } from '../agents/agent-adapter';
1
+ import type { AgentID } from '../shared/agent-id';
2
2
 
3
3
  const BUILT_IN_MARKS: Readonly<Record<AgentID, string>> = { grok: 'g', codex: 'x' };
4
4
 
@@ -1,9 +1,11 @@
1
1
  import { match } from 'ts-pattern';
2
2
  import { createActor } from 'xstate';
3
- import type { AgentID } from '../agents/agent-adapter';
4
- import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
3
+ import { countSessionStates } from '../protocol/count-session-states';
5
4
  import { DaemonError } from '../protocol/daemon-error';
6
5
  import type { EventMsg } from '../protocol/protocol';
6
+ import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
7
+ import { sortSessionViews } from '../protocol/sort-session-views';
8
+ import type { AgentID } from '../shared/agent-id';
7
9
  import { loadConfig } from '../shared/config';
8
10
  import { makeSingleFlight } from '../shared/make-single-flight';
9
11
  import { bootDaemonClient } from './boot-daemon';
@@ -1,5 +1,5 @@
1
- import type { SessionState } from '../daemon/sessions';
2
- import { sortSessionViews } from '../daemon/sessions';
1
+ import type { SessionState } from '../protocol/session-state';
2
+ import { sortSessionViews } from '../protocol/sort-session-views';
3
3
 
4
4
  interface TabCandidate {
5
5
  readonly id: string;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
- import type { AgentID } from '../agents/agent-adapter';
3
2
  import { DaemonError } from '../protocol/daemon-error';
3
+ import type { AgentID } from '../shared/agent-id';
4
4
  import { loadConfig } from '../shared/config';
5
5
  import { collectAgentPicks } from './collect-agent-picks';
6
6
  import type { AgentPick } from './collect-agent-picks';
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { toAgentID } from '../agents/agent-adapter';
3
- import type { AgentID } from '../agents/agent-adapter';
4
- import type { SessionState } from '../daemon/sessions';
2
+ import type { SessionState } from '../protocol/session-state';
3
+ import type { AgentID } from '../shared/agent-id';
4
+ import { toAgentID } from '../shared/to-agent-id';
5
5
 
6
6
  export interface MirrorSession {
7
7
  id: string;
package/src/client/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { AgentID } from '../agents/agent-adapter';
2
- import { PINNED_GROUP_KEY } from '../daemon/sessions';
3
- import type { SessionState } from '../daemon/sessions';
1
+ import { PINNED_GROUP_KEY } from '../protocol/pinned-group-key';
2
+ import type { SessionState } from '../protocol/session-state';
3
+ import type { AgentID } from '../shared/agent-id';
4
4
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
5
5
  import { formatDir } from './dirs';
6
6
  import { formatOverlayAgentMark } from './format-overlay-agent-mark';
@@ -18,18 +18,24 @@ export interface FleetEvent {
18
18
  readonly label?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Builds the events from the sessions that may name them. Rows written
23
+ * before atc session ids stayed stable across restores carry an earlier atc
24
+ * id, so a session of `aliases` that holds a row's agent session id names
25
+ * it when no session holds the atc id; every session that may name a row
26
+ * may alias it unless the caller gives fewer.
27
+ */
21
28
  export function buildFleetEvents(
22
29
  rows: readonly StoredEvent[],
23
30
  sessions: readonly SessionDescriptor[],
31
+ aliases: readonly SessionDescriptor[] = sessions,
24
32
  ): FleetEvent[] {
25
33
  return rows.map((row) => {
26
- // Rows written before atc session ids stayed stable across restores
27
- // carry an earlier atc id, so the agent session id links them.
28
34
  const live =
35
+ sessions.find((s) => s.id === row.atcID) ??
29
36
  (row.agentSessionID === null
30
37
  ? undefined
31
- : sessions.find((s) => s.agentSessionID === row.agentSessionID)) ??
32
- sessions.find((s) => s.id === row.atcID);
38
+ : aliases.find((s) => s.agentSessionID === row.agentSessionID));
33
39
 
34
40
  return {
35
41
  cursor: encodeCursor({ kind: 'events', id: row.id }),
@@ -0,0 +1,14 @@
1
+ import type { FleetEntry } from '../store/fleet-entry';
2
+ import { buildTargetIdentity } from './build-target-identity';
3
+ import type { TargetGrant } from './target-access';
4
+
5
+ /**
6
+ * The target a fleet row's session is bound to. A row that stores no
7
+ * target ran on the implicit `local` target, under its identity.
8
+ */
9
+ export function buildGrantFromFleetEntry(entry: FleetEntry): TargetGrant {
10
+ return {
11
+ target: entry.target ?? 'local',
12
+ targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
13
+ };
14
+ }
@@ -1,6 +1,6 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import type { AgentSessionID } from '../shared/agent-session-id';
3
2
  import type { SessionID } from '../shared/session-id';
3
+ import { truncateDetail } from '../shared/truncate-detail';
4
4
  import type { MessageRecord, MessageStatus } from '../store/message-record';
5
5
  import type { TrailEntry } from '../store/trail-entry';
6
6
 
@@ -1,6 +1,6 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import type { AgentSessionID } from '../shared/agent-session-id';
3
2
  import type { SessionID } from '../shared/session-id';
3
+ import { truncateDetail } from '../shared/truncate-detail';
4
4
  import type { TrailEntry } from '../store/trail-entry';
5
5
  import type { NoteReport } from './parse-report';
6
6
 
@@ -17,17 +17,23 @@ export interface ReportView {
17
17
  readonly complete: boolean;
18
18
  }
19
19
 
20
+ /**
21
+ * Builds the view from the sessions that may name it. A row written before
22
+ * atc session ids stayed stable across restores carries an earlier atc id,
23
+ * so a session of `aliases` that holds its agent session id names it when
24
+ * no session holds the atc id. The alias only names the report: whoever
25
+ * checks who may read it checks the row's own atc id.
26
+ */
20
27
  export function buildReportView(
21
28
  stored: StoredReport,
22
29
  sessions: readonly SessionDescriptor[],
30
+ aliases: readonly SessionDescriptor[],
23
31
  ): ReportView {
24
- // A row written before atc session ids stayed stable across restores
25
- // carries an earlier atc id, so the agent session id links it.
26
32
  const live =
33
+ sessions.find((s) => s.id === stored.atcID) ??
27
34
  (stored.agentSessionID === null
28
35
  ? undefined
29
- : sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
30
- sessions.find((s) => s.id === stored.atcID);
36
+ : aliases.find((s) => s.agentSessionID === stored.agentSessionID));
31
37
 
32
38
  return {
33
39
  report: encodeCursor({ kind: 'events', id: stored.id }),
@@ -1,23 +1,25 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
2
  import type { SessionID } from '../shared/session-id';
3
3
  import { toSessionID } from '../shared/to-session-id';
4
+ import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
4
5
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
5
- import { buildTargetIdentity } from './build-target-identity';
6
- import type { DaemonContext } from './daemon-connection';
6
+ import type { DaemonContext } from './daemon-context';
7
7
  import type { KeyedRequest } from './idempotency-ledger';
8
8
  import type { TargetAccess, TargetGrant } from './target-access';
9
9
 
10
10
  /**
11
11
  * The daemon as a principal with the given access sees it. Its keyed
12
12
  * spawns and messages hold their keys in the given namespace, apart from
13
- * every other namespace's. A session on a target outside the access does
14
- * not exist here: every lookup of it answers as a lookup of an unknown
15
- * session does, so the request's own handling refuses it with the words
16
- * and data it gives a session that never existed. Lists leave such
17
- * sessions out, and a spawn may use only a target the access holds, a
18
- * spawn's replayed answer included. A kill, or a pin, of a session whose
19
- * sub-sessions reach past the access is refused whole, before anything
20
- * changes.
13
+ * every other namespace's. A session exists here only when the access
14
+ * reaches every session in its tree: its top-level session and each
15
+ * sub-session of that one. Every lookup of any other session answers as a
16
+ * lookup of an unknown session does, so the request's own handling refuses
17
+ * it with the words and data it gives a session that never existed. Lists
18
+ * leave such sessions out, the directory list leaves out directories
19
+ * spawned only on targets outside the access, and a spawn may use only a
20
+ * target the access holds, a spawn's replayed answer included. A kill, a
21
+ * forget, or a change checks the tree in the same synchronous step that
22
+ * starts it, so a sub-session added after the check is never part of it.
21
23
  */
22
24
  export function buildScopedContext(
23
25
  ctx: DaemonContext,
@@ -28,11 +30,11 @@ export function buildScopedContext(
28
30
  const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
29
31
  keyed === null ? null : { ...keyed, principal: keyNamespace };
30
32
 
31
- const canSee = (id: SessionID): boolean => {
32
- const grant = ctx.findSessionGrant(id);
33
+ const canSee = (id: SessionID): boolean => ctx.canSeeSession(id, access);
33
34
 
34
- return grant !== null && access.canUse(grant);
35
- };
35
+ // A request's own access narrowed to this one.
36
+ const mergeAccess = (outer: TargetAccess | null): TargetAccess =>
37
+ outer === null ? access : outer.merge(access);
36
38
 
37
39
  const canUseTarget = (target: string): boolean => {
38
40
  const targetIdentity = ctx.findTargetIdentity(target);
@@ -57,10 +59,7 @@ export function buildScopedContext(
57
59
  return null;
58
60
  }
59
61
 
60
- return {
61
- target: entry.target ?? 'local',
62
- targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
63
- };
62
+ return buildGrantFromFleetEntry(entry);
64
63
  };
65
64
 
66
65
  // Throws the refusal a fresh spawn to the session's target gets when a
@@ -73,24 +72,9 @@ export function buildScopedContext(
73
72
  }
74
73
  };
75
74
 
76
- // Throws the refusal of a change to a session whose sub-sessions the
77
- // change would reach too when any of them is out of reach. The refusal
78
- // holds only the session the caller sent, never a sub-session's id or
79
- // how many there are.
80
- const requireTreeInReach = (id: SessionID, action: string): void => {
81
- if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
82
- return;
83
- }
84
-
85
- throw new DaemonError(
86
- 'target_forbidden',
87
- `this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
88
- { session: id },
89
- );
90
- };
91
-
92
75
  return {
93
76
  ...ctx,
77
+ isSessionVisible: canSee,
94
78
  collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
95
79
  collectAgents: () => {
96
80
  const list = ctx.collectAgents();
@@ -115,11 +99,12 @@ export function buildScopedContext(
115
99
  }),
116
100
  };
117
101
  },
118
- collectFleet: async () => {
119
- const fleet = await ctx.collectFleet();
102
+ collectSpawnDirs: (outer) => {
103
+ const merged = outer === null ? access : outer.merge(access);
120
104
 
121
- return fleet.filter((entry) => canSee(entry.sessionID));
105
+ return ctx.collectSpawnDirs(merged);
122
106
  },
107
+ resolveSpawnParent: (id) => (canSee(id) ? ctx.resolveSpawnParent(id) : 'missing'),
123
108
  resolveSpawnTarget: (requested) => {
124
109
  if (requested !== undefined) {
125
110
  requireTarget(canUseTarget, requested);
@@ -132,12 +117,10 @@ export function buildScopedContext(
132
117
  return target;
133
118
  },
134
119
  spawnSession: async (plan, keyed) => {
135
- let answer: Readonly<Record<string, unknown>>;
136
-
137
120
  // A held key's replay is checked against the target its session was
138
121
  // bound to, inside the spawn, before its answer leaves the daemon.
139
122
  try {
140
- answer = await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
123
+ return await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
141
124
  } catch (error) {
142
125
  // A held key's refusal may carry the session its spawn made.
143
126
  if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
@@ -146,29 +129,17 @@ export function buildScopedContext(
146
129
 
147
130
  throw error;
148
131
  }
149
-
150
- return answer;
151
132
  },
152
- killSession: (id) => {
153
- if (!canSee(id)) {
154
- return Promise.resolve(false);
155
- }
156
133
 
157
- requireTreeInReach(id, 'kill');
158
-
159
- return ctx.killSession(id);
160
- },
161
- updateSession: (id, name, pinned) => {
162
- if (!canSee(id)) {
163
- return false;
164
- }
134
+ // The daemon's kill takes the session's sub-sessions before its first
135
+ // await, so the set it kills is the set this check saw.
136
+ killSession: (id) => (canSee(id) ? ctx.killSession(id) : Promise.resolve(false)),
165
137
 
166
- if (pinned !== undefined) {
167
- requireTreeInReach(id, 'pin');
168
- }
169
-
170
- return ctx.updateSession(id, name, pinned);
171
- },
138
+ // A session out of reach answers before any confirm token is handed
139
+ // out or taken, so its host is never touched.
140
+ forgetSession: (id, confirmToken) =>
141
+ canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
142
+ updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
172
143
  ackSession: (id) => canSee(id) && ctx.ackSession(id),
173
144
  buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
174
145
  readSessionScreen: (id) =>
@@ -190,12 +161,16 @@ export function buildScopedContext(
190
161
  writeSessionLine: (sessionID, text) =>
191
162
  canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
192
163
  ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
193
- adoptSession: (id, cols, rows) =>
194
- canSee(id) ? ctx.adoptSession(id, cols, rows) : Promise.resolve('missing' as const),
164
+ adoptSession: (id, cols, rows, outer) =>
165
+ canSee(id)
166
+ ? ctx.adoptSession(id, cols, rows, mergeAccess(outer))
167
+ : Promise.resolve('missing' as const),
195
168
  resizeSession: (client, sessionID, dims) =>
196
169
  canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
197
- readSessionRecord: (id) =>
198
- canSee(id) ? ctx.readSessionRecord(id) : Promise.resolve('missing' as const),
170
+ readSessionRecord: (id, outer) =>
171
+ canSee(id)
172
+ ? ctx.readSessionRecord(id, mergeAccess(outer))
173
+ : Promise.resolve('missing' as const),
199
174
  loadSessionTranscript: (id, from, limit) =>
200
175
  canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
201
176
  readEvents: (afterID, limit, waitMs, sessionID, outer) => {
@@ -203,31 +178,43 @@ export function buildScopedContext(
203
178
 
204
179
  return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
205
180
  },
206
- readReport: (id, outer) => {
207
- const merged = outer === null ? access : outer.merge(access);
208
-
209
- return ctx.readReport(id, merged);
210
- },
211
- writeSessionMessage: (sessionID, from, text, keyed) =>
181
+ readReport: (id, outer) => ctx.readReport(id, mergeAccess(outer)),
182
+ writeSessionMessage: (sessionID, from, text, keyed, outer) =>
212
183
  canSee(sessionID)
213
- ? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
184
+ ? ctx.writeSessionMessage(
185
+ sessionID,
186
+ from,
187
+ text,
188
+ buildPrincipalKey(keyed),
189
+ mergeAccess(outer),
190
+ )
214
191
  : Promise.resolve('missing' as const),
215
192
  readMessage: async (messageID, waitMs) => {
216
193
  // The owner is checked before any wait, so a message outside the
217
194
  // access answers at once, as an unknown message does.
218
195
  const view = await ctx.readMessage(messageID, 0);
219
196
 
220
- if (view === null || !canSee(view.session)) {
197
+ // Only the session the message was sent to owns it here, never one
198
+ // that shares its agent session id.
199
+ if (view === null || view.session !== view.record.atcID || !canSee(view.session)) {
221
200
  return null;
222
201
  }
223
202
 
224
- return waitMs === 0 ? view : ctx.readMessage(messageID, waitMs);
203
+ if (waitMs === 0) {
204
+ return view;
205
+ }
206
+
207
+ // The message may move to another session that holds its agent
208
+ // session id while the read waits.
209
+ const waited = await ctx.readMessage(messageID, waitMs);
210
+
211
+ return waited !== null && waited.session === waited.record.atcID ? waited : null;
225
212
  },
226
- attachTap: (client, sessionID) =>
227
- canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
228
- ackMessage: (client, sessionID, messageID) =>
213
+ attachTap: (client, sessionID, outer) =>
214
+ canSee(sessionID) ? ctx.attachTap(client, sessionID, mergeAccess(outer)) : 'missing',
215
+ ackMessage: (client, sessionID, messageID, outer) =>
229
216
  canSee(sessionID)
230
- ? ctx.ackMessage(client, sessionID, messageID)
217
+ ? ctx.ackMessage(client, sessionID, messageID, mergeAccess(outer))
231
218
  : Promise.resolve('unknown' as const),
232
219
  };
233
220
  }
@@ -1,7 +1,7 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import { PROTOCOL_V } from '../protocol/protocol';
3
2
  import type { EventMsg } from '../protocol/protocol';
4
3
  import type { SessionID } from '../shared/session-id';
4
+ import { truncateDetail } from '../shared/truncate-detail';
5
5
  import type { MessageRecord } from '../store/message-record';
6
6
 
7
7
  /**