@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,178 @@
1
+ /**
2
+ * A live agent session, and the handle every later layer attaches through.
3
+ *
4
+ * The ownership model, because more than one layer holds this object.
5
+ * The registry owns a session's lifetime; everyone else borrows. Whoever attaches to observe a
6
+ * session — a permission gate, a stream forwarder — holds this handle, reads it, subscribes to it,
7
+ * and never ends it. `stop()` exists on the handle for the registry's convenience and takes the
8
+ * session out of its registry when it runs, so there is exactly one way a session ends and exactly
9
+ * one place that knows it has.
10
+ *
11
+ * This session reads the agent's output once and fans it out.
12
+ * The SDK hands back a single-consumer stream, so if two layers both iterated it they would split
13
+ * the messages between them and each would silently see half. The pump here is the only consumer;
14
+ * everything else subscribes. A subscriber's callback runs synchronously in the pump, so a
15
+ * subscriber that needs to do slow work must hand off to its own buffer rather than awaiting inside
16
+ * the callback.
17
+ *
18
+ * What this deliberately does not do: it emits no frames and knows nothing about a controller
19
+ * link. The declared state model — what an agent is at a moment, and why — belongs to the layer
20
+ * above, and a session inventing its own status words would hand that layer a vocabulary it never
21
+ * chose. The events below are local and small on purpose: something to translate from, not a model.
22
+ *
23
+ * An agent does not report itself until a turn starts, and the whole shape here follows from it.
24
+ * Starting the process produces nothing — no id, no version, no tool list, no stderr, indefinitely.
25
+ * The init message arrives about two seconds after the first turn is queued. Observed both ways: a
26
+ * full-inheritance environment with no turn stayed silent for 45 seconds, and a filtered one with
27
+ * a turn queued first reported in 2.5. So a session cannot be handed back "already identified" —
28
+ * waiting for that before accepting a turn is a deadlock, because the turn is what causes it.
29
+ * `create` therefore returns a session in `provisioning`, and `whenLive()` is how a caller that
30
+ * needs the id or the version receipt waits for them.
31
+ */
32
+ import type { SDKMessage } from '../host/agent-process.js';
33
+ import type { SessionConfigureChange } from '../host/wire-request.js';
34
+ import type { WorkspaceTrust } from '../host/workspace-trust.js';
35
+ import type { Result } from '../core/result.js';
36
+ /**
37
+ * Where the agent process is. Three values, because there are three genuinely different situations.
38
+ *
39
+ * This is not the declared state model and it is not a peer of it. `SessionState` in
40
+ * state/model.ts answers "what is this session doing, and why". This answers the narrower question
41
+ * "does the process exist, and has the agent named itself" — which is what `prompt`, `stop` and
42
+ * `whenLive` branch on. The declared model subsumes these three: provisioning sits inside spawning,
43
+ * live spans everything between, ended is ended. So this is an input to that machine, arriving
44
+ * there as a cause of kind `process`. One declared model, one substrate feeding it.
45
+ *
46
+ * Do not restore symmetry between the two. They were never symmetric, and a second declared model
47
+ * is exactly how one concept ends up with two names that drift.
48
+ */
49
+ export type SessionLifecycle =
50
+ /** Started, but the agent has not yet said what it is. No id, no version receipt. */
51
+ 'provisioning'
52
+ /** The agent reported itself and is accepting turns. */
53
+ | 'live'
54
+ /** Over. `endCause` says why, and it is never inferred from silence. */
55
+ | 'ended';
56
+ /**
57
+ * Why a session ended. Local to this layer and stated as data so the set can be enumerated.
58
+ *
59
+ * Four values, all of them observed rather than assumed: nothing here means "the host stopped
60
+ * hearing from it". The layer that owns the declared state model maps these onto its own causes.
61
+ */
62
+ export declare const SESSION_END_CAUSES: readonly ["stop_requested", "process_ended", "process_failed", "start_timed_out"];
63
+ export type SessionEndCause = (typeof SESSION_END_CAUSES)[number];
64
+ export interface SessionEnded {
65
+ readonly cause: SessionEndCause;
66
+ readonly detail: string;
67
+ readonly at: string;
68
+ }
69
+ /**
70
+ * A named condition that does not stop the session but changes what is true of it.
71
+ *
72
+ * `subscriber_failed` means a listener on this handle threw: the session and its process are fine,
73
+ * and saying so distinctly is the point — a subscriber's bug must not wear an outage's name.
74
+ *
75
+ * `session_id_collision` means the agent named itself with an id the registry already holds, so
76
+ * this session was refused registration and stopped. Its own kind because it is neither an outage
77
+ * nor a bug: it is what a resume-without-fork legitimately produces.
78
+ *
79
+ * This union is local — not exported on `periscope/protocol`, not validated by any closed wire
80
+ * enum — which is the only reason widening it is a safe, additive change. Putting it on the wire
81
+ * would make an added kind decode-fatal to an older peer; re-decide the shape then, not by copy.
82
+ */
83
+ export interface SessionDegrade {
84
+ readonly kind: 'workspace_untrusted' | 'subscriber_failed' | 'session_id_collision';
85
+ readonly detail: string;
86
+ readonly at: string;
87
+ }
88
+ export type SessionListener = (message: SDKMessage) => void;
89
+ export type SessionEndListener = (ended: SessionEnded) => void;
90
+ export type SessionDegradeListener = (degrade: SessionDegrade) => void;
91
+ /** Drop a subscription. Calling it twice is harmless. */
92
+ export type Unsubscribe = () => void;
93
+ export interface HostedSessionFacts {
94
+ /** The agent's own session id — the host owns identity, and this is where it comes from. */
95
+ readonly id: string;
96
+ readonly cwd: string;
97
+ readonly startedAt: string;
98
+ /** Read from this session's init message. Never cached across sessions — see agent-process.ts. */
99
+ readonly cliVersion: string;
100
+ readonly model: string;
101
+ readonly permissionMode: string;
102
+ /** Where the agent found credentials. `'oauth'` means the ambient credentials file resolved. */
103
+ readonly apiKeySource: string;
104
+ readonly tools: readonly string[];
105
+ readonly skills: readonly string[];
106
+ readonly plugins: readonly {
107
+ readonly name: string;
108
+ readonly path: string;
109
+ readonly version: string | null;
110
+ }[];
111
+ readonly capabilities: readonly string[];
112
+ /**
113
+ * The MCP servers the agent connected to, by name and status, as the agent reported them.
114
+ *
115
+ * The receipt for a registration, and the only one that can tell a failure apart from an empty
116
+ * server. Reading `tools` for an `mcp__{server}__` prefix answers "did any tool arrive"; it cannot
117
+ * answer "did the server connect", and those differ exactly when a server is reachable over a
118
+ * network rather than living in this process. See `AgentInitFacts.mcpServers`.
119
+ */
120
+ readonly mcpServers: readonly {
121
+ readonly name: string;
122
+ readonly status: string;
123
+ }[];
124
+ /** What was recorded about this directory at start. Reported, never acted on. */
125
+ readonly workspaceTrust: WorkspaceTrust;
126
+ }
127
+ export declare class HostedSession {
128
+ #private;
129
+ get state(): SessionLifecycle;
130
+ /** Null until the agent has reported itself. A caller past `create()` always has them. */
131
+ get facts(): HostedSessionFacts | null;
132
+ /** Null while the session is alive. Never inferred — an ended session always says why. */
133
+ get ended(): SessionEnded | null;
134
+ /** Every degrade so far, oldest first. What a subscriber replay delivers, readable directly. */
135
+ get degrades(): readonly SessionDegrade[];
136
+ /** The agent's id, or a refusal while it is still provisioning. */
137
+ get id(): string | null;
138
+ /**
139
+ * How many observers are attached right now.
140
+ *
141
+ * A long-running host has to be able to answer "is anything accumulating?", and this is the one
142
+ * number that says so per session. It is also what makes the release at the end of a session a
143
+ * checkable fact rather than a line of code nobody can see run — an assertion that a dead session
144
+ * emits nothing would pass whether or not the listeners were ever dropped.
145
+ */
146
+ get observerCount(): number;
147
+ /** Send a turn. Refuses on a session that has ended rather than swallowing the text. */
148
+ prompt(text: string): Result<void>;
149
+ /** Stop the current turn without ending the session. */
150
+ interrupt(): Promise<void>;
151
+ /** Apply a `session_configure` — the asked members, in order, through the SDK's live setters. */
152
+ configure(change: SessionConfigureChange): Promise<void>;
153
+ /** End the session and release it from its registry. Idempotent. `#finish` closes the process. */
154
+ stop(detail?: string): void;
155
+ onMessage(listener: SessionListener): Unsubscribe;
156
+ /** Fires exactly once. A listener added after the end is called immediately with the same value. */
157
+ onEnd(listener: SessionEndListener): Unsubscribe;
158
+ /**
159
+ * Replays every degrade so far to the new listener, then keeps it current. Replay is what makes
160
+ * a degrade raised inside `create()` — before any caller could possibly subscribe — observable
161
+ * at all; without it that emission has structurally no audience.
162
+ */
163
+ onDegrade(listener: SessionDegradeListener): Unsubscribe;
164
+ /** Report a named condition that changes what is true without ending anything. */
165
+ degrade(kind: SessionDegrade['kind'], detail: string): void;
166
+ /**
167
+ * Wait until the agent has reported itself, so the id and the version receipt are known.
168
+ *
169
+ * This only completes once a turn has been queued. Awaiting it before prompting waits forever,
170
+ * because the turn is what makes the agent initialize — see this file's header. A caller that
171
+ * wants both usually wants `prompt()` first and this second.
172
+ *
173
+ * The timeout is not optional comfort: a start that never completes is the worst failure here —
174
+ * no error, no message, and a caller waiting on a process that may not even be running.
175
+ */
176
+ whenLive(timeoutMs: number): Promise<Result<HostedSessionFacts>>;
177
+ }
178
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,288 @@
1
+ import { readInitFacts } from '../host/agent-process.js';
2
+ import { ok, refuse } from '../core/result.js';
3
+ /**
4
+ * Why a session ended. Local to this layer and stated as data so the set can be enumerated.
5
+ *
6
+ * Four values, all of them observed rather than assumed: nothing here means "the host stopped
7
+ * hearing from it". The layer that owns the declared state model maps these onto its own causes.
8
+ */
9
+ export const SESSION_END_CAUSES = [
10
+ /** Someone asked. The only deliberate one. */
11
+ 'stop_requested',
12
+ /** The agent's own stream completed — a normal end. */
13
+ 'process_ended',
14
+ /** The stream threw. The process died in a way it did not choose. */
15
+ 'process_failed',
16
+ /** It never reported itself inside the start window, so it never became live. */
17
+ 'start_timed_out',
18
+ ];
19
+ export class HostedSession {
20
+ #process;
21
+ #clock;
22
+ #onReleased;
23
+ #messageListeners = new Set();
24
+ #endListeners = new Set();
25
+ #degradeListeners = new Set();
26
+ #liveWaiters = [];
27
+ #cwd;
28
+ #trust;
29
+ #onLive;
30
+ #degrades = [];
31
+ #state = 'provisioning';
32
+ #facts = null;
33
+ #ended = null;
34
+ /**
35
+ * Constructed by `SessionRegistry` only. Internal: an embedder borrows a handle from the registry
36
+ * and never builds one, so the process seam stays out of the published API.
37
+ * @internal
38
+ */
39
+ constructor(process, clock, cwd, trust, onLive, onReleased) {
40
+ this.#process = process;
41
+ this.#clock = clock;
42
+ this.#cwd = cwd;
43
+ this.#trust = trust;
44
+ this.#onLive = onLive;
45
+ this.#onReleased = onReleased;
46
+ // Reading starts immediately. The agent will say nothing until a turn is queued, but a consumer
47
+ // attached later would miss whatever came before it.
48
+ void this.#pump();
49
+ }
50
+ get state() {
51
+ return this.#state;
52
+ }
53
+ /** Null until the agent has reported itself. A caller past `create()` always has them. */
54
+ get facts() {
55
+ return this.#facts;
56
+ }
57
+ /** Null while the session is alive. Never inferred — an ended session always says why. */
58
+ get ended() {
59
+ return this.#ended;
60
+ }
61
+ /** Every degrade so far, oldest first. What a subscriber replay delivers, readable directly. */
62
+ get degrades() {
63
+ return [...this.#degrades];
64
+ }
65
+ /** The agent's id, or a refusal while it is still provisioning. */
66
+ get id() {
67
+ return this.#facts?.id ?? null;
68
+ }
69
+ /**
70
+ * How many observers are attached right now.
71
+ *
72
+ * A long-running host has to be able to answer "is anything accumulating?", and this is the one
73
+ * number that says so per session. It is also what makes the release at the end of a session a
74
+ * checkable fact rather than a line of code nobody can see run — an assertion that a dead session
75
+ * emits nothing would pass whether or not the listeners were ever dropped.
76
+ */
77
+ get observerCount() {
78
+ return this.#messageListeners.size + this.#endListeners.size + this.#degradeListeners.size;
79
+ }
80
+ /** Send a turn. Refuses on a session that has ended rather than swallowing the text. */
81
+ prompt(text) {
82
+ if (this.#state === 'ended') {
83
+ return refuse('session-unknown', `session has ended (${this.#ended?.cause ?? 'unknown'})`);
84
+ }
85
+ this.#process.prompt(text);
86
+ return ok(undefined);
87
+ }
88
+ /** Stop the current turn without ending the session. */
89
+ interrupt() {
90
+ if (this.#state === 'ended')
91
+ return Promise.resolve();
92
+ return this.#process.interrupt();
93
+ }
94
+ /** Apply a `session_configure` — the asked members, in order, through the SDK's live setters. */
95
+ async configure(change) {
96
+ if (this.#state === 'ended')
97
+ return;
98
+ if (change.model !== undefined)
99
+ await this.#process.setModel(change.model);
100
+ if (change.permissionMode !== undefined)
101
+ await this.#process.setPermissionMode(change.permissionMode);
102
+ if (change.thinking !== undefined)
103
+ await this.#process.setThinking(change.thinking);
104
+ }
105
+ /** End the session and release it from its registry. Idempotent. `#finish` closes the process. */
106
+ stop(detail = 'stop requested') {
107
+ if (this.#state === 'ended')
108
+ return;
109
+ this.#finish('stop_requested', detail);
110
+ }
111
+ onMessage(listener) {
112
+ this.#messageListeners.add(listener);
113
+ return () => this.#messageListeners.delete(listener);
114
+ }
115
+ /** Fires exactly once. A listener added after the end is called immediately with the same value. */
116
+ onEnd(listener) {
117
+ if (this.#ended !== null) {
118
+ listener(this.#ended);
119
+ return () => undefined;
120
+ }
121
+ this.#endListeners.add(listener);
122
+ return () => this.#endListeners.delete(listener);
123
+ }
124
+ /**
125
+ * Replays every degrade so far to the new listener, then keeps it current. Replay is what makes
126
+ * a degrade raised inside `create()` — before any caller could possibly subscribe — observable
127
+ * at all; without it that emission has structurally no audience.
128
+ */
129
+ onDegrade(listener) {
130
+ for (const degrade of this.#degrades) {
131
+ try {
132
+ listener(degrade);
133
+ }
134
+ catch {
135
+ // The failure channel is best-effort by construction: reporting a reporter would recurse.
136
+ }
137
+ }
138
+ this.#degradeListeners.add(listener);
139
+ return () => this.#degradeListeners.delete(listener);
140
+ }
141
+ // -------------------------------------------------------------------------
142
+ // Internal — driven by the registry, which is the only thing that constructs one of these.
143
+ // -------------------------------------------------------------------------
144
+ /** Report a named condition that changes what is true without ending anything. */
145
+ degrade(kind, detail) {
146
+ const degrade = { kind, detail, at: this.#clock() };
147
+ this.#degrades.push(degrade);
148
+ for (const listener of this.#degradeListeners) {
149
+ try {
150
+ listener(degrade);
151
+ }
152
+ catch {
153
+ // Terminal by design: this is the failure channel, so a listener of it that throws has
154
+ // nowhere further to be reported without recursing. Every other fan-out reports here.
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Wait until the agent has reported itself, so the id and the version receipt are known.
160
+ *
161
+ * This only completes once a turn has been queued. Awaiting it before prompting waits forever,
162
+ * because the turn is what makes the agent initialize — see this file's header. A caller that
163
+ * wants both usually wants `prompt()` first and this second.
164
+ *
165
+ * The timeout is not optional comfort: a start that never completes is the worst failure here —
166
+ * no error, no message, and a caller waiting on a process that may not even be running.
167
+ */
168
+ whenLive(timeoutMs) {
169
+ if (this.#facts !== null)
170
+ return Promise.resolve(ok(this.#facts));
171
+ if (this.#ended !== null) {
172
+ return Promise.resolve(refuse('session-spawn-failed', `the session ended before reporting itself (${this.#ended.cause}: ${this.#ended.detail})`));
173
+ }
174
+ return new Promise((resolve) => {
175
+ let settled = false;
176
+ const settle = (result) => {
177
+ if (settled)
178
+ return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ resolve(result);
182
+ };
183
+ // Ref'd, for the same reason as the gate's deadline (`gate/gate.ts`): this timer is the
184
+ // property. An `unref()`d timer would let the event loop drain in the one state where the
185
+ // timeout is needed — a host waiting on an agent that will never speak, with nothing else
186
+ // pending — so the timer would never fire and the wait would hang forever, which is the exact
187
+ // behaviour the timeout exists to prevent.
188
+ //
189
+ // `settle` clears the timer on every path, so a session that does report itself pays nothing.
190
+ const timer = setTimeout(() => {
191
+ const detail = `the agent did not report itself within ${timeoutMs}ms`;
192
+ this.#finish('start_timed_out', detail);
193
+ settle(refuse('session-spawn-failed', detail));
194
+ }, timeoutMs);
195
+ this.#liveWaiters.push(settle);
196
+ });
197
+ }
198
+ async #pump() {
199
+ try {
200
+ for await (const message of this.#process.messages) {
201
+ const facts = readInitFacts(message);
202
+ if (facts !== null && this.#facts === null)
203
+ this.#adopt(facts);
204
+ for (const listener of this.#messageListeners) {
205
+ try {
206
+ listener(message);
207
+ }
208
+ catch (error) {
209
+ // A subscriber's bug is not a process outage. The pump keeps reading, the other
210
+ // subscribers keep receiving, and the failure is named for what it is — under its own
211
+ // kind, so it cannot impersonate the process dying.
212
+ this.degrade('subscriber_failed', `an onMessage subscriber threw: ${describe(error)}`);
213
+ }
214
+ }
215
+ }
216
+ this.#finish('process_ended', 'the agent process ended');
217
+ }
218
+ catch (error) {
219
+ // The stream is what this cause is for, but it is not the only thing that can land here.
220
+ // `readInitFacts` runs above, outside the per-listener guard, so a bug in this package's own
221
+ // reader arrives here too and is reported as the agent process dying. That misattribution is
222
+ // the real residual — the throw is loud, and it is loud under the wrong name. It is named here
223
+ // rather than caught separately because a reader fault genuinely does end the session, and
224
+ // inventing a second end cause would tell an operator less, not more: what they need is the
225
+ // message text in the detail, which `describe` carries.
226
+ this.#finish('process_failed', describe(error));
227
+ }
228
+ }
229
+ #adopt(facts) {
230
+ this.#facts = {
231
+ id: facts.sessionId,
232
+ // The agent's own cwd is preferred over the one that was requested: if they ever disagree,
233
+ // the one the agent is actually in is the true answer.
234
+ cwd: facts.cwd === '' ? this.#cwd : facts.cwd,
235
+ startedAt: this.#clock(),
236
+ cliVersion: facts.cliVersion,
237
+ model: facts.model,
238
+ permissionMode: facts.permissionMode,
239
+ apiKeySource: facts.apiKeySource,
240
+ tools: facts.tools,
241
+ skills: facts.skills,
242
+ plugins: facts.plugins,
243
+ capabilities: facts.capabilities,
244
+ mcpServers: facts.mcpServers,
245
+ workspaceTrust: this.#trust,
246
+ };
247
+ this.#state = 'live';
248
+ // Keyed by the agent's own id only now, because only now is there one.
249
+ this.#onLive(this);
250
+ while (this.#liveWaiters.length > 0)
251
+ this.#liveWaiters.shift()?.(ok(this.#facts));
252
+ }
253
+ #finish(cause, detail) {
254
+ if (this.#state === 'ended')
255
+ return;
256
+ this.#state = 'ended';
257
+ this.#ended = { cause, detail, at: this.#clock() };
258
+ // Closed on every ending path, not only the deliberate ones — close() is idempotent, and a
259
+ // pump-failure path that skipped it would leave a live process with nobody reading its stream.
260
+ this.#process.close();
261
+ this.#onReleased(this);
262
+ for (const listener of this.#endListeners) {
263
+ try {
264
+ listener(this.#ended);
265
+ }
266
+ catch (error) {
267
+ // An end listener that throws must not take the host down with an unhandled rejection —
268
+ // this fan-out runs inside the pump's own promise, which nothing awaits by design.
269
+ this.degrade('subscriber_failed', `an onEnd listener threw: ${describe(error)}`);
270
+ }
271
+ }
272
+ // A session that ends before reporting itself must release anyone waiting on it, with the
273
+ // reason. Leaving them pending is the same silent hang the timeout exists to prevent.
274
+ const ending = this.#ended;
275
+ while (this.#liveWaiters.length > 0) {
276
+ this.#liveWaiters.shift()?.(refuse('session-spawn-failed', `the session ended before reporting itself (${ending.cause}: ${ending.detail})`));
277
+ }
278
+ // Nothing keyed by a session may outlive it. A long-running host that keeps listeners alive per
279
+ // dead session dies of something no single session can be blamed for.
280
+ this.#messageListeners.clear();
281
+ this.#endListeners.clear();
282
+ this.#degradeListeners.clear();
283
+ }
284
+ }
285
+ function describe(error) {
286
+ return error instanceof Error ? error.message : String(error);
287
+ }
288
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The environment a spawned agent process receives — an allow-list, not a strip-list.
3
+ *
4
+ * The Agent SDK's `env` option replaces the subprocess environment rather than merging it, and when
5
+ * omitted the subprocess inherits `process.env` whole. So the default posture is full inheritance,
6
+ * and the only way to hold a boundary is to state what crosses it. A strip-list can only ever remove
7
+ * what someone thought of; this one is "absent unless declared".
8
+ *
9
+ * The failures this prevents are all silent, which is why the set is pinned key-by-key rather than
10
+ * eyeballed. Three are recorded, and two have been observed in a live host:
11
+ * - `CLAUDE_CODE_CHILD_SESSION` inherited: the CLI treats the spawn as a nested session and never
12
+ * persists its transcript, while the Stop path still reports the path it never wrote.
13
+ * - `CLAUDE_EFFORT` inherited: the host's own effort silently becomes every spawn's effort.
14
+ * - a host secret inherited: every subprocess the agent runs authenticates as the host.
15
+ *
16
+ * How the declared set was derived — read this before adding or removing a key.
17
+ * Not copied from any single product's list. A list derived from a capture of one product's
18
+ * machines declares that product's own service-discovery variables; carrying those here is how a
19
+ * general-purpose package quietly becomes a single-product one. The test that gates this file runs
20
+ * a closed diff over two corpora — a live host capture and a production capture from a different
21
+ * machine — and every key in either is declared or carries a written reason for being dropped. Two
22
+ * corpora rather than one because a single capture under-includes whatever that box happens to
23
+ * lack: proxy variables, `XDG_`, `LC_`. Do not tidy them into one.
24
+ *
25
+ * Product-specific names are not declared here. They come back through `extraAllowedKeys`, which the
26
+ * embedder states for its own deployment — `USERPROFILE` is a primitive, a dashboard endpoint URL
27
+ * is somebody's product.
28
+ *
29
+ * The standing posture, because it decides every close call: over-inclusion is recoverable and
30
+ * visible; under-inclusion breaks agents in ways that read as model failures many sessions later.
31
+ * So a key is declared unless there is a positive reason to drop it — it carries a credential, it is
32
+ * another process's handle, it is one product's configuration, or it actively lies to the child
33
+ * about what it is.
34
+ */
35
+ /** Any environment-shaped map. Deliberately not `NodeJS.ProcessEnv` — nothing here needs a runtime. */
36
+ export type EnvSource = Readonly<Record<string, string | undefined>>;
37
+ export interface SpawnEnvPolicy {
38
+ /**
39
+ * Keys the embedder declares for its own deployment, on top of the general set.
40
+ *
41
+ * This is where a product's own variables belong. Matched case-insensitively like the rest, and
42
+ * it cannot override a denied key — see `DENIED_PATTERNS`.
43
+ */
44
+ readonly extraAllowedKeys?: readonly string[];
45
+ /**
46
+ * Keys the embedder refuses even though they are declared. For a host that knows a name in the
47
+ * general set is a secret on its machines.
48
+ */
49
+ readonly extraDeniedKeys?: readonly string[];
50
+ /**
51
+ * Literal values set on the spawn, applied after filtering.
52
+ *
53
+ * Setting a value is not the same act as inheriting one: a value stated here is a decision with an
54
+ * author, and it overrides whatever the host env happened to hold. That asymmetry is deliberate —
55
+ * a variable this package refuses to inherit can still be set, but only by naming it and its value.
56
+ */
57
+ readonly extraEnv?: Readonly<Record<string, string>>;
58
+ }
59
+ /** Every key this package declares for a spawned agent, in declaration order. For the pin. */
60
+ export declare const DECLARED_EXACT_KEYS: readonly string[];
61
+ export declare const DECLARED_PREFIXES: readonly string[];
62
+ export declare const DECLARED_SUFFIXES: readonly string[];
63
+ export declare const STRIPPED_HOST_SESSION_KEYS: readonly string[];
64
+ /** Is this key declared for spawned agents? Exported so the gate test can drive it directly. */
65
+ export declare function isDeclaredSpawnEnvKey(key: string, policy?: SpawnEnvPolicy): boolean;
66
+ /**
67
+ * Strip `user:pass@` from a proxy URL, keeping the proxy reachable.
68
+ *
69
+ * The key stays declared and the credential leaves the value: proxy URLs routinely embed
70
+ * credentials, so declaring the key was declaring a secret — but dropping the key breaks every
71
+ * spawn behind a corporate proxy, which trades an availability failure for a confidentiality one.
72
+ * A value that does not parse as a URL (`NO_PROXY` is a comma-separated host list) is returned
73
+ * verbatim: a redaction must never corrupt a value it does not understand, because a mangled
74
+ * no-proxy list silently changes which hosts bypass the proxy.
75
+ */
76
+ export declare function redactProxyCredential(value: string): string;
77
+ /**
78
+ * The environment for a spawned agent: the declared subset of `baseEnv`, then the embedder's
79
+ * literals, then the host-session strip.
80
+ *
81
+ * The strip runs last on purpose. It is the one rule that cannot be widened away, so the property
82
+ * "a spawned agent is never told it is a continuation of this process" holds no matter what a later
83
+ * change does to the declared set or what an embedder passes.
84
+ *
85
+ * Returns a fresh object every call and never mutates `baseEnv`.
86
+ */
87
+ export declare function composeSpawnEnv(baseEnv: EnvSource, policy?: SpawnEnvPolicy): Record<string, string>;
88
+ //# sourceMappingURL=spawn-env.d.ts.map