@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,694 @@
1
+ /**
2
+ * The agent SDK seam: the only module in this package that starts an agent process, and the one
3
+ * that re-exports the SDK's types for every layer above.
4
+ *
5
+ * It is not the only file that names `@anthropic-ai/claude-agent-sdk`. Three others in
6
+ * `src/host/` do (the MCP server builder, the store adapter and the telemetry reader), because
7
+ * each bridges one SDK shape and none of them spawns anything. The rule the pin enforces is the
8
+ * directory, not this file; what remains unique here is `query()`.
9
+ *
10
+ * It lives in `src/host/` because calling `query()` spawns a real CLI subprocess. The boundary this
11
+ * directory holds is stated as "nothing outside `src/host/` touches the filesystem, spawns a
12
+ * process, or reads the machine", and a package import that spawns is exactly as privileged as
13
+ * `node:child_process` even though no lint rule would have noticed. Pinned by
14
+ * src/pins/sdk-confinement.test.ts.
15
+ *
16
+ * Everything above this file works in the package's own vocabulary and never imports the SDK, so
17
+ * "what starts a process, and with what?" has one answer. The SDK types the layer above genuinely
18
+ * needs are re-exported here rather than imported there; a type-only import would still name the
19
+ * specifier, and one place naming it is the whole point.
20
+ *
21
+ * `windowsHide` is not a knob this package has, and it does not need to be. Node's default is
22
+ * `false` (the widespread "true since Node 15" belief is wrong), and where no ancestor process owns
23
+ * a console there is none to inherit, so every un-hidden spawn allocates a real visible window. It
24
+ * is absent from the SDK's `Options`, but the SDK's own `spawnLocalProcess` passes
25
+ * `windowsHide: true`, so the default path is correct. The trap is `spawnClaudeCodeProcess`: a
26
+ * custom spawn function replaces that path entirely, and one written without `windowsHide`
27
+ * reintroduces a flashing window on every spawn.
28
+ */
29
+ import { query } from '@anthropic-ai/claude-agent-sdk';
30
+ import type {
31
+ EffortLevel,
32
+ HookCallbackMatcher,
33
+ HookEvent,
34
+ HookInput,
35
+ HookJSONOutput,
36
+ McpSdkServerConfigWithInstance,
37
+ McpServerConfig,
38
+ Options,
39
+ PermissionMode,
40
+ Query,
41
+ SDKMessage,
42
+ SDKUserMessage,
43
+ SdkPluginConfig,
44
+ SessionStore,
45
+ SessionStoreFlush,
46
+ SettingSource,
47
+ SpawnOptions,
48
+ SpawnedProcess,
49
+ ThinkingConfig,
50
+ } from '@anthropic-ai/claude-agent-sdk';
51
+
52
+ import { AsyncQueue } from '../core/async-queue.js';
53
+
54
+ export type {
55
+ HookCallbackMatcher,
56
+ HookEvent,
57
+ HookInput,
58
+ HookJSONOutput,
59
+ McpSdkServerConfigWithInstance,
60
+ McpServerConfig,
61
+ SDKMessage,
62
+ SDKUserMessage,
63
+ SdkPluginConfig,
64
+ SessionStore,
65
+ SessionStoreFlush,
66
+ SettingSource,
67
+ SpawnOptions,
68
+ SpawnedProcess,
69
+ ThinkingConfig,
70
+ EffortLevel,
71
+ PermissionMode,
72
+ };
73
+
74
+ /** Exactly the shape `Options.hooks` takes, so a composer above cannot drift from it. */
75
+ export type HookRegistrations = Partial<Record<HookEvent, HookCallbackMatcher[]>>;
76
+
77
+ /**
78
+ * The message union's discriminator, as one string per shape — `type`, or `type/subtype` for the
79
+ * `system` family.
80
+ *
81
+ * Derived from the union rather than listed, so a message type the SDK adds shows up as a missing
82
+ * key wherever this is used exhaustively instead of being silently absent.
83
+ */
84
+ type SystemSubtype = Extract<SDKMessage, { type: 'system' }>['subtype'];
85
+ export type MessageDiscriminator = Exclude<SDKMessage['type'], 'system'> | `system/${SystemSubtype}`;
86
+
87
+ /** The discriminator of one message, in the same form `MessageDiscriminator` names. */
88
+ export function discriminatorOf(message: SDKMessage): MessageDiscriminator {
89
+ return message.type === 'system' ? (`system/${message.subtype}` as MessageDiscriminator) : message.type;
90
+ }
91
+
92
+ /** How the agent's process is created. Replaces the SDK's own local spawn when supplied. */
93
+ export type SpawnAgentProcess = (options: SpawnOptions) => SpawnedProcess;
94
+
95
+ /**
96
+ * What the layer above asks for. Deliberately NOT the SDK's `Options`: composing it is pure work
97
+ * that belongs outside this directory, and keeping the SDK's shape on this side of the seam is what
98
+ * lets the composition be unit-tested with no process anywhere near it.
99
+ */
100
+ export interface AgentProcessRequest {
101
+ /** Absolute, always. The agent's working directory. */
102
+ readonly cwd: string;
103
+ /** The complete environment. Never merged with `process.env` — see sessions/spawn-env.ts. */
104
+ readonly env: Record<string, string>;
105
+ /**
106
+ * Which on-disk settings tiers the agent may load. `[]` means none.
107
+ *
108
+ * The SDK loads ALL of them when this is omitted, so it is passed explicitly on every start: a
109
+ * default that reads whatever files happen to be beside the working directory is not a default a
110
+ * host can reason about.
111
+ */
112
+ readonly settingSources: readonly SettingSource[];
113
+ readonly plugins: readonly SdkPluginConfig[] | null;
114
+ /**
115
+ * The hook callbacks this session runs. Passed straight through, never composed here.
116
+ *
117
+ * Each event maps to an ARRAY of matchers and the SDK runs every entry, so observation and
118
+ * decision are separate registrations on the same event rather than one handler doing both. See
119
+ * `hooks.ts` for the composer that merges them.
120
+ */
121
+ readonly hooks: HookRegistrations | null;
122
+ /** A session id to continue. Null starts a new one. */
123
+ readonly resume: string | null;
124
+ /** With `resume`, continue into a NEW session id instead of extending the old one. */
125
+ readonly fork: boolean;
126
+ /**
127
+ * Emit `stream_event` messages while the agent composes a turn.
128
+ *
129
+ * Nothing partial reaches the message stream without this: with it off there is no
130
+ * `SDKPartialAssistantMessage` at all, so text arrives only when a block completes and a turn
131
+ * cannot be rendered as it happens. It is also the precondition for any thinking prose.
132
+ */
133
+ readonly includePartialMessages: boolean;
134
+ /**
135
+ * How much of the agent's reasoning is emitted. `null` leaves the SDK's own default.
136
+ *
137
+ * `{type:'adaptive'}` fires `thinking_delta` events whose prose is EMPTY;
138
+ * `{type:'adaptive', display:'summarized'}` streams real reasoning text — which costs tokens on
139
+ * the wire and puts reasoning into transcripts and mirrors, so it is asked for rather than
140
+ * assumed. Requires `includePartialMessages`.
141
+ */
142
+ readonly thinking: ThinkingConfig | null;
143
+ /**
144
+ * Forward a subagent's full conversation, not only its tool calls.
145
+ *
146
+ * Off, the stream carries a subagent's `tool_use`/`tool_result` blocks and nothing else — enough
147
+ * to count activity, not enough to read. On, its text and thinking arrive as ordinary messages
148
+ * with `parent_tool_use_id` set, which is the only way a nested turn can be rendered live.
149
+ */
150
+ readonly forwardSubagentText: boolean;
151
+ /**
152
+ * The agent process's stderr, line by line.
153
+ *
154
+ * Wired on every start because some conditions are reported here and NOWHERE else — an untrusted
155
+ * workspace voids its permission rules with a stderr line and no error.
156
+ */
157
+ readonly onStderr: ((data: string) => void) | null;
158
+ /**
159
+ * In-process MCP servers this session may call, by server name.
160
+ *
161
+ * This key enlarges the set of tools the gate must decide about. It is safe for a structural
162
+ * reason, stated at `TOOL_SURFACE_OPTION_KEYS` below, and not for the reason the streaming keys
163
+ * are safe.
164
+ */
165
+ readonly mcpServers: Readonly<Record<string, McpServerConfig>> | null;
166
+ /**
167
+ * Use ONLY the servers above, ignoring project `.mcp.json`, user settings, plugin MCP and on-disk
168
+ * agent frontmatter. `null` leaves the SDK's own default.
169
+ *
170
+ * The composer above defaults this on (see sessions/registry.ts). It is the workspace-MCP
171
+ * attack-surface switch: without it, a repository the host provisioned as a workspace can declare
172
+ * its own MCP servers and the agent picks them up, which means a directory's CONTENTS decide what
173
+ * tools exist. That is the one lane where a workspace becomes a tool-registration channel.
174
+ */
175
+ readonly strictMcpConfig: boolean | null;
176
+ /**
177
+ * Mirror this session's transcript to a store the embedder supplies. `null` mirrors nowhere.
178
+ *
179
+ * This object receives every message the agent saw. It is a confidentiality and egress surface,
180
+ * and it must not be filed with the streaming keys, which only widen what this host itself
181
+ * observes.
182
+ *
183
+ * What makes it safe is structural, and it is the same shape as the gate's no-matcher argument:
184
+ * a store is a live object with methods, so it cannot be expressed in JSON and cannot cross the
185
+ * link. It can only be supplied by the code that composes this host, which is the embedder. No
186
+ * controller, and nothing arriving over the wire, can name a destination for a transcript. Pinned
187
+ * by pins/persistence-egress.test.ts rather than left as a sentence.
188
+ *
189
+ * The one documented incompatibility is unreachable by construction. A store may not be
190
+ * combined with session persistence turned off, because the mirror runs after the local write
191
+ * succeeds. `persistSession` is not composable through this type, so the SDK's own default stands
192
+ * and the combination cannot be built here.
193
+ */
194
+ readonly sessionStore: SessionStore | null;
195
+ /**
196
+ * How eagerly mirrored entries are flushed. `null` leaves the SDK's own default of batching.
197
+ *
198
+ * Eager gives near-real-time delivery at the cost of one call per frame with no coalescing, so it
199
+ * is asked for rather than assumed. Ignored entirely when no store is set.
200
+ */
201
+ readonly sessionStoreFlush: SessionStoreFlush | null;
202
+ /**
203
+ * Create the process yourself, for a VM, a container or a remote machine.
204
+ *
205
+ * Two things a custom spawn loses, and both are silent.
206
+ * 1. `windowsHide`. The SDK's own spawn passes it; Node's default is `false`, so a custom spawn
207
+ * written without it allocates a real visible window per session wherever no ancestor process
208
+ * owns a console. See this file's header.
209
+ * 2. stderr. `SpawnedProcess` carries only stdin and stdout, so a custom spawn that does not
210
+ * route the child's stderr somewhere loses `onStderr` entirely, and the untrusted-workspace
211
+ * condition is reported only there.
212
+ */
213
+ readonly spawn: SpawnAgentProcess | null;
214
+ /**
215
+ * Which model runs this session. `null` leaves the CLI's own default.
216
+ *
217
+ * Absent, not narrowed: `model` and `systemPrompt` were never among this type's keys, so adding
218
+ * them filled a gap; it did not widen a security narrowing. The eight `SHADOWING_LANES` below are
219
+ * a deliberate narrowing and they stay closed. Two different facts, and conflating them costs a
220
+ * reader a whole cycle on the wrong objection.
221
+ *
222
+ * What made the gap expensive: a host that cannot state a model cannot run the same agent twice on
223
+ * purpose, and every caller silently got whatever the installed CLI defaulted to.
224
+ */
225
+ readonly model: string | null;
226
+ /**
227
+ * What the agent is told at the start. `null` leaves the CLI's own preset.
228
+ *
229
+ * The one composable key that cannot be proven from the agent's own report. `AgentInitFacts`
230
+ * below is lifted entirely out of the SDK's `system/init` message, and that message carries no
231
+ * system prompt, so unlike `model`, `tools` or `mcpServers`, nothing coming back from the agent
232
+ * confirms this took effect. It ships unproven-by-report, and that is stated here rather than left
233
+ * for someone to discover while trying to write the assertion.
234
+ *
235
+ * Typed off the SDK's own option so it cannot drift from what `query()` accepts.
236
+ */
237
+ readonly systemPrompt: AgentSystemPrompt | null;
238
+ /** The effort level. `null` leaves the SDK's own default. */
239
+ readonly effort: EffortLevel | null;
240
+ /**
241
+ * The permission mode. `null` leaves the SDK's default. Deliberately opened: see
242
+ * `CLI_PARITY_OPTION_KEYS` for the reason stated as data, and `SHADOWING_LANES` for what stays
243
+ * closed. The gate's authority is the `PreToolUse` hook; a mode never pre-answers it.
244
+ */
245
+ readonly permissionMode: PermissionMode | null;
246
+ }
247
+
248
+ /**
249
+ * What the SDK accepts as a system prompt — a string, a list of strings, or a preset descriptor.
250
+ *
251
+ * Derived from `Options` rather than restated: this package's rule is that the types win, and a
252
+ * hand-copied union is a second copy that can disagree with the first.
253
+ */
254
+ export type AgentSystemPrompt = NonNullable<Options['systemPrompt']>;
255
+
256
+ /**
257
+ * Every key `Options` is composed from, as data.
258
+ *
259
+ * This is the permission-config pin's subject, and it is why that pin is a compile error rather
260
+ * than a grep. The SDK's `Options` carries nine lanes that alter permission outcomes:
261
+ * `permissionMode`, `settings`, `managedSettings`, `toolAliases`, `permissionPromptToolName`,
262
+ * `allowedTools`, `disallowedTools`, `canUseTool`, and the `permissions` block a settings object can
263
+ * carry. Eight of them (`SHADOWING_LANES`) are unreachable, because a caller can only supply the
264
+ * keys below and `startAgentProcess` composes `Options` from exactly these; `permissionMode` is the
265
+ * one opened by name, in `CLI_PARITY_OPTION_KEYS`. Adding a composable option breaks this
266
+ * declaration, and the pin fails at build time instead of when someone remembers to look.
267
+ *
268
+ * The composable set is declared as several lists, each with its own reason, because the moment two
269
+ * different justifications share one constant, neither can be checked:
270
+ *
271
+ * - `STREAMING_OPTION_KEYS` select what the process emits, never what it may do. A widening for
272
+ * visibility is not a weakening for permission.
273
+ * - `TOOL_SURFACE_OPTION_KEYS` change which tools exist. Read that list's note for why it is
274
+ * nonetheless safe.
275
+ * - `PERSISTENCE_OPTION_KEYS` decide where the transcript goes: the egress lane. Its safety
276
+ * argument is structural and is pinned rather than stated.
277
+ * - `AGENT_SELECTION_OPTION_KEYS` choose which agent runs and what it is told at the start.
278
+ * - `CLI_PARITY_OPTION_KEYS` expose what the CLI itself exposes, including the one shadowing lane
279
+ * that is a posture rather than a rule file.
280
+ *
281
+ * The distinction that decides whether a widening is a weakening: `model` and `systemPrompt` were
282
+ * absent, not narrowed. They were never among the composable keys, so opening them filled a gap.
283
+ * The eight lanes in `SHADOWING_LANES` are a deliberate security narrowing and stay closed, and the
284
+ * pin still asserts each one by name. A gap filled and a narrowing widened are different acts and
285
+ * this file tells them apart.
286
+ *
287
+ * `toolAliases` is the one worth naming: it redirects tool names after the model emits them, so a
288
+ * gate matching on `tool_name` would see the alias source while the target executed. A silent
289
+ * mismatch, and unreachable here by construction.
290
+ */
291
+ export const AGENT_PROCESS_REQUEST_KEYS = {
292
+ cwd: true,
293
+ env: true,
294
+ settingSources: true,
295
+ plugins: true,
296
+ hooks: true,
297
+ resume: true,
298
+ fork: true,
299
+ onStderr: true,
300
+ spawn: true,
301
+ includePartialMessages: true,
302
+ thinking: true,
303
+ forwardSubagentText: true,
304
+ mcpServers: true,
305
+ strictMcpConfig: true,
306
+ sessionStore: true,
307
+ sessionStoreFlush: true,
308
+ model: true,
309
+ systemPrompt: true,
310
+ effort: true,
311
+ permissionMode: true,
312
+ } as const satisfies Record<keyof AgentProcessRequest, true>;
313
+
314
+ /**
315
+ * The eight closed lanes, as data: the subject both permission pins are about.
316
+ *
317
+ * The defect this guards against: the lanes were once written out twice, once per pin, and the two
318
+ * copies disagreed. The scan pin listed `permissionPrompt`, which does not exist in `sdk.d.ts` at
319
+ * all, and omitted `permissions`, which does. So the "two mechanisms, one invariant" argument (a
320
+ * type cannot see a module that reaches past the composer, a scan cannot see a type) was being made
321
+ * by two lists guarding different sets, with the real option covered by only one of them.
322
+ *
323
+ * One declaration now, derived by both, and every name checked against the shipped types.
324
+ */
325
+ export const SHADOWING_LANES: readonly string[] = [
326
+ // `permissionMode` was deliberately removed from this list (CLI parity); see
327
+ // `CLI_PARITY_OPTION_KEYS`. It is the one lane that is a posture the operator chooses in the open,
328
+ // not a rule file or a pre-answer; the eight below are the latter and stay closed.
329
+ 'settings',
330
+ 'managedSettings',
331
+ 'toolAliases',
332
+ 'permissionPromptToolName',
333
+ 'allowedTools',
334
+ 'disallowedTools',
335
+ 'canUseTool',
336
+ 'permissions',
337
+ ];
338
+
339
+ /**
340
+ * The keys that widened the composable set for visibility, kept as data so the reason is checkable
341
+ * rather than remembered.
342
+ *
343
+ * One reason covers all three: each selects what the process emits. None appears in any permission
344
+ * evaluation path, so none can change whether a tool runs, only how much of the run is visible.
345
+ * That is why widening the set here does not weaken the boundary above.
346
+ *
347
+ * The pin asserts every member is composable and is none of the eight shadowing lanes, so a later
348
+ * addition cannot join this list by assertion alone.
349
+ */
350
+ export const STREAMING_OPTION_KEYS = [
351
+ 'includePartialMessages',
352
+ 'thinking',
353
+ 'forwardSubagentText',
354
+ ] as const satisfies readonly (keyof AgentProcessRequest)[];
355
+
356
+ /**
357
+ * The keys that change which tools exist: a different class from the streaming three, kept
358
+ * separate so the two reasons cannot be confused for one.
359
+ *
360
+ * These are not data keys. `mcpServers` introduces tools, and `PreToolUse` fires for MCP tools and
361
+ * inside subagents (observed, not assumed). So this widening enlarges the surface the gate must
362
+ * cover, and the streaming keys' reason ("it only selects what is emitted") is false of it.
363
+ *
364
+ * Nor are they shadowing lanes. The closed lanes share one property: they can answer a permission
365
+ * before the gate does. These answer nothing. What makes them safe is structural: `permissionHooks`
366
+ * registers `PreToolUse` with no `matcher`, so a tool this host has never heard of reaches exactly
367
+ * the same handler as `Bash`. Coverage is by construction rather than by enumeration, which is why a
368
+ * tool set the host cannot predict is still a tool set the host decides about.
369
+ *
370
+ * If a `matcher` were ever introduced, this classification stops being true, and these two keys
371
+ * become the first way to add a tool nothing decides about. That sentence is the whole reason this
372
+ * list exists as data instead of as a decision somebody made once.
373
+ *
374
+ * `strictMcpConfig` belongs here because it acts on the same surface, but it moves the opposite
375
+ * way: it only ever removes servers the host did not declare. It is the safe direction of the same
376
+ * lane, and grouping them keeps that visible.
377
+ */
378
+ export const TOOL_SURFACE_OPTION_KEYS = [
379
+ 'mcpServers',
380
+ 'strictMcpConfig',
381
+ ] as const satisfies readonly (keyof AgentProcessRequest)[];
382
+
383
+ /**
384
+ * The keys that decide where a transcript goes: a third class again, and the reason is not the
385
+ * other two lists' reason.
386
+ *
387
+ * These are a confidentiality and egress surface. The streaming keys widen what this host observes
388
+ * about its own session. The tool-surface keys widen what the agent may do, and are covered because
389
+ * the gate has no matcher. These do neither: they hand a live object every message the agent saw,
390
+ * and a store is by definition somewhere else. Filing them under "it only selects what is emitted"
391
+ * would put a false sentence inside the constant whose entire job is to make the reason checkable.
392
+ *
393
+ * Nor are they shadowing lanes. The closed lanes share one property: they can answer a permission
394
+ * before the gate does. A store answers nothing and is never consulted about whether a tool runs.
395
+ *
396
+ * What makes them safe is that a destination cannot be named from off-box, and it is structural
397
+ * rather than enforced: `SessionStore` is an object with methods, so it has no JSON representation
398
+ * and cannot arrive over the link. The only code that can supply one is the code that composes this
399
+ * host. That is the same shape of argument as the gate's no-matcher coverage (a property of what
400
+ * the type is, not of a check somebody remembered to write) and it is pinned by
401
+ * pins/persistence-egress.test.ts.
402
+ *
403
+ * If a store ever becomes constructible from data (a URL, a connection string, a descriptor the
404
+ * host resolves into a client), this classification stops being true, and these become the first
405
+ * way a transcript can be sent somewhere the operator did not choose. That sentence is why this list
406
+ * is data instead of a decision somebody made once.
407
+ */
408
+ export const PERSISTENCE_OPTION_KEYS = [
409
+ 'sessionStore',
410
+ 'sessionStoreFlush',
411
+ ] as const satisfies readonly (keyof AgentProcessRequest)[];
412
+
413
+ /**
414
+ * The keys that choose which agent runs and what it is told at the start: a fourth class, and its
415
+ * reason is not any of the other three's.
416
+ *
417
+ * These were absent, not narrowed, and that is the whole classification. `model` and
418
+ * `systemPrompt` were simply not in this type, so nothing was ever protecting them; there was no
419
+ * decision to reverse, only a capability nobody had wired. The eight `SHADOWING_LANES` are the
420
+ * opposite case: each was considered and closed. Opening a gap and re-opening a closed lane look
421
+ * identical in a diff, and this list is how they stop looking identical.
422
+ *
423
+ * Neither appears in any permission evaluation path. They do not pre-answer a call, do not
424
+ * redirect a tool name, do not load a rule file and are never consulted about whether a tool runs,
425
+ * which is exactly the property the closed lanes share and these do not. What they change is which
426
+ * weights answer and what standing instructions those weights start with.
427
+ *
428
+ * The cost, because a widening with no stated cost is a widening nobody checked: a controller that
429
+ * can set a system prompt can give the agent standing instructions this host will never see the
430
+ * effect of, since the SDK's init message does not report one. The gate still decides every tool
431
+ * call, so the boundary is unmoved, but "the host can state what this agent was told" is not a
432
+ * property this package has; `AgentInitFacts` is where that can be verified.
433
+ *
434
+ * The pin asserts every member is composable and is none of the eight, exactly as the other lists
435
+ * do.
436
+ */
437
+ export const AGENT_SELECTION_OPTION_KEYS = [
438
+ 'model',
439
+ 'systemPrompt',
440
+ ] as const satisfies readonly (keyof AgentProcessRequest)[];
441
+
442
+ /**
443
+ * The CLI-parity keys, and the list that re-opens a closed lane on purpose. `effort` was absent (a
444
+ * gap, like `model`). `permissionMode` was once a shadowing lane, considered and closed, and it is
445
+ * opened here by name, with the reason:
446
+ *
447
+ * a session through this host must expose what `claude` exposes, and bypass is a common default
448
+ * for an operator. The gate never depended on the mode: `PreToolUse` fires under every mode, so
449
+ * the boundary set is still held under `bypassPermissions`. What the mode changes is the CLI's
450
+ * own prompt flow, which this host's gate already answers.
451
+ *
452
+ * The other eight stay closed: they are rule files and pre-answers, which is a different thing from a
453
+ * posture chosen in the open. Pinned by `pins/permission-config.test.ts`.
454
+ */
455
+ export const CLI_PARITY_OPTION_KEYS = [
456
+ 'effort',
457
+ 'permissionMode',
458
+ ] as const satisfies readonly (keyof AgentProcessRequest)[];
459
+
460
+ /** A started agent process, in this package's terms. */
461
+ export interface AgentProcess {
462
+ /**
463
+ * Everything the agent emits, in order. One consumer.
464
+ *
465
+ * This is a narrowing wrapper, not the SDK's `Query`, and that is load-bearing. `query()`
466
+ * returns an object that IS an async generator AND carries `setPermissionMode`,
467
+ * `applyFlagSettings`, `setMcpServers` and `setMcpPermissionModeOverride` — four calls that change
468
+ * permission outcomes mid-session, after any construction-time check has run. Handing that object
469
+ * out under an `AsyncGenerator` annotation hides them from the compiler and from nobody else: one
470
+ * cast, or any plain JavaScript, reaches all four. So it is wrapped rather than annotated, and
471
+ * "the composed options cannot ship a shadowing setting" stays true without the words "unless you
472
+ * cast" attached to it.
473
+ *
474
+ * Where a mid-session control goes instead: a named method on this handle, beside `prompt`,
475
+ * `interrupt` and `close`. Never by widening this property back to the `Query`, and never by
476
+ * casting it; the point is that one file decides which of the SDK's controls this package
477
+ * offers.
478
+ */
479
+ readonly messages: AsyncGenerator<SDKMessage, void>;
480
+ /** Queue a turn. Returns false once the process is closed. */
481
+ prompt(text: string): boolean;
482
+ /** Stop the current turn in band, leaving the session alive. */
483
+ interrupt(): Promise<void>;
484
+ /**
485
+ * The named mid-session controls this package offers (the doc above says where they go): the
486
+ * three members of `session_configure`, each the SDK's own streaming-input setter behind a method.
487
+ * `setPermissionMode` is here deliberately: the one permission mutator that is a posture, reached
488
+ * only from the wire through `readSessionConfigure`; the other three stay unreachable.
489
+ */
490
+ setModel(model: string | null): Promise<void>;
491
+ setPermissionMode(mode: PermissionMode): Promise<void>;
492
+ setThinking(thinking: ThinkingConfig): Promise<void>;
493
+ /** End the process and release everything it holds. Idempotent. */
494
+ close(): void;
495
+ }
496
+
497
+ /**
498
+ * Facts the agent states about itself at start, lifted out of the SDK's init message.
499
+ *
500
+ * This is the per-spawn version receipt, and its shape is why it cannot rot: `cliVersion` arrives
501
+ * on the session's own stream, so there is no place to cache it even by accident. The CLI can
502
+ * auto-update underneath a long-running host, and a value read once at startup would then be a
503
+ * confident lie on every later session — which is the failure this receipt exists to catch.
504
+ *
505
+ * Absent is `null`, never an omitted property: this record is destined for the wire, where JSON has
506
+ * no `undefined` and an optional field makes "not reported" and "reported as unset" the same thing.
507
+ */
508
+ export interface AgentInitFacts {
509
+ readonly sessionId: string;
510
+ readonly cliVersion: string;
511
+ readonly cwd: string;
512
+ readonly model: string;
513
+ readonly permissionMode: string;
514
+ /** Where the agent found its credentials. The evidence that ambient auth actually resolved. */
515
+ readonly apiKeySource: string;
516
+ readonly tools: readonly string[];
517
+ readonly skills: readonly string[];
518
+ readonly plugins: readonly AgentPluginFact[];
519
+ readonly capabilities: readonly string[];
520
+ /**
521
+ * The MCP servers this agent actually connected to, and what happened to each.
522
+ *
523
+ * The agent reports this on the init message. Without it, "did my server register?" is answered
524
+ * by inferring it from tool names, which cannot tell a server that failed to connect from one that
525
+ * connected and exposed no tools, and an HTTP or stdio server, unlike an in-process one, can fail
526
+ * to connect. With it, the answer is the agent's own word, per server, by name and status.
527
+ *
528
+ * The status string is carried verbatim and never interpreted here. It is the agent's vocabulary,
529
+ * not this package's, and mapping it onto this package's words would be a second translation
530
+ * site, the thing `state/observer.ts` exists to be the only one of.
531
+ */
532
+ readonly mcpServers: readonly AgentMcpServerFact[];
533
+ }
534
+
535
+ /** One MCP server as the agent reported it at start. `status` is the SDK's word, carried as-is. */
536
+ export interface AgentMcpServerFact {
537
+ readonly name: string;
538
+ readonly status: string;
539
+ }
540
+
541
+ export interface AgentPluginFact {
542
+ readonly name: string;
543
+ readonly path: string;
544
+ readonly version: string | null;
545
+ }
546
+
547
+ /**
548
+ * The init message's facts, or null for every other message.
549
+ *
550
+ * Reading it here rather than above the seam keeps the SDK's message union on this side; the layer
551
+ * above branches on a plain record.
552
+ */
553
+ export function readInitFacts(message: SDKMessage): AgentInitFacts | null {
554
+ if (message.type !== 'system' || message.subtype !== 'init') return null;
555
+ return {
556
+ sessionId: message.session_id,
557
+ cliVersion: message.claude_code_version,
558
+ cwd: message.cwd,
559
+ model: message.model,
560
+ permissionMode: message.permissionMode,
561
+ apiKeySource: message.apiKeySource,
562
+ tools: message.tools,
563
+ skills: message.skills,
564
+ // Guarded for the same reason as `mcp_servers` below: `plugins` is declared required and read
565
+ // with `.map(…)`, the identical shape, the identical runtime-wins argument, the identical
566
+ // `TypeError`. Every member of this class is guarded, not only the one observed failing.
567
+ plugins: (message.plugins ?? []).map((plugin) => ({
568
+ name: plugin.name,
569
+ path: plugin.path,
570
+ version: plugin.version ?? null,
571
+ })),
572
+ capabilities: message.capabilities ?? [],
573
+ // Guarded though the type says required. `mcp_servers` is declared non-optional on the init
574
+ // message, so `message.mcp_servers.map(…)` typechecks, and this package's rule is that the
575
+ // runtime wins over the types. An init message without the field would throw a TypeError inside
576
+ // `readInitFacts`, and the pump's own catch would convert the throw into `process_failed`: the
577
+ // session ends, and the reader's bug is reported as the agent process dying. `capabilities` one
578
+ // line up is guarded for the same reason, and it at least has `?` in the type as a warning.
579
+ //
580
+ // The cost of an unguarded read is misattribution, not silence. This read has exactly one
581
+ // caller, `HostedSession.#pump`, which calls it before and outside the per-listener try/catch,
582
+ // so nothing swallows it: the enclosing catch finishes the session as `process_failed`, closes
583
+ // the process, releases the registry entry, refuses every `whenLive` waiter and puts a
584
+ // transition to `ended` on the wire. `state/observer.ts` contains no catch at all, so no
585
+ // observer wrapper can eat the throw.
586
+ mcpServers: (message.mcp_servers ?? []).map((server) => ({ name: server.name, status: server.status })),
587
+ };
588
+ }
589
+
590
+ /**
591
+ * The message stream, and ONLY the message stream.
592
+ *
593
+ * `return` and `throw` are delegated, not just `next`. `for await…of` calls `iterator.return()`
594
+ * when the loop leaves early — a `break`, a `return`, or a throw inside the body — and that call is
595
+ * what lets the underlying query release the subprocess. A wrapper implementing only `next` would
596
+ * swallow it, leaking a session per abandoned loop with no error, no log and nothing null: exactly
597
+ * the silent-loss shape this package is built against. Pinned by agent-process.test.ts.
598
+ *
599
+ * Exported for that pin. It is not part of the package's public surface.
600
+ */
601
+ export function messagesOf(source: AsyncGenerator<SDKMessage, void>): AsyncGenerator<SDKMessage, void> {
602
+ return {
603
+ next: (...args) => source.next(...args),
604
+ return: (value) => source.return(value),
605
+ throw: (error) => source.throw(error),
606
+ [Symbol.asyncIterator]() {
607
+ return this;
608
+ },
609
+ } as AsyncGenerator<SDKMessage, void>;
610
+ }
611
+
612
+ /** Start an agent process. The subprocess exists when this returns. */
613
+ export function startAgentProcess(request: AgentProcessRequest): AgentProcess {
614
+ const input = new AsyncQueue<SDKUserMessage>();
615
+
616
+ const options: Options = {
617
+ cwd: request.cwd,
618
+ env: request.env,
619
+ settingSources: [...request.settingSources],
620
+ includePartialMessages: request.includePartialMessages,
621
+ forwardSubagentText: request.forwardSubagentText,
622
+ ...(request.thinking === null ? {} : { thinking: request.thinking }),
623
+ ...(request.mcpServers === null ? {} : { mcpServers: { ...request.mcpServers } }),
624
+ ...(request.strictMcpConfig === null ? {} : { strictMcpConfig: request.strictMcpConfig }),
625
+ // `persistSession` is deliberately never composed: the mirror runs after the local write, so a
626
+ // store cannot be combined with local persistence off. Leaving the SDK's default in place is
627
+ // what makes that combination unbuildable here rather than merely undocumented.
628
+ ...(request.sessionStore === null ? {} : { sessionStore: request.sessionStore }),
629
+ ...(request.sessionStoreFlush === null ? {} : { sessionStoreFlush: request.sessionStoreFlush }),
630
+ ...(request.plugins === null ? {} : { plugins: [...request.plugins] }),
631
+ ...(request.hooks === null ? {} : { hooks: request.hooks }),
632
+ ...(request.resume === null ? {} : { resume: request.resume, forkSession: request.fork }),
633
+ ...(request.onStderr === null ? {} : { stderr: request.onStderr }),
634
+ ...(request.spawn === null ? {} : { spawnClaudeCodeProcess: request.spawn }),
635
+ // Omitted rather than passed as null when unset, like every other optional above: the SDK reads
636
+ // an absent key as "use the default" and a present-but-null one as a value it must interpret.
637
+ ...(request.model === null ? {} : { model: request.model }),
638
+ ...(request.systemPrompt === null ? {} : { systemPrompt: request.systemPrompt }),
639
+ ...(request.effort === null ? {} : { effort: request.effort }),
640
+ ...(request.permissionMode === null ? {} : { permissionMode: request.permissionMode }),
641
+ };
642
+
643
+ const running: Query = query({ prompt: input, options });
644
+ let closed = false;
645
+
646
+ return {
647
+ messages: messagesOf(running),
648
+ prompt(text: string): boolean {
649
+ if (closed) return false;
650
+ // `session_id` and `uuid` are optional on this type and deliberately left off: the agent
651
+ // stamps its own. Sending an empty string would be a PRESENT id that is blank, which is a
652
+ // different and worse thing than an absent one.
653
+ const message: SDKUserMessage = {
654
+ type: 'user',
655
+ message: { role: 'user', content: [{ type: 'text', text }] },
656
+ parent_tool_use_id: null,
657
+ };
658
+ input.push(message);
659
+ return true;
660
+ },
661
+ async interrupt(): Promise<void> {
662
+ if (closed) return;
663
+ // The SDK's receipt names which queued messages survived the interrupt. Not surfaced here:
664
+ // acting on it needs the turn model, which belongs to the layer that owns turns.
665
+ await running.interrupt();
666
+ },
667
+ async setModel(model: string | null): Promise<void> {
668
+ if (closed) return;
669
+ // `undefined`, never null: the SDK reads undefined as "the default" and null as a model named null.
670
+ await running.setModel(model ?? undefined);
671
+ },
672
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
673
+ if (closed) return;
674
+ await running.setPermissionMode(mode);
675
+ },
676
+ async setThinking(thinking: ThinkingConfig): Promise<void> {
677
+ if (closed) return;
678
+ // The SDK's LIVE setter is the token cap, and on current models it is on/off: 0 = disabled,
679
+ // null = the default (adaptive). A fixed budget is REJECTED by Opus 5 / Sonnet 5 / Fable, so an
680
+ // `enabled` ask maps to adaptive rather than to a 400. The DISPLAY rides along: `summarized` when
681
+ // asked, because the models' default (`omitted`) streams thinking blocks with empty text — the
682
+ // "no thinking" an operator sees while paying for it.
683
+ const cap = thinking.type === 'disabled' ? 0 : null;
684
+ const display = thinking.type === 'disabled' ? undefined : thinking.display;
685
+ await running.setMaxThinkingTokens(cap, display);
686
+ },
687
+ close(): void {
688
+ if (closed) return;
689
+ closed = true;
690
+ input.end();
691
+ running.close();
692
+ },
693
+ };
694
+ }