@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,417 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { isAbsolutePath, isContainedBy, normalizePath } from '../core/paths.js';
3
+ import { MAX_WORKSPACE_ID_LENGTH } from '../core/workspace-id.js';
4
+ import { parseBranchList, parseBranchTips, parseWorktreePorcelain } from './worktree-porcelain.js';
5
+ /**
6
+ * The exact `git worktree add` argv, and the one decision that matters.
7
+ *
8
+ * `branchExists` decides between two different commands, not between two spellings of one.
9
+ * exists gives `worktree add <path> <branch>` attach: the branch keeps its own tip.
10
+ * new gives `worktree add -B <branch> <path> <baseRef>` create, off the base ref.
11
+ * Passing `-B` in the first case hard-resets the branch and discards its commits. See this file's
12
+ * header — that is not a hypothetical.
13
+ *
14
+ * Pure and total: no filesystem, no git, no I/O. Exported so the rule is testable directly rather
15
+ * than only through a provider that would need a repository to exercise.
16
+ */
17
+ export function worktreeAddArgs(path, branch, baseRef, branchExists) {
18
+ if (branchExists)
19
+ return ['worktree', 'add', path, branch];
20
+ return ['worktree', 'add', '-B', branch, path, baseRef];
21
+ }
22
+ export class GitWorktreeProvider {
23
+ #repositoryRoot;
24
+ /** The checkout this provider clones from — see `WorkspaceProvider.repositoryRoot`. */
25
+ get repositoryRoot() {
26
+ return this.#repositoryRoot;
27
+ }
28
+ #workspaceRoot;
29
+ #branchFor;
30
+ #baseRef;
31
+ #effects;
32
+ #commands;
33
+ constructor(options) {
34
+ this.#repositoryRoot = normalizePath(options.repositoryRoot);
35
+ this.#workspaceRoot = normalizePath(options.workspaceRoot);
36
+ this.#branchFor = options.branchFor ?? ((sessionId) => `periscope/${sessionId}`);
37
+ this.#baseRef = options.baseRef ?? 'HEAD';
38
+ this.#effects = options.effects;
39
+ this.#commands = options.commands;
40
+ }
41
+ /** Where this session's worktree is, whether or not it exists yet. */
42
+ pathFor(sessionId) {
43
+ return `${this.#workspaceRoot}/${sessionId}`;
44
+ }
45
+ async provision(sessionId) {
46
+ const invalid = rejectUnusableId(sessionId);
47
+ if (invalid !== null)
48
+ return invalid;
49
+ if (!isAbsolutePath(this.#workspaceRoot) || !isAbsolutePath(this.#repositoryRoot)) {
50
+ return refuse('workspace-provision-failed', `both roots must be absolute: repository ${this.#repositoryRoot}, workspace ${this.#workspaceRoot}`);
51
+ }
52
+ const path = this.pathFor(sessionId);
53
+ try {
54
+ // Inside the try, deliberately: `branchFor` is caller-supplied and may refuse a render by
55
+ // throwing (a scheme whose output fails the refname screen). A throw here must become the
56
+ // same named `workspace-provision-failed` every other provisioning failure gets — the
57
+ // provider's own contract is results, never exceptions.
58
+ const branch = this.#branchFor(sessionId);
59
+ // An existing directory is attached to, never recreated. The whole point of re-provisioning is
60
+ // that whatever is there survives — including the case where the directory is intact and only
61
+ // this host's memory of it was lost.
62
+ if (await this.#effects.exists(path)) {
63
+ return ok({ path, meta: { branch, baseRef: this.#baseRef, attached: 'directory' } });
64
+ }
65
+ await this.#effects.makeDirectory(this.#workspaceRoot);
66
+ // The probe that decides between attach and create. It runs for every session, not only the
67
+ // ones a caller expects to be re-provisioned: the whole failure is that nobody expects it.
68
+ const branchExists = await this.#branchExists(branch);
69
+ await this.#commands.run('git', worktreeAddArgs(path, branch, this.#baseRef, branchExists), this.#repositoryRoot);
70
+ return ok({
71
+ path,
72
+ meta: {
73
+ branch,
74
+ baseRef: this.#baseRef,
75
+ // Reported rather than inferred: an operator reading this can tell whether their commits
76
+ // were preserved by attachment or whether the branch was created fresh.
77
+ attached: branchExists ? 'branch' : 'created',
78
+ },
79
+ });
80
+ }
81
+ catch (error) {
82
+ return refuse('workspace-provision-failed', `could not provision a worktree for ${sessionId} at ${path}: ${describe(error)}`);
83
+ }
84
+ }
85
+ /**
86
+ * Release the worktree.
87
+ *
88
+ * The branch is never deleted unless the caller names it in the ask. Removing a directory is
89
+ * reversible — the commits are still on the branch. Deleting a branch is not, so branch deletion
90
+ * is opt-in per call (`deleteBranch`), refused for an unmerged branch unless forced, and the
91
+ * merged check runs before anything is removed: a refusal never leaves a half-cleaned key. A
92
+ * deletion that fails after the directory went is a partial, stated on the receipt.
93
+ *
94
+ * The branch is the one the worktree is on (from `worktree list`), which may differ from the
95
+ * scheme's render if an agent checked out elsewhere; only when the directory is already gone is
96
+ * the scheme's render the name.
97
+ */
98
+ async release(sessionId, options) {
99
+ if (options?.remove !== true)
100
+ return ok(undefined);
101
+ const path = this.pathFor(sessionId);
102
+ const receipt = (directoryRemoved, branchDeleted, refusal = null) => ({
103
+ path,
104
+ directoryRemoved,
105
+ branchDeleted,
106
+ refusal,
107
+ });
108
+ try {
109
+ const existed = await this.#effects.exists(path);
110
+ if (options.deleteBranch !== true) {
111
+ if (!existed)
112
+ return ok(receipt(false, false));
113
+ await this.#removeWorktree(path);
114
+ return ok(receipt(true, false));
115
+ }
116
+ const branch = (existed ? await this.#branchAt(path) : null) ?? this.#branchFor(sessionId);
117
+ if (!existed && !(await this.#branchExists(branch)))
118
+ return ok(receipt(false, false));
119
+ if (options.force !== true) {
120
+ const unmerged = await this.#unmergedProblem(branch);
121
+ if (unmerged !== null)
122
+ return refuse('branch-not-merged', `${unmerged} — nothing was removed`);
123
+ }
124
+ if (existed)
125
+ await this.#removeWorktree(path);
126
+ try {
127
+ await this.#commands.run('git', ['branch', options.force === true ? '-D' : '-d', branch], this.#repositoryRoot);
128
+ }
129
+ catch (error) {
130
+ const removed = existed ? `removed the worktree at ${path} but ` : '';
131
+ return ok(receipt(existed, false, {
132
+ reason: 'workspace-release-failed',
133
+ detail: `${removed}could not delete branch ${branch}: ${describe(error)}`,
134
+ }));
135
+ }
136
+ return ok(receipt(existed, true));
137
+ }
138
+ catch (error) {
139
+ return refuse('workspace-release-failed', `could not release the worktree for ${sessionId} at ${path}: ${describe(error)}`);
140
+ }
141
+ }
142
+ /** The key of the directory at `path` when it sits directly under the workspace root, else null. */
143
+ keyForPath(path) {
144
+ return keyDirectlyUnder(path, this.#workspaceRoot);
145
+ }
146
+ /**
147
+ * `--force` because a worktree with uncommitted changes is still a worktree the caller asked to
148
+ * remove; refusing here would leave it registered with no way for this API to clear it.
149
+ */
150
+ async #removeWorktree(path) {
151
+ await this.#commands.run('git', ['worktree', 'remove', '--force', path], this.#repositoryRoot);
152
+ }
153
+ /** The branch the worktree at `path` is on, or null when it is detached or not a worktree git knows. */
154
+ async #branchAt(path) {
155
+ const porcelain = await this.#commands.run('git', ['worktree', 'list', '--porcelain'], this.#repositoryRoot);
156
+ const entry = parseWorktreePorcelain(porcelain).find((worktree) => normalizePath(worktree.path) === path);
157
+ return entry?.branch ?? null;
158
+ }
159
+ /** Why `branch` may not be deleted without force, or null when it is merged into the default branch. */
160
+ async #unmergedProblem(branch) {
161
+ const defaultBranch = await this.#defaultBranch();
162
+ if (defaultBranch === null) {
163
+ return `branch ${branch} cannot be judged merged: the repository names no default branch; force the deletion to delete it anyway`;
164
+ }
165
+ const merged = parseBranchList(await this.#commands.run('git', ['branch', '--merged', defaultBranch, '--format=%(refname:short)'], this.#repositoryRoot));
166
+ if (merged.has(branch))
167
+ return null;
168
+ return `branch ${branch} is not merged into ${defaultBranch}; force the deletion to delete it anyway`;
169
+ }
170
+ /**
171
+ * Every worktree under the workspace root, from disk, with what git knows about its branch.
172
+ *
173
+ * Three commands for the whole list, plus one `rev-list --count` per branch entry: `worktree list --porcelain` for the worktrees, `for-each-ref`
174
+ * for every branch tip's date, and `branch --merged <default>` for the merged set. Only worktrees
175
+ * CONTAINED BY the workspace root are reported: the repository itself and any checkout the
176
+ * operator keeps elsewhere are not this provider's to list, so no cleanup composed from this
177
+ * answer can name them. `merged` is null when the repository names no default branch.
178
+ */
179
+ async inventory() {
180
+ try {
181
+ const porcelain = await this.#commands.run('git', ['worktree', 'list', '--porcelain'], this.#repositoryRoot);
182
+ const provisioned = parseWorktreePorcelain(porcelain)
183
+ .map((worktree) => ({ ...worktree, path: normalizePath(worktree.path) }))
184
+ .filter((worktree) => !worktree.bare &&
185
+ worktree.path !== this.#workspaceRoot &&
186
+ isContainedBy(worktree.path, this.#workspaceRoot));
187
+ const defaultBranch = await this.#defaultBranch();
188
+ const merged = defaultBranch === null
189
+ ? null
190
+ : parseBranchList(await this.#commands.run('git', ['branch', '--merged', defaultBranch, '--format=%(refname:short)'], this.#repositoryRoot));
191
+ const tips = parseBranchTips(await this.#commands.run('git', ['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso-strict)', 'refs/heads/'], this.#repositoryRoot));
192
+ const entries = [];
193
+ for (const worktree of provisioned) {
194
+ entries.push({
195
+ key: lastSegment(worktree.path),
196
+ path: worktree.path,
197
+ branch: worktree.branch,
198
+ head: worktree.head,
199
+ detached: worktree.detached,
200
+ locked: worktree.locked,
201
+ prunable: worktree.prunable,
202
+ merged: merged === null || worktree.branch === null ? null : merged.has(worktree.branch),
203
+ // One `rev-list --count` per BRANCH entry: the third reading ("nothing here yet") needs the
204
+ // count, and git offers no batch form of it. A count that fails to read is null, never zero.
205
+ aheadCount: defaultBranch === null || worktree.branch === null
206
+ ? null
207
+ : await this.#aheadCount(defaultBranch, worktree.branch),
208
+ lastCommitAt: worktree.branch === null ? null : (tips.get(worktree.branch) ?? null),
209
+ });
210
+ }
211
+ // Newest first by the tip's date, the ones without a date last, ties by key: the order a
212
+ // controller pages through, so a fresh worktree is on the first page rather than wherever git
213
+ // happened to list it.
214
+ entries.sort(byNewestTip);
215
+ return ok({ entries, defaultBranch });
216
+ }
217
+ catch (error) {
218
+ return refuse('workspace-list-failed', describe(error));
219
+ }
220
+ }
221
+ /**
222
+ * The branch `merged` is judged against: the remote's HEAD when the repository has one, else
223
+ * `main`, else `master`, else null. Probed, never assumed, and a probe that fails reads as
224
+ * "not this one" because every arm's failure is the absence it tests for.
225
+ */
226
+ async #defaultBranch() {
227
+ try {
228
+ const remoteHead = await this.#commands.run('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], this.#repositoryRoot);
229
+ const short = remoteHead.replace(/^origin\//, '').trim();
230
+ if (short !== '')
231
+ return short;
232
+ }
233
+ catch {
234
+ // No remote HEAD recorded; fall through to the conventional names.
235
+ }
236
+ for (const candidate of ['main', 'master']) {
237
+ if (await this.#branchExists(candidate))
238
+ return candidate;
239
+ }
240
+ return null;
241
+ }
242
+ /** Commits on `branch` past `defaultBranch`, or null when git cannot say. */
243
+ async #aheadCount(defaultBranch, branch) {
244
+ try {
245
+ const text = await this.#commands.run('git', ['rev-list', '--count', `${defaultBranch}..${branch}`], this.#repositoryRoot);
246
+ const count = Number.parseInt(text.trim(), 10);
247
+ return Number.isInteger(count) && count >= 0 ? count : null;
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ /** `rev-parse --verify` succeeds for a branch that exists and fails for one that does not. */
254
+ async #branchExists(branch) {
255
+ try {
256
+ await this.#commands.run('git', ['rev-parse', '--verify', branch], this.#repositoryRoot);
257
+ return true;
258
+ }
259
+ catch {
260
+ // A failing probe means "treat it as new", which takes the `-B` form — so a probe that fails
261
+ // for a reason other than the branch being absent (a broken git, a bad repository path) would
262
+ // hard-reset an existing branch. That is why `provision` runs the probe inside its own
263
+ // try/catch: the `add` that follows fails too, and the refusal names the whole operation
264
+ // rather than silently taking the destructive path.
265
+ return false;
266
+ }
267
+ }
268
+ }
269
+ /**
270
+ * A session id that cannot safely become a path segment or a branch-name component.
271
+ *
272
+ * Refused rather than sanitized. Rewriting an id to make it safe means the directory no longer
273
+ * corresponds to the id the caller used, so two different sessions can collapse onto one workspace —
274
+ * which is the exact isolation property the plain-directory tests check, broken quietly by a
275
+ * helpful-looking fix.
276
+ *
277
+ * The screen is the union of two rule sets. The id becomes both a directory segment (win32/POSIX)
278
+ * and a git branch-name component (`periscope/{id}` by default); an id containing a character that
279
+ * only git refuses would pass a separators-only screen and fail inside git as an unnamed
280
+ * `workspace-provision-failed`. So the guard screens every character either rule set refuses — the
281
+ * `git check-ref-format` component rules together with the win32 segment rules — and the refusal
282
+ * names the offending character and its rule. Deliberately not screened: win32 reserved device
283
+ * names (CON, NUL, …) — a name-list, not a character class; those still die loudly at provision
284
+ * under the named refusal below.
285
+ */
286
+ function rejectUnusableId(sessionId) {
287
+ if (sessionId.trim() === '') {
288
+ return refuse('workspace-provision-failed', 'a session id is required to name a workspace');
289
+ }
290
+ if (sessionId.length > MAX_WORKSPACE_ID_LENGTH) {
291
+ return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} is longer than ${MAX_WORKSPACE_ID_LENGTH} characters, so it cannot name a worktree branch or directory`);
292
+ }
293
+ if (/[\\/]/.test(sessionId) || sessionId.includes('..')) {
294
+ return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} contains path separators or a parent reference, so it cannot name a directory`);
295
+ }
296
+ const illegality = refnameOrPathIllegality(sessionId);
297
+ if (illegality !== null) {
298
+ return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} ${illegality}, so it cannot name a worktree branch or directory`);
299
+ }
300
+ return null;
301
+ }
302
+ /** Which rule refuses each screened character — quoted verbatim into the refusal detail. */
303
+ const CHARACTER_LAWS = new Map([
304
+ [' ', 'illegal in a git refname'],
305
+ [':', 'illegal in a git refname AND a win32 path segment'],
306
+ ['~', 'illegal in a git refname'],
307
+ ['^', 'illegal in a git refname'],
308
+ ['[', 'illegal in a git refname'],
309
+ ['?', 'illegal in a git refname AND a win32 path segment'],
310
+ ['*', 'illegal in a git refname AND a win32 path segment'],
311
+ ['"', 'illegal in a win32 path segment'],
312
+ ['<', 'illegal in a win32 path segment'],
313
+ ['>', 'illegal in a win32 path segment'],
314
+ ['|', 'illegal in a win32 path segment'],
315
+ ]);
316
+ /**
317
+ * The refname/path-segment class check: the first way this id would be refused by
318
+ * `git check-ref-format` (as the component of a branch name) or by win32 (as a path segment), as a
319
+ * human sentence — or null for an id both rule sets accept. Pure and total, exported so the class
320
+ * is testable without a provider.
321
+ */
322
+ export function refnameOrPathIllegality(sessionId) {
323
+ // deliberate: the control range is the thing being refused, so the regex names it
324
+ // eslint-disable-next-line no-control-regex
325
+ const control = sessionId.match(/[\u0000-\u001f\u007f]/);
326
+ if (control !== null) {
327
+ const code = control[0].codePointAt(0).toString(16).padStart(2, '0');
328
+ return `contains a control character (0x${code}) — illegal in a git refname and a win32 path segment`;
329
+ }
330
+ const character = sessionId.match(/[ :~^?*"<>|[]/);
331
+ if (character !== null) {
332
+ return `contains '${character[0]}' — ${CHARACTER_LAWS.get(character[0])}`;
333
+ }
334
+ if (sessionId.includes('@{'))
335
+ return `contains '@{' — illegal in a git refname`;
336
+ if (sessionId.startsWith('.'))
337
+ return `begins with '.' — a git refname component cannot`;
338
+ if (sessionId.endsWith('.lock'))
339
+ return `ends with '.lock' — a git refname component cannot`;
340
+ if (sessionId.endsWith('.'))
341
+ return `ends with '.' — illegal in a git refname component and a win32 path segment`;
342
+ return null;
343
+ }
344
+ export { rejectUnusableId };
345
+ /**
346
+ * The same screen as `rejectUnusableId`, as a sentence a caller can attach to its own noun.
347
+ *
348
+ * The host validates `session_new.workspaceKey` before any workspace is claimed, and its refusal
349
+ * must name the field — "the session id …" would send a controller author to the wrong key. The
350
+ * union class itself stays single-sourced in `refnameOrPathIllegality`; the two structural checks
351
+ * are restated here because their sentences are the caller's to phrase. Keep the three checks in
352
+ * step with `rejectUnusableId` above — a key this accepts and that refuses (or the reverse) would
353
+ * mean the host's guard and the provider's disagree about the same id.
354
+ */
355
+ export function unusableKeyProblem(key) {
356
+ if (key.trim() === '')
357
+ return 'is empty, so it cannot name a workspace';
358
+ if (key.length > MAX_WORKSPACE_ID_LENGTH) {
359
+ return `is ${key.length} characters long — over the ${MAX_WORKSPACE_ID_LENGTH}-character bound, so it cannot name a worktree branch or directory`;
360
+ }
361
+ if (/[\\/]/.test(key) || key.includes('..')) {
362
+ return 'contains path separators or a parent reference, so it cannot name a directory';
363
+ }
364
+ const illegality = refnameOrPathIllegality(key);
365
+ if (illegality !== null)
366
+ return `${illegality}, so it cannot name a worktree branch or directory`;
367
+ return null;
368
+ }
369
+ /** The bound on a workspace id; declared in `core/` so the wire codec can enforce it too. */
370
+ export { MAX_WORKSPACE_ID_LENGTH };
371
+ /**
372
+ * A key safe to echo into a refusal detail: verbatim when short, truncated with its length named
373
+ * when not. Every screen above bounds accepted ids, but the refusal for an over-length id still
374
+ * has to describe the very string that broke the bound — this is the one place an unbounded input
375
+ * reaches a detail, so it is bounded here rather than at each caller's discretion.
376
+ */
377
+ export function keyPreview(id) {
378
+ if (id.length <= 100)
379
+ return id;
380
+ return `${id.slice(0, 100)}… (${id.length} characters)`;
381
+ }
382
+ function describe(error) {
383
+ return error instanceof Error ? error.message : String(error);
384
+ }
385
+ /** Newest tip first; entries with no tip date (detached, unknown) after every dated one; ties by key. */
386
+ export function byNewestTip(a, b) {
387
+ if (a.lastCommitAt === null && b.lastCommitAt === null)
388
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
389
+ if (a.lastCommitAt === null)
390
+ return 1;
391
+ if (b.lastCommitAt === null)
392
+ return -1;
393
+ const byDate = Date.parse(b.lastCommitAt) - Date.parse(a.lastCommitAt);
394
+ if (byDate !== 0 && !Number.isNaN(byDate))
395
+ return byDate;
396
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
397
+ }
398
+ /** The last path segment: the directory name, which is the key a worktree was provisioned at. */
399
+ function lastSegment(path) {
400
+ const segments = path.split('/').filter((segment) => segment !== '');
401
+ return segments[segments.length - 1] ?? '';
402
+ }
403
+ /**
404
+ * The key of `path` when it is an absolute path to a directory DIRECTLY under `root` (normalised),
405
+ * else null: the root itself, a nested path, a relative path and anything elsewhere are all null,
406
+ * so a release addressed by path can only ever name what a provision could have made.
407
+ */
408
+ export function keyDirectlyUnder(path, root) {
409
+ if (!isAbsolutePath(path))
410
+ return null;
411
+ const candidate = normalizePath(path);
412
+ if (candidate === root || !isContainedBy(candidate, root))
413
+ return null;
414
+ const key = lastSegment(candidate);
415
+ return candidate === `${root}/${key}` ? key : null;
416
+ }
417
+ //# sourceMappingURL=git-worktree.js.map
@@ -0,0 +1,9 @@
1
+ /** Workspace provisioning and the cwd a session runs in. */
2
+ export type { CommandEffects, ReleaseOptions, ReleaseReceipt, Workspace, WorkspaceEffects, WorkspaceEntry, WorkspaceInventory, WorkspaceProvider, } from './provider.js';
3
+ export type { PorcelainWorktree } from './worktree-porcelain.js';
4
+ export { parseBranchList, parseBranchTips, parseWorktreePorcelain } from './worktree-porcelain.js';
5
+ export type { PlainDirProviderOptions } from './plain-dir.js';
6
+ export { PlainDirProvider } from './plain-dir.js';
7
+ export type { GitWorktreeProviderOptions } from './git-worktree.js';
8
+ export { GitWorktreeProvider, worktreeAddArgs } from './git-worktree.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { parseBranchList, parseBranchTips, parseWorktreePorcelain } from './worktree-porcelain.js';
2
+ export { PlainDirProvider } from './plain-dir.js';
3
+ export { GitWorktreeProvider, worktreeAddArgs } from './git-worktree.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * One plain directory per session, under a root the embedder names.
3
+ *
4
+ * The simplest thing that satisfies the contract, and enough to prove an agent works: no git, no
5
+ * branches, no conventions. A code-review tool that clones somewhere itself, or a host running
6
+ * agents over scratch space, wants exactly this and nothing more.
7
+ *
8
+ * Isolation is by construction, not by check. Each session's directory is a distinct child of the
9
+ * root named by its own id, so two sessions cannot share one unless they share an id — and ids are
10
+ * refused if they contain separators, which is the only way one could climb into another's.
11
+ */
12
+ import type { Result } from '../core/result.js';
13
+ import type { ReleaseOptions, ReleaseReceipt, Workspace, WorkspaceEffects, WorkspaceInventory, WorkspaceProvider } from './provider.js';
14
+ export interface PlainDirProviderOptions {
15
+ /** Absolute. Every session directory is created directly beneath it. */
16
+ readonly root: string;
17
+ readonly effects: WorkspaceEffects;
18
+ }
19
+ export declare class PlainDirProvider implements WorkspaceProvider {
20
+ #private;
21
+ constructor(options: PlainDirProviderOptions);
22
+ /** Where this session's directory is, whether or not it exists yet. */
23
+ pathFor(sessionId: string): string;
24
+ /**
25
+ * The directories under the root. A plain directory has no branch, so every git-shaped member is
26
+ * null or false, and `merged` is null: nothing here can say whether work is kept anywhere.
27
+ */
28
+ inventory(): Promise<Result<WorkspaceInventory>>;
29
+ provision(sessionId: string): Promise<Result<Workspace>>;
30
+ /** The key of the directory at `path` when it sits directly under the root, else null. */
31
+ keyForPath(path: string): string | null;
32
+ release(sessionId: string, options?: ReleaseOptions): Promise<Result<ReleaseReceipt | undefined>>;
33
+ }
34
+ //# sourceMappingURL=plain-dir.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { isAbsolutePath, normalizePath } from '../core/paths.js';
3
+ import { keyDirectlyUnder, rejectUnusableId } from './git-worktree.js';
4
+ export class PlainDirProvider {
5
+ #root;
6
+ #effects;
7
+ constructor(options) {
8
+ this.#root = normalizePath(options.root);
9
+ this.#effects = options.effects;
10
+ }
11
+ /** Where this session's directory is, whether or not it exists yet. */
12
+ pathFor(sessionId) {
13
+ return `${this.#root}/${sessionId}`;
14
+ }
15
+ /**
16
+ * The directories under the root. A plain directory has no branch, so every git-shaped member is
17
+ * null or false, and `merged` is null: nothing here can say whether work is kept anywhere.
18
+ */
19
+ async inventory() {
20
+ const list = this.#effects.listDirectories?.bind(this.#effects);
21
+ if (list === undefined) {
22
+ return refuse('workspace-list-failed', 'this provider has no directory listing to read');
23
+ }
24
+ try {
25
+ const names = await list(this.#root);
26
+ const entries = names.map((name) => ({
27
+ key: name,
28
+ path: `${this.#root}/${name}`,
29
+ branch: null,
30
+ head: null,
31
+ detached: false,
32
+ locked: false,
33
+ prunable: false,
34
+ merged: null,
35
+ aheadCount: null,
36
+ lastCommitAt: null,
37
+ }));
38
+ return ok({ entries, defaultBranch: null });
39
+ }
40
+ catch (error) {
41
+ return refuse('workspace-list-failed', describe(error));
42
+ }
43
+ }
44
+ async provision(sessionId) {
45
+ const invalid = rejectUnusableId(sessionId);
46
+ if (invalid !== null)
47
+ return invalid;
48
+ if (!isAbsolutePath(this.#root)) {
49
+ return refuse('workspace-provision-failed', `the workspace root must be absolute: ${this.#root}`);
50
+ }
51
+ const path = this.pathFor(sessionId);
52
+ try {
53
+ // Creating an existing directory is a no-op, so re-provisioning attaches to whatever is there
54
+ // rather than replacing it. Same rule as the worktree provider, for the same reason: a host
55
+ // that lost track of a directory must not destroy its contents on the way back.
56
+ const reused = await this.#effects.exists(path);
57
+ await this.#effects.makeDirectory(path);
58
+ return ok({ path, meta: { attached: reused ? 'directory' : 'created' } });
59
+ }
60
+ catch (error) {
61
+ return refuse('workspace-provision-failed', `could not provision a directory for ${sessionId} at ${path}: ${describe(error)}`);
62
+ }
63
+ }
64
+ /** The key of the directory at `path` when it sits directly under the root, else null. */
65
+ keyForPath(path) {
66
+ return keyDirectlyUnder(path, this.#root);
67
+ }
68
+ async release(sessionId, options) {
69
+ // The default is to leave it — see ReleaseOptions. A directory nobody asked to remove is
70
+ // evidence, and deleting it by default destroys it exactly when a session ended badly.
71
+ if (options?.remove !== true)
72
+ return ok(undefined);
73
+ // A plain directory has no branch, so `deleteBranch` finds nothing to delete: the receipt says
74
+ // so (`branchDeleted: false`, no refusal), the idempotent answer for a thing already absent.
75
+ const path = this.pathFor(sessionId);
76
+ try {
77
+ const existed = await this.#effects.exists(path);
78
+ if (existed)
79
+ await this.#effects.removeDirectory(path);
80
+ return ok({ path, directoryRemoved: existed, branchDeleted: false, refusal: null });
81
+ }
82
+ catch (error) {
83
+ return refuse('workspace-release-failed', `could not release the directory for ${sessionId} at ${path}: ${describe(error)}`);
84
+ }
85
+ }
86
+ }
87
+ function describe(error) {
88
+ return error instanceof Error ? error.message : String(error);
89
+ }
90
+ //# sourceMappingURL=plain-dir.js.map