@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- 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);
|