clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -0,0 +1,1018 @@
1
+ // Claude Code's top-level transcripts are a read-only source. Discovery is bounded;
2
+ // only opening a timeline scans the transcript, into the same capped event log as host sessions.
3
+ import fs from "node:fs";
4
+ import { execFileSync } from "node:child_process";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { EventLog } from "./eventlog.js";
8
+ import { processCommands } from "./processes.js";
9
+ import { messageContent, sendUserFileImages } from "./images.js";
10
+ import { PERMISSION_MODES } from "./protocol.js";
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const WINDOW = 256 * 1024;
13
+ const RECENT_MS = 14 * 24 * 60 * 60 * 1000;
14
+ /**
15
+ * pid → the start time `ps` reported for it. A pid's start time never changes while it lives, and
16
+ * a recycled pid gets a new registry procStart, so one synchronous `ps` per process is enough;
17
+ * running it for every registry entry on every poll blocked the host (2026-10-12).
18
+ */
19
+ const verifiedStarts = new Map();
20
+ let snapshot = null;
21
+ /** Registry identity is authoritative; ps flags remain a legacy fallback only. Cached for 1 s. */
22
+ export function liveDesktopProcesses(dir, maxAgeMs = 1000, platform = process.platform) {
23
+ if (platform !== "darwin")
24
+ return new Map();
25
+ const now = Date.now();
26
+ if (snapshot && snapshot.dir === dir && now - snapshot.at < maxAgeMs)
27
+ return snapshot.result;
28
+ const result = new Map();
29
+ snapshot = { dir, at: now, result };
30
+ let entries;
31
+ try {
32
+ entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch (error) {
35
+ if (missing(error))
36
+ return result;
37
+ throw error;
38
+ }
39
+ for (const entry of entries) {
40
+ if (!entry.isFile() || !/^\d+\.json$/.test(entry.name))
41
+ continue;
42
+ const pid = Number(entry.name.slice(0, -5));
43
+ let value;
44
+ try {
45
+ value = record(fs.readFileSync(path.join(dir, entry.name), "utf8"));
46
+ }
47
+ catch (error) {
48
+ if (missing(error))
49
+ continue;
50
+ throw error;
51
+ }
52
+ if (!value || value.entrypoint !== "claude-desktop" || typeof value.sessionId !== "string" || !UUID.test(value.sessionId) || pid <= 1 || (value.pid !== undefined && value.pid !== pid))
53
+ continue;
54
+ if (!["busy", "shell", "idle", "waiting"].includes(str(value.status)))
55
+ continue;
56
+ try {
57
+ process.kill(pid, 0);
58
+ if (typeof value.procStart === "string" && verifiedStarts.get(pid) !== value.procStart) {
59
+ const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"] }).trim();
60
+ if (started !== value.procStart)
61
+ continue;
62
+ verifiedStarts.set(pid, started);
63
+ }
64
+ }
65
+ catch {
66
+ continue;
67
+ } // Dead or recycled process, not a live desktop writer.
68
+ const status = value.status;
69
+ result.set(value.sessionId, { sessionId: value.sessionId, pid, status, ...(typeof value.bridgeSessionId === "string" ? { bridgeSessionId: value.bridgeSessionId } : {}) });
70
+ }
71
+ return result;
72
+ }
73
+ /** Match session flags, not UUIDs appearing in a prompt or another flag's value. */
74
+ export function processHasSession(output, id) {
75
+ if (!UUID.test(id))
76
+ return false;
77
+ return output.split("\n").some(line => {
78
+ const args = line.trim().split(/\s+/).map(arg => arg.replace(/^"|"$/g, ""));
79
+ return args.some((arg, i) => arg === `--resume=${id}` || arg === `--session-id=${id}` ||
80
+ ((arg === "--session-id" || arg === "--resume") && args[i + 1] === id));
81
+ });
82
+ }
83
+ function object(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
85
+ }
86
+ const str = (value) => typeof value === "string" ? value : "";
87
+ /** A transcript's top-level permissionMode, only when it names a mode the host knows. */
88
+ const permissionMode = (value) => typeof value === "string" && PERMISSION_MODES.includes(value) ? value : null;
89
+ function record(line) {
90
+ try {
91
+ return object(JSON.parse(line));
92
+ }
93
+ catch {
94
+ return null;
95
+ } // Untrusted JSONL boundary.
96
+ }
97
+ function text(content) {
98
+ if (typeof content === "string")
99
+ return content;
100
+ if (!Array.isArray(content))
101
+ return "";
102
+ return content.flatMap(value => {
103
+ const block = object(value);
104
+ return block?.type === "text" ? [str(block.text)] : block?.type === "image" ? ["[image]"] : [];
105
+ }).join("\n\n");
106
+ }
107
+ function prompt(r) {
108
+ if (r.type !== "user" || r.isSidechain === true || r.isMeta === true)
109
+ return "";
110
+ const message = object(r.message);
111
+ const body = text(message?.content).trim();
112
+ // CLI-generated command/context messages are not the person's first prompt.
113
+ return /^(<local-command-|<command-name>|<system-reminder>|\[Request interrupted)/.test(body) ? "" : body;
114
+ }
115
+ function title(value) { return value.trim().replace(/\s+/g, " ").slice(0, 160); }
116
+ /** Unknown records and malformed block shapes never become events. */
117
+ export function transcriptEvents(line, cwd, summarize, sink, subs) {
118
+ const r = record(line);
119
+ const bodies = [];
120
+ if (!r)
121
+ return { at: null, bodies };
122
+ const sidechain = r.isSidechain === true;
123
+ if (sidechain && !subs?.allowSidechain)
124
+ return { at: null, bodies };
125
+ const state = subs?.state;
126
+ const resolve = subs?.resolve;
127
+ const time = typeof r.timestamp === "string" ? Date.parse(r.timestamp) : NaN;
128
+ const at = Number.isFinite(time) ? time : null;
129
+ const message = object(r.message);
130
+ // A notification that arrives mid-turn is queued, not written as a user line (BackgroundTasks.accept).
131
+ const queued = r.type === "attachment" && object(r.attachment)?.type === "queued_command" ? str(object(r.attachment)?.prompt) : "";
132
+ if (r.type === "user" || queued) {
133
+ const p = queued || (sidechain ? text(message?.content).trim() : prompt(r));
134
+ const notification = /^<task-notification>[\s\S]*<\/task-notification>$/.test(p);
135
+ if (notification) {
136
+ const toolUseId = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(p)?.[1];
137
+ const result = /<result>([\s\S]*)<\/result>/.exec(p)?.[1];
138
+ const status = /<status>([^<]+)<\/status>/.exec(p)?.[1];
139
+ // A notification without its tool call has nothing to attach to; it is not the user's words.
140
+ if (toolUseId) {
141
+ bodies.push({ kind: "tool_result", toolUseId, ok: status === "completed", text: result ?? p });
142
+ const running = state?.get(toolUseId);
143
+ if (running && resolve) {
144
+ const info = resolve(toolUseId, running.input);
145
+ bodies.push({ kind: "subagent", toolUseId, sub: `sub-${toolUseId}`, description: running.description, model: info.model ?? running.model, status: status === "completed" ? "done" : "failed", steps: info.steps });
146
+ }
147
+ state?.delete(toolUseId);
148
+ }
149
+ }
150
+ else if (p && r.type === "user") {
151
+ const converted = messageContent(message?.content, sink);
152
+ bodies.push({ kind: "user", ...converted, clientMsgId: str(r.uuid) });
153
+ }
154
+ for (const value of Array.isArray(message?.content) ? message.content : []) {
155
+ const b = object(value);
156
+ if (b?.type === "tool_result" && typeof b.tool_use_id === "string") {
157
+ const converted = messageContent(b.content, sink);
158
+ bodies.push({ kind: "tool_result", toolUseId: b.tool_use_id, ok: b.is_error !== true, ...converted });
159
+ // A synchronous worker finishes at its tool result; an async one only launches here and ends at its notification.
160
+ const running = state?.get(b.tool_use_id);
161
+ const launched = object(r.toolUseResult)?.isAsync === true || /(?:launched asynchronously|async(?: agent)? launched)/i.test(typeof converted.text === "string" ? converted.text : "");
162
+ if (running && resolve && !launched) {
163
+ const info = resolve(b.tool_use_id, running.input);
164
+ bodies.push({ kind: "subagent", toolUseId: b.tool_use_id, sub: `sub-${b.tool_use_id}`, description: running.description, model: info.model ?? running.model, status: b.is_error === true ? "failed" : "done", steps: info.steps });
165
+ state?.delete(b.tool_use_id);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ else if (r.type === "assistant" && message && Array.isArray(message.content)) {
171
+ const tools = [];
172
+ const msg = str(r.uuid) || str(message.id);
173
+ message.content.forEach((value, index) => {
174
+ const block = object(value);
175
+ if (block?.type === "thinking" && typeof block.thinking === "string")
176
+ bodies.push({ kind: "thinking", msg, block: index, text: block.thinking });
177
+ });
178
+ for (const value of message.content) {
179
+ const b = object(value);
180
+ if (b?.type !== "tool_use" || typeof b.id !== "string" || typeof b.name !== "string")
181
+ continue;
182
+ const input = object(b.input) ?? {};
183
+ const worker = b.name === "Agent" || b.name === "Task";
184
+ const summary = summarize(b.name, input, cwd);
185
+ tools.push({ id: b.id, name: b.name,
186
+ summary: worker ? [summary || str(input.subagent_type) || b.name, str(input.model)].filter(Boolean).join(" · ") : summary,
187
+ input: JSON.stringify(input, null, 2) });
188
+ // Only a top-level worker opens its own log; sidechain records never carry their own subagent event.
189
+ if (worker && !sidechain && state && resolve) {
190
+ const info = resolve(b.id, input);
191
+ const running = { description: info.description || summary || str(input.subagent_type) || b.name, model: info.model, steps: info.steps, input };
192
+ state.set(b.id, running);
193
+ bodies.push({ kind: "subagent", toolUseId: b.id, sub: `sub-${b.id}`, description: running.description, model: running.model, status: "running", steps: running.steps });
194
+ }
195
+ }
196
+ const body = messageContent(message.content, sink);
197
+ body.images.push(...sendUserFileImages(message.content, cwd, sink));
198
+ if (body.text || body.images.length || tools.length)
199
+ bodies.push({ kind: "assistant", msg: str(r.uuid) || str(message.id), model: str(message.model), ...body, tools });
200
+ }
201
+ else if (r.type === "result") {
202
+ const ok = r.subtype === "success" && r.is_error !== true;
203
+ bodies.push({ kind: "result", ok, ...(!ok ? { error: str(r.result) || str(r.subtype) } : {}), ms: typeof r.duration_ms === "number" ? r.duration_ms : 0, turns: typeof r.num_turns === "number" ? r.num_turns : 0 });
204
+ }
205
+ else if (r.type === "system" && r.subtype === "turn_duration" && typeof r.durationMs === "number") {
206
+ bodies.push({ kind: "result", ok: true, ms: r.durationMs, turns: 1 });
207
+ }
208
+ return { at, bodies };
209
+ }
210
+ /** Conversation records, not trailing bridge/cost/title bookkeeping, determine turn completion.
211
+ * Desktop tails (2026-10-12): assistant.message.stop_reason=end_turn; user tool_result
212
+ * blocks carry tool_use_id; system stop_hook_summary follows the final assistant.
213
+ * A tool result finishes a tool, not the turn: the model still owes its next reply.
214
+ */
215
+ function unfinishedTurn(lines) {
216
+ let unfinished = false;
217
+ for (const line of lines) {
218
+ const r = record(line);
219
+ if (!r || r.isSidechain === true || r.isMeta === true)
220
+ continue;
221
+ if (r.type === "result" || (r.type === "system" && r.subtype === "turn_duration")) {
222
+ unfinished = false;
223
+ }
224
+ else if (r.type === "system" && r.subtype === "stop_hook_summary") {
225
+ unfinished = r.preventedContinuation === true;
226
+ }
227
+ else if (r.type === "user") {
228
+ const content = object(r.message)?.content;
229
+ if (typeof content === "string" && /^\[Request interrupted/.test(content))
230
+ unfinished = false;
231
+ else if (prompt(r) || (Array.isArray(content) && content.some(b => object(b)?.type === "tool_result")))
232
+ unfinished = true;
233
+ }
234
+ else if (r.type === "assistant") {
235
+ const message = object(r.message);
236
+ if (!message || !Array.isArray(message.content))
237
+ continue;
238
+ unfinished = message.content.some(b => object(b)?.type === "tool_use") ||
239
+ (message.stop_reason !== "end_turn" && message.stop_reason !== "stop_sequence" &&
240
+ ("stop_reason" in message || !message.content.some(b => object(b)?.type === "text")));
241
+ }
242
+ }
243
+ return unfinished;
244
+ }
245
+ /** Complete lines only: a partial head/tail record must not be interpreted. */
246
+ function windowLines(fd, size, tail) {
247
+ const start = tail ? Math.max(0, size - WINDOW) : 0;
248
+ const buffer = Buffer.alloc(Math.min(size - start, WINDOW));
249
+ const n = fs.readSync(fd, buffer, 0, buffer.length, start);
250
+ let data = buffer.toString("utf8", 0, n);
251
+ if (start > 0)
252
+ data = data.slice(data.indexOf("\n") + 1);
253
+ if (start + n < size || !data.endsWith("\n"))
254
+ data = data.slice(0, Math.max(0, data.lastIndexOf("\n")));
255
+ return data.split("\n");
256
+ }
257
+ /** Whether a subagent transcript's last record is an answer that ends the agent's loop. */
258
+ export function agentEnded(file) {
259
+ let fd;
260
+ try {
261
+ fd = fs.openSync(file, "r");
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ try {
267
+ const last = windowLines(fd, fs.fstatSync(fd).size, true).filter(Boolean).at(-1);
268
+ const r = last ? record(last) : null;
269
+ const message = object(r?.message);
270
+ const content = message?.content;
271
+ return r?.type === "assistant" && ["end_turn", "stop_sequence"].includes(str(message?.stop_reason)) &&
272
+ !(Array.isArray(content) && content.some(b => object(b)?.type === "tool_use"));
273
+ }
274
+ finally {
275
+ fs.closeSync(fd);
276
+ }
277
+ }
278
+ function missing(error) {
279
+ return ["ENOENT", "ENOTDIR", "ELOOP"].includes(error.code ?? "");
280
+ }
281
+ /** A meta.json or subagent transcript that is not there yet is simply unknown, never fatal. */
282
+ function safeRead(file) {
283
+ try {
284
+ return fs.readFileSync(file, "utf8");
285
+ }
286
+ catch {
287
+ return "";
288
+ }
289
+ }
290
+ const SUB_META = /^agent-([\w-]+)\.meta\.json$/;
291
+ /** A hook prefixes a subagent's stored description with "Model·effort · ", shown separately as `model`. */
292
+ function stripModelPrefix(description) {
293
+ return description.replace(/^\S*·\S* · /, "");
294
+ }
295
+ /** The model without its `@effort` suffix (`gpt-6.1-sol@medium` → `gpt-6.1-sol`). */
296
+ function withoutEffort(model) {
297
+ return model.replace(/@[^@]*$/, "");
298
+ }
299
+ /** Top-level task lifecycle only; synchronous workers and nested subagents are not background tasks.
300
+ * Also the main turn's start as the app counts it: the person's last message (origin "human"; compaction
301
+ * summaries and task notifications are not), until the model ends its turn or the person interrupts. */
302
+ export class BackgroundTasks {
303
+ tools = new Map();
304
+ active = new Map();
305
+ terminal = new Set();
306
+ get count() { return this.active.size; }
307
+ get list() { return Array.from(this.active.values()); }
308
+ turnStartedAt = null;
309
+ accept(line) {
310
+ const r = record(line);
311
+ if (!r || r.isSidechain === true)
312
+ return;
313
+ const content = object(r.message)?.content;
314
+ const at = typeof r.timestamp === "string" ? Date.parse(r.timestamp) : NaN;
315
+ if (r.type === "user" && object(r.origin)?.kind === "human" && Number.isFinite(at))
316
+ this.turnStartedAt = at;
317
+ else if (r.type === "user" && typeof content === "string" && /^\[Request interrupted/.test(content))
318
+ this.turnStartedAt = null;
319
+ else if (r.type === "assistant" && Array.isArray(content) && !content.some(b => object(b)?.type === "tool_use") &&
320
+ ["end_turn", "stop_sequence"].includes(str(object(r.message)?.stop_reason)))
321
+ this.turnStartedAt = null;
322
+ // A notification that arrives mid-turn is queued, not written as a user line: the transcript has it
323
+ // only as a queued_command attachment (and the queue's own enqueue record), seen 2026-10-16.
324
+ const queued = r.type === "attachment" && object(r.attachment)?.type === "queued_command" ? str(object(r.attachment)?.prompt) : "";
325
+ const notification = queued || (r.type === "user" ? text(content) : "");
326
+ if (notification.startsWith("<task-notification>")) {
327
+ const id = /<task-id>([^<]+)<\/task-id>/.exec(notification)?.[1];
328
+ if (id && /<status>(completed|killed|failed|stopped)<\/status>/.test(notification))
329
+ this.end(id);
330
+ return;
331
+ }
332
+ for (const value of Array.isArray(content) ? content : []) {
333
+ const b = object(value);
334
+ if (r.type === "assistant" && b?.type === "tool_use" && typeof b.id === "string") {
335
+ const name = str(b.name);
336
+ const input = object(b.input);
337
+ if (["Agent", "Task", "Bash"].includes(name))
338
+ this.tools.set(b.id, { name, background: input?.run_in_background === true, label: str(input?.description) || str(input?.command) || name });
339
+ // A task stopped with TaskStop gets no notification at all (seen 2026-10-16: a shell "running" four hours).
340
+ if (name === "TaskStop")
341
+ this.tools.set(b.id, { name, background: false, label: str(input?.task_id) || str(input?.shell_id) });
342
+ }
343
+ if (r.type !== "user" || b?.type !== "tool_result" || b.is_error === true)
344
+ continue;
345
+ const tool = this.tools.get(str(b.tool_use_id));
346
+ if (!tool)
347
+ continue;
348
+ if (tool.name === "TaskStop") {
349
+ if (tool.label)
350
+ this.end(tool.label);
351
+ this.tools.delete(str(b.tool_use_id));
352
+ continue;
353
+ }
354
+ const result = object(r.toolUseResult);
355
+ const body = text(b.content);
356
+ let id = "";
357
+ if (tool.name === "Bash") {
358
+ id = str(result?.backgroundTaskId) || (tool.background ? /(?:ID|id):\s*([\w-]+)/.exec(body)?.[1] ?? "" : "");
359
+ }
360
+ else if (result?.isAsync === true || /(?:launched asynchronously|async(?: agent)? launched)/i.test(body)) {
361
+ id = str(result?.agentId) || str(result?.taskId) || /(?:agentId|task[_ -]?id):\s*([\w-]+)/i.exec(body)?.[1] || "";
362
+ }
363
+ if (id && !this.terminal.has(id))
364
+ this.active.set(id, { id, kind: tool.name === "Bash" ? "shell" : "agent", label: tool.label, startedAt: Number.isFinite(at) ? at : null });
365
+ this.tools.delete(str(b.tool_use_id));
366
+ }
367
+ }
368
+ end(id) {
369
+ this.active.delete(id);
370
+ this.terminal.add(id);
371
+ }
372
+ }
373
+ export class DesktopSessions {
374
+ opts;
375
+ projects;
376
+ sources = new Map();
377
+ imported = new Map();
378
+ subImports = new Map();
379
+ tasks = new Map();
380
+ /** Per-session subagent state for an incremental tail (a bridged session), so a tool_result in a
381
+ * later poll still closes the worker its tool_use opened in an earlier one. */
382
+ tailSubs = new Map();
383
+ /** session → toolUseId → agentId, rebuilt when the subagents directory changes. */
384
+ agentMaps = new Map();
385
+ temp = null;
386
+ scannedAt = -Infinity;
387
+ checkedAt = -Infinity;
388
+ constructor(opts) {
389
+ this.opts = opts;
390
+ this.projects = path.resolve(opts.projects ?? path.join(os.homedir(), ".claude", "projects"));
391
+ }
392
+ list() {
393
+ this.refresh();
394
+ this.updateLive();
395
+ const excluded = this.opts.excluded();
396
+ return Array.from(this.sources.values()).filter(s => !excluded.has(s.summary.id)).map(s => {
397
+ // Unfinished-looking tasks linger in old transcripts; only a busy process can have any running.
398
+ const live = this.liveProcess(s.summary.id);
399
+ if (live?.bridgeSessionId) {
400
+ const working = live.status === "busy" || live.status === "shell";
401
+ const taskList = working ? this.backgroundTaskList(s.summary.id) : [];
402
+ s.summary = { ...s.summary, tasks: taskList.length, taskList, turnStartedAt: working ? this.turnStartedAt(s.summary.id) : null };
403
+ }
404
+ const cached = this.imported.get(s.summary.id);
405
+ return cached && cached.size === s.size && cached.mtime === s.mtime
406
+ ? { ...s.summary, lastSeq: cached.log.lastSeq, epoch: cached.log.epoch } : s.summary;
407
+ });
408
+ }
409
+ poll() {
410
+ const fingerprint = () => JSON.stringify(Array.from(this.sources.values())
411
+ .filter(s => !this.opts.excluded().has(s.summary.id))
412
+ .map(s => [s.summary.id, s.summary.state, s.summary.updatedAt, s.summary.title, s.summary.tasks, s.summary.taskList?.map(t => t.id).join(), s.summary.turnStartedAt]));
413
+ const before = fingerprint();
414
+ this.list();
415
+ return before !== fingerprint();
416
+ }
417
+ has(id) {
418
+ if (!UUID.test(id) || this.opts.excluded().has(id))
419
+ return false;
420
+ this.refresh();
421
+ return this.sources.has(id);
422
+ }
423
+ liveProcess(id) {
424
+ return (this.opts.registry?.() ?? liveDesktopProcesses(path.join(path.dirname(this.projects), "sessions"))).get(id);
425
+ }
426
+ isOpen(id) {
427
+ return !!this.liveProcess(id) || processHasSession(this.opts.processes?.() ?? processCommands(), id);
428
+ }
429
+ /** Tail complete transcript lines into an existing host log; never write the transcript. */
430
+ tail(id, offset, visit, sink) {
431
+ const source = this.sources.get(id);
432
+ if (!source)
433
+ return offset;
434
+ const fd = this.open(source.file);
435
+ try {
436
+ const size = fs.fstatSync(fd).size;
437
+ if (size < offset)
438
+ throw new Error("Desktop transcript was truncated.");
439
+ let state = this.tailSubs.get(id);
440
+ if (!state) {
441
+ state = new Map();
442
+ this.tailSubs.set(id, state);
443
+ }
444
+ const resolve = (toolUseId, input) => this.subInfo(id, toolUseId, input);
445
+ return scanLines(fd, size, line => {
446
+ for (const body of transcriptEvents(line, source.summary.cwd, this.opts.summarize, sink, { state, resolve }).bodies)
447
+ visit(body);
448
+ }, offset);
449
+ }
450
+ finally {
451
+ fs.closeSync(fd);
452
+ }
453
+ }
454
+ backgroundTasks(id) { return this.backgroundTaskList(id).length; }
455
+ /** A background agent whose own transcript ends on its final answer is done, notification or not:
456
+ * one read through TaskOutput, or finished before a compaction, never gets a <task-notification> line
457
+ * in the session's transcript and otherwise stays "running" for good (seen 2026-10-14, five hours). */
458
+ backgroundTaskList(id) {
459
+ const list = this.scan(id)?.list ?? [];
460
+ const source = this.sources.get(id);
461
+ if (!source)
462
+ return list;
463
+ const dir = this.subagentsDir(source);
464
+ return list.filter(task => task.kind !== "agent" || !agentEnded(path.join(dir, `agent-${task.id}.jsonl`)));
465
+ }
466
+ turnStartedAt(id) { return this.scan(id)?.turnStartedAt ?? null; }
467
+ /** A subagent transcript lives beside its session file: `<projects>/<proj>/<id>/subagents/agent-<agentId>.jsonl`. */
468
+ subagentsDir(source) {
469
+ return path.join(path.dirname(source.file), path.basename(source.file, ".jsonl"), "subagents");
470
+ }
471
+ /** toolUseId → agentId, from the `agent-*.meta.json` files a worker writes when it starts. */
472
+ agentMap(id) {
473
+ const empty = new Map();
474
+ const source = this.sources.get(id);
475
+ if (!source)
476
+ return empty;
477
+ const dir = this.subagentsDir(source);
478
+ let at;
479
+ try {
480
+ at = fs.statSync(dir).mtimeMs;
481
+ }
482
+ catch {
483
+ return empty;
484
+ }
485
+ const cached = this.agentMaps.get(id);
486
+ if (cached && cached.at === at)
487
+ return cached.map;
488
+ const map = new Map();
489
+ try {
490
+ for (const name of fs.readdirSync(dir)) {
491
+ const match = SUB_META.exec(name);
492
+ if (!match)
493
+ continue;
494
+ const meta = record(safeRead(path.join(dir, name)));
495
+ const toolUseId = str(meta?.toolUseId);
496
+ if (toolUseId)
497
+ map.set(toolUseId, match[1]);
498
+ }
499
+ }
500
+ catch (error) {
501
+ if (!missing(error))
502
+ throw error;
503
+ }
504
+ this.agentMaps.set(id, { at, map });
505
+ return map;
506
+ }
507
+ /** What a worker's own transcript reports, for its parent's subagent events. */
508
+ subInfo(id, toolUseId, input) {
509
+ const source = this.sources.get(id);
510
+ const agentId = source ? this.agentMap(id).get(toolUseId) : undefined;
511
+ let model = "", steps = 0, ended = false, description = "";
512
+ if (source && agentId) {
513
+ const dir = this.subagentsDir(source);
514
+ const meta = record(safeRead(path.join(dir, `agent-${agentId}.meta.json`)));
515
+ if (meta) {
516
+ model = withoutEffort(str(meta.model));
517
+ description = stripModelPrefix(str(meta.description));
518
+ }
519
+ const read = this.readSubTranscript(path.join(dir, `agent-${agentId}.jsonl`));
520
+ model = read.model || model;
521
+ steps = read.steps;
522
+ ended = read.ended;
523
+ }
524
+ return { model: model || str(input.model) || null, steps, description, ended };
525
+ }
526
+ /** The model that answered (its last assistant record) and how far it got. Tail-bounded read. */
527
+ readSubTranscript(file) {
528
+ let fd;
529
+ try {
530
+ fd = this.openUnder(file);
531
+ }
532
+ catch {
533
+ return { model: "", steps: 0, ended: false };
534
+ }
535
+ try {
536
+ let model = "", steps = 0;
537
+ let last = null;
538
+ scanLines(fd, fs.fstatSync(fd).size, line => {
539
+ const r = record(line);
540
+ if (!r)
541
+ return;
542
+ last = r;
543
+ if (r.type === "assistant") {
544
+ steps++;
545
+ const m = object(r.message);
546
+ if (typeof m?.model === "string")
547
+ model = m.model;
548
+ }
549
+ });
550
+ const ending = last;
551
+ const message = object(ending?.message);
552
+ const content = message?.content;
553
+ const ended = ending?.type === "assistant" && ["end_turn", "stop_sequence"].includes(str(message?.stop_reason)) &&
554
+ !(Array.isArray(content) && content.some(b => object(b)?.type === "tool_use"));
555
+ return { model, steps, ended };
556
+ }
557
+ finally {
558
+ fs.closeSync(fd);
559
+ }
560
+ }
561
+ /** A Mac session's worker log: the subagent transcript converted to events, cached like the main one.
562
+ * Unknown session or unknown worker → null, which the `sub` op turns into "no such subagent". */
563
+ subLog(id, sub) {
564
+ const match = /^sub-([\w-]{1,80})$/.exec(sub);
565
+ if (!match)
566
+ return null;
567
+ const source = this.sources.get(id);
568
+ if (!source)
569
+ return null;
570
+ const agentId = this.agentMap(id).get(match[1]);
571
+ if (!agentId)
572
+ return null;
573
+ const file = path.join(this.subagentsDir(source), `agent-${agentId}.jsonl`);
574
+ let stat;
575
+ try {
576
+ stat = fs.lstatSync(file);
577
+ }
578
+ catch (error) {
579
+ if (!missing(error))
580
+ throw error;
581
+ return null;
582
+ }
583
+ if (!stat.isFile())
584
+ return null;
585
+ const key = `${id}::${sub}`;
586
+ const cached = this.subImports.get(key);
587
+ if (cached && cached.size === stat.size && cached.mtime === stat.mtimeMs)
588
+ return cached.log;
589
+ if (cached) {
590
+ this.dispose(cached);
591
+ this.subImports.delete(key);
592
+ }
593
+ this.temp ??= fs.mkdtempSync(path.join(os.tmpdir(), "ripple-desktop-"));
594
+ const dir = fs.mkdtempSync(path.join(this.temp, "sub-"));
595
+ const log = this.importSub(id, file, dir);
596
+ this.subImports.set(key, { size: stat.size, mtime: stat.mtimeMs, log, dir });
597
+ return log;
598
+ }
599
+ importSub(id, file, dir) {
600
+ const source = this.sources.get(id);
601
+ const fd = this.openUnder(file);
602
+ let at = source.summary.createdAt;
603
+ const log = EventLog.open({ dir, now: () => at });
604
+ try {
605
+ scanLines(fd, fs.fstatSync(fd).size, line => {
606
+ const parsed = transcriptEvents(line, source.summary.cwd, this.opts.summarize, data => log.image(data), { allowSidechain: true });
607
+ at = parsed.at ?? at;
608
+ for (const body of parsed.bodies)
609
+ log.append(body.kind === "user" && !body.clientMsgId ? { ...body, clientMsgId: `desktop-${log.lastSeq + 1}` } : body);
610
+ });
611
+ return log;
612
+ }
613
+ catch (error) {
614
+ log.close();
615
+ throw error;
616
+ }
617
+ finally {
618
+ fs.closeSync(fd);
619
+ }
620
+ }
621
+ scan(id) {
622
+ const source = this.sources.get(id);
623
+ if (!source)
624
+ return null;
625
+ const fd = this.open(source.file);
626
+ try {
627
+ const size = fs.fstatSync(fd).size;
628
+ let cached = this.tasks.get(id);
629
+ if (!cached || cached.file !== source.file || size < cached.offset) {
630
+ cached = { file: source.file, offset: 0, tracker: new BackgroundTasks() };
631
+ this.tasks.set(id, cached);
632
+ }
633
+ cached.offset = scanLines(fd, size, line => cached.tracker.accept(line), cached.offset);
634
+ return cached.tracker;
635
+ }
636
+ finally {
637
+ fs.closeSync(fd);
638
+ }
639
+ }
640
+ transcriptSize(id) { return this.sources.get(id)?.size ?? 0; }
641
+ /** Whether the transcript's newest turn ended ok, from the tail window; null when it holds no
642
+ * result record at all. A Mac session the host is not tailing offers no other word on how its
643
+ * turn ended, so this is what turns an alert's "finished" into "failed"; anything unreadable
644
+ * stays null and the caller says ok. */
645
+ turnOutcome(id) {
646
+ const source = this.sources.get(id);
647
+ if (!source)
648
+ return null;
649
+ let fd;
650
+ try {
651
+ fd = this.open(source.file);
652
+ }
653
+ catch (error) {
654
+ if (missing(error))
655
+ return null;
656
+ throw error;
657
+ }
658
+ try {
659
+ let outcome = null;
660
+ for (const line of windowLines(fd, fs.fstatSync(fd).size, true)) {
661
+ const r = record(line);
662
+ if (!r || r.isSidechain === true || r.isMeta === true)
663
+ continue;
664
+ if (r.type === "result")
665
+ outcome = r.subtype === "success" && r.is_error !== true;
666
+ else if (r.type === "system" && r.subtype === "turn_duration")
667
+ outcome = true;
668
+ }
669
+ return outcome;
670
+ }
671
+ finally {
672
+ fs.closeSync(fd);
673
+ }
674
+ }
675
+ /** Private notification context from a bounded, read-only tail; never imports a session log. */
676
+ pushContext(id) {
677
+ const source = this.sources.get(id);
678
+ if (!source)
679
+ return { text: "", error: "" };
680
+ const fd = this.open(source.file);
681
+ try {
682
+ let answer = "", error = "";
683
+ for (const line of windowLines(fd, fs.fstatSync(fd).size, true)) {
684
+ const r = record(line);
685
+ if (!r || r.isSidechain === true || r.isMeta === true)
686
+ continue;
687
+ if (prompt(r)) {
688
+ answer = "";
689
+ error = "";
690
+ }
691
+ if (r.type === "assistant") {
692
+ const content = object(r.message)?.content;
693
+ const value = Array.isArray(content) ? content.flatMap(b => {
694
+ const block = object(b);
695
+ return block?.type === "text" ? [str(block.text)] : [];
696
+ }).join("\n") : "";
697
+ if (value.trim())
698
+ answer = value;
699
+ }
700
+ if (r.type === "result" && (r.subtype !== "success" || r.is_error === true))
701
+ error = str(r.result) || str(r.subtype);
702
+ }
703
+ return { text: answer, error };
704
+ }
705
+ finally {
706
+ fs.closeSync(fd);
707
+ }
708
+ }
709
+ /** The mode the Claude app last ran this session in, from the transcript's newest user prompt. */
710
+ permissionMode(id) { return this.sources.get(id)?.summary.permissionMode; }
711
+ /** The transcript's current title (a Mac rename lands as a custom-title line), without liveness checks. Read-only. */
712
+ titleOf(id) {
713
+ const source = this.sources.get(id);
714
+ if (!source)
715
+ return null;
716
+ let stat;
717
+ try {
718
+ stat = fs.lstatSync(source.file);
719
+ }
720
+ catch (error) {
721
+ if (!missing(error))
722
+ throw error;
723
+ return null;
724
+ }
725
+ if (!stat.isFile())
726
+ return null;
727
+ return this.readSummary(id, source.file, stat)?.summary.title ?? null;
728
+ }
729
+ continuation(id) {
730
+ if (!this.has(id))
731
+ throw new Error("no such session");
732
+ const source = this.sources.get(id);
733
+ const fd = this.open(source.file);
734
+ try {
735
+ const stat = fs.fstatSync(fd);
736
+ const current = this.readSummary(id, source.file, stat);
737
+ if (!current)
738
+ throw new Error("transcript has no working folder");
739
+ return { summary: current.summary, open: this.isOpen(id) };
740
+ }
741
+ finally {
742
+ fs.closeSync(fd);
743
+ }
744
+ }
745
+ /** Convert directly into the durable host log, preserving timestamps and externalized blobs. */
746
+ importInto(id, dir, now) {
747
+ if (!this.has(id))
748
+ throw new Error("no such session");
749
+ const source = this.sources.get(id);
750
+ const fd = this.open(source.file);
751
+ let at = source.summary.createdAt;
752
+ let importing = true;
753
+ const state = new Map();
754
+ const resolve = (toolUseId, input) => this.subInfo(id, toolUseId, input);
755
+ const log = EventLog.open({ dir, now: () => importing ? at : now() });
756
+ try {
757
+ source.size = scanLines(fd, fs.fstatSync(fd).size, line => {
758
+ const parsed = transcriptEvents(line, source.summary.cwd, this.opts.summarize, data => log.image(data), { state, resolve });
759
+ at = parsed.at ?? at;
760
+ for (const body of parsed.bodies)
761
+ log.append(body.kind === "user" && !body.clientMsgId ? { ...body, clientMsgId: `desktop-${log.lastSeq + 1}` } : body);
762
+ });
763
+ // A worker whose own transcript ended on its answer, but whose notification never landed in
764
+ // the parent, is done: without this it would read "running" for good.
765
+ for (const [toolUseId, running] of state) {
766
+ const info = resolve(toolUseId, running.input);
767
+ if (info.ended)
768
+ log.append({ kind: "subagent", toolUseId, sub: `sub-${toolUseId}`, description: running.description, model: info.model ?? running.model, status: "done", steps: info.steps });
769
+ }
770
+ importing = false;
771
+ const figures = this.opts.figures?.(id);
772
+ if (figures)
773
+ log.append({ kind: "figures", figures });
774
+ return log;
775
+ }
776
+ catch (error) {
777
+ log.close();
778
+ throw error;
779
+ }
780
+ finally {
781
+ fs.closeSync(fd);
782
+ }
783
+ }
784
+ /** One ps snapshot and stats of the capped list; unchanged files are never reread. */
785
+ updateLive() {
786
+ const now = (this.opts.now ?? Date.now)();
787
+ if (now - this.checkedAt < 3000)
788
+ return;
789
+ const registry = this.opts.registry?.() ?? liveDesktopProcesses(path.join(path.dirname(this.projects), "sessions"));
790
+ const processes = this.sources.size ? this.opts.processes?.() ?? processCommands() : "";
791
+ for (const [id, previous] of this.sources) {
792
+ let stat;
793
+ try {
794
+ stat = fs.lstatSync(previous.file);
795
+ }
796
+ catch (error) {
797
+ if (!missing(error))
798
+ throw error;
799
+ this.sources.delete(id);
800
+ continue;
801
+ }
802
+ if (!stat.isFile()) {
803
+ this.sources.delete(id);
804
+ continue;
805
+ }
806
+ const source = previous.size === stat.size && previous.mtime === stat.mtimeMs
807
+ ? previous : this.readSummary(id, previous.file, stat);
808
+ if (!source) {
809
+ this.sources.delete(id);
810
+ continue;
811
+ }
812
+ const live = registry.get(id);
813
+ source.summary = { ...source.summary, state: live ? live.status === "waiting" ? "waiting" : live.status === "idle" ? "idle" : "running" : processHasSession(processes, id) &&
814
+ (now - source.mtime <= 10_000 || source.unfinished) ? "running" : "stopped" };
815
+ this.sources.set(id, source);
816
+ }
817
+ this.checkedAt = now;
818
+ }
819
+ refresh() {
820
+ const now = (this.opts.now ?? Date.now)();
821
+ if (now - this.scannedAt < 30_000)
822
+ return; // Discovery is slower than the cheap live-state poll.
823
+ this.scannedAt = now;
824
+ const candidates = [];
825
+ const excluded = this.opts.excluded();
826
+ const sidebar = this.opts.sidebarIds?.();
827
+ try {
828
+ for (const project of fs.readdirSync(this.projects, { withFileTypes: true })) {
829
+ if (!project.isDirectory())
830
+ continue; // Never follow project symlinks or recurse.
831
+ const dir = path.join(this.projects, project.name);
832
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
833
+ const id = entry.name.slice(0, -6);
834
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl") || !UUID.test(id) || excluded.has(id))
835
+ continue;
836
+ const file = path.join(dir, entry.name);
837
+ const stat = fs.lstatSync(file);
838
+ if (stat.isFile() && (sidebar?.size ? sidebar.has(id) : stat.mtimeMs >= now - RECENT_MS))
839
+ candidates.push({ id, file, stat });
840
+ }
841
+ }
842
+ }
843
+ catch (error) {
844
+ if (!missing(error))
845
+ throw error;
846
+ }
847
+ candidates.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
848
+ const next = new Map();
849
+ for (const { id, file, stat } of candidates) {
850
+ if (!sidebar?.size && next.size >= 50)
851
+ break;
852
+ if (next.has(id))
853
+ continue;
854
+ const cached = this.sources.get(id);
855
+ const source = cached?.file === file && cached.size === stat.size && cached.mtime === stat.mtimeMs
856
+ ? cached : this.readSummary(id, file, stat);
857
+ if (source)
858
+ next.set(id, source);
859
+ }
860
+ this.sources = next;
861
+ for (const id of this.tasks.keys())
862
+ if (!next.has(id))
863
+ this.tasks.delete(id);
864
+ for (const id of this.tailSubs.keys())
865
+ if (!next.has(id))
866
+ this.tailSubs.delete(id);
867
+ for (const id of this.agentMaps.keys())
868
+ if (!next.has(id))
869
+ this.agentMaps.delete(id);
870
+ for (const [id, cached] of this.imported)
871
+ if (!next.has(id)) {
872
+ this.dispose(cached);
873
+ this.imported.delete(id);
874
+ }
875
+ for (const [key, cached] of this.subImports)
876
+ if (!next.has(key.slice(0, key.indexOf("::")))) {
877
+ this.dispose(cached);
878
+ this.subImports.delete(key);
879
+ }
880
+ }
881
+ /** Resolve both directories and the file; a replaced symlink may not escape projects/. */
882
+ open(file) {
883
+ const root = fs.realpathSync(this.projects);
884
+ const real = fs.realpathSync(file);
885
+ if (!real.startsWith(root + path.sep) || path.relative(root, real).split(path.sep).length !== 2)
886
+ throw new Error("transcript outside projects");
887
+ return fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
888
+ }
889
+ /** A subagent transcript sits deeper than the two-segment session file; only projects/ matters. */
890
+ openUnder(file) {
891
+ const root = fs.realpathSync(this.projects);
892
+ const real = fs.realpathSync(file);
893
+ if (!real.startsWith(root + path.sep))
894
+ throw new Error("transcript outside projects");
895
+ return fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
896
+ }
897
+ readSummary(id, file, stat) {
898
+ let fd;
899
+ try {
900
+ fd = this.open(file);
901
+ }
902
+ catch (error) {
903
+ if (missing(error))
904
+ return null;
905
+ throw error;
906
+ }
907
+ try {
908
+ let cwd = "", model = "", ownTitle = "", firstPrompt = "";
909
+ let mode = null;
910
+ let createdAt = stat.birthtimeMs;
911
+ const tail = windowLines(fd, stat.size, true);
912
+ for (const line of [...windowLines(fd, stat.size, false), ...tail]) {
913
+ const r = record(line);
914
+ if (!r || r.isSidechain === true)
915
+ continue;
916
+ if (!cwd && typeof r.cwd === "string")
917
+ cwd = r.cwd;
918
+ if (!firstPrompt)
919
+ firstPrompt = title(prompt(r));
920
+ // Only a person's prompt carries the top-level mode; later user lines without one (tool
921
+ // results) keep the last seen mode, so the newest prompt's mode wins.
922
+ if (r.type === "user") {
923
+ const named = permissionMode(r.permissionMode);
924
+ if (named)
925
+ mode = named;
926
+ }
927
+ if (r.type === "custom-title" && typeof r.customTitle === "string")
928
+ ownTitle = title(r.customTitle);
929
+ if (r.type === "summary" && typeof r.summary === "string" && !ownTitle)
930
+ ownTitle = title(r.summary);
931
+ const m = object(r.message);
932
+ if (r.type === "assistant" && typeof m?.model === "string")
933
+ model = m.model;
934
+ const at = typeof r.timestamp === "string" ? Date.parse(r.timestamp) : NaN;
935
+ if (Number.isFinite(at))
936
+ createdAt = Math.min(createdAt, at);
937
+ }
938
+ if (!cwd)
939
+ return null;
940
+ return { file, size: stat.size, mtime: stat.mtimeMs, unfinished: unfinishedTurn(tail), summary: { id, title: ownTitle || firstPrompt || id, cwd, model, ...(mode ? { permissionMode: mode } : {}), effort: null, state: "stopped", pending: 0, createdAt, updatedAt: stat.mtimeMs, lastSeq: 0, epoch: "", origin: "desktop", readOnly: false } };
941
+ }
942
+ finally {
943
+ fs.closeSync(fd);
944
+ }
945
+ }
946
+ log(id) {
947
+ if (!this.has(id))
948
+ throw new Error("no such session");
949
+ const source = this.sources.get(id);
950
+ const fd = this.open(source.file);
951
+ try {
952
+ const stat = fs.fstatSync(fd);
953
+ const cached = this.imported.get(id);
954
+ if (cached && cached.size === stat.size && cached.mtime === stat.mtimeMs)
955
+ return cached.log;
956
+ if (cached) {
957
+ this.dispose(cached);
958
+ this.imported.delete(id);
959
+ }
960
+ this.temp ??= fs.mkdtempSync(path.join(os.tmpdir(), "ripple-desktop-"));
961
+ const dir = fs.mkdtempSync(path.join(this.temp, "snapshot-"));
962
+ const log = this.importInto(id, dir, this.opts.now ?? Date.now);
963
+ this.imported.set(id, { size: stat.size, mtime: stat.mtimeMs, log, dir });
964
+ return log;
965
+ }
966
+ finally {
967
+ fs.closeSync(fd);
968
+ }
969
+ }
970
+ /** The snapshot a phone may be reading, only while it still matches the transcript. Never builds one. */
971
+ snapshot(id) {
972
+ const cached = this.imported.get(id);
973
+ const source = this.sources.get(id);
974
+ if (!cached || !source)
975
+ return null;
976
+ try {
977
+ const stat = fs.statSync(source.file);
978
+ return cached.size === stat.size && cached.mtime === stat.mtimeMs ? cached.log : null;
979
+ }
980
+ catch {
981
+ return null;
982
+ }
983
+ }
984
+ dispose(cached) { cached.log.close(); fs.rmSync(cached.dir, { recursive: true, force: true }); }
985
+ close() {
986
+ for (const cached of this.imported.values())
987
+ this.dispose(cached);
988
+ this.imported.clear();
989
+ for (const cached of this.subImports.values())
990
+ this.dispose(cached);
991
+ this.subImports.clear();
992
+ this.tailSubs.clear();
993
+ this.agentMaps.clear();
994
+ if (this.temp)
995
+ fs.rmSync(this.temp, { recursive: true, force: true });
996
+ this.temp = null;
997
+ }
998
+ }
999
+ // Streaming chunks, not readFileSync: no full-file buffer, even for a multi-GB transcript.
1000
+ function scanLines(fd, size, visit, from = 0) {
1001
+ const buffer = Buffer.alloc(WINDOW);
1002
+ let carry = Buffer.alloc(0);
1003
+ for (let offset = from; offset < size;) {
1004
+ const n = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset);
1005
+ if (!n)
1006
+ break;
1007
+ offset += n;
1008
+ const data = Buffer.concat([carry, buffer.subarray(0, n)]);
1009
+ let start = 0;
1010
+ for (let end = data.indexOf(10); end !== -1; end = data.indexOf(10, start)) {
1011
+ visit(data.toString("utf8", start, end));
1012
+ start = end + 1;
1013
+ }
1014
+ carry = Buffer.from(data.subarray(start));
1015
+ }
1016
+ // An in-flight unterminated append is ignored until the next snapshot.
1017
+ return size - carry.length;
1018
+ }