@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Which workspace provider a real process gets.
3
+ *
4
+ * Split out of the composition root (`main.ts` and `serve.ts`) rather than exported from it, for
5
+ * the reason that file's own header gives: everything below the composition root takes its
6
+ * configuration as arguments, which is what makes the rest of the package testable without a
7
+ * process. The selector is pure and lives where it can be called on its own.
8
+ */
9
+ import type { HostConfiguration } from '../control/frames.js';
10
+ import {
11
+ GitWorktreeProvider,
12
+ keyPreview,
13
+ refnameOrPathIllegality,
14
+ unusableKeyProblem,
15
+ } from '../workspace/git-worktree.js';
16
+ import { PlainDirProvider } from '../workspace/plain-dir.js';
17
+ import type { WorkspaceProvider } from '../workspace/provider.js';
18
+ import { nodeCommandEffects, nodeWorkspaceEffects } from '../host/workspace-fs.js';
19
+
20
+ /** The environment answers the selector reads. A subset of the binary's `Config`, by structure. */
21
+ export interface WorkspaceConfig {
22
+ /** When set, every session gets a directory beneath it instead of the one the controller named. */
23
+ readonly workspaceRoot: string | null;
24
+ /** When set alongside the root, sessions get a linked git worktree on their own branch. */
25
+ readonly repositoryRoot: string | null;
26
+ /**
27
+ * The branch template a worktree's branch is rendered from: `{key}` and `{repo}` are the
28
+ * two placeholders, e.g. `periscope/{repo}/{key}`. Null means the provider's own fallback
29
+ * (`periscope/{sessionId}`).
30
+ */
31
+ readonly branchScheme: string | null;
32
+ }
33
+
34
+ /** The two placeholder names a branch scheme may use. Anything else refuses at startup by name. */
35
+ const SCHEME_PLACEHOLDERS = ['key', 'repo'] as const;
36
+
37
+ /** Render a branch scheme. Pure; exported so the rule is testable without a provider. */
38
+ export function renderBranch(scheme: string, key: string, repo: string): string {
39
+ return scheme.replaceAll('{key}', key).replaceAll('{repo}', repo);
40
+ }
41
+
42
+ /**
43
+ * The first way this rendered branch name is illegal, or null.
44
+ *
45
+ * A legal key can render an illegal refname: a `{repo}` carrying a dot-prefixed segment, a
46
+ * scheme with a trailing slash, a literal ending `.lock`. Validating the key and then building an
47
+ * unvalidated branch from it would let a branch die inside git on every open, so the result is
48
+ * screened: per slash-separated component, against the same union rule the key already passed
49
+ * (`refnameOrPathIllegality`, the single source of that class).
50
+ */
51
+ export function branchNameProblem(branch: string): string | null {
52
+ if (branch.trim() === '') return 'is empty — a branch must have a name';
53
+ for (const component of branch.split('/')) {
54
+ if (component === '') return `has an empty component — '${branch}' contains a doubled or edge slash`;
55
+ const illegality = refnameOrPathIllegality(component);
56
+ if (illegality !== null) return `has a component '${component}' that ${illegality}`;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * What is wrong with this workspace posture, at startup, or null when it is usable.
63
+ *
64
+ * Screened when the process boots, not at the first session. A machine that will refuse every
65
+ * session (an unusable default key, a scheme with a typo'd placeholder) must say so when it starts,
66
+ * where the one person who can fix it is looking, not days later when someone finally opens a
67
+ * session. Pure, and exported precisely so it is testable without starting a host.
68
+ *
69
+ * Three families of refusal, each by name:
70
+ * - a setting that depends on another that is absent (a key with no provider, a scheme with no
71
+ * repository); silently ignoring either is a misconfiguration nobody finds;
72
+ * - a default workspace key that fails the same union screen a wire-supplied key must pass;
73
+ * - a branch scheme with an unknown placeholder or an unmatched brace (never rendered literally;
74
+ * `{repoo}` or a trailing `{repo` in a branch name is a silent wrong answer), or whose literal
75
+ * text already renders illegally.
76
+ */
77
+ export function workspacePostureProblem(posture: {
78
+ readonly workspaceRoot: string | null;
79
+ readonly repositoryRoot: string | null;
80
+ readonly branchScheme: string | null;
81
+ readonly workspaceKey: string | null;
82
+ }): string | null {
83
+ const hasProvider = posture.workspaceRoot !== null && posture.workspaceRoot !== '';
84
+ const hasRepository = posture.repositoryRoot !== null && posture.repositoryRoot !== '';
85
+
86
+ if (posture.workspaceKey !== null && posture.workspaceKey !== '') {
87
+ if (!hasProvider) {
88
+ return (
89
+ 'PERISCOPE_WORKSPACE_KEY is set but PERISCOPE_WORKSPACE_ROOT is not — with no workspace ' +
90
+ 'provider there is nothing to provision at that key, so the setting would be silently ignored'
91
+ );
92
+ }
93
+ const problem = unusableKeyProblem(posture.workspaceKey);
94
+ if (problem !== null) {
95
+ return `PERISCOPE_WORKSPACE_KEY ${keyPreview(posture.workspaceKey)} ${problem} — every session on this host would refuse`;
96
+ }
97
+ }
98
+
99
+ if (posture.branchScheme !== null && posture.branchScheme !== '') {
100
+ if (!hasProvider || !hasRepository) {
101
+ return (
102
+ 'PERISCOPE_BRANCH_SCHEME is set but sessions get no git worktrees here (it needs both ' +
103
+ 'PERISCOPE_WORKSPACE_ROOT and PERISCOPE_REPOSITORY_ROOT) — the setting would be silently ignored'
104
+ );
105
+ }
106
+ for (const match of posture.branchScheme.matchAll(/\{([^}]*)\}/g)) {
107
+ const placeholder = match[1] ?? '';
108
+ if (!(SCHEME_PLACEHOLDERS as readonly string[]).includes(placeholder)) {
109
+ return (
110
+ `PERISCOPE_BRANCH_SCHEME uses an unknown placeholder '{${placeholder}}' — the two it may use are ` +
111
+ `{key} and {repo}, and an unknown one is refused rather than rendered literally into a branch name`
112
+ );
113
+ }
114
+ }
115
+ // A brace that survives placeholder substitution is by definition not a placeholder this host
116
+ // understands. The loop above sees only well-formed `{...}` groups, so a malformed brace is
117
+ // invisible to it: `periscope/{key}/{repo` would pass and render the literal branch
118
+ // `periscope/K/{repo`, and `{` is refname-legal, so the render screen below cannot catch it
119
+ // either. Screened on the scheme text, not the rendered name, deliberately: a repository
120
+ // directory legitimately named with a brace must still render, so `branchNameProblem` is the
121
+ // wrong layer for this rule.
122
+ const residue = posture.branchScheme.replace(/\{[^}]*\}/g, '');
123
+ if (residue.includes('{') || residue.includes('}')) {
124
+ return (
125
+ `PERISCOPE_BRANCH_SCHEME contains an unmatched '{' or '}' — a brace that survives placeholder ` +
126
+ `substitution is not a placeholder this host understands, and it would render literally into a branch name`
127
+ );
128
+ }
129
+ // The scheme's literal text, screened with benign placeholder values: a trailing slash or a
130
+ // literal `.lock` is wrong for every key, so it is caught here; a violation only a particular
131
+ // key or repo name produces is caught at render, inside the provider's own named refusal.
132
+ const rendered = renderBranch(posture.branchScheme, 'k', 'r');
133
+ const problem = branchNameProblem(rendered);
134
+ if (problem !== null) {
135
+ return `PERISCOPE_BRANCH_SCHEME renders an illegal branch name (${rendered} ${problem})`;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /** The last path segment of a repository root — what `{repo}` renders as. */
143
+ function repositoryName(repositoryRoot: string): string {
144
+ const segments = repositoryRoot.split(/[\\/]+/).filter((segment) => segment !== '');
145
+ return segments[segments.length - 1] ?? '';
146
+ }
147
+
148
+ /**
149
+ * The branch scheme a git-worktree host renders when none is configured: the repository's name,
150
+ * then the key. With keys named after the platform's own identities (`session-150`,
151
+ * `run-34`) this puts every session on `repo/session-150`, the name an operator reads in
152
+ * the session's own URL. Explicitly configured schemes override it; the `workspace:branch-scheme`
153
+ * marker means "explicitly configured", never "this default".
154
+ */
155
+ export const DEFAULT_BRANCH_SCHEME = '{repo}/{key}';
156
+
157
+ /**
158
+ * The `branchFor` a scheme produces, extracted so the render-and-screen rule is testable without
159
+ * a provider, a repository, or a real git (the same reason this whole file exists).
160
+ *
161
+ * A render the screen refuses throws, and the throw is the contract: the provider calls this
162
+ * inside its own try, so the message lands in a named `workspace-provision-failed` refusal rather
163
+ * than dying inside git. This is the runtime half of the screen, for the violations only a
164
+ * particular key or repo name produces (the startup screen already caught the scheme's literal
165
+ * text).
166
+ */
167
+ export function branchRenderer(scheme: string, repositoryRoot: string): (sessionId: string) => string {
168
+ const repo = repositoryName(repositoryRoot);
169
+ return (sessionId: string): string => {
170
+ const branch = renderBranch(scheme, sessionId, repo);
171
+ const problem = branchNameProblem(branch);
172
+ if (problem !== null) {
173
+ throw new Error(
174
+ `the branch scheme '${scheme}' rendered an illegal branch name: '${branch}' ${problem}`,
175
+ );
176
+ }
177
+ return branch;
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The provider selector: which workspace provider, if any, the shipped entry point composes.
183
+ *
184
+ * With no workspace root there is no provider at all and every session on a host shares the
185
+ * controller's `cwd` verbatim, the weakest isolation available. With a workspace root alone, each
186
+ * session gets a plain directory and no branch. With a repository root as well, each session gets
187
+ * a linked git worktree on its own branch, which is what lets a controller admit a push to a
188
+ * session's own branch.
189
+ *
190
+ * The presence of a repository root is the mode switch: a deployment that does not set one gets
191
+ * plain directories. There is no boolean to get backwards, and the git
192
+ * mode cannot be selected without naming the repository it would link worktrees to, which is the one
193
+ * fact the provider cannot default.
194
+ *
195
+ * No branch formula lives in the provider. `git-worktree.ts`'s header states that branch naming is
196
+ * the caller's decision, and this file is the caller: it always hands a `branchFor` in — the
197
+ * configured scheme, else `DEFAULT_BRANCH_SCHEME` — so the provider's own `periscope/{sessionId}`
198
+ * fallback is reached only by a hand composition that names nothing. The rendered name is
199
+ * screened: a render only a particular key or repo produces illegally throws here, which the
200
+ * provider's own try/catch turns into a named `workspace-provision-failed` refusal carrying this
201
+ * message, never an unnamed death inside git.
202
+ */
203
+ /** The scheme a git-worktree host renders: the configured one, else the default. */
204
+ function effectiveScheme(configured: string | null): string {
205
+ return configured === null || configured === '' ? DEFAULT_BRANCH_SCHEME : configured;
206
+ }
207
+
208
+ export function workspacesFor(config: WorkspaceConfig): WorkspaceProvider | null {
209
+ if (config.workspaceRoot === null || config.workspaceRoot === '') return null;
210
+
211
+ if (config.repositoryRoot !== null && config.repositoryRoot !== '') {
212
+ return new GitWorktreeProvider({
213
+ repositoryRoot: config.repositoryRoot,
214
+ workspaceRoot: config.workspaceRoot,
215
+ effects: nodeWorkspaceEffects,
216
+ commands: nodeCommandEffects(),
217
+ branchFor: branchRenderer(effectiveScheme(config.branchScheme), config.repositoryRoot),
218
+ });
219
+ }
220
+
221
+ return new PlainDirProvider({ root: config.workspaceRoot, effects: nodeWorkspaceEffects });
222
+ }
223
+
224
+ /**
225
+ * The mode, as capability markers for the hello: the read half of what `periscope config` writes.
226
+ * A host that can be configured but cannot report how it is configured gives a controller nothing
227
+ * to verify.
228
+ *
229
+ * A pure twin of `workspacesFor`, kept beside it so the two cannot drift: the marker is derived
230
+ * from the same predicates that choose the provider, never from a second reading of the
231
+ * environment. Exactly one `workspace:*` mode marker is always present; absence of all three in a
232
+ * hello therefore means "this build does not report", which is what lets a controller render
233
+ * "not reported" instead of a default.
234
+ *
235
+ * Markers, not values. Which repository root, which scheme text: those would be payload members
236
+ * and a protocol-version change, deliberately not smuggled into marker strings.
237
+ */
238
+ export function workspaceCapabilitiesOf(config: WorkspaceConfig): readonly string[] {
239
+ if (config.workspaceRoot === null || config.workspaceRoot === '') return ['workspace:none'];
240
+ if (config.repositoryRoot !== null && config.repositoryRoot !== '') {
241
+ const hasScheme = config.branchScheme !== null && config.branchScheme !== '';
242
+ return hasScheme ? ['workspace:git-worktree', 'workspace:branch-scheme'] : ['workspace:git-worktree'];
243
+ }
244
+ return ['workspace:plain'];
245
+ }
246
+
247
+ /** The values the hello reports that the workspace config does not hold. */
248
+ export interface HostConfigurationExtras {
249
+ readonly transcriptsRoot: string | null;
250
+ readonly controllerUrl: string | null;
251
+ readonly decisionUrl: string | null;
252
+ readonly agentHome: string | null;
253
+ }
254
+
255
+ /**
256
+ * The values behind the markers: what the hello reports as `configuration`.
257
+ *
258
+ * The other pure twin of `workspacesFor`. Read from the same `WorkspaceConfig` the selector and
259
+ * the markers consume, so a root the selector treats as unset (null or empty) is reported as
260
+ * null here, never as an empty string a controller would render as a path. A value present here
261
+ * and a `workspace:none` marker cannot both be true of one config, and the test pins that.
262
+ */
263
+ export function hostConfigurationOf(
264
+ config: WorkspaceConfig,
265
+ extras: HostConfigurationExtras,
266
+ ): HostConfiguration {
267
+ const setOrNull = (value: string | null): string | null => (value === null || value === '' ? null : value);
268
+ const gitMode = setOrNull(config.workspaceRoot) !== null && setOrNull(config.repositoryRoot) !== null;
269
+ return {
270
+ repositoryRoot: setOrNull(config.repositoryRoot),
271
+ workspaceRoot: setOrNull(config.workspaceRoot),
272
+ // The EFFECTIVE scheme: what a provision would render. In git mode that is never null; the
273
+ // marker beside it says whether it was configured or defaulted.
274
+ branchScheme: gitMode ? effectiveScheme(config.branchScheme) : setOrNull(config.branchScheme),
275
+ transcriptsRoot: setOrNull(extras.transcriptsRoot),
276
+ controllerUrl: setOrNull(extras.controllerUrl),
277
+ decisionUrl: setOrNull(extras.decisionUrl),
278
+ agentHome: setOrNull(extras.agentHome),
279
+ };
280
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Reconnect delay: exponential, and jittered.
3
+ *
4
+ * The jitter is the load-bearing half. A controller restart drops every host at the same instant;
5
+ * without jitter they all wait the same exponential delay and reconnect in lockstep, which knocks
6
+ * the controller over again and does it harder on each round.
7
+ */
8
+ export interface BackoffOptions {
9
+ /** Floor, and the first attempt's ceiling. */
10
+ readonly baseMs: number;
11
+ /** Ceiling, however many attempts have failed. */
12
+ readonly maxMs: number;
13
+ /** Growth per attempt. */
14
+ readonly factor: number;
15
+ }
16
+
17
+ export const DEFAULT_BACKOFF: BackoffOptions = {
18
+ baseMs: 500,
19
+ maxMs: 30_000,
20
+ factor: 2,
21
+ };
22
+
23
+ /**
24
+ * The delay before attempt `attempt` (0-based, so 0 is the first retry).
25
+ *
26
+ * Jittered across the whole window above the floor rather than a fixed fraction of it, so two
27
+ * hosts drawing from different streams separate on the first retry instead of converging.
28
+ * `random` is injected: a caller that cannot control it cannot test that the spread exists.
29
+ */
30
+ export function nextDelayMs(
31
+ attempt: number,
32
+ random: () => number,
33
+ options: BackoffOptions = DEFAULT_BACKOFF,
34
+ ): number {
35
+ const safeAttempt = Math.max(0, Math.floor(attempt));
36
+ const uncapped = options.baseMs * Math.pow(options.factor, safeAttempt);
37
+ const ceiling = Math.min(options.maxMs, uncapped);
38
+ const span = Math.max(0, ceiling - options.baseMs);
39
+ return Math.round(options.baseMs + random() * span);
40
+ }