@naswerks/periscope 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,1294 @@
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
+ import { systemClock, systemTicker } from '../core/time.js';
6
+ import { KeyedTurns } from '../core/keyed-turns.js';
7
+ import { refusal } from '../core/refusal.js';
8
+ import { ok, refuse } from '../core/result.js';
9
+ import { ControllerLink } from '../control/link.js';
10
+ import { WORKSPACE_PAGE_SIZE, hostConfigureResult, sessionListResult, workspaceListResult, transcriptFailed, transcriptListResult, transcriptTailResult, unsetHostConfiguration, workspaceReleaseBulkResult, workspaceReleaseEntryResult, workspaceReleaseResult, repositoryListResult, repositoryReadResult, stateTransitionUpdate, } from '../control/frames.js';
11
+ import { forwardSession } from '../control/stream.js';
12
+ import { localGate } from '../gate/local.js';
13
+ import { recordGateOutcome } from '../gate/outcome.js';
14
+ import { deadlineOrderRefusal, permissionHooks } from '../gate/gate.js';
15
+ import { SessionRegistry } from '../sessions/registry.js';
16
+ import { SessionStateMachine } from '../state/machine.js';
17
+ import { SessionObserver } from '../state/observer.js';
18
+ import { keyPreview, unusableKeyProblem } from '../workspace/git-worktree.js';
19
+ import { bulkOriginFor, postBulk } from './bulk-post.js';
20
+ import { readWhere } from './git-facts.js';
21
+ import { normalizePath } from '../core/paths.js';
22
+ import { mergeHooks, observationHooks } from './hooks.js';
23
+ import { createToolServer } from './mcp-server.js';
24
+ import { listTranscripts, tailTranscript } from './claude-transcripts.js';
25
+ import { listRepositoryDirectory, readRepositoryFile } from './repository-read.js';
26
+ import { nodePathResolver } from './paths.js';
27
+ import { isBypassMode, mergeMcpServers, readSessionConfigure, readSessionRequest } from './wire-request.js';
28
+ import { isDroppable } from '../control/frames.js';
29
+ /**
30
+ * Assemble one session: machine, observer, gate and forwarding, then start it.
31
+ *
32
+ * This is the wiring every consumer needs, written down where it can be used.
33
+ */
34
+ export function composeSession(options) {
35
+ // The one combination this function will not assemble, and this is the only place that can see
36
+ // it: the gate knows whether it grants and the request knows which settings tiers load, and
37
+ // neither knows the other. Refused before any process exists, by name, rather than resolved by a
38
+ // precedence that would surprise one of them.
39
+ //
40
+ // The ground for the refusal is narrow. Per the documented permission rules, deny and ask are
41
+ // evaluated whatever a hook returns, so a grant skips only mode, allow rules and `canUseTool`; an
42
+ // effective allow does not short-circuit every later permission check. The refusal stands on the
43
+ // narrower ground that the two mechanisms answer the same question from different places, and a
44
+ // session that both grants and loads operator rules has two authorities with no stated
45
+ // precedence. See `gate.ts` for the full order and for why the claim is documented rather than
46
+ // measured.
47
+ //
48
+ // And `settingSources: []` does not mean "no operator rules live". Managed policy settings and
49
+ // `~/.claude.json` load regardless of this field, precisely on the managed corporate laptop this
50
+ // package's threat model is written for. So this refusal is incomplete on its own logic: it
51
+ // catches the tiers named here and cannot see the two that are always on.
52
+ const settingSources = options.request?.settingSources ?? [];
53
+ // The pair is refused, except under bypassPermissions. What the grant skips is "permission
54
+ // mode, allow rules and canUseTool"; under bypass the mode already allows all of that, so the
55
+ // grant changes nothing and there is no second authority to shadow. A hook deny survives every
56
+ // mode, so the gate's boundary set still holds. The precedence is therefore stated by the mode
57
+ // itself: the operator asked for bypass, and got exactly the CLI's bypass plus this gate's
58
+ // refusals. Every other mode keeps the refusal below, unchanged.
59
+ if ((options.gate?.grantOnAllow ?? false) &&
60
+ settingSources.length > 0 &&
61
+ !isBypassMode(options.request?.permissionMode)) {
62
+ return refuse('permission-grant-shadows-settings', `grantOnAllow makes this gate's allow effective, and this session also loads operator settings ` +
63
+ `(${settingSources.join(', ')}) — two authorities over the same call with no stated ` +
64
+ `precedence. Per the SDK's documented evaluation order a hook allow does NOT skip deny or ` +
65
+ `ask rules, so those survive the grant; what it skips is permission mode, allow rules and ` +
66
+ `canUseTool. Load no settings, do not grant, or run bypassPermissions — where the grant ` +
67
+ `changes nothing because the mode already allows what it would. Note that managed policy and ` +
68
+ `~/.claude.json load regardless of settingSources, so an empty list is not proof that no ` +
69
+ `operator rule is live.`);
70
+ }
71
+ // The second combination this function will not assemble. `permissionHooks` throws when the
72
+ // host's own deadline does not expire before the matcher's, and that throw is right: the pair is
73
+ // checkable, the failure it prevents is a block nobody can explain, and an embedder calling
74
+ // `permissionHooks` directly should meet it loudly. But gate timings ride `session_new`, so a
75
+ // controller can send the pair, and a throw from here would escape the payload dispatcher's
76
+ // `void this.#open(…)` as an unhandled rejection, which means the controller gets no answer at
77
+ // all. A command that vanishes reads as a host that hung, which is precisely what refusing every
78
+ // unknown payload by name exists to prevent. Caught here rather than by relaxing the throw: the
79
+ // check keeps its teeth, and the composer keeps its contract of returning a Result for
80
+ // everything it declines to build.
81
+ const inverted = deadlineOrderRefusal(options.gate?.decisionTimeoutMs, options.gate?.matcherTimeoutSeconds);
82
+ if (inverted !== null)
83
+ return refuse('gate-deadlines-inverted', inverted);
84
+ const clock = options.clock ?? systemClock;
85
+ const ticker = options.ticker ?? systemTicker;
86
+ const machine = new SessionStateMachine({
87
+ where: options.where ?? unknownWhere(options.cwd),
88
+ clock,
89
+ ticker,
90
+ correlationId: options.correlationId ?? null,
91
+ });
92
+ const observer = new SessionObserver(machine);
93
+ // The gate records every outcome on the machine. That emission is what makes a deny, an outage and
94
+ // an expiry visible off-box at all, so it is unconditional and the embedder's listener is extra.
95
+ const gate = permissionHooks({
96
+ decide: options.decide,
97
+ // The escalation body carries the controller's handle. This function is the one place that
98
+ // holds both the handle and the gate, so it is where the two transports are tied together.
99
+ sessionKey: options.sessionKey,
100
+ onOutcome: (outcome) => {
101
+ recordGateOutcome(machine, outcome);
102
+ options.onOutcome?.(outcome);
103
+ },
104
+ ...(options.localGate === undefined ? {} : { localGate: options.localGate }),
105
+ ...(options.gate?.decisionTimeoutMs === undefined
106
+ ? {}
107
+ : { decisionTimeoutMs: options.gate.decisionTimeoutMs }),
108
+ ...(options.gate?.holdAfterMs === undefined ? {} : { holdAfterMs: options.gate.holdAfterMs }),
109
+ ...(options.gate?.matcherTimeoutSeconds === undefined
110
+ ? {}
111
+ : { matcherTimeoutSeconds: options.gate.matcherTimeoutSeconds }),
112
+ ...(options.gate?.grantOnAllow === undefined ? {} : { grantOnAllow: options.gate.grantOnAllow }),
113
+ // The `gate-cannot-grant` residual reaches the same lane every other named degrade uses, so an
114
+ // embedder who already subscribes to refusals learns about it without opting in to anything new.
115
+ ...(options.onRefusal === undefined
116
+ ? {}
117
+ : {
118
+ onDegrade: (degrade) => options.onRefusal?.(refusal(degrade.name, degrade.detail)),
119
+ }),
120
+ });
121
+ // Observation first, the gate second. The order is a convention rather than a race guard (see
122
+ // permissionHooks' own note), but it is the one both files state, so it is written once here.
123
+ const hooks = mergeHooks(observationHooks({
124
+ observer,
125
+ ...(options.onHookFailure === undefined ? {} : { onHandlerFailure: options.onHookFailure }),
126
+ }), gate);
127
+ const created = options.registry.create({ ...(options.request ?? {}), cwd: options.cwd, hooks });
128
+ if (!created.ok)
129
+ return refuse(created.refusal.reason, created.refusal.detail);
130
+ const session = created.value;
131
+ // Before the first record. See this file's header: the forwarder subscribes to the machine, so
132
+ // anything recorded ahead of this line is emitted to nobody.
133
+ const detach = forwardSession({
134
+ sessionKey: options.sessionKey,
135
+ session,
136
+ observer,
137
+ sink: options.sink,
138
+ ...(options.onRefusal === undefined ? {} : { onRefusal: options.onRefusal }),
139
+ });
140
+ observer.created(`a session was requested in ${options.cwd}`);
141
+ return ok({ sessionKey: options.sessionKey, session, machine, observer, detach });
142
+ }
143
+ /**
144
+ * The `where` for a directory nobody has read git facts for.
145
+ *
146
+ * Stated rather than left null: a transition whose `where` is absent cannot be told apart from one
147
+ * whose repository could not be determined, and only the second is a fact worth reporting.
148
+ */
149
+ function unknownWhere(cwd) {
150
+ return { cwd, worktree: null, branch: null, unknownReason: 'the repository was not read for this session' };
151
+ }
152
+ /**
153
+ * A controller's per-session timings, as a partial the composer can spread.
154
+ *
155
+ * Absent keys rather than nulls: `GateTimings` reads an absent key as "use the default", and a
156
+ * present `undefined` would spread over a value the embedder deliberately set. The difference is
157
+ * invisible until an embedder configures a timeout and a controller sends `gate: {…, holdAfterMs:
158
+ * null}` — at which point the embedder's value either survives or silently does not.
159
+ *
160
+ * Nothing validates the pair here, deliberately. `permissionHooks` throws at construction when the
161
+ * host's own deadline does not expire before the matcher's, and that check must stay the only one:
162
+ * a second copy of the rule is a second thing to keep in step. The throw surfaces as a refused
163
+ * composition before any process exists, which is where a controller wants to learn it.
164
+ */
165
+ function readGateTimings(gate) {
166
+ if (gate === null)
167
+ return {};
168
+ return {
169
+ ...(gate.decisionTimeoutMs === null ? {} : { decisionTimeoutMs: gate.decisionTimeoutMs }),
170
+ ...(gate.holdAfterMs === null ? {} : { holdAfterMs: gate.holdAfterMs }),
171
+ ...(gate.matcherTimeoutSeconds === null ? {} : { matcherTimeoutSeconds: gate.matcherTimeoutSeconds }),
172
+ };
173
+ }
174
+ /**
175
+ * How many turns may wait for one still-opening session before the rest are refused.
176
+ *
177
+ * The bound exists because the buffer is fed from the wire, not because anyone expects to reach it:
178
+ * a controller sends one seed per `session_new`, so the ordinary depth is 1. An unbounded per-handle
179
+ * buffer that a peer can grow is the shape this package refuses everywhere else, and the window it
180
+ * lives in is exactly as long as a workspace provider takes, which is seconds, not microseconds, and
181
+ * is the whole reason this queue exists.
182
+ */
183
+ const MAX_HELD_TURNS = 8;
184
+ /**
185
+ * A host: one outbound link, one registry, and the dispatcher between them.
186
+ *
187
+ * It interprets exactly the payload kinds its `#dispatch` switch names and refuses the rest by
188
+ * name: the session commands (`session_new`, `session_prompt`, `session_cancel`,
189
+ * `session_configure`), `bulk_request`, and the host-scoped asks (the discovery, workspace,
190
+ * configure and repository asks). Derive the count from the switch below; never carry it from
191
+ * prose. Anything else arriving inbound is
192
+ * either a frame this host produces (an update, a delta, a receipt) or a kind a newer controller
193
+ * invented, and both are reported rather than ignored; a command that vanishes reads to the
194
+ * controller as a host that hung.
195
+ */
196
+ export class PeriscopeHost {
197
+ #options;
198
+ #link;
199
+ #registry;
200
+ /** Keyed by the controller's handle, which is the only id present when `session_new` arrives. */
201
+ #sessions = new Map();
202
+ /**
203
+ * Handles whose open is still in flight. `#open` awaits the workspace provider before it can
204
+ * populate `#sessions`, so the map alone cannot make the duplicate guard hold across that await;
205
+ * the reservation is taken synchronously and released in the same call, whatever the outcome.
206
+ */
207
+ #opening = new Set();
208
+ /**
209
+ * regression: an undroppable frame the link's queue refused at capacity — a turn-end transition, a
210
+ * result — was reported here and dropped, so the controller never learned the turn ended and the
211
+ * session read as still working until its next turn. The queue's refusal is backpressure; the
212
+ * caller has to hold the frame and offer it again. Held per session, in order; while a session holds
213
+ * frames its later undroppable frames queue behind them, so the controller sees the session's order.
214
+ */
215
+ #heldFrames = new Map();
216
+ #heldFramesTimer = null;
217
+ /**
218
+ * Turns that arrived while their session was still opening, in order.
219
+ *
220
+ * A controller sends `session_new` then `session_prompt`, and that ordering is real on the wire,
221
+ * but a send returning Ok means written to the socket, not accepted, because the host
222
+ * acknowledges no controller frame (the reason `OutboundRetention` exists). Here the open is
223
+ * asynchronous: it awaits the workspace provider, and a real `GitWorktreeProvider` is a
224
+ * `git worktree add`, which takes seconds (measured at 13 s once). A seed landing in that window
225
+ * would be refused `session-unknown`, and the session would then open and sit idle forever.
226
+ * Nothing downstream could say why: a born session with no first turn looks exactly like a slow
227
+ * one.
228
+ *
229
+ * With `PlainDirProvider` (a `mkdir`) the window never opens, so only the git provider, which
230
+ * is what a session needs in order to commit or push at all, exposes this.
231
+ *
232
+ * The invariant, and it is the whole point: a held turn is answered, never dropped. Delivered
233
+ * when the session opens (`#compose`), refused if the open failed (`#open`'s `finally`, which runs
234
+ * on every path including a throw), or withdrawn because the controller cancelled it (`#withdraw`).
235
+ * Three exits, all of them reported. A queue that silently discarded on any one of them would be
236
+ * the same defect wearing a fix's clothes.
237
+ *
238
+ * It is deliberately not consulted for a handle this host has never heard of. `session-unknown`
239
+ * stays the honest, immediate answer for an id that does not exist and never will; retrying that
240
+ * is futile and `core/refusal.ts` says so. Only a handle already reserved in `#opening` can hold.
241
+ */
242
+ #held = new Map();
243
+ /**
244
+ * Which workspace key each live-or-opening session provisioned at (protocol v5).
245
+ *
246
+ * Two consumers, and both exist because keys can alias. The release sites read it so a session
247
+ * provisioned at a shared key releases that key rather than its own session key; without it,
248
+ * `provision('shared-1')` paired with `release(sessionKey)` would leak the claim on every
249
+ * aliased session. And the reap's in-use guard scans its values: N sessions can share one key,
250
+ * each holding its own entry here, so "is this key in use" stays true until the last of them
251
+ * closes; a single-mapping guard would clear on the first close and let a reap delete a tree
252
+ * other sessions are still working in.
253
+ *
254
+ * Written synchronously before the provision await (so an opening session already guards its
255
+ * key); cleared in `#open`'s finally when the open produced no session, and in `#close`.
256
+ */
257
+ #workspaceKeyFor = new Map();
258
+ /**
259
+ * Every provider call rides a per-key turn. `#dispatch` never awaits a handler, and the work
260
+ * behind these verbs is seconds long and mutually destructive on one key: a `git worktree add`
261
+ * measured at 13 to 18 s, a `git worktree remove --force` the same order. The reachable
262
+ * interleavings: a `session_new` provisioning into a directory the reap is mid-deleting (the
263
+ * in-use scan happened before the multi-second await), and a reap starting inside `#close`'s
264
+ * window (the map entry is deleted before its release settles). One serial queue per key closes
265
+ * every direction at the only chokepoint all three verbs share; keys that differ never wait on
266
+ * each other.
267
+ */
268
+ #workspaceTurns = new KeyedTurns();
269
+ /**
270
+ * The pieces a `host_configure` may replace, held apart from the frozen options so a reconfigure
271
+ * swaps them in one place and every handler reads the current one. Initialised from the options.
272
+ */
273
+ #workspaces;
274
+ #transcriptsRoot;
275
+ #bulk;
276
+ #linkCapabilities;
277
+ #configuration;
278
+ #overriddenByEnvironment;
279
+ #pendingRestart;
280
+ constructor(options) {
281
+ this.#options = options;
282
+ this.#workspaces = options.workspaces;
283
+ this.#transcriptsRoot = options.transcriptsRoot;
284
+ this.#bulk = options.bulk;
285
+ this.#linkCapabilities = options.linkCapabilities ?? [];
286
+ this.#configuration = options.configuration ?? unsetHostConfiguration();
287
+ this.#overriddenByEnvironment = options.overriddenByEnvironment ?? [];
288
+ this.#pendingRestart = options.pendingRestart ?? [];
289
+ if (options.registry !== undefined && (options.baseEnv !== undefined || options.homeDir !== undefined)) {
290
+ // A registry carries its own environment and home; a second pair beside it would be read by
291
+ // nothing, and an embedder who passed both would believe the pair was in effect.
292
+ throw new Error('PeriscopeHostOptions: baseEnv and homeDir are ignored when a registry is supplied; pass them to the registry');
293
+ }
294
+ this.#registry =
295
+ options.registry ??
296
+ new SessionRegistry({
297
+ baseEnv: options.baseEnv ?? {},
298
+ homeDir: options.homeDir ?? '',
299
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
300
+ });
301
+ const handlers = {
302
+ onTransition: (transition) => this.#report({ kind: 'link', transition }),
303
+ onSessionFrame: (frame) => this.#dispatch(frame),
304
+ onGap: (sessionKey, expected, received) => this.#report({ kind: 'gap', sessionKey, expected, received }),
305
+ onRefusal: (refused) => this.#report({ kind: 'refusal', refusal: refused, sessionKey: null }),
306
+ };
307
+ this.#link =
308
+ options.link?.(handlers) ??
309
+ new ControllerLink({
310
+ url: options.controllerUrl,
311
+ hostId: options.hostId,
312
+ handlers,
313
+ ...(options.credential === undefined ? {} : { credential: options.credential }),
314
+ ...(options.backoff === undefined ? {} : { backoff: options.backoff }),
315
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
316
+ ...(options.ticker === undefined ? {} : { ticker: options.ticker }),
317
+ ...(options.linkCapabilities === undefined ? {} : { capabilities: options.linkCapabilities }),
318
+ ...(options.configuration === undefined ? {} : { configuration: options.configuration }),
319
+ ...(options.pendingRestart === undefined ? {} : { pendingRestart: options.pendingRestart }),
320
+ ...(options.linkTimings?.heartbeatIntervalMs === undefined
321
+ ? {}
322
+ : { heartbeatIntervalMs: options.linkTimings.heartbeatIntervalMs }),
323
+ ...(options.linkTimings?.heartbeatTimeoutMs === undefined
324
+ ? {}
325
+ : { heartbeatTimeoutMs: options.linkTimings.heartbeatTimeoutMs }),
326
+ ...(options.linkTimings?.connectTimeoutMs === undefined
327
+ ? {}
328
+ : { connectTimeoutMs: options.linkTimings.connectTimeoutMs }),
329
+ });
330
+ }
331
+ get link() {
332
+ return this.#link;
333
+ }
334
+ get registry() {
335
+ return this.#registry;
336
+ }
337
+ /** The composed session behind a controller handle, or a refusal naming the handle. */
338
+ session(sessionKey) {
339
+ const composed = this.#sessions.get(sessionKey);
340
+ if (composed === undefined) {
341
+ return refuse('session-unknown', `no session for controller handle ${sessionKey}`);
342
+ }
343
+ return ok(composed);
344
+ }
345
+ start() {
346
+ this.#link.start();
347
+ }
348
+ /** Ends every session first, then the link — so the end transitions still have somewhere to go. */
349
+ stop(detail = 'host shutting down') {
350
+ if (this.#heldFramesTimer !== null) {
351
+ clearTimeout(this.#heldFramesTimer);
352
+ this.#heldFramesTimer = null;
353
+ }
354
+ for (const composed of [...this.#sessions.values()])
355
+ composed.session.stop(detail);
356
+ this.#registry.stopAll(detail);
357
+ this.#link.stop(detail);
358
+ }
359
+ // -------------------------------------------------------------------------
360
+ #dispatch(frame) {
361
+ const payload = frame.payload;
362
+ switch (payload.kind) {
363
+ case 'session_new':
364
+ void this.#open(frame.sessionId, payload);
365
+ return;
366
+ case 'session_prompt': {
367
+ // A turn for a session whose open is still in flight waits for it rather than being refused.
368
+ // The reservation it tests is the one `#open` already takes; see `#held`.
369
+ if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
370
+ return this.#hold(frame.sessionId, payload.text);
371
+ }
372
+ const composed = this.session(frame.sessionId);
373
+ if (!composed.ok)
374
+ return this.#refuse(frame.sessionId, composed.refusal);
375
+ return this.#prompt(frame.sessionId, composed.value, payload.text);
376
+ }
377
+ case 'session_cancel': {
378
+ // The same race, the other frame. A cancel arriving while the session is still opening
379
+ // stops the turn that is waiting, the only turn that can exist yet. It does not stop the
380
+ // open: `session_cancel` ends a turn, never a session, and that contract is pinned.
381
+ if (!this.#sessions.has(frame.sessionId) && this.#opening.has(frame.sessionId)) {
382
+ return this.#withdraw(frame.sessionId);
383
+ }
384
+ const composed = this.session(frame.sessionId);
385
+ if (!composed.ok)
386
+ return this.#refuse(frame.sessionId, composed.refusal);
387
+ composed.value.observer.interrupted('the controller cancelled the turn');
388
+ void composed.value.session.interrupt().catch((error) => {
389
+ this.#refuse(frame.sessionId, refusal('session-unknown', `the interrupt failed: ${describe(error)}`));
390
+ });
391
+ return;
392
+ }
393
+ case 'session_configure': {
394
+ // Protocol v6: the SDK's live setters. A session still opening has no query to configure yet; the
395
+ // controller is told so by name rather than left to assume the change landed.
396
+ const composed = this.session(frame.sessionId);
397
+ if (!composed.ok)
398
+ return this.#refuse(frame.sessionId, composed.refusal);
399
+ const change = readSessionConfigure(payload);
400
+ if (!change.ok)
401
+ return this.#refuse(frame.sessionId, change.refusal);
402
+ void composed.value.session.configure(change.value).catch((error) => {
403
+ this.#refuse(frame.sessionId, refusal('session-unknown', `the configure failed: ${describe(error)}`));
404
+ });
405
+ return;
406
+ }
407
+ case 'bulk_request':
408
+ void this.#deliver(frame.sessionId, payload.deliveryId, payload.what, payload.postUrl, payload.fromOffset);
409
+ return;
410
+ // The discovery requests are host-scoped, not session-scoped. The routing key on these
411
+ // frames is a channel the controller chose; no session needs to exist behind it, so none of
412
+ // the session-existence machinery above applies. The answer rides back on the same key.
413
+ case 'session_list':
414
+ return this.#answerSessionList(frame.sessionId, payload);
415
+ case 'transcript_list':
416
+ void this.#answerTranscriptList(frame.sessionId, payload);
417
+ return;
418
+ case 'transcript_tail':
419
+ void this.#answerTranscriptTail(frame.sessionId, payload);
420
+ return;
421
+ case 'workspace_release':
422
+ void this.#answerWorkspaceRelease(frame.sessionId, payload);
423
+ return;
424
+ case 'workspace_release_bulk':
425
+ void this.#answerWorkspaceReleaseBulk(frame.sessionId, payload);
426
+ return;
427
+ case 'host_configure':
428
+ return this.#answerHostConfigure(frame.sessionId, payload);
429
+ case 'workspace_list':
430
+ void this.#answerWorkspaceList(frame.sessionId, payload);
431
+ return;
432
+ case 'repository_list':
433
+ void this.#answerRepositoryList(frame.sessionId, payload);
434
+ return;
435
+ case 'repository_read':
436
+ void this.#answerRepositoryRead(frame.sessionId, payload);
437
+ return;
438
+ default:
439
+ // A kind this host produces, or one a newer controller invented. Named either way.
440
+ this.#refuse(frame.sessionId, refusal('frame-malformed', `this host takes no inbound "${payload.kind}" — it is not a command`));
441
+ return;
442
+ }
443
+ }
444
+ /**
445
+ * Send one turn at a live session and record that it happened.
446
+ *
447
+ * One path for both arrivals. A turn that waited and a turn that walked straight in must reach the
448
+ * agent identically and leave the same trace, or "it was queued" would become a second, quieter
449
+ * grade of delivery that nothing downstream could tell apart from the first.
450
+ */
451
+ #prompt(sessionKey, composed, text) {
452
+ const sent = composed.session.prompt(text);
453
+ if (!sent.ok)
454
+ return this.#refuse(sessionKey, sent.refusal);
455
+ composed.observer.promptSubmitted('the controller sent a turn');
456
+ }
457
+ /** Hold a turn for a session still opening, or refuse it when the bound is already reached. */
458
+ #hold(sessionKey, text) {
459
+ const waiting = this.#held.get(sessionKey) ?? [];
460
+ if (waiting.length >= MAX_HELD_TURNS) {
461
+ // Named rather than dropped, and `session-unknown` rather than a new reason: from the sender's
462
+ // side this handle still holds no session, and the vocabulary in `core/refusal.ts` is closed on
463
+ // purpose; a reason minted for one call site is one nobody else can branch on.
464
+ return this.#refuse(sessionKey, refusal('session-unknown', `${MAX_HELD_TURNS} turns are already waiting for ${sessionKey} to open — this one was not taken`));
465
+ }
466
+ waiting.push(text);
467
+ this.#held.set(sessionKey, waiting);
468
+ this.#report({ kind: 'prompt-held', sessionKey, held: waiting.length });
469
+ }
470
+ /** Every held turn, in arrival order, the moment the session exists. */
471
+ #drain(sessionKey, composed) {
472
+ const waiting = this.#held.get(sessionKey);
473
+ if (waiting === undefined)
474
+ return;
475
+ this.#held.delete(sessionKey);
476
+ for (const text of waiting)
477
+ this.#prompt(sessionKey, composed, text);
478
+ this.#report({ kind: 'prompt-delivered', sessionKey, delivered: waiting.length });
479
+ }
480
+ /**
481
+ * The controller withdrew the turns it was waiting on. The third answer, and the only one the
482
+ * sender asked for.
483
+ *
484
+ * It is reported rather than silent, which is what keeps it inside the invariant instead of
485
+ * being the exception that swallows it: a withdrawn turn and a dropped one are the same absence on
486
+ * the wire, and only one of them is something to investigate.
487
+ *
488
+ * The limit: no session exists yet, so there is no machine to record an `interrupted`
489
+ * transition on; this is reported by the host, not by the session's own state log. A cancel that
490
+ * lands after the open takes the ordinary path and is recorded there.
491
+ */
492
+ #withdraw(sessionKey) {
493
+ const waiting = this.#held.get(sessionKey) ?? [];
494
+ this.#held.delete(sessionKey);
495
+ this.#report({ kind: 'prompt-withdrawn', sessionKey, withdrawn: waiting.length });
496
+ }
497
+ /**
498
+ * The open ended without a session: every turn still waiting is refused, one refusal each.
499
+ *
500
+ * This is the half that keeps the queue honest. Holding a turn is a promise to answer it, and the
501
+ * only failure worse than refusing a turn that could have been served is accepting one and saying
502
+ * nothing, which is the defect this whole queue exists to end, relocated one layer in.
503
+ */
504
+ #abandon(sessionKey) {
505
+ const waiting = this.#held.get(sessionKey);
506
+ if (waiting === undefined)
507
+ return;
508
+ this.#held.delete(sessionKey);
509
+ for (const _text of waiting) {
510
+ this.#refuse(sessionKey, refusal('session-unknown', `${sessionKey} never opened, so a turn held for it cannot be delivered`));
511
+ }
512
+ }
513
+ async #open(sessionKey, opening) {
514
+ if (this.#sessions.has(sessionKey) || this.#opening.has(sessionKey)) {
515
+ // Two `session_new` for one handle: the second must not silently replace the first, which
516
+ // would leave a live agent running with nothing routed to it. The `#opening` half of the
517
+ // check is what makes this hold for two frames in flight: with a workspace provider
518
+ // configured there is an await between here and the map write, and a guard that reads only
519
+ // the map lets both frames through it: double spawn, one leaked agent, one handle.
520
+ return this.#refuse(sessionKey, refusal('session-unknown', `${sessionKey} is already a live or opening session here`));
521
+ }
522
+ this.#opening.add(sessionKey);
523
+ try {
524
+ await this.#compose(sessionKey, opening);
525
+ }
526
+ catch (error) {
527
+ // Every exit from an open is a wire answer. The dispatcher calls this as
528
+ // `void this.#open(…)`, so without this catch a throw anywhere in `#compose` would become an
529
+ // unhandled rejection: the controller gets nothing at all, its record of the session reads
530
+ // `open` forever, and on a host with no unhandledRejection handler the process could die
531
+ // outright. A command that vanishes reads as a host that hung, the exact failure refusing
532
+ // every unknown payload by name exists to prevent, arriving through the one door that was not
533
+ // refusing.
534
+ //
535
+ // Caught here rather than at each throw site, deliberately. Guarding a named list of throw
536
+ // sources leaves the next one unguarded; this arm cannot be outrun by a source nobody has
537
+ // written yet.
538
+ //
539
+ // The `finally` below still runs for the queue: held turns are answered by `#abandon`, so a
540
+ // throw during an open does not silently strand the turns waiting on it.
541
+ this.#refuseOpen(sessionKey, null, refusal('session-spawn-failed', `opening ${sessionKey} threw rather than returning a refusal: ${describe(error)}`));
542
+ }
543
+ finally {
544
+ this.#opening.delete(sessionKey);
545
+ // A successful open drained and cleared the queue already, so this finds nothing. Anything still
546
+ // here means the open did not produce a session (a refused request, a failed provision, a
547
+ // `giveBack`, or a throw) and each waiting turn is answered rather than forgotten. It sits in
548
+ // `finally` so no failure path can skip it, including one nobody has written yet.
549
+ this.#abandon(sessionKey);
550
+ // An open that produced no session holds no workspace claim, so its key entry goes too: in
551
+ // `finally` for the same reason as the queue, and guarded so a successful open keeps the
552
+ // entry the release sites and the in-use guard read for the session's whole life.
553
+ if (!this.#sessions.has(sessionKey))
554
+ this.#workspaceKeyFor.delete(sessionKey);
555
+ }
556
+ }
557
+ async #compose(sessionKey, opening) {
558
+ // Narrowed before the workspace is claimed, and the order is the point. A request this host
559
+ // cannot use is refused while it still owns nothing: provisioning first would take a directory,
560
+ // find the request unusable, and have to hand it back, a claim/release round trip on every
561
+ // malformed frame, in the one path a stranger can drive.
562
+ const requested = readSessionRequest(opening.request);
563
+ if (!requested.ok)
564
+ return this.#refuseOpen(sessionKey, null, requested.refusal);
565
+ const provider = this.#workspaces;
566
+ // Where the workspace key resolves: each `??` is a default for an absence, never an
567
+ // override, so a controller that names a key never has the host's configured default consulted.
568
+ // Validated before the workspace is claimed, same order and same reason as the request
569
+ // narrowing above: a key this host cannot use must refuse while it still owns nothing.
570
+ const askedKey = opening.workspaceKey ?? this.#options.defaultWorkspaceKey ?? null;
571
+ if (askedKey !== null) {
572
+ const source = opening.workspaceKey !== null
573
+ ? 'session_new.workspaceKey'
574
+ : "this host's configured default workspace key";
575
+ if (provider === undefined) {
576
+ // The `path-input-missing` posture's mirror image: with no provider there is nothing to
577
+ // provision at any key, and running in the controller's cwd instead would deliver the
578
+ // wrong topology silently, the exact failure an explicit key exists to end.
579
+ return this.#refuseOpen(sessionKey, null, refusal('workspace-provision-failed', `${source} names ${askedKey} and this host has no workspace provider — there is nothing to provision at that key, so the ask cannot be honoured`));
580
+ }
581
+ const problem = unusableKeyProblem(askedKey);
582
+ if (problem !== null) {
583
+ // An unusable key is refused by the host's own guard with the field named, never left to
584
+ // die inside git as an unnamed provision failure.
585
+ // `keyPreview`, never the raw key: an over-length key's refusal must itself stay sendable.
586
+ return this.#refuseOpen(sessionKey, null, refusal('workspace-provision-failed', `${source} ${keyPreview(askedKey)} ${problem}`));
587
+ }
588
+ }
589
+ const workspaceKey = askedKey ?? sessionKey;
590
+ let cwd = opening.cwd;
591
+ // The provider decides, except for the repository it clones from. A named cwd is advisory
592
+ // under a provider, with one exception: the provider's own repository root, the operator's
593
+ // checkout, trusted in their ~/.claude.json, where the CLI they know runs and every settings
594
+ // tier loads. There is no isolation to protect when the operator asks for the source itself,
595
+ // and no worktree could be more "the repo" than the repo. Any other named directory still gets
596
+ // a provisioned workspace. The workspace guard's root follows the cwd either way; the
597
+ // operator's checkout claims no workspace key, so the reap never touches it.
598
+ const operatorsCheckout = provider !== undefined &&
599
+ cwd !== null &&
600
+ provider.repositoryRoot !== undefined &&
601
+ normalizePath(cwd).toLowerCase() === normalizePath(provider.repositoryRoot).toLowerCase();
602
+ // A resume runs where its transcript lives, or not at all. The CLI keeps transcripts per cwd;
603
+ // a resume moved into a provisioned workspace finds nothing and becomes a fresh session that
604
+ // says nothing. A named cwd the provider would not honour is therefore refused by name, on the
605
+ // wire, instead of being quietly overruled.
606
+ if (provider !== undefined && !operatorsCheckout && cwd !== null && opening.request?.resume) {
607
+ return this.#refuseOpen(sessionKey, null, refusal('resume-cwd-not-honoured', `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`));
608
+ }
609
+ let claimed = false;
610
+ if (provider !== undefined && !operatorsCheckout) {
611
+ // Before the await, so the reap's in-use guard already covers a session whose open is still
612
+ // in flight. `#open`'s finally clears it again when the open produced no session.
613
+ this.#workspaceKeyFor.set(sessionKey, workspaceKey);
614
+ // The turn, not a bare await: a provision arriving while this key's reap (or a closing
615
+ // session's release) is still running waits for it to settle instead of racing the removal.
616
+ const provisioned = await this.#workspaceTurns.run(workspaceKey, () => provider.provision(workspaceKey));
617
+ if (!provisioned.ok)
618
+ return this.#refuseOpen(sessionKey, null, provisioned.refusal);
619
+ cwd = provisioned.value.path;
620
+ claimed = true;
621
+ }
622
+ else if (cwd === null) {
623
+ // A null cwd is "the provider decides", and this host has no provider, so there is no
624
+ // decider and nowhere honest to fall back to. Not `process.cwd()`: sharing the host's own
625
+ // directory is the weakest isolation `bin/workspaces.ts` knows, and it is a posture an embedder
626
+ // chooses, never one a frame's absence smuggles in. Refused before anything is claimed, with
627
+ // the same name a path-taking tool uses for a pathless input.
628
+ return this.#refuseOpen(sessionKey, null, refusal('path-input-missing', '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'));
629
+ }
630
+ // A workspace this host asked for and then could not use is handed back, on every failure path
631
+ // below. The provider's default is to leave the directory alone, so this is a release of the
632
+ // claim rather than a deletion, but a host that never releases makes `provision` and `release`
633
+ // unbalanced, and a provider counting sessions would leak one per refused start.
634
+ const giveBack = async (refused) => {
635
+ this.#refuseOpen(sessionKey, cwd, refused);
636
+ if (!claimed || provider === undefined)
637
+ return;
638
+ // The claim was taken at the workspace key, so it is handed back at the workspace key;
639
+ // releasing the session key here would release nothing the moment keys alias.
640
+ const released = await provider.release(workspaceKey);
641
+ if (!released.ok)
642
+ this.#refuse(sessionKey, released.refusal);
643
+ };
644
+ const tools = this.#toolServer(sessionKey);
645
+ if (!tools.ok)
646
+ return giveBack(tools.refusal);
647
+ // The one throw source in this function, named. `readWhere` walks up from `cwd` looking for
648
+ // a `.git`, so it touches the filesystem with a path the controller chose: a malformed `cwd`
649
+ // (an illegal segment, a path the OS refuses to stat) throws out of here rather than
650
+ // returning. The catch in `#open` would turn that into a refusal anyway; this exists so the
651
+ // refusal says where, instead of naming the whole open.
652
+ //
653
+ // Its two neighbours are not throw sources and are not guarded: `localGate({…})` builds a
654
+ // closure and touches nothing at construction, and `nodePathResolver` is only stored here; its
655
+ // one throw fires later, inside the gate's decide path, which is a different surface with a
656
+ // different answer.
657
+ let where;
658
+ try {
659
+ where = readWhere(cwd);
660
+ }
661
+ catch (error) {
662
+ return giveBack(refusal('session-spawn-failed', `reading git facts for ${cwd} threw, so this session has no honest 'where': ${describe(error)}`));
663
+ }
664
+ const servers = mergeMcpServers(requested.value.mcpServers, tools.value);
665
+ if (!servers.ok)
666
+ return giveBack(servers.refusal);
667
+ const composed = composeSession({
668
+ registry: this.#registry,
669
+ sessionKey,
670
+ cwd,
671
+ sink: this.#link,
672
+ decide: this.#options.decide,
673
+ // Jailed to its own workspace by default, and the default is the whole point: a host whose
674
+ // local gate is opt-in is a host most embedders run without one.
675
+ localGate: localGate({
676
+ workspaceRoot: cwd,
677
+ resolve: nodePathResolver,
678
+ protectedPaths: this.#options.protectedPaths,
679
+ ...(this.#options.toolFamilies === undefined ? {} : { toolFamilies: this.#options.toolFamilies }),
680
+ }),
681
+ where,
682
+ // The controller's own handle when it sent one, the routing key otherwise. The key stays the
683
+ // fallback rather than null: a transition with no correlation at all is strictly less useful
684
+ // than one correlated to the handle the controller is already using. The two are still
685
+ // different contracts (one is interpreted by construction, the other never) and nothing here
686
+ // derives one from the other in the direction that would matter.
687
+ correlationId: opening.correlationId ?? sessionKey,
688
+ // This host grants what its gate approves, and that is a posture rather than an inherited
689
+ // default. It loads no settings files, so nothing sits behind the gate that a grant could
690
+ // override, and without it the gate would be a veto: able to refuse a call, unable to let one
691
+ // through. An embedder who wants the silent-allow posture sets `grantOnAllow: false` and gets
692
+ // a host whose agent cannot run a tool, which is a legitimate thing to want and a surprising
693
+ // thing to get by accident. Recorded in SECURITY.md, not only here.
694
+ // Three layers, and the order is a decision. The host's posture is the floor, the embedder's
695
+ // configuration overrides it, and the controller's per-session timings win last, because a
696
+ // timing is a statement about how long that controller is willing to wait, and it is the only
697
+ // party that knows. `grantOnAllow` is deliberately not reachable from the wire and so cannot be
698
+ // overridden by the last spread; see `SessionNewGate`.
699
+ gate: { grantOnAllow: true, ...(this.#options.gate ?? {}), ...readGateTimings(opening.gate) },
700
+ request: { ...requested.value, ...(servers.value === null ? {} : { mcpServers: servers.value }) },
701
+ ...(this.#options.clock === undefined ? {} : { clock: this.#options.clock }),
702
+ ...(this.#options.ticker === undefined ? {} : { ticker: this.#options.ticker }),
703
+ onRefusal: (refused) => this.#refuse(sessionKey, refused),
704
+ onHookFailure: (failure) => this.#refuse(sessionKey, refusal('transition-cause-unnamed', `a ${failure.event} handler threw: ${describe(failure.error)}`)),
705
+ });
706
+ if (!composed.ok)
707
+ return giveBack(composed.refusal);
708
+ this.#sessions.set(sessionKey, composed.value);
709
+ composed.value.machine.onTransition((transition) => this.#report({ kind: 'transition', sessionKey, transition }));
710
+ composed.value.session.onEnd(() => this.#close(sessionKey));
711
+ // `onDegrade` replays what was raised before this line ran, which is what makes the
712
+ // create-time degrades (an untrusted workspace is reported during `create()`) reachable at
713
+ // all; the collision degrade arrives live later, when the agent reports an id already held.
714
+ composed.value.session.onDegrade((degrade) => this.#report({ kind: 'degrade', sessionKey, degrade }));
715
+ this.#report({ kind: 'session-opened', sessionKey, cwd });
716
+ // After the open is reported, so the trace reads in causal order: held, opened, delivered. The
717
+ // gap between the first and the last is the provisioning window, stated rather than inferred.
718
+ this.#drain(sessionKey, composed.value);
719
+ }
720
+ /**
721
+ * Everything a finished session holds, released in the order that keeps its last frames.
722
+ *
723
+ * `forgetSession` deliberately does not discard unacked frames: a session's final transitions are
724
+ * its most important and are exactly the ones still in flight if the link is down when it ends.
725
+ */
726
+ #close(sessionKey) {
727
+ const composed = this.#sessions.get(sessionKey);
728
+ if (composed === undefined)
729
+ return;
730
+ this.#sessions.delete(sessionKey);
731
+ composed.detach();
732
+ this.#link.forgetSession(sessionKey);
733
+ // Released at the key the workspace was provisioned at: for an aliased session the two
734
+ // differ, and releasing the session key would leak the claim. Deleted from the map first so
735
+ // the in-use guard stops counting this session the moment it is gone.
736
+ const workspaceKey = this.#workspaceKeyFor.get(sessionKey) ?? sessionKey;
737
+ this.#workspaceKeyFor.delete(sessionKey);
738
+ const provider = this.#workspaces;
739
+ // The release rides the key's turn: the map entry above is gone synchronously, so a reap
740
+ // arriving now already sees the key as free; serializing the release behind the same turn is
741
+ // what keeps that reap from removing the tree while this release still runs.
742
+ if (provider !== undefined) {
743
+ void this.#workspaceTurns
744
+ .run(workspaceKey, () => provider.release(workspaceKey))
745
+ .then((released) => {
746
+ if (!released.ok)
747
+ this.#refuse(sessionKey, released.refusal);
748
+ },
749
+ // A provider that throws instead of returning a refusal must land in the same place: the
750
+ // named-refusal contract for a quiet release leak exists precisely because the failure is
751
+ // invisible until a disk fills, and an unhandled rejection here was the one path around it.
752
+ (error) => this.#refuse(sessionKey, refusal('workspace-release-failed', describe(error))));
753
+ }
754
+ this.#report({ kind: 'session-closed', sessionKey });
755
+ }
756
+ /**
757
+ * The in-process MCP server for a session, or null when this host offers no tools.
758
+ *
759
+ * The descriptors are the embedder's, not the controller's, and that is forced rather than
760
+ * chosen: no payload kind registers an in-process tool, so there is no way for a controller to
761
+ * declare one over the link. (A controller can name an HTTP or stdio MCP server through
762
+ * `session_new.request.mcpServers`; see `wire-request.ts`.)
763
+ */
764
+ #toolServer(sessionKey) {
765
+ const tools = this.#options.tools;
766
+ if (tools === undefined)
767
+ return ok(null);
768
+ const built = createToolServer({
769
+ ...tools,
770
+ // Read at call time, never captured: the agent has no id until its first turn is queued.
771
+ identity: () => ({ sessionId: this.#sessions.get(sessionKey)?.machine.sessionId ?? null }),
772
+ });
773
+ if (!built.ok) {
774
+ return refuse(built.refusal.reason, built.refusal.detail);
775
+ }
776
+ return ok({ [tools.name]: built.value });
777
+ }
778
+ /**
779
+ * Answer `session_list` from what this host holds: every handle it routes, plus the registry's
780
+ * two counts. Synchronous: no filesystem, no process, nothing to await.
781
+ */
782
+ #answerSessionList(channelKey, asked) {
783
+ const sessions = [...this.#sessions.entries()].map(([sessionKey, composed]) => ({
784
+ sessionKey,
785
+ sessionId: composed.session.id,
786
+ state: composed.session.state,
787
+ cwd: composed.machine.where.cwd,
788
+ startedAt: composed.session.facts?.startedAt ?? null,
789
+ }));
790
+ this.#send(channelKey, sessionListResult(asked.requestId, sessions, {
791
+ liveCount: this.#registry.liveCount,
792
+ provisioningCount: this.#registry.provisioningCount,
793
+ }));
794
+ }
795
+ /**
796
+ * Answer `transcript_list` from the discovery door, one page per request.
797
+ *
798
+ * Every exit sends a wire answer. A missing root, a failed walk and a thrown error all become a
799
+ * `transcript_failed` naming the reason; an answer that dies inside the host would read to the
800
+ * asker as a host that hung, which is the exact failure the closed dispatch set exists to end.
801
+ * (`#send` reports a send the link refused locally; the attempt is what every exit guarantees.)
802
+ */
803
+ async #answerTranscriptList(channelKey, asked) {
804
+ const root = this.#transcriptsRoot;
805
+ if (root === undefined) {
806
+ return this.#failDiscovery(channelKey, asked.requestId, refusal('path-input-missing', 'this host has no transcripts root configured — the discovery door cannot look'));
807
+ }
808
+ try {
809
+ const page = await listTranscripts(root, { fromIndex: asked.fromIndex });
810
+ this.#send(channelKey, transcriptListResult(asked.requestId, page.entries, {
811
+ totalCount: page.totalCount,
812
+ ...(page.nextIndex === null ? {} : { nextIndex: page.nextIndex }),
813
+ }));
814
+ }
815
+ catch (error) {
816
+ this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
817
+ }
818
+ }
819
+ /** Answer `transcript_tail`. Same exit discipline as the listing: every path answers. */
820
+ async #answerTranscriptTail(channelKey, asked) {
821
+ const root = this.#transcriptsRoot;
822
+ if (root === undefined) {
823
+ return this.#failDiscovery(channelKey, asked.requestId, refusal('path-input-missing', 'this host has no transcripts root configured — the discovery door cannot look'));
824
+ }
825
+ try {
826
+ const answer = await tailTranscript(root, asked.projectSlug, asked.sessionId, {
827
+ fromOffset: asked.fromOffset,
828
+ needle: asked.needle,
829
+ });
830
+ if (!answer.ok)
831
+ return this.#failDiscovery(channelKey, asked.requestId, answer.refusal);
832
+ this.#send(channelKey, transcriptTailResult(asked.requestId, {
833
+ found: answer.value.found,
834
+ absent: answer.value.absent,
835
+ newOffset: answer.value.newOffset,
836
+ ...(answer.value.sizeBytes === null ? {} : { sizeBytes: answer.value.sizeBytes }),
837
+ ...(answer.value.mtimeMs === null ? {} : { mtimeMs: answer.value.mtimeMs }),
838
+ }));
839
+ }
840
+ catch (error) {
841
+ this.#failDiscovery(channelKey, asked.requestId, refusal('transcript-read-failed', describe(error)));
842
+ }
843
+ }
844
+ /**
845
+ * Answer `repository_list`: one directory of the repository this host provisions from,
846
+ * names only, jailed to the repository root by `listRepositoryDirectory`. Every exit is the one
847
+ * result kind; a host with no repository root has nothing to read under and says so by name.
848
+ */
849
+ async #answerRepositoryList(channelKey, asked) {
850
+ const refused = (why) => this.#refuseRepository(channelKey, repositoryListResult(asked.requestId, [], false, wireRefusalOf(why)), why);
851
+ const root = this.#workspaces?.repositoryRoot;
852
+ if (root === undefined)
853
+ return refused(noRepositoryRoot());
854
+ try {
855
+ const listed = await listRepositoryDirectory(root, asked.path, undefined, this.#options.protectedPaths);
856
+ if (!listed.ok)
857
+ return refused(listed.refusal);
858
+ this.#send(channelKey, repositoryListResult(asked.requestId, listed.value.entries, listed.value.truncated));
859
+ }
860
+ catch (error) {
861
+ refused(refusal('repository-read-failed', describe(error)));
862
+ }
863
+ }
864
+ /** Answer `repository_read`: the head of one text file under the repository root. */
865
+ async #answerRepositoryRead(channelKey, asked) {
866
+ const nothing = { text: null, sizeBytes: 0, truncated: false };
867
+ const refused = (why) => this.#refuseRepository(channelKey, repositoryReadResult(asked.requestId, nothing, wireRefusalOf(why)), why);
868
+ const root = this.#workspaces?.repositoryRoot;
869
+ if (root === undefined)
870
+ return refused(noRepositoryRoot());
871
+ try {
872
+ const read = await readRepositoryFile(root, asked.path, asked.maxBytes, this.#options.protectedPaths);
873
+ if (!read.ok)
874
+ return refused(read.refusal);
875
+ this.#send(channelKey, repositoryReadResult(asked.requestId, read.value));
876
+ }
877
+ catch (error) {
878
+ refused(refusal('repository-read-failed', describe(error)));
879
+ }
880
+ }
881
+ /** A refused repository ask goes on the wire as its result kind, and is reported locally too. */
882
+ #refuseRepository(channelKey, answer, why) {
883
+ this.#send(channelKey, answer);
884
+ this.#refuse(channelKey, why);
885
+ }
886
+ /** A discovery request that could not be answered says so on the wire; the `#failDelivery` twin. */
887
+ #failDiscovery(channelKey, requestId, refused) {
888
+ this.#send(channelKey, transcriptFailed(requestId, { reason: refused.reason, detail: refused.detail }));
889
+ this.#refuse(channelKey, refused);
890
+ }
891
+ /**
892
+ * Answer `workspace_release` (protocol v5): remove a workspace's directory, on demand, by name.
893
+ *
894
+ * Every exit is the one result kind: `refusal: null` is the released answer, a named refusal
895
+ * is every other, so "every exit sends a wire answer" is a property of the shape here rather than
896
+ * a discipline across two builders. The answer is one small frame, nowhere near the frame cap,
897
+ * unconditionally, since the key screen is length-bounded and the detail echoes `keyPreview`,
898
+ * never the raw key.
899
+ *
900
+ * The in-use guard is many-to-one, because keys alias: N sessions can share one workspace key,
901
+ * each with its own entry in `#workspaceKeyFor`, so the guard scans values and holds until the
902
+ * last session on the key is gone. A guard that cleared on the first close would delete a tree
903
+ * the other sessions are still working in, the exact conditions the shared-worktree topology
904
+ * exists to create. Opening sessions count too: their entry is written before the provision
905
+ * await.
906
+ *
907
+ * `ReleaseOptions.remove` stays default-false everywhere else. This is the one production path
908
+ * that passes it, and it passes it because a caller asked; a session ending still leaves its
909
+ * directory for whoever wants to look at it.
910
+ */
911
+ async #answerWorkspaceRelease(channelKey, asked) {
912
+ const outcome = await this.#releaseOne(asked, false);
913
+ this.#send(channelKey, workspaceReleaseResult(asked.requestId, outcome.result));
914
+ if (outcome.refused !== null)
915
+ this.#refuse(channelKey, outcome.refused);
916
+ }
917
+ /**
918
+ * Every entry is judged and released on its own — different keys interleave, the same key
919
+ * serialises under its turn — and a refusal on one never aborts the rest. Duplicates are judged
920
+ * on the name as asked: the second entry naming a key or a path already in this ask does nothing
921
+ * and says so.
922
+ */
923
+ async #answerWorkspaceReleaseBulk(channelKey, asked) {
924
+ const named = new Set();
925
+ const outcomes = await Promise.all(asked.releases.map((entry) => {
926
+ const name = entry.workspaceKey ?? entry.path;
927
+ const duplicate = name !== null && named.has(name);
928
+ if (name !== null)
929
+ named.add(name);
930
+ return this.#releaseOne(entry, duplicate);
931
+ }));
932
+ this.#send(channelKey, workspaceReleaseBulkResult(asked.requestId, outcomes.map((outcome) => outcome.result)));
933
+ for (const outcome of outcomes) {
934
+ if (outcome.refused !== null)
935
+ this.#refuse(channelKey, outcome.refused);
936
+ }
937
+ }
938
+ /**
939
+ * One release: resolve the address to a key, screen it, then under the key's turn run the in-use
940
+ * guard and the provider's release with the ask's flags. Every exit is a result; `refused` is the
941
+ * same refusal for the log.
942
+ */
943
+ async #releaseOne(ask, duplicate) {
944
+ const provider = this.#workspaces;
945
+ const refused = (reason, detail, partial = {}) => {
946
+ const problem = refusal(reason, detail);
947
+ return {
948
+ result: workspaceReleaseEntryResult({
949
+ ...partial,
950
+ refusal: { reason: problem.reason, detail: problem.detail },
951
+ }),
952
+ refused: problem,
953
+ };
954
+ };
955
+ if (provider === undefined) {
956
+ return refused('workspace-release-failed', 'this host has no workspace provider — there is no workspace to release');
957
+ }
958
+ if (ask.workspaceKey !== null && ask.path !== null) {
959
+ return refused('workspace-release-failed', 'workspace_release names both a workspaceKey and a path — name exactly one');
960
+ }
961
+ let workspaceKey;
962
+ if (ask.path !== null) {
963
+ if (provider.keyForPath === undefined) {
964
+ return refused('workspace-release-failed', "this host's workspace provider cannot address a worktree by path — name its workspaceKey", { path: ask.path });
965
+ }
966
+ const resolved = provider.keyForPath(ask.path);
967
+ if (resolved === null) {
968
+ return refused('workspace-release-failed', `workspace_release.path ${keyPreview(ask.path)} is not a directory directly under this host's workspace root`, { path: ask.path });
969
+ }
970
+ workspaceKey = resolved;
971
+ }
972
+ else if (ask.workspaceKey !== null) {
973
+ workspaceKey = ask.workspaceKey;
974
+ }
975
+ else {
976
+ return refused('workspace-release-failed', 'workspace_release names neither a workspaceKey nor a path — name exactly one');
977
+ }
978
+ const address = { workspaceKey, path: ask.path };
979
+ const problem = unusableKeyProblem(workspaceKey);
980
+ if (problem !== null) {
981
+ return refused('workspace-release-failed', `workspace_release.workspaceKey ${keyPreview(workspaceKey)} ${problem}`, address);
982
+ }
983
+ if (duplicate) {
984
+ return refused('workspace-release-failed', `workspace ${workspaceKey} is named twice in one ask — this entry does nothing`, address);
985
+ }
986
+ // The scan and the removal ride one turn. The in-use guard is check-then-act across a
987
+ // multi-second `git worktree remove`; scanned outside the turn, a `session_new` on this key
988
+ // could pass its own guard mid-removal and provision into a dying directory while this answer
989
+ // reads released. Inside the turn the scan runs after any queued provision or close-release
990
+ // settles, so it reads the map the other verbs have finished writing.
991
+ return this.#workspaceTurns.run(workspaceKey, async () => {
992
+ const inUse = [...this.#workspaceKeyFor.values()].includes(workspaceKey);
993
+ if (inUse) {
994
+ return refused('workspace-release-failed', `workspace ${workspaceKey} still backs a live or opening session on this host — close every session on it before asking for its removal`, address);
995
+ }
996
+ try {
997
+ const released = await provider.release(workspaceKey, {
998
+ remove: true,
999
+ deleteBranch: ask.deleteBranch,
1000
+ force: ask.force,
1001
+ });
1002
+ if (!released.ok)
1003
+ return refused(released.refusal.reason, released.refusal.detail, address);
1004
+ const receipt = released.value;
1005
+ // A provider that answers no receipt vouches for nothing beyond "released or absent".
1006
+ if (receipt === undefined)
1007
+ return { result: workspaceReleaseEntryResult(address), refused: null };
1008
+ const partial = {
1009
+ workspaceKey,
1010
+ path: receipt.path,
1011
+ directoryRemoved: receipt.directoryRemoved,
1012
+ branchDeleted: receipt.branchDeleted,
1013
+ };
1014
+ if (receipt.refusal !== null)
1015
+ return refused(receipt.refusal.reason, receipt.refusal.detail, partial);
1016
+ return { result: workspaceReleaseEntryResult(partial), refused: null };
1017
+ }
1018
+ catch (error) {
1019
+ // A provider that throws instead of refusing lands in the same named place, the contract
1020
+ // the ordinary release path already holds.
1021
+ return refused('workspace-release-failed', describe(error), address);
1022
+ }
1023
+ });
1024
+ }
1025
+ /**
1026
+ * List the worktrees under the workspace root, read from disk now. Paged like the transcript
1027
+ * listing; every exit is one `workspace_list_result`, refusal or page, so a host that cannot look
1028
+ * says so by name rather than going quiet.
1029
+ */
1030
+ async #answerWorkspaceList(channelKey, asked) {
1031
+ const answer = (page, refused) => {
1032
+ this.#send(channelKey, workspaceListResult(asked.requestId, page.entries, page, refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail }));
1033
+ if (refused !== undefined)
1034
+ this.#refuse(channelKey, refused);
1035
+ };
1036
+ const empty = { entries: [], totalCount: 0, defaultBranch: null };
1037
+ const provider = this.#workspaces;
1038
+ if (provider === undefined) {
1039
+ return answer(empty, refusal('workspace-list-failed', 'this host has no workspace provider — there is nothing to list'));
1040
+ }
1041
+ if (provider.inventory === undefined) {
1042
+ return answer(empty, refusal('workspace-list-failed', "this host's workspace provider keeps no inventory"));
1043
+ }
1044
+ let inventory;
1045
+ try {
1046
+ inventory = await provider.inventory();
1047
+ }
1048
+ catch (error) {
1049
+ return answer(empty, refusal('workspace-list-failed', describe(error)));
1050
+ }
1051
+ if (!inventory.ok)
1052
+ return answer(empty, inventory.refusal);
1053
+ const all = inventory.value.entries;
1054
+ const from = Math.max(0, asked.fromIndex);
1055
+ const end = Math.min(all.length, from + WORKSPACE_PAGE_SIZE);
1056
+ return answer({
1057
+ entries: all.slice(from, end),
1058
+ totalCount: all.length,
1059
+ ...(end < all.length ? { nextIndex: end } : {}),
1060
+ defaultBranch: inventory.value.defaultBranch,
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Apply a configuration change. Every exit is one `host_configure_result` carrying the
1065
+ * effective configuration, so a controller always learns what this host runs with, refused or
1066
+ * not. The seam validates and writes; this method only decides whether the host is busy and swaps
1067
+ * the rebuilt pieces in, then tells the link what its next hello declares.
1068
+ */
1069
+ #answerHostConfigure(channelKey, asked) {
1070
+ const answer = (refused) => {
1071
+ this.#send(channelKey, hostConfigureResult(asked.requestId, this.#configuration, this.#overriddenByEnvironment, refused === undefined ? undefined : { reason: refused.reason, detail: refused.detail }, this.#pendingRestart));
1072
+ if (refused !== undefined)
1073
+ this.#refuse(channelKey, refused);
1074
+ };
1075
+ const reconfigure = this.#options.reconfigure;
1076
+ if (reconfigure === undefined) {
1077
+ return answer(refusal('config-write-failed', 'this host was composed without a configuration seam, so nothing can be written'));
1078
+ }
1079
+ // Busy means a workspace is claimed or being claimed: a session releases through the provider
1080
+ // that provisioned it, so the roots cannot move under one. The seam refuses only when the ask
1081
+ // actually changes a root; a scheme or transcripts-root change applies live regardless.
1082
+ const hostBusy = this.#sessions.size > 0 || this.#opening.size > 0;
1083
+ let outcome;
1084
+ try {
1085
+ outcome = reconfigure(asked.entries, hostBusy);
1086
+ }
1087
+ catch (error) {
1088
+ return answer(refusal('config-write-failed', describe(error)));
1089
+ }
1090
+ if (!outcome.ok)
1091
+ return answer(outcome.refusal);
1092
+ const rebuilt = outcome.value;
1093
+ this.#workspaces = rebuilt.workspaces;
1094
+ this.#transcriptsRoot = rebuilt.transcriptsRoot;
1095
+ this.#bulk = rebuilt.bulk;
1096
+ this.#linkCapabilities = rebuilt.linkCapabilities;
1097
+ this.#configuration = rebuilt.configuration;
1098
+ this.#overriddenByEnvironment = rebuilt.overriddenByEnvironment;
1099
+ // The control-plane addresses are never applied to the live link: the host keeps dialling what it
1100
+ // dialled, names the keys as pending, and the next start reads the file.
1101
+ this.#pendingRestart = rebuilt.pendingRestart;
1102
+ this.#link.announce?.(this.#linkCapabilities, this.#configuration, this.#pendingRestart);
1103
+ return answer();
1104
+ }
1105
+ async #deliver(sessionKey, deliveryId, what, postUrl, fromOffset) {
1106
+ const resolve = this.#bulk;
1107
+ if (resolve === undefined) {
1108
+ return this.#failDelivery(sessionKey, deliveryId, refusal('bulk-target-invalid', 'this host resolves no bulk content — nothing can be delivered'));
1109
+ }
1110
+ const filePath = resolve(what, sessionKey);
1111
+ if (!filePath.ok)
1112
+ return this.#failDelivery(sessionKey, deliveryId, filePath.refusal);
1113
+ // The one origin this host will POST to, derived from the link it already dials. Resolved per
1114
+ // delivery rather than cached so a reconfigured controller URL cannot leave a stale trusted
1115
+ // origin behind it.
1116
+ const allowedOrigin = bulkOriginFor(this.#options.controllerUrl);
1117
+ if (!allowedOrigin.ok)
1118
+ return this.#failDelivery(sessionKey, deliveryId, allowedOrigin.refusal);
1119
+ // The same credential the link and the decision POST present, resolved per delivery. A
1120
+ // credential that refuses posts headerless rather than not at all: this lane is a read-back the
1121
+ // controller itself asked for, and the receiver's refusal comes back loud on the wire as
1122
+ // `bulk-delivery-failed` naming the status, which beats a silent no-request.
1123
+ let headers;
1124
+ const credential = this.#options.credential;
1125
+ if (credential !== undefined) {
1126
+ const authorized = await credential.authorize();
1127
+ if (authorized.ok)
1128
+ headers = { [authorized.value.header]: authorized.value.value };
1129
+ }
1130
+ let receipt;
1131
+ try {
1132
+ receipt = await postBulk({
1133
+ deliveryId,
1134
+ postUrl,
1135
+ allowedOrigin: allowedOrigin.value,
1136
+ filePath: filePath.value,
1137
+ fromOffset,
1138
+ ...(headers === undefined ? {} : { headers }),
1139
+ });
1140
+ }
1141
+ catch (error) {
1142
+ receipt = refuse('bulk-delivery-failed', describe(error));
1143
+ }
1144
+ if (!receipt.ok)
1145
+ return this.#failDelivery(sessionKey, deliveryId, receipt.refusal);
1146
+ this.#send(sessionKey, {
1147
+ kind: 'bulk_delivered',
1148
+ deliveryId,
1149
+ byteCount: receipt.value.byteCount,
1150
+ // The stat pair rides the receipt so a transcript puller can detect a rewrite. See
1151
+ // `BulkDelivered` in frames.ts.
1152
+ sizeBytes: receipt.value.sizeBytes,
1153
+ mtimeMs: receipt.value.mtimeMs,
1154
+ });
1155
+ }
1156
+ /** A delivery that did not happen says so on the wire; it is a receipt, not a silence. */
1157
+ #failDelivery(sessionKey, deliveryId, refused) {
1158
+ this.#send(sessionKey, {
1159
+ kind: 'bulk_failed',
1160
+ deliveryId,
1161
+ refusal: { reason: refused.reason, detail: refused.detail },
1162
+ });
1163
+ this.#refuse(sessionKey, refused);
1164
+ }
1165
+ /**
1166
+ * Send one payload. A refusal other than queue overflow is reported locally, since there is no
1167
+ * wire to tell; an overflow refusal of an undroppable frame holds the frame for a retry instead.
1168
+ */
1169
+ #send(sessionKey, payload) {
1170
+ const pending = this.#heldFrames.get(sessionKey);
1171
+ if (pending !== undefined && pending.length > 0) {
1172
+ if (isDroppable(payload.kind)) {
1173
+ this.#refuse(sessionKey, refusal('queue-dropped-droppable', `frames are held for this session; discarded an incoming ${payload.kind}`));
1174
+ return;
1175
+ }
1176
+ this.#holdFrame(sessionKey, payload);
1177
+ return;
1178
+ }
1179
+ const sent = this.#link.send(sessionKey, payload);
1180
+ if (sent.ok)
1181
+ return;
1182
+ if (sent.refusal.reason === 'queue-overflow-undroppable' && !isDroppable(payload.kind)) {
1183
+ this.#holdFrame(sessionKey, payload);
1184
+ return;
1185
+ }
1186
+ this.#refuse(sessionKey, sent.refusal);
1187
+ }
1188
+ #holdFrame(sessionKey, payload) {
1189
+ const pending = this.#heldFrames.get(sessionKey) ?? [];
1190
+ if (pending.length >= HELD_FRAMES_MAX) {
1191
+ this.#refuse(sessionKey, refusal('queue-overflow-undroppable', `${HELD_FRAMES_MAX} frames are already held for this session; ${payload.kind} could not be held`));
1192
+ return;
1193
+ }
1194
+ pending.push(payload);
1195
+ this.#heldFrames.set(sessionKey, pending);
1196
+ this.#scheduleHeldFrames();
1197
+ }
1198
+ #scheduleHeldFrames() {
1199
+ if (this.#heldFramesTimer !== null)
1200
+ return;
1201
+ // Ref'd on purpose: a held turn-end is work this process has promised to deliver.
1202
+ this.#heldFramesTimer = setTimeout(() => {
1203
+ this.#heldFramesTimer = null;
1204
+ this.#retryHeldFrames();
1205
+ }, HELD_RETRY_MS);
1206
+ }
1207
+ #retryHeldFrames() {
1208
+ let remaining = false;
1209
+ for (const [sessionKey, pending] of this.#heldFrames) {
1210
+ while (pending.length > 0) {
1211
+ const head = pending[0];
1212
+ if (head === undefined)
1213
+ break;
1214
+ const sent = this.#link.send(sessionKey, head);
1215
+ if (sent.ok) {
1216
+ pending.shift();
1217
+ continue;
1218
+ }
1219
+ if (sent.refusal.reason !== 'queue-overflow-undroppable') {
1220
+ this.#refuse(sessionKey, sent.refusal);
1221
+ pending.shift();
1222
+ continue;
1223
+ }
1224
+ break;
1225
+ }
1226
+ if (pending.length === 0)
1227
+ this.#heldFrames.delete(sessionKey);
1228
+ else
1229
+ remaining = true;
1230
+ }
1231
+ if (remaining)
1232
+ this.#scheduleHeldFrames();
1233
+ }
1234
+ #refuse(sessionKey, refused) {
1235
+ this.#report({ kind: 'refusal', refusal: refused, sessionKey });
1236
+ }
1237
+ /**
1238
+ * A refused open goes on the wire. Reported locally only, the controller would keep a session
1239
+ * record nobody could talk to and the operator would watch a session that "did nothing" while
1240
+ * the refusal sat in this host's log. The session never had a machine, so this is the one
1241
+ * transition composed by hand: spawning to ended, cause `refusal` with the reason as its event
1242
+ * and the whole detail; the controller stores it, ends its record, and can show why. Local
1243
+ * report first, then the wire.
1244
+ */
1245
+ #refuseOpen(sessionKey, cwd, refused) {
1246
+ this.#refuse(sessionKey, refused);
1247
+ let where;
1248
+ try {
1249
+ where =
1250
+ cwd === null
1251
+ ? { cwd: '', worktree: null, branch: null, unknownReason: 'the session never had a workspace' }
1252
+ : readWhere(cwd);
1253
+ }
1254
+ catch {
1255
+ where = { cwd: cwd ?? '', worktree: null, branch: null, unknownReason: 'git facts could not be read' };
1256
+ }
1257
+ const transition = {
1258
+ sessionId: null,
1259
+ // The controller's own handle is the only correlation this session ever had.
1260
+ correlationId: sessionKey,
1261
+ seq: 1,
1262
+ at: new Date(this.#options.clock?.() ?? Date.now()).toISOString(),
1263
+ from: 'spawning',
1264
+ to: 'ended',
1265
+ activity: null,
1266
+ entryId: null,
1267
+ cause: { kind: 'refusal', event: refused.reason, detail: refused.detail },
1268
+ where,
1269
+ };
1270
+ const sent = this.#link.send(sessionKey, stateTransitionUpdate(transition));
1271
+ if (!sent.ok)
1272
+ this.#report({ kind: 'refusal', refusal: sent.refusal, sessionKey });
1273
+ }
1274
+ #report(event) {
1275
+ try {
1276
+ this.#options.report?.(event);
1277
+ }
1278
+ catch {
1279
+ // The reporter is the failure channel; reporting a reporter would recurse.
1280
+ }
1281
+ }
1282
+ }
1283
+ function describe(error) {
1284
+ return error instanceof Error ? error.message : String(error);
1285
+ }
1286
+ /** The repository asks on a host composed without a repository root: nothing to read under. */
1287
+ function noRepositoryRoot() {
1288
+ return refusal('repository-path-escape', 'this host has no repository root configured, so there is nothing to read under');
1289
+ }
1290
+ /** A local refusal as the wire carries it. */
1291
+ function wireRefusalOf(why) {
1292
+ return { reason: why.reason, detail: why.detail };
1293
+ }
1294
+ //# sourceMappingURL=host.js.map