@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,339 @@
1
+ /**
2
+ * The discovery door: read-only enumeration and tail-probing of the agent CLI's own transcript
3
+ * directory, `~/.claude/projects`.
4
+ *
5
+ * The posture, stated once because it is the whole design. This host names `~/.claude` in one
6
+ * other place, `host/paths.ts`, to protect it from the sessions it runs. That protection is about
7
+ * the agent's hands; this module is the host's own eyes, a deliberate read-only capability over a
8
+ * directory every Claude session on the machine writes into (a VS Code session and a session this
9
+ * host started land side by side). The two postures do not touch: nothing here weakens the gate,
10
+ * and nothing here can write.
11
+ *
12
+ * Read-only is a property, not a promise. The module's filesystem surface is exactly
13
+ * `createReadStream`, `readdir` and `stat`; no write-capable API is imported, and
14
+ * `src/pins/transcript-readonly.test.ts` holds the import surface to that allowlist.
15
+ *
16
+ * The jail: three layers, all three always, for every caller-supplied name:
17
+ * 1. the strict name allowlist (`NAME_ALLOWLIST`);
18
+ * 2. an explicit `'.'` / `'..'` reject — dot-names PASS the regex, so this layer is load-bearing;
19
+ * 3. resolve-then-containment — the real resolver first, then `isContainedBy` against the root,
20
+ * so a name the first two layers somehow admitted still cannot shape a path outside it.
21
+ * A violation refuses `transcript-path-escape`, naming the layer. An ABSENT transcript is a value,
22
+ * never a refusal: a missing projects directory is a fresh machine and lists as empty.
23
+ *
24
+ * The rewrite hazard: the CLI rewrites a transcript on compaction,
25
+ * so a byte-offset resume across a rewrite is invalid. Every answer that carries an offset also
26
+ * carries the file's current (size, mtime) pair; a caller that sees either move under it re-reads
27
+ * whole rather than resuming. A file SHORTER than the asked offset is treated as rewritten and
28
+ * scanned from 0.
29
+ *
30
+ * Project slugs are OPAQUE NAMES, never decoded: the CLI flattens every non-alphanumeric character
31
+ * of a directory path to `-` (on win32 `C:\src\x\.y` becomes `C--src-x--y`), so the
32
+ * mapping is not invertible and nothing here tries.
33
+ */
34
+ import { createReadStream } from 'node:fs';
35
+ import { readdir, stat } from 'node:fs/promises';
36
+ import { ok, refuse } from '../core/result.js';
37
+ import { isContainedBy, normalizePath } from '../core/paths.js';
38
+ import { TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX } from '../control/frames.js';
39
+ import { nodePathResolver } from './paths.js';
40
+ /**
41
+ * The strict name allowlist — drive/path characters only. `'.'` and `'..'` PASS this regex, which
42
+ * is exactly why the dot-name reject below is its own layer rather than a tightening of this one.
43
+ */
44
+ export const NAME_ALLOWLIST = /^[A-Za-z0-9._-]+$/;
45
+ /**
46
+ * The agent's home when nothing configures one: the folder the agent CLI keeps its state in, under
47
+ * the user's home directory. Null when there is no home to derive it from — a refusable state,
48
+ * never a fallback: inventing one would turn "this machine has no home directory" into a silent
49
+ * empty listing that reads as a fresh machine.
50
+ */
51
+ export function defaultAgentHome(env = process.env) {
52
+ const home = env['USERPROFILE'] ?? env['HOME'];
53
+ if (typeof home === 'string' && home.trim() !== '') {
54
+ return normalizePath(`${home.replace(/[\\/]+$/, '')}/.claude`);
55
+ }
56
+ return null;
57
+ }
58
+ /** Where the agent CLI keeps transcripts under its home: derived, never configured on its own. */
59
+ export function transcriptsRootUnder(agentHome) {
60
+ return normalizePath(`${agentHome.replace(/[\\/]+$/, '')}/projects`);
61
+ }
62
+ /** The transcripts root under the default agent home, or null when there is no home. */
63
+ export function claudeProjectsRoot(env = process.env) {
64
+ const home = defaultAgentHome(env);
65
+ return home === null ? null : transcriptsRootUnder(home);
66
+ }
67
+ /** How much of a transcript's head is read for its `cwd` — the first entries, never the file. */
68
+ const CWD_HEAD_BYTES = 64 * 1024;
69
+ /**
70
+ * The first `cwd` string on the file's first entries, or null. A summary line carries none; the first
71
+ * user or assistant entry does. Every failure is null — the listing describes files, it does not refuse
72
+ * over one of them.
73
+ */
74
+ async function readTranscriptCwd(path) {
75
+ let head = '';
76
+ try {
77
+ for await (const chunk of createReadStream(path, {
78
+ start: 0,
79
+ end: CWD_HEAD_BYTES - 1,
80
+ encoding: 'utf8',
81
+ })) {
82
+ head += chunk;
83
+ }
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ for (const line of head.split('\n')) {
89
+ const trimmed = line.trim();
90
+ if (trimmed.length === 0)
91
+ continue;
92
+ try {
93
+ const entry = JSON.parse(trimmed);
94
+ if (typeof entry === 'object' &&
95
+ entry !== null &&
96
+ typeof entry.cwd === 'string') {
97
+ const cwd = entry.cwd;
98
+ if (cwd.length > 0)
99
+ return cwd;
100
+ }
101
+ }
102
+ catch {
103
+ // a partial last line inside the window, or a non-JSON line — keep looking
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * THE JAIL. Resolve `{root}/{projectSlug}/{sessionId}.jsonl` with all three layers applied to both
110
+ * caller-supplied names. The one path-shaping function in this module — list, tail and the bulk
111
+ * resolver all come through here, so there is no second door to keep honest.
112
+ */
113
+ export function resolveTranscriptPath(root, projectSlug, sessionId) {
114
+ for (const [value, what] of [
115
+ [projectSlug, 'projectSlug'],
116
+ [sessionId, 'sessionId'],
117
+ ]) {
118
+ if (value === '.' || value === '..') {
119
+ return refuse('transcript-path-escape', `${what} is a dot-name — refused by the dot-name layer`);
120
+ }
121
+ if (!NAME_ALLOWLIST.test(value)) {
122
+ return refuse('transcript-path-escape', `${what} fails the name allowlist — refused by the allowlist layer`);
123
+ }
124
+ }
125
+ const resolvedRoot = normalizePath(nodePathResolver(root));
126
+ const candidate = normalizePath(nodePathResolver(`${resolvedRoot}/${projectSlug}/${sessionId}.jsonl`));
127
+ if (!isContainedBy(candidate, resolvedRoot)) {
128
+ return refuse('transcript-path-escape', `the resolved path leaves the projects root — refused by the containment layer`);
129
+ }
130
+ return ok(candidate);
131
+ }
132
+ /**
133
+ * Enumerate every session transcript: direct-child `*.jsonl` per slug directory (nested files are
134
+ * the CLI's subagent transcripts and are not part of this listing). Newest first by mtime, paged.
135
+ * A missing root is a fresh machine and answers an empty listing, never an error; a file deleted
136
+ * between readdir and stat is skipped for the same reason.
137
+ */
138
+ export async function listTranscripts(root, page = {}) {
139
+ const fromIndex = Math.max(0, page.fromIndex ?? 0);
140
+ const pageSize = Math.max(1, page.pageSize ?? TRANSCRIPT_PAGE_SIZE);
141
+ const resolvedRoot = normalizePath(nodePathResolver(root));
142
+ let slugDirs;
143
+ try {
144
+ slugDirs = (await readdir(resolvedRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && NAME_ALLOWLIST.test(entry.name) && entry.name !== '.' && entry.name !== '..');
145
+ }
146
+ catch {
147
+ return { entries: [], totalCount: 0, nextIndex: null };
148
+ }
149
+ const all = [];
150
+ for (const slugDir of slugDirs) {
151
+ let files;
152
+ try {
153
+ files = await readdir(`${resolvedRoot}/${slugDir.name}`, { withFileTypes: true });
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ for (const file of files) {
159
+ if (!file.isFile() || !file.name.endsWith('.jsonl'))
160
+ continue;
161
+ const sessionId = file.name.slice(0, -'.jsonl'.length);
162
+ if (sessionId === '.' || sessionId === '..' || !NAME_ALLOWLIST.test(sessionId))
163
+ continue;
164
+ try {
165
+ const stats = await stat(`${resolvedRoot}/${slugDir.name}/${file.name}`);
166
+ all.push({
167
+ projectSlug: slugDir.name,
168
+ sessionId,
169
+ sizeBytes: stats.size,
170
+ mtimeMs: Math.floor(stats.mtimeMs),
171
+ });
172
+ }
173
+ catch {
174
+ // Deleted between readdir and stat — the listing describes what exists, not what did.
175
+ }
176
+ }
177
+ }
178
+ all.sort((a, b) => b.mtimeMs - a.mtimeMs);
179
+ // The cwd is read for the PAGE only — one head per listed row, never one per file on the machine.
180
+ const entries = await Promise.all(all.slice(fromIndex, fromIndex + pageSize).map(async (entry) => ({
181
+ ...entry,
182
+ cwd: await readTranscriptCwd(`${resolvedRoot}/${entry.projectSlug}/${entry.sessionId}.jsonl`),
183
+ })));
184
+ const nextIndex = fromIndex + entries.length;
185
+ return {
186
+ entries,
187
+ totalCount: all.length,
188
+ nextIndex: nextIndex < all.length ? nextIndex : null,
189
+ };
190
+ }
191
+ /**
192
+ * Probe one transcript from `fromOffset` for a user entry matching `needle` (null = any user-text
193
+ * entry). Jail violations refuse; an absent file answers `{absent: true}`; a read that FAILS is a
194
+ * refusal (`transcript-read-failed`), never a false negative — to the asker "no new entry" and
195
+ * "could not look" must not be the same answer.
196
+ */
197
+ export async function tailTranscript(root, projectSlug, sessionId, probe = {}) {
198
+ const resolved = resolveTranscriptPath(root, projectSlug, sessionId);
199
+ if (!resolved.ok)
200
+ return resolved;
201
+ let stats;
202
+ try {
203
+ stats = await stat(resolved.value);
204
+ }
205
+ catch {
206
+ return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
207
+ }
208
+ if (!stats.isFile()) {
209
+ return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
210
+ }
211
+ const sizeBytes = stats.size;
212
+ const mtimeMs = Math.floor(stats.mtimeMs);
213
+ const asked = Math.max(0, probe.fromOffset ?? 0);
214
+ // A file shorter than the baseline was rewritten (compaction, or a fork replacing it): scan from
215
+ // the start and let the needle reject stale entries — resuming into a rewritten file would read
216
+ // from the middle of a line that no longer exists.
217
+ const start = sizeBytes < asked ? 0 : asked;
218
+ const needle = probe.needle ?? null;
219
+ if (start >= sizeBytes) {
220
+ return ok({ absent: false, found: false, newOffset: sizeBytes, sizeBytes, mtimeMs });
221
+ }
222
+ try {
223
+ const found = await scanForUserEntry(resolved.value, start, sizeBytes, needle);
224
+ return ok({ absent: false, found, newOffset: sizeBytes, sizeBytes, mtimeMs });
225
+ }
226
+ catch (error) {
227
+ return refuse('transcript-read-failed', `reading ${projectSlug}/${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
228
+ }
229
+ }
230
+ /**
231
+ * The bulk-lane resolver for transcript reads: `claude-transcript:{slug}/{sessionId}` to a jailed
232
+ * absolute path. A locator outside the namespace is refused as an invalid bulk target so an
233
+ * embedder composing several resolvers can tell "not mine" from "mine and malformed".
234
+ */
235
+ export function claudeTranscriptResolver(root) {
236
+ return (what) => {
237
+ if (!what.startsWith(TRANSCRIPT_WHAT_PREFIX)) {
238
+ return refuse('bulk-target-invalid', `this host resolves "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}" locators; got "${what}"`);
239
+ }
240
+ const rest = what.slice(TRANSCRIPT_WHAT_PREFIX.length);
241
+ const slash = rest.indexOf('/');
242
+ if (slash <= 0 || slash === rest.length - 1) {
243
+ return refuse('bulk-target-invalid', `a transcript locator is "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}"; got "${what}"`);
244
+ }
245
+ return resolveTranscriptPath(root, rest.slice(0, slash), rest.slice(slash + 1));
246
+ };
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // The user-entry predicate: the rule that decides whether a new user entry landed.
250
+ // ---------------------------------------------------------------------------
251
+ /** Collapse every whitespace run to one space and trim, so needle matching survives reflowing. */
252
+ const normalizeForMatch = (text) => text.replace(/\s+/g, ' ').trim();
253
+ /** A user entry's text: a flat string content, or its concatenated `text` blocks. */
254
+ function userTextOf(message) {
255
+ const content = message.content;
256
+ if (typeof content === 'string')
257
+ return content;
258
+ if (!Array.isArray(content))
259
+ return '';
260
+ let out = '';
261
+ for (const block of content) {
262
+ if (block !== null &&
263
+ typeof block === 'object' &&
264
+ block.type === 'text' &&
265
+ typeof block.text === 'string') {
266
+ out += block.text;
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ /**
272
+ * One line: does it count as a matching user entry? Unparseable or foreign lines are skipped,
273
+ * never thrown — a truncated LAST line is the normal state of a file being appended to. A
274
+ * tool-result-only user line has no text block and so never matches.
275
+ */
276
+ export function isMatchingUserEntry(line, needle) {
277
+ let root;
278
+ try {
279
+ root = JSON.parse(line);
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ if (root === null || typeof root !== 'object' || Array.isArray(root))
285
+ return false;
286
+ if (root.type !== 'user')
287
+ return false;
288
+ const message = root.message;
289
+ if (message === null || typeof message !== 'object' || Array.isArray(message))
290
+ return false;
291
+ const role = message.role;
292
+ if (role !== undefined && role !== 'user')
293
+ return false;
294
+ const text = userTextOf(message);
295
+ if (text.trim().length === 0)
296
+ return false;
297
+ if (needle === null || needle === '')
298
+ return true;
299
+ return normalizeForMatch(text).includes(needle);
300
+ }
301
+ /**
302
+ * Read `[start, end)` in chunks with a line carry, so a large transcript never lands in one buffer
303
+ * and a partial final line is simply the last carry. The final carry is tested too — an
304
+ * unterminated tail is still a line.
305
+ */
306
+ function scanForUserEntry(file, start, end, needle) {
307
+ return new Promise((resolve, reject) => {
308
+ const stream = createReadStream(file, { start, end: end - 1, encoding: 'utf8' });
309
+ let carry = '';
310
+ let found = false;
311
+ stream.on('data', (chunk) => {
312
+ if (found)
313
+ return;
314
+ const text = carry + String(chunk);
315
+ const lines = text.split('\n');
316
+ carry = lines.pop() ?? '';
317
+ for (const raw of lines) {
318
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
319
+ if (line.length === 0)
320
+ continue;
321
+ if (isMatchingUserEntry(line, needle)) {
322
+ found = true;
323
+ stream.destroy();
324
+ return;
325
+ }
326
+ }
327
+ });
328
+ stream.on('error', reject);
329
+ stream.on('close', () => {
330
+ if (!found && carry.length > 0) {
331
+ const line = carry.endsWith('\r') ? carry.slice(0, -1) : carry;
332
+ if (line.length > 0 && isMatchingUserEntry(line, needle))
333
+ found = true;
334
+ }
335
+ resolve(found);
336
+ });
337
+ });
338
+ }
339
+ //# sourceMappingURL=claude-transcripts.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Every key the config file may carry — the environment variables the composition root reads,
3
+ * minus `PERISCOPE_CONFIG_DIR` (the bootstrap exclusion above).
4
+ */
5
+ export declare const CONFIG_KEYS: readonly ["PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL", "PERISCOPE_HOST_ID", "PERISCOPE_WORKSPACE_ROOT", "PERISCOPE_REPOSITORY_ROOT", "PERISCOPE_BRANCH_SCHEME", "PERISCOPE_WORKSPACE_KEY", "PERISCOPE_AGENT_HOME"];
6
+ export type ConfigKey = (typeof CONFIG_KEYS)[number];
7
+ /**
8
+ * The keys a controller may set over the wire (`host_configure`): where this machine's things are,
9
+ * and where its control plane is. The two URLs are written to the file and never applied to
10
+ * the live link: the host dials what it dialled until its next start, and names the keys as pending
11
+ * in its answer and in every hello until then. Never the host id (a paired credential outranks it
12
+ * anyway) or the default workspace key (a start-up posture, screened at boot).
13
+ */
14
+ export declare const WIRE_CONFIGURABLE_KEYS: readonly ["PERISCOPE_WORKSPACE_ROOT", "PERISCOPE_REPOSITORY_ROOT", "PERISCOPE_BRANCH_SCHEME", "PERISCOPE_AGENT_HOME", "PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL"];
15
+ export type WireConfigurableKey = (typeof WIRE_CONFIGURABLE_KEYS)[number];
16
+ /** The wire-settable keys that take effect only at the next start: the control-plane addresses. */
17
+ export declare const RESTART_KEYS: readonly ["PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL"];
18
+ export declare function isWireConfigurableKey(value: string): value is WireConfigurableKey;
19
+ export declare function isConfigKey(value: string): value is ConfigKey;
20
+ /** Where the config file lives, or null when there is nowhere to keep one (no home, no override). */
21
+ export declare function configFilePath(env?: NodeJS.ProcessEnv): string | null;
22
+ /** What a read found: the values, or the named problem that makes them unusable. */
23
+ export interface ConfigFileReading {
24
+ readonly values: Readonly<Record<string, string>>;
25
+ /** Null when the file is usable (a missing file is usable: it reads as empty). */
26
+ readonly problem: string | null;
27
+ }
28
+ /**
29
+ * Read the config file. A MISSING file is `{}` — the ordinary state of every machine that has
30
+ * never run `periscope config`. A file that exists and cannot be used is a PROBLEM, never a silent
31
+ * empty: falling back would make a corrupt file behave like a deliberate absence, which is the
32
+ * exact ambiguity the paired credential's fatal-on-corrupt posture exists to end.
33
+ */
34
+ export declare function readConfigFile(env?: NodeJS.ProcessEnv): ConfigFileReading;
35
+ /**
36
+ * Write one entry, creating the directory and file as needed. Returns null, or the named problem.
37
+ * Refuses an unknown key BEFORE touching the disk — see the closed-set rule in the header.
38
+ */
39
+ export declare function writeConfigEntry(env: NodeJS.ProcessEnv, key: string, value: string): string | null;
40
+ /** One entry of a multi-key write: a value to set, or null to remove the key. */
41
+ export interface ConfigEntryWrite {
42
+ readonly key: string;
43
+ readonly value: string | null;
44
+ }
45
+ /**
46
+ * Write several entries as one read-modify-write, creating the directory and file as needed.
47
+ * Returns null, or the named problem. Every key is screened BEFORE the disk is touched, so a set
48
+ * with one bad key writes nothing; a null value removes its key. The same corrupt-file posture as
49
+ * the single-entry write: never merged over.
50
+ */
51
+ export declare function writeConfigEntries(env: NodeJS.ProcessEnv, entries: readonly ConfigEntryWrite[]): string | null;
52
+ /**
53
+ * The environment, with file values FILLING ABSENCES — the one implementation of "env wins".
54
+ *
55
+ * Returns a NEW plain object; the process environment is never mutated. Only the closed key set is
56
+ * consulted, so nothing outside it (identity configuration, the config dir itself) can arrive from
57
+ * a file. Empty-string env values count as absent, matching how the composition root reads them.
58
+ */
59
+ export declare function withConfigFallback(env: NodeJS.ProcessEnv, values: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
60
+ //# sourceMappingURL=config-file.d.ts.map
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The config file: the CLI as a first-class writer, so a library user with no UI is not env-only.
3
+ *
4
+ * It lives beside the credential files on purpose. `periscopeCredentialDir(env)` is the single
5
+ * source both the token cache's location and the gate's protected set derive from, so a config file
6
+ * written under it is protected from the sessions this host runs by having been put there — not by
7
+ * somebody remembering to add a line. (Nothing in it is secret today; the property costs nothing
8
+ * and the alternative is a second directory with a second protection question.)
9
+ *
10
+ * The environment still wins, per key. The file is a fallback for an absence: a value here is
11
+ * consulted only when the same variable is unset or empty in the process environment, so a
12
+ * deployment that sets env vars is configured by them alone — and there is
13
+ * never a precedence question to look up, because a stated env var makes the file's value
14
+ * unreachable. `withConfigFallback` is the one place that rule is implemented.
15
+ *
16
+ * The key set is closed, on both the write and the read. An unknown key written is refused naming
17
+ * the legal set (a typo'd key that "worked" would configure nothing, silently). An unknown key
18
+ * read is a problem too, for the same reason from the other side: this host wrote none, so one in
19
+ * the file is a hand-edit that does nothing, the misconfiguration nobody finds. And
20
+ * `PERISCOPE_CONFIG_DIR` is deliberately not settable here: it is what says where this file is,
21
+ * and a file that could move itself is a bootstrap circle.
22
+ *
23
+ * Sync I/O throughout, deliberately: `serve()` stays synchronous (its own docblock says why), and a
24
+ * one-shot read at boot plus a one-shot write per `config` invocation need no event loop.
25
+ */
26
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join, periscopeCredentialDir } from './paths.js';
28
+ /**
29
+ * Every key the config file may carry — the environment variables the composition root reads,
30
+ * minus `PERISCOPE_CONFIG_DIR` (the bootstrap exclusion above).
31
+ */
32
+ export const CONFIG_KEYS = [
33
+ 'PERISCOPE_CONTROLLER_URL',
34
+ 'PERISCOPE_DECISION_URL',
35
+ 'PERISCOPE_HOST_ID',
36
+ 'PERISCOPE_WORKSPACE_ROOT',
37
+ 'PERISCOPE_REPOSITORY_ROOT',
38
+ 'PERISCOPE_BRANCH_SCHEME',
39
+ 'PERISCOPE_WORKSPACE_KEY',
40
+ 'PERISCOPE_AGENT_HOME',
41
+ ];
42
+ /**
43
+ * The keys a controller may set over the wire (`host_configure`): where this machine's things are,
44
+ * and where its control plane is. The two URLs are written to the file and never applied to
45
+ * the live link: the host dials what it dialled until its next start, and names the keys as pending
46
+ * in its answer and in every hello until then. Never the host id (a paired credential outranks it
47
+ * anyway) or the default workspace key (a start-up posture, screened at boot).
48
+ */
49
+ export const WIRE_CONFIGURABLE_KEYS = [
50
+ 'PERISCOPE_WORKSPACE_ROOT',
51
+ 'PERISCOPE_REPOSITORY_ROOT',
52
+ 'PERISCOPE_BRANCH_SCHEME',
53
+ 'PERISCOPE_AGENT_HOME',
54
+ 'PERISCOPE_CONTROLLER_URL',
55
+ 'PERISCOPE_DECISION_URL',
56
+ ];
57
+ /** The wire-settable keys that take effect only at the next start: the control-plane addresses. */
58
+ export const RESTART_KEYS = [
59
+ 'PERISCOPE_CONTROLLER_URL',
60
+ 'PERISCOPE_DECISION_URL',
61
+ ];
62
+ export function isWireConfigurableKey(value) {
63
+ return WIRE_CONFIGURABLE_KEYS.includes(value);
64
+ }
65
+ export function isConfigKey(value) {
66
+ return CONFIG_KEYS.includes(value);
67
+ }
68
+ /** Where the config file lives, or null when there is nowhere to keep one (no home, no override). */
69
+ export function configFilePath(env = process.env) {
70
+ const dir = periscopeCredentialDir(env);
71
+ return dir === null ? null : join(dir, 'config.json');
72
+ }
73
+ /**
74
+ * Read the config file. A MISSING file is `{}` — the ordinary state of every machine that has
75
+ * never run `periscope config`. A file that exists and cannot be used is a PROBLEM, never a silent
76
+ * empty: falling back would make a corrupt file behave like a deliberate absence, which is the
77
+ * exact ambiguity the paired credential's fatal-on-corrupt posture exists to end.
78
+ */
79
+ export function readConfigFile(env = process.env) {
80
+ const path = configFilePath(env);
81
+ if (path === null)
82
+ return { values: {}, problem: null };
83
+ let raw;
84
+ try {
85
+ raw = readFileSync(path, 'utf8');
86
+ }
87
+ catch (error) {
88
+ if (error.code === 'ENOENT')
89
+ return { values: {}, problem: null };
90
+ return {
91
+ values: {},
92
+ problem: `the config file at ${path} exists and cannot be read: ${describe(error)}`,
93
+ };
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(raw);
98
+ }
99
+ catch (error) {
100
+ return {
101
+ values: {},
102
+ problem: `the config file at ${path} is not JSON: ${describe(error)}`,
103
+ };
104
+ }
105
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
106
+ return {
107
+ values: {},
108
+ problem: `the config file at ${path} must hold one JSON object of string values`,
109
+ };
110
+ }
111
+ const values = {};
112
+ for (const [key, value] of Object.entries(parsed)) {
113
+ if (!isConfigKey(key)) {
114
+ return {
115
+ values: {},
116
+ problem: `the config file at ${path} carries an unknown key '${key}' — this host reads only ` +
117
+ `${CONFIG_KEYS.join(', ')}, so the entry would configure nothing`,
118
+ };
119
+ }
120
+ if (typeof value !== 'string') {
121
+ return {
122
+ values: {},
123
+ problem: `the config file at ${path} carries a non-string value for '${key}'`,
124
+ };
125
+ }
126
+ values[key] = value;
127
+ }
128
+ return { values, problem: null };
129
+ }
130
+ /**
131
+ * Write one entry, creating the directory and file as needed. Returns null, or the named problem.
132
+ * Refuses an unknown key BEFORE touching the disk — see the closed-set rule in the header.
133
+ */
134
+ export function writeConfigEntry(env, key, value) {
135
+ if (!isConfigKey(key)) {
136
+ return `'${key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
137
+ }
138
+ const path = configFilePath(env);
139
+ if (path === null) {
140
+ return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
141
+ }
142
+ const existing = readConfigFile(env);
143
+ if (existing.problem !== null) {
144
+ // Never merged-over: writing "around" a corrupt file destroys whatever the operator hand-wrote.
145
+ return `${existing.problem} — fix or remove it before writing`;
146
+ }
147
+ try {
148
+ const dir = path.slice(0, path.lastIndexOf('/'));
149
+ mkdirSync(dir, { recursive: true });
150
+ const next = { ...existing.values, [key]: value };
151
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
152
+ return null;
153
+ }
154
+ catch (error) {
155
+ return `the config file at ${path} could not be written: ${describe(error)}`;
156
+ }
157
+ }
158
+ /**
159
+ * Write several entries as one read-modify-write, creating the directory and file as needed.
160
+ * Returns null, or the named problem. Every key is screened BEFORE the disk is touched, so a set
161
+ * with one bad key writes nothing; a null value removes its key. The same corrupt-file posture as
162
+ * the single-entry write: never merged over.
163
+ */
164
+ export function writeConfigEntries(env, entries) {
165
+ for (const entry of entries) {
166
+ if (!isConfigKey(entry.key)) {
167
+ return `'${entry.key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
168
+ }
169
+ }
170
+ const path = configFilePath(env);
171
+ if (path === null) {
172
+ return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
173
+ }
174
+ const existing = readConfigFile(env);
175
+ if (existing.problem !== null) {
176
+ return `${existing.problem} — fix or remove it before writing`;
177
+ }
178
+ try {
179
+ const dir = path.slice(0, path.lastIndexOf('/'));
180
+ mkdirSync(dir, { recursive: true });
181
+ const next = { ...existing.values };
182
+ for (const entry of entries) {
183
+ if (entry.value === null)
184
+ delete next[entry.key];
185
+ else
186
+ next[entry.key] = entry.value;
187
+ }
188
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
189
+ return null;
190
+ }
191
+ catch (error) {
192
+ return `the config file at ${path} could not be written: ${describe(error)}`;
193
+ }
194
+ }
195
+ /**
196
+ * The environment, with file values FILLING ABSENCES — the one implementation of "env wins".
197
+ *
198
+ * Returns a NEW plain object; the process environment is never mutated. Only the closed key set is
199
+ * consulted, so nothing outside it (identity configuration, the config dir itself) can arrive from
200
+ * a file. Empty-string env values count as absent, matching how the composition root reads them.
201
+ */
202
+ export function withConfigFallback(env, values) {
203
+ const merged = { ...env };
204
+ for (const key of CONFIG_KEYS) {
205
+ const fromEnv = env[key];
206
+ const fromFile = values[key];
207
+ if ((fromEnv === undefined || fromEnv === '') && fromFile !== undefined && fromFile !== '') {
208
+ merged[key] = fromFile;
209
+ }
210
+ }
211
+ return merged;
212
+ }
213
+ function describe(error) {
214
+ return error instanceof Error ? error.message : String(error);
215
+ }
216
+ //# sourceMappingURL=config-file.js.map
@@ -0,0 +1,7 @@
1
+ import type { TransitionWhere } from '../state/model.js';
2
+ /**
3
+ * The `where` for a directory. Never throws — the caller stamps this onto every transition, so a
4
+ * throw here would take out the state record along with the fact it was decorating.
5
+ */
6
+ export declare function readWhere(cwd: string): TransitionWhere;
7
+ //# sourceMappingURL=git-facts.d.ts.map