@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,160 @@
1
+ # The session state machine
2
+
3
+ The declared state model every other part emits into: caused transitions, open entries with ages,
4
+ the durable append-only log they land in, and the coverage tables that make "every hook wired or
5
+ declined" auditable. The package overview is [architecture.md](architecture.md); how transitions
6
+ ride the wire is in [protocol.md](protocol.md).
7
+
8
+ ## Two machines, two altitudes
9
+
10
+ Periscope's machine speaks the SDK's session lifecycle and lives in memory, per host, one per
11
+ session. A controller's machine speaks the controller's own vocabulary and consumes Periscope's
12
+ transitions as input. The host emits valid, caused transitions and detects unpaired entries; it
13
+ never learns a controller's word and never derives a controller's state. A consumer that finds
14
+ itself adding a status field has found a missing state in this model, not a local column.
15
+
16
+ ## The vocabulary (`src/state/model.ts`)
17
+
18
+ Seven states: `spawning`, `ready`, `working`, `idle`, `errored`, `interrupted`, `ended`. `ready`
19
+ means the agent reported itself (`system/init`, the only source of the session id, the CLI version,
20
+ the model, the tool, skill and plugin inventory and `apiKeySource`). Init arrives mid-turn, so a
21
+ real trace reads `working` then `ready` then `working`: `UserPromptSubmit` fires before
22
+ `system/init`. A session created but never prompted sits in `spawning` until the start timeout;
23
+ that missing `ready` is what makes the emits-nothing-until-prompted behaviour legible instead of a
24
+ hang.
25
+
26
+ Two notes for a consumer of the `ended` edge:
27
+
28
+ - A refused open is emitted as `spawning` to `ended` with cause `{ kind: 'refusal', event: <reason> }`.
29
+ No machine ever existed, so it is the one transition the host composes by hand: `sessionId: null`,
30
+ `correlationId` the controller's handle, `seq: 1`, the refusal's detail carried whole. It rides
31
+ the ordinary `StateTransitionUpdate` lane.
32
+ - The host's `ended` is the controller's fact. A controller ends its own record of the session on
33
+ it, the refused open included, and never waits for a second signal.
34
+
35
+ Six activity kinds, structured `{ kind, name }` and never composed strings: `tool`, `subagent`,
36
+ `permission`, `elicitation`, `requesting`, `compacting`. `formatActivity()` makes the display
37
+ string; the structure is the truth. There is no activity for "escalated to a human": that is a
38
+ judgement the host cannot make.
39
+
40
+ `SessionLifecycle` (`provisioning`, `live`, `ended`, in `src/sessions/session.ts`) is an input to
41
+ this machine; lifecycle changes arrive as `cause.kind: 'process'`. It is not a peer model, and the
42
+ two are not symmetric.
43
+
44
+ ## The transition record
45
+
46
+ ```
47
+ { sessionId, seq, at, from, to, activity,
48
+ cause: { kind, event, detail }, // kind: hook | sdk-message | control | timeout | process | refusal
49
+ where: { worktree, branch, cwd } }
50
+ ```
51
+
52
+ - `cause` is compile-closed and required. `CauseEvent` is the union of the hook events, the message
53
+ discriminators, `CONTROL_EVENTS`, `PROCESS_EVENTS`, `TIMEOUT_EVENTS` and `RefusalReason`; a
54
+ transition that cannot name its cause does not compile.
55
+ - `refusal` is the sixth kind because a denial and an outage cannot be told apart with five: a deny
56
+ is `control/permission_denied`, an outage is `refusal/permission-decision-unavailable`, different
57
+ in kind and in event, never only in prose. `detail` is for people and is never branched on.
58
+ - `kind` and `event` are validated independently, never as a pair: `nameable()`
59
+ (`src/state/machine.ts`) accepts `{ kind: 'hook', event: 'permission_denied' }` although it is
60
+ incoherent. Coherence is the author's job, stated at `CONTROL_EVENTS`.
61
+ - `where` carries forward across transitions and updates on `CwdChanged`; `src/host/git-facts.ts`
62
+ reads worktree and branch read-only and understands the linked-worktree `.git` file. The
63
+ `spawning` transition's `where.cwd` is the workspace the session got, and that property is
64
+ pinned.
65
+
66
+ ## The state is the record
67
+
68
+ `SessionStateMachine` has no backing fields: `state`, `activity`, `sessionId` and `where` all derive
69
+ from the last recorded transition, and the commit has one assignment, no branches and one exit
70
+ (`pins/state-record.test.ts`). A path that skips the record therefore also fails to change the
71
+ state, so suppression is self-defeating instead of silent. Two laws follow: a state record and a
72
+ control signal never share a guard, and no state is inferred from the absence of something.
73
+
74
+ `record()` never throws, deliberately. Every hook handler is wrapped in `try/catch` (a throwing hook
75
+ is fail-open in the SDK), so a throw from the emitter would be swallowed by the safety wrapper and
76
+ the transition would vanish. Loud means refused, counted and reported on `onRejected`; emission
77
+ rides outside the deny path's wrapper.
78
+
79
+ ## Open entries are aged, never silently reconciled
80
+
81
+ Entry operations are `open`, `close`, `background` and `abandon-open`. Every entry gets an exit or
82
+ is surfaced open with an age. A session sitting in `tool:Bash` for forty minutes is the most useful
83
+ thing this layer can say, and cleanup may mark an entry abandoned but never erase that it happened.
84
+ Backgrounding: an entry moves to the background set at the moment it backgrounds, with a cause
85
+ (`task_updated`'s `is_backgrounded`), still owes an exit (`task_notification`), and a session with
86
+ only background work is `idle`; it can take a new prompt while a build runs.
87
+
88
+ For a denied tool call `PostToolUse` does not fire; `PostToolBatch` is the entry backstop (its
89
+ `tool_calls` includes denied ids) and it records "closed by the end of its batch". It never says
90
+ denied. The denial itself is the gate's own `control/permission_denied` transition
91
+ ([gate.md](gate.md)).
92
+
93
+ A transition recorded before `system/init` has `sessionId: null`, and a `SessionFrame` requires a
94
+ non-empty id; the frame's `sessionId` is the controller's handle for exactly this reason. The one
95
+ pre-init transition that rides the wire is the refused open above: the frame carries the
96
+ controller's handle, the body carries `sessionId: null`.
97
+
98
+ ## Where a transition ends up
99
+
100
+ Three destinations, not interchangeable:
101
+
102
+ | Destination | What it holds | The rule |
103
+ | ----------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `TransitionStore` (`src/state/store.ts`) | A bounded in-memory ring, rejections included | It drops its oldest once a session outruns the window; never the record of anything |
105
+ | The wire | Every recorded transition, whichever lane produced it | `forwardSession` subscribes to `machine.onTransition`, so a hold, a deny, an outage and a local refusal all reach the controller |
106
+ | The durable log (`src/persistence/transition-log.ts`) | The keepable record | Append-only; the single point of truth for locally-decided refusals |
107
+
108
+ Append-only is load-bearing. The ring overwrites and the wire is delivery, not storage, so the
109
+ durable log is the only place a path-jail denial, a boundary command, a credential-path read or an
110
+ unconvertible tool descriptor survives at all. A tidy-up here does not lose a log line; it loses
111
+ the only evidence that the gate ever ran.
112
+
113
+ - `markAbandoned` appends, never edits. It writes a new record carrying the reason; a reconciler
114
+ that quietly closed the entry would destroy the forty-minute sentence above.
115
+ - The cause survives the round trip or the read refuses. The encoder writes all three parts and the
116
+ decoder rejects an entry that lost any of them rather than substituting a plausible default.
117
+ - Deltas never enter it (declared in `MESSAGE_ROUTING`), and that half of the replay rule points
118
+ the opposite way from retransmission ([protocol.md](protocol.md)). Both halves are pinned.
119
+
120
+ ## The coverage tables
121
+
122
+ `src/state/coverage.ts` lists every hook event and every `SDKMessage` discriminator as wired or
123
+ declined, each row carrying its reason. Count the tables; never carry the numbers. They are kept
124
+ honest three ways: `satisfies Record<HookEvent, CoverageRow>` breaks the build when the SDK adds an
125
+ event; `pins/hook-coverage.test.ts` parses the unions out of the installed `sdk.d.ts` itself, with a
126
+ positive control on the parser; and `observationHooks()` derives its registrations from the table,
127
+ pinned to match. An event absent from the table is a gap, not a default.
128
+
129
+ The table answers "does this move the machine". `MESSAGE_ROUTING` (`src/control/stream-routing.ts`)
130
+ answers "does this go on the wire". They disagree in both directions on purpose (`assistant` is
131
+ declined here and forwarded there; `session_state_changed` is wired here and declined there) and
132
+ must never be merged.
133
+
134
+ The runtime wins over the types. Four wired events are measured not to fire under an SDK-hosted
135
+ session: `SessionStart` (take the start receipt from `system/init`), `PermissionRequest` and
136
+ `PermissionDenied` (a hook-authored deny blocks while both stay silent), and
137
+ `system/session_state_changed` (`Stop` and `result` are the observed turn boundaries). The rows stay
138
+ wired, because "wired and not observed" is a question and "declined" closes one, and each says
139
+ "measured not to fire" at the row. Re-measure before relying on any of the four.
140
+
141
+ ## The reporter is not a roster
142
+
143
+ `SessionStateReporter` enumerates this host's sessions with state, activity and open-entry ages:
144
+ the raw material a controller's roster aggregates. It has no predicate, no search and no notion of
145
+ which sessions are interesting; a host that judges gets rebuilt per product. Do not grow it one.
146
+
147
+ ## Where things live
148
+
149
+ | Path | What |
150
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
151
+ | `src/state/model.ts` | States, activities, the closed cause vocabularies, `SessionTransition`, `OpenEntry` |
152
+ | `src/state/machine.ts` | `SessionStateMachine`: derive from the last transition; `record`, `onTransition`, `onRejected` |
153
+ | `src/state/observer.ts` | The one translation site from SDK hooks and messages to transition requests |
154
+ | `src/state/store.ts` | `TransitionStore`, the bounded in-memory ring that retains rejections and drops its oldest |
155
+ | `src/persistence/transition-log.ts` | The durable append-only log; `markAbandoned`; the cause-survives-or-refuse decoder |
156
+ | `src/state/coverage.ts` | `HOOK_COVERAGE` and `MESSAGE_COVERAGE`, wired or declined with reasons |
157
+ | `src/state/reporter.ts` | Enumeration of this host's sessions |
158
+ | `src/host/hooks.ts` | `observationHooks()`, derived from the table, and `mergeHooks()` |
159
+ | `src/host/git-facts.ts` | Read-only `where`, including the linked-worktree `.git` file |
160
+ | `src/pins/state-record.test.ts` | The no-suppressing-branch structural pin |
@@ -0,0 +1,141 @@
1
+ # Examples
2
+
3
+ Three things live here: **the smallest controller that completes a link**, **a reference
4
+ implementation of a controller**, and **a proof that this package is usable by somebody who did not
5
+ write it**, driven against a real agent.
6
+
7
+ Both consume Periscope by **package name** — `@naswerks/periscope` and `@naswerks/periscope/protocol` — through the
8
+ `exports` map, exactly as `npm install` resolves it. Not one deep relative import. A deep import
9
+ would prove the files exist; these prove the published entry points are sufficient.
10
+
11
+ ## Running them
12
+
13
+ Node 22.18 or later strips types natively, so there is no build step for the examples themselves.
14
+
15
+ ```
16
+ npm run build # the package's own dist/, which the examples import
17
+ npm run typecheck:examples
18
+ node examples/minimal-controller/controller.ts # the two URLs a host needs, then denies every tool
19
+ node examples/test-controller/serve.ts # the reference, with a pair code printed to redeem
20
+ node examples/parallel-run-proof/run.ts # the proof; drives a real agent
21
+ ```
22
+
23
+ `test-controller/serve.ts` is the local rehearsal of the pairing walk-through: it prints the exact
24
+ `periscope pair <code> --controller <origin>` line, and a host paired to it shows up in its log.
25
+
26
+ The proof drives a **real agent** and costs real money. `PERISCOPE_PROOF_OUT=<path>` writes the
27
+ whole run to a file.
28
+
29
+ ## `minimal-controller/`
30
+
31
+ The smallest controller that completes a link: it welcomes the host, answers its heartbeat, acks
32
+ every frame and denies every tool call. It stays this small on purpose, and the reference controller
33
+ below is where every further behaviour lives. It is `minimal-controller/controller.ts`, verbatim (a
34
+ pin keeps the two identical):
35
+
36
+ ```ts
37
+ // The smallest controller that completes a link: the WebSocket the host dials, answering the hello,
38
+ // the heartbeat and every session frame's ack, and the HTTP endpoint that denies every tool call.
39
+ // Both transports are required; a host with nowhere to send a decision refuses to start.
40
+ import { createServer } from 'node:http';
41
+ import { WebSocketServer, type RawData } from 'ws';
42
+ import { PROTOCOL_VERSION, decode, encode, type ControlPayload } from '@naswerks/periscope/protocol';
43
+
44
+ const text = (data: RawData): string =>
45
+ Buffer.concat(Array.isArray(data) ? data : [Buffer.from(data as ArrayBuffer)]).toString('utf8');
46
+
47
+ new WebSocketServer({ port: 8790, path: '/link' }).on('connection', (socket) => {
48
+ const control = (payload: ControlPayload): void => {
49
+ const frame = encode({ frame: 'control', at: new Date().toISOString(), payload });
50
+ if (frame.ok) socket.send(frame.value);
51
+ };
52
+ socket.on('message', (data) => {
53
+ const frame = decode(text(data));
54
+ if (!frame.ok) return;
55
+ if (frame.value.frame === 'session') {
56
+ control({ kind: 'link_ack', cursors: [{ sessionId: frame.value.sessionId, seq: frame.value.seq }] });
57
+ } else if (frame.value.payload.kind === 'link_hello') {
58
+ control({ kind: 'link_welcome', protocolVersion: PROTOCOL_VERSION, capabilities: [], cursors: [] });
59
+ } else if (frame.value.payload.kind === 'link_ping') {
60
+ control({ kind: 'link_pong', nonce: frame.value.payload.nonce });
61
+ }
62
+ });
63
+ });
64
+
65
+ createServer((_request, response) => {
66
+ response.writeHead(200, { 'content-type': 'application/json' });
67
+ response.end(
68
+ JSON.stringify({ behavior: 'deny', message: 'the minimal controller denies every tool call' }),
69
+ );
70
+ }).listen(8791);
71
+
72
+ console.log(
73
+ 'PERISCOPE_CONTROLLER_URL=ws://127.0.0.1:8790/link PERISCOPE_DECISION_URL=http://127.0.0.1:8791/decisions',
74
+ );
75
+ ```
76
+
77
+ Run it with `node examples/minimal-controller/controller.ts`, export the two URLs it prints, and
78
+ start a host with `periscope`.
79
+
80
+ ## `test-controller/`
81
+
82
+ The reference controller, not a harness: the smallest complete other end of a host. It accepts the
83
+ outbound connection, negotiates the version, answers the heartbeat, acks every frame, answers
84
+ permission escalations over HTTP, serves a couple of tools, receives bulk posts, and renders every
85
+ frame to a log a human can read. Two things a controller in another language copies from it:
86
+
87
+ - **The doors.** `POST /asks/<kind>` for every host-scoped ask (`session_list`, `transcript_list`,
88
+ `transcript_tail`, `workspace_list`, `workspace_release`, `workspace_release_bulk`,
89
+ `host_configure`, `repository_list`, `repository_read`): the JSON body is the ask's members
90
+ without `requestId`, the controller mints one, sends the ask on the host's discovery channel, and
91
+ answers the result payload whole when the frame carrying that `requestId` arrives. An ask the codec
92
+ refuses is answered 400 before a sequence number is spent; a host that does not answer is 504.
93
+ - **Pairing.** `POST /api/periscope/pair-codes` mints a single-use code; `POST /api/periscope/pair`
94
+ with `{ code, machineLabel }` redeems it for `{ hostId, hostCredential, controllerUrl, decisionUrl }`,
95
+ which is what `periscope pair <code> --controller <origin>` expects. Once a machine has paired,
96
+ every WebSocket upgrade presents the paired bearer or is refused with 401, which the host reads as
97
+ `link-unauthorized` and does not retry. Until one has, the door is open, because this reference
98
+ cannot validate a sign-in token without an identity provider; a real controller does.
99
+
100
+ **It keeps one host row and the frames it saw, in memory, and no more.** No roster, no queue, no
101
+ orchestration of its own: what a session means is the embedder's. `src/host/end-to-end.test.ts`
102
+ drives every door, the pair round-trip and a host-scoped ask across a reconnect against a real host.
103
+
104
+ Its permission endpoint is typed by `DecisionRequest` and `Decision` from
105
+ `@naswerks/periscope/protocol`, the same names a controller in another language reproduces from
106
+ [the wire protocol](../docs/protocol.md).
107
+
108
+ ## `parallel-run-proof/`
109
+
110
+ Drives a genuine multi-turn piece of work in a real git worktree and reports its assertions, each
111
+ of them **observed** or **`not exercised` with a reason**. Nothing in it concludes a property from
112
+ reading the code. `permission-mode-probe.ts` beside it is the receipt for what an allowed call
113
+ reaches with and without `grantOnAllow` (`SECURITY.md`, fact 5).
114
+
115
+ ## A controller in another language
116
+
117
+ Nothing here is TypeScript-specific: the wire is [the protocol document](../docs/protocol.md) and
118
+ the vectors under `contracts/wire-vectors/`, and a controller that reads them implements the same
119
+ door in any language. The controller these examples rehearse against is one of two the package is
120
+ measured against; the other is written in C#, in a separate application, and pairs a stock install
121
+ of this package the same way.
122
+
123
+ ## What a controller provides beyond the wire
124
+
125
+ The wire is half of a controller. The other half is stated here because a first implementer meets
126
+ it only by running a host:
127
+
128
+ 1. **Two transports.** Frames ride the WebSocket; a permission escalation arrives as an ordinary
129
+ HTTP `POST` to a URL the host is configured with (`PERISCOPE_DECISION_URL`). Nothing on the wire
130
+ announces that endpoint; [the wire protocol](../docs/protocol.md) states its request and answer.
131
+ 2. **The permission types are the highest-consequence surface.** `@naswerks/periscope/protocol`
132
+ exports `Decision`, `DecisionRequest`, `Decider` and `EscalationTransport`, so a TypeScript
133
+ controller types its decision endpoint without the privileged entry point; a controller in
134
+ another language reproduces the member table in the protocol document.
135
+ 3. **There is no wire lane for registering an in-process tool.** Descriptors are the embedder's
136
+ (`PeriscopeHostOptions.tools`); a controller gives a session its tools through
137
+ `session_new.request.mcpServers`, an HTTP or stdio MCP server declaration.
138
+ 4. **`escalatingDecider` is on the main entry point, not the wire subpath.** The subpath carries
139
+ the types; the function that POSTs a decision request ships from `@naswerks/periscope`, which
140
+ reaches the privileged module. The proof hand-writes an eight-line replacement to record what a
141
+ controller-side implementer actually has to reproduce.
@@ -0,0 +1,38 @@
1
+ // The smallest controller that completes a link: the WebSocket the host dials, answering the hello,
2
+ // the heartbeat and every session frame's ack, and the HTTP endpoint that denies every tool call.
3
+ // Both transports are required; a host with nowhere to send a decision refuses to start.
4
+ import { createServer } from 'node:http';
5
+ import { WebSocketServer, type RawData } from 'ws';
6
+ import { PROTOCOL_VERSION, decode, encode, type ControlPayload } from '@naswerks/periscope/protocol';
7
+
8
+ const text = (data: RawData): string =>
9
+ Buffer.concat(Array.isArray(data) ? data : [Buffer.from(data as ArrayBuffer)]).toString('utf8');
10
+
11
+ new WebSocketServer({ port: 8790, path: '/link' }).on('connection', (socket) => {
12
+ const control = (payload: ControlPayload): void => {
13
+ const frame = encode({ frame: 'control', at: new Date().toISOString(), payload });
14
+ if (frame.ok) socket.send(frame.value);
15
+ };
16
+ socket.on('message', (data) => {
17
+ const frame = decode(text(data));
18
+ if (!frame.ok) return;
19
+ if (frame.value.frame === 'session') {
20
+ control({ kind: 'link_ack', cursors: [{ sessionId: frame.value.sessionId, seq: frame.value.seq }] });
21
+ } else if (frame.value.payload.kind === 'link_hello') {
22
+ control({ kind: 'link_welcome', protocolVersion: PROTOCOL_VERSION, capabilities: [], cursors: [] });
23
+ } else if (frame.value.payload.kind === 'link_ping') {
24
+ control({ kind: 'link_pong', nonce: frame.value.payload.nonce });
25
+ }
26
+ });
27
+ });
28
+
29
+ createServer((_request, response) => {
30
+ response.writeHead(200, { 'content-type': 'application/json' });
31
+ response.end(
32
+ JSON.stringify({ behavior: 'deny', message: 'the minimal controller denies every tool call' }),
33
+ );
34
+ }).listen(8791);
35
+
36
+ console.log(
37
+ 'PERISCOPE_CONTROLLER_URL=ws://127.0.0.1:8790/link PERISCOPE_DECISION_URL=http://127.0.0.1:8791/decisions',
38
+ );
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What actually happens to a tool call under each decision, measured side by side.
3
+ *
4
+ * Run it: node examples/parallel-run-proof/permission-mode-probe.ts
5
+ *
6
+ * It exists because two different causes produce the same visible outcome (the file is not there)
7
+ * and telling them apart is the difference between "the gate works" and "nothing runs anyway":
8
+ *
9
+ * DENIED the controller said no. The tool must not run, and the MODEL must be told why, in the
10
+ * controller's own words.
11
+ * ALLOWED the controller said yes. The tool must run.
12
+ *
13
+ * A probe that only checked whether the file appeared would score both as a pass for the deny case
14
+ * and would never notice that the allow case fails for a reason that has nothing to do with the
15
+ * gate. So both are run and both tool results are printed verbatim; the reader compares.
16
+ *
17
+ * It composes the parts by hand rather than calling `composeSession`, so nothing here can be an
18
+ * artifact of the composer. This is the wiring the package documents, typed out.
19
+ */
20
+ import { existsSync, mkdtempSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ import type { Decision } from '@naswerks/periscope';
25
+ import {
26
+ SessionObserver,
27
+ SessionRegistry,
28
+ SessionStateMachine,
29
+ mergeHooks,
30
+ observationHooks,
31
+ permissionHooks,
32
+ readTurnSpend,
33
+ systemClock,
34
+ systemTicker,
35
+ } from '@naswerks/periscope';
36
+
37
+ interface Case {
38
+ readonly label: string;
39
+ readonly decision: Decision;
40
+ /** Whether the gate's decision is made EFFECTIVE. The third case is the whole point. */
41
+ readonly grantOnAllow: boolean;
42
+ }
43
+
44
+ const CASES: Case[] = [
45
+ {
46
+ label: 'DENIED ',
47
+ decision: { behavior: 'deny', message: 'the probe controller refuses this write' },
48
+ grantOnAllow: false,
49
+ },
50
+ // The residual, kept as a permanent case. An embedder who composes by hand and does not grant
51
+ // gets a gate that cannot say yes, and nothing tells them: the tool simply does not run.
52
+ { label: 'ALLOWED-SILENT ', decision: { behavior: 'allow' }, grantOnAllow: false },
53
+ { label: 'ALLOWED-GRANTED', decision: { behavior: 'allow' }, grantOnAllow: true },
54
+ ];
55
+
56
+ let spentUsd = 0;
57
+
58
+ for (const one of CASES) {
59
+ const cwd = mkdtempSync(join(tmpdir(), 'periscope-permission-probe-'));
60
+
61
+ const machine = new SessionStateMachine({
62
+ where: { cwd, worktree: null, branch: null, unknownReason: 'a probe workspace is not a repository' },
63
+ clock: systemClock,
64
+ ticker: systemTicker,
65
+ });
66
+ const observer = new SessionObserver(machine);
67
+ const causes: string[] = [];
68
+ machine.onTransition((transition) => causes.push(`${transition.cause.kind}/${transition.cause.event}`));
69
+
70
+ const registry = new SessionRegistry({
71
+ baseEnv: process.env,
72
+ homeDir: process.env['USERPROFILE'] ?? process.env['HOME'] ?? '',
73
+ startTimeoutMs: 120_000,
74
+ });
75
+
76
+ const opened = await registry.open({
77
+ cwd,
78
+ prompt:
79
+ `Use the Write tool once to write the single word pomegranate to ${join(cwd, 'probe.txt').replace(/\\/g, '/')}. ` +
80
+ 'If it is refused, do not retry and do not use any other tool — just say REFUSED. Then stop.',
81
+ hooks: mergeHooks(
82
+ observationHooks({ observer }),
83
+ permissionHooks({
84
+ sessionKey: `probe-${one.label}`,
85
+ decide: () => Promise.resolve(one.decision),
86
+ onOutcome: () => undefined,
87
+ grantOnAllow: one.grantOnAllow,
88
+ }),
89
+ ),
90
+ });
91
+
92
+ if (!opened.ok) {
93
+ console.log(`${one.label} THE SESSION DID NOT START: ${opened.refusal.reason} ${opened.refusal.detail}`);
94
+ continue;
95
+ }
96
+
97
+ const session = opened.value;
98
+ let toolResult = '(no tool result was seen)';
99
+
100
+ await new Promise<void>((resolve) => {
101
+ const timer = setTimeout(resolve, 150_000);
102
+ session.onMessage((message) => {
103
+ const seen = message as unknown as { type: string; message?: { content?: unknown } };
104
+ // A `user` message on the output stream IS the tool result. It is the only place the CLI says
105
+ // why a tool did not run, and the only place the model's own explanation can be read.
106
+ if (seen.type === 'user') toolResult = JSON.stringify(seen.message?.content);
107
+ if (seen.type === 'result') {
108
+ spentUsd += readTurnSpend(message)?.totalCostUsd ?? 0;
109
+ clearTimeout(timer);
110
+ resolve();
111
+ }
112
+ });
113
+ });
114
+
115
+ console.log(
116
+ `${one.label} permissionMode reported by the agent: ${session.facts?.permissionMode ?? '(unknown)'}`,
117
+ );
118
+ console.log(`${one.label} probe.txt on disk: ${existsSync(join(cwd, 'probe.txt'))}`);
119
+ console.log(
120
+ `${one.label} the trace says: ${[...new Set(causes)].filter((c) => !c.startsWith('sdk-message')).join(', ')}`,
121
+ );
122
+ console.log(`${one.label} TOOL RESULT: ${toolResult.slice(0, 600)}`);
123
+ console.log('');
124
+
125
+ session.stop('the probe finished');
126
+ }
127
+
128
+ console.log(`MEASURED SPEND: $${spentUsd.toFixed(4)} — read with readTurnSpend, never computed`);
129
+ process.exit(0);