@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,1901 @@
1
+ /** How long to wait before re-offering held frames to the link. */
2
+ const HELD_RETRY_MS = 250;
3
+ /** How many refused undroppable frames one session may hold before the next is reported lost. */
4
+ const HELD_FRAMES_MAX = 512;
5
+
6
+ /**
7
+ * The composer: the file that makes the parts a host.
8
+ *
9
+ * Why this file exists: every part of this package is built and proven through its own seam, and
10
+ * something has to join them. Without this file a session created through the registry runs with
11
+ * `hooks: null` (no gate, no observation) and the link decodes `session_new`, `session_prompt`,
12
+ * `session_cancel` and `bulk_request` with nothing consuming them, so the host cannot be told to
13
+ * do anything at all.
14
+ *
15
+ * Two things live here and the split is the testability.
16
+ * `composeSession` is the per-session assembly (machine, observer, gate, forwarding) and it
17
+ * takes everything as arguments, so it can be checked without a socket or a process.
18
+ * `PeriscopeHost` owns the link and the registry and turns inbound payloads into calls on it.
19
+ *
20
+ * It lives in `src/host/` for the ordinary reason: it wires the real path resolver, the real bulk
21
+ * POST and the real MCP server, all of which are confined here. It imports no `node:` builtin of
22
+ * its own; the boundary is satisfied by construction rather than by permission.
23
+ *
24
+ * The order of assembly is load-bearing and it is not obvious. Forwarding is attached before the
25
+ * first transition is recorded, because the forwarder subscribes to the machine: attach it after
26
+ * and the `spawning` record (the one that carries where the session is and that it exists at all)
27
+ * is emitted to nobody. It is the first frame a controller ever sees for a session, so losing it
28
+ * costs the session's whole opening.
29
+ */
30
+ import type { Clock, Ticker } from '../core/time.js';
31
+ import { systemClock, systemTicker } from '../core/time.js';
32
+ import { KeyedTurns } from '../core/keyed-turns.js';
33
+ import type { Refusal, RefusalReason } from '../core/refusal.js';
34
+ import { refusal } from '../core/refusal.js';
35
+ import type { Result } from '../core/result.js';
36
+ import { ok, refuse } from '../core/result.js';
37
+ import type { ControllerCredential } from '../control/credential.js';
38
+ import type { BackoffOptions } from '../control/backoff.js';
39
+ import type { LinkHandlers } from '../control/link.js';
40
+ import { ControllerLink } from '../control/link.js';
41
+ import type { LinkTransition } from '../control/link-state.js';
42
+ import type {
43
+ HostConfiguration,
44
+ HostConfigure,
45
+ HostConfigureEntry,
46
+ SessionFrame,
47
+ SessionList,
48
+ SessionNew,
49
+ WorkspaceList,
50
+ RepositoryList,
51
+ RepositoryListResult,
52
+ RepositoryRead,
53
+ RepositoryReadResult,
54
+ WireRefusal,
55
+ SessionNewGate,
56
+ SessionPayload,
57
+ TranscriptList,
58
+ TranscriptTail,
59
+ WorkspaceRelease,
60
+ WorkspaceReleaseBulk,
61
+ WorkspaceReleaseEntry,
62
+ WorkspaceReleaseEntryResult,
63
+ } from '../control/frames.js';
64
+ import {
65
+ WORKSPACE_PAGE_SIZE,
66
+ hostConfigureResult,
67
+ sessionListResult,
68
+ workspaceListResult,
69
+ transcriptFailed,
70
+ transcriptListResult,
71
+ transcriptTailResult,
72
+ unsetHostConfiguration,
73
+ workspaceReleaseBulkResult,
74
+ workspaceReleaseEntryResult,
75
+ workspaceReleaseResult,
76
+ repositoryListResult,
77
+ repositoryReadResult,
78
+ stateTransitionUpdate,
79
+ } from '../control/frames.js';
80
+ import type { FrameSink } from '../control/stream.js';
81
+ import { forwardSession } from '../control/stream.js';
82
+ import type { Decider } from '../gate/decision.js';
83
+ import type { LocalGate } from '../gate/local.js';
84
+ import type { ToolFamilies } from '../gate/local.js';
85
+ import { localGate } from '../gate/local.js';
86
+ import type { GateOutcome } from '../gate/outcome.js';
87
+ import { recordGateOutcome } from '../gate/outcome.js';
88
+ import { deadlineOrderRefusal, permissionHooks } from '../gate/gate.js';
89
+ import type { McpServerOptions } from '../mcp/server.js';
90
+ import type { SessionRequest } from '../sessions/registry.js';
91
+ import { SessionRegistry } from '../sessions/registry.js';
92
+ import type { HostedSession, SessionDegrade, Unsubscribe } from '../sessions/session.js';
93
+ import { SessionStateMachine } from '../state/machine.js';
94
+ import type { SessionTransition, TransitionWhere } from '../state/model.js';
95
+ import { SessionObserver } from '../state/observer.js';
96
+ import type { WorkspaceEntry, WorkspaceInventory, WorkspaceProvider } from '../workspace/provider.js';
97
+ import { keyPreview, unusableKeyProblem } from '../workspace/git-worktree.js';
98
+ import type { McpServerConfig } from './agent-process.js';
99
+ import type { BulkPostReceipt } from './bulk-post.js';
100
+ import { bulkOriginFor, postBulk } from './bulk-post.js';
101
+ import { readWhere } from './git-facts.js';
102
+ import { normalizePath } from '../core/paths.js';
103
+ import { mergeHooks, observationHooks } from './hooks.js';
104
+ import { createToolServer } from './mcp-server.js';
105
+ import { listTranscripts, tailTranscript } from './claude-transcripts.js';
106
+ import { listRepositoryDirectory, readRepositoryFile } from './repository-read.js';
107
+ import { nodePathResolver } from './paths.js';
108
+ import { isBypassMode, mergeMcpServers, readSessionConfigure, readSessionRequest } from './wire-request.js';
109
+ import { isDroppable } from '../control/frames.js';
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // The per-session assembly.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** How long the gate waits, when it reports a call as held, and whether its allow takes effect. */
116
+ export interface GateTimings {
117
+ readonly decisionTimeoutMs?: number;
118
+ readonly holdAfterMs?: number;
119
+ readonly matcherTimeoutSeconds?: number;
120
+ /**
121
+ * Make the gate's allow effective.
122
+ *
123
+ * Defaults to false, and leaving it there gives a gate that cannot say yes. Without it
124
+ * this gate is a veto: it can refuse a call and cannot let one through. Observed on a real
125
+ * session: the gate allowed a `Write`, the tool did not run, and the model was told "Claude
126
+ * requested permissions to write to ..., but you haven't granted it yet", in a host with no user
127
+ * to grant anything. `PeriscopeHost` sets it to `true` for exactly this reason; an embedder
128
+ * calling `composeSession` by hand does not.
129
+ *
130
+ * The first allow that does not take effect raises a `gate-cannot-grant` degrade (see
131
+ * `permissionHooks`), so an embedder who left it off is told by a named outcome rather than by
132
+ * a comment.
133
+ *
134
+ * Setting it with `settingSources` non-empty is refused (`permission-grant-shadows-settings`).
135
+ * Per the SDK's documented evaluation order a hook allow does not skip operator deny or ask
136
+ * rules (it skips permission mode, allow rules and `canUseTool`), so the exposure is narrower
137
+ * than a bypass. See `composeSession` for what the refusal does and does not cover.
138
+ */
139
+ readonly grantOnAllow?: boolean;
140
+ }
141
+
142
+ export interface ComposeSessionOptions {
143
+ readonly registry: SessionRegistry;
144
+ /** The controller's handle: the frame routing key, not the agent's id. See frames.ts. */
145
+ readonly sessionKey: string;
146
+ /** Where the session runs. Absolute; the registry refuses anything else. */
147
+ readonly cwd: string;
148
+ readonly sink: FrameSink;
149
+ /** Who answers a permission escalation. Usually `escalatingDecider`. */
150
+ readonly decide: Decider;
151
+ /**
152
+ * The host's own gate, consulted before the decider.
153
+ *
154
+ * Optional in the type, supplied by default by `PeriscopeHost`. Absent means a path escape or a
155
+ * credential read waits `decisionTimeoutMs` for an unreachable controller and is reported as an
156
+ * outage, instead of being refused locally by name; see `gate/local.ts`. Composing without one
157
+ * is a decision, so it stays possible and is never the default.
158
+ */
159
+ readonly localGate?: LocalGate;
160
+ readonly gate?: GateTimings;
161
+ /** Read once, at composition. Carried by every transition until the agent moves itself. */
162
+ readonly where?: TransitionWhere;
163
+ /** Opaque and never interpreted here. The controller's own meaning handle. */
164
+ readonly correlationId?: string | null;
165
+ /** Everything else the session takes — MCP servers, a store, plugins, resume. */
166
+ readonly request?: Omit<SessionRequest, 'cwd' | 'hooks'>;
167
+ readonly clock?: Clock;
168
+ readonly ticker?: Ticker;
169
+ /** Every refusal the sink returned, every transition the machine would not record. */
170
+ readonly onRefusal?: (refused: Refusal) => void;
171
+ /**
172
+ * Told about every gate outcome, in addition to the machine record, never instead of it.
173
+ *
174
+ * The record is what reaches the wire and it is not optional; this is for an embedder that also
175
+ * wants to count, meter or log outcomes locally.
176
+ */
177
+ readonly onOutcome?: (outcome: GateOutcome) => void;
178
+ /** A hook handler that threw. The CLI treats a throwing hook as absent, so it is never silent. */
179
+ readonly onHookFailure?: (failure: { event: string; error: unknown }) => void;
180
+ }
181
+
182
+ /** One composed session: the handle, its machine, and how to take the wiring back down. */
183
+ export interface ComposedSession {
184
+ readonly sessionKey: string;
185
+ readonly session: HostedSession;
186
+ readonly machine: SessionStateMachine;
187
+ readonly observer: SessionObserver;
188
+ /** Stops forwarding. Idempotent. Does not end the session; the registry owns lifetime. */
189
+ readonly detach: Unsubscribe;
190
+ }
191
+
192
+ /**
193
+ * Assemble one session: machine, observer, gate and forwarding, then start it.
194
+ *
195
+ * This is the wiring every consumer needs, written down where it can be used.
196
+ */
197
+ export function composeSession(options: ComposeSessionOptions): Result<ComposedSession> {
198
+ // The one combination this function will not assemble, and this is the only place that can see
199
+ // it: the gate knows whether it grants and the request knows which settings tiers load, and
200
+ // neither knows the other. Refused before any process exists, by name, rather than resolved by a
201
+ // precedence that would surprise one of them.
202
+ //
203
+ // The ground for the refusal is narrow. Per the documented permission rules, deny and ask are
204
+ // evaluated whatever a hook returns, so a grant skips only mode, allow rules and `canUseTool`; an
205
+ // effective allow does not short-circuit every later permission check. The refusal stands on the
206
+ // narrower ground that the two mechanisms answer the same question from different places, and a
207
+ // session that both grants and loads operator rules has two authorities with no stated
208
+ // precedence. See `gate.ts` for the full order and for why the claim is documented rather than
209
+ // measured.
210
+ //
211
+ // And `settingSources: []` does not mean "no operator rules live". Managed policy settings and
212
+ // `~/.claude.json` load regardless of this field, precisely on the managed corporate laptop this
213
+ // package's threat model is written for. So this refusal is incomplete on its own logic: it
214
+ // catches the tiers named here and cannot see the two that are always on.
215
+ const settingSources = options.request?.settingSources ?? [];
216
+ // The pair is refused, except under bypassPermissions. What the grant skips is "permission
217
+ // mode, allow rules and canUseTool"; under bypass the mode already allows all of that, so the
218
+ // grant changes nothing and there is no second authority to shadow. A hook deny survives every
219
+ // mode, so the gate's boundary set still holds. The precedence is therefore stated by the mode
220
+ // itself: the operator asked for bypass, and got exactly the CLI's bypass plus this gate's
221
+ // refusals. Every other mode keeps the refusal below, unchanged.
222
+ if (
223
+ (options.gate?.grantOnAllow ?? false) &&
224
+ settingSources.length > 0 &&
225
+ !isBypassMode(options.request?.permissionMode)
226
+ ) {
227
+ return refuse<ComposedSession>(
228
+ 'permission-grant-shadows-settings',
229
+ `grantOnAllow makes this gate's allow effective, and this session also loads operator settings ` +
230
+ `(${settingSources.join(', ')}) — two authorities over the same call with no stated ` +
231
+ `precedence. Per the SDK's documented evaluation order a hook allow does NOT skip deny or ` +
232
+ `ask rules, so those survive the grant; what it skips is permission mode, allow rules and ` +
233
+ `canUseTool. Load no settings, do not grant, or run bypassPermissions — where the grant ` +
234
+ `changes nothing because the mode already allows what it would. Note that managed policy and ` +
235
+ `~/.claude.json load regardless of settingSources, so an empty list is not proof that no ` +
236
+ `operator rule is live.`,
237
+ );
238
+ }
239
+
240
+ // The second combination this function will not assemble. `permissionHooks` throws when the
241
+ // host's own deadline does not expire before the matcher's, and that throw is right: the pair is
242
+ // checkable, the failure it prevents is a block nobody can explain, and an embedder calling
243
+ // `permissionHooks` directly should meet it loudly. But gate timings ride `session_new`, so a
244
+ // controller can send the pair, and a throw from here would escape the payload dispatcher's
245
+ // `void this.#open(…)` as an unhandled rejection, which means the controller gets no answer at
246
+ // all. A command that vanishes reads as a host that hung, which is precisely what refusing every
247
+ // unknown payload by name exists to prevent. Caught here rather than by relaxing the throw: the
248
+ // check keeps its teeth, and the composer keeps its contract of returning a Result for
249
+ // everything it declines to build.
250
+ const inverted = deadlineOrderRefusal(options.gate?.decisionTimeoutMs, options.gate?.matcherTimeoutSeconds);
251
+ if (inverted !== null) return refuse<ComposedSession>('gate-deadlines-inverted', inverted);
252
+
253
+ const clock = options.clock ?? systemClock;
254
+ const ticker = options.ticker ?? systemTicker;
255
+
256
+ const machine = new SessionStateMachine({
257
+ where: options.where ?? unknownWhere(options.cwd),
258
+ clock,
259
+ ticker,
260
+ correlationId: options.correlationId ?? null,
261
+ });
262
+ const observer = new SessionObserver(machine);
263
+
264
+ // The gate records every outcome on the machine. That emission is what makes a deny, an outage and
265
+ // an expiry visible off-box at all, so it is unconditional and the embedder's listener is extra.
266
+ const gate = permissionHooks({
267
+ decide: options.decide,
268
+ // The escalation body carries the controller's handle. This function is the one place that
269
+ // holds both the handle and the gate, so it is where the two transports are tied together.
270
+ sessionKey: options.sessionKey,
271
+ onOutcome: (outcome) => {
272
+ recordGateOutcome(machine, outcome);
273
+ options.onOutcome?.(outcome);
274
+ },
275
+ ...(options.localGate === undefined ? {} : { localGate: options.localGate }),
276
+ ...(options.gate?.decisionTimeoutMs === undefined
277
+ ? {}
278
+ : { decisionTimeoutMs: options.gate.decisionTimeoutMs }),
279
+ ...(options.gate?.holdAfterMs === undefined ? {} : { holdAfterMs: options.gate.holdAfterMs }),
280
+ ...(options.gate?.matcherTimeoutSeconds === undefined
281
+ ? {}
282
+ : { matcherTimeoutSeconds: options.gate.matcherTimeoutSeconds }),
283
+ ...(options.gate?.grantOnAllow === undefined ? {} : { grantOnAllow: options.gate.grantOnAllow }),
284
+ // The `gate-cannot-grant` residual reaches the same lane every other named degrade uses, so an
285
+ // embedder who already subscribes to refusals learns about it without opting in to anything new.
286
+ ...(options.onRefusal === undefined
287
+ ? {}
288
+ : {
289
+ onDegrade: (degrade: { name: RefusalReason; detail: string }): void =>
290
+ options.onRefusal?.(refusal(degrade.name, degrade.detail)),
291
+ }),
292
+ });
293
+
294
+ // Observation first, the gate second. The order is a convention rather than a race guard (see
295
+ // permissionHooks' own note), but it is the one both files state, so it is written once here.
296
+ const hooks = mergeHooks(
297
+ observationHooks({
298
+ observer,
299
+ ...(options.onHookFailure === undefined ? {} : { onHandlerFailure: options.onHookFailure }),
300
+ }),
301
+ gate,
302
+ );
303
+
304
+ const created = options.registry.create({ ...(options.request ?? {}), cwd: options.cwd, hooks });
305
+ if (!created.ok) return refuse<ComposedSession>(created.refusal.reason, created.refusal.detail);
306
+ const session = created.value;
307
+
308
+ // Before the first record. See this file's header: the forwarder subscribes to the machine, so
309
+ // anything recorded ahead of this line is emitted to nobody.
310
+ const detach = forwardSession({
311
+ sessionKey: options.sessionKey,
312
+ session,
313
+ observer,
314
+ sink: options.sink,
315
+ ...(options.onRefusal === undefined ? {} : { onRefusal: options.onRefusal }),
316
+ });
317
+
318
+ observer.created(`a session was requested in ${options.cwd}`);
319
+
320
+ return ok({ sessionKey: options.sessionKey, session, machine, observer, detach });
321
+ }
322
+
323
+ /**
324
+ * The `where` for a directory nobody has read git facts for.
325
+ *
326
+ * Stated rather than left null: a transition whose `where` is absent cannot be told apart from one
327
+ * whose repository could not be determined, and only the second is a fact worth reporting.
328
+ */
329
+ function unknownWhere(cwd: string): TransitionWhere {
330
+ return { cwd, worktree: null, branch: null, unknownReason: 'the repository was not read for this session' };
331
+ }
332
+
333
+ /**
334
+ * A controller's per-session timings, as a partial the composer can spread.
335
+ *
336
+ * Absent keys rather than nulls: `GateTimings` reads an absent key as "use the default", and a
337
+ * present `undefined` would spread over a value the embedder deliberately set. The difference is
338
+ * invisible until an embedder configures a timeout and a controller sends `gate: {…, holdAfterMs:
339
+ * null}` — at which point the embedder's value either survives or silently does not.
340
+ *
341
+ * Nothing validates the pair here, deliberately. `permissionHooks` throws at construction when the
342
+ * host's own deadline does not expire before the matcher's, and that check must stay the only one:
343
+ * a second copy of the rule is a second thing to keep in step. The throw surfaces as a refused
344
+ * composition before any process exists, which is where a controller wants to learn it.
345
+ */
346
+ function readGateTimings(gate: SessionNewGate | null): Partial<GateTimings> {
347
+ if (gate === null) return {};
348
+ return {
349
+ ...(gate.decisionTimeoutMs === null ? {} : { decisionTimeoutMs: gate.decisionTimeoutMs }),
350
+ ...(gate.holdAfterMs === null ? {} : { holdAfterMs: gate.holdAfterMs }),
351
+ ...(gate.matcherTimeoutSeconds === null ? {} : { matcherTimeoutSeconds: gate.matcherTimeoutSeconds }),
352
+ };
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // The host.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /** Turns the opaque `what` of a bulk request into a file this host will POST. */
360
+ export type BulkResolver = (what: string, sessionKey: string) => Result<string>;
361
+
362
+ /**
363
+ * The link surface a host uses. `ControllerLink` satisfies it.
364
+ *
365
+ * It is structural so the host's own rules can be checked without a socket: that an unknown
366
+ * handle is refused rather than dropped, that two `session_new` for one handle do not silently
367
+ * replace each other, that a failed delivery still sends a receipt, that a finished session is
368
+ * released in an order which keeps its last frames. It is not a way to test the transport: a
369
+ * substitute proves the substitute, and everything this package claims about reconnect, replay and
370
+ * `seq` is proven against the real link in `control/`.
371
+ */
372
+ export interface HostLink extends FrameSink {
373
+ start(): void;
374
+ stop(detail?: string): void;
375
+ forgetSession(sessionId: string): void;
376
+ /** The version the controller chose at the last accepted handshake; optional because a test link negotiates nothing. */
377
+ readonly negotiatedVersion?: number | null;
378
+ /** Replace what the next `link_hello` declares; optional because a test link declares nothing. */
379
+ announce?(
380
+ capabilities: readonly string[],
381
+ configuration: HostConfiguration,
382
+ pendingRestart: readonly string[],
383
+ ): void;
384
+ }
385
+
386
+ /**
387
+ * What a reconfigure produced: the pieces the host swaps in place of the ones it was composed with.
388
+ * `undefined` members mean the same as an omitted option at composition (no provider, no root).
389
+ */
390
+ export interface HostReconfigured {
391
+ readonly workspaces: WorkspaceProvider | undefined;
392
+ readonly transcriptsRoot: string | undefined;
393
+ readonly bulk: BulkResolver | undefined;
394
+ readonly linkCapabilities: readonly string[];
395
+ readonly configuration: HostConfiguration;
396
+ readonly overriddenByEnvironment: readonly string[];
397
+ /** The keys written but not in effect until the next start. */
398
+ readonly pendingRestart: readonly string[];
399
+ }
400
+
401
+ /**
402
+ * The configuration seam a `host_configure` ask runs through. The composition root supplies it,
403
+ * because writing the config file and rebuilding a provider both read the environment, which is
404
+ * that file's job alone. `hostBusy` is true while any session is live or opening; the seam refuses
405
+ * a roots change by name in that state rather than writing a file the running host cannot honour.
406
+ */
407
+ export type HostReconfigurer = (
408
+ entries: readonly HostConfigureEntry[],
409
+ hostBusy: boolean,
410
+ ) => Result<HostReconfigured>;
411
+
412
+ export interface PeriscopeHostOptions {
413
+ readonly controllerUrl: string;
414
+ readonly hostId: string;
415
+ /**
416
+ * The identity this host presents outbound, on the link's upgrade and on every bulk POST
417
+ * (resolved per delivery; a refusing credential posts headerless, and the receiver's refusal
418
+ * comes back as `bulk-delivery-failed`). Omitted means every outbound surface is anonymous.
419
+ */
420
+ readonly credential?: ControllerCredential;
421
+ readonly backoff?: BackoffOptions;
422
+ /**
423
+ * The default link's clocks: the heartbeat's interval and timeout, and how long a dial may sit
424
+ * without an open before it is abandoned and retried. Ignored when `link` supplies the link.
425
+ */
426
+ readonly linkTimings?: {
427
+ readonly heartbeatIntervalMs?: number;
428
+ readonly heartbeatTimeoutMs?: number;
429
+ readonly connectTimeoutMs?: number;
430
+ };
431
+ /** Who answers a permission escalation. Required: a host with no decider gates nothing. */
432
+ readonly decide: Decider;
433
+ /**
434
+ * Absolute paths the agent may never read or write, whatever its workspace.
435
+ *
436
+ * `host/paths.ts`'s `credentialPaths(env)` computes the default set; the composition root passes
437
+ * it in, because reading the environment is that file's job and only that file's.
438
+ */
439
+ readonly protectedPaths: readonly string[];
440
+ /**
441
+ * Which tool names the local gate treats as path writes, path reads and shell commands.
442
+ *
443
+ * Defaults to the SDK's own tools (`DEFAULT_TOOL_FAMILIES`). An MCP tool the embedder registers
444
+ * arrives as `mcp__{server}__{tool}`, matches nothing, and escalates with no local opinion; naming
445
+ * it here gives it the same local treatment as the built-in it resembles. Data, never guessed:
446
+ * the host cannot know which of an embedder's tools are dangerous.
447
+ */
448
+ readonly toolFamilies?: ToolFamilies;
449
+ /**
450
+ * Where sessions run. With a provider the controller's `cwd` is advisory: the provider decides,
451
+ * and the session's `spawning` transition carries the directory it actually got, so the
452
+ * controller learns where its session is rather than assuming.
453
+ */
454
+ readonly workspaces?: WorkspaceProvider;
455
+ /**
456
+ * The key an unkeyed `session_new` provisions at, when it should not be the session key
457
+ * (protocol v5).
458
+ *
459
+ * A default for an absence, never an override: resolution is exactly
460
+ * `opening.workspaceKey ?? defaultWorkspaceKey ?? sessionKey`, so a controller that names a key
461
+ * never has this consulted and no precedence question exists. It is what lets a library user
462
+ * with no controller at all get a shared tree: every unkeyed session lands in one workspace.
463
+ * The composition root screens the configured value at startup (same union screen as a wire
464
+ * key); the per-frame validation below still covers an embedder that passes one here directly.
465
+ */
466
+ readonly defaultWorkspaceKey?: string;
467
+ /**
468
+ * In-process tools this host offers every session. See the note on registration below.
469
+ *
470
+ * `identity` is not the embedder's to supply: it must be read at CALL time and only this host
471
+ * knows which session is calling, so it is filled in here and omitted from the type rather than
472
+ * accepted and ignored.
473
+ */
474
+ readonly tools?: Omit<McpServerOptions, 'identity'>;
475
+ readonly bulk?: BulkResolver;
476
+ /**
477
+ * Where the agent CLI's transcripts live (`~/.claude/projects`), for the discovery door.
478
+ *
479
+ * The composition root derives it (`claudeProjectsRoot(env)` in `host/claude-transcripts.ts`),
480
+ * because reading the environment is that layer's job. Absent means the door answers every
481
+ * `transcript_list` / `transcript_tail` with a named failure rather than guessing a root;
482
+ * `session_list` needs no filesystem and always answers.
483
+ */
484
+ readonly transcriptsRoot?: string;
485
+ readonly gate?: GateTimings;
486
+ /**
487
+ * Capability markers this host declares in its `link_hello`, beside the built-in `bulk-post`,
488
+ * never replacing it.
489
+ *
490
+ * The workspace mode rides here: the composition root computes
491
+ * `workspaceCapabilitiesOf(config)` (`bin/workspaces.ts`) because only it knows which provider
492
+ * it chose; the mode is deliberately erased from `WorkspaceProvider` itself. Markers are values
493
+ * in an existing open string list, so declaring a new one is not a protocol change and bumps no
494
+ * version; a controller that does not know a marker ignores it by construction. Values a marker
495
+ * cannot carry (which repository root, which scheme text) are a payload-member conversation, a
496
+ * version bump, and deliberately not smuggled in here.
497
+ */
498
+ readonly linkCapabilities?: readonly string[];
499
+ /**
500
+ * The values behind those markers: which roots, which scheme, which transcripts root,
501
+ * which controller. The composition root computes `hostConfigurationOf(config, ...)` beside the
502
+ * markers, from the same reading, so the two cannot disagree. Omitted, the hello reports every
503
+ * value as null.
504
+ */
505
+ readonly configuration?: HostConfiguration;
506
+ /** The wire-settable keys the environment sets, reported on a configure answer. */
507
+ readonly overriddenByEnvironment?: readonly string[];
508
+ /** The keys the file names differently from what this process dialled; empty at a fresh start. */
509
+ readonly pendingRestart?: readonly string[];
510
+ /** How a `host_configure` ask is applied. Omitted, every such ask refuses `config-write-failed`. */
511
+ readonly reconfigure?: HostReconfigurer;
512
+ readonly registry?: SessionRegistry;
513
+ /** How the link is built. Defaults to the real `ControllerLink`. See `HostLink`. */
514
+ readonly link?: (handlers: LinkHandlers) => HostLink;
515
+ /** The environment sessions are filtered from, when this builds its own registry. */
516
+ readonly baseEnv?: Readonly<Record<string, string | undefined>>;
517
+ readonly homeDir?: string;
518
+ readonly clock?: Clock;
519
+ readonly ticker?: Ticker;
520
+ /** Every named thing that happened. A host with no reporter is a host nobody can debug. */
521
+ readonly report?: (event: HostEvent) => void;
522
+ }
523
+
524
+ /** What a host reports. Data rather than log lines, so an embedder decides the format. */
525
+ export type HostEvent =
526
+ | { readonly kind: 'link'; readonly transition: LinkTransition }
527
+ | { readonly kind: 'refusal'; readonly refusal: Refusal; readonly sessionKey: string | null }
528
+ | {
529
+ readonly kind: 'gap';
530
+ readonly sessionKey: string;
531
+ readonly expected: number;
532
+ readonly received: number;
533
+ }
534
+ | { readonly kind: 'session-opened'; readonly sessionKey: string; readonly cwd: string }
535
+ | { readonly kind: 'session-closed'; readonly sessionKey: string }
536
+ | { readonly kind: 'prompt-held'; readonly sessionKey: string; readonly held: number }
537
+ | { readonly kind: 'prompt-delivered'; readonly sessionKey: string; readonly delivered: number }
538
+ | { readonly kind: 'prompt-withdrawn'; readonly sessionKey: string; readonly withdrawn: number }
539
+ | { readonly kind: 'transition'; readonly sessionKey: string; readonly transition: SessionTransition }
540
+ /**
541
+ * A named condition that changed what is true of a session without ending it.
542
+ *
543
+ * Every degrade the sessions layer raises arrives here. The registry names its conditions (an
544
+ * untrusted workspace whose settings rules are silently void, an id collision whose own detail
545
+ * says how to resolve it) into a listener set, and a listener set with no subscribers would
546
+ * mean the one message that answers the operator's question is raised and never received. The
547
+ * subscription in `#compose` is the audience; a pin in `host.test.ts` holds every kind to it.
548
+ */
549
+ | { readonly kind: 'degrade'; readonly sessionKey: string; readonly degrade: SessionDegrade };
550
+
551
+ /**
552
+ * How many turns may wait for one still-opening session before the rest are refused.
553
+ *
554
+ * The bound exists because the buffer is fed from the wire, not because anyone expects to reach it:
555
+ * a controller sends one seed per `session_new`, so the ordinary depth is 1. An unbounded per-handle
556
+ * buffer that a peer can grow is the shape this package refuses everywhere else, and the window it
557
+ * lives in is exactly as long as a workspace provider takes, which is seconds, not microseconds, and
558
+ * is the whole reason this queue exists.
559
+ */
560
+ const MAX_HELD_TURNS = 8;
561
+
562
+ /**
563
+ * A host: one outbound link, one registry, and the dispatcher between them.
564
+ *
565
+ * It interprets exactly the payload kinds its `#dispatch` switch names and refuses the rest by
566
+ * name: the session commands (`session_new`, `session_prompt`, `session_cancel`,
567
+ * `session_configure`), `bulk_request`, and the host-scoped asks (the discovery, workspace,
568
+ * configure and repository asks). Derive the count from the switch below; never carry it from
569
+ * prose. Anything else arriving inbound is
570
+ * either a frame this host produces (an update, a delta, a receipt) or a kind a newer controller
571
+ * invented, and both are reported rather than ignored; a command that vanishes reads to the
572
+ * controller as a host that hung.
573
+ */
574
+ export class PeriscopeHost {
575
+ readonly #options: PeriscopeHostOptions;
576
+ readonly #link: HostLink;
577
+ readonly #registry: SessionRegistry;
578
+ /** Keyed by the controller's handle, which is the only id present when `session_new` arrives. */
579
+ readonly #sessions = new Map<string, ComposedSession>();
580
+ /**
581
+ * Handles whose open is still in flight. `#open` awaits the workspace provider before it can
582
+ * populate `#sessions`, so the map alone cannot make the duplicate guard hold across that await;
583
+ * the reservation is taken synchronously and released in the same call, whatever the outcome.
584
+ */
585
+ readonly #opening = new Set<string>();
586
+
587
+ /**
588
+ * regression: an undroppable frame the link's queue refused at capacity — a turn-end transition, a
589
+ * result — was reported here and dropped, so the controller never learned the turn ended and the
590
+ * session read as still working until its next turn. The queue's refusal is backpressure; the
591
+ * caller has to hold the frame and offer it again. Held per session, in order; while a session holds
592
+ * frames its later undroppable frames queue behind them, so the controller sees the session's order.
593
+ */
594
+ readonly #heldFrames = new Map<string, SessionPayload[]>();
595
+ #heldFramesTimer: ReturnType<typeof setTimeout> | null = null;
596
+ /**
597
+ * Turns that arrived while their session was still opening, in order.
598
+ *
599
+ * A controller sends `session_new` then `session_prompt`, and that ordering is real on the wire,
600
+ * but a send returning Ok means written to the socket, not accepted, because the host
601
+ * acknowledges no controller frame (the reason `OutboundRetention` exists). Here the open is
602
+ * asynchronous: it awaits the workspace provider, and a real `GitWorktreeProvider` is a
603
+ * `git worktree add`, which takes seconds (measured at 13 s once). A seed landing in that window
604
+ * would be refused `session-unknown`, and the session would then open and sit idle forever.
605
+ * Nothing downstream could say why: a born session with no first turn looks exactly like a slow
606
+ * one.
607
+ *
608
+ * With `PlainDirProvider` (a `mkdir`) the window never opens, so only the git provider, which
609
+ * is what a session needs in order to commit or push at all, exposes this.
610
+ *
611
+ * The invariant, and it is the whole point: a held turn is answered, never dropped. Delivered
612
+ * when the session opens (`#compose`), refused if the open failed (`#open`'s `finally`, which runs
613
+ * on every path including a throw), or withdrawn because the controller cancelled it (`#withdraw`).
614
+ * Three exits, all of them reported. A queue that silently discarded on any one of them would be
615
+ * the same defect wearing a fix's clothes.
616
+ *
617
+ * It is deliberately not consulted for a handle this host has never heard of. `session-unknown`
618
+ * stays the honest, immediate answer for an id that does not exist and never will; retrying that
619
+ * is futile and `core/refusal.ts` says so. Only a handle already reserved in `#opening` can hold.
620
+ */
621
+ readonly #held = new Map<string, string[]>();
622
+ /**
623
+ * Which workspace key each live-or-opening session provisioned at (protocol v5).
624
+ *
625
+ * Two consumers, and both exist because keys can alias. The release sites read it so a session
626
+ * provisioned at a shared key releases that key rather than its own session key; without it,
627
+ * `provision('shared-1')` paired with `release(sessionKey)` would leak the claim on every
628
+ * aliased session. And the reap's in-use guard scans its values: N sessions can share one key,
629
+ * each holding its own entry here, so "is this key in use" stays true until the last of them
630
+ * closes; a single-mapping guard would clear on the first close and let a reap delete a tree
631
+ * other sessions are still working in.
632
+ *
633
+ * Written synchronously before the provision await (so an opening session already guards its
634
+ * key); cleared in `#open`'s finally when the open produced no session, and in `#close`.
635
+ */
636
+ readonly #workspaceKeyFor = new Map<string, string>();
637
+ /**
638
+ * Every provider call rides a per-key turn. `#dispatch` never awaits a handler, and the work
639
+ * behind these verbs is seconds long and mutually destructive on one key: a `git worktree add`
640
+ * measured at 13 to 18 s, a `git worktree remove --force` the same order. The reachable
641
+ * interleavings: a `session_new` provisioning into a directory the reap is mid-deleting (the
642
+ * in-use scan happened before the multi-second await), and a reap starting inside `#close`'s
643
+ * window (the map entry is deleted before its release settles). One serial queue per key closes
644
+ * every direction at the only chokepoint all three verbs share; keys that differ never wait on
645
+ * each other.
646
+ */
647
+ readonly #workspaceTurns = new KeyedTurns();
648
+ /**
649
+ * The pieces a `host_configure` may replace, held apart from the frozen options so a reconfigure
650
+ * swaps them in one place and every handler reads the current one. Initialised from the options.
651
+ */
652
+ #workspaces: WorkspaceProvider | undefined;
653
+ #transcriptsRoot: string | undefined;
654
+ #bulk: BulkResolver | undefined;
655
+ #linkCapabilities: readonly string[];
656
+ #configuration: HostConfiguration;
657
+ #overriddenByEnvironment: readonly string[];
658
+ #pendingRestart: readonly string[];
659
+
660
+ constructor(options: PeriscopeHostOptions) {
661
+ this.#options = options;
662
+ this.#workspaces = options.workspaces;
663
+ this.#transcriptsRoot = options.transcriptsRoot;
664
+ this.#bulk = options.bulk;
665
+ this.#linkCapabilities = options.linkCapabilities ?? [];
666
+ this.#configuration = options.configuration ?? unsetHostConfiguration();
667
+ this.#overriddenByEnvironment = options.overriddenByEnvironment ?? [];
668
+ this.#pendingRestart = options.pendingRestart ?? [];
669
+ if (options.registry !== undefined && (options.baseEnv !== undefined || options.homeDir !== undefined)) {
670
+ // A registry carries its own environment and home; a second pair beside it would be read by
671
+ // nothing, and an embedder who passed both would believe the pair was in effect.
672
+ throw new Error(
673
+ 'PeriscopeHostOptions: baseEnv and homeDir are ignored when a registry is supplied; pass them to the registry',
674
+ );
675
+ }
676
+ this.#registry =
677
+ options.registry ??
678
+ new SessionRegistry({
679
+ baseEnv: options.baseEnv ?? {},
680
+ homeDir: options.homeDir ?? '',
681
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
682
+ });
683
+
684
+ const handlers: LinkHandlers = {
685
+ onTransition: (transition) => this.#report({ kind: 'link', transition }),
686
+ onSessionFrame: (frame) => this.#dispatch(frame),
687
+ onGap: (sessionKey, expected, received) =>
688
+ this.#report({ kind: 'gap', sessionKey, expected, received }),
689
+ onRefusal: (refused) => this.#report({ kind: 'refusal', refusal: refused, sessionKey: null }),
690
+ };
691
+
692
+ this.#link =
693
+ options.link?.(handlers) ??
694
+ new ControllerLink({
695
+ url: options.controllerUrl,
696
+ hostId: options.hostId,
697
+ handlers,
698
+ ...(options.credential === undefined ? {} : { credential: options.credential }),
699
+ ...(options.backoff === undefined ? {} : { backoff: options.backoff }),
700
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
701
+ ...(options.ticker === undefined ? {} : { ticker: options.ticker }),
702
+ ...(options.linkCapabilities === undefined ? {} : { capabilities: options.linkCapabilities }),
703
+ ...(options.configuration === undefined ? {} : { configuration: options.configuration }),
704
+ ...(options.pendingRestart === undefined ? {} : { pendingRestart: options.pendingRestart }),
705
+ ...(options.linkTimings?.heartbeatIntervalMs === undefined
706
+ ? {}
707
+ : { heartbeatIntervalMs: options.linkTimings.heartbeatIntervalMs }),
708
+ ...(options.linkTimings?.heartbeatTimeoutMs === undefined
709
+ ? {}
710
+ : { heartbeatTimeoutMs: options.linkTimings.heartbeatTimeoutMs }),
711
+ ...(options.linkTimings?.connectTimeoutMs === undefined
712
+ ? {}
713
+ : { connectTimeoutMs: options.linkTimings.connectTimeoutMs }),
714
+ });
715
+ }
716
+
717
+ get link(): HostLink {
718
+ return this.#link;
719
+ }
720
+
721
+ get registry(): SessionRegistry {
722
+ return this.#registry;
723
+ }
724
+
725
+ /** The composed session behind a controller handle, or a refusal naming the handle. */
726
+ session(sessionKey: string): Result<ComposedSession> {
727
+ const composed = this.#sessions.get(sessionKey);
728
+ if (composed === undefined) {
729
+ return refuse<ComposedSession>('session-unknown', `no session for controller handle ${sessionKey}`);
730
+ }
731
+ return ok(composed);
732
+ }
733
+
734
+ start(): void {
735
+ this.#link.start();
736
+ }
737
+
738
+ /** Ends every session first, then the link — so the end transitions still have somewhere to go. */
739
+ stop(detail = 'host shutting down'): void {
740
+ if (this.#heldFramesTimer !== null) {
741
+ clearTimeout(this.#heldFramesTimer);
742
+ this.#heldFramesTimer = null;
743
+ }
744
+ for (const composed of [...this.#sessions.values()]) composed.session.stop(detail);
745
+ this.#registry.stopAll(detail);
746
+ this.#link.stop(detail);
747
+ }
748
+
749
+ // -------------------------------------------------------------------------
750
+
751
+ #dispatch(frame: SessionFrame): void {
752
+ const payload: SessionPayload = frame.payload;
753
+ switch (payload.kind) {
754
+ case 'session_new':
755
+ void this.#open(frame.sessionId, payload);
756
+ return;
757
+ case 'session_prompt': {
758
+ // A turn for a session whose open is still in flight waits for it rather than being refused.
759
+ // The reservation it tests is the one `#open` already takes; see `#held`.
760
+ if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
761
+ return this.#hold(frame.sessionId, payload.text);
762
+ }
763
+ const composed = this.session(frame.sessionId);
764
+ if (!composed.ok) return this.#refuse(frame.sessionId, composed.refusal);
765
+ return this.#prompt(frame.sessionId, composed.value, payload.text);
766
+ }
767
+ case 'session_cancel': {
768
+ // The same race, the other frame. A cancel arriving while the session is still opening
769
+ // stops the turn that is waiting, the only turn that can exist yet. It does not stop the
770
+ // open: `session_cancel` ends a turn, never a session, and that contract is pinned.
771
+ if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
772
+ return this.#withdraw(frame.sessionId);
773
+ }
774
+ const composed = this.session(frame.sessionId);
775
+ if (!composed.ok) return this.#refuse(frame.sessionId, composed.refusal);
776
+ composed.value.observer.interrupted('the controller cancelled the turn');
777
+ void composed.value.session.interrupt().catch((error: unknown) => {
778
+ this.#refuse(
779
+ frame.sessionId,
780
+ refusal('session-unknown', `the interrupt failed: ${describe(error)}`),
781
+ );
782
+ });
783
+ return;
784
+ }
785
+ case 'session_configure': {
786
+ // Protocol v6: the SDK's live setters. A session still opening has no query to configure yet; the
787
+ // controller is told so by name rather than left to assume the change landed.
788
+ const composed = this.session(frame.sessionId);
789
+ if (!composed.ok) return this.#refuse(frame.sessionId, composed.refusal);
790
+ const change = readSessionConfigure(payload);
791
+ if (!change.ok) return this.#refuse(frame.sessionId, change.refusal);
792
+ void composed.value.session.configure(change.value).catch((error: unknown) => {
793
+ this.#refuse(
794
+ frame.sessionId,
795
+ refusal('session-unknown', `the configure failed: ${describe(error)}`),
796
+ );
797
+ });
798
+ return;
799
+ }
800
+ case 'bulk_request':
801
+ void this.#deliver(
802
+ frame.sessionId,
803
+ payload.deliveryId,
804
+ payload.what,
805
+ payload.postUrl,
806
+ payload.fromOffset,
807
+ );
808
+ return;
809
+ // The discovery requests are host-scoped, not session-scoped. The routing key on these
810
+ // frames is a channel the controller chose; no session needs to exist behind it, so none of
811
+ // the session-existence machinery above applies. The answer rides back on the same key.
812
+ case 'session_list':
813
+ return this.#answerSessionList(frame.sessionId, payload);
814
+ case 'transcript_list':
815
+ void this.#answerTranscriptList(frame.sessionId, payload);
816
+ return;
817
+ case 'transcript_tail':
818
+ void this.#answerTranscriptTail(frame.sessionId, payload);
819
+ return;
820
+ case 'workspace_release':
821
+ void this.#answerWorkspaceRelease(frame.sessionId, payload);
822
+ return;
823
+ case 'workspace_release_bulk':
824
+ void this.#answerWorkspaceReleaseBulk(frame.sessionId, payload);
825
+ return;
826
+ case 'host_configure':
827
+ return this.#answerHostConfigure(frame.sessionId, payload);
828
+ case 'workspace_list':
829
+ void this.#answerWorkspaceList(frame.sessionId, payload);
830
+ return;
831
+ case 'repository_list':
832
+ void this.#answerRepositoryList(frame.sessionId, payload);
833
+ return;
834
+ case 'repository_read':
835
+ void this.#answerRepositoryRead(frame.sessionId, payload);
836
+ return;
837
+ default:
838
+ // A kind this host produces, or one a newer controller invented. Named either way.
839
+ this.#refuse(
840
+ frame.sessionId,
841
+ refusal('frame-malformed', `this host takes no inbound "${payload.kind}" — it is not a command`),
842
+ );
843
+ return;
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Send one turn at a live session and record that it happened.
849
+ *
850
+ * One path for both arrivals. A turn that waited and a turn that walked straight in must reach the
851
+ * agent identically and leave the same trace, or "it was queued" would become a second, quieter
852
+ * grade of delivery that nothing downstream could tell apart from the first.
853
+ */
854
+ #prompt(sessionKey: string, composed: ComposedSession, text: string): void {
855
+ const sent = composed.session.prompt(text);
856
+ if (!sent.ok) return this.#refuse(sessionKey, sent.refusal);
857
+ composed.observer.promptSubmitted('the controller sent a turn');
858
+ }
859
+
860
+ /** Hold a turn for a session still opening, or refuse it when the bound is already reached. */
861
+ #hold(sessionKey: string, text: string): void {
862
+ const waiting = this.#held.get(sessionKey) ?? [];
863
+ if (waiting.length >= MAX_HELD_TURNS) {
864
+ // Named rather than dropped, and `session-unknown` rather than a new reason: from the sender's
865
+ // side this handle still holds no session, and the vocabulary in `core/refusal.ts` is closed on
866
+ // purpose; a reason minted for one call site is one nobody else can branch on.
867
+ return this.#refuse(
868
+ sessionKey,
869
+ refusal(
870
+ 'session-unknown',
871
+ `${MAX_HELD_TURNS} turns are already waiting for ${sessionKey} to open — this one was not taken`,
872
+ ),
873
+ );
874
+ }
875
+ waiting.push(text);
876
+ this.#held.set(sessionKey, waiting);
877
+ this.#report({ kind: 'prompt-held', sessionKey, held: waiting.length });
878
+ }
879
+
880
+ /** Every held turn, in arrival order, the moment the session exists. */
881
+ #drain(sessionKey: string, composed: ComposedSession): void {
882
+ const waiting = this.#held.get(sessionKey);
883
+ if (waiting === undefined) return;
884
+ this.#held.delete(sessionKey);
885
+ for (const text of waiting) this.#prompt(sessionKey, composed, text);
886
+ this.#report({ kind: 'prompt-delivered', sessionKey, delivered: waiting.length });
887
+ }
888
+
889
+ /**
890
+ * The controller withdrew the turns it was waiting on. The third answer, and the only one the
891
+ * sender asked for.
892
+ *
893
+ * It is reported rather than silent, which is what keeps it inside the invariant instead of
894
+ * being the exception that swallows it: a withdrawn turn and a dropped one are the same absence on
895
+ * the wire, and only one of them is something to investigate.
896
+ *
897
+ * The limit: no session exists yet, so there is no machine to record an `interrupted`
898
+ * transition on; this is reported by the host, not by the session's own state log. A cancel that
899
+ * lands after the open takes the ordinary path and is recorded there.
900
+ */
901
+ #withdraw(sessionKey: string): void {
902
+ const waiting = this.#held.get(sessionKey) ?? [];
903
+ this.#held.delete(sessionKey);
904
+ this.#report({ kind: 'prompt-withdrawn', sessionKey, withdrawn: waiting.length });
905
+ }
906
+
907
+ /**
908
+ * The open ended without a session: every turn still waiting is refused, one refusal each.
909
+ *
910
+ * This is the half that keeps the queue honest. Holding a turn is a promise to answer it, and the
911
+ * only failure worse than refusing a turn that could have been served is accepting one and saying
912
+ * nothing, which is the defect this whole queue exists to end, relocated one layer in.
913
+ */
914
+ #abandon(sessionKey: string): void {
915
+ const waiting = this.#held.get(sessionKey);
916
+ if (waiting === undefined) return;
917
+ this.#held.delete(sessionKey);
918
+ for (const _text of waiting) {
919
+ this.#refuse(
920
+ sessionKey,
921
+ refusal('session-unknown', `${sessionKey} never opened, so a turn held for it cannot be delivered`),
922
+ );
923
+ }
924
+ }
925
+
926
+ async #open(sessionKey: string, opening: SessionNew): Promise<void> {
927
+ if (this.#sessions.has(sessionKey) || this.#opening.has(sessionKey)) {
928
+ // Two `session_new` for one handle: the second must not silently replace the first, which
929
+ // would leave a live agent running with nothing routed to it. The `#opening` half of the
930
+ // check is what makes this hold for two frames in flight: with a workspace provider
931
+ // configured there is an await between here and the map write, and a guard that reads only
932
+ // the map lets both frames through it: double spawn, one leaked agent, one handle.
933
+ return this.#refuse(
934
+ sessionKey,
935
+ refusal('session-unknown', `${sessionKey} is already a live or opening session here`),
936
+ );
937
+ }
938
+
939
+ this.#opening.add(sessionKey);
940
+ try {
941
+ await this.#compose(sessionKey, opening);
942
+ } catch (error) {
943
+ // Every exit from an open is a wire answer. The dispatcher calls this as
944
+ // `void this.#open(…)`, so without this catch a throw anywhere in `#compose` would become an
945
+ // unhandled rejection: the controller gets nothing at all, its record of the session reads
946
+ // `open` forever, and on a host with no unhandledRejection handler the process could die
947
+ // outright. A command that vanishes reads as a host that hung, the exact failure refusing
948
+ // every unknown payload by name exists to prevent, arriving through the one door that was not
949
+ // refusing.
950
+ //
951
+ // Caught here rather than at each throw site, deliberately. Guarding a named list of throw
952
+ // sources leaves the next one unguarded; this arm cannot be outrun by a source nobody has
953
+ // written yet.
954
+ //
955
+ // The `finally` below still runs for the queue: held turns are answered by `#abandon`, so a
956
+ // throw during an open does not silently strand the turns waiting on it.
957
+ this.#refuseOpen(
958
+ sessionKey,
959
+ null,
960
+ refusal(
961
+ 'session-spawn-failed',
962
+ `opening ${sessionKey} threw rather than returning a refusal: ${describe(error)}`,
963
+ ),
964
+ );
965
+ } finally {
966
+ this.#opening.delete(sessionKey);
967
+ // A successful open drained and cleared the queue already, so this finds nothing. Anything still
968
+ // here means the open did not produce a session (a refused request, a failed provision, a
969
+ // `giveBack`, or a throw) and each waiting turn is answered rather than forgotten. It sits in
970
+ // `finally` so no failure path can skip it, including one nobody has written yet.
971
+ this.#abandon(sessionKey);
972
+ // An open that produced no session holds no workspace claim, so its key entry goes too: in
973
+ // `finally` for the same reason as the queue, and guarded so a successful open keeps the
974
+ // entry the release sites and the in-use guard read for the session's whole life.
975
+ if (!this.#sessions.has(sessionKey)) this.#workspaceKeyFor.delete(sessionKey);
976
+ }
977
+ }
978
+
979
+ async #compose(sessionKey: string, opening: SessionNew): Promise<void> {
980
+ // Narrowed before the workspace is claimed, and the order is the point. A request this host
981
+ // cannot use is refused while it still owns nothing: provisioning first would take a directory,
982
+ // find the request unusable, and have to hand it back, a claim/release round trip on every
983
+ // malformed frame, in the one path a stranger can drive.
984
+ const requested = readSessionRequest(opening.request);
985
+ if (!requested.ok) return this.#refuseOpen(sessionKey, null, requested.refusal);
986
+
987
+ const provider = this.#workspaces;
988
+
989
+ // Where the workspace key resolves: each `??` is a default for an absence, never an
990
+ // override, so a controller that names a key never has the host's configured default consulted.
991
+ // Validated before the workspace is claimed, same order and same reason as the request
992
+ // narrowing above: a key this host cannot use must refuse while it still owns nothing.
993
+ const askedKey = opening.workspaceKey ?? this.#options.defaultWorkspaceKey ?? null;
994
+ if (askedKey !== null) {
995
+ const source =
996
+ opening.workspaceKey !== null
997
+ ? 'session_new.workspaceKey'
998
+ : "this host's configured default workspace key";
999
+ if (provider === undefined) {
1000
+ // The `path-input-missing` posture's mirror image: with no provider there is nothing to
1001
+ // provision at any key, and running in the controller's cwd instead would deliver the
1002
+ // wrong topology silently, the exact failure an explicit key exists to end.
1003
+ return this.#refuseOpen(
1004
+ sessionKey,
1005
+ null,
1006
+ refusal(
1007
+ 'workspace-provision-failed',
1008
+ `${source} names ${askedKey} and this host has no workspace provider — there is nothing to provision at that key, so the ask cannot be honoured`,
1009
+ ),
1010
+ );
1011
+ }
1012
+ const problem = unusableKeyProblem(askedKey);
1013
+ if (problem !== null) {
1014
+ // An unusable key is refused by the host's own guard with the field named, never left to
1015
+ // die inside git as an unnamed provision failure.
1016
+ // `keyPreview`, never the raw key: an over-length key's refusal must itself stay sendable.
1017
+ return this.#refuseOpen(
1018
+ sessionKey,
1019
+ null,
1020
+ refusal('workspace-provision-failed', `${source} ${keyPreview(askedKey)} ${problem}`),
1021
+ );
1022
+ }
1023
+ }
1024
+ const workspaceKey = askedKey ?? sessionKey;
1025
+
1026
+ let cwd = opening.cwd;
1027
+ // The provider decides, except for the repository it clones from. A named cwd is advisory
1028
+ // under a provider, with one exception: the provider's own repository root, the operator's
1029
+ // checkout, trusted in their ~/.claude.json, where the CLI they know runs and every settings
1030
+ // tier loads. There is no isolation to protect when the operator asks for the source itself,
1031
+ // and no worktree could be more "the repo" than the repo. Any other named directory still gets
1032
+ // a provisioned workspace. The workspace guard's root follows the cwd either way; the
1033
+ // operator's checkout claims no workspace key, so the reap never touches it.
1034
+ const operatorsCheckout =
1035
+ provider !== undefined &&
1036
+ cwd !== null &&
1037
+ provider.repositoryRoot !== undefined &&
1038
+ normalizePath(cwd).toLowerCase() === normalizePath(provider.repositoryRoot).toLowerCase();
1039
+ // A resume runs where its transcript lives, or not at all. The CLI keeps transcripts per cwd;
1040
+ // a resume moved into a provisioned workspace finds nothing and becomes a fresh session that
1041
+ // says nothing. A named cwd the provider would not honour is therefore refused by name, on the
1042
+ // wire, instead of being quietly overruled.
1043
+ if (provider !== undefined && !operatorsCheckout && cwd !== null && opening.request?.resume) {
1044
+ return this.#refuseOpen(
1045
+ sessionKey,
1046
+ null,
1047
+ refusal(
1048
+ 'resume-cwd-not-honoured',
1049
+ `the resume names ${cwd}, but this host provisions workspaces and honours only its own repository root (${provider.repositoryRoot ?? 'none configured'}) — the transcript would not be found anywhere else, so the session is not started`,
1050
+ ),
1051
+ );
1052
+ }
1053
+ let claimed = false;
1054
+ if (provider !== undefined && !operatorsCheckout) {
1055
+ // Before the await, so the reap's in-use guard already covers a session whose open is still
1056
+ // in flight. `#open`'s finally clears it again when the open produced no session.
1057
+ this.#workspaceKeyFor.set(sessionKey, workspaceKey);
1058
+ // The turn, not a bare await: a provision arriving while this key's reap (or a closing
1059
+ // session's release) is still running waits for it to settle instead of racing the removal.
1060
+ const provisioned = await this.#workspaceTurns.run(workspaceKey, () =>
1061
+ provider.provision(workspaceKey),
1062
+ );
1063
+ if (!provisioned.ok) return this.#refuseOpen(sessionKey, null, provisioned.refusal);
1064
+ cwd = provisioned.value.path;
1065
+ claimed = true;
1066
+ } else if (cwd === null) {
1067
+ // A null cwd is "the provider decides", and this host has no provider, so there is no
1068
+ // decider and nowhere honest to fall back to. Not `process.cwd()`: sharing the host's own
1069
+ // directory is the weakest isolation `bin/workspaces.ts` knows, and it is a posture an embedder
1070
+ // chooses, never one a frame's absence smuggles in. Refused before anything is claimed, with
1071
+ // the same name a path-taking tool uses for a pathless input.
1072
+ return this.#refuseOpen(
1073
+ sessionKey,
1074
+ null,
1075
+ refusal(
1076
+ 'path-input-missing',
1077
+ 'session_new carries cwd: null and this host has no workspace provider — there is no decider to defer to, so the session has nowhere to run',
1078
+ ),
1079
+ );
1080
+ }
1081
+
1082
+ // A workspace this host asked for and then could not use is handed back, on every failure path
1083
+ // below. The provider's default is to leave the directory alone, so this is a release of the
1084
+ // claim rather than a deletion, but a host that never releases makes `provision` and `release`
1085
+ // unbalanced, and a provider counting sessions would leak one per refused start.
1086
+ const giveBack = async (refused: Refusal): Promise<void> => {
1087
+ this.#refuseOpen(sessionKey, cwd, refused);
1088
+ if (!claimed || provider === undefined) return;
1089
+ // The claim was taken at the workspace key, so it is handed back at the workspace key;
1090
+ // releasing the session key here would release nothing the moment keys alias.
1091
+ const released = await provider.release(workspaceKey);
1092
+ if (!released.ok) this.#refuse(sessionKey, released.refusal);
1093
+ };
1094
+
1095
+ const tools = this.#toolServer(sessionKey);
1096
+ if (!tools.ok) return giveBack(tools.refusal);
1097
+
1098
+ // The one throw source in this function, named. `readWhere` walks up from `cwd` looking for
1099
+ // a `.git`, so it touches the filesystem with a path the controller chose: a malformed `cwd`
1100
+ // (an illegal segment, a path the OS refuses to stat) throws out of here rather than
1101
+ // returning. The catch in `#open` would turn that into a refusal anyway; this exists so the
1102
+ // refusal says where, instead of naming the whole open.
1103
+ //
1104
+ // Its two neighbours are not throw sources and are not guarded: `localGate({…})` builds a
1105
+ // closure and touches nothing at construction, and `nodePathResolver` is only stored here; its
1106
+ // one throw fires later, inside the gate's decide path, which is a different surface with a
1107
+ // different answer.
1108
+ let where: TransitionWhere;
1109
+ try {
1110
+ where = readWhere(cwd);
1111
+ } catch (error) {
1112
+ return giveBack(
1113
+ refusal(
1114
+ 'session-spawn-failed',
1115
+ `reading git facts for ${cwd} threw, so this session has no honest 'where': ${describe(error)}`,
1116
+ ),
1117
+ );
1118
+ }
1119
+
1120
+ const servers = mergeMcpServers(requested.value.mcpServers, tools.value);
1121
+ if (!servers.ok) return giveBack(servers.refusal);
1122
+
1123
+ const composed = composeSession({
1124
+ registry: this.#registry,
1125
+ sessionKey,
1126
+ cwd,
1127
+ sink: this.#link,
1128
+ decide: this.#options.decide,
1129
+ // Jailed to its own workspace by default, and the default is the whole point: a host whose
1130
+ // local gate is opt-in is a host most embedders run without one.
1131
+ localGate: localGate({
1132
+ workspaceRoot: cwd,
1133
+ resolve: nodePathResolver,
1134
+ protectedPaths: this.#options.protectedPaths,
1135
+ ...(this.#options.toolFamilies === undefined ? {} : { toolFamilies: this.#options.toolFamilies }),
1136
+ }),
1137
+ where,
1138
+ // The controller's own handle when it sent one, the routing key otherwise. The key stays the
1139
+ // fallback rather than null: a transition with no correlation at all is strictly less useful
1140
+ // than one correlated to the handle the controller is already using. The two are still
1141
+ // different contracts (one is interpreted by construction, the other never) and nothing here
1142
+ // derives one from the other in the direction that would matter.
1143
+ correlationId: opening.correlationId ?? sessionKey,
1144
+ // This host grants what its gate approves, and that is a posture rather than an inherited
1145
+ // default. It loads no settings files, so nothing sits behind the gate that a grant could
1146
+ // override, and without it the gate would be a veto: able to refuse a call, unable to let one
1147
+ // through. An embedder who wants the silent-allow posture sets `grantOnAllow: false` and gets
1148
+ // a host whose agent cannot run a tool, which is a legitimate thing to want and a surprising
1149
+ // thing to get by accident. Recorded in SECURITY.md, not only here.
1150
+ // Three layers, and the order is a decision. The host's posture is the floor, the embedder's
1151
+ // configuration overrides it, and the controller's per-session timings win last, because a
1152
+ // timing is a statement about how long that controller is willing to wait, and it is the only
1153
+ // party that knows. `grantOnAllow` is deliberately not reachable from the wire and so cannot be
1154
+ // overridden by the last spread; see `SessionNewGate`.
1155
+ gate: { grantOnAllow: true, ...(this.#options.gate ?? {}), ...readGateTimings(opening.gate) },
1156
+ request: { ...requested.value, ...(servers.value === null ? {} : { mcpServers: servers.value }) },
1157
+ ...(this.#options.clock === undefined ? {} : { clock: this.#options.clock }),
1158
+ ...(this.#options.ticker === undefined ? {} : { ticker: this.#options.ticker }),
1159
+ onRefusal: (refused) => this.#refuse(sessionKey, refused),
1160
+ onHookFailure: (failure) =>
1161
+ this.#refuse(
1162
+ sessionKey,
1163
+ refusal('transition-cause-unnamed', `a ${failure.event} handler threw: ${describe(failure.error)}`),
1164
+ ),
1165
+ });
1166
+ if (!composed.ok) return giveBack(composed.refusal);
1167
+
1168
+ this.#sessions.set(sessionKey, composed.value);
1169
+ composed.value.machine.onTransition((transition) =>
1170
+ this.#report({ kind: 'transition', sessionKey, transition }),
1171
+ );
1172
+ composed.value.session.onEnd(() => this.#close(sessionKey));
1173
+ // `onDegrade` replays what was raised before this line ran, which is what makes the
1174
+ // create-time degrades (an untrusted workspace is reported during `create()`) reachable at
1175
+ // all; the collision degrade arrives live later, when the agent reports an id already held.
1176
+ composed.value.session.onDegrade((degrade) => this.#report({ kind: 'degrade', sessionKey, degrade }));
1177
+ this.#report({ kind: 'session-opened', sessionKey, cwd });
1178
+ // After the open is reported, so the trace reads in causal order: held, opened, delivered. The
1179
+ // gap between the first and the last is the provisioning window, stated rather than inferred.
1180
+ this.#drain(sessionKey, composed.value);
1181
+ }
1182
+
1183
+ /**
1184
+ * Everything a finished session holds, released in the order that keeps its last frames.
1185
+ *
1186
+ * `forgetSession` deliberately does not discard unacked frames: a session's final transitions are
1187
+ * its most important and are exactly the ones still in flight if the link is down when it ends.
1188
+ */
1189
+ #close(sessionKey: string): void {
1190
+ const composed = this.#sessions.get(sessionKey);
1191
+ if (composed === undefined) return;
1192
+ this.#sessions.delete(sessionKey);
1193
+ composed.detach();
1194
+ this.#link.forgetSession(sessionKey);
1195
+ // Released at the key the workspace was provisioned at: for an aliased session the two
1196
+ // differ, and releasing the session key would leak the claim. Deleted from the map first so
1197
+ // the in-use guard stops counting this session the moment it is gone.
1198
+ const workspaceKey = this.#workspaceKeyFor.get(sessionKey) ?? sessionKey;
1199
+ this.#workspaceKeyFor.delete(sessionKey);
1200
+ const provider = this.#workspaces;
1201
+ // The release rides the key's turn: the map entry above is gone synchronously, so a reap
1202
+ // arriving now already sees the key as free; serializing the release behind the same turn is
1203
+ // what keeps that reap from removing the tree while this release still runs.
1204
+ if (provider !== undefined) {
1205
+ void this.#workspaceTurns
1206
+ .run(workspaceKey, () => provider.release(workspaceKey))
1207
+ .then(
1208
+ (released) => {
1209
+ if (!released.ok) this.#refuse(sessionKey, released.refusal);
1210
+ },
1211
+ // A provider that throws instead of returning a refusal must land in the same place: the
1212
+ // named-refusal contract for a quiet release leak exists precisely because the failure is
1213
+ // invisible until a disk fills, and an unhandled rejection here was the one path around it.
1214
+ (error: unknown) => this.#refuse(sessionKey, refusal('workspace-release-failed', describe(error))),
1215
+ );
1216
+ }
1217
+ this.#report({ kind: 'session-closed', sessionKey });
1218
+ }
1219
+
1220
+ /**
1221
+ * The in-process MCP server for a session, or null when this host offers no tools.
1222
+ *
1223
+ * The descriptors are the embedder's, not the controller's, and that is forced rather than
1224
+ * chosen: no payload kind registers an in-process tool, so there is no way for a controller to
1225
+ * declare one over the link. (A controller can name an HTTP or stdio MCP server through
1226
+ * `session_new.request.mcpServers`; see `wire-request.ts`.)
1227
+ */
1228
+ #toolServer(sessionKey: string): Result<Readonly<Record<string, McpServerConfig>> | null> {
1229
+ const tools = this.#options.tools;
1230
+ if (tools === undefined) return ok(null);
1231
+
1232
+ const built = createToolServer({
1233
+ ...tools,
1234
+ // Read at call time, never captured: the agent has no id until its first turn is queued.
1235
+ identity: () => ({ sessionId: this.#sessions.get(sessionKey)?.machine.sessionId ?? null }),
1236
+ });
1237
+ if (!built.ok) {
1238
+ return refuse<Readonly<Record<string, McpServerConfig>> | null>(
1239
+ built.refusal.reason,
1240
+ built.refusal.detail,
1241
+ );
1242
+ }
1243
+ return ok({ [tools.name]: built.value });
1244
+ }
1245
+
1246
+ /**
1247
+ * Answer `session_list` from what this host holds: every handle it routes, plus the registry's
1248
+ * two counts. Synchronous: no filesystem, no process, nothing to await.
1249
+ */
1250
+ #answerSessionList(channelKey: string, asked: SessionList): void {
1251
+ const sessions = [...this.#sessions.entries()].map(([sessionKey, composed]) => ({
1252
+ sessionKey,
1253
+ sessionId: composed.session.id,
1254
+ state: composed.session.state,
1255
+ cwd: composed.machine.where.cwd,
1256
+ startedAt: composed.session.facts?.startedAt ?? null,
1257
+ }));
1258
+ this.#send(
1259
+ channelKey,
1260
+ sessionListResult(asked.requestId, sessions, {
1261
+ liveCount: this.#registry.liveCount,
1262
+ provisioningCount: this.#registry.provisioningCount,
1263
+ }),
1264
+ );
1265
+ }
1266
+
1267
+ /**
1268
+ * Answer `transcript_list` from the discovery door, one page per request.
1269
+ *
1270
+ * Every exit sends a wire answer. A missing root, a failed walk and a thrown error all become a
1271
+ * `transcript_failed` naming the reason; an answer that dies inside the host would read to the
1272
+ * asker as a host that hung, which is the exact failure the closed dispatch set exists to end.
1273
+ * (`#send` reports a send the link refused locally; the attempt is what every exit guarantees.)
1274
+ */
1275
+ async #answerTranscriptList(channelKey: string, asked: TranscriptList): Promise<void> {
1276
+ const root = this.#transcriptsRoot;
1277
+ if (root === undefined) {
1278
+ return this.#failDiscovery(
1279
+ channelKey,
1280
+ asked.requestId,
1281
+ refusal(
1282
+ 'path-input-missing',
1283
+ 'this host has no transcripts root configured — the discovery door cannot look',
1284
+ ),
1285
+ );
1286
+ }
1287
+ try {
1288
+ const page = await listTranscripts(root, { fromIndex: asked.fromIndex });
1289
+ this.#send(
1290
+ channelKey,
1291
+ transcriptListResult(asked.requestId, page.entries, {
1292
+ totalCount: page.totalCount,
1293
+ ...(page.nextIndex === null ? {} : { nextIndex: page.nextIndex }),
1294
+ }),
1295
+ );
1296
+ } catch (error) {
1297
+ this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
1298
+ }
1299
+ }
1300
+
1301
+ /** Answer `transcript_tail`. Same exit discipline as the listing: every path answers. */
1302
+ async #answerTranscriptTail(channelKey: string, asked: TranscriptTail): Promise<void> {
1303
+ const root = this.#transcriptsRoot;
1304
+ if (root === undefined) {
1305
+ return this.#failDiscovery(
1306
+ channelKey,
1307
+ asked.requestId,
1308
+ refusal(
1309
+ 'path-input-missing',
1310
+ 'this host has no transcripts root configured — the discovery door cannot look',
1311
+ ),
1312
+ );
1313
+ }
1314
+ try {
1315
+ const answer = await tailTranscript(root, asked.projectSlug, asked.sessionId, {
1316
+ fromOffset: asked.fromOffset,
1317
+ needle: asked.needle,
1318
+ });
1319
+ if (!answer.ok) return this.#failDiscovery(channelKey, asked.requestId, answer.refusal);
1320
+ this.#send(
1321
+ channelKey,
1322
+ transcriptTailResult(asked.requestId, {
1323
+ found: answer.value.found,
1324
+ absent: answer.value.absent,
1325
+ newOffset: answer.value.newOffset,
1326
+ ...(answer.value.sizeBytes === null ? {} : { sizeBytes: answer.value.sizeBytes }),
1327
+ ...(answer.value.mtimeMs === null ? {} : { mtimeMs: answer.value.mtimeMs }),
1328
+ }),
1329
+ );
1330
+ } catch (error) {
1331
+ this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
1332
+ }
1333
+ }
1334
+
1335
+ /**
1336
+ * Answer `repository_list`: one directory of the repository this host provisions from,
1337
+ * names only, jailed to the repository root by `listRepositoryDirectory`. Every exit is the one
1338
+ * result kind; a host with no repository root has nothing to read under and says so by name.
1339
+ */
1340
+ async #answerRepositoryList(channelKey: string, asked: RepositoryList): Promise<void> {
1341
+ const refused = (why: Refusal): void =>
1342
+ this.#refuseRepository(
1343
+ channelKey,
1344
+ repositoryListResult(asked.requestId, [], false, wireRefusalOf(why)),
1345
+ why,
1346
+ );
1347
+ const root = this.#workspaces?.repositoryRoot;
1348
+ if (root === undefined) return refused(noRepositoryRoot());
1349
+ try {
1350
+ const listed = await listRepositoryDirectory(root, asked.path, undefined, this.#options.protectedPaths);
1351
+ if (!listed.ok) return refused(listed.refusal);
1352
+ this.#send(
1353
+ channelKey,
1354
+ repositoryListResult(asked.requestId, listed.value.entries, listed.value.truncated),
1355
+ );
1356
+ } catch (error) {
1357
+ refused(refusal('repository-read-failed', describe(error)));
1358
+ }
1359
+ }
1360
+
1361
+ /** Answer `repository_read`: the head of one text file under the repository root. */
1362
+ async #answerRepositoryRead(channelKey: string, asked: RepositoryRead): Promise<void> {
1363
+ const nothing = { text: null, sizeBytes: 0, truncated: false };
1364
+ const refused = (why: Refusal): void =>
1365
+ this.#refuseRepository(
1366
+ channelKey,
1367
+ repositoryReadResult(asked.requestId, nothing, wireRefusalOf(why)),
1368
+ why,
1369
+ );
1370
+ const root = this.#workspaces?.repositoryRoot;
1371
+ if (root === undefined) return refused(noRepositoryRoot());
1372
+ try {
1373
+ const read = await readRepositoryFile(root, asked.path, asked.maxBytes, this.#options.protectedPaths);
1374
+ if (!read.ok) return refused(read.refusal);
1375
+ this.#send(channelKey, repositoryReadResult(asked.requestId, read.value));
1376
+ } catch (error) {
1377
+ refused(refusal('repository-read-failed', describe(error)));
1378
+ }
1379
+ }
1380
+
1381
+ /** A refused repository ask goes on the wire as its result kind, and is reported locally too. */
1382
+ #refuseRepository(
1383
+ channelKey: string,
1384
+ answer: RepositoryListResult | RepositoryReadResult,
1385
+ why: Refusal,
1386
+ ): void {
1387
+ this.#send(channelKey, answer);
1388
+ this.#refuse(channelKey, why);
1389
+ }
1390
+
1391
+ /** A discovery request that could not be answered says so on the wire; the `#failDelivery` twin. */
1392
+ #failDiscovery(channelKey: string, requestId: string, refused: Refusal): void {
1393
+ this.#send(channelKey, transcriptFailed(requestId, { reason: refused.reason, detail: refused.detail }));
1394
+ this.#refuse(channelKey, refused);
1395
+ }
1396
+
1397
+ /**
1398
+ * Answer `workspace_release` (protocol v5): remove a workspace's directory, on demand, by name.
1399
+ *
1400
+ * Every exit is the one result kind: `refusal: null` is the released answer, a named refusal
1401
+ * is every other, so "every exit sends a wire answer" is a property of the shape here rather than
1402
+ * a discipline across two builders. The answer is one small frame, nowhere near the frame cap,
1403
+ * unconditionally, since the key screen is length-bounded and the detail echoes `keyPreview`,
1404
+ * never the raw key.
1405
+ *
1406
+ * The in-use guard is many-to-one, because keys alias: N sessions can share one workspace key,
1407
+ * each with its own entry in `#workspaceKeyFor`, so the guard scans values and holds until the
1408
+ * last session on the key is gone. A guard that cleared on the first close would delete a tree
1409
+ * the other sessions are still working in, the exact conditions the shared-worktree topology
1410
+ * exists to create. Opening sessions count too: their entry is written before the provision
1411
+ * await.
1412
+ *
1413
+ * `ReleaseOptions.remove` stays default-false everywhere else. This is the one production path
1414
+ * that passes it, and it passes it because a caller asked; a session ending still leaves its
1415
+ * directory for whoever wants to look at it.
1416
+ */
1417
+ async #answerWorkspaceRelease(channelKey: string, asked: WorkspaceRelease): Promise<void> {
1418
+ const outcome = await this.#releaseOne(asked, false);
1419
+ this.#send(channelKey, workspaceReleaseResult(asked.requestId, outcome.result));
1420
+ if (outcome.refused !== null) this.#refuse(channelKey, outcome.refused);
1421
+ }
1422
+
1423
+ /**
1424
+ * Every entry is judged and released on its own — different keys interleave, the same key
1425
+ * serialises under its turn — and a refusal on one never aborts the rest. Duplicates are judged
1426
+ * on the name as asked: the second entry naming a key or a path already in this ask does nothing
1427
+ * and says so.
1428
+ */
1429
+ async #answerWorkspaceReleaseBulk(channelKey: string, asked: WorkspaceReleaseBulk): Promise<void> {
1430
+ const named = new Set<string>();
1431
+ const outcomes = await Promise.all(
1432
+ asked.releases.map((entry) => {
1433
+ const name = entry.workspaceKey ?? entry.path;
1434
+ const duplicate = name !== null && named.has(name);
1435
+ if (name !== null) named.add(name);
1436
+ return this.#releaseOne(entry, duplicate);
1437
+ }),
1438
+ );
1439
+ this.#send(
1440
+ channelKey,
1441
+ workspaceReleaseBulkResult(
1442
+ asked.requestId,
1443
+ outcomes.map((outcome) => outcome.result),
1444
+ ),
1445
+ );
1446
+ for (const outcome of outcomes) {
1447
+ if (outcome.refused !== null) this.#refuse(channelKey, outcome.refused);
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * One release: resolve the address to a key, screen it, then under the key's turn run the in-use
1453
+ * guard and the provider's release with the ask's flags. Every exit is a result; `refused` is the
1454
+ * same refusal for the log.
1455
+ */
1456
+ async #releaseOne(
1457
+ ask: WorkspaceReleaseEntry,
1458
+ duplicate: boolean,
1459
+ ): Promise<{ result: WorkspaceReleaseEntryResult; refused: Refusal | null }> {
1460
+ const provider = this.#workspaces;
1461
+ const refused = (
1462
+ reason: RefusalReason,
1463
+ detail: string,
1464
+ partial: Partial<WorkspaceReleaseEntryResult> = {},
1465
+ ): { result: WorkspaceReleaseEntryResult; refused: Refusal } => {
1466
+ const problem = refusal(reason, detail);
1467
+ return {
1468
+ result: workspaceReleaseEntryResult({
1469
+ ...partial,
1470
+ refusal: { reason: problem.reason, detail: problem.detail },
1471
+ }),
1472
+ refused: problem,
1473
+ };
1474
+ };
1475
+
1476
+ if (provider === undefined) {
1477
+ return refused(
1478
+ 'workspace-release-failed',
1479
+ 'this host has no workspace provider — there is no workspace to release',
1480
+ );
1481
+ }
1482
+ if (ask.workspaceKey !== null && ask.path !== null) {
1483
+ return refused(
1484
+ 'workspace-release-failed',
1485
+ 'workspace_release names both a workspaceKey and a path — name exactly one',
1486
+ );
1487
+ }
1488
+ let workspaceKey: string;
1489
+ if (ask.path !== null) {
1490
+ if (provider.keyForPath === undefined) {
1491
+ return refused(
1492
+ 'workspace-release-failed',
1493
+ "this host's workspace provider cannot address a worktree by path — name its workspaceKey",
1494
+ { path: ask.path },
1495
+ );
1496
+ }
1497
+ const resolved = provider.keyForPath(ask.path);
1498
+ if (resolved === null) {
1499
+ return refused(
1500
+ 'workspace-release-failed',
1501
+ `workspace_release.path ${keyPreview(ask.path)} is not a directory directly under this host's workspace root`,
1502
+ { path: ask.path },
1503
+ );
1504
+ }
1505
+ workspaceKey = resolved;
1506
+ } else if (ask.workspaceKey !== null) {
1507
+ workspaceKey = ask.workspaceKey;
1508
+ } else {
1509
+ return refused(
1510
+ 'workspace-release-failed',
1511
+ 'workspace_release names neither a workspaceKey nor a path — name exactly one',
1512
+ );
1513
+ }
1514
+ const address: Partial<WorkspaceReleaseEntryResult> = { workspaceKey, path: ask.path };
1515
+ const problem = unusableKeyProblem(workspaceKey);
1516
+ if (problem !== null) {
1517
+ return refused(
1518
+ 'workspace-release-failed',
1519
+ `workspace_release.workspaceKey ${keyPreview(workspaceKey)} ${problem}`,
1520
+ address,
1521
+ );
1522
+ }
1523
+ if (duplicate) {
1524
+ return refused(
1525
+ 'workspace-release-failed',
1526
+ `workspace ${workspaceKey} is named twice in one ask — this entry does nothing`,
1527
+ address,
1528
+ );
1529
+ }
1530
+ // The scan and the removal ride one turn. The in-use guard is check-then-act across a
1531
+ // multi-second `git worktree remove`; scanned outside the turn, a `session_new` on this key
1532
+ // could pass its own guard mid-removal and provision into a dying directory while this answer
1533
+ // reads released. Inside the turn the scan runs after any queued provision or close-release
1534
+ // settles, so it reads the map the other verbs have finished writing.
1535
+ return this.#workspaceTurns.run(workspaceKey, async () => {
1536
+ const inUse = [...this.#workspaceKeyFor.values()].includes(workspaceKey);
1537
+ if (inUse) {
1538
+ return refused(
1539
+ 'workspace-release-failed',
1540
+ `workspace ${workspaceKey} still backs a live or opening session on this host — close every session on it before asking for its removal`,
1541
+ address,
1542
+ );
1543
+ }
1544
+ try {
1545
+ const released = await provider.release(workspaceKey, {
1546
+ remove: true,
1547
+ deleteBranch: ask.deleteBranch,
1548
+ force: ask.force,
1549
+ });
1550
+ if (!released.ok) return refused(released.refusal.reason, released.refusal.detail, address);
1551
+ const receipt = released.value;
1552
+ // A provider that answers no receipt vouches for nothing beyond "released or absent".
1553
+ if (receipt === undefined) return { result: workspaceReleaseEntryResult(address), refused: null };
1554
+ const partial = {
1555
+ workspaceKey,
1556
+ path: receipt.path,
1557
+ directoryRemoved: receipt.directoryRemoved,
1558
+ branchDeleted: receipt.branchDeleted,
1559
+ };
1560
+ if (receipt.refusal !== null) return refused(receipt.refusal.reason, receipt.refusal.detail, partial);
1561
+ return { result: workspaceReleaseEntryResult(partial), refused: null };
1562
+ } catch (error) {
1563
+ // A provider that throws instead of refusing lands in the same named place, the contract
1564
+ // the ordinary release path already holds.
1565
+ return refused('workspace-release-failed', describe(error), address);
1566
+ }
1567
+ });
1568
+ }
1569
+
1570
+ /**
1571
+ * List the worktrees under the workspace root, read from disk now. Paged like the transcript
1572
+ * listing; every exit is one `workspace_list_result`, refusal or page, so a host that cannot look
1573
+ * says so by name rather than going quiet.
1574
+ */
1575
+ async #answerWorkspaceList(channelKey: string, asked: WorkspaceList): Promise<void> {
1576
+ const answer = (
1577
+ page: {
1578
+ entries: readonly WorkspaceEntry[];
1579
+ totalCount: number;
1580
+ nextIndex?: number;
1581
+ defaultBranch: string | null;
1582
+ },
1583
+ refused?: Refusal,
1584
+ ): void => {
1585
+ this.#send(
1586
+ channelKey,
1587
+ workspaceListResult(
1588
+ asked.requestId,
1589
+ page.entries,
1590
+ page,
1591
+ refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail },
1592
+ ),
1593
+ );
1594
+ if (refused !== undefined) this.#refuse(channelKey, refused);
1595
+ };
1596
+ const empty = { entries: [], totalCount: 0, defaultBranch: null };
1597
+
1598
+ const provider = this.#workspaces;
1599
+ if (provider === undefined) {
1600
+ return answer(
1601
+ empty,
1602
+ refusal('workspace-list-failed', 'this host has no workspace provider — there is nothing to list'),
1603
+ );
1604
+ }
1605
+ if (provider.inventory === undefined) {
1606
+ return answer(
1607
+ empty,
1608
+ refusal('workspace-list-failed', "this host's workspace provider keeps no inventory"),
1609
+ );
1610
+ }
1611
+ let inventory: Result<WorkspaceInventory>;
1612
+ try {
1613
+ inventory = await provider.inventory();
1614
+ } catch (error) {
1615
+ return answer(empty, refusal('workspace-list-failed', describe(error)));
1616
+ }
1617
+ if (!inventory.ok) return answer(empty, inventory.refusal);
1618
+
1619
+ const all = inventory.value.entries;
1620
+ const from = Math.max(0, asked.fromIndex);
1621
+ const end = Math.min(all.length, from + WORKSPACE_PAGE_SIZE);
1622
+ return answer({
1623
+ entries: all.slice(from, end),
1624
+ totalCount: all.length,
1625
+ ...(end < all.length ? { nextIndex: end } : {}),
1626
+ defaultBranch: inventory.value.defaultBranch,
1627
+ });
1628
+ }
1629
+
1630
+ /**
1631
+ * Apply a configuration change. Every exit is one `host_configure_result` carrying the
1632
+ * effective configuration, so a controller always learns what this host runs with, refused or
1633
+ * not. The seam validates and writes; this method only decides whether the host is busy and swaps
1634
+ * the rebuilt pieces in, then tells the link what its next hello declares.
1635
+ */
1636
+ #answerHostConfigure(channelKey: string, asked: HostConfigure): void {
1637
+ const answer = (refused?: Refusal): void => {
1638
+ this.#send(
1639
+ channelKey,
1640
+ hostConfigureResult(
1641
+ asked.requestId,
1642
+ this.#configuration,
1643
+ this.#overriddenByEnvironment,
1644
+ refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail },
1645
+ this.#pendingRestart,
1646
+ ),
1647
+ );
1648
+ if (refused !== undefined) this.#refuse(channelKey, refused);
1649
+ };
1650
+
1651
+ const reconfigure = this.#options.reconfigure;
1652
+ if (reconfigure === undefined) {
1653
+ return answer(
1654
+ refusal(
1655
+ 'config-write-failed',
1656
+ 'this host was composed without a configuration seam, so nothing can be written',
1657
+ ),
1658
+ );
1659
+ }
1660
+ // Busy means a workspace is claimed or being claimed: a session releases through the provider
1661
+ // that provisioned it, so the roots cannot move under one. The seam refuses only when the ask
1662
+ // actually changes a root; a scheme or transcripts-root change applies live regardless.
1663
+ const hostBusy = this.#sessions.size > 0 || this.#opening.size > 0;
1664
+ let outcome: Result<HostReconfigured>;
1665
+ try {
1666
+ outcome = reconfigure(asked.entries, hostBusy);
1667
+ } catch (error) {
1668
+ return answer(refusal('config-write-failed', describe(error)));
1669
+ }
1670
+ if (!outcome.ok) return answer(outcome.refusal);
1671
+
1672
+ const rebuilt = outcome.value;
1673
+ this.#workspaces = rebuilt.workspaces;
1674
+ this.#transcriptsRoot = rebuilt.transcriptsRoot;
1675
+ this.#bulk = rebuilt.bulk;
1676
+ this.#linkCapabilities = rebuilt.linkCapabilities;
1677
+ this.#configuration = rebuilt.configuration;
1678
+ this.#overriddenByEnvironment = rebuilt.overriddenByEnvironment;
1679
+ // The control-plane addresses are never applied to the live link: the host keeps dialling what it
1680
+ // dialled, names the keys as pending, and the next start reads the file.
1681
+ this.#pendingRestart = rebuilt.pendingRestart;
1682
+ this.#link.announce?.(this.#linkCapabilities, this.#configuration, this.#pendingRestart);
1683
+ return answer();
1684
+ }
1685
+
1686
+ async #deliver(
1687
+ sessionKey: string,
1688
+ deliveryId: string,
1689
+ what: string,
1690
+ postUrl: string,
1691
+ fromOffset: number,
1692
+ ): Promise<void> {
1693
+ const resolve = this.#bulk;
1694
+ if (resolve === undefined) {
1695
+ return this.#failDelivery(
1696
+ sessionKey,
1697
+ deliveryId,
1698
+ refusal('bulk-target-invalid', 'this host resolves no bulk content — nothing can be delivered'),
1699
+ );
1700
+ }
1701
+
1702
+ const filePath = resolve(what, sessionKey);
1703
+ if (!filePath.ok) return this.#failDelivery(sessionKey, deliveryId, filePath.refusal);
1704
+
1705
+ // The one origin this host will POST to, derived from the link it already dials. Resolved per
1706
+ // delivery rather than cached so a reconfigured controller URL cannot leave a stale trusted
1707
+ // origin behind it.
1708
+ const allowedOrigin = bulkOriginFor(this.#options.controllerUrl);
1709
+ if (!allowedOrigin.ok) return this.#failDelivery(sessionKey, deliveryId, allowedOrigin.refusal);
1710
+
1711
+ // The same credential the link and the decision POST present, resolved per delivery. A
1712
+ // credential that refuses posts headerless rather than not at all: this lane is a read-back the
1713
+ // controller itself asked for, and the receiver's refusal comes back loud on the wire as
1714
+ // `bulk-delivery-failed` naming the status, which beats a silent no-request.
1715
+ let headers: Readonly<Record<string, string>> | undefined;
1716
+ const credential = this.#options.credential;
1717
+ if (credential !== undefined) {
1718
+ const authorized = await credential.authorize();
1719
+ if (authorized.ok) headers = { [authorized.value.header]: authorized.value.value };
1720
+ }
1721
+
1722
+ let receipt: Result<BulkPostReceipt>;
1723
+ try {
1724
+ receipt = await postBulk({
1725
+ deliveryId,
1726
+ postUrl,
1727
+ allowedOrigin: allowedOrigin.value,
1728
+ filePath: filePath.value,
1729
+ fromOffset,
1730
+ ...(headers === undefined ? {} : { headers }),
1731
+ });
1732
+ } catch (error) {
1733
+ receipt = refuse<BulkPostReceipt>('bulk-delivery-failed', describe(error));
1734
+ }
1735
+ if (!receipt.ok) return this.#failDelivery(sessionKey, deliveryId, receipt.refusal);
1736
+
1737
+ this.#send(sessionKey, {
1738
+ kind: 'bulk_delivered',
1739
+ deliveryId,
1740
+ byteCount: receipt.value.byteCount,
1741
+ // The stat pair rides the receipt so a transcript puller can detect a rewrite. See
1742
+ // `BulkDelivered` in frames.ts.
1743
+ sizeBytes: receipt.value.sizeBytes,
1744
+ mtimeMs: receipt.value.mtimeMs,
1745
+ });
1746
+ }
1747
+
1748
+ /** A delivery that did not happen says so on the wire; it is a receipt, not a silence. */
1749
+ #failDelivery(sessionKey: string, deliveryId: string, refused: Refusal): void {
1750
+ this.#send(sessionKey, {
1751
+ kind: 'bulk_failed',
1752
+ deliveryId,
1753
+ refusal: { reason: refused.reason, detail: refused.detail },
1754
+ });
1755
+ this.#refuse(sessionKey, refused);
1756
+ }
1757
+
1758
+ /**
1759
+ * Send one payload. A refusal other than queue overflow is reported locally, since there is no
1760
+ * wire to tell; an overflow refusal of an undroppable frame holds the frame for a retry instead.
1761
+ */
1762
+ #send(sessionKey: string, payload: SessionPayload): void {
1763
+ const pending = this.#heldFrames.get(sessionKey);
1764
+ if (pending !== undefined && pending.length > 0) {
1765
+ if (isDroppable(payload.kind)) {
1766
+ this.#refuse(
1767
+ sessionKey,
1768
+ refusal(
1769
+ 'queue-dropped-droppable',
1770
+ `frames are held for this session; discarded an incoming ${payload.kind}`,
1771
+ ),
1772
+ );
1773
+ return;
1774
+ }
1775
+ this.#holdFrame(sessionKey, payload);
1776
+ return;
1777
+ }
1778
+ const sent = this.#link.send(sessionKey, payload);
1779
+ if (sent.ok) return;
1780
+ if (sent.refusal.reason === 'queue-overflow-undroppable' && !isDroppable(payload.kind)) {
1781
+ this.#holdFrame(sessionKey, payload);
1782
+ return;
1783
+ }
1784
+ this.#refuse(sessionKey, sent.refusal);
1785
+ }
1786
+
1787
+ #holdFrame(sessionKey: string, payload: SessionPayload): void {
1788
+ const pending = this.#heldFrames.get(sessionKey) ?? [];
1789
+ if (pending.length >= HELD_FRAMES_MAX) {
1790
+ this.#refuse(
1791
+ sessionKey,
1792
+ refusal(
1793
+ 'queue-overflow-undroppable',
1794
+ `${HELD_FRAMES_MAX} frames are already held for this session; ${payload.kind} could not be held`,
1795
+ ),
1796
+ );
1797
+ return;
1798
+ }
1799
+ pending.push(payload);
1800
+ this.#heldFrames.set(sessionKey, pending);
1801
+ this.#scheduleHeldFrames();
1802
+ }
1803
+
1804
+ #scheduleHeldFrames(): void {
1805
+ if (this.#heldFramesTimer !== null) return;
1806
+ // Ref'd on purpose: a held turn-end is work this process has promised to deliver.
1807
+ this.#heldFramesTimer = setTimeout(() => {
1808
+ this.#heldFramesTimer = null;
1809
+ this.#retryHeldFrames();
1810
+ }, HELD_RETRY_MS);
1811
+ }
1812
+
1813
+ #retryHeldFrames(): void {
1814
+ let remaining = false;
1815
+ for (const [sessionKey, pending] of this.#heldFrames) {
1816
+ while (pending.length > 0) {
1817
+ const head = pending[0];
1818
+ if (head === undefined) break;
1819
+ const sent = this.#link.send(sessionKey, head);
1820
+ if (sent.ok) {
1821
+ pending.shift();
1822
+ continue;
1823
+ }
1824
+ if (sent.refusal.reason !== 'queue-overflow-undroppable') {
1825
+ this.#refuse(sessionKey, sent.refusal);
1826
+ pending.shift();
1827
+ continue;
1828
+ }
1829
+ break;
1830
+ }
1831
+ if (pending.length === 0) this.#heldFrames.delete(sessionKey);
1832
+ else remaining = true;
1833
+ }
1834
+ if (remaining) this.#scheduleHeldFrames();
1835
+ }
1836
+
1837
+ #refuse(sessionKey: string, refused: Refusal): void {
1838
+ this.#report({ kind: 'refusal', refusal: refused, sessionKey });
1839
+ }
1840
+
1841
+ /**
1842
+ * A refused open goes on the wire. Reported locally only, the controller would keep a session
1843
+ * record nobody could talk to and the operator would watch a session that "did nothing" while
1844
+ * the refusal sat in this host's log. The session never had a machine, so this is the one
1845
+ * transition composed by hand: spawning to ended, cause `refusal` with the reason as its event
1846
+ * and the whole detail; the controller stores it, ends its record, and can show why. Local
1847
+ * report first, then the wire.
1848
+ */
1849
+ #refuseOpen(sessionKey: string, cwd: string | null, refused: Refusal): void {
1850
+ this.#refuse(sessionKey, refused);
1851
+ let where: TransitionWhere;
1852
+ try {
1853
+ where =
1854
+ cwd === null
1855
+ ? { cwd: '', worktree: null, branch: null, unknownReason: 'the session never had a workspace' }
1856
+ : readWhere(cwd);
1857
+ } catch {
1858
+ where = { cwd: cwd ?? '', worktree: null, branch: null, unknownReason: 'git facts could not be read' };
1859
+ }
1860
+ const transition: SessionTransition = {
1861
+ sessionId: null,
1862
+ // The controller's own handle is the only correlation this session ever had.
1863
+ correlationId: sessionKey,
1864
+ seq: 1,
1865
+ at: new Date(this.#options.clock?.() ?? Date.now()).toISOString(),
1866
+ from: 'spawning',
1867
+ to: 'ended',
1868
+ activity: null,
1869
+ entryId: null,
1870
+ cause: { kind: 'refusal', event: refused.reason, detail: refused.detail },
1871
+ where,
1872
+ };
1873
+ const sent = this.#link.send(sessionKey, stateTransitionUpdate(transition));
1874
+ if (!sent.ok) this.#report({ kind: 'refusal', refusal: sent.refusal, sessionKey });
1875
+ }
1876
+
1877
+ #report(event: HostEvent): void {
1878
+ try {
1879
+ this.#options.report?.(event);
1880
+ } catch {
1881
+ // The reporter is the failure channel; reporting a reporter would recurse.
1882
+ }
1883
+ }
1884
+ }
1885
+
1886
+ function describe(error: unknown): string {
1887
+ return error instanceof Error ? error.message : String(error);
1888
+ }
1889
+
1890
+ /** The repository asks on a host composed without a repository root: nothing to read under. */
1891
+ function noRepositoryRoot(): Refusal {
1892
+ return refusal(
1893
+ 'repository-path-escape',
1894
+ 'this host has no repository root configured, so there is nothing to read under',
1895
+ );
1896
+ }
1897
+
1898
+ /** A local refusal as the wire carries it. */
1899
+ function wireRefusalOf(why: Refusal): WireRefusal {
1900
+ return { reason: why.reason, detail: why.detail };
1901
+ }