@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semantic versioning: patch
5
+ for no wire or API change, minor for a protocol bump (the previous version stays supported for one
6
+ minor), an agent SDK pin bump or an additive API, major for a wire change outside the window or a
7
+ removed export. The package version and the wire protocol version are separate numbers.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] - 2026-09-17
12
+
13
+ Initial public release.
14
+
15
+ - One outbound WebSocket, commands only. Bulk content leaves by HTTP on a lane the controller names.
16
+ Sequence numbers are dense per session per direction, minted at the first write; frames are
17
+ retained until acknowledged and replayed from the controller's cursors after a reconnect.
18
+ - A fail-closed permission gate on every session: a `PreToolUse` hook with no permission prompt
19
+ anywhere, local refusals for path escapes, credential reads and unrecognised git verbs, then an
20
+ HTTP decision endpoint for everything else; no answer is a refusal.
21
+ - Workspaces: a plain directory or a git worktree per session, a branch scheme, list and release
22
+ over the wire, and a repository read door jailed to the root and to the protected set.
23
+ - Identity: a generic OIDC client (authorization code with PKCE over loopback; device code opt-in)
24
+ or a paired machine credential shaped `p1.<hostId>.<secret>`, presented on the upgrade, the
25
+ decision POST and the bulk POST.
26
+ - The `periscope` binary: `serve`, `login`, `pair`, `config`, `status`, `version`.
27
+ - The contracts a second implementer proves against: the wire vectors under
28
+ `contracts/wire-vectors/`, the public-API snapshot, and the installed agent SDK's type hash.
29
+
30
+ Protocol v9; the negotiated window opens at v9. Tested against `@anthropic-ai/claude-agent-sdk`
31
+ 0.3.220 (Claude Code 2.1.220) on ubuntu and windows with node 22 and 24: 1485 tests, 1462 passing,
32
+ 0 failing, 23 skipped on every leg (the skips are the live probes and one platform-only case),
33
+ measured by this repository's CI on the commit `v1.0.0` names.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Sloma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # Periscope
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40naswerks%2Fperiscope.svg)](https://www.npmjs.com/package/@naswerks/periscope)
4
+ [![ci](https://github.com/naswerks/periscope/actions/workflows/ci.yml/badge.svg)](https://github.com/naswerks/periscope/actions/workflows/ci.yml)
5
+ [![licence: MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)
6
+ [![node >= 22](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](package.json)
7
+
8
+ Self-hosted runner for agent sessions: dials out to your controller, then spawns, gates, observes,
9
+ prompts and streams. It runs where the code is; the controller runs wherever you put it.
10
+
11
+ ```sh
12
+ npm install -g @naswerks/periscope # the host, on the machine that runs sessions
13
+ npm install @naswerks/periscope # the library: wire types, codec, and the host as a module
14
+ ```
15
+
16
+ The process on your machine dials out to the controller and takes its orders from there. What it
17
+ hosts is a live conversation you can steer mid-turn, not a job you collect at the end. It opens no
18
+ port and carries no opinion about what a session _means_: it emits what happened, and the
19
+ controller decides what to do about it. The agent it hosts is Claude Code, through the Claude Agent
20
+ SDK, behind a seam that names no agent on the wire.
21
+
22
+ ## Run a host
23
+
24
+ You have a controller's address and a pair code from whoever runs it.
25
+
26
+ ```sh
27
+ npm install -g @naswerks/periscope
28
+ npm install -g @anthropic-ai/claude-code && claude auth login
29
+ periscope pair <code> --controller https://controller.example --label "build box"
30
+ periscope
31
+ ```
32
+
33
+ The second line signs the agent in, once, as the OS user that runs the host (`claude auth status`
34
+ says whether it already is): the host runs the Claude Code CLI headless, and it authenticates
35
+ through the credential under `~/.claude` that `claude auth login` writes. `ANTHROPIC_API_KEY` in
36
+ your shell is not inherited by a session.
37
+
38
+ The third line answers with the host's new id and writes the credential and the two addresses the
39
+ host dials, so nothing else needs setting:
40
+
41
+ ```
42
+ paired as ph-8cb226ae…; credential written to ~/.periscope/paired-credential.json
43
+ this host now dials with the paired credential - the sign-in token expiry no longer applies to it
44
+ PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL written to the config file - serve needs nothing else
45
+ ```
46
+
47
+ The fourth starts the host, in the foreground, until you stop it:
48
+
49
+ ```
50
+ [host] periscope 1.0.0 · host ph-8cb226ae… (paired; configured build-box) · credential paired · workspace none · config file ~/.periscope/config.json
51
+ [credential] paired as ph-8cb226ae… - the paired credential is presented on every dial
52
+ [link] idle -> connecting (start_requested)
53
+ [link] connecting -> open (socket_connected)
54
+ [link] open -> accepted (hello_completed) — protocol v9
55
+ ```
56
+
57
+ `periscope status` prints the same posture from any terminal and never dials. A controller on a
58
+ development certificate (`https://localhost:…`) is refused by Node until Node is pointed at that
59
+ certificate: [configuration](docs/configuration.md#the-two-addresses).
60
+
61
+ To give sessions a repository and a git worktree each, from the terminal or from the controller, to
62
+ run under a supervisor, or to set anything by hand: [configuration](docs/configuration.md). To pair
63
+ with a signed-in identity instead of a machine credential: [identity](docs/identity.md).
64
+
65
+ ## Write a controller
66
+
67
+ A controller serves two transports: the WebSocket the host dials, and an HTTP endpoint the host
68
+ POSTs each permission decision to. The second is easy to miss, because nothing on the wire announces
69
+ it. `@naswerks/periscope/protocol` ships the wire types and the codec without anything that can
70
+ reach a process or a disk; `contracts/wire-vectors/` is the same contract as bytes, for a controller
71
+ in any language.
72
+
73
+ ```sh
74
+ git clone https://github.com/naswerks/periscope && cd periscope && npm ci && npm run build
75
+ node examples/test-controller/serve.ts
76
+ ```
77
+
78
+ That runs the reference controller and prints the exact `periscope pair` line that redeems a code
79
+ against it. [The wire protocol](docs/protocol.md) is the contract: the envelope, the sequence rules,
80
+ the handshake, pairing, the decision endpoint's request and answer, and a checklist of what a
81
+ controller owes. [`examples/`](examples/README.md) holds the smallest controller that completes a
82
+ link (forty lines) and the reference.
83
+
84
+ ## How it works
85
+
86
+ **One socket out, nothing in.** The host opens a single WebSocket to the controller and keeps it
87
+ alive: a heartbeat both ways, jittered backoff, a bounded offline queue. Frames carry commands and
88
+ facts (every state transition the machine records rides the wire); bulk content leaves by an HTTP
89
+ POST the controller asks for. Sequence numbers are dense per session per direction, so a reconnect
90
+ replays exactly what was missed. [protocol](docs/protocol.md)
91
+
92
+ **The gate fails closed.** There is no permission prompt: the host runs the agent headless, does not
93
+ pass `--dangerously-skip-permissions`, and registers a `PreToolUse` hook on every session that is
94
+ the only path to a yes. That is a stricter gate than the prompt, not a weaker one: path escapes,
95
+ credential reads and unrecognised git verbs are refused locally before the controller is asked,
96
+ everything else is the controller's decision, and no answer is a refusal. What a paired controller
97
+ can reach on the machine is stated in [`SECURITY.md`](SECURITY.md); read it before you install this.
98
+ [gate](docs/gate.md)
99
+
100
+ **Workspaces are the host's.** A session runs in the directory the controller names, or in a plain
101
+ directory per workspace key, or in a linked git worktree of a repository on its own branch, and the
102
+ controller can list and release them over the wire. [configuration](docs/configuration.md)
103
+
104
+ **Identity is paired or signed in.** A paired machine credential has no clock and dies only when the
105
+ controller revokes it; a signed-in user's token goes through a generic OIDC client with no provider
106
+ baked in. The agent's own sign-in is a separate credential. [identity](docs/identity.md)
107
+
108
+ **The session is a state machine.** Every message, hook and gate outcome is a recorded transition
109
+ with a cause from a closed vocabulary, so an unattended night is readable afterwards.
110
+ [state machine](docs/state-machine.md)
111
+
112
+ ## Does it work, and against what
113
+
114
+ `npm test` is a clean build then `node --test` over the compiled output; CI runs it on
115
+ `{ubuntu-latest, windows-latest}` x node `{22, 24}` on every change, ratchets line coverage against
116
+ `coverage.floor`, packs the tarball and installs it into an empty project, and runs mutation testing
117
+ weekly. Both operating systems are load-bearing: Windows is where `USERPROFILE`, case-insensitive
118
+ env matching and path handling are observable, and Linux is the only place POSIX file modes mean
119
+ anything. The figure for a release is in [`CHANGELOG.md`](CHANGELOG.md).
120
+
121
+ | | |
122
+ | -------------------------------- | ------------------------------------- |
123
+ | `@anthropic-ai/claude-agent-sdk` | **0.3.220**, pinned exactly, no caret |
124
+ | Claude Code CLI | **2.1.220** (bundled with that SDK) |
125
+ | Node | 22 or later; CI proves 22 and 24 |
126
+
127
+ The SDK is pre-1.0, so the SHA-256 of its installed type definitions is kept in
128
+ `contracts/sdk.sha256` and CI fails on any drift; a bump cannot land without someone reading what
129
+ changed. The package follows semantic versioning; the wire protocol version is a separate number
130
+ with its own window ([versioning](CONTRIBUTING.md#versioning)).
131
+
132
+ ## Naming
133
+
134
+ Method and field naming follows the [Agent Client Protocol](https://agentclientprotocol.com) (Zed
135
+ Industries, Apache-2.0): `session/new`, `session/prompt`, `session/cancel`, `session/update`, its
136
+ camelCase keys and snake_case discriminators. This is not ACP compatibility, and Periscope must not
137
+ be described as ACP-compatible: ACP points the connection inbound, Periscope dials out, and every
138
+ type here is written from scratch. Elsewhere the vocabulary is the SDK's own.
139
+
140
+ ## Documents
141
+
142
+ [architecture](docs/architecture.md) · [protocol](docs/protocol.md) ·
143
+ [configuration](docs/configuration.md) · [identity](docs/identity.md) · [gate](docs/gate.md) ·
144
+ [state machine](docs/state-machine.md) · [`examples/`](examples/README.md) ·
145
+ [`SECURITY.md`](SECURITY.md) · [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`CHANGELOG.md`](CHANGELOG.md) ·
146
+ licence [MIT](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,315 @@
1
+ # Security
2
+
3
+ Periscope runs Claude Code sessions on your machine on behalf of a remote controller. That is a
4
+ serious thing to install, and this document is written to be read _before_ you do. It states the
5
+ posture, what each control covers, and what it does not.
6
+
7
+ ## The permission model
8
+
9
+ Periscope runs the agent headless under the Agent SDK, so there is no interactive prompt. It does
10
+ not pass `--dangerously-skip-permissions` and sets no permission mode; it registers a `PreToolUse`
11
+ hook on every session, and that hook is the only path to a yes: the host turns `grantOnAllow` on
12
+ for every session it opens, so the hook's allow is what lets a tool run and its deny, or its
13
+ silence, is what stops one. Read on its own that looks like the safety being switched off. It is
14
+ the opposite: the interactive prompt is replaced by a stricter gate, not removed.
15
+
16
+ The interactive prompt asks a human sitting at the terminal. That is a fine control when someone is
17
+ sitting there. Periscope exists for the case where nobody is, so the question has to be answered by
18
+ something that is still awake at 3am, and the options are _a stricter gate_ or _no gate_.
19
+
20
+ | | The interactive prompt | Periscope's gate |
21
+ | ----------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
22
+ | **Who decides** | whoever is at the keyboard | your controller, or a rule that runs locally |
23
+ | **When nobody answers** | the session blocks forever | the tool does not run: every timeout, error and outage is a refusal |
24
+ | **Is the decision recorded** | no | yes: every call, decision and reason is a state transition on the wire |
25
+ | **Can it be checked offline** | n/a | partly: the local gate refuses path escapes, credential reads and unrecognised git verbs with the controller unreachable |
26
+ | **What a crash does** | n/a | refuses. A hook that _throws_ is treated by the SDK as absent, so every handler is wrapped and returns an explicit deny |
27
+
28
+ The invariant is absolute: no answer, any error, any timeout, any outage means the tool does not
29
+ run. It is fail-closed on every path; the suite exercises each path rather than reading it off the
30
+ types.
31
+
32
+ ### The gate has a local half, and that is what makes it provable offline
33
+
34
+ Before anything is asked of your controller, a local rule runs. It only ever _adds_ refusals; it
35
+ can never turn a "no" into a "yes". It refuses, locally and immediately: a path that escapes the
36
+ session's workspace, a read of the credential set, and a git invocation whose verb is not on the
37
+ allow-list, by parsing the command rather than by matching strings. A boundary-crossing shape (a
38
+ push, a force, a remote change, a branch deletion, a merge) is recognised locally but escalated to
39
+ the controller like any other call, so a person can answer it; with the controller unreachable it
40
+ refuses when the decision deadline passes. So the answer to _"what happens if the controller is
41
+ compromised or unreachable?"_ is: the local classes are refused before the question is asked, and
42
+ everything else refuses because nobody answered.
43
+
44
+ ## What this can touch on your machine
45
+
46
+ Nothing outside `src/host/` imports `node:fs`, `node:fs/promises`, `node:child_process` or
47
+ `node:os`. One directory. You can answer _"what can this touch?"_ by reading it, and the rule is
48
+ enforced twice: an ESLint rule for editor feedback, and a tree-walking test that survives the lint
49
+ config being edited, disabled or deleted.
50
+
51
+ `@naswerks/periscope/protocol`, the subpath a controller imports, structurally cannot reach that directory,
52
+ does not pull the Agent SDK, and uses no Node-only global. Importing the wire contract does not hand
53
+ you a package that can read your disk.
54
+
55
+ ## The posture: five facts that are one picture
56
+
57
+ What you actually get: two facts about credentials, two about the tool surface, and one about what
58
+ the gate's decision is worth. Stated together rather than scattered, because taken singly each one
59
+ reads milder than the situation is.
60
+
61
+ ### 1. On Windows the token cache has no OS-level protection at all
62
+
63
+ POSIX modes are inert on win32. Measured on win32 with node v24.16.0: a file written `0o600`, a file
64
+ `chmod`ed to `0o600`, and a deliberately world-readable `0o666` all report `666`. Only the write bit
65
+ is real: `chmod 0444` does read back as `444`.
66
+
67
+ The genuine controls on Windows would be NTFS ACLs, and both routes to them were rejected
68
+ deliberately: a native module would be the first compiled code in a dependency set that is
69
+ otherwise plain JavaScript, and shelling out to `icacls` on every credential write puts a
70
+ `child_process` call in the credential path, inside the boundary that exists to keep that surface
71
+ small.
72
+
73
+ > So on Windows, the controls over your token cache are: the gate's credential-path denial (scoped
74
+ > as fact 3 states, not total), your controller's decisions, and your OS user account. That is a
75
+ > real degradation from the POSIX story. If you assumed file permissions were protecting that file,
76
+ > they are not.
77
+
78
+ ### 2. And on Windows the host cannot even measure that protection
79
+
80
+ This is a _different_ fact from the one above, and collapsing the two would be misleading. Above is
81
+ _there is no protection_; this is _the instrument cannot see it_. A `0600` assertion on win32 cannot
82
+ distinguish an owner-only file from a world-readable one, so a check written the obvious way would
83
+ pass vacuously and report a privacy it never confirmed.
84
+
85
+ Periscope therefore probes what the filesystem actually records and reports one of three outcomes,
86
+ never two:
87
+
88
+ | | |
89
+ | ---------------- | ---------------------------------------------------------------------------------------- |
90
+ | `enforced` | modes are honoured; the file is verified, and one wider than requested is refused |
91
+ | `write-bit-only` | only writability is recorded, so privacy is unconfirmable. A named degrade, not a pass |
92
+ | `unobservable` | not even clearing the write bit changed anything: inconclusive, and reported differently |
93
+
94
+ The third outcome exists because an instrument with no inconclusive state reports confidence it has
95
+ not earned. `chmod 0444` is used as a positive control, proving the probe can observe _something_
96
+ before it reports that it cannot observe privacy.
97
+
98
+ A Linux CI leg closes the second of these two and not the first. Being able to measure protection
99
+ on POSIX does not create protection on Windows.
100
+
101
+ ### 3. The gate has no opinion about MCP tools you register, or about built-in tools outside its families
102
+
103
+ The local gate matches on tool name, in three families (`DEFAULT_TOOL_FAMILIES`): writes are `Write`,
104
+ `Edit`, `MultiEdit`, `NotebookEdit`; reads are `Read`, `NotebookRead`; shells are `Bash`,
105
+ `PowerShell`. An MCP tool
106
+ arrives as `mcp__{server}__{tool}`, matches nothing, and gets no local opinion, so the decision
107
+ escalates to your controller. That is fail-closed and correct.
108
+
109
+ The same fall-through applies to built-in tools the families do not name, and that scopes the
110
+ credential-path denial itself. The default read family is `Read`/`NotebookRead` only, so built-in
111
+ `Grep` and `Glob`, both read primitives, get no local opinion and escalate; the gate's own suite
112
+ pins that deliberately (`local.test.ts`, _"a tool outside every declared family gets no local
113
+ opinion"_, with `Grep` as the example). The shell scan matches a protected path written literally,
114
+ so expansion forms (`~`, `$HOME`, `%USERPROFILE%`) and symlink indirection escalate too. What this
115
+ means for fact 1's picture: offline, every one of those escalations is refused as an outage, which
116
+ is still fail-closed, a narrower control than a by-name denial rather than an open door; online, for
117
+ exactly those vectors, what stands between the agent and the token cache is your controller, not
118
+ this gate. Widening the local denial to cover them is a known open question, deliberately left open
119
+ rather than closed in passing: a `Grep` refused for carrying no path at all is the kind of
120
+ over-refusal a widening has to weigh, and that deserves its own decision.
121
+
122
+ Name what is lost, because "no opinion" undersells it. With the controller unreachable, such a call
123
+ is refused as an outage rather than by name, which is precisely the distinction the local gate was
124
+ built to make. The cost is the kind of refusal, not the delay, and the by-name half of that is a
125
+ measurement: with both of the controller's transports dead, a built-in naming the token cache was
126
+ refused 5s after the turn was queued, by name (`credential-path-denied`), against an escalation
127
+ deadline of 15s, so the answer was the local gate's, not a timeout expiring. A call with no local
128
+ opinion in the same outage is refused as exactly that, an outage. A refused connection fails fast;
129
+ the decision timeout is only paid when the controller _accepts_ and does not answer. An outage
130
+ cannot be told apart from a failure on the controller's side, and that is what is lost. So:
131
+
132
+ > If you register MCP tools and never touch `ToolFamilies`, your gate is offline-provable for
133
+ > `Bash` and not for your own tools.
134
+
135
+ The mechanism to close it is `PeriscopeHostOptions.toolFamilies`: `ToolFamilies` is embedder-supplied
136
+ data, so naming your tool in the right family gives it the same local treatment as the built-in it
137
+ resembles. The git verb allow-list inside the shell family is not configurable; an unrecognised verb
138
+ refuses locally and a boundary shape escalates, whoever the embedder is.
139
+ Periscope deliberately does not guess which of your tools are dangerous. It cannot know, and a host
140
+ that guessed would be wrong in exactly the cases that matter.
141
+
142
+ ### 4. `strictMcpConfig` defaults ON, and turning it off re-admits servers that can fail silently
143
+
144
+ Periscope registers MCP servers in-process. There is no stdio child, no connect race, and nothing
145
+ for the CLI to fail to reconnect, which is why the absence of a status/reconnect surface is harmless
146
+ rather than a gap. That sentence is about the server this package registers itself; a server the
147
+ controller declares in `session_new.request.mcpServers` is whatever it declares, a stdio child
148
+ included (see _What a paired controller can reach_ below). The reasoning holds only while
149
+ `strictMcpConfig` is on, which excludes servers nobody declared: project `.mcp.json`, user settings,
150
+ plugin MCP, agent frontmatter.
151
+
152
+ > Turn `strictMcpConfig` off and you re-admit process-transport servers that can fail, with no
153
+ > status or reconnect surface to notice or recover. It is one flag, and it is the whole residual.
154
+
155
+ ### 5. A composed host grants what its gate approves, and a hand-composed one does not
156
+
157
+ The gate returns _no opinion_ on an allow by default, so that it can only ever add a refusal and
158
+ never delete one the operator configured. On its own that makes it a veto rather than a gate: the
159
+ agent's own permission mode is then the decider, and in a host with nobody at a keyboard there is
160
+ nobody to answer it. Measured on real sessions, three, identical but for the decision:
161
+
162
+ | Decision | File on disk | What the model was told |
163
+ | ---------------------- | ------------ | ------------------------------------------------------------------------------- |
164
+ | **deny** | no | _"the probe controller refuses this write"_: your reason, verbatim |
165
+ | **allow**, not granted | **no** | _"Claude requested permissions to write to ... but you haven't granted it yet"_ |
166
+ | **allow**, granted | **yes** | _"File created successfully at: ..."_ |
167
+
168
+ So `PeriscopeHost` sets `grantOnAllow`: it makes the decision your gate already took take effect,
169
+ one call at a time, for exactly the calls it approved. It is not `bypassPermissions`; nothing is
170
+ disabled wholesale.
171
+
172
+ > Three residuals.
173
+ > **(1)** The flag defaults OFF. An embedder who calls `composeSession` by hand and does not set it
174
+ > gets a gate that cannot say yes: the tool simply does not run and the agent reports a permission
175
+ > it was never going to be granted. This raises a `gate-cannot-grant` degrade on the first allow
176
+ > that does not take effect, and the consequence is stated on `GateTimings.grantOnAllow` itself,
177
+ > where an embedder actually reads it.
178
+ > **(2)** `grantOnAllow` with settings files loaded is refused by name
179
+ > (`permission-grant-shadows-settings`). Load no settings, or do not grant.
180
+ > **(3)** That refusal is incomplete on its own logic, and knowing why matters more than the rule:
181
+ > `settingSources: []` does _not_ mean no operator rules are live. Managed policy settings and
182
+ > `~/.claude.json` load regardless of that field, and a managed machine is exactly where such a
183
+ > policy exists. The refusal sees the tiers you name and cannot see the two that are always on.
184
+
185
+ > **What an effective allow actually skips.** The [Claude Code permissions
186
+ > documentation](https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks) states
187
+ > that a hook's decision does not bypass permission rules: deny and ask rules are evaluated whatever
188
+ > a `PreToolUse` hook returns. So a grant skips only the permission mode, the allow rules and
189
+ > `canUseTool`; the operator's deny and ask rules survive it. A veto-only gate still cannot say yes
190
+ > (that was measured, above), so the default is still correct; the residual exposure of a grant is
191
+ > smaller than a full bypass, and the refusal in (2) rests on "two authorities, no stated
192
+ > precedence" rather than on a bypass.
193
+ >
194
+ > That paragraph is documented, not measured. The probe that would settle it exists in
195
+ > `gate.live.test.ts` (does a hook allow override an operator deny rule?) and has not been
196
+ > exercised cleanly: run from inside an agent session, the child inherits the enclosing session's
197
+ > tool surface, satisfies the prompt with a tool the deny rule did not name, and never calls the
198
+ > denied one. Run it on a machine that is not itself an agent session before treating any of this
199
+ > as observed.
200
+
201
+ ## The outbound-only posture
202
+
203
+ There is no inbound port anywhere in this package. Periscope dials out to your controller over a
204
+ single WebSocket and keeps it alive. Nothing can connect _to_ it, which is what lets it run on a
205
+ machine behind a firewall that would never allow a listener.
206
+
207
+ One exception, and it is bounded: during an interactive sign-in a loopback listener binds
208
+ `127.0.0.1` explicitly, accepts exactly one callback, and times out. Because the agent shares this
209
+ host's OS user, any local process could reach that listener, so `state` is verified on every
210
+ callback, always. PKCE protects the code exchange; `state` protects the callback. They are not
211
+ substitutes for each other.
212
+
213
+ ## Pairing and revocation
214
+
215
+ The credential a running host presents is, in the shipped configuration, a **paired bearer**: an
216
+ string shaped `p1.<hostId>.<secret>` minted by your controller when a signed-in user redeems a
217
+ short-lived pair code (`periscope pair <code>`); the shape is the contract, because the host reads
218
+ its own id out of it. A controller should keep only a hash of the secret, so that this machine
219
+ holds the one copy, in `paired-credential.json` under the config directory (`~/.periscope`
220
+ or `PERISCOPE_CONFIG_DIR`), written `0600` and verified after the write. It has no expiry and no
221
+ rotation schedule. On POSIX the mode is enforced and a wider file is refused; on Windows the mode is
222
+ not real (fact 1 above), the host reports `write-bit-only` or `unobservable`, and the file's only
223
+ protections are the gate's credential-path denial, your controller, and your OS account.
224
+
225
+ Revocation is the controller's, per machine: it refuses the bearer at the upgrade (401 or 403) and
226
+ closes the link, and the host reads that refusal as `link-unauthorized`, which is terminal: it exits
227
+ non-zero naming the remedy. Rotation is re-pairing: mint a new code, run `periscope pair` again, and
228
+ a controller should invalidate the old bearer when the new one is issued. Nothing in this package
229
+ rotates the bearer on its own, and a copied bearer works from any machine until it is revoked.
230
+
231
+ The signed-in alternative (`periscope login`) presents a real user's own access token: nothing about
232
+ the provider is baked in, its revocation is the provider's, and a wrong identity configuration refuses
233
+ to start rather than authenticating as nobody. A provider may let its refresh token lapse after a
234
+ period of inactivity, which is why the paired bearer is the shipped default for an unattended host.
235
+
236
+ ### What a paired controller can reach on this machine
237
+
238
+ Pairing extends trust, and this is its exact extent. A controller holding this host's bearer can,
239
+ over the link and without a further credential:
240
+
241
+ - **Run a command on this machine at session start.** `session_new.request.mcpServers` is passed
242
+ to the agent as declared; a stdio server declaration (`{ type: 'stdio', command, args }`) is a
243
+ process the CLI spawns as this host's OS user, before the gate sees a single tool call. The host
244
+ screens the shape of the declaration, not what the command does.
245
+ - **Set the agent's environment.** `session_new.request.env.extraEnv` sets any variable in the
246
+ spawn environment after the allow-list has run, `PATH` and `NODE_OPTIONS` included, and
247
+ `extraAllowedKeys` re-admits keys of this host's own environment by name (the credential-shaped
248
+ deny list still wins there); only the host-session markers are stripped after it.
249
+ - **Remove directories.** `workspace_release` deletes a worktree under the workspace root, and the
250
+ controller can set that root through `host_configure` (a root change is refused only while a
251
+ session is live or opening).
252
+ - **Read files.** Every transcript under the agent home (`<agent home>/projects`, by default
253
+ `~/.claude/projects`), which includes sessions the operator ran from an editor or a terminal,
254
+ not only sessions this host started; and any directory listing or text-file head under the
255
+ repository root (`repository_list` / `repository_read`, read-only and bounded), a root the
256
+ controller can re-point through `host_configure`. Both doors are jailed to their root, and the
257
+ repository doors also honour the host's protected set: a path at or beneath a credential
258
+ directory refuses `credential-path-denied` whatever the root is, on the lexical resolution and
259
+ on the real path.
260
+ - **Reconfigure the host.** The workspace root, the repository root, the branch scheme, the agent
261
+ home and the two controller URLs through `host_configure`, written to the config file; the URLs
262
+ apply at the next start.
263
+ - **Start sessions** that run the agent with the interactive prompt replaced by the gate, and
264
+ answer every permission decision those sessions raise.
265
+
266
+ None of this is a defect to be closed: a runner that could not run a tool server, set a session's
267
+ environment or clean up its worktrees would not be a runner. It is the trust a pairing extends, so
268
+ pair a host only with a controller you would trust with all of it, and revoke the pairing when that
269
+ stops being true.
270
+
271
+ ## Running in a container
272
+
273
+ - **Do not run as root.** An unattended agent running as root has the whole machine on every tool
274
+ call, and a container built the obvious way runs as root. Periscope refuses to start as uid 0 by
275
+ policy, before anything else runs, so this arrives as one clear line at start-up rather than as a
276
+ session that can reach everything.
277
+ - **`exec` the process so it receives SIGTERM.** PID 1 gets no default signal dispositions, so
278
+ without `exec` every `docker stop` is a SIGKILL that runs no cleanup path and abandons credential
279
+ files mid-write.
280
+ - **Keep the shebang LF.** A CRLF shebang fails as _"no such file or directory"_ naming the
281
+ interpreter rather than the line ending. `.gitattributes` pins it.
282
+
283
+ ## Versions this was tested against
284
+
285
+ | | |
286
+ | -------------------------------- | ------------------------------------------------------------ |
287
+ | `@anthropic-ai/claude-agent-sdk` | **0.3.220**, pinned exactly, no caret |
288
+ | Bundled Claude Code CLI | **2.1.220** |
289
+ | Node | 22 or later, and CI runs 22 and 24 on both Linux and Windows |
290
+
291
+ The SDK is pre-1.0 and its surface moves without semver protection, so a SHA-256 of the installed
292
+ type definitions is kept in `contracts/sdk.sha256` and a CI job fails on any drift between the installed
293
+ types and that baseline; the SDK's own files are not redistributed. A bump cannot land without someone
294
+ reading what changed.
295
+
296
+ ## Supported versions
297
+
298
+ The latest minor of the current major receives fixes; older minors do not. The wire protocol has
299
+ its own window, stated in [the wire protocol](docs/protocol.md): a controller and a host one
300
+ protocol release apart connect. `periscope version` prints the running version.
301
+
302
+ ## Reporting a vulnerability
303
+
304
+ Report it through the repository's private security advisory form (GitHub Security Advisories:
305
+ <https://github.com/naswerks/periscope/security/advisories/new>) rather than a public issue. If you
306
+ are unsure whether something is a vulnerability, report it anyway: a false alarm costs a reply, and
307
+ the alternative costs more.
308
+
309
+ Please include the version (`periscope version`), the operating system, and what you observed. A
310
+ reproduction is welcome but not required; a clear description of the mechanism is worth more than a
311
+ script that only runs on your machine.
312
+
313
+ Expect an acknowledgement within seven days. A confirmed report is fixed in a release before it is
314
+ described publicly, with credit to the reporter unless they ask otherwise; a report that turns out
315
+ not to be a vulnerability gets an explanation of why.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Which command the binary was asked for.
3
+ *
4
+ * Extracted out of the composition root so it can be called without starting a host, the same
5
+ * reason, and the same shape, as `workspaces.ts`. `bin/periscope.ts` calls `main()` on load, so
6
+ * importing it to test one function starts a host, opens a socket and spawns nothing useful. The
7
+ * fix is to split the function out; it is not to add a startup guard to the entry point, because
8
+ * changing production behaviour to make a test possible is how a suite starts describing a system
9
+ * nobody ships.
10
+ *
11
+ * The parser is deliberately tiny and not an option library. This package's direct dependencies
12
+ * are the agent SDK with its peer closure, `ws` and `zod`; a CLI framework for a handful of verbs
13
+ * would be a new dependency in a package whose boundary claims are auditable from one directory.
14
+ */
15
+ /** The verbs this binary answers to. Anything else is named rather than guessed at. */
16
+ export type Command =
17
+ /** Run the host: dial the controller, serve sessions. The default, and what a supervisor starts. */
18
+ {
19
+ readonly kind: 'serve';
20
+ }
21
+ /**
22
+ * Acquire a token interactively and write it to the cache the daemon reads.
23
+ *
24
+ * Why the verb exists: `bin/serve.ts` never calls `signIn`. It builds a credential over the
25
+ * file token cache and a refresher, so it presents a token that is already there, and nothing
26
+ * else in this package ever writes one. An `npx`-installed host on someone's laptop with no way
27
+ * to sign in is not a host; it is a process that reads an empty cache, refuses by name, and
28
+ * (because the link is fail-open) connects with no headers at all.
29
+ */
30
+ | {
31
+ readonly kind: 'login';
32
+ }
33
+ /**
34
+ * Trade a pair code for this machine's durable credential and write it beside the token cache.
35
+ *
36
+ * An OIDC refresh token can expire after a period of inactivity, so an unattended host on the
37
+ * `login` credential can quietly stop being able to dial. A paired credential has no clock; it
38
+ * dies only when the controller revokes it, and that refusal is loud (the upgrade 401 is terminal
39
+ * and the process exits naming it). `code: null` is the operator forgetting the argument, named
40
+ * at parse so the message can say what to type.
41
+ */
42
+ | {
43
+ readonly kind: 'pair';
44
+ readonly code: string | null;
45
+ /** `--controller <origin>`: where to redeem, and what the controller names its routes from. */
46
+ readonly controller: string | null;
47
+ /** `--label <name>`: what the controller lists this machine as; overrides the environment. */
48
+ readonly label: string | null;
49
+ /** An argument the parser could not place, named so the usage line can say what to type. */
50
+ readonly problem: string | null;
51
+ }
52
+ /**
53
+ * Read or write the config file the daemon falls back to.
54
+ *
55
+ * Why the verb exists: with every setting readable from the environment only, a user with no UI
56
+ * and no supervisor has no way to configure a repository short of exporting variables into every
57
+ * shell that starts the host. The file is a fallback (the environment still wins per key) and
58
+ * nothing else in this package writes it. `key: null` lists the file; `value: null` reads one
59
+ * key; both stated is a write.
60
+ */
61
+ | {
62
+ readonly kind: 'config';
63
+ readonly key: string | null;
64
+ readonly value: string | null;
65
+ /** `--unset <key>`: remove the key from the file. */
66
+ readonly unset: boolean;
67
+ }
68
+ /** Print the package version. */
69
+ | {
70
+ readonly kind: 'version';
71
+ }
72
+ /** Say what the verbs are. */
73
+ | {
74
+ readonly kind: 'status';
75
+ } | {
76
+ readonly kind: 'help';
77
+ }
78
+ /** A verb this binary does not have. Named, never silently treated as `serve`. */
79
+ | {
80
+ readonly kind: 'unknown';
81
+ readonly name: string;
82
+ };
83
+ /**
84
+ * Read the command from an argv tail (`process.argv.slice(2)`).
85
+ *
86
+ * No arguments means `serve`, and that is a contract rather than a preference: a supervisor or
87
+ * container starts this binary with no arguments, so a bare invocation is the daemon.
88
+ *
89
+ * An unrecognised first argument is `unknown`, never `serve`. Falling back to the default would
90
+ * mean `periscope logn` silently starts a host: the operator believes they are signing in, the
91
+ * process dials out and starts accepting sessions, and the mistake surfaces much later as an empty
92
+ * token cache. A typo must fail loudly at the only moment it is cheap to fix.
93
+ */
94
+ export declare function readCommand(argv: readonly string[]): Command;
95
+ /** What `help` prints. Kept beside the parser so a new verb cannot be added without a line here. */
96
+ export declare const USAGE: string;
97
+ //# sourceMappingURL=command.d.ts.map