@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,152 @@
1
+ /**
2
+ * The directory a session runs in, and who decides what it looks like.
3
+ *
4
+ * This is entirely the host's job. The SDK has no provisioning surface at all — it takes a `cwd`
5
+ * and assumes something already made it. So the choice of what a workspace is — a plain directory, a
6
+ * checkout, a linked worktree, a container mount — is the embedder's, and this is the seam where
7
+ * that choice plugs in.
8
+ *
9
+ * The provider is the embedder's, not the package's. Two implementations ship because they cover
10
+ * the two shapes almost everyone needs, but the interesting policies are not here and must not move
11
+ * here: a shared directory per task, a write-once key, a branch naming rule, a cleanup schedule.
12
+ * Those are the embedder's own conventions, and a tool that reviews code in a throwaway clone
13
+ * should be able to write its own provider without ever learning that any of them exist.
14
+ *
15
+ * Every result is a named refusal, never an exception. `provision` failing is an ordinary outcome
16
+ * — a disk is full, a path is taken, a git command failed — and the caller has to decide what to do
17
+ * about it. A thrown error at this seam would make "the session could not get a directory"
18
+ * indistinguishable from a bug in the provider, and the package names the difference everywhere else.
19
+ */
20
+ import type { Result } from '../core/result.js';
21
+ import type { Refusal } from '../core/refusal.js';
22
+ /** Where a session works, plus whatever the provider wants the caller to know about it. */
23
+ export interface Workspace {
24
+ /** Absolute, always. This is what becomes the session's `cwd`. */
25
+ readonly path: string;
26
+ /**
27
+ * Provider-specific facts, as strings.
28
+ *
29
+ * Deliberately opaque to this package. A git provider reports its branch and base ref here; a
30
+ * plain-directory provider reports almost nothing; somebody else's reports a container id. Nothing
31
+ * in the host reads a key out of this — it exists so a provider can tell its caller something
32
+ * without this file having to grow a field for every provider anyone might write.
33
+ */
34
+ readonly meta: Readonly<Record<string, string>>;
35
+ }
36
+ /** What `release` should do with the directory. */
37
+ export interface ReleaseOptions {
38
+ /**
39
+ * Remove the workspace from disk. Defaults to false.
40
+ *
41
+ * The default is "leave it", and that is a deliberate asymmetry. A session that ended badly is
42
+ * one whose directory somebody wants to look at, and a host that tidied up by default would
43
+ * destroy the evidence exactly when it matters. Leaving a directory costs disk; deleting one that
44
+ * was still wanted costs the investigation.
45
+ */
46
+ readonly remove?: boolean;
47
+ /**
48
+ * Delete the branch the worktree is on, after the directory. Defaults to false, and only means
49
+ * anything with `remove`. Deleting a branch is not reversible the way removing a directory is,
50
+ * so it is asked for per call and never remembered.
51
+ */
52
+ readonly deleteBranch?: boolean;
53
+ /** Delete the branch even when it is not merged into the repository's default branch. */
54
+ readonly force?: boolean;
55
+ }
56
+ /**
57
+ * What a release did. `refusal` is the partial: the directory went and the branch did not, said
58
+ * rather than hidden. Null means everything asked for happened, or was already absent.
59
+ */
60
+ export interface ReleaseReceipt {
61
+ /** The workspace's path as this provider knows it. */
62
+ readonly path: string;
63
+ /** A directory existed and is gone. */
64
+ readonly directoryRemoved: boolean;
65
+ readonly branchDeleted: boolean;
66
+ readonly refusal: Refusal | null;
67
+ }
68
+ /** One workspace on disk, as the provider's inventory reports it. See `WorkspaceListEntry` on the wire. */
69
+ export interface WorkspaceEntry {
70
+ readonly key: string;
71
+ readonly path: string;
72
+ readonly branch: string | null;
73
+ readonly head: string | null;
74
+ readonly detached: boolean;
75
+ readonly locked: boolean;
76
+ readonly prunable: boolean;
77
+ readonly merged: boolean | null;
78
+ /** Commits past the default branch; null when detached or when no default branch was named. */
79
+ readonly aheadCount: number | null;
80
+ readonly lastCommitAt: string | null;
81
+ }
82
+ /** What `inventory` answers: every workspace under the root, and the default branch `merged` was judged against. */
83
+ export interface WorkspaceInventory {
84
+ readonly entries: readonly WorkspaceEntry[];
85
+ readonly defaultBranch: string | null;
86
+ }
87
+ export interface WorkspaceProvider {
88
+ /**
89
+ * Get the directory for this session, creating it if needed.
90
+ *
91
+ * It must be safe to call twice for one session. A host that lost a directory — a cleanup ran,
92
+ * a volume remounted, a container restarted — re-provisions, and the second call must attach to
93
+ * whatever survived rather than recreate it. That is not a nicety: see git-worktree.ts, where
94
+ * getting it wrong destroys committed work.
95
+ */
96
+ provision(sessionId: string): Promise<Result<Workspace>>;
97
+ /**
98
+ * Done with it. Idempotent: releasing a session this provider does not hold succeeds. The
99
+ * receipt is optional so a provider that predates it still conforms; the host reads no receipt as
100
+ * "vouches for nothing" and reports both flags false.
101
+ */
102
+ release(sessionId: string, options?: ReleaseOptions): Promise<Result<ReleaseReceipt | undefined>>;
103
+ /**
104
+ * The key of the workspace at `path`, when `path` is a directory directly under this provider's
105
+ * root; null for anything else. Optional: a provider without it cannot be addressed by path.
106
+ */
107
+ keyForPath?(path: string): string | null;
108
+ /**
109
+ * What exists under the workspace root right now, read from disk on demand. Optional: a provider
110
+ * that keeps no inventory leaves it out and the host answers `workspace-list-failed` by name.
111
+ * Only what this provider provisioned is listed; the repository it links from never is.
112
+ */
113
+ inventory?(): Promise<Result<WorkspaceInventory>>;
114
+ /**
115
+ * The repository this provider clones from, when it has one. The one directory a controller may
116
+ * name and get, provider or not: the operator's own checkout has no isolation to protect.
117
+ */
118
+ readonly repositoryRoot?: string;
119
+ }
120
+ /**
121
+ * The filesystem and process effects a provider needs, as injected functions.
122
+ *
123
+ * Why this exists rather than a direct `node:fs` import. `src/host/` is the only directory allowed
124
+ * to touch the machine (pinned by pins/host-boundary.test.ts), and the provider logic — which argv a
125
+ * git call gets, when a directory is reused rather than made, what a release does — is exactly the
126
+ * part worth testing without a disk. So the logic lives here and the real implementations live in
127
+ * host/workspace-fs.ts, the same split the path jail already uses.
128
+ *
129
+ * The payoff is not tidiness: it makes "never hard-reset an existing branch" a pure unit test rather
130
+ * than something only a destroyed branch could have proven.
131
+ */
132
+ export interface WorkspaceEffects {
133
+ /** Create a directory and every missing parent. Succeeds if it already exists. */
134
+ makeDirectory(path: string): Promise<void>;
135
+ /** Does anything exist at this path? */
136
+ exists(path: string): Promise<boolean>;
137
+ /** Remove a directory and everything under it. Succeeds if it is already gone. */
138
+ removeDirectory(path: string): Promise<void>;
139
+ /** The names of the directories directly under `path`, or an empty list when it does not exist. Optional. */
140
+ listDirectories?(path: string): Promise<string[]>;
141
+ }
142
+ /** Running a command. Separate from the filesystem effects because only one provider needs it. */
143
+ export interface CommandEffects {
144
+ /**
145
+ * Run a program with an argv array and return its stdout, trimmed.
146
+ *
147
+ * Argv, never a command string. A shell string would make every branch name and path an
148
+ * injection site, and branch names come from a caller this package does not control.
149
+ */
150
+ run(program: string, args: readonly string[], cwd: string): Promise<string>;
151
+ }
152
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Readers for the three git outputs an inventory is built from. Pure: text in, records out, no
3
+ * process and no filesystem, so the parsing is testable against fixtures and the provider that
4
+ * runs the commands stays thin.
5
+ *
6
+ * `git worktree list --porcelain` prints one block per worktree, blank-line separated, each line
7
+ * `<attribute> <value>` or a bare attribute: `worktree <path>`, `HEAD <sha>`, `branch <ref>`,
8
+ * `detached`, `locked [reason]`, `prunable [reason]`, `bare`. Attributes this reader does not know
9
+ * are carried past, never fatal, so a newer git cannot break the inventory by adding one.
10
+ */
11
+ /** One worktree as git reports it. `branch` is the short name (`refs/heads/` stripped), null when detached. */
12
+ export interface PorcelainWorktree {
13
+ readonly path: string;
14
+ readonly head: string | null;
15
+ readonly branch: string | null;
16
+ readonly detached: boolean;
17
+ readonly locked: boolean;
18
+ readonly prunable: boolean;
19
+ readonly bare: boolean;
20
+ }
21
+ export declare function parseWorktreePorcelain(text: string): PorcelainWorktree[];
22
+ /**
23
+ * `git for-each-ref --format='%(refname:short)%09%(committerdate:iso-strict)' refs/heads/`: one
24
+ * branch per line, tab-separated. Returns the tip's committer date per branch, as given.
25
+ */
26
+ export declare function parseBranchTips(text: string): Map<string, string>;
27
+ /** `git branch --merged <ref> --format='%(refname:short)'`: one branch per line. */
28
+ export declare function parseBranchList(text: string): Set<string>;
29
+ //# sourceMappingURL=worktree-porcelain.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Readers for the three git outputs an inventory is built from. Pure: text in, records out, no
3
+ * process and no filesystem, so the parsing is testable against fixtures and the provider that
4
+ * runs the commands stays thin.
5
+ *
6
+ * `git worktree list --porcelain` prints one block per worktree, blank-line separated, each line
7
+ * `<attribute> <value>` or a bare attribute: `worktree <path>`, `HEAD <sha>`, `branch <ref>`,
8
+ * `detached`, `locked [reason]`, `prunable [reason]`, `bare`. Attributes this reader does not know
9
+ * are carried past, never fatal, so a newer git cannot break the inventory by adding one.
10
+ */
11
+ const HEADS_PREFIX = 'refs/heads/';
12
+ export function parseWorktreePorcelain(text) {
13
+ const worktrees = [];
14
+ let current = null;
15
+ const flush = () => {
16
+ if (current !== null)
17
+ worktrees.push({ ...current });
18
+ current = null;
19
+ };
20
+ for (const raw of text.split(/\r?\n/)) {
21
+ const line = raw.trimEnd();
22
+ if (line === '') {
23
+ flush();
24
+ continue;
25
+ }
26
+ const space = line.indexOf(' ');
27
+ const attribute = space === -1 ? line : line.slice(0, space);
28
+ const value = space === -1 ? '' : line.slice(space + 1);
29
+ if (attribute === 'worktree') {
30
+ flush();
31
+ current = {
32
+ path: value,
33
+ head: null,
34
+ branch: null,
35
+ detached: false,
36
+ locked: false,
37
+ prunable: false,
38
+ bare: false,
39
+ };
40
+ continue;
41
+ }
42
+ if (current === null)
43
+ continue;
44
+ switch (attribute) {
45
+ case 'HEAD':
46
+ current.head = value === '' ? null : value;
47
+ break;
48
+ case 'branch':
49
+ current.branch = value.startsWith(HEADS_PREFIX) ? value.slice(HEADS_PREFIX.length) : value;
50
+ break;
51
+ case 'detached':
52
+ current.detached = true;
53
+ break;
54
+ case 'locked':
55
+ current.locked = true;
56
+ break;
57
+ case 'prunable':
58
+ current.prunable = true;
59
+ break;
60
+ case 'bare':
61
+ current.bare = true;
62
+ break;
63
+ default:
64
+ break;
65
+ }
66
+ }
67
+ flush();
68
+ return worktrees;
69
+ }
70
+ /**
71
+ * `git for-each-ref --format='%(refname:short)%09%(committerdate:iso-strict)' refs/heads/`: one
72
+ * branch per line, tab-separated. Returns the tip's committer date per branch, as given.
73
+ */
74
+ export function parseBranchTips(text) {
75
+ const tips = new Map();
76
+ for (const raw of text.split(/\r?\n/)) {
77
+ const line = raw.trim();
78
+ if (line === '')
79
+ continue;
80
+ const tab = line.indexOf('\t');
81
+ if (tab === -1)
82
+ continue;
83
+ const branch = line.slice(0, tab).trim();
84
+ const at = line.slice(tab + 1).trim();
85
+ if (branch !== '' && at !== '')
86
+ tips.set(branch, at);
87
+ }
88
+ return tips;
89
+ }
90
+ /** `git branch --merged <ref> --format='%(refname:short)'`: one branch per line. */
91
+ export function parseBranchList(text) {
92
+ const branches = new Set();
93
+ for (const raw of text.split(/\r?\n/)) {
94
+ const line = raw.trim().replace(/^[*+]\s+/, '');
95
+ if (line !== '')
96
+ branches.add(line);
97
+ }
98
+ return branches;
99
+ }
100
+ //# sourceMappingURL=worktree-porcelain.js.map
@@ -0,0 +1,277 @@
1
+ # Architecture
2
+
3
+ What the package is and how its parts compose. The wire is in [protocol.md](protocol.md), the
4
+ session's state model in [state-machine.md](state-machine.md), the permission gate in
5
+ [gate.md](gate.md).
6
+
7
+ ## What it is
8
+
9
+ Periscope runs where the code lives, hosts Claude Code sessions through the Claude Agent SDK, and
10
+ reports to a controller anywhere else over one outbound WebSocket. The controller decides what a
11
+ session means; the host carries what it did. Three properties follow, each enforced rather than
12
+ promised:
13
+
14
+ - Generic. Nothing on the wire is shaped to one product. The test for a new member or event: could
15
+ a different controller make different meaning from it? If yes it belongs here; if it means
16
+ something only under one controller's rules it stays with that controller.
17
+ - The SDK's words. The package says session, message, hook, tool, subagent, turn, result and
18
+ permission as the SDK does. A controller's own vocabulary fails the build
19
+ (`pins/vocabulary.test.ts`). One site translates SDK events into transitions:
20
+ `src/state/observer.ts`.
21
+ - Auditable blast radius. Nothing outside `src/host/` imports `node:fs`, `node:fs/promises`,
22
+ `node:child_process` or `node:os`, and nothing outside it names the SDK (ESLint
23
+ `no-restricted-imports`, `pins/host-boundary.test.ts`, `pins/sdk-confinement.test.ts`). The
24
+ `./protocol` subpath cannot reach `src/host/`, so a consumer of the wire types acquires nothing
25
+ that can spawn a process.
26
+
27
+ Where a concept exists in the Agent Client Protocol, the name is ACP's (`session/new` style,
28
+ camelCase keys, snake_case discriminators). Periscope is not ACP-compatible and does not claim to
29
+ be; no ACP transport dials out. The names that are the package's own (`link`, `bulk`) are registered
30
+ in `HOST_NOUNS` (`src/core/vocab.ts`).
31
+
32
+ ## Composition
33
+
34
+ Two things live in `src/host/host.ts`.
35
+
36
+ `composeSession(options)` assembles one session: machine, observer, gate, forwarding. It takes
37
+ everything as arguments, so it is checkable without a socket or a process, and returns
38
+ `{ sessionKey, session, machine, observer, detach }`.
39
+
40
+ - Forwarding is attached before the first transition is recorded. The forwarder subscribes to the
41
+ machine; attached later, the `spawning` record goes to nobody, and it is the first frame a
42
+ controller sees for a session.
43
+ - Observation hooks are registered before the gate's:
44
+ `mergeHooks(observationHooks(...), permissionHooks(...))`. Matchers dispatch in array order but
45
+ are awaited concurrently, and a gate registered first would open a hold the observer closes in
46
+ the same event. No pin enforces the order; it is stated at both sites.
47
+ - Every gate outcome is recorded on the machine unconditionally. `onOutcome` is an extra listener,
48
+ never a substitute.
49
+
50
+ `PeriscopeHost` owns the link and the registry and turns inbound payloads into calls on them.
51
+
52
+ - It answers exactly the payload kinds its dispatch names (the session commands, `bulk_request` and
53
+ the host-scoped asks in [protocol.md](protocol.md)) and refuses everything else
54
+ `frame-malformed`. A command that vanished would read as a host that hung.
55
+ - Two `session_new` for one handle never replace each other silently: the duplicate guard is a
56
+ synchronous reservation that holds across the workspace provider's await.
57
+ - A workspace claimed and then unusable is handed back on every failure path; provision and release
58
+ stay balanced.
59
+ - Shutdown ends sessions first, then the link, so the end transitions have somewhere to go.
60
+ `forgetSession` does not discard unacknowledged frames.
61
+ - `baseEnv` and `homeDir` build the agent's environment. Passing them beside a `registry` is refused
62
+ at construction: the host reads them only to build its own registry, and the pair would otherwise
63
+ be ignored in silence.
64
+ - `linkTimings` (`heartbeatIntervalMs`, `heartbeatTimeoutMs`, `connectTimeoutMs`) and `backoff`
65
+ reach the default link; `link` replaces the link wholesale.
66
+
67
+ A session refused at its open answers on the wire ([protocol.md](protocol.md), the refused open).
68
+
69
+ ## The binary
70
+
71
+ `periscope` is six verbs (`src/bin/command.ts`): `serve` (the default: no arguments means `serve`),
72
+ `login`, `pair <code>`, `config`, `status` and `help`. An unrecognised first argument is `unknown`,
73
+ never `serve`; falling back would let a typo start a host. Every claim that the binary does not do
74
+ something is about the daemon; the other verbs are where interactive work lives.
75
+
76
+ - `serve` dials the controller and serves sessions. It refuses to start as root (an unattended
77
+ agent as root has the whole machine on every tool call; refused by policy, before anything else is
78
+ read) and refuses to start without `PERISCOPE_DECISION_URL` (a host that cannot ask is an open door or a
79
+ session where nothing runs). It never signs anyone in: it presents credentials that are already
80
+ there and refuses by name when they are not. It prints one `[host]` posture line before dialling
81
+ and writes the link's state to `link-state.json` beside the credentials on every transition.
82
+ - `login` runs the sign-in and writes the token cache. `pair <code>`, with `--controller <origin>`
83
+ and `--label <name>`, redeems a controller-minted code for a paired credential and writes the
84
+ controller's addresses to the config file. `config` shows or edits `config.json`. `status` prints
85
+ the whole posture from the link record, the credentials and the settings, and never dials.
86
+ - `config.json` lives beside the credentials under a closed key allowlist (`CONFIG_KEYS`: the two
87
+ control-plane URLs, the host id, both roots, the branch scheme, the workspace key, the agent home;
88
+ `PERISCOPE_CONFIG_DIR` is excluded, a file cannot move itself). `serve`, `login`, `pair` and
89
+ `status` read the environment with the file filling its absences; the environment always wins. A
90
+ corrupt file is fatal by name for all four. `config` itself reads the raw environment so it can
91
+ still name a broken file.
92
+ - The agent home is the setting (`PERISCOPE_AGENT_HOME`, default `<home>/.claude`); the transcripts
93
+ root derives from it.
94
+
95
+ ## Sessions
96
+
97
+ `SessionRegistry` owns session lifetime as instance state, so two embedders in one process cannot
98
+ see each other's sessions; everyone else borrows a `HostedSession` handle.
99
+
100
+ - The agent emits nothing until a turn is queued. `create()` is synchronous provisioning with no id;
101
+ `open(request & { prompt })` is the ordinary one-call shape. Waiting for output before prompting
102
+ hangs with no diagnostic.
103
+ - The spawn environment is a derived allow-list, never inherited (`src/sessions/spawn-env.ts`):
104
+ exact, prefix and suffix rules, case-insensitive, deny before allow, and the host-session strip
105
+ (`HOST_SESSION_MARKERS`) runs last, after `extraEnv`, so the recorded hazards cannot be
106
+ re-admitted. Product-specific keys come in through `extraAllowedKeys` and `extraEnv`.
107
+ - Workspace trust is read, never granted (`src/host/workspace-trust.ts`). The package default is
108
+ `settingSources: []`, the only setting under which the host can state what an agent's permissions
109
+ are; what it costs is `CLAUDE.md` loading. The project tier loads only in a directory the CLI
110
+ trusts, which a provisioned worktree is not.
111
+ - One consumer of the SDK stream: `HostedSession` pumps it once and fans out on `onMessage`,
112
+ `onEnd` and `onDegrade`. Subscribe; never iterate the stream yourself. A throwing subscriber
113
+ becomes a `subscriber_failed` degrade and cannot kill the pump.
114
+ - The CLI version is a per-spawn fact on `system/init`. `apiKeySource` is provenance, never an
115
+ "is this authenticated" predicate: it reads `none` on a session that billed.
116
+
117
+ A `session_prompt` arriving while its session is still opening is held, not refused. `session_new`
118
+ awaits the workspace provider, and a git worktree takes seconds; the host acknowledges no controller
119
+ frame, so the sender cannot know the open finished. A held turn is delivered when the session
120
+ registers, refused in the open's `finally` on every failure path, or withdrawn by a `session_cancel`
121
+ during the open; it is never dropped. The hold is bounded at `MAX_HELD_TURNS`; overflow refuses
122
+ `session-unknown` with the bound in its detail. A cancel during the open withdraws the turn and does
123
+ not stop the open. `HostEvent` reports `prompt-held`, `prompt-delivered` and `prompt-withdrawn`.
124
+
125
+ ## The two read-only doors
126
+
127
+ `src/host/claude-transcripts.ts` enumerates and tails the agent CLI's own transcript directory
128
+ (`<agentHome>/projects`), so a controller can list and open sessions the machine ran outside this
129
+ host. `src/host/paths.ts` names the same directory, the configured agent home included, to
130
+ protect it from the sessions this host runs;
131
+ the two postures do not touch, and nothing here can write. Read-only is a property: the modules'
132
+ whole filesystem surface is `createReadStream`, `readdir`, `stat` and `realpath`, held by
133
+ `pins/transcript-readonly.test.ts`.
134
+
135
+ - Every caller-supplied name passes three layers: a strict allowlist, an explicit `.` and `..`
136
+ reject, and resolve-then-containment. A violation refuses `transcript-path-escape` naming the
137
+ layer. An absent transcript is a value, never a refusal.
138
+ - Project slugs are opaque; the CLI's flattening of a path is not invertible and nothing here tries.
139
+ - The CLI rewrites a transcript on compaction, so every answer carrying an offset also carries the
140
+ file's size and mtime; a caller seeing either move re-reads whole.
141
+ - Each listed transcript carries the `cwd` the CLI recorded, because the CLI keeps transcripts per
142
+ directory: a resume must run there or it becomes a fresh session that says nothing.
143
+
144
+ `src/host/repository-read.ts` reads the repository under `PERISCOPE_REPOSITORY_ROOT`:
145
+ `repository_list` (names, sorted, capped at `MAX_REPOSITORY_ENTRIES`) and `repository_read` (a text
146
+ head capped at `MAX_REPOSITORY_READ_BYTES`, cut on a character boundary, with the whole size). The
147
+ jail is lexical (join, resolve, contain) and physical (`realpath` both sides, contain again); a file
148
+ whose head holds a NUL byte refuses `repository-read-failed` rather than shipping binary as text.
149
+ The listing does not follow links; the read does.
150
+
151
+ ## Workspaces
152
+
153
+ `WorkspaceProvider` is the seam the SDK does not have: what a workspace is. Two implementations
154
+ ship, `PlainDirProvider` and `GitWorktreeProvider`; policies (a shared directory per piece of work, a
155
+ cleanup schedule) belong to the embedder. Every result is a named refusal, never an exception.
156
+
157
+ - The binary selects the git provider on the presence of `PERISCOPE_REPOSITORY_ROOT`
158
+ (`src/bin/workspaces.ts`). `PERISCOPE_WORKSPACE_ROOT` alone gives plain directories. Neither gives
159
+ no provider and the controller's `cwd` verbatim, and a directory nobody created fails the spawn.
160
+ - The workspace key resolves `session_new.workspaceKey ?? PERISCOPE_WORKSPACE_KEY ?? sessionKey`,
161
+ each a default for an absence. It is screened before any claim (`src/core/workspace-id.ts`: the
162
+ union of git refname law and path-segment law, plus `MAX_WORKSPACE_ID_LENGTH`; the refusal names
163
+ the character and which law rejects it), and a non-null key with no provider refuses
164
+ `workspace-provision-failed`. Sessions can share a key; the in-use guard holds until the last
165
+ closes, inside the per-key turn (`src/core/keyed-turns.ts` serialises provision, release and
166
+ close per key, and different keys interleave freely).
167
+ - The branch scheme is the caller's template: `PERISCOPE_BRANCH_SCHEME` with `{key}` and `{repo}`,
168
+ default `{repo}/{key}`. The posture is screened at start-up, fatally, by name: an unknown
169
+ placeholder, an illegal literal, a brace that survives substitution, a dependent setting absent.
170
+ - `inventory()` lists the worktrees contained by the workspace root (never the operator's checkout),
171
+ newest tip first, with branch, head, merged (git's answer: a squash merge reads unmerged),
172
+ `aheadCount` and `lastCommitAt`.
173
+ - `release(key, { remove, deleteBranch, force })`: `remove` alone leaves the branch; `deleteBranch`
174
+ deletes a merged branch; an unmerged branch refuses `branch-not-merged` and removes nothing unless
175
+ `force`. The receipt states `directoryRemoved` and `branchDeleted` separately. A session ending
176
+ leaves its directory; the reap is the one path that removes one.
177
+ - `host_configure` reconfigures the running host (`src/bin/reconfigure.ts`): the two roots, the
178
+ branch scheme, the agent home and the two control-plane URLs. A root change while a session is
179
+ open or opening refuses `config-host-busy`. The URLs are written but apply at the next start, and
180
+ are named in `pendingRestart` on every hello and answer until then.
181
+ - Under a provider the controller's `cwd` is advisory; the `spawning` transition carries the
182
+ directory the session got. One exception: a `cwd` equal to the provider's own `repositoryRoot`
183
+ runs there, the operator's trusted checkout, claiming no key. A resume runs where its transcript
184
+ lives or refuses `resume-cwd-not-honoured` on the wire.
185
+
186
+ ## MCP
187
+
188
+ The host's own MCP server registers tool descriptors the embedder supplies: JSON in, Zod inside
189
+ (`src/mcp/schema.ts`). An unconvertible construct is refused at registration, never widened to
190
+ `z.any()`, never skipped. No payload kind carries a descriptor. A controller declares MCP servers
191
+ through `session_new.request.mcpServers` (a name colliding with a host-registered server is
192
+ refused), and every tool they add is decided by the same gate as `Bash`. `identity` is filled by
193
+ the host at call time.
194
+
195
+ ## Identity
196
+
197
+ The host signs in a real user through a generic OIDC client and presents that user's token; a
198
+ paired machine credential is the alternative for a host meant to stay up.
199
+
200
+ - The authority and client id are configuration (`PERISCOPE_IDENTITY_AUTHORITY`,
201
+ `PERISCOPE_IDENTITY_CLIENT_ID`), never a constant; absence is a named refusal at start-up.
202
+ Loopback with PKCE is the primary flow; the device-code flow is opt-in. `offline_access` is in
203
+ the default scope set so an unattended host can refresh.
204
+ - `readCredential` prefers the paired credential (`p1.<hostId>.<secret>`, beside the token cache)
205
+ over the token cache even when both exist: a refresh token dies after inactivity and a paired
206
+ credential has no clock. A missing paired file falls through; a corrupt one is fatal, never a
207
+ fallback. The paired credential's host id outranks `PERISCOPE_HOST_ID` at the hello.
208
+ - A controller that refuses the credential at the door (401 or 403) is `link-unauthorized`, and it
209
+ is terminal: the process exits 1 naming it, because redialling a door that refused the identity
210
+ is a loop with no exit.
211
+ - The token cache and the paired credential are protected by derivation: `credentialPaths(env)` is
212
+ the one source of the gate's protected set and the cache's own location.
213
+ - File-mode privacy is measured, never assumed from the platform: `enforced`, `write-bit-only`
214
+ (the Windows shape) or `unobservable`. A mode that is not enforced is said by name as a degrade.
215
+ - `signIn` and `signInWithDeviceCode` are exported for an embedder; the daemon does not call them.
216
+
217
+ ## Persistence and telemetry
218
+
219
+ - A delivery receipt is read from raw stored entries, never from the SDK's conversation reader:
220
+ compaction relinks `parentUuid`, so a walk cannot reach pre-compaction turns. Three defences hold
221
+ the receipt: raw entries, a uuid anchor (never a numeric offset), and compaction-produced entries
222
+ excluded as candidates.
223
+ - The transition log is append-only, and that is load-bearing ([state-machine.md](state-machine.md)).
224
+ - Spend is consumed as the agent reports it, per model. Nothing multiplies tokens by a rate.
225
+
226
+ ## What ships
227
+
228
+ - Exports are exactly `.` and `./protocol`; a third subpath would reopen the boundary
229
+ (`pins/package-shape.test.ts`, `pins/protocol-closure.test.ts`).
230
+ - `files` is an allowlist: `dist/` without tests, maps, pins and test support, plus README,
231
+ SECURITY and LICENSE. `npm pack` is gated against it.
232
+ - `contracts/` holds the approved snapshots, in the repository and not the tarball: `wire-vectors/`
233
+ (every payload kind byte for byte, the refusal and tolerance cases), `public-api.txt` (every
234
+ export of both barrels with its signature), and the installed SDK's type hash with its version.
235
+ `npm run contracts:update` re-approves the first two after an intended change;
236
+ `npm run check:drift` compares the SDK.
237
+ - The suite is `npm test`: a clean build, then every `*.test.js`. Its figure is four numbers per
238
+ platform (tests, pass, fail, skipped) with the invocation. Live probes skip loudly unless
239
+ `PERISCOPE_LIVE=1`.
240
+
241
+ ## How not to use it
242
+
243
+ - Do not compose by hand and leave `grantOnAllow` off: the gate can refuse and cannot approve, and
244
+ every allowed call silently fails to happen. Use `PeriscopeHost`, or set the flag and pass
245
+ `onRefusal` and `onDegrade`.
246
+ - Do not assume the daemon signs anyone in. `login` writes the token cache; `pair` writes the
247
+ paired credential.
248
+ - Do not assume a host's id is what its config says; the paired credential's id wins.
249
+ - Do not point a bulk `postUrl` outside the controller's own origin; it refuses
250
+ `bulk-target-not-controller`.
251
+ - Do not expect one transport. A controller serves the WebSocket and an HTTP decision endpoint.
252
+ - Do not add an inbound port, a payload lane or a second stream consumer. Dial-out, commands only
253
+ and a single pump are the design.
254
+ - Do not teach it a controller's vocabulary, a roster, or an interpretation of `correlationId`.
255
+ - Do not make retransmission skip deltas, and do not put deltas in a durable store; both halves are
256
+ pinned.
257
+ - One controller and one replica per host.
258
+
259
+ ## Where things live
260
+
261
+ | Path | What |
262
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
263
+ | `src/index.ts` | The public barrel; anything not exported here or from `protocol.ts` is internal |
264
+ | `src/protocol.ts` | The `./protocol` subpath: types and runtime values whose closure reaches no `host/` file and no `node:` builtin |
265
+ | `src/bin/` | The binary: `command.ts` (the verbs), `main.ts` (dispatch through `Io`), `serve.ts` (the daemon), `login.ts`, `pair.ts`, `config.ts`, `status.ts`, `posture.ts`, `workspaces.ts` (the provider selector and the scheme), `reconfigure.ts` |
266
+ | `src/host/` | The only importer of the SDK and the process-reaching builtins: `host.ts`, `agent-process.ts` (the SDK seam), `hooks.ts`, `paths.ts`, `config-file.ts`, `link-state-file.ts`, `claude-transcripts.ts`, `repository-read.ts`, `bulk-post.ts`, `wire-request.ts`, `sign-in.ts` |
267
+ | `src/control/` | The wire: `frames.ts`, `codec.ts`, `seq.ts`, `queue.ts`, `link.ts`, `link-state.ts`, `stream.ts`, `stream-routing.ts` |
268
+ | `src/sessions/` | `registry.ts`, `session.ts`, `spawn-env.ts` |
269
+ | `src/state/` | `model.ts`, `machine.ts`, `observer.ts`, `store.ts`, `coverage.ts`, `reporter.ts` |
270
+ | `src/gate/` | `gate.ts`, `decision.ts`, `outcome.ts`, `local.ts`, `jail.ts`, `shell.ts`, `command.ts`, `escalate.ts` |
271
+ | `src/workspace/` | `provider.ts`, `plain-dir.ts`, `git-worktree.ts`, `worktree-porcelain.ts` |
272
+ | `src/identity/` | Generic OIDC: config, PKCE, authorize, device code, the token store, the file-mode measurement |
273
+ | `src/mcp/` | JSON Schema to Zod, the descriptor, the server |
274
+ | `src/persistence/`, `src/telemetry/` | The receipt, the transition log, per-model spend |
275
+ | `src/pins/` | The structural tests; `walk.ts` is the tree walker they share |
276
+ | `examples/` | The minimal controller, the reference controller with its runnable entry, and the parallel-run proof |
277
+ | `contracts/` | The approved snapshots |