@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,106 @@
1
+ /**
2
+ * Which worktree and which branch a directory sits in: the "where" half of every transition.
3
+ *
4
+ * Why this is read at all: a trace whose location is a bare path answers "where" with something
5
+ * a reader still has to go and look up. Worktree and branch are what makes a transition legible on
6
+ * its own, which is the whole bar the state model is held to.
7
+ *
8
+ * It reads; it never provisions. No `git` subprocess, no writes, no repository creation, just
9
+ * two small file reads. Creating and managing worktrees is a different job and stays one.
10
+ *
11
+ * `.git` is often a file, not a directory, and that is the trap this file exists around.
12
+ * In a linked worktree, `.git` is a text file holding `gitdir: <path>` and pointing at a directory
13
+ * under the main repository's `.git/worktrees/`. A walk-up that looks for a `.git` directory finds
14
+ * nothing there and reports "not a repository", which is wrong, silently, in exactly the
15
+ * environment agents are most often given.
16
+ *
17
+ * Every failure is a named null, never an exception and never an empty string. Three ordinary
18
+ * situations produce no branch: the directory is not in a repository at all (normal — a plain
19
+ * working directory is a legitimate place to run), HEAD is detached (there IS no branch name), or
20
+ * HEAD is unreadable. An empty string would make all three indistinguishable from each other and
21
+ * from a branch literally named "".
22
+ */
23
+ import { readFileSync, statSync } from 'node:fs';
24
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
25
+ /** How far up to walk. A repository root further than this from cwd is not a case worth serving. */
26
+ const MAX_DEPTH = 64;
27
+ /**
28
+ * The `where` for a directory. Never throws — the caller stamps this onto every transition, so a
29
+ * throw here would take out the state record along with the fact it was decorating.
30
+ */
31
+ export function readWhere(cwd) {
32
+ const located = locateGit(cwd);
33
+ if (located === null) {
34
+ return { cwd, worktree: null, branch: null, unknownReason: 'not inside a git repository' };
35
+ }
36
+ const head = readHead(located.gitDir);
37
+ return {
38
+ cwd,
39
+ worktree: located.worktree,
40
+ branch: head.branch,
41
+ unknownReason: head.reason,
42
+ };
43
+ }
44
+ /** Walk up from `start` for a `.git` entry, accepting both the directory and the pointer-file form. */
45
+ function locateGit(start) {
46
+ let current = resolve(start);
47
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
48
+ const marker = join(current, '.git');
49
+ const kind = entryKind(marker);
50
+ if (kind === 'dir')
51
+ return { worktree: current, gitDir: marker };
52
+ if (kind === 'file') {
53
+ const pointed = readGitDirPointer(marker, current);
54
+ // A `.git` file that does not parse is still a `.git` file: this IS the worktree root, and
55
+ // saying so with an unreadable HEAD beats walking past it and reporting "not a repository".
56
+ return { worktree: current, gitDir: pointed ?? marker };
57
+ }
58
+ const parent = dirname(current);
59
+ if (parent === current)
60
+ return null;
61
+ current = parent;
62
+ }
63
+ return null;
64
+ }
65
+ function entryKind(path) {
66
+ try {
67
+ const stats = statSync(path);
68
+ return stats.isDirectory() ? 'dir' : 'file';
69
+ }
70
+ catch {
71
+ return 'absent';
72
+ }
73
+ }
74
+ /** `gitdir: <path>` out of a linked worktree's `.git` file. Relative paths resolve against it. */
75
+ function readGitDirPointer(marker, worktree) {
76
+ try {
77
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(marker, 'utf8'));
78
+ const target = pointer?.[1]?.trim();
79
+ if (target === undefined || target.length === 0)
80
+ return null;
81
+ return isAbsolute(target) ? target : resolve(worktree, target);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** The branch, or which of the ordinary situations means there is not one. */
88
+ function readHead(gitDir) {
89
+ let raw;
90
+ try {
91
+ raw = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
92
+ }
93
+ catch {
94
+ return { branch: null, reason: 'HEAD could not be read' };
95
+ }
96
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(raw);
97
+ if (ref?.[1] !== undefined)
98
+ return { branch: ref[1].trim(), reason: null };
99
+ // A bare object id is a detached HEAD: there genuinely is no branch, which is different from
100
+ // failing to find one, and a reader deciding whether a run is on the branch it should be needs
101
+ // to be able to tell those apart.
102
+ if (/^[0-9a-f]{40}$/i.test(raw))
103
+ return { branch: null, reason: 'HEAD is detached' };
104
+ return { branch: null, reason: `HEAD is in an unrecognised form: ${raw.slice(0, 40)}` };
105
+ }
106
+ //# sourceMappingURL=git-facts.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Turns the coverage table into the `options.hooks` object the SDK actually takes.
3
+ *
4
+ * Registration is derived from the table, not written beside it. The list of events registered
5
+ * here is the list `coverage.ts` marks `wired`, so the table cannot claim an event is wired while
6
+ * the wiring quietly lacks it. A table that can disagree with the code is a document that will.
7
+ *
8
+ * Every handler is wrapped, because a throwing hook is fail-open. A hook that throws is treated
9
+ * by the CLI as absent rather than as a refusal (observed behaviour, not an assumption). For an
10
+ * observer that would mean a lost transition and nothing else, which is precisely the silent loss
11
+ * this model exists to prevent, so the wrapper is here and not left to the caller.
12
+ *
13
+ * This handler never decides anything. It returns an empty output on every path, including the
14
+ * failure path. `HookCallbackMatcher.hooks` is an array and the SDK runs every entry, so a
15
+ * permission-decision handler registers on the same event alongside this one; neither has to know
16
+ * about the other, and observation cannot accidentally become authorization.
17
+ */
18
+ import type { HookRegistrations } from './agent-process.js';
19
+ import type { SessionObserver } from '../state/observer.js';
20
+ /** Told about a handler that threw, so a fail-open hook is never a silent one. */
21
+ export type HookFailureListener = (failure: {
22
+ event: string;
23
+ error: unknown;
24
+ }) => void;
25
+ export interface ObservationHookOptions {
26
+ readonly observer: SessionObserver;
27
+ readonly onHandlerFailure?: HookFailureListener;
28
+ }
29
+ /** Every event the table marks `wired`, in the SDK's own order. */
30
+ export declare function wiredHookEvents(): readonly string[];
31
+ /**
32
+ * The `hooks` object for a session, registering exactly the wired events.
33
+ *
34
+ * No matcher is set: a matcher filters by tool name, and this observes every tool. The absence is
35
+ * deliberate rather than an omission.
36
+ */
37
+ export declare function observationHooks(options: ObservationHookOptions): HookRegistrations;
38
+ /**
39
+ * Combine independent hook registrations, concatenating the matchers per event.
40
+ *
41
+ * This is the seam that keeps observation and authorization apart. A permission decision and a
42
+ * state record answer different questions, have different consumers and fail differently, and the
43
+ * failure this package is built against is exactly what happens when they share a guard: the
44
+ * control concern wins, and the observability loss is silent. Two matchers on one event, merged
45
+ * here, means neither can suppress the other because neither knows the other exists.
46
+ *
47
+ * "Earlier arguments run first" is true of dispatch and false of completion. Handlers on one
48
+ * event have their synchronous prologues run in array order, and are then awaited concurrently
49
+ * (observed behaviour, not an assumption). So a handler may rely on an earlier one having started
50
+ * and must never rely on it having finished: anything order-dependent belongs before the first
51
+ * `await`. (The
52
+ * gate's permission entry opens from its hold timer, not in its prologue, so it depends on no
53
+ * registration order; it registers second by convention.)
54
+ */
55
+ export declare function mergeHooks(...registrations: readonly HookRegistrations[]): HookRegistrations;
56
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { HOOK_COVERAGE } from '../state/coverage.js';
2
+ import { HOOK_EVENTS } from '../state/model.js';
3
+ /** Every event the table marks `wired`, in the SDK's own order. */
4
+ export function wiredHookEvents() {
5
+ return HOOK_EVENTS.filter((event) => HOOK_COVERAGE[event].handling === 'wired');
6
+ }
7
+ /**
8
+ * The `hooks` object for a session, registering exactly the wired events.
9
+ *
10
+ * No matcher is set: a matcher filters by tool name, and this observes every tool. The absence is
11
+ * deliberate rather than an omission.
12
+ */
13
+ export function observationHooks(options) {
14
+ const handler = (input) => {
15
+ try {
16
+ // The results are not read here. A transition the machine refuses is reported on
17
+ // `machine.onRejected` and counted in `rejectedCount`, the one channel every refused record
18
+ // takes whatever lane produced it; reporting it again from this handler would count one
19
+ // refusal twice. The observer builds a request only for an event it names, so an event this
20
+ // package does not know records nothing rather than producing a refusal to route.
21
+ options.observer.observeHook(input);
22
+ }
23
+ catch (error) {
24
+ // A throw here would make the CLI treat the hook as absent, which is the fail-open hole. It
25
+ // is caught, reported, and never rethrown; losing one observation loudly beats losing the
26
+ // handler entirely and silently. The listener is guarded too: a reporter that throws must
27
+ // not reopen the hole it exists to report.
28
+ try {
29
+ options.onHandlerFailure?.({ event: input.hook_event_name, error });
30
+ }
31
+ catch {
32
+ // Nothing further can be reported; the handler still answers.
33
+ }
34
+ }
35
+ return Promise.resolve({});
36
+ };
37
+ // One matcher object per event. A shared instance would let a `timeout` or `matcher` set on one
38
+ // event's entry apply to every event.
39
+ const registrations = {};
40
+ for (const event of HOOK_EVENTS) {
41
+ if (HOOK_COVERAGE[event].handling !== 'wired')
42
+ continue;
43
+ const matcher = { hooks: [handler] };
44
+ registrations[event] = [matcher];
45
+ }
46
+ return registrations;
47
+ }
48
+ /**
49
+ * Combine independent hook registrations, concatenating the matchers per event.
50
+ *
51
+ * This is the seam that keeps observation and authorization apart. A permission decision and a
52
+ * state record answer different questions, have different consumers and fail differently, and the
53
+ * failure this package is built against is exactly what happens when they share a guard: the
54
+ * control concern wins, and the observability loss is silent. Two matchers on one event, merged
55
+ * here, means neither can suppress the other because neither knows the other exists.
56
+ *
57
+ * "Earlier arguments run first" is true of dispatch and false of completion. Handlers on one
58
+ * event have their synchronous prologues run in array order, and are then awaited concurrently
59
+ * (observed behaviour, not an assumption). So a handler may rely on an earlier one having started
60
+ * and must never rely on it having finished: anything order-dependent belongs before the first
61
+ * `await`. (The
62
+ * gate's permission entry opens from its hold timer, not in its prologue, so it depends on no
63
+ * registration order; it registers second by convention.)
64
+ */
65
+ export function mergeHooks(...registrations) {
66
+ const merged = {};
67
+ for (const registration of registrations) {
68
+ for (const [event, matchers] of Object.entries(registration)) {
69
+ const key = event;
70
+ merged[key] = [...(merged[key] ?? []), ...(matchers ?? [])];
71
+ }
72
+ }
73
+ return merged;
74
+ }
75
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The composer: the file that makes the parts a host.
3
+ *
4
+ * Why this file exists: every part of this package is built and proven through its own seam, and
5
+ * something has to join them. Without this file a session created through the registry runs with
6
+ * `hooks: null` (no gate, no observation) and the link decodes `session_new`, `session_prompt`,
7
+ * `session_cancel` and `bulk_request` with nothing consuming them, so the host cannot be told to
8
+ * do anything at all.
9
+ *
10
+ * Two things live here and the split is the testability.
11
+ * `composeSession` is the per-session assembly (machine, observer, gate, forwarding) and it
12
+ * takes everything as arguments, so it can be checked without a socket or a process.
13
+ * `PeriscopeHost` owns the link and the registry and turns inbound payloads into calls on it.
14
+ *
15
+ * It lives in `src/host/` for the ordinary reason: it wires the real path resolver, the real bulk
16
+ * POST and the real MCP server, all of which are confined here. It imports no `node:` builtin of
17
+ * its own; the boundary is satisfied by construction rather than by permission.
18
+ *
19
+ * The order of assembly is load-bearing and it is not obvious. Forwarding is attached before the
20
+ * first transition is recorded, because the forwarder subscribes to the machine: attach it after
21
+ * and the `spawning` record (the one that carries where the session is and that it exists at all)
22
+ * is emitted to nobody. It is the first frame a controller ever sees for a session, so losing it
23
+ * costs the session's whole opening.
24
+ */
25
+ import type { Clock, Ticker } from '../core/time.js';
26
+ import type { Refusal } from '../core/refusal.js';
27
+ import type { Result } from '../core/result.js';
28
+ import type { ControllerCredential } from '../control/credential.js';
29
+ import type { BackoffOptions } from '../control/backoff.js';
30
+ import type { LinkHandlers } from '../control/link.js';
31
+ import type { LinkTransition } from '../control/link-state.js';
32
+ import type { HostConfiguration, HostConfigureEntry } from '../control/frames.js';
33
+ import type { FrameSink } from '../control/stream.js';
34
+ import type { Decider } from '../gate/decision.js';
35
+ import type { LocalGate } from '../gate/local.js';
36
+ import type { ToolFamilies } from '../gate/local.js';
37
+ import type { GateOutcome } from '../gate/outcome.js';
38
+ import type { McpServerOptions } from '../mcp/server.js';
39
+ import type { SessionRequest } from '../sessions/registry.js';
40
+ import { SessionRegistry } from '../sessions/registry.js';
41
+ import type { HostedSession, SessionDegrade, Unsubscribe } from '../sessions/session.js';
42
+ import { SessionStateMachine } from '../state/machine.js';
43
+ import type { SessionTransition, TransitionWhere } from '../state/model.js';
44
+ import { SessionObserver } from '../state/observer.js';
45
+ import type { WorkspaceProvider } from '../workspace/provider.js';
46
+ /** How long the gate waits, when it reports a call as held, and whether its allow takes effect. */
47
+ export interface GateTimings {
48
+ readonly decisionTimeoutMs?: number;
49
+ readonly holdAfterMs?: number;
50
+ readonly matcherTimeoutSeconds?: number;
51
+ /**
52
+ * Make the gate's allow effective.
53
+ *
54
+ * Defaults to false, and leaving it there gives a gate that cannot say yes. Without it
55
+ * this gate is a veto: it can refuse a call and cannot let one through. Observed on a real
56
+ * session: the gate allowed a `Write`, the tool did not run, and the model was told "Claude
57
+ * requested permissions to write to ..., but you haven't granted it yet", in a host with no user
58
+ * to grant anything. `PeriscopeHost` sets it to `true` for exactly this reason; an embedder
59
+ * calling `composeSession` by hand does not.
60
+ *
61
+ * The first allow that does not take effect raises a `gate-cannot-grant` degrade (see
62
+ * `permissionHooks`), so an embedder who left it off is told by a named outcome rather than by
63
+ * a comment.
64
+ *
65
+ * Setting it with `settingSources` non-empty is refused (`permission-grant-shadows-settings`).
66
+ * Per the SDK's documented evaluation order a hook allow does not skip operator deny or ask
67
+ * rules (it skips permission mode, allow rules and `canUseTool`), so the exposure is narrower
68
+ * than a bypass. See `composeSession` for what the refusal does and does not cover.
69
+ */
70
+ readonly grantOnAllow?: boolean;
71
+ }
72
+ export interface ComposeSessionOptions {
73
+ readonly registry: SessionRegistry;
74
+ /** The controller's handle: the frame routing key, not the agent's id. See frames.ts. */
75
+ readonly sessionKey: string;
76
+ /** Where the session runs. Absolute; the registry refuses anything else. */
77
+ readonly cwd: string;
78
+ readonly sink: FrameSink;
79
+ /** Who answers a permission escalation. Usually `escalatingDecider`. */
80
+ readonly decide: Decider;
81
+ /**
82
+ * The host's own gate, consulted before the decider.
83
+ *
84
+ * Optional in the type, supplied by default by `PeriscopeHost`. Absent means a path escape or a
85
+ * credential read waits `decisionTimeoutMs` for an unreachable controller and is reported as an
86
+ * outage, instead of being refused locally by name; see `gate/local.ts`. Composing without one
87
+ * is a decision, so it stays possible and is never the default.
88
+ */
89
+ readonly localGate?: LocalGate;
90
+ readonly gate?: GateTimings;
91
+ /** Read once, at composition. Carried by every transition until the agent moves itself. */
92
+ readonly where?: TransitionWhere;
93
+ /** Opaque and never interpreted here. The controller's own meaning handle. */
94
+ readonly correlationId?: string | null;
95
+ /** Everything else the session takes — MCP servers, a store, plugins, resume. */
96
+ readonly request?: Omit<SessionRequest, 'cwd' | 'hooks'>;
97
+ readonly clock?: Clock;
98
+ readonly ticker?: Ticker;
99
+ /** Every refusal the sink returned, every transition the machine would not record. */
100
+ readonly onRefusal?: (refused: Refusal) => void;
101
+ /**
102
+ * Told about every gate outcome, in addition to the machine record, never instead of it.
103
+ *
104
+ * The record is what reaches the wire and it is not optional; this is for an embedder that also
105
+ * wants to count, meter or log outcomes locally.
106
+ */
107
+ readonly onOutcome?: (outcome: GateOutcome) => void;
108
+ /** A hook handler that threw. The CLI treats a throwing hook as absent, so it is never silent. */
109
+ readonly onHookFailure?: (failure: {
110
+ event: string;
111
+ error: unknown;
112
+ }) => void;
113
+ }
114
+ /** One composed session: the handle, its machine, and how to take the wiring back down. */
115
+ export interface ComposedSession {
116
+ readonly sessionKey: string;
117
+ readonly session: HostedSession;
118
+ readonly machine: SessionStateMachine;
119
+ readonly observer: SessionObserver;
120
+ /** Stops forwarding. Idempotent. Does not end the session; the registry owns lifetime. */
121
+ readonly detach: Unsubscribe;
122
+ }
123
+ /**
124
+ * Assemble one session: machine, observer, gate and forwarding, then start it.
125
+ *
126
+ * This is the wiring every consumer needs, written down where it can be used.
127
+ */
128
+ export declare function composeSession(options: ComposeSessionOptions): Result<ComposedSession>;
129
+ /** Turns the opaque `what` of a bulk request into a file this host will POST. */
130
+ export type BulkResolver = (what: string, sessionKey: string) => Result<string>;
131
+ /**
132
+ * The link surface a host uses. `ControllerLink` satisfies it.
133
+ *
134
+ * It is structural so the host's own rules can be checked without a socket: that an unknown
135
+ * handle is refused rather than dropped, that two `session_new` for one handle do not silently
136
+ * replace each other, that a failed delivery still sends a receipt, that a finished session is
137
+ * released in an order which keeps its last frames. It is not a way to test the transport: a
138
+ * substitute proves the substitute, and everything this package claims about reconnect, replay and
139
+ * `seq` is proven against the real link in `control/`.
140
+ */
141
+ export interface HostLink extends FrameSink {
142
+ start(): void;
143
+ stop(detail?: string): void;
144
+ forgetSession(sessionId: string): void;
145
+ /** The version the controller chose at the last accepted handshake; optional because a test link negotiates nothing. */
146
+ readonly negotiatedVersion?: number | null;
147
+ /** Replace what the next `link_hello` declares; optional because a test link declares nothing. */
148
+ announce?(capabilities: readonly string[], configuration: HostConfiguration, pendingRestart: readonly string[]): void;
149
+ }
150
+ /**
151
+ * What a reconfigure produced: the pieces the host swaps in place of the ones it was composed with.
152
+ * `undefined` members mean the same as an omitted option at composition (no provider, no root).
153
+ */
154
+ export interface HostReconfigured {
155
+ readonly workspaces: WorkspaceProvider | undefined;
156
+ readonly transcriptsRoot: string | undefined;
157
+ readonly bulk: BulkResolver | undefined;
158
+ readonly linkCapabilities: readonly string[];
159
+ readonly configuration: HostConfiguration;
160
+ readonly overriddenByEnvironment: readonly string[];
161
+ /** The keys written but not in effect until the next start. */
162
+ readonly pendingRestart: readonly string[];
163
+ }
164
+ /**
165
+ * The configuration seam a `host_configure` ask runs through. The composition root supplies it,
166
+ * because writing the config file and rebuilding a provider both read the environment, which is
167
+ * that file's job alone. `hostBusy` is true while any session is live or opening; the seam refuses
168
+ * a roots change by name in that state rather than writing a file the running host cannot honour.
169
+ */
170
+ export type HostReconfigurer = (entries: readonly HostConfigureEntry[], hostBusy: boolean) => Result<HostReconfigured>;
171
+ export interface PeriscopeHostOptions {
172
+ readonly controllerUrl: string;
173
+ readonly hostId: string;
174
+ /**
175
+ * The identity this host presents outbound, on the link's upgrade and on every bulk POST
176
+ * (resolved per delivery; a refusing credential posts headerless, and the receiver's refusal
177
+ * comes back as `bulk-delivery-failed`). Omitted means every outbound surface is anonymous.
178
+ */
179
+ readonly credential?: ControllerCredential;
180
+ readonly backoff?: BackoffOptions;
181
+ /**
182
+ * The default link's clocks: the heartbeat's interval and timeout, and how long a dial may sit
183
+ * without an open before it is abandoned and retried. Ignored when `link` supplies the link.
184
+ */
185
+ readonly linkTimings?: {
186
+ readonly heartbeatIntervalMs?: number;
187
+ readonly heartbeatTimeoutMs?: number;
188
+ readonly connectTimeoutMs?: number;
189
+ };
190
+ /** Who answers a permission escalation. Required: a host with no decider gates nothing. */
191
+ readonly decide: Decider;
192
+ /**
193
+ * Absolute paths the agent may never read or write, whatever its workspace.
194
+ *
195
+ * `host/paths.ts`'s `credentialPaths(env)` computes the default set; the composition root passes
196
+ * it in, because reading the environment is that file's job and only that file's.
197
+ */
198
+ readonly protectedPaths: readonly string[];
199
+ /**
200
+ * Which tool names the local gate treats as path writes, path reads and shell commands.
201
+ *
202
+ * Defaults to the SDK's own tools (`DEFAULT_TOOL_FAMILIES`). An MCP tool the embedder registers
203
+ * arrives as `mcp__{server}__{tool}`, matches nothing, and escalates with no local opinion; naming
204
+ * it here gives it the same local treatment as the built-in it resembles. Data, never guessed:
205
+ * the host cannot know which of an embedder's tools are dangerous.
206
+ */
207
+ readonly toolFamilies?: ToolFamilies;
208
+ /**
209
+ * Where sessions run. With a provider the controller's `cwd` is advisory: the provider decides,
210
+ * and the session's `spawning` transition carries the directory it actually got, so the
211
+ * controller learns where its session is rather than assuming.
212
+ */
213
+ readonly workspaces?: WorkspaceProvider;
214
+ /**
215
+ * The key an unkeyed `session_new` provisions at, when it should not be the session key
216
+ * (protocol v5).
217
+ *
218
+ * A default for an absence, never an override: resolution is exactly
219
+ * `opening.workspaceKey ?? defaultWorkspaceKey ?? sessionKey`, so a controller that names a key
220
+ * never has this consulted and no precedence question exists. It is what lets a library user
221
+ * with no controller at all get a shared tree: every unkeyed session lands in one workspace.
222
+ * The composition root screens the configured value at startup (same union screen as a wire
223
+ * key); the per-frame validation below still covers an embedder that passes one here directly.
224
+ */
225
+ readonly defaultWorkspaceKey?: string;
226
+ /**
227
+ * In-process tools this host offers every session. See the note on registration below.
228
+ *
229
+ * `identity` is not the embedder's to supply: it must be read at CALL time and only this host
230
+ * knows which session is calling, so it is filled in here and omitted from the type rather than
231
+ * accepted and ignored.
232
+ */
233
+ readonly tools?: Omit<McpServerOptions, 'identity'>;
234
+ readonly bulk?: BulkResolver;
235
+ /**
236
+ * Where the agent CLI's transcripts live (`~/.claude/projects`), for the discovery door.
237
+ *
238
+ * The composition root derives it (`claudeProjectsRoot(env)` in `host/claude-transcripts.ts`),
239
+ * because reading the environment is that layer's job. Absent means the door answers every
240
+ * `transcript_list` / `transcript_tail` with a named failure rather than guessing a root;
241
+ * `session_list` needs no filesystem and always answers.
242
+ */
243
+ readonly transcriptsRoot?: string;
244
+ readonly gate?: GateTimings;
245
+ /**
246
+ * Capability markers this host declares in its `link_hello`, beside the built-in `bulk-post`,
247
+ * never replacing it.
248
+ *
249
+ * The workspace mode rides here: the composition root computes
250
+ * `workspaceCapabilitiesOf(config)` (`bin/workspaces.ts`) because only it knows which provider
251
+ * it chose; the mode is deliberately erased from `WorkspaceProvider` itself. Markers are values
252
+ * in an existing open string list, so declaring a new one is not a protocol change and bumps no
253
+ * version; a controller that does not know a marker ignores it by construction. Values a marker
254
+ * cannot carry (which repository root, which scheme text) are a payload-member conversation, a
255
+ * version bump, and deliberately not smuggled in here.
256
+ */
257
+ readonly linkCapabilities?: readonly string[];
258
+ /**
259
+ * The values behind those markers: which roots, which scheme, which transcripts root,
260
+ * which controller. The composition root computes `hostConfigurationOf(config, ...)` beside the
261
+ * markers, from the same reading, so the two cannot disagree. Omitted, the hello reports every
262
+ * value as null.
263
+ */
264
+ readonly configuration?: HostConfiguration;
265
+ /** The wire-settable keys the environment sets, reported on a configure answer. */
266
+ readonly overriddenByEnvironment?: readonly string[];
267
+ /** The keys the file names differently from what this process dialled; empty at a fresh start. */
268
+ readonly pendingRestart?: readonly string[];
269
+ /** How a `host_configure` ask is applied. Omitted, every such ask refuses `config-write-failed`. */
270
+ readonly reconfigure?: HostReconfigurer;
271
+ readonly registry?: SessionRegistry;
272
+ /** How the link is built. Defaults to the real `ControllerLink`. See `HostLink`. */
273
+ readonly link?: (handlers: LinkHandlers) => HostLink;
274
+ /** The environment sessions are filtered from, when this builds its own registry. */
275
+ readonly baseEnv?: Readonly<Record<string, string | undefined>>;
276
+ readonly homeDir?: string;
277
+ readonly clock?: Clock;
278
+ readonly ticker?: Ticker;
279
+ /** Every named thing that happened. A host with no reporter is a host nobody can debug. */
280
+ readonly report?: (event: HostEvent) => void;
281
+ }
282
+ /** What a host reports. Data rather than log lines, so an embedder decides the format. */
283
+ export type HostEvent = {
284
+ readonly kind: 'link';
285
+ readonly transition: LinkTransition;
286
+ } | {
287
+ readonly kind: 'refusal';
288
+ readonly refusal: Refusal;
289
+ readonly sessionKey: string | null;
290
+ } | {
291
+ readonly kind: 'gap';
292
+ readonly sessionKey: string;
293
+ readonly expected: number;
294
+ readonly received: number;
295
+ } | {
296
+ readonly kind: 'session-opened';
297
+ readonly sessionKey: string;
298
+ readonly cwd: string;
299
+ } | {
300
+ readonly kind: 'session-closed';
301
+ readonly sessionKey: string;
302
+ } | {
303
+ readonly kind: 'prompt-held';
304
+ readonly sessionKey: string;
305
+ readonly held: number;
306
+ } | {
307
+ readonly kind: 'prompt-delivered';
308
+ readonly sessionKey: string;
309
+ readonly delivered: number;
310
+ } | {
311
+ readonly kind: 'prompt-withdrawn';
312
+ readonly sessionKey: string;
313
+ readonly withdrawn: number;
314
+ } | {
315
+ readonly kind: 'transition';
316
+ readonly sessionKey: string;
317
+ readonly transition: SessionTransition;
318
+ }
319
+ /**
320
+ * A named condition that changed what is true of a session without ending it.
321
+ *
322
+ * Every degrade the sessions layer raises arrives here. The registry names its conditions (an
323
+ * untrusted workspace whose settings rules are silently void, an id collision whose own detail
324
+ * says how to resolve it) into a listener set, and a listener set with no subscribers would
325
+ * mean the one message that answers the operator's question is raised and never received. The
326
+ * subscription in `#compose` is the audience; a pin in `host.test.ts` holds every kind to it.
327
+ */
328
+ | {
329
+ readonly kind: 'degrade';
330
+ readonly sessionKey: string;
331
+ readonly degrade: SessionDegrade;
332
+ };
333
+ /**
334
+ * A host: one outbound link, one registry, and the dispatcher between them.
335
+ *
336
+ * It interprets exactly the payload kinds its `#dispatch` switch names and refuses the rest by
337
+ * name: the session commands (`session_new`, `session_prompt`, `session_cancel`,
338
+ * `session_configure`), `bulk_request`, and the host-scoped asks (the discovery, workspace,
339
+ * configure and repository asks). Derive the count from the switch below; never carry it from
340
+ * prose. Anything else arriving inbound is
341
+ * either a frame this host produces (an update, a delta, a receipt) or a kind a newer controller
342
+ * invented, and both are reported rather than ignored; a command that vanishes reads to the
343
+ * controller as a host that hung.
344
+ */
345
+ export declare class PeriscopeHost {
346
+ #private;
347
+ constructor(options: PeriscopeHostOptions);
348
+ get link(): HostLink;
349
+ get registry(): SessionRegistry;
350
+ /** The composed session behind a controller handle, or a refusal naming the handle. */
351
+ session(sessionKey: string): Result<ComposedSession>;
352
+ start(): void;
353
+ /** Ends every session first, then the link — so the end transitions still have somewhere to go. */
354
+ stop(detail?: string): void;
355
+ }
356
+ //# sourceMappingURL=host.d.ts.map