@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,232 @@
1
+ /**
2
+ * What this host is, said once: its ids, its credential, its workspace, its link and where each
3
+ * setting came from. `serve` prints the one-line form at start-up and `status` prints the whole,
4
+ * from one description, so the two can never disagree about the host they describe. Pure over its
5
+ * inputs; the reads that fill them live with the verbs.
6
+ */
7
+ import { CONFIG_KEYS, WIRE_CONFIGURABLE_KEYS, configFilePath } from '../host/config-file.js';
8
+ import type { LinkStateRecord } from '../host/link-state-file.js';
9
+ import type { CredentialPosture } from './serve.js';
10
+ import { workspaceCapabilitiesOf } from './workspaces.js';
11
+
12
+ export interface PostureInputs {
13
+ /** The process environment as it was. */
14
+ readonly raw: NodeJS.ProcessEnv;
15
+ /** The environment with the config file filling its absences. */
16
+ readonly merged: NodeJS.ProcessEnv;
17
+ /** The values the config file holds, so a key can say which source it came from. */
18
+ readonly fileValues: Readonly<Record<string, string>>;
19
+ /** What `readCredential` decided, or its refusal. */
20
+ readonly credential: CredentialPosture | string;
21
+ /** The cached token's expiry in epoch milliseconds, when a token cache is readable. */
22
+ readonly tokenExpiresAtMs: number | null;
23
+ readonly nowMs: number;
24
+ readonly link: LinkStateRecord | null;
25
+ /** Whether the process that wrote the link record is still alive. */
26
+ readonly pidAlive: (pid: number) => boolean;
27
+ /** The machine's hostname, the host id's fallback. */
28
+ readonly hostname: string;
29
+ }
30
+
31
+ export type CredentialKind = 'paired' | 'token' | 'absent' | 'unreadable';
32
+ export type WorkspaceMode = 'git-worktree' | 'plain' | 'none';
33
+ export type Source = 'environment' | 'config-file' | 'default' | 'unset';
34
+
35
+ export interface Setting {
36
+ readonly key: string;
37
+ readonly value: string | null;
38
+ readonly source: Source;
39
+ /** Set in both places, and the environment won. */
40
+ readonly shadowsFile: boolean;
41
+ }
42
+
43
+ export interface Posture {
44
+ readonly hostId: {
45
+ readonly configured: string;
46
+ readonly paired: string | null;
47
+ readonly effective: string;
48
+ };
49
+ readonly credential: {
50
+ readonly kind: CredentialKind;
51
+ readonly expiresAt: string | null;
52
+ readonly expired: boolean;
53
+ readonly problem: string | null;
54
+ };
55
+ readonly workspace: { readonly mode: WorkspaceMode; readonly branchScheme: string | null };
56
+ readonly sources: readonly Setting[];
57
+ readonly configFile: string | null;
58
+ readonly link: {
59
+ readonly state: string;
60
+ readonly cause: string | null;
61
+ readonly at: string | null;
62
+ readonly detail: string | null;
63
+ readonly negotiatedVersion: number | null;
64
+ readonly pid: number | null;
65
+ readonly alive: boolean;
66
+ };
67
+ }
68
+
69
+ function settingOf(key: string, inputs: PostureInputs): Setting {
70
+ const fromEnv = inputs.raw[key];
71
+ const fromFile = inputs.fileValues[key];
72
+ const inEnv = fromEnv !== undefined && fromEnv !== '';
73
+ const inFile = fromFile !== undefined && fromFile !== '';
74
+ if (inEnv) return { key, value: fromEnv, source: 'environment', shadowsFile: inFile };
75
+ if (inFile) return { key, value: fromFile, source: 'config-file', shadowsFile: false };
76
+ const merged = inputs.merged[key];
77
+ if (merged !== undefined && merged !== '')
78
+ return { key, value: merged, source: 'default', shadowsFile: false };
79
+ return { key, value: null, source: 'unset', shadowsFile: false };
80
+ }
81
+
82
+ export function describePosture(inputs: PostureInputs): Posture {
83
+ const keys = [
84
+ ...new Set<string>([
85
+ ...CONFIG_KEYS,
86
+ ...WIRE_CONFIGURABLE_KEYS,
87
+ 'PERISCOPE_HOST_ID',
88
+ 'PERISCOPE_CONFIG_DIR',
89
+ ]),
90
+ ];
91
+ const sources = keys.map((key) => settingOf(key, inputs));
92
+ const value = (key: string): string | null => sources.find((one) => one.key === key)?.value ?? null;
93
+
94
+ const configured = value('PERISCOPE_HOST_ID') ?? inputs.hostname;
95
+ const paired = typeof inputs.credential === 'string' ? null : inputs.credential.pairedHostId;
96
+
97
+ let kind: CredentialKind;
98
+ let problem: string | null = null;
99
+ if (typeof inputs.credential === 'string') {
100
+ kind = 'unreadable';
101
+ problem = inputs.credential;
102
+ } else if (inputs.credential.pairedHostId !== null) kind = 'paired';
103
+ else if (inputs.credential.credential !== null) kind = 'token';
104
+ else kind = 'absent';
105
+ const expiresAt =
106
+ kind === 'token' && inputs.tokenExpiresAtMs !== null
107
+ ? new Date(inputs.tokenExpiresAtMs).toISOString()
108
+ : null;
109
+ const expired =
110
+ kind === 'token' && inputs.tokenExpiresAtMs !== null && inputs.tokenExpiresAtMs <= inputs.nowMs;
111
+
112
+ const capabilities = workspaceCapabilitiesOf({
113
+ workspaceRoot: value('PERISCOPE_WORKSPACE_ROOT'),
114
+ repositoryRoot: value('PERISCOPE_REPOSITORY_ROOT'),
115
+ branchScheme: value('PERISCOPE_BRANCH_SCHEME'),
116
+ });
117
+ const mode: WorkspaceMode = capabilities.includes('workspace:git-worktree')
118
+ ? 'git-worktree'
119
+ : capabilities.includes('workspace:plain')
120
+ ? 'plain'
121
+ : 'none';
122
+
123
+ const link = inputs.link;
124
+ return {
125
+ hostId: { configured, paired, effective: paired ?? configured },
126
+ credential: { kind, expiresAt, expired, problem },
127
+ workspace: { mode, branchScheme: value('PERISCOPE_BRANCH_SCHEME') },
128
+ sources,
129
+ configFile: configFilePath(inputs.raw),
130
+ link:
131
+ link === null
132
+ ? {
133
+ state: 'unknown',
134
+ cause: null,
135
+ at: null,
136
+ detail: null,
137
+ negotiatedVersion: null,
138
+ pid: null,
139
+ alive: false,
140
+ }
141
+ : {
142
+ state: link.state,
143
+ cause: link.cause,
144
+ at: link.at,
145
+ detail: link.detail,
146
+ negotiatedVersion: link.negotiatedVersion,
147
+ pid: link.pid,
148
+ alive: inputs.pidAlive(link.pid),
149
+ },
150
+ };
151
+ }
152
+
153
+ /** The one line `serve` prints at start-up: the host, the credential, the workspace, the config file. */
154
+ export function postureLine(posture: Posture): string {
155
+ // A token posture names the cache it will present: configured for a token with none cached is
156
+ // the state every refusal at the first dial comes from, and it is visible here first.
157
+ const credential =
158
+ posture.credential.kind === 'token'
159
+ ? posture.credential.expiresAt !== null
160
+ ? `token (expires ${posture.credential.expiresAt})`
161
+ : 'token (no cache yet - sign in first)'
162
+ : posture.credential.kind;
163
+ return (
164
+ `host ${posture.hostId.effective}` +
165
+ (posture.hostId.paired !== null && posture.hostId.paired !== posture.hostId.configured
166
+ ? ` (paired; configured ${posture.hostId.configured})`
167
+ : '') +
168
+ ` · credential ${credential} · workspace ${posture.workspace.mode}` +
169
+ ` · config file ${posture.configFile ?? '(none)'}`
170
+ );
171
+ }
172
+
173
+ /** The whole posture, one fact per line, for `periscope status`. */
174
+ export function renderPosture(posture: Posture): string[] {
175
+ const lines: string[] = [];
176
+ const link = posture.link;
177
+ if (link.state === 'unknown') {
178
+ lines.push(
179
+ 'link: unknown — no host has started with this config directory yet; run periscope to start one',
180
+ );
181
+ } else {
182
+ const liveness = link.alive
183
+ ? `serve pid ${link.pid} is running`
184
+ : `serve pid ${link.pid} is gone, so this is the last thing it said`;
185
+ lines.push(
186
+ `link: ${link.state} since ${link.at} (${link.cause}${link.detail === null ? '' : `: ${link.detail}`}); ${liveness}`,
187
+ );
188
+ if (link.negotiatedVersion !== null)
189
+ lines.push(`protocol: v${link.negotiatedVersion} negotiated at the last accepted handshake`);
190
+ }
191
+ const credential = posture.credential;
192
+ switch (credential.kind) {
193
+ case 'paired':
194
+ lines.push(`credential: paired as ${posture.hostId.paired} (no expiry; rotation is re-pairing)`);
195
+ break;
196
+ case 'token':
197
+ lines.push(
198
+ `credential: signed-in token${credential.expiresAt === null ? ' (no cache yet - run periscope login)' : `, ${credential.expired ? 'EXPIRED at' : 'expires'} ${credential.expiresAt}`}`,
199
+ );
200
+ break;
201
+ case 'absent':
202
+ lines.push('credential: absent — this host dials without authentication');
203
+ break;
204
+ default:
205
+ lines.push(`credential: unreadable — ${credential.problem ?? ''}`);
206
+ }
207
+ lines.push(
208
+ `host id: ${posture.hostId.effective}` +
209
+ (posture.hostId.paired !== null
210
+ ? posture.hostId.paired === posture.hostId.configured
211
+ ? ' (the paired credential agrees)'
212
+ : ` (the paired credential's, overriding the configured ${posture.hostId.configured})`
213
+ : ''),
214
+ );
215
+ lines.push(
216
+ `workspace: ${posture.workspace.mode}${posture.workspace.branchScheme === null ? '' : `, branch scheme ${posture.workspace.branchScheme}`}`,
217
+ );
218
+ lines.push(`config file: ${posture.configFile ?? '(none: no home directory and no PERISCOPE_CONFIG_DIR)'}`);
219
+ for (const setting of posture.sources) {
220
+ // The config directory is never a config-file key (a file cannot move itself), so it is listed
221
+ // only when the environment set it.
222
+ if (setting.key === 'PERISCOPE_CONFIG_DIR' && setting.source === 'unset') continue;
223
+ const where =
224
+ setting.source === 'environment'
225
+ ? setting.shadowsFile
226
+ ? 'environment, shadowing the config file'
227
+ : 'environment'
228
+ : setting.source;
229
+ lines.push(` ${setting.key} = ${setting.value ?? '(unset)'} [${where}]`);
230
+ }
231
+ return lines;
232
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * A configuration change asked over the wire (`host_configure`, protocol v7): validate, write,
3
+ * rebuild.
4
+ *
5
+ * Split out of the composition root for the reason `workspaces.ts` gives: everything below the
6
+ * composition root takes its configuration as arguments, and this is the one function that
7
+ * reads the config file, screens a candidate posture and builds the pieces the host swaps in. It
8
+ * is called with the RAW environment because the file fills absences in it (`withConfigFallback`),
9
+ * and the same rule that governs `serve` governs a change made while serving: the environment
10
+ * wins per key, so a value this writes that the environment shadows is reported as overridden,
11
+ * never silently inert.
12
+ *
13
+ * Nothing is written until the whole candidate posture has passed the same screen start-up runs,
14
+ * so a half-applied set never lands on disk; and a change to a workspace root is refused while the
15
+ * host is busy, because a session releases through the provider that provisioned it.
16
+ */
17
+ import type { HostConfigureEntry } from '../control/frames.js';
18
+ import { isAbsolutePath, normalizePath } from '../core/paths.js';
19
+ import type { Result } from '../core/result.js';
20
+ import { ok, refuse } from '../core/result.js';
21
+ import {
22
+ claudeTranscriptResolver,
23
+ defaultAgentHome,
24
+ transcriptsRootUnder,
25
+ } from '../host/claude-transcripts.js';
26
+ import {
27
+ RESTART_KEYS,
28
+ WIRE_CONFIGURABLE_KEYS,
29
+ isWireConfigurableKey,
30
+ readConfigFile,
31
+ withConfigFallback,
32
+ writeConfigEntries,
33
+ } from '../host/config-file.js';
34
+ import type { HostReconfigured } from '../host/host.js';
35
+ import {
36
+ hostConfigurationOf,
37
+ workspaceCapabilitiesOf,
38
+ workspacePostureProblem,
39
+ workspacesFor,
40
+ } from './workspaces.js';
41
+
42
+ /** The effective posture a merged environment describes, with absence as null. */
43
+ interface EffectivePosture {
44
+ readonly workspaceRoot: string | null;
45
+ readonly repositoryRoot: string | null;
46
+ readonly branchScheme: string | null;
47
+ readonly workspaceKey: string | null;
48
+ readonly agentHome: string | null;
49
+ readonly controllerUrl: string | null;
50
+ readonly decisionUrl: string | null;
51
+ }
52
+
53
+ /**
54
+ * The control-plane addresses the running host actually uses: what it dialled at start. A file
55
+ * value that differs is written and reported as pending, never applied to the live link.
56
+ */
57
+ export interface LiveAddresses {
58
+ readonly controllerUrl: string | null;
59
+ readonly decisionUrl: string | null;
60
+ }
61
+
62
+ function setOrNull(value: string | undefined): string | null {
63
+ return value === undefined || value === '' ? null : value;
64
+ }
65
+
66
+ function postureOf(merged: NodeJS.ProcessEnv): EffectivePosture {
67
+ return {
68
+ workspaceRoot: setOrNull(merged['PERISCOPE_WORKSPACE_ROOT']),
69
+ repositoryRoot: setOrNull(merged['PERISCOPE_REPOSITORY_ROOT']),
70
+ branchScheme: setOrNull(merged['PERISCOPE_BRANCH_SCHEME']),
71
+ workspaceKey: setOrNull(merged['PERISCOPE_WORKSPACE_KEY']),
72
+ agentHome: setOrNull(merged['PERISCOPE_AGENT_HOME']),
73
+ controllerUrl: setOrNull(merged['PERISCOPE_CONTROLLER_URL']),
74
+ decisionUrl: setOrNull(merged['PERISCOPE_DECISION_URL']),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Why a control-plane address cannot be written, or null. The link is dialled as a WebSocket and
80
+ * the decision endpoint is POSTed to, so each key takes the schemes its act can use and nothing
81
+ * else: a value that fits the other key would fail at the first act after the restart it waits for.
82
+ */
83
+ /**
84
+ * The first thing wrong with ONE value for ONE key, or null: the per-key half of `candidateProblem`,
85
+ * shared with `periscope config` and the daemon's start-up read so a value the wire would refuse is
86
+ * refused wherever it is typed. A key this screen does not know passes; the closed key set is
87
+ * checked by the writer.
88
+ */
89
+ export function configValueProblem(key: string, value: string): string | null {
90
+ switch (key) {
91
+ case 'PERISCOPE_CONTROLLER_URL':
92
+ return addressProblem(key, value, ['ws:', 'wss:']);
93
+ case 'PERISCOPE_DECISION_URL':
94
+ return addressProblem(key, value, ['http:', 'https:']);
95
+ case 'PERISCOPE_WORKSPACE_ROOT':
96
+ case 'PERISCOPE_REPOSITORY_ROOT':
97
+ case 'PERISCOPE_AGENT_HOME':
98
+ return isAbsolutePath(value) ? null : `${key} must be an absolute path — got '${value}'`;
99
+ case 'PERISCOPE_BRANCH_SCHEME':
100
+ return value.includes('{key}')
101
+ ? null
102
+ : `PERISCOPE_BRANCH_SCHEME '${value}' has no {key} placeholder — every workspace would render the same branch`;
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function addressProblem(key: string, value: string | null, schemes: readonly string[]): string | null {
109
+ if (value === null) return null;
110
+ let parsed: URL;
111
+ try {
112
+ parsed = new URL(value);
113
+ } catch {
114
+ return `${key} must be an absolute URL — got '${value}'`;
115
+ }
116
+ if (!schemes.includes(parsed.protocol)) {
117
+ return `${key} must use ${schemes.map((scheme) => scheme.slice(0, -1)).join(' or ')} — got '${value}'`;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function sameRoot(a: string | null, b: string | null): boolean {
123
+ if (a === null || b === null) return a === b;
124
+ return normalizePath(a).toLowerCase() === normalizePath(b).toLowerCase();
125
+ }
126
+
127
+ /** The wire-settable keys the environment sets: their file values are unreachable. */
128
+ export function overriddenByEnvironment(raw: NodeJS.ProcessEnv): readonly string[] {
129
+ return WIRE_CONFIGURABLE_KEYS.filter((key) => {
130
+ const value = raw[key];
131
+ return value !== undefined && value !== '';
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The first thing wrong with a candidate posture, or null. The same screen start-up runs
137
+ * (`workspacePostureProblem`) plus the rules the wire needs that start-up gets from the shell: a
138
+ * root must be absolute, and a scheme must render distinct branches (contain `{key}`).
139
+ */
140
+ export function candidateProblem(posture: EffectivePosture): string | null {
141
+ for (const [key, value] of [
142
+ ['PERISCOPE_WORKSPACE_ROOT', posture.workspaceRoot],
143
+ ['PERISCOPE_REPOSITORY_ROOT', posture.repositoryRoot],
144
+ ['PERISCOPE_AGENT_HOME', posture.agentHome],
145
+ ] as const) {
146
+ if (value !== null && !isAbsolutePath(value)) return `${key} must be an absolute path — got '${value}'`;
147
+ }
148
+ if (posture.branchScheme !== null && !posture.branchScheme.includes('{key}')) {
149
+ return `PERISCOPE_BRANCH_SCHEME '${posture.branchScheme}' has no {key} placeholder — every workspace would render the same branch`;
150
+ }
151
+ const controller = addressProblem('PERISCOPE_CONTROLLER_URL', posture.controllerUrl, ['ws:', 'wss:']);
152
+ if (controller !== null) return controller;
153
+ const decision = addressProblem('PERISCOPE_DECISION_URL', posture.decisionUrl, ['http:', 'https:']);
154
+ if (decision !== null) return decision;
155
+ return workspacePostureProblem({
156
+ workspaceRoot: posture.workspaceRoot,
157
+ repositoryRoot: posture.repositoryRoot,
158
+ branchScheme: posture.branchScheme,
159
+ workspaceKey: posture.workspaceKey,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Apply the entries: screen every key, screen the candidate posture as a whole, refuse a roots
165
+ * change while busy, write the file once, and rebuild what the host swaps in.
166
+ */
167
+ export function reconfigureHost(
168
+ raw: NodeJS.ProcessEnv,
169
+ entries: readonly HostConfigureEntry[],
170
+ hostBusy: boolean,
171
+ live?: LiveAddresses,
172
+ ): Result<HostReconfigured> {
173
+ const seen = new Set<string>();
174
+ for (const entry of entries) {
175
+ if (!isWireConfigurableKey(entry.key)) {
176
+ return refuse(
177
+ 'config-key-unknown',
178
+ `'${entry.key}' is not a key this host takes over the wire — the settable keys are ${WIRE_CONFIGURABLE_KEYS.join(', ')}`,
179
+ );
180
+ }
181
+ if (seen.has(entry.key)) {
182
+ return refuse('config-value-invalid', `'${entry.key}' is named twice in one ask`);
183
+ }
184
+ seen.add(entry.key);
185
+ }
186
+
187
+ const file = readConfigFile(raw);
188
+ if (file.problem !== null) {
189
+ return refuse('config-write-failed', `${file.problem} — fix or remove it before writing`);
190
+ }
191
+ const before = postureOf(withConfigFallback(raw, file.values));
192
+
193
+ // The candidate: the file with the entries applied, under the environment. An empty value is the
194
+ // documented twin of unset everywhere this package reads, so it removes the key like null does.
195
+ const nextValues: Record<string, string> = { ...file.values };
196
+ const writes: { key: string; value: string | null }[] = [];
197
+ for (const entry of entries) {
198
+ const value = entry.value === null || entry.value === '' ? null : entry.value;
199
+ if (value === null) delete nextValues[entry.key];
200
+ else nextValues[entry.key] = value;
201
+ writes.push({ key: entry.key, value });
202
+ }
203
+ // A written address is screened on its own, before the merge: the environment may shadow it today,
204
+ // and a bad value would still be the one the next start reads once the shadow lifts.
205
+ for (const write of writes) {
206
+ if (write.value === null) continue;
207
+ const problem =
208
+ write.key === 'PERISCOPE_CONTROLLER_URL'
209
+ ? addressProblem(write.key, write.value, ['ws:', 'wss:'])
210
+ : write.key === 'PERISCOPE_DECISION_URL'
211
+ ? addressProblem(write.key, write.value, ['http:', 'https:'])
212
+ : null;
213
+ if (problem !== null) return refuse('config-value-invalid', problem);
214
+ }
215
+ const merged = withConfigFallback(raw, nextValues);
216
+ const after = postureOf(merged);
217
+
218
+ const problem = candidateProblem(after);
219
+ if (problem !== null) return refuse('config-value-invalid', problem);
220
+
221
+ const rootsChange =
222
+ !sameRoot(before.workspaceRoot, after.workspaceRoot) ||
223
+ !sameRoot(before.repositoryRoot, after.repositoryRoot);
224
+ if (rootsChange && hostBusy) {
225
+ return refuse(
226
+ 'config-host-busy',
227
+ 'a workspace root cannot change while a session is live or opening on this host — close every session and ask again',
228
+ );
229
+ }
230
+
231
+ const written = writeConfigEntries(raw, writes);
232
+ if (written !== null) return refuse('config-write-failed', written);
233
+
234
+ const workspaceConfig = {
235
+ workspaceRoot: after.workspaceRoot,
236
+ repositoryRoot: after.repositoryRoot,
237
+ branchScheme: after.branchScheme,
238
+ };
239
+ const agentHome = after.agentHome ?? defaultAgentHome(raw);
240
+ const transcriptsRoot = agentHome === null ? null : transcriptsRootUnder(agentHome);
241
+ // The addresses in effect are what the host dialled at start; absent a caller's word, the merged
242
+ // view before this ask is the closest thing to it. A key whose file value differs is pending.
243
+ const dialled: LiveAddresses = live ?? {
244
+ controllerUrl: before.controllerUrl,
245
+ decisionUrl: before.decisionUrl,
246
+ };
247
+ const pendingRestart = RESTART_KEYS.filter((key) =>
248
+ key === 'PERISCOPE_CONTROLLER_URL'
249
+ ? after.controllerUrl !== dialled.controllerUrl
250
+ : after.decisionUrl !== dialled.decisionUrl,
251
+ );
252
+ return ok({
253
+ workspaces: workspacesFor(workspaceConfig) ?? undefined,
254
+ transcriptsRoot: transcriptsRoot ?? undefined,
255
+ bulk: transcriptsRoot === null ? undefined : claudeTranscriptResolver(transcriptsRoot),
256
+ linkCapabilities: workspaceCapabilitiesOf(workspaceConfig),
257
+ configuration: hostConfigurationOf(workspaceConfig, {
258
+ transcriptsRoot,
259
+ controllerUrl: after.controllerUrl,
260
+ decisionUrl: after.decisionUrl,
261
+ agentHome,
262
+ }),
263
+ overriddenByEnvironment: overriddenByEnvironment(raw),
264
+ pendingRestart,
265
+ });
266
+ }