@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,166 @@
1
+ /**
2
+ * The host half of the bulk lane: bytes leave over an outbound HTTP POST, never over the link.
3
+ *
4
+ * The link carries a locator and gets back a receipt; the content travels here. That inversion is
5
+ * what keeps a megabyte transcript off a socket that also carries the transitions telling you what
6
+ * a session is doing — a single large frame would otherwise stall every session sharing the link.
7
+ *
8
+ * The read is streamed. Buffering a whole transcript to send it would block the event loop for
9
+ * every other session this host is serving, which is the same failure in a different costume.
10
+ */
11
+ import { createReadStream } from 'node:fs';
12
+ import { stat } from 'node:fs/promises';
13
+ import { Readable } from 'node:stream';
14
+
15
+ import type { Result } from '../core/result.js';
16
+ import { ok, refuse } from '../core/result.js';
17
+ import { requireAbsolute } from '../core/paths.js';
18
+
19
+ export interface BulkPostRequest {
20
+ readonly deliveryId: string;
21
+ readonly postUrl: string;
22
+ /**
23
+ * The only origin this delivery may reach. Required, with no default, and that is the mechanism
24
+ * rather than a style.
25
+ *
26
+ * `postUrl` is chosen by the peer and arrives verbatim on the wire; this is the value that says
27
+ * which peer the host actually works for. An optional field defaulting to "no binding" would let
28
+ * every present and future caller inherit a fail-open nobody picked. Required, the compiler
29
+ * forces each caller to name the origin it trusts.
30
+ *
31
+ * Compute it with {@link bulkOriginFor}, which is the only thing that knows how to turn the
32
+ * host's configured `ws(s)://` controller URL into the `http(s)://` origin a POST lands on.
33
+ */
34
+ readonly allowedOrigin: string;
35
+ /** Absolute. A relative path means "relative to a cwd the controller cannot see". */
36
+ readonly filePath: string;
37
+ readonly fromOffset: number;
38
+ readonly headers?: Readonly<Record<string, string>>;
39
+ }
40
+
41
+ /**
42
+ * The host's ONE controller origin, derived from the link URL it already dials.
43
+ *
44
+ * Derived, never separately configured. The host dials `controllerUrl` and trusts whatever
45
+ * answers it; the bulk POST goes to the same deployment over plain HTTP. Reading a second setting
46
+ * would create a way for the two to disagree, and a host whose "trusted origin" is configured apart
47
+ * from the one it actually talks to is a host that can be pointed at two peers at once.
48
+ */
49
+ export function bulkOriginFor(controllerUrl: string): Result<string> {
50
+ let parsed: URL;
51
+ try {
52
+ parsed = new URL(controllerUrl);
53
+ } catch {
54
+ return refuse('bulk-target-invalid', `the configured controller URL is not a URL: ${controllerUrl}`);
55
+ }
56
+
57
+ // The link speaks WebSocket and the bulk lane speaks HTTP against the SAME deployment, so the
58
+ // scheme is mapped rather than compared: `wss://host:8443/periscope/link` and
59
+ // `https://host:8443/periscope/bulk/<id>` are one controller, and `URL.origin` alone would call
60
+ // them different peers.
61
+ const scheme =
62
+ parsed.protocol === 'ws:' ? 'http:' : parsed.protocol === 'wss:' ? 'https:' : parsed.protocol;
63
+ if (scheme !== 'http:' && scheme !== 'https:') {
64
+ return refuse('bulk-target-invalid', `a controller URL cannot carry scheme ${parsed.protocol}`);
65
+ }
66
+
67
+ return ok(`${scheme}//${parsed.host}`);
68
+ }
69
+
70
+ export interface BulkPostReceipt {
71
+ readonly deliveryId: string;
72
+ readonly byteCount: number;
73
+ /**
74
+ * The file's stat at the moment the delivery was read (size total, mtime as floored epoch ms).
75
+ * Carried so a transcript puller can detect the CLI rewriting the file under it — a byte-offset
76
+ * resume across a rewrite is invalid, and only the deliverer's own stat can say.
77
+ */
78
+ readonly sizeBytes: number;
79
+ readonly mtimeMs: number;
80
+ }
81
+
82
+ export async function postBulk(request: BulkPostRequest): Promise<Result<BulkPostReceipt>> {
83
+ const absolute = requireAbsolute(request.filePath);
84
+ if (!absolute.ok) return { ok: false, refusal: absolute.refusal };
85
+
86
+ let target: URL;
87
+ try {
88
+ target = new URL(request.postUrl);
89
+ } catch {
90
+ return refuse('bulk-target-invalid', `not a URL: ${request.postUrl}`);
91
+ }
92
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
93
+ return refuse('bulk-target-invalid', `unsupported scheme: ${target.protocol}`);
94
+ }
95
+
96
+ // The destination is the peer's choice; whether it is reached is not.
97
+ //
98
+ // The caller attaches this host's durable, non-expiring paired credential to the request (see
99
+ // `host.ts`'s `#deliver`). With a credential riding along, a peer-named `postUrl` is an
100
+ // exfiltration surface: any code path that can compose a `BulkRequest` could name
101
+ // `http://attacker.example/x` and receive the operator's transcript and the device credential
102
+ // that outlives the discovery of the theft.
103
+ //
104
+ // Checked after the scheme, on purpose. A garbled URL should still say it is garbled; this reason
105
+ // is reserved for a target that is well-formed and simply is not this host's controller.
106
+ if (target.origin !== request.allowedOrigin) {
107
+ return refuse(
108
+ 'bulk-target-not-controller',
109
+ `refusing to deliver to ${target.origin}: this host posts bulk content only to its own ` +
110
+ `controller at ${request.allowedOrigin}. The destination rides the wire, so a peer that ` +
111
+ `names another origin is asking for the transcript AND this host's durable credential`,
112
+ );
113
+ }
114
+
115
+ try {
116
+ const stats = await stat(absolute.value);
117
+ const start = Math.min(Math.max(0, request.fromOffset), stats.size);
118
+ const byteCount = stats.size - start;
119
+
120
+ // Bounded to the bytes the header promised. `content-length` is declared from the stat above,
121
+ // but the stream was opened after it: reading a live transcript (the headline use case) lets
122
+ // the CLI append between the two, so an unbounded stream drains past the declared length and
123
+ // the delivery dies as `bulk-delivery-failed`, a name that blames the delivery for a file that
124
+ // simply moved. The end offset is inclusive, hence the -1.
125
+ //
126
+ // The zero-byte arm is not `end: start - 1`: `end` must be a non-negative integer, so an
127
+ // empty delivery at offset 0 would throw rather than send nothing. An empty stream is the
128
+ // honest encoding of "the header promised no bytes".
129
+ const stream =
130
+ byteCount > 0
131
+ ? createReadStream(absolute.value, { start, end: start + byteCount - 1 })
132
+ : Readable.from([]);
133
+ const response = await fetch(target, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'content-type': 'application/octet-stream',
137
+ 'content-length': String(byteCount),
138
+ 'x-delivery-id': request.deliveryId,
139
+ ...request.headers,
140
+ },
141
+ body: Readable.toWeb(stream) as ReadableStream<Uint8Array>,
142
+ // Required by undici whenever the body is a stream: the request body is still being sent
143
+ // while the response is read. Without it the fetch rejects before a byte moves.
144
+ duplex: 'half',
145
+ });
146
+
147
+ if (!response.ok) {
148
+ return refuse(
149
+ 'bulk-delivery-failed',
150
+ `controller answered ${response.status} ${response.statusText} for delivery ${request.deliveryId}`,
151
+ );
152
+ }
153
+
154
+ return ok({
155
+ deliveryId: request.deliveryId,
156
+ byteCount,
157
+ sizeBytes: stats.size,
158
+ mtimeMs: Math.floor(stats.mtimeMs),
159
+ });
160
+ } catch (error) {
161
+ return refuse(
162
+ 'bulk-delivery-failed',
163
+ `delivery ${request.deliveryId} failed: ${error instanceof Error ? error.message : String(error)}`,
164
+ );
165
+ }
166
+ }
@@ -0,0 +1,404 @@
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
+
37
+ import type { Result } from '../core/result.js';
38
+ import { ok, refuse } from '../core/result.js';
39
+ import { isContainedBy, normalizePath } from '../core/paths.js';
40
+ import { TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX } from '../control/frames.js';
41
+ import { nodePathResolver } from './paths.js';
42
+
43
+ /**
44
+ * The strict name allowlist — drive/path characters only. `'.'` and `'..'` PASS this regex, which
45
+ * is exactly why the dot-name reject below is its own layer rather than a tightening of this one.
46
+ */
47
+ export const NAME_ALLOWLIST = /^[A-Za-z0-9._-]+$/;
48
+
49
+ /**
50
+ * The agent's home when nothing configures one: the folder the agent CLI keeps its state in, under
51
+ * the user's home directory. Null when there is no home to derive it from — a refusable state,
52
+ * never a fallback: inventing one would turn "this machine has no home directory" into a silent
53
+ * empty listing that reads as a fresh machine.
54
+ */
55
+ export function defaultAgentHome(env: NodeJS.ProcessEnv = process.env): string | null {
56
+ const home = env['USERPROFILE'] ?? env['HOME'];
57
+ if (typeof home === 'string' && home.trim() !== '') {
58
+ return normalizePath(`${home.replace(/[\\/]+$/, '')}/.claude`);
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Where the agent CLI keeps transcripts under its home: derived, never configured on its own. */
64
+ export function transcriptsRootUnder(agentHome: string): string {
65
+ return normalizePath(`${agentHome.replace(/[\\/]+$/, '')}/projects`);
66
+ }
67
+
68
+ /** The transcripts root under the default agent home, or null when there is no home. */
69
+ export function claudeProjectsRoot(env: NodeJS.ProcessEnv = process.env): string | null {
70
+ const home = defaultAgentHome(env);
71
+ return home === null ? null : transcriptsRootUnder(home);
72
+ }
73
+
74
+ /** One transcript on disk. `mtimeMs` is a floored integer so equality against a stamp holds. */
75
+ export interface DiscoveredTranscript {
76
+ readonly projectSlug: string;
77
+ readonly sessionId: string;
78
+ readonly sizeBytes: number;
79
+ readonly mtimeMs: number;
80
+ /**
81
+ * The working directory the CLI recorded on its entries, read off the file's own head, never
82
+ * decoded from the slug (the flattening is not invertible). The resume handle's other half: the
83
+ * CLI keeps transcripts per cwd, so a resume that does not run there finds nothing. Null when the
84
+ * head carries none.
85
+ */
86
+ readonly cwd: string | null;
87
+ }
88
+
89
+ /** How much of a transcript's head is read for its `cwd` — the first entries, never the file. */
90
+ const CWD_HEAD_BYTES = 64 * 1024;
91
+
92
+ /**
93
+ * The first `cwd` string on the file's first entries, or null. A summary line carries none; the first
94
+ * user or assistant entry does. Every failure is null — the listing describes files, it does not refuse
95
+ * over one of them.
96
+ */
97
+ async function readTranscriptCwd(path: string): Promise<string | null> {
98
+ let head = '';
99
+ try {
100
+ for await (const chunk of createReadStream(path, {
101
+ start: 0,
102
+ end: CWD_HEAD_BYTES - 1,
103
+ encoding: 'utf8',
104
+ })) {
105
+ head += chunk as string;
106
+ }
107
+ } catch {
108
+ return null;
109
+ }
110
+ for (const line of head.split('\n')) {
111
+ const trimmed = line.trim();
112
+ if (trimmed.length === 0) continue;
113
+ try {
114
+ const entry: unknown = JSON.parse(trimmed);
115
+ if (
116
+ typeof entry === 'object' &&
117
+ entry !== null &&
118
+ typeof (entry as { cwd?: unknown }).cwd === 'string'
119
+ ) {
120
+ const cwd = (entry as { cwd: string }).cwd;
121
+ if (cwd.length > 0) return cwd;
122
+ }
123
+ } catch {
124
+ // a partial last line inside the window, or a non-JSON line — keep looking
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export interface TranscriptPage {
131
+ readonly entries: readonly DiscoveredTranscript[];
132
+ readonly totalCount: number;
133
+ /** Ask again from here for the next page; null when this page ends the listing. */
134
+ readonly nextIndex: number | null;
135
+ }
136
+
137
+ /** What a tail probe answers. `absent` true means the transcript does not exist — a real negative. */
138
+ export interface TranscriptTailAnswer {
139
+ readonly absent: boolean;
140
+ readonly found: boolean;
141
+ readonly newOffset: number;
142
+ readonly sizeBytes: number | null;
143
+ readonly mtimeMs: number | null;
144
+ }
145
+
146
+ /**
147
+ * THE JAIL. Resolve `{root}/{projectSlug}/{sessionId}.jsonl` with all three layers applied to both
148
+ * caller-supplied names. The one path-shaping function in this module — list, tail and the bulk
149
+ * resolver all come through here, so there is no second door to keep honest.
150
+ */
151
+ export function resolveTranscriptPath(root: string, projectSlug: string, sessionId: string): Result<string> {
152
+ for (const [value, what] of [
153
+ [projectSlug, 'projectSlug'],
154
+ [sessionId, 'sessionId'],
155
+ ] as const) {
156
+ if (value === '.' || value === '..') {
157
+ return refuse('transcript-path-escape', `${what} is a dot-name — refused by the dot-name layer`);
158
+ }
159
+ if (!NAME_ALLOWLIST.test(value)) {
160
+ return refuse(
161
+ 'transcript-path-escape',
162
+ `${what} fails the name allowlist — refused by the allowlist layer`,
163
+ );
164
+ }
165
+ }
166
+
167
+ const resolvedRoot = normalizePath(nodePathResolver(root));
168
+ const candidate = normalizePath(nodePathResolver(`${resolvedRoot}/${projectSlug}/${sessionId}.jsonl`));
169
+ if (!isContainedBy(candidate, resolvedRoot)) {
170
+ return refuse(
171
+ 'transcript-path-escape',
172
+ `the resolved path leaves the projects root — refused by the containment layer`,
173
+ );
174
+ }
175
+ return ok(candidate);
176
+ }
177
+
178
+ /**
179
+ * Enumerate every session transcript: direct-child `*.jsonl` per slug directory (nested files are
180
+ * the CLI's subagent transcripts and are not part of this listing). Newest first by mtime, paged.
181
+ * A missing root is a fresh machine and answers an empty listing, never an error; a file deleted
182
+ * between readdir and stat is skipped for the same reason.
183
+ */
184
+ export async function listTranscripts(
185
+ root: string,
186
+ page: { fromIndex?: number; pageSize?: number } = {},
187
+ ): Promise<TranscriptPage> {
188
+ const fromIndex = Math.max(0, page.fromIndex ?? 0);
189
+ const pageSize = Math.max(1, page.pageSize ?? TRANSCRIPT_PAGE_SIZE);
190
+ const resolvedRoot = normalizePath(nodePathResolver(root));
191
+
192
+ let slugDirs;
193
+ try {
194
+ slugDirs = (await readdir(resolvedRoot, { withFileTypes: true })).filter(
195
+ (entry) =>
196
+ entry.isDirectory() && NAME_ALLOWLIST.test(entry.name) && entry.name !== '.' && entry.name !== '..',
197
+ );
198
+ } catch {
199
+ return { entries: [], totalCount: 0, nextIndex: null };
200
+ }
201
+
202
+ const all: Omit<DiscoveredTranscript, 'cwd'>[] = [];
203
+ for (const slugDir of slugDirs) {
204
+ let files;
205
+ try {
206
+ files = await readdir(`${resolvedRoot}/${slugDir.name}`, { withFileTypes: true });
207
+ } catch {
208
+ continue;
209
+ }
210
+ for (const file of files) {
211
+ if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
212
+ const sessionId = file.name.slice(0, -'.jsonl'.length);
213
+ if (sessionId === '.' || sessionId === '..' || !NAME_ALLOWLIST.test(sessionId)) continue;
214
+ try {
215
+ const stats = await stat(`${resolvedRoot}/${slugDir.name}/${file.name}`);
216
+ all.push({
217
+ projectSlug: slugDir.name,
218
+ sessionId,
219
+ sizeBytes: stats.size,
220
+ mtimeMs: Math.floor(stats.mtimeMs),
221
+ });
222
+ } catch {
223
+ // Deleted between readdir and stat — the listing describes what exists, not what did.
224
+ }
225
+ }
226
+ }
227
+
228
+ all.sort((a, b) => b.mtimeMs - a.mtimeMs);
229
+ // The cwd is read for the PAGE only — one head per listed row, never one per file on the machine.
230
+ const entries: DiscoveredTranscript[] = await Promise.all(
231
+ all.slice(fromIndex, fromIndex + pageSize).map(async (entry) => ({
232
+ ...entry,
233
+ cwd: await readTranscriptCwd(`${resolvedRoot}/${entry.projectSlug}/${entry.sessionId}.jsonl`),
234
+ })),
235
+ );
236
+ const nextIndex = fromIndex + entries.length;
237
+ return {
238
+ entries,
239
+ totalCount: all.length,
240
+ nextIndex: nextIndex < all.length ? nextIndex : null,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Probe one transcript from `fromOffset` for a user entry matching `needle` (null = any user-text
246
+ * entry). Jail violations refuse; an absent file answers `{absent: true}`; a read that FAILS is a
247
+ * refusal (`transcript-read-failed`), never a false negative — to the asker "no new entry" and
248
+ * "could not look" must not be the same answer.
249
+ */
250
+ export async function tailTranscript(
251
+ root: string,
252
+ projectSlug: string,
253
+ sessionId: string,
254
+ probe: { fromOffset?: number; needle?: string | null } = {},
255
+ ): Promise<Result<TranscriptTailAnswer>> {
256
+ const resolved = resolveTranscriptPath(root, projectSlug, sessionId);
257
+ if (!resolved.ok) return resolved;
258
+
259
+ let stats;
260
+ try {
261
+ stats = await stat(resolved.value);
262
+ } catch {
263
+ return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
264
+ }
265
+ if (!stats.isFile()) {
266
+ return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
267
+ }
268
+
269
+ const sizeBytes = stats.size;
270
+ const mtimeMs = Math.floor(stats.mtimeMs);
271
+ const asked = Math.max(0, probe.fromOffset ?? 0);
272
+ // A file shorter than the baseline was rewritten (compaction, or a fork replacing it): scan from
273
+ // the start and let the needle reject stale entries — resuming into a rewritten file would read
274
+ // from the middle of a line that no longer exists.
275
+ const start = sizeBytes < asked ? 0 : asked;
276
+ const needle = probe.needle ?? null;
277
+
278
+ if (start >= sizeBytes) {
279
+ return ok({ absent: false, found: false, newOffset: sizeBytes, sizeBytes, mtimeMs });
280
+ }
281
+
282
+ try {
283
+ const found = await scanForUserEntry(resolved.value, start, sizeBytes, needle);
284
+ return ok({ absent: false, found, newOffset: sizeBytes, sizeBytes, mtimeMs });
285
+ } catch (error) {
286
+ return refuse(
287
+ 'transcript-read-failed',
288
+ `reading ${projectSlug}/${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`,
289
+ );
290
+ }
291
+ }
292
+
293
+ /**
294
+ * The bulk-lane resolver for transcript reads: `claude-transcript:{slug}/{sessionId}` to a jailed
295
+ * absolute path. A locator outside the namespace is refused as an invalid bulk target so an
296
+ * embedder composing several resolvers can tell "not mine" from "mine and malformed".
297
+ */
298
+ export function claudeTranscriptResolver(root: string): (what: string, sessionKey: string) => Result<string> {
299
+ return (what: string): Result<string> => {
300
+ if (!what.startsWith(TRANSCRIPT_WHAT_PREFIX)) {
301
+ return refuse(
302
+ 'bulk-target-invalid',
303
+ `this host resolves "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}" locators; got "${what}"`,
304
+ );
305
+ }
306
+ const rest = what.slice(TRANSCRIPT_WHAT_PREFIX.length);
307
+ const slash = rest.indexOf('/');
308
+ if (slash <= 0 || slash === rest.length - 1) {
309
+ return refuse(
310
+ 'bulk-target-invalid',
311
+ `a transcript locator is "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}"; got "${what}"`,
312
+ );
313
+ }
314
+ return resolveTranscriptPath(root, rest.slice(0, slash), rest.slice(slash + 1));
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // The user-entry predicate: the rule that decides whether a new user entry landed.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** Collapse every whitespace run to one space and trim, so needle matching survives reflowing. */
323
+ const normalizeForMatch = (text: string): string => text.replace(/\s+/g, ' ').trim();
324
+
325
+ /** A user entry's text: a flat string content, or its concatenated `text` blocks. */
326
+ function userTextOf(message: unknown): string {
327
+ const content = (message as { content?: unknown }).content;
328
+ if (typeof content === 'string') return content;
329
+ if (!Array.isArray(content)) return '';
330
+ let out = '';
331
+ for (const block of content) {
332
+ if (
333
+ block !== null &&
334
+ typeof block === 'object' &&
335
+ (block as { type?: unknown }).type === 'text' &&
336
+ typeof (block as { text?: unknown }).text === 'string'
337
+ ) {
338
+ out += (block as { text: string }).text;
339
+ }
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /**
345
+ * One line: does it count as a matching user entry? Unparseable or foreign lines are skipped,
346
+ * never thrown — a truncated LAST line is the normal state of a file being appended to. A
347
+ * tool-result-only user line has no text block and so never matches.
348
+ */
349
+ export function isMatchingUserEntry(line: string, needle: string | null): boolean {
350
+ let root: unknown;
351
+ try {
352
+ root = JSON.parse(line);
353
+ } catch {
354
+ return false;
355
+ }
356
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return false;
357
+ if ((root as { type?: unknown }).type !== 'user') return false;
358
+ const message = (root as { message?: unknown }).message;
359
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) return false;
360
+ const role = (message as { role?: unknown }).role;
361
+ if (role !== undefined && role !== 'user') return false;
362
+
363
+ const text = userTextOf(message);
364
+ if (text.trim().length === 0) return false;
365
+ if (needle === null || needle === '') return true;
366
+ return normalizeForMatch(text).includes(needle);
367
+ }
368
+
369
+ /**
370
+ * Read `[start, end)` in chunks with a line carry, so a large transcript never lands in one buffer
371
+ * and a partial final line is simply the last carry. The final carry is tested too — an
372
+ * unterminated tail is still a line.
373
+ */
374
+ function scanForUserEntry(file: string, start: number, end: number, needle: string | null): Promise<boolean> {
375
+ return new Promise((resolve, reject) => {
376
+ const stream = createReadStream(file, { start, end: end - 1, encoding: 'utf8' });
377
+ let carry = '';
378
+ let found = false;
379
+
380
+ stream.on('data', (chunk) => {
381
+ if (found) return;
382
+ const text = carry + String(chunk);
383
+ const lines = text.split('\n');
384
+ carry = lines.pop() ?? '';
385
+ for (const raw of lines) {
386
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
387
+ if (line.length === 0) continue;
388
+ if (isMatchingUserEntry(line, needle)) {
389
+ found = true;
390
+ stream.destroy();
391
+ return;
392
+ }
393
+ }
394
+ });
395
+ stream.on('error', reject);
396
+ stream.on('close', () => {
397
+ if (!found && carry.length > 0) {
398
+ const line = carry.endsWith('\r') ? carry.slice(0, -1) : carry;
399
+ if (line.length > 0 && isMatchingUserEntry(line, needle)) found = true;
400
+ }
401
+ resolve(found);
402
+ });
403
+ });
404
+ }