@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,392 @@
1
+ /**
2
+ * The live sessions this host is running, and the only thing that starts or ends one.
3
+ *
4
+ * Instance state on a class, never module globals, and that is the whole reason this is a class
5
+ * at all. A module-level map would make the package a singleton for its process: two embedders in
6
+ * one host — a test beside a running host, two controllers, one library used twice — would share a
7
+ * session table and end each other's work. Every piece of per-session state here is a private
8
+ * field, and a test runs two registries side by side to prove they cannot see each other.
9
+ *
10
+ * The ownership model in one line: this object owns lifetime, everything else borrows a handle.
11
+ * `create` mints, `get` borrows, `stop`/`stopAll` end. A session removes itself from here the
12
+ * moment it ends, whichever way it ended, so "in the registry" and "alive" are the same fact rather
13
+ * than two that can drift.
14
+ */
15
+ import type {
16
+ AgentProcess,
17
+ AgentProcessRequest,
18
+ AgentSystemPrompt,
19
+ HookRegistrations,
20
+ McpServerConfig,
21
+ SdkPluginConfig,
22
+ SessionStore,
23
+ SessionStoreFlush,
24
+ SettingSource,
25
+ SpawnAgentProcess,
26
+ ThinkingConfig,
27
+ EffortLevel,
28
+ PermissionMode,
29
+ } from '../host/agent-process.js';
30
+ import { startAgentProcess } from '../host/agent-process.js';
31
+ import type { WorkspaceTrust } from '../host/workspace-trust.js';
32
+ import { isUntrustedWorkspaceWarning, readWorkspaceTrust, trustConfigPath } from '../host/workspace-trust.js';
33
+ import type { Clock } from '../core/time.js';
34
+ import { systemClock } from '../core/time.js';
35
+ import type { Result } from '../core/result.js';
36
+ import { ok, refuse } from '../core/result.js';
37
+ import { requireAbsolute } from '../core/paths.js';
38
+ import type { SpawnEnvPolicy } from './spawn-env.js';
39
+ import { composeSpawnEnv } from './spawn-env.js';
40
+ import { HostedSession } from './session.js';
41
+
42
+ /** What a caller asks for when starting a session. */
43
+ export interface SessionRequest {
44
+ /** Absolute. Refused otherwise — a relative cwd means "relative to something you cannot see". */
45
+ readonly cwd: string;
46
+ /**
47
+ * Which on-disk settings tiers the agent may load. Defaults to none.
48
+ *
49
+ * Reading no settings files is the default because it is the only setting under which the host
50
+ * can state what an agent's permissions are. Loading the project tier also brings the trust
51
+ * problem back: its rules are silently void in a workspace nobody accepted. A caller that wants
52
+ * project instructions loaded must ask, and gets told what it costs.
53
+ */
54
+ readonly settingSources?: readonly SettingSource[];
55
+ readonly plugins?: readonly SdkPluginConfig[];
56
+ /** Hook callbacks for this session. Carried to the process untouched — see host/hooks.ts. */
57
+ readonly hooks?: HookRegistrations;
58
+ /** Extra allow/deny/literal declarations on top of the general spawn environment. */
59
+ readonly env?: SpawnEnvPolicy;
60
+ /** A session id to continue. The controller decides when; the host only carries it out. */
61
+ readonly resume?: string;
62
+ /** With `resume`, continue into a new id instead of extending the old session. */
63
+ readonly fork?: boolean;
64
+ /**
65
+ * Stream this session's turns as they compose. Defaults to on.
66
+ *
67
+ * On because the incremental lane is droppable by construction — a delta never enters the replay
68
+ * ring or the durable store, so what it costs under pressure is a repaint. Off, a turn can only
69
+ * be rendered after it is over, which is a different product.
70
+ */
71
+ readonly includePartialMessages?: boolean;
72
+ /**
73
+ * How much reasoning this session emits. Defaults to the SDK's own default (deltas fire, prose
74
+ * is empty), so summarized prose is something a caller asks for.
75
+ *
76
+ * Why the default is not "on when someone is watching": that rule cannot be written in this
77
+ * package. Whether a run is watched is a judgement about what the session is for, and this host
78
+ * has no fact that distinguishes one — inventing one would be a name earned by observation, which
79
+ * is the failure the declared model exists to end. The caller knows; the host offers the knob.
80
+ * The asymmetry in the defaults carries the intent instead: the cheap half is on, and the half
81
+ * that costs tokens on the wire and puts reasoning text into transcripts and mirrors is opt-in.
82
+ */
83
+ readonly thinking?: ThinkingConfig;
84
+ /** Forward a subagent's whole conversation rather than only its tool calls. Defaults to off. */
85
+ readonly forwardSubagentText?: boolean;
86
+ /**
87
+ * In-process MCP servers this session may call, by server name. Build them with `mcp/`.
88
+ *
89
+ * Every tool these add is decided by the same gate as `Bash` — `PreToolUse` is registered with
90
+ * no matcher, so coverage does not depend on the host recognising the tool. What the host's own
91
+ * gate does with them is a different question: see `gate/local.ts`, which matches on tool name and
92
+ * therefore has no opinion about an `mcp__…` tool unless the embedder names it in `ToolFamilies`.
93
+ */
94
+ readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
95
+ /**
96
+ * Ignore every MCP server this host did not declare. Defaults to true.
97
+ *
98
+ * On by default, and this is the one place a workspace's contents could otherwise decide what
99
+ * tools exist. Without it the agent also loads project `.mcp.json`, user settings, plugin MCP and
100
+ * on-disk agent frontmatter — so a repository the host checked out into a workspace can register
101
+ * its own servers, and a `WorkspaceProvider` that clones untrusted code becomes a tool-registration
102
+ * channel. The host provisions those directories programmatically, which is exactly the case where
103
+ * nobody reviewed what is in them.
104
+ *
105
+ * Turning it off is legitimate and costs something named. A consumer whose own repository
106
+ * declares the servers it wants needs this off, and forcing them to fork the package would be
107
+ * worse. But the cost is not only a wider tool surface: an in-process server cannot fail to
108
+ * connect, while the stdio and HTTP servers this re-admits can — and this package deliberately
109
+ * does not expose the SDK's `mcpServerStatus()` / `reconnectMcpServer()`, because reaching them
110
+ * means handing out the query object whose narrowing is a pinned security property. So with strict
111
+ * off, a failed server is neither detectable nor recoverable through this package. Strict on is
112
+ * what makes that absence harmless.
113
+ */
114
+ readonly strictMcpConfig?: boolean;
115
+ /**
116
+ * Mirror this session's transcript to a store. Defaults to none.
117
+ *
118
+ * The store receives every message the agent saw, so supplying one is an egress decision and it
119
+ * is deliberately the embedder's — this type is the local API, not the wire. Nothing arriving over
120
+ * the link can reach this field: a store is an object with methods and has no JSON form, so a
121
+ * controller cannot name a destination for a transcript even in principle. Pinned by
122
+ * pins/persistence-egress.test.ts.
123
+ *
124
+ * Local disk remains the primary record whatever is set here. The subprocess writes locally
125
+ * first and the mirror runs after that write succeeds, so a store can legitimately lag — and when
126
+ * a batch is dropped it stays behind, silently. See host/session-store.ts on reading that report.
127
+ */
128
+ readonly sessionStore?: SessionStore;
129
+ /** How eagerly the mirror flushes. Defaults to the SDK's batching. Ignored with no store. */
130
+ readonly sessionStoreFlush?: SessionStoreFlush;
131
+ /** Create the process yourself — a VM, a container, a remote machine. See agent-process.ts. */
132
+ readonly spawn?: SpawnAgentProcess;
133
+ /**
134
+ * Which model runs this session. Defaults to the CLI's own.
135
+ *
136
+ * Absent, not narrowed — this and `systemPrompt` were simply not composable before, so they fill
137
+ * a gap rather than widen the permission narrowing. See `AGENT_SELECTION_OPTION_KEYS`.
138
+ *
139
+ * Whether it took effect is provable: the agent reports `model` on its own `system/init`, so a
140
+ * caller reads it back off `HostedSession`'s facts rather than trusting the request.
141
+ */
142
+ readonly model?: string;
143
+ /**
144
+ * What the agent is told at the start. Defaults to the CLI's own preset.
145
+ *
146
+ * This one is not provable the same way. The init message carries no system prompt, so nothing
147
+ * the agent says back confirms it. Asking for it is a decision made blind, by construction.
148
+ */
149
+ readonly systemPrompt?: AgentSystemPrompt;
150
+ /** The effort level. Defaults to the SDK's own. */
151
+ readonly effort?: EffortLevel;
152
+ /** The permission mode. Defaults to the SDK's own. See `CLI_PARITY_OPTION_KEYS`. */
153
+ readonly permissionMode?: PermissionMode;
154
+ }
155
+
156
+ export interface SessionRegistryOptions {
157
+ /** The environment sessions are filtered from. The composition root passes `process.env`. */
158
+ readonly baseEnv: Readonly<Record<string, string | undefined>>;
159
+ /** Absolute path to the user's home, for the trust read. */
160
+ readonly homeDir: string;
161
+ readonly clock?: Clock;
162
+ /** How long the agent has to report itself before the start is called failed. */
163
+ readonly startTimeoutMs?: number;
164
+ /**
165
+ * How a process is started. Defaults to the real one.
166
+ *
167
+ * What this is for, stated because it would otherwise be misused. It exists so the registry's
168
+ * own rules — that a session leaves the table however it ends, that an unknown id is refused, that
169
+ * two registries share nothing — can be checked without a process. It is not a way to test the
170
+ * agent's behaviour: a substitute proves the substitute, and everything this package claims about
171
+ * the SDK is proven against a real session instead (see the `.live.test.ts` files).
172
+ *
173
+ * Internal: the process seam is the package's own and is not part of the published API, so the
174
+ * shape a runtime supplies can change without a major version.
175
+ * @internal
176
+ */
177
+ readonly startProcess?: (request: AgentProcessRequest) => AgentProcess;
178
+ }
179
+
180
+ /**
181
+ * 60 seconds. The process is a large native binary starting cold, and a loaded host has been
182
+ * observed holding a trivial invocation of it past ten. Too short turns a slow machine into a
183
+ * spurious outage; there is no upper bound that is too generous for a case that otherwise hangs.
184
+ */
185
+ const DEFAULT_START_TIMEOUT_MS = 60_000;
186
+
187
+ export class SessionRegistry {
188
+ readonly #live = new Map<string, HostedSession>();
189
+ readonly #provisioning = new Set<HostedSession>();
190
+ readonly #baseEnv: Readonly<Record<string, string | undefined>>;
191
+ readonly #homeDir: string;
192
+ readonly #clock: Clock;
193
+ readonly #startTimeoutMs: number;
194
+ readonly #startProcess: (request: AgentProcessRequest) => AgentProcess;
195
+
196
+ constructor(options: SessionRegistryOptions) {
197
+ this.#baseEnv = options.baseEnv;
198
+ this.#homeDir = options.homeDir;
199
+ this.#clock = options.clock ?? systemClock;
200
+ this.#startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
201
+ this.#startProcess = options.startProcess ?? startAgentProcess;
202
+ }
203
+
204
+ /** Every live session. A copy — a caller iterating this cannot be surprised by one ending. */
205
+ list(): HostedSession[] {
206
+ return [...this.#live.values()];
207
+ }
208
+
209
+ get liveCount(): number {
210
+ return this.#live.size;
211
+ }
212
+
213
+ /** Sessions that have started but not yet reported themselves. Separate because they have no id. */
214
+ get provisioningCount(): number {
215
+ return this.#provisioning.size;
216
+ }
217
+
218
+ get(sessionId: string): Result<HostedSession> {
219
+ const session = this.#live.get(sessionId);
220
+ if (session === undefined) {
221
+ return refuse<HostedSession>('session-unknown', `no live session ${sessionId} in this registry`);
222
+ }
223
+ return ok(session);
224
+ }
225
+
226
+ /**
227
+ * Start a session. Returns as soon as the process exists — synchronously, and without an id.
228
+ *
229
+ * Why there is no id yet, and why this is not an oversight. The agent reports itself only once
230
+ * a turn has been queued; before that it emits nothing at all, for as long as you care to wait
231
+ * (observed: 45 seconds of silence with no turn, 2.5 seconds with one). So a `create` that
232
+ * waited for the id would deadlock against the `prompt` that causes it.
233
+ *
234
+ * The shape a caller wants is therefore: create, prompt, then `whenLive()` if it needs the id, the
235
+ * version receipt or the tool list. Until then the session is in `provisioning` — held by this
236
+ * registry, counted by `provisioningCount`, stopped by `stopAll`, but not yet reachable by id
237
+ * because there is no id to reach it by.
238
+ */
239
+ create(request: SessionRequest): Result<HostedSession> {
240
+ const cwd = requireAbsolute(request.cwd);
241
+ if (!cwd.ok) return refuse<HostedSession>(cwd.refusal.reason, cwd.refusal.detail);
242
+
243
+ const settingSources = request.settingSources ?? [];
244
+ const trust = readWorkspaceTrust(trustConfigPath(this.#homeDir), cwd.value);
245
+
246
+ const session = new HostedSession(
247
+ this.#startProcess({
248
+ cwd: cwd.value,
249
+ env: composeSpawnEnv(this.#baseEnv, request.env ?? {}),
250
+ settingSources,
251
+ plugins: request.plugins ?? null,
252
+ hooks: request.hooks ?? null,
253
+ resume: request.resume ?? null,
254
+ fork: request.fork ?? false,
255
+ // The two defaults that carry the streaming policy are these two lines. See SessionRequest
256
+ // for why the rule is stated as an asymmetry rather than as attendedness.
257
+ includePartialMessages: request.includePartialMessages ?? true,
258
+ thinking: request.thinking ?? null,
259
+ forwardSubagentText: request.forwardSubagentText ?? false,
260
+ mcpServers: request.mcpServers ?? null,
261
+ // The default is strict, and it is stated rather than left to the SDK. Omitting the key
262
+ // lets a provisioned workspace's own files register servers — see SessionRequest.
263
+ strictMcpConfig: request.strictMcpConfig ?? true,
264
+ // No default destination, and there must never be one: a host that mirrored somewhere by
265
+ // default would be sending transcripts off-box without anyone choosing to.
266
+ sessionStore: request.sessionStore ?? null,
267
+ sessionStoreFlush: request.sessionStoreFlush ?? null,
268
+ spawn: request.spawn ?? null,
269
+ // No default for either: naming a model here would make this package's own choice look like
270
+ // the CLI's, and the CLI's default is the one an operator can actually see and change.
271
+ model: request.model ?? null,
272
+ systemPrompt: request.systemPrompt ?? null,
273
+ effort: request.effort ?? null,
274
+ permissionMode: request.permissionMode ?? null,
275
+ // The untrusted-workspace condition is reported here and nowhere else in the SDK, so the
276
+ // callback is wired on every start rather than only when it is expected.
277
+ onStderr: (data: string) => {
278
+ if (isUntrustedWorkspaceWarning(data)) {
279
+ session.degrade('workspace_untrusted', data.trim());
280
+ }
281
+ },
282
+ }),
283
+ this.#clock,
284
+ cwd.value,
285
+ trust,
286
+ (live) => this.#adopt(live),
287
+ (finished) => this.#release(finished),
288
+ );
289
+
290
+ this.#provisioning.add(session);
291
+
292
+ // Stated before the first message, because a caller that asked for settings files in a
293
+ // workspace nobody trusted has already lost its rules by the time anything runs — and the only
294
+ // other notice is a line on stderr.
295
+ if (settingSources.length > 0 && trust !== 'trusted') {
296
+ session.degrade(
297
+ 'workspace_untrusted',
298
+ `settings sources ${settingSources.join(', ')} were requested but this workspace is ` +
299
+ `${trust}: permission rules from settings files will be ignored`,
300
+ );
301
+ }
302
+
303
+ return ok(session);
304
+ }
305
+
306
+ /**
307
+ * Start a session, send its first turn, and wait until it has reported itself.
308
+ *
309
+ * The ordinary shape, offered as one call because getting it wrong deadlocks: the turn must be
310
+ * queued before the wait, since it is what makes the agent initialize.
311
+ */
312
+ async open(request: SessionRequest & { readonly prompt: string }): Promise<Result<HostedSession>> {
313
+ const created = this.create(request);
314
+ if (!created.ok) return created;
315
+
316
+ const sent = created.value.prompt(request.prompt);
317
+ if (!sent.ok) {
318
+ created.value.stop('the first turn could not be queued');
319
+ return refuse<HostedSession>(sent.refusal.reason, sent.refusal.detail);
320
+ }
321
+
322
+ const live = await created.value.whenLive(this.#startTimeoutMs);
323
+ if (!live.ok) return refuse<HostedSession>(live.refusal.reason, live.refusal.detail);
324
+ return ok(created.value);
325
+ }
326
+
327
+ /** End one session. Refuses an id this registry does not hold rather than succeeding quietly. */
328
+ stop(sessionId: string, detail = 'stop requested'): Result<void> {
329
+ const session = this.#live.get(sessionId);
330
+ if (session === undefined) {
331
+ return refuse<void>('session-unknown', `no live session ${sessionId} in this registry`);
332
+ }
333
+ session.stop(detail);
334
+ return ok(undefined);
335
+ }
336
+
337
+ /**
338
+ * End everything this registry holds, including sessions still provisioning.
339
+ *
340
+ * Provisioning sessions are included deliberately: they hold a real process even though they have
341
+ * no id yet, and a shutdown that only walked the keyed map would leave those running.
342
+ */
343
+ stopAll(detail = 'host shutting down'): void {
344
+ for (const session of [...this.#live.values(), ...this.#provisioning]) {
345
+ session.stop(detail);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The agent has named itself, so the session becomes reachable by that id and stops provisioning.
351
+ *
352
+ * An id already held is not overwritten, and the reason is an observed SDK property: a resume
353
+ * without `fork` keeps the same session id. So resuming a session this registry is already
354
+ * running produces two live handles claiming one key. Overwriting would make the older one
355
+ * untracked-but-alive — invisible to `list`, unreachable by `get`, missed by `stopAll`, still
356
+ * holding a process — and then its eventual end would evict the newer session's entry, so a live
357
+ * session would become unreachable because a different one finished. Both losses are silent, and
358
+ * both falsify this file's own rule that "in the registry" and "alive" are the same fact.
359
+ *
360
+ * The newcomer is refused rather than the incumbent evicted: the incumbent is the one already
361
+ * being observed, and a degrade names the collision on the session that is about to be dropped —
362
+ * whose caller is the one that can do something about it.
363
+ */
364
+ #adopt(session: HostedSession): void {
365
+ const id = session.facts?.id;
366
+ if (id === undefined) return;
367
+ this.#provisioning.delete(session);
368
+
369
+ const incumbent = this.#live.get(id);
370
+ if (incumbent !== undefined && incumbent !== session) {
371
+ session.degrade(
372
+ 'session_id_collision',
373
+ `the agent reported session id ${id}, which this registry already holds — a resume without ` +
374
+ `fork keeps the original id. This session stays unregistered; stop the one that holds the ` +
375
+ `id, or resume with fork so the agent mints a new one`,
376
+ );
377
+ session.stop(`session id ${id} is already held by a live session in this registry`);
378
+ return;
379
+ }
380
+
381
+ this.#live.set(id, session);
382
+ }
383
+
384
+ /** Only the holder of an id may release it — a colliding session must not evict the incumbent. */
385
+ #release(session: HostedSession): void {
386
+ this.#provisioning.delete(session);
387
+ const id = session.facts?.id;
388
+ if (id !== undefined && this.#live.get(id) === session) this.#live.delete(id);
389
+ }
390
+ }
391
+
392
+ export type { WorkspaceTrust };