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,1800 @@
1
+ // Sessions are the host's. Each one is a Claude Code process driven through the Agent SDK in
2
+ // a folder and on a model the phone chose, sent through the router like any CLI (ARCHITECTURE
3
+ // §1: HTTPS_PROXY + NODE_EXTRA_CA_CERTS — not ANTHROPIC_BASE_URL, which the router's proxy port
4
+ // answers 405; measured 2026-10-11, docs/specs/2026-10-11-ripple-remote-ux.md). Everything a
5
+ // phone may need later goes into the session's event log before it goes to any socket.
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { EventLog } from "./eventlog.js";
10
+ import { attachmentContent, messageContent, sendUserFileImages } from "./images.js";
11
+ import { ATTACHMENT_CAP, ATTACHMENT_COUNT } from "./protocol.js";
12
+ import { DesktopSessions } from "./desktop.js";
13
+ import { DesktopSidebar, PHONE_GROUP } from "./sidebar.js";
14
+ import { BridgeAuthError, BridgeClient, DESKTOP_BLOCKED, object, slashCommands } from "./bridge-client.js";
15
+ import { isDirectory } from "./folders.js";
16
+ import { readJson, writeFileAtomic } from "./fsutil.js";
17
+ import { createWorktree } from "./worktree.js";
18
+ import { MessageReceipts, OutcomeUnknownError } from "./receipts.js";
19
+ import { PERMISSION_MODES } from "./protocol.js";
20
+ /** An async iterable the SDK reads user messages from; a session's query lives as long as it. */
21
+ class Inbox {
22
+ queue = [];
23
+ waiting = null;
24
+ done = false;
25
+ push(m) {
26
+ if (this.done)
27
+ return;
28
+ if (this.waiting) {
29
+ const w = this.waiting;
30
+ this.waiting = null;
31
+ w({ value: m, done: false });
32
+ }
33
+ else
34
+ this.queue.push(m);
35
+ }
36
+ close() {
37
+ this.queue = [];
38
+ this.done = true;
39
+ if (this.waiting) {
40
+ const w = this.waiting;
41
+ this.waiting = null;
42
+ w({ value: undefined, done: true });
43
+ }
44
+ }
45
+ [Symbol.asyncIterator]() {
46
+ return {
47
+ next: () => {
48
+ const m = this.queue.shift();
49
+ if (m)
50
+ return Promise.resolve({ value: m, done: false });
51
+ if (this.done)
52
+ return Promise.resolve({ value: undefined, done: true });
53
+ return new Promise((resolve) => { this.waiting = resolve; });
54
+ },
55
+ };
56
+ }
57
+ }
58
+ /** Routed models take their effort as `model@level`, which the router reads (ARCHITECTURE §3a); Claude models take the SDK's own option. */
59
+ export function modelArg(model, effort) {
60
+ if (model.startsWith("claude-"))
61
+ return effort && ["low", "medium", "high", "xhigh", "max"].includes(effort) ? { model, effort } : { model };
62
+ return { model: effort ? `${model}@${effort}` : model };
63
+ }
64
+ const str = (v) => (typeof v === "string" ? v : "");
65
+ const oneLine = (s, n = 200) => { const l = s.split("\n").find((x) => x.trim()) ?? ""; return l.length > n ? l.slice(0, n - 1) + "…" : l; };
66
+ /** One line a person reads to know what a tool call does. */
67
+ export function summarize(tool, input, cwd) {
68
+ const rel = (p) => (p && path.isAbsolute(p) && (p === cwd || p.startsWith(cwd + "/")) ? path.relative(cwd, p) || "." : p);
69
+ switch (tool) {
70
+ case "Bash": return oneLine(str(input.command));
71
+ case "Read":
72
+ case "Write":
73
+ case "Edit":
74
+ case "MultiEdit": return rel(str(input.file_path));
75
+ case "NotebookEdit": return rel(str(input.notebook_path));
76
+ case "Glob": return str(input.pattern);
77
+ case "Grep": return `${str(input.pattern)}${input.path ? " " + rel(str(input.path)) : ""}`;
78
+ case "WebFetch": return str(input.url);
79
+ case "WebSearch": return str(input.query);
80
+ case "Task":
81
+ case "Agent": return oneLine(str(input.description));
82
+ case "SendUserFile": {
83
+ const files = (Array.isArray(input.files) ? input.files : []).filter((f) => typeof f === "string").map(rel);
84
+ return oneLine([...files, str(input.caption)].filter(Boolean).join(", "));
85
+ }
86
+ case "TodoWrite": return Array.isArray(input.todos) ? `${input.todos.length}` : "";
87
+ default: {
88
+ const first = Object.values(input).find((v) => typeof v === "string");
89
+ return first ? oneLine(first) : oneLine(JSON.stringify(input), 120);
90
+ }
91
+ }
92
+ }
93
+ /** What the permission card shows in full: the command, the change, or the arguments. */
94
+ export function permissionDetail(tool, input, cwd) {
95
+ if (tool === "Bash")
96
+ return str(input.command);
97
+ if (tool === "Edit")
98
+ return `${summarize(tool, input, cwd)}\n- ${str(input.old_string).split("\n").join("\n- ")}\n+ ${str(input.new_string).split("\n").join("\n+ ")}`;
99
+ if (tool === "Write")
100
+ return `${summarize(tool, input, cwd)}\n+ ${str(input.content).split("\n").join("\n+ ")}`;
101
+ if (tool === "MultiEdit" && Array.isArray(input.edits))
102
+ return input.edits.map(edit => permissionDetail("Edit", { ...object(edit), file_path: input.file_path }, cwd)).join("\n\n");
103
+ if (tool === "ExitPlanMode")
104
+ return str(input.plan);
105
+ return JSON.stringify(input, null, 2);
106
+ }
107
+ function questionInputs(input) {
108
+ if (!Array.isArray(input.questions))
109
+ throw new Error("Invalid AskUserQuestion input");
110
+ return input.questions.map(value => {
111
+ const q = object(value);
112
+ if (!q || typeof q.question !== "string" || typeof q.header !== "string" || typeof q.multiSelect !== "boolean" || !Array.isArray(q.options))
113
+ throw new Error("Invalid AskUserQuestion question");
114
+ const options = q.options.map(value => {
115
+ const option = object(value);
116
+ if (!option || typeof option.label !== "string" || typeof option.description !== "string")
117
+ throw new Error("Invalid AskUserQuestion option");
118
+ return { label: option.label, description: option.description };
119
+ });
120
+ return { question: q.question, header: q.header, multiSelect: q.multiSelect, options };
121
+ });
122
+ }
123
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
124
+ /** A stable v4-shaped UUID for a phone message id. */
125
+ function uuidFrom(text) {
126
+ const h = createHash("sha256").update(text).digest("hex");
127
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 3) | 8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
128
+ }
129
+ /**
130
+ * The attention a state change should leave behind, or null when it should leave none.
131
+ * waiting → permission (a person must answer; opening the session does not clear it)
132
+ * error → failed (the last turn errored)
133
+ * idle, after a turn that ran → finished (the turn ended and nobody has looked yet)
134
+ * running/starting/stopped → none (work (re)started, or the pending turn is gone)
135
+ * Any other transition keeps what is already there.
136
+ */
137
+ export function nextAttention(current, from, to, at) {
138
+ if (from === to)
139
+ return current;
140
+ if (to === "waiting")
141
+ return current?.reason === "permission" ? current : { reason: "permission", at };
142
+ if (to === "error")
143
+ return current?.reason === "failed" ? current : { reason: "failed", at };
144
+ if (to === "idle" && (from === "running" || from === "waiting"))
145
+ return { reason: "finished", at };
146
+ if (to === "running" || to === "starting" || to === "stopped")
147
+ return null;
148
+ return current;
149
+ }
150
+ /** Mac sessions the host asks the router about for the first time in one desktop poll. */
151
+ const FIRST_SIGHTS_PER_POLL = 4;
152
+ /** The SDK's model key without the `@effort` a routed model carries. */
153
+ const windowKey = (model) => model.replace(/@[^@]*$/, "");
154
+ export class Sessions {
155
+ opts;
156
+ records = new Map();
157
+ logs = new Map();
158
+ live = new Map();
159
+ /** Slash commands per session (its init event), and the latest seen anywhere for sessions with none yet. */
160
+ commandLists = new Map();
161
+ lastCommands = [];
162
+ figuresAt = new Map();
163
+ lastFigures = new Map();
164
+ /** model → the context window the SDK reported for it (`modelUsage`), learned from host sessions and
165
+ * reused for Mac sessions on the same model, which never report one. Kept in `context-windows.json`. */
166
+ windows = new Map();
167
+ windowsFile;
168
+ /** Mac sessions with no record here: the latest figures read, put back into the snapshot a phone reads. */
169
+ desktopFigures = new Map();
170
+ /** Mac session → whether a turn was running at the last poll; absent until the session is first seen. */
171
+ macTurns = new Map();
172
+ creating = new Map();
173
+ handingOff = new Map();
174
+ shuttingDown = false;
175
+ file;
176
+ /** Attention per session id, and archived session ids; kept outside the logs so both survive a restart. */
177
+ attention = new Map();
178
+ archived = new Set();
179
+ listFile;
180
+ now;
181
+ receipts;
182
+ /** In-flight turns keyed by receipt key, so a concurrent resend rides the first attempt. */
183
+ sending = new Map();
184
+ desktop;
185
+ sidebar;
186
+ sidebarFingerprint = "";
187
+ desktopModels = new Map();
188
+ bridge;
189
+ bridgeStates = new Map();
190
+ /** The last state seen per non-bridged Mac session, for the away-alert transitions. The first
191
+ * sight of a session only seeds it: a host start must not push for turns that ended before it. */
192
+ macStates = new Map();
193
+ /** The registry says busy (it counts background work) while the main turn itself is not running. */
194
+ background(process) {
195
+ const state = this.bridgeState(process);
196
+ return (process.status === "busy" || process.status === "shell") && state !== "running" && state !== "waiting";
197
+ }
198
+ bridgeState(process) {
199
+ const cached = process.bridgeSessionId ? this.bridgeStates.get(process.bridgeSessionId)?.state : undefined;
200
+ return cached ?? (process.status === "waiting" ? "waiting" : process.status === "idle" ? "idle" : "running");
201
+ }
202
+ pollBridgeState(id) {
203
+ const cached = this.bridgeStates.get(id);
204
+ if (cached?.pending)
205
+ return cached.pending;
206
+ if (cached && this.now() - cached.at < 3000)
207
+ return Promise.resolve();
208
+ const entry = { at: this.now(), ...(cached?.state ? { state: cached.state } : {}) };
209
+ this.bridgeStates.set(id, entry);
210
+ let changed = false;
211
+ entry.pending = this.bridge.session(id).then(session => {
212
+ this.noteBridge(id, null);
213
+ const next = session.worker_status === "requires_action" ? "waiting" : session.worker_status;
214
+ // Phones are told only when the state actually moved; every desktop poll re-reads this.
215
+ if (entry.state !== next) {
216
+ entry.state = next;
217
+ changed = true;
218
+ }
219
+ }).catch(error => {
220
+ if (entry.state !== undefined) {
221
+ delete entry.state;
222
+ changed = true;
223
+ } // The registry is the fallback only on a failed read.
224
+ this.noteBridge(id, error, "desktop state");
225
+ }).finally(() => { delete entry.pending; if (changed)
226
+ this.opts.onSessions(); });
227
+ return entry.pending;
228
+ }
229
+ /** Why Claude app sessions cannot be reached at all right now (the Mac's Claude login), or null. */
230
+ bridgeProblem = null;
231
+ /** The last error logged per Remote Control session, so a poll that keeps failing logs once. */
232
+ bridgeErrors = new Map();
233
+ desktopProblem() { return this.bridgeProblem; }
234
+ /**
235
+ * Record how a Remote Control request went. Every poll re-asks, so the log gets a line when
236
+ * something changes, not per request (an expired login wrote one every few seconds per session,
237
+ * 2026-10-15). A login problem is the host's, not a session's: phones are told through
238
+ * onDesktopProblem, and it clears on the first request that succeeds.
239
+ */
240
+ noteBridge(bridgeId, error, label = "desktop state") {
241
+ const log = this.opts.log ?? console.error;
242
+ if (error === null) {
243
+ this.bridgeErrors.delete(bridgeId);
244
+ if (this.bridgeProblem === null)
245
+ return;
246
+ this.bridgeProblem = null;
247
+ log("[desktop] Claude app sessions answer again");
248
+ this.opts.onDesktopProblem?.(null);
249
+ return;
250
+ }
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ if (error instanceof BridgeAuthError) {
253
+ if (this.bridgeProblem === message)
254
+ return;
255
+ this.bridgeProblem = message;
256
+ log(`[desktop] Claude app sessions cannot be reached: ${message}`);
257
+ this.opts.onDesktopProblem?.(message);
258
+ return;
259
+ }
260
+ if (this.bridgeErrors.get(bridgeId) === message)
261
+ return;
262
+ this.bridgeErrors.set(bridgeId, message);
263
+ if (this.bridgeErrors.size > 200)
264
+ this.bridgeErrors.delete(this.bridgeErrors.keys().next().value);
265
+ log(`[${label}] ${bridgeId}: ${message}`);
266
+ }
267
+ bridges = new Map();
268
+ bridgeSends = new Map();
269
+ /** uuids of prompts sent over Remote Control, so their transcript echo is not shown twice. */
270
+ bridgeEchoes = new Set();
271
+ /** Cap for bridgeEchoes: prompts are answered or superseded long before this many pile up. */
272
+ static ECHO_CAP = 500;
273
+ /** Bridge requestIds already answered or withdrawn (`${session}/${requestId}` → answered at),
274
+ * so a replay that still lists them skips them instead of reviving the card. Bounded below. */
275
+ answeredBridge = new Map();
276
+ static ANSWERED_CAP = 500;
277
+ static ANSWERED_MS = 3_600_000;
278
+ /** Recent phone message ids per session, so resend checks skip the log scan while warm. */
279
+ seenIds = new Map();
280
+ static SEEN_INDEX_CAP = 200;
281
+ /** Transcript size when a bridged record's title was last refreshed (a Mac rename appends). */
282
+ bridgeTitleSize = new Map();
283
+ bridgeDecisions = new Map();
284
+ constructor(opts) {
285
+ this.opts = opts;
286
+ this.bridge = opts.bridgeClient ?? new BridgeClient();
287
+ this.now = opts.now ?? Date.now;
288
+ this.file = path.join(opts.dir, "sessions.json");
289
+ this.listFile = path.join(opts.dir, "list-state.json");
290
+ this.windowsFile = path.join(opts.dir, "context-windows.json");
291
+ try {
292
+ for (const [model, window] of Object.entries(readJson(this.windowsFile) ?? {}))
293
+ if (typeof window === "number" && window > 0)
294
+ this.windows.set(model, window);
295
+ }
296
+ catch { /* a damaged file only means the windows are learned again */ }
297
+ this.receipts = MessageReceipts.open(path.join(opts.dir, "receipts.json"), this.now);
298
+ for (const r of readJson(this.file) ?? [])
299
+ this.records.set(r.id, r);
300
+ const listState = readJson(this.listFile);
301
+ for (const [id, attention] of Object.entries(listState?.attention ?? {}))
302
+ if (attention?.reason)
303
+ this.attention.set(id, attention);
304
+ for (const id of listState?.archived ?? [])
305
+ this.archived.add(id);
306
+ this.sidebar = opts.desktopSidebar ? new DesktopSidebar({ ...opts.desktopSidebar, now: this.now,
307
+ onError: error => (opts.log ?? console.error)(`[desktop sidebar] ${error instanceof Error ? error.message : String(error)}`),
308
+ }) : null;
309
+ this.desktop = opts.desktopProjects === undefined ? null : new DesktopSessions({
310
+ ...(this.sidebar ? { sidebarIds: () => new Set(this.sidebar.read().sessions.keys()) } : {}),
311
+ projects: opts.desktopProjects, now: this.now, summarize, figures: id => this.desktopFigures.get(id), ...(opts.desktopProcesses ? { processes: opts.desktopProcesses } : {}),
312
+ ...(opts.desktopRegistry ? { registry: opts.desktopRegistry } : {}),
313
+ // Only transcripts the host is writing right now, or a phone session not yet handed to the
314
+ // desktop app, are hidden; any other one may be open in the app and must stay reachable
315
+ // (a record the host once resumed is rebased onto Remote Control from here).
316
+ excluded: () => new Set(Array.from(this.records.values()).filter(r => !r.desktopBridge && (this.live.has(r.id) || r.desktopHandoff === "pending")).flatMap(r => r.sdkSessionId ? [r.id, r.sdkSessionId] : [r.id])),
317
+ });
318
+ }
319
+ /** After a host restart: no query survived, so say so in every session that was mid-turn, and close what it was asking. */
320
+ recover() {
321
+ for (const p of this.opts.permissions.expiredOnOpen) {
322
+ if (this.records.has(p.session))
323
+ this.append(p.session, { kind: "permission_decided", id: p.id, decision: "expired", reason: "host_restarted" });
324
+ }
325
+ for (const r of this.records.values()) {
326
+ if (r.state === "running" || r.state === "starting" || r.state === "waiting") {
327
+ this.append(r.id, { kind: "notice", code: "host_restarted", text: "호스트가 다시 시작되어 이 턴이 중단되었습니다" }, false);
328
+ this.setState(r, "stopped", undefined, false);
329
+ }
330
+ }
331
+ }
332
+ list() {
333
+ const host = Array.from(this.records.values()).sort((a, b) => b.updatedAt - a.updatedAt).map((r) => this.summary(r));
334
+ const hostCliIds = new Set([...this.records.values()].flatMap(r => r.sdkSessionId ? [r.sdkSessionId] : []));
335
+ const sessions = [...host, ...(this.desktop?.list() ?? []).filter(s => !this.records.has(s.id) && !hostCliIds.has(s.id)).map(s => {
336
+ const process = this.desktop?.liveProcess(s.id);
337
+ return { ...s, ...this.desktopModels.get(s.id), ...(process?.bridgeSessionId ? { state: this.bridgeState(process), desktopBridge: true, background: this.background(process) } : {}), ...(this.archived.has(s.id) ? { archived: true } : {}), attention: this.attention.get(s.id) ?? null };
338
+ })];
339
+ return this.sidebar?.join(sessions, id => this.records.get(id)?.sdkSessionId ?? id) ?? sessions;
340
+ }
341
+ groups() {
342
+ return this.sidebar ? [...this.sidebar.read().groups.map(g => g.name), PHONE_GROUP] : undefined;
343
+ }
344
+ async pollDesktop() {
345
+ if (this.desktop?.poll())
346
+ this.opts.onSessions();
347
+ const sessions = this.list();
348
+ this.pollMacAlerts(sessions);
349
+ if (this.sidebar) {
350
+ const fingerprint = JSON.stringify([this.groups(), sessions]);
351
+ if (fingerprint !== this.sidebarFingerprint) {
352
+ this.sidebarFingerprint = fingerprint;
353
+ this.opts.onSessions();
354
+ }
355
+ }
356
+ const ids = new Set(sessions.flatMap(s => {
357
+ const process = this.desktop?.liveProcess(this.records.get(s.id)?.sdkSessionId ?? s.id);
358
+ return process?.bridgeSessionId ? [process.bridgeSessionId] : [];
359
+ }));
360
+ const reads = [...ids].map(id => this.pollBridgeState(id));
361
+ for (const id of this.bridgeStates.keys())
362
+ if (!ids.has(id))
363
+ this.bridgeStates.delete(id);
364
+ for (const r of this.records.values()) {
365
+ if (!this.desktop)
366
+ continue;
367
+ // A record the host once resumed, now open in the desktop app with Remote Control.
368
+ if (!r.desktopBridge && !this.live.has(r.id) && r.sdkSessionId && this.desktop.has(r.sdkSessionId) && this.desktop.liveProcess(r.sdkSessionId)?.bridgeSessionId) {
369
+ try {
370
+ this.rebaseDesktop(r);
371
+ }
372
+ catch (error) {
373
+ this.bridgeFailure(r.id, error);
374
+ continue;
375
+ }
376
+ }
377
+ if (!r.desktopBridge)
378
+ continue;
379
+ try {
380
+ r.transcriptOffset = this.desktop.tail(r.sdkSessionId ?? r.id, r.transcriptOffset ?? 0, body => {
381
+ // Our own Remote Control echo: drop it from the bounded set and skip it, once.
382
+ if (body.kind === "user" && this.bridgeEchoes.delete(body.clientMsgId))
383
+ return;
384
+ if (body.kind !== "user" || !this.seen(r.id, body.clientMsgId)) {
385
+ if (body.kind === "user")
386
+ this.rememberClientMsgId(r.id, body.clientMsgId);
387
+ this.append(r.id, body);
388
+ }
389
+ }, data => this.log(r.id).image(data));
390
+ this.save();
391
+ this.refreshBridgeTitle(r);
392
+ const process = this.desktop.liveProcess(r.sdkSessionId ?? r.id);
393
+ this.setState(r, process ? this.bridgeState(process) : "stopped", undefined, !!process);
394
+ if (process?.bridgeSessionId)
395
+ this.ensureBridge(r.id, process.bridgeSessionId);
396
+ else
397
+ this.closeBridge(r.id);
398
+ }
399
+ catch (error) {
400
+ this.bridgeFailure(r.id, error);
401
+ }
402
+ }
403
+ await Promise.all(reads);
404
+ for (const r of this.records.values()) {
405
+ if (!r.desktopBridge)
406
+ continue;
407
+ const process = this.desktop?.liveProcess(r.sdkSessionId ?? r.id);
408
+ this.setState(r, process ? this.bridgeState(process) : "stopped", undefined, !!process);
409
+ }
410
+ this.pollMacFigures(sessions);
411
+ }
412
+ /**
413
+ * The mod's figures for Claude app sessions, which no SDK query of ours drives: once when a session
414
+ * is first seen, at most every 15 s while its turn runs, and once more when the turn ends. The
415
+ * first sights are spread over polls so a host start does not ask the router about every old session at once.
416
+ */
417
+ pollMacFigures(sessions) {
418
+ if (!this.desktop)
419
+ return;
420
+ const seen = new Set();
421
+ let firstSights = 0;
422
+ for (const s of sessions) {
423
+ const record = this.records.get(s.id);
424
+ if (record ? !record.desktopBridge : s.origin !== "desktop")
425
+ continue;
426
+ if (s.archived || !s.model)
427
+ continue;
428
+ seen.add(s.id);
429
+ const running = s.state === "running" || s.state === "waiting" || s.state === "starting";
430
+ const wasRunning = this.macTurns.get(s.id);
431
+ if (wasRunning === undefined && firstSights >= FIRST_SIGHTS_PER_POLL)
432
+ continue;
433
+ this.macTurns.set(s.id, running);
434
+ if (wasRunning === undefined)
435
+ firstSights++;
436
+ if (wasRunning === undefined || wasRunning || running) {
437
+ const forced = wasRunning === undefined || (wasRunning && !running);
438
+ void this.readFigures(s.id, record?.sdkSessionId ?? s.id, s.model, record?.contextWindow ?? null, forced, true);
439
+ }
440
+ }
441
+ for (const id of this.macTurns.keys()) {
442
+ if (seen.has(id))
443
+ continue;
444
+ this.macTurns.delete(id);
445
+ if (this.records.has(id))
446
+ continue;
447
+ this.figuresAt.delete(id);
448
+ this.lastFigures.delete(id);
449
+ this.desktopFigures.delete(id);
450
+ }
451
+ }
452
+ learnWindow(model, window) {
453
+ const key = windowKey(model);
454
+ if (!(window > 0) || this.windows.get(key) === window)
455
+ return;
456
+ this.windows.set(key, window);
457
+ try {
458
+ writeFileAtomic(this.windowsFile, JSON.stringify(Object.fromEntries(this.windows)));
459
+ }
460
+ catch (error) {
461
+ (this.opts.log ?? console.error)(`[context windows] not saved: ${error instanceof Error ? error.message : String(error)}`);
462
+ }
463
+ }
464
+ /**
465
+ * Alerts for Mac sessions the host is not tailing: a bridged record already pushes through its
466
+ * own appended events, so this path is for the ones only the list knows. A turn that was running
467
+ * and is now idle finished (ok, or failed when the transcript's result says so), and a session
468
+ * that newly waits for approval asks. The first sight of a session only seeds its state.
469
+ */
470
+ pollMacAlerts(sessions) {
471
+ if (!this.desktop)
472
+ return;
473
+ const seen = new Set();
474
+ for (const s of sessions) {
475
+ if (s.origin !== "desktop" || this.records.has(s.id) || this.archived.has(s.id))
476
+ continue;
477
+ seen.add(s.id);
478
+ const permission = s.state === "waiting" || s.attention?.reason === "permission";
479
+ const previous = this.macStates.get(s.id);
480
+ this.macStates.set(s.id, { state: s.state, permission });
481
+ if (!previous)
482
+ continue;
483
+ if (permission && !previous.permission)
484
+ this.alert(s.id, { kind: "permission" });
485
+ else if (previous.state === "running" && s.state === "idle")
486
+ this.alert(s.id, { kind: "result", ok: this.desktop.turnOutcome(s.id) ?? true });
487
+ }
488
+ for (const id of this.macStates.keys())
489
+ if (!seen.has(id))
490
+ this.macStates.delete(id);
491
+ }
492
+ /** Hand an alert to the pushes, but only when the Mac is away: the person at the desk sees it already. */
493
+ alert(id, body) {
494
+ const { onAlert, away } = this.opts;
495
+ if (!onAlert || !away)
496
+ return;
497
+ void away().then(awayNow => { if (awayNow)
498
+ onAlert(id, body); })
499
+ .catch(error => (this.opts.log ?? console.error)(`[alert] ${id}: ${error instanceof Error ? error.message : String(error)}`));
500
+ }
501
+ has(id) { return this.records.has(id) || (this.desktop?.has(id) ?? false); }
502
+ /** Archive/unarchive: only a flag. Transcripts and files are never deleted, just hidden by default. */
503
+ setArchived(id, archived) {
504
+ if (!this.has(id))
505
+ throw new Error("no such session");
506
+ const changed = archived ? !this.archived.has(id) : this.archived.has(id);
507
+ if (changed) {
508
+ if (archived)
509
+ this.archived.add(id);
510
+ else
511
+ this.archived.delete(id);
512
+ this.saveListState();
513
+ this.opts.onSessions();
514
+ }
515
+ return changed;
516
+ }
517
+ /** The phone is looking at this session: clear its attention — except a permission request, which
518
+ * only clears when the approval is answered. Reports whether anything changed. */
519
+ markSeen(id) {
520
+ const current = this.attention.get(id);
521
+ if (!current || current.reason === "permission")
522
+ return false;
523
+ this.attention.delete(id);
524
+ this.saveListState();
525
+ this.opts.onSessions();
526
+ return true;
527
+ }
528
+ /** "Mark unread": put the attention back, guessed from what the session is doing now. */
529
+ markUnread(id) {
530
+ const s = this.list().find(x => x.id === id);
531
+ if (!s)
532
+ throw new Error("no such session");
533
+ const reason = s.pending > 0 || s.state === "waiting" ? "permission" : s.state === "error" ? "failed" : "finished";
534
+ const attention = { reason, at: this.now() };
535
+ this.setAttention(id, attention);
536
+ this.opts.onSessions();
537
+ return attention;
538
+ }
539
+ /** An approval was answered; a permission attention clears once nothing else is still open. */
540
+ clearPermissionAttention(id) {
541
+ if (this.attention.get(id)?.reason !== "permission")
542
+ return;
543
+ if (this.opts.permissions.pending(id).length > 0)
544
+ return;
545
+ this.attention.delete(id);
546
+ this.saveListState();
547
+ this.opts.onSessions();
548
+ }
549
+ /** The slash commands the phone offers after "/": a Mac session's from its Remote Control init (re-read
550
+ * after five minutes), a host session's from its own init; otherwise the latest list seen anywhere. */
551
+ async commands(id) {
552
+ if (!this.has(id))
553
+ throw new Error("no such session");
554
+ const cached = this.commandLists.get(id);
555
+ const bridgeId = this.desktop?.liveProcess(this.records.get(id)?.sdkSessionId ?? id)?.bridgeSessionId;
556
+ if (bridgeId && (!cached || this.now() - cached.at > 300_000)) {
557
+ try {
558
+ const list = await this.bridge.commands(bridgeId);
559
+ if (list) {
560
+ this.commandLists.set(id, { at: this.now(), list });
561
+ this.lastCommands = list;
562
+ return list;
563
+ }
564
+ }
565
+ catch (error) {
566
+ (this.opts.log ?? console.error)(`[commands] ${error instanceof Error ? error.message : String(error)}`);
567
+ }
568
+ }
569
+ return cached?.list ?? this.lastCommands;
570
+ }
571
+ continueDesktop(id, clientMsgId, bridgeMode = false) {
572
+ if (!this.desktop)
573
+ throw new Error("no such session");
574
+ const { summary: s, open } = this.desktop.continuation(id);
575
+ if (open && !bridgeMode)
576
+ throw new Error(DESKTOP_BLOCKED);
577
+ const log = this.desktop.importInto(id, path.join(this.opts.dir, "sessions", id), this.now);
578
+ const r = { id, sdkSessionId: id, cwd: s.cwd, model: s.model, effort: s.effort, title: s.title, createdAt: s.createdAt, updatedAt: this.now(), state: "stopped", createdBy: clientMsgId, contextWindow: null, desktopHandoff: "excluded" };
579
+ if (bridgeMode) {
580
+ r.desktopBridge = true;
581
+ r.transcriptOffset = this.desktop.transcriptSize(id);
582
+ }
583
+ Object.assign(r, this.desktopModels.get(id));
584
+ this.desktopModels.delete(id);
585
+ this.logs.set(id, log);
586
+ this.records.set(id, r);
587
+ this.save();
588
+ this.append(id, { kind: "notice", code: bridgeMode ? "desktop_bridge" : "desktop_resumed" });
589
+ this.opts.onSessions();
590
+ return r;
591
+ }
592
+ /** A turn is in progress somewhere (including one waiting on the phone for a permission). */
593
+ busy() {
594
+ for (const r of this.records.values())
595
+ if (r.state === "starting" || r.state === "running" || r.state === "waiting")
596
+ return true;
597
+ return false;
598
+ }
599
+ summaryOf(id) {
600
+ const r = this.records.get(id);
601
+ if (r)
602
+ return this.summary(r);
603
+ // A Mac session the host is not tailing has no record: its push (onAlert) still needs a title.
604
+ return this.desktop?.has(id) ? this.list().find(s => s.id === id) : undefined;
605
+ }
606
+ /** Only the encrypted native push path asks for private turn context. */
607
+ async pushContext(id, event) {
608
+ const summary = this.summaryOf(id);
609
+ const record = this.records.get(id);
610
+ const context = summary ? { title: summary.title === id ? "" : summary.title, cwd: summary.cwd } : {};
611
+ if (event.kind === "result") {
612
+ if (record) {
613
+ // onEvent fires immediately after append, before a later turn can change this cursor.
614
+ const log = this.log(id);
615
+ const seq = "seq" in event && typeof event.seq === "number" ? event.seq : log.lastSeq;
616
+ context.text = log.lastAssistantText(seq);
617
+ }
618
+ else
619
+ Object.assign(context, this.desktop?.pushContext(id));
620
+ }
621
+ if (event.kind === "permission" && !("tool" in event)) {
622
+ const bridgeId = this.desktop?.liveProcess(record?.sdkSessionId ?? id)?.bridgeSessionId;
623
+ if (bridgeId) {
624
+ const request = (await this.bridge.openRequests(bridgeId))[0];
625
+ if (request) {
626
+ context.tool = request.tool;
627
+ context.summary = summarize(request.tool, request.input, summary?.cwd ?? "");
628
+ }
629
+ }
630
+ }
631
+ return context;
632
+ }
633
+ summary(r) {
634
+ const log = this.log(r.id);
635
+ const process = r.desktopBridge ? this.desktop?.liveProcess(r.sdkSessionId ?? r.id) : undefined;
636
+ const state = process ? this.bridgeState(process) : r.desktopBridge ? "stopped" : r.state;
637
+ // The transcript keeps tasks whose end was never recorded; only a process the registry calls
638
+ // busy can have any running, so an idle one reports none.
639
+ const working = process?.status === "busy" || process?.status === "shell";
640
+ const taskList = working ? this.desktop?.backgroundTaskList(r.sdkSessionId ?? r.id) ?? [] : [];
641
+ const turnStartedAt = working ? this.desktop?.turnStartedAt(r.sdkSessionId ?? r.id) ?? null : null;
642
+ // A desktop session's mode is the Claude app's; it lives in the transcript, not the record.
643
+ const mode = r.desktopBridge ? this.desktop?.permissionMode(r.sdkSessionId ?? r.id) : (r.permissionMode ?? "auto");
644
+ return { ...(r.desktopBridge ? { desktopBridge: true, tasks: taskList.length, taskList, turnStartedAt, ...(process ? { background: this.background(process) } : {}) } : {}), origin: "host", readOnly: false, id: r.id, title: r.title, cwd: r.cwd, model: r.model, effort: r.effort, ...(mode ? { permissionMode: mode } : {}), state, pending: this.opts.permissions.pending(r.id).length, ...(this.archived.has(r.id) ? { archived: true } : {}), attention: this.attention.get(r.id) ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, lastSeq: log.lastSeq, epoch: log.epoch };
645
+ }
646
+ log(id) {
647
+ if (!this.records.has(id)) {
648
+ const process = this.desktop?.liveProcess(id);
649
+ if (process?.bridgeSessionId && this.desktop?.has(id)) {
650
+ this.continueDesktop(id, "", true);
651
+ this.ensureBridge(id, process.bridgeSessionId);
652
+ return this.logs.get(id);
653
+ }
654
+ if (this.desktop?.has(id))
655
+ return this.desktop.log(id);
656
+ throw new Error("no such session");
657
+ }
658
+ let log = this.logs.get(id);
659
+ if (!log) {
660
+ log = EventLog.open({ dir: path.join(this.opts.dir, "sessions", id), now: this.now });
661
+ this.logs.set(id, log);
662
+ }
663
+ return log;
664
+ }
665
+ subLog(id, sub) {
666
+ if (!/^sub-[\w-]{1,80}$/.test(sub))
667
+ return null;
668
+ const record = this.records.get(id);
669
+ // A Mac (Claude app) session's worker log lives beside its transcript, not under the host's dir.
670
+ // Only a session the host itself drives (a resumed record) keeps the host's own sub log, so a
671
+ // live SDK worker is written and read in the same place.
672
+ if ((!record || record.desktopBridge) && this.desktop?.has(id))
673
+ return this.desktop.subLog(id, sub);
674
+ if (!this.records.has(id))
675
+ return null;
676
+ const key = `${id}/${sub}`;
677
+ let log = this.logs.get(key);
678
+ if (!log) {
679
+ log = EventLog.open({ dir: path.join(this.opts.dir, "sessions", id, sub), now: this.now });
680
+ this.logs.set(key, log);
681
+ }
682
+ return log;
683
+ }
684
+ recentSources() {
685
+ return Array.from(this.records.values()).map((r) => ({ path: r.cwd, lastUsed: r.updatedAt, model: r.model }));
686
+ }
687
+ save() {
688
+ writeFileAtomic(this.file, JSON.stringify(Array.from(this.records.values()), null, 1));
689
+ }
690
+ saveListState() {
691
+ writeFileAtomic(this.listFile, JSON.stringify({ attention: Object.fromEntries(this.attention), archived: [...this.archived] }));
692
+ }
693
+ /** Set/clear a session's attention, persisting only when it actually changed. */
694
+ setAttention(id, next) {
695
+ const current = this.attention.get(id) ?? null;
696
+ if (current?.reason === next?.reason && current?.at === next?.at)
697
+ return;
698
+ if (next)
699
+ this.attention.set(id, next);
700
+ else
701
+ this.attention.delete(id);
702
+ this.saveListState();
703
+ }
704
+ /** `touch` false: the host noticing a session is no longer live is not the session being used (a restart moved every old one to "now"). */
705
+ append(id, body, touch = true) {
706
+ const event = this.log(id).append(body);
707
+ const r = this.records.get(id);
708
+ if (r && touch)
709
+ r.updatedAt = this.now();
710
+ this.opts.onEvent(id, event);
711
+ return event;
712
+ }
713
+ setState(r, state, detail, touch = true) {
714
+ if (r.state === state && !detail)
715
+ return;
716
+ const from = r.state;
717
+ r.state = state;
718
+ this.setAttention(r.id, nextAttention(this.attention.get(r.id) ?? null, from, state, this.now()));
719
+ this.append(r.id, { kind: "state", state, ...(detail ? { detail } : {}) }, touch);
720
+ this.save();
721
+ this.opts.onSessions();
722
+ }
723
+ // ---- what the phone asks for --------------------------------------------------------------
724
+ /** Idempotent on clientMsgId: a phone that resends after a dropped ack gets the same session.
725
+ * A resend that finds a pending receipt left from before a host restart throws
726
+ * OutcomeUnknownError instead of running again: the first attempt may or may not have run. */
727
+ create(req) {
728
+ for (const r of this.records.values())
729
+ if (r.createdBy === req.clientMsgId)
730
+ return Promise.resolve(r.id);
731
+ const key = `create:${req.clientMsgId}`;
732
+ if (this.receipts.status(key) === "completed") {
733
+ const session = this.receipts.sessionOf(key);
734
+ if (session && this.records.has(session))
735
+ return Promise.resolve(session);
736
+ if (session)
737
+ return Promise.resolve(session);
738
+ }
739
+ if (this.receipts.status(key) === "pending") {
740
+ // A resend can arrive while the first create is still checking the folder; both get the one session.
741
+ const riding = this.creating.get(req.clientMsgId);
742
+ if (riding)
743
+ return riding;
744
+ throw new OutcomeUnknownError();
745
+ }
746
+ this.receipts.begin(key, "");
747
+ // A resend can arrive while the first create is still checking the folder; both get the one session.
748
+ let pending = this.creating.get(req.clientMsgId);
749
+ if (!pending) {
750
+ pending = this.createNew(req).then(id => { this.receipts.complete(key, id); return id; }, (error) => { this.receipts.abandon(key); throw error; }).finally(() => this.creating.delete(req.clientMsgId));
751
+ this.creating.set(req.clientMsgId, pending);
752
+ }
753
+ return pending;
754
+ }
755
+ async createNew(req) {
756
+ const mode = req.permissionMode ?? "auto";
757
+ if (!PERMISSION_MODES.includes(mode))
758
+ throw new Error("invalid permission mode");
759
+ const isolation = req.isolation ?? "local";
760
+ if (isolation !== "local" && isolation !== "worktree")
761
+ throw new Error("invalid isolation");
762
+ // A worktree session runs in a fresh branch under the repo's parent; the picked folder itself is
763
+ // never modified. The host validates everything (worktree.ts), refusing a folder that is not a repo.
764
+ const cwd = isolation === "worktree"
765
+ ? (await createWorktree({ cwd: path.resolve(req.cwd), text: req.text, ...(req.baseBranch ? { baseBranch: req.baseBranch } : {}), now: this.now })).dir
766
+ : path.resolve(req.cwd);
767
+ if (!(await isDirectory(cwd)))
768
+ throw new Error(`not a folder: ${cwd}`);
769
+ const now = this.now();
770
+ const r = { id: randomUUID(), sdkSessionId: null, cwd, model: req.model, effort: req.effort, permissionMode: mode, title: oneLine(req.text, 80), createdAt: now, updatedAt: now, state: "starting", createdBy: req.clientMsgId, contextWindow: null, desktopHandoff: "pending" };
771
+ this.records.set(r.id, r);
772
+ this.save();
773
+ this.append(r.id, { kind: "state", state: "starting" });
774
+ if (req.text.trim())
775
+ await this.send(r.id, req.clientMsgId, req.text);
776
+ else
777
+ this.setState(r, "idle");
778
+ this.opts.onSessions();
779
+ return r.id;
780
+ }
781
+ /** Idempotent on clientMsgId. Starts (or resumes) the query when none is live.
782
+ * The receipt is written pending before the input reaches the SDK and completed once the SDK
783
+ * has accepted it; a resend of a completed id returns without running again, and a resend that
784
+ * finds a pending receipt from before a host restart throws OutcomeUnknownError. */
785
+ send(id, clientMsgId, text, attachments = [], behavior = "steer") {
786
+ if (behavior !== "steer" && behavior !== "interrupt")
787
+ throw new Error("Invalid send behavior.");
788
+ const key = `send:${id}:${clientMsgId}`;
789
+ if (this.receipts.status(key) === "completed")
790
+ return id;
791
+ if (this.receipts.status(key) === "pending") {
792
+ const riding = this.sending.get(key) ?? this.bridgeSends.get(`${id}/${clientMsgId}`);
793
+ if (riding)
794
+ return riding;
795
+ throw new OutcomeUnknownError();
796
+ }
797
+ if (this.seen(id, clientMsgId))
798
+ return id;
799
+ // Delegated turns own their own receipts; this wrapper only forwards them.
800
+ if (/^\/(clear|new)\s*$/.test(text) && !attachments.length) {
801
+ const s = this.list().find(s => s.id === id);
802
+ if (!s)
803
+ throw new Error("no such session");
804
+ return this.create({ clientMsgId, cwd: s.cwd, model: s.model, effort: s.effort, permissionMode: s.permissionMode ?? "auto", text: "" });
805
+ }
806
+ // The composer's /exit archives the session (phase 2): a flag, not a close — nothing is deleted.
807
+ if (/^\/exit\s*$/.test(text)) {
808
+ this.setArchived(id, true);
809
+ return id;
810
+ }
811
+ if (behavior === "interrupt")
812
+ return this.interrupt(id).then(() => this.send(id, clientMsgId, text, attachments, "steer"));
813
+ this.receipts.begin(key, id);
814
+ try {
815
+ return this.trackReceipt(key, this.sendAccepted(id, clientMsgId, text, attachments));
816
+ }
817
+ catch (error) {
818
+ this.receipts.abandon(key);
819
+ throw error;
820
+ }
821
+ }
822
+ /** Complete the receipt once the SDK accepted the input; abandon it when the attempt never got there. */
823
+ trackReceipt(key, work) {
824
+ if (typeof work === "string") {
825
+ this.receipts.complete(key);
826
+ return work;
827
+ }
828
+ const tracked = work.then(session => { this.receipts.complete(key, session); return session; }, (error) => { this.receipts.abandon(key); throw error; });
829
+ this.sending.set(key, tracked);
830
+ void tracked.finally(() => { if (this.sending.get(key) === tracked)
831
+ this.sending.delete(key); }).catch(() => { });
832
+ return tracked;
833
+ }
834
+ sendAccepted(id, clientMsgId, text, attachments) {
835
+ const existing = this.records.get(id);
836
+ const desktopId = existing?.sdkSessionId ?? id;
837
+ if (this.desktop && !this.live.has(id) && (existing?.sdkSessionId || this.desktop.has(id))) {
838
+ const process = this.desktop.liveProcess(desktopId);
839
+ if (process?.bridgeSessionId) {
840
+ const key = `${id}/${clientMsgId}`;
841
+ const pending = this.bridgeSends.get(key);
842
+ if (pending)
843
+ return pending;
844
+ const sending = this.sendBridge(id, clientMsgId, text, attachments, process.bridgeSessionId).finally(() => this.bridgeSends.delete(key));
845
+ this.bridgeSends.set(key, sending);
846
+ return sending;
847
+ }
848
+ if (process || this.desktop.isOpen(desktopId))
849
+ throw new Error(DESKTOP_BLOCKED);
850
+ if (existing?.desktopBridge) {
851
+ this.closeBridge(id);
852
+ existing.desktopBridge = false;
853
+ this.save();
854
+ }
855
+ }
856
+ const r = this.records.get(id) ?? this.continueDesktop(id, clientMsgId);
857
+ id = r.id;
858
+ if (this.seen(id, clientMsgId))
859
+ return id;
860
+ const converted = attachmentContent(r.cwd, text, attachments, blob => this.log(id).blob(blob));
861
+ this.opts.permissions.denySession(id, "superseded by a new message from the user");
862
+ this.append(id, { kind: "user", text, clientMsgId, images: converted.images });
863
+ this.rememberClientMsgId(id, clientMsgId);
864
+ let live = this.live.get(id);
865
+ const handoff = this.handingOff.get(id);
866
+ if (!live && !handoff)
867
+ live = this.start(r);
868
+ // A message sent while a turn runs is queued by the CLI and starts no turn of its own yet.
869
+ if (r.state !== "running" && r.state !== "waiting") {
870
+ if (live)
871
+ live.turnStart = this.now();
872
+ this.append(id, { kind: "turn", model: r.model, effort: r.effort });
873
+ this.setState(r, "running");
874
+ }
875
+ const message = { type: "user", uuid: uuidFrom(clientMsgId), message: { role: "user", content: converted.content }, parent_tool_use_id: null, session_id: r.sdkSessionId ?? "", origin: { kind: "human" } };
876
+ if (handoff)
877
+ handoff.messages.push(message);
878
+ else if (live) {
879
+ live.pendingTurns++;
880
+ live.input.push(message);
881
+ }
882
+ return id;
883
+ }
884
+ async sendBridge(id, clientMsgId, text, attachments, bridgeId) {
885
+ if (attachments.some(a => !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(a?.mediaType))) {
886
+ throw new Error("Only photos can be attached to Claude app sessions for now.");
887
+ }
888
+ const r = this.records.get(id) ?? this.continueDesktop(id, clientMsgId, true);
889
+ // Rebase deletes this log's blobs too: validate and retain the bytes before it can run.
890
+ const converted = attachmentContent(r.cwd, text, attachments, blob => this.log(id).blob(blob));
891
+ if (Array.isArray(converted.content)) {
892
+ const blocks = converted.content;
893
+ const images = blocks.filter(block => block.type === "image");
894
+ if (images.reduce((size, block) => size + block.source.data.length, 0) > ATTACHMENT_COUNT * ATTACHMENT_CAP) {
895
+ throw new Error("Photo payload is too large.");
896
+ }
897
+ converted.content = [...images, ...blocks.filter(block => block.type === "text")];
898
+ }
899
+ if (!r.desktopBridge)
900
+ this.rebaseDesktop(r);
901
+ if (this.seen(id, clientMsgId))
902
+ return id;
903
+ // Re-store in the current epoch; the local event must never point at deleted upload blobs.
904
+ if (Array.isArray(converted.content)) {
905
+ let index = 0;
906
+ for (const block of converted.content) {
907
+ if (block.type === "image")
908
+ converted.images[index++].blob = this.log(id).image(block.source.data);
909
+ }
910
+ }
911
+ const prior = this.bridges.get(id);
912
+ if (prior?.bridgeId === bridgeId && prior.permissions.size)
913
+ this.supersedeBridge(id, prior);
914
+ // The stream only carries permission prompts; sending does not depend on it.
915
+ this.ensureBridge(id, bridgeId);
916
+ // The API takes only a UUID; phones send short random ids. Derived, not random, so a retry of
917
+ // the same message is the same event; the transcript echoes it under this uuid.
918
+ const uuid = UUID_RE.test(clientMsgId) ? clientMsgId : uuidFrom(clientMsgId);
919
+ while (this.bridgeEchoes.size >= Sessions.ECHO_CAP)
920
+ this.bridgeEchoes.delete(this.bridgeEchoes.values().next().value);
921
+ this.bridgeEchoes.add(uuid);
922
+ try {
923
+ await this.bridge.send(bridgeId, uuid, converted.content);
924
+ }
925
+ catch (error) {
926
+ this.noteBridge(bridgeId, error, "bridge send");
927
+ throw new Error(error instanceof Error ? error.message : DESKTOP_BLOCKED);
928
+ }
929
+ this.noteBridge(bridgeId, null);
930
+ // The durable user event also deduplicates the desktop transcript's echo by uuid.
931
+ this.append(id, { kind: "user", text, clientMsgId, images: converted.images });
932
+ this.rememberClientMsgId(id, clientMsgId);
933
+ // A turn the desktop app finished while no phone was polling left the record "running";
934
+ // without a fresh state event the phone would time this turn from that old one.
935
+ if (r.state === "running" || r.state === "waiting")
936
+ this.setState(r, "idle");
937
+ // Phones time a turn from its turn event; a desktop transcript's last turn has no result
938
+ // event to end it, so without this one the clock would run from that earlier turn.
939
+ this.append(id, { kind: "turn", model: r.model, effort: r.effort });
940
+ this.setState(r, "running");
941
+ return id;
942
+ }
943
+ /**
944
+ * A record the host once drove (resume) becomes a Remote Control one: its log stops where the
945
+ * host stopped, so it is rebuilt from the transcript instead of tailing from "now" and
946
+ * skipping everything the desktop app said in between. The new epoch makes phones refetch.
947
+ */
948
+ rebaseDesktop(r) {
949
+ if (!this.desktop)
950
+ return;
951
+ const dir = path.join(this.opts.dir, "sessions", r.id);
952
+ this.logs.get(r.id)?.close();
953
+ this.logs.delete(r.id);
954
+ // The log is rebuilt from the transcript: ids the old log held may be gone, so the
955
+ // duplicate index must not claim them as seen or a resend would be dropped unsent.
956
+ this.seenIds.delete(r.id);
957
+ fs.rmSync(dir, { recursive: true, force: true });
958
+ this.logs.set(r.id, this.desktop.importInto(r.sdkSessionId ?? r.id, dir, this.now));
959
+ r.desktopBridge = true;
960
+ r.transcriptOffset = this.desktop.transcriptSize(r.sdkSessionId ?? r.id);
961
+ this.save();
962
+ this.opts.onSessions();
963
+ }
964
+ /**
965
+ * A rename on the Mac lands in the transcript as a custom-title line. The session list and
966
+ * pushes read the record title, so refresh it when the transcript grew since the last check.
967
+ * Read-only: never writes the transcript, and a failed read only logs.
968
+ */
969
+ refreshBridgeTitle(r) {
970
+ if (!this.desktop)
971
+ return;
972
+ try {
973
+ const transcript = r.sdkSessionId ?? r.id;
974
+ const size = this.desktop.transcriptSize(transcript);
975
+ if (size === this.bridgeTitleSize.get(r.id))
976
+ return;
977
+ this.bridgeTitleSize.set(r.id, size);
978
+ const title = this.desktop.titleOf(transcript);
979
+ if (title && title !== r.title) {
980
+ r.title = title;
981
+ this.save();
982
+ this.opts.onSessions();
983
+ }
984
+ }
985
+ catch (error) {
986
+ (this.opts.log ?? console.error)(`[bridge title] ${error instanceof Error ? error.message : String(error)}`);
987
+ }
988
+ }
989
+ ensureBridge(id, bridgeId) {
990
+ const previous = this.bridges.get(id);
991
+ // A failed stream is retried on the next poll or send rather than blocking the session for good.
992
+ if (previous?.bridgeId === bridgeId && !previous.failed)
993
+ return previous;
994
+ this.closeBridge(id);
995
+ const connection = { bridgeId, abort: new AbortController(), failed: false, permissions: new Map() };
996
+ this.bridges.set(id, connection);
997
+ const replay = () => {
998
+ // After every stream (re)connect, re-read approvals already waiting: the stream starts at
999
+ // the newest event, so anything requested before it connected would otherwise never card.
1000
+ void this.syncBridgeApprovals(id, bridgeId).catch(error => this.noteBridge(bridgeId, error, "bridge replay"));
1001
+ };
1002
+ void this.bridge.stream(bridgeId, connection.abort.signal, frame => this.bridgeFrame(id, frame), replay).catch(error => {
1003
+ if (!connection.abort.signal.aborted)
1004
+ this.bridgeFailure(id, error);
1005
+ });
1006
+ replay();
1007
+ return connection;
1008
+ }
1009
+ closeBridge(id) {
1010
+ const connection = this.bridges.get(id);
1011
+ if (!connection)
1012
+ return;
1013
+ connection.abort.abort();
1014
+ this.opts.permissions.expireSession(id, "aborted");
1015
+ this.bridges.delete(id);
1016
+ // With nothing pending, a permission attention would otherwise stick: markSeen skips it and
1017
+ // decide on an expired record returns false, so clear it the way answering does.
1018
+ this.clearPermissionAttention(id);
1019
+ }
1020
+ bridgeFailure(id, error) {
1021
+ const connection = this.bridges.get(id);
1022
+ if (connection) {
1023
+ connection.failed = true;
1024
+ connection.abort.abort();
1025
+ }
1026
+ this.opts.permissions.expireSession(id, "aborted");
1027
+ this.clearPermissionAttention(id);
1028
+ // Logged, not shown: a dropped permission stream reconnects on the next poll and says nothing
1029
+ // new to the user — unless the login is the cause, which noteBridge tells phones once.
1030
+ this.noteBridge(connection?.bridgeId ?? id, error, "remote control stream");
1031
+ }
1032
+ bridgeFrame(id, frame) {
1033
+ if (frame.event !== "client_event")
1034
+ return;
1035
+ const payload = object(frame.data.payload);
1036
+ const connection = this.bridges.get(id);
1037
+ if (!payload || !connection)
1038
+ return;
1039
+ // Responses from another client close a pending card during replay or live delivery.
1040
+ if (payload.type === "control_response") {
1041
+ const response = object(payload.response);
1042
+ if (typeof response?.request_id !== "string")
1043
+ return;
1044
+ const pending = connection.permissions.get(response.request_id);
1045
+ if (pending)
1046
+ this.opts.permissions.expire(pending.id, "cancelled");
1047
+ connection.permissions.delete(response.request_id);
1048
+ this.rememberAnsweredBridge(id, response.request_id);
1049
+ return;
1050
+ }
1051
+ // Only the server's worker channel may ask for authorization or withdraw a request.
1052
+ if (frame.data.source !== "worker" || typeof payload.request_id !== "string")
1053
+ return;
1054
+ const requestId = payload.request_id;
1055
+ if (payload.type === "control_cancel_request") {
1056
+ const pending = connection.permissions.get(requestId);
1057
+ if (pending)
1058
+ this.opts.permissions.expire(pending.id, "cancelled");
1059
+ connection.permissions.delete(requestId);
1060
+ this.rememberAnsweredBridge(id, requestId);
1061
+ return;
1062
+ }
1063
+ const request = object(payload.request);
1064
+ const input = object(request?.input);
1065
+ if (payload.type !== "control_request" || request?.subtype !== "can_use_tool" || typeof request.tool_name !== "string" || !input || typeof request.tool_use_id !== "string" || connection.permissions.has(requestId))
1066
+ return;
1067
+ const r = this.records.get(id);
1068
+ if (!r)
1069
+ return;
1070
+ this.openBridgePermission(id, connection, r, { requestId, tool: request.tool_name, input, toolUseId: request.tool_use_id });
1071
+ }
1072
+ /** A permission prompt from Remote Control, live or replayed: one record, one card, one decision. */
1073
+ openBridgePermission(id, connection, r, req) {
1074
+ if (connection.permissions.has(req.requestId) || this.wasAnsweredBridge(id, req.requestId))
1075
+ return;
1076
+ const { record, decision } = this.opts.permissions.request({ session: id, tool: req.tool, title: "", summary: summarize(req.tool, req.input, r.cwd), input: req.input }, connection.abort.signal);
1077
+ connection.permissions.set(req.requestId, { id: record.id, input: req.input, toolUseId: req.toolUseId });
1078
+ this.append(id, { kind: "permission", id: record.id, tool: record.tool, title: "", summary: record.summary, input: permissionDetail(record.tool, req.input, r.cwd), canAlways: false });
1079
+ this.setState(r, "waiting");
1080
+ void decision.then(answer => {
1081
+ this.append(id, { kind: "permission_decided", id: record.id, decision: answer.decision, ...(answer.reason ? { reason: answer.reason } : {}) });
1082
+ if (answer.decision === "expired")
1083
+ connection.permissions.delete(req.requestId);
1084
+ this.clearPermissionAttention(id);
1085
+ this.opts.onSessions();
1086
+ });
1087
+ }
1088
+ /** Re-read approvals already waiting on a Mac session (phone attach and every stream
1089
+ * reconnect), so no approval card is missed. Reads the session's events from the start cursor
1090
+ * (BridgeClient.events, `sort_order=asc`) instead of the stream's newest-event cursor. */
1091
+ async syncBridge(id) {
1092
+ const bridgeId = this.bridges.get(id)?.bridgeId
1093
+ ?? this.desktop?.liveProcess(this.records.get(id)?.sdkSessionId ?? id)?.bridgeSessionId;
1094
+ if (!bridgeId)
1095
+ return;
1096
+ // A Mac session the host never opened has no record yet: import it (bridge mode) first.
1097
+ if (!this.records.has(id)) {
1098
+ try {
1099
+ this.log(id);
1100
+ }
1101
+ catch {
1102
+ return;
1103
+ }
1104
+ }
1105
+ this.ensureBridge(id, bridgeId);
1106
+ await this.syncBridgeApprovals(id, bridgeId);
1107
+ }
1108
+ async syncBridgeApprovals(id, bridgeId) {
1109
+ const connection = this.bridges.get(id);
1110
+ const r = this.records.get(id);
1111
+ if (!connection || connection.bridgeId !== bridgeId || !r)
1112
+ return;
1113
+ let open;
1114
+ try {
1115
+ open = await this.bridge.openRequests(bridgeId);
1116
+ }
1117
+ catch (error) {
1118
+ this.bridgeFailure(id, error);
1119
+ return;
1120
+ }
1121
+ if (this.bridges.get(id) !== connection)
1122
+ return;
1123
+ for (const req of open)
1124
+ this.openBridgePermission(id, connection, r, req);
1125
+ }
1126
+ /**
1127
+ * Remember a bridge requestId as answered, so a replay that still lists it (the server records
1128
+ * the control_response after we do) skips it instead of reviving an answered card. Bounded to
1129
+ * the last ANSWERED_CAP entries and entries from the last hour.
1130
+ */
1131
+ rememberAnsweredBridge(id, requestId) {
1132
+ const at = this.now();
1133
+ for (const [key, answeredAt] of this.answeredBridge)
1134
+ if (at - answeredAt > Sessions.ANSWERED_MS)
1135
+ this.answeredBridge.delete(key);
1136
+ this.answeredBridge.set(`${id}/${requestId}`, at);
1137
+ while (this.answeredBridge.size > Sessions.ANSWERED_CAP)
1138
+ this.answeredBridge.delete(this.answeredBridge.keys().next().value);
1139
+ }
1140
+ wasAnsweredBridge(id, requestId) {
1141
+ const at = this.answeredBridge.get(`${id}/${requestId}`);
1142
+ if (at === undefined)
1143
+ return false;
1144
+ if (this.now() - at > Sessions.ANSWERED_MS) {
1145
+ this.answeredBridge.delete(`${id}/${requestId}`);
1146
+ return false;
1147
+ }
1148
+ return true;
1149
+ }
1150
+ /** Remember a phone message id so later resend checks hit memory instead of the log. Bounded. */
1151
+ rememberClientMsgId(id, clientMsgId) {
1152
+ let set = this.seenIds.get(id);
1153
+ if (!set) {
1154
+ set = new Set();
1155
+ this.seenIds.set(id, set);
1156
+ }
1157
+ set.add(clientMsgId);
1158
+ while (set.size > Sessions.SEEN_INDEX_CAP)
1159
+ set.delete(set.values().next().value);
1160
+ }
1161
+ seen(id, clientMsgId) {
1162
+ // Warm index (this process appended every user event in it): absence means absent, no scan.
1163
+ if (this.seenIds.get(id)?.has(clientMsgId))
1164
+ return true;
1165
+ if (this.seenIds.has(id))
1166
+ return false;
1167
+ // Cold index (after a restart): scan from the end, where a resend's original is, and warm
1168
+ // the index with the ids the scan passes over.
1169
+ const log = this.log(id);
1170
+ const collected = [];
1171
+ let before = log.lastSeq + 1;
1172
+ for (let pages = 0; pages < 8 && before > 1; pages++) {
1173
+ const page = log.before(before);
1174
+ for (const e of page.events) {
1175
+ if (e.kind !== "user" || !e.clientMsgId)
1176
+ continue;
1177
+ if (collected.length < Sessions.SEEN_INDEX_CAP)
1178
+ collected.push(e.clientMsgId);
1179
+ if (e.clientMsgId === clientMsgId) {
1180
+ this.rememberClientMsgId(id, clientMsgId);
1181
+ return true;
1182
+ }
1183
+ }
1184
+ if (!page.hasOlder || !page.events.length)
1185
+ break;
1186
+ before = page.events[0].seq;
1187
+ }
1188
+ this.seenIds.set(id, new Set(collected));
1189
+ return false;
1190
+ }
1191
+ decide(id, permId, decision, message, answers) {
1192
+ if (decision !== "allow" && decision !== "allow_always" && decision !== "deny")
1193
+ throw new Error("invalid decision");
1194
+ const connection = this.bridges.get(id);
1195
+ const pending = connection && [...connection.permissions].find(([, value]) => value.id === permId);
1196
+ if (connection && pending) {
1197
+ const sending = this.bridgeDecisions.get(permId);
1198
+ if (sending)
1199
+ return sending;
1200
+ const answer = this.decideBridge(id, permId, decision, pending[0], pending[1])
1201
+ .then(ok => { if (ok)
1202
+ this.clearPermissionAttention(id); return ok; })
1203
+ .finally(() => this.bridgeDecisions.delete(permId));
1204
+ this.bridgeDecisions.set(permId, answer);
1205
+ return answer;
1206
+ }
1207
+ const p = this.opts.permissions.get(permId);
1208
+ if (!p || p.session !== id || p.state !== "pending")
1209
+ return false;
1210
+ if (p.tool === "ExitPlanMode" && decision !== "deny")
1211
+ return this.implementPlan(id, permId);
1212
+ if (p.tool === "AskUserQuestion" && decision !== "deny") {
1213
+ const questions = questionInputs(object(p.input) ?? {});
1214
+ if (!answers || !questions.length || questions.some(q => typeof answers[q.question] !== "string" || !answers[q.question].trim()))
1215
+ throw new Error("Answer every question before submitting.");
1216
+ if (Object.values(answers).some(value => typeof value !== "string"))
1217
+ throw new Error("Invalid question answers.");
1218
+ }
1219
+ const decided = this.opts.permissions.decide(permId, decision, message, answers) !== null;
1220
+ if (decided)
1221
+ this.clearPermissionAttention(id);
1222
+ return decided;
1223
+ }
1224
+ planDecisions = new Map();
1225
+ implementPlan(id, permId) {
1226
+ const pending = this.planDecisions.get(permId);
1227
+ if (pending)
1228
+ return pending;
1229
+ const change = (async () => {
1230
+ const r = this.records.get(id);
1231
+ await this.setMode(id, r.prePlanMode === "bypassPermissions" || r.permissionMode === "bypassPermissions" ? "bypassPermissions" : "acceptEdits");
1232
+ const approved = this.opts.permissions.decide(permId, "allow") !== null;
1233
+ if (approved)
1234
+ this.clearPermissionAttention(id);
1235
+ return approved;
1236
+ })();
1237
+ this.planDecisions.set(permId, change);
1238
+ void change.finally(() => this.planDecisions.delete(permId)).catch(() => { });
1239
+ return change;
1240
+ }
1241
+ /** The control_response the Remote Control side accepts for a permission answer (bridge-client.ts post). */
1242
+ bridgeDecisionPayload(requestId, pending, decision, message) {
1243
+ return { type: "control_response", uuid: randomUUID(), response: { subtype: "success", request_id: requestId, response: { behavior: decision, updatedInput: pending.input, toolUseID: pending.toolUseId, ...(message ? { message } : {}) } } };
1244
+ }
1245
+ async decideBridge(id, permId, decision, requestId, pending) {
1246
+ const connection = this.bridges.get(id);
1247
+ if (!connection || connection.failed || this.opts.permissions.get(permId)?.state !== "pending")
1248
+ return false;
1249
+ if (decision === "allow_always")
1250
+ throw new Error("Remote Control supports allow once only.");
1251
+ try {
1252
+ await this.bridge.post(connection.bridgeId, this.bridgeDecisionPayload(requestId, pending, decision, decision === "deny" ? "The user denied this from their phone." : undefined));
1253
+ }
1254
+ catch (error) {
1255
+ this.bridgeFailure(id, error);
1256
+ throw error;
1257
+ }
1258
+ connection.permissions.delete(requestId);
1259
+ this.rememberAnsweredBridge(id, requestId);
1260
+ return this.opts.permissions.decide(permId, decision) !== null;
1261
+ }
1262
+ /**
1263
+ * A new message from the phone supersedes waiting Mac approvals, as host sessions do
1264
+ * (parity doc phase 1: "승인 대기 중에 새 메시지 → 대기 승인 거부"). Each open request gets
1265
+ * the deny control_response the Remote Control side accepts, and the local record resolves
1266
+ * denied the same way the host path's denySession resolves it.
1267
+ */
1268
+ supersedeBridge(id, connection) {
1269
+ const reason = "superseded by a new message from the user";
1270
+ for (const [requestId, pending] of [...connection.permissions]) {
1271
+ connection.permissions.delete(requestId);
1272
+ this.rememberAnsweredBridge(id, requestId);
1273
+ void this.bridge.post(connection.bridgeId, this.bridgeDecisionPayload(requestId, pending, "deny", "The user superseded this with a new message."))
1274
+ .catch(error => this.bridgeFailure(id, error));
1275
+ this.opts.permissions.decide(pending.id, "deny", reason);
1276
+ }
1277
+ this.clearPermissionAttention(id);
1278
+ }
1279
+ async interrupt(id) {
1280
+ const bridgeId = this.desktop?.liveProcess(this.records.get(id)?.sdkSessionId ?? id)?.bridgeSessionId;
1281
+ if (!this.bridges.has(id) && bridgeId) {
1282
+ this.log(id);
1283
+ this.ensureBridge(id, bridgeId);
1284
+ }
1285
+ const connection = this.bridges.get(id);
1286
+ if (connection) {
1287
+ if (connection.failed)
1288
+ throw new Error(DESKTOP_BLOCKED);
1289
+ try {
1290
+ await this.bridge.interrupt(connection.bridgeId);
1291
+ }
1292
+ catch (error) {
1293
+ this.bridgeFailure(id, error);
1294
+ throw error;
1295
+ }
1296
+ this.append(id, { kind: "notice", code: "interrupted" });
1297
+ return;
1298
+ }
1299
+ const live = this.live.get(id);
1300
+ if (!live)
1301
+ return;
1302
+ // Open requests resolve as expired; their canUseTool continuation writes the event.
1303
+ this.opts.permissions.expireSession(id, "aborted");
1304
+ const r = this.records.get(id);
1305
+ // Only a running turn can be stopped; a flag set while idle would swallow the next real result.
1306
+ if (!r || (r.state !== "running" && r.state !== "waiting"))
1307
+ return;
1308
+ live.interrupted = true;
1309
+ live.input.close();
1310
+ const handoff = this.handingOff.get(id);
1311
+ if (handoff)
1312
+ handoff.messages = [];
1313
+ await live.ready;
1314
+ try {
1315
+ await live.query.interrupt();
1316
+ }
1317
+ finally {
1318
+ // SDK Query.interrupt() may retain still_queued input (sdk.d.ts:2850–2857).
1319
+ // No cancelAsyncMessage is exposed: closing the process withdraws that input too.
1320
+ this.stop(id);
1321
+ this.setState(r, "idle");
1322
+ }
1323
+ this.append(id, { kind: "notice", code: "interrupted" });
1324
+ }
1325
+ modeChanges = new Map();
1326
+ setMode(id, mode) {
1327
+ const previous = this.modeChanges.get(id) ?? Promise.resolve();
1328
+ const change = previous.catch(() => { }).then(() => this.changeMode(id, mode));
1329
+ this.modeChanges.set(id, change);
1330
+ void change.finally(() => { if (this.modeChanges.get(id) === change)
1331
+ this.modeChanges.delete(id); }).catch(() => { });
1332
+ return change;
1333
+ }
1334
+ async changeMode(id, mode) {
1335
+ if (!PERMISSION_MODES.includes(mode))
1336
+ throw new Error("invalid permission mode");
1337
+ const existing = this.records.get(id);
1338
+ const desktopId = existing?.sdkSessionId ?? id;
1339
+ if (existing?.desktopBridge || this.bridges.has(id) || this.desktop?.liveProcess(desktopId)?.bridgeSessionId || this.desktop?.isOpen(desktopId))
1340
+ throw new Error("Permission mode switching for Mac Remote Control is not supported yet.");
1341
+ const r = existing ?? this.continueDesktop(id, "");
1342
+ const live = this.live.get(id);
1343
+ if (live) {
1344
+ await live.ready;
1345
+ if (this.live.get(id) !== live)
1346
+ throw new Error("The session stopped before its mode changed. Try again.");
1347
+ await live.query.setPermissionMode(mode);
1348
+ }
1349
+ this.recordMode(r, mode);
1350
+ }
1351
+ recordMode(r, mode) {
1352
+ if (mode === "plan" && r.permissionMode !== "plan")
1353
+ r.prePlanMode = r.permissionMode ?? "auto";
1354
+ if (mode !== "plan")
1355
+ delete r.prePlanMode;
1356
+ r.permissionMode = mode;
1357
+ this.save();
1358
+ this.append(r.id, { kind: "mode", mode });
1359
+ this.opts.onSessions();
1360
+ }
1361
+ async setModel(id, model, effort) {
1362
+ if (this.desktop?.isOpen(this.records.get(id)?.sdkSessionId ?? id))
1363
+ throw new Error("열려 있는 세션의 모델은 Claude 앱에서 변경해 주십시오. / Change this live session's model in the Claude app.");
1364
+ if (!this.records.has(id) && this.desktop?.has(id)) {
1365
+ this.desktopModels.set(id, { model, effort });
1366
+ this.opts.onSessions();
1367
+ return;
1368
+ }
1369
+ const r = this.records.get(id);
1370
+ if (!r)
1371
+ throw new Error("no such session");
1372
+ const changedModel = r.model !== model;
1373
+ const live = this.live.get(id);
1374
+ if (live && changedModel) {
1375
+ await live.ready;
1376
+ await live.query.setModel(modelArg(model, effort).model);
1377
+ }
1378
+ r.model = model;
1379
+ r.effort = effort;
1380
+ this.save();
1381
+ this.append(id, { kind: "notice", code: "model_changed", text: effort ? `${model} · ${effort}` : model });
1382
+ if (!live) {
1383
+ this.opts.onSessions();
1384
+ return;
1385
+ }
1386
+ // Effort-only changes resume a fresh process after this turn, for routed and Claude models alike.
1387
+ if (!changedModel)
1388
+ live.restartAfterTurn = true;
1389
+ if (live.restartAfterTurn && (r.state === "idle" || r.state === "error"))
1390
+ this.stop(id);
1391
+ this.opts.onSessions();
1392
+ }
1393
+ stop(id) {
1394
+ const live = this.live.get(id);
1395
+ if (!live)
1396
+ return;
1397
+ this.live.delete(id);
1398
+ live.input.close();
1399
+ live.abort.abort();
1400
+ try {
1401
+ live.query.close();
1402
+ }
1403
+ catch { /* already ended */ }
1404
+ }
1405
+ shutdown() {
1406
+ this.shuttingDown = true;
1407
+ for (const id of Array.from(this.live.keys()))
1408
+ this.stop(id);
1409
+ for (const id of this.bridges.keys())
1410
+ this.closeBridge(id);
1411
+ for (const log of this.logs.values())
1412
+ log.close();
1413
+ this.desktop?.close();
1414
+ }
1415
+ // ---- the query ----------------------------------------------------------------------------
1416
+ start(r) {
1417
+ if (r.sdkSessionId && this.desktop?.isOpen(r.sdkSessionId))
1418
+ throw new Error(DESKTOP_BLOCKED);
1419
+ const abort = new AbortController();
1420
+ const input = new Inbox();
1421
+ const env = {};
1422
+ for (const [k, v] of Object.entries(process.env))
1423
+ if (v !== undefined)
1424
+ env[k] = v;
1425
+ delete env.ANTHROPIC_BASE_URL;
1426
+ delete env.CLAUDECODE;
1427
+ const m = modelArg(r.model, r.effort);
1428
+ const options = {
1429
+ cwd: r.cwd,
1430
+ ...(!r.sdkSessionId ? { title: r.title } : {}),
1431
+ model: m.model,
1432
+ ...(m.effort ? { effort: m.effort } : {}),
1433
+ includePartialMessages: true,
1434
+ settingSources: ["user", "project", "local"],
1435
+ permissionMode: r.permissionMode ?? "auto",
1436
+ ...(r.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
1437
+ abortController: abort,
1438
+ env,
1439
+ canUseTool: this.canUseTool(r.id),
1440
+ stderr: (line) => { if (!line.includes("unrecognized_model"))
1441
+ process.stderr.write(`[session ${r.id.slice(0, 8)}] ${line}`); },
1442
+ ...(r.sdkSessionId ? { resume: r.sdkSessionId } : {}),
1443
+ ...(this.opts.claudePath ? { pathToClaudeCodeExecutable: this.opts.claudePath } : {}),
1444
+ };
1445
+ const live = { query: null, ready: Promise.resolve(), input, abort, buffer: null, flushTimer: null, current: null, subs: new Map(), turnStart: this.now(), restartAfterTurn: false, retried: false, interrupted: false, pendingTurns: 0 };
1446
+ this.live.set(r.id, live);
1447
+ live.ready = this.opts.router.status().then((s) => {
1448
+ // The router's own word on where its proxy and CA are wins over whatever this process inherited.
1449
+ Object.assign(env, s.proxyEnv);
1450
+ }).finally(() => {
1451
+ if (this.live.get(r.id) !== live)
1452
+ return;
1453
+ live.query = this.opts.queryFn({ prompt: input, options });
1454
+ void this.consume(r, live);
1455
+ });
1456
+ return live;
1457
+ }
1458
+ async consume(r, live) {
1459
+ let failure = null;
1460
+ try {
1461
+ for await (const msg of live.query) {
1462
+ if (this.live.get(r.id) !== live)
1463
+ break;
1464
+ this.handle(r, live, msg);
1465
+ }
1466
+ }
1467
+ catch (e) {
1468
+ if (!live.abort.signal.aborted)
1469
+ failure = e.message;
1470
+ }
1471
+ if (this.live.get(r.id) !== live)
1472
+ return;
1473
+ this.flush(r.id, live);
1474
+ this.live.delete(r.id);
1475
+ this.opts.permissions.expireSession(r.id, "aborted");
1476
+ if (failure) {
1477
+ this.append(r.id, { kind: "result", ok: false, error: failure, ms: this.now() - live.turnStart, turns: 0 });
1478
+ this.setState(r, "error", failure.slice(0, 300));
1479
+ }
1480
+ else if (r.state !== "idle" && r.state !== "error")
1481
+ this.setState(r, "stopped");
1482
+ }
1483
+ handle(r, live, msg) {
1484
+ const parent = msg.parent_tool_use_id ?? null;
1485
+ switch (msg.type) {
1486
+ case "system": {
1487
+ const mode = PERMISSION_MODES.find(mode => mode === msg.permissionMode);
1488
+ if (!parent && mode && mode !== r.permissionMode && (msg.subtype === "init" || msg.subtype === "status"))
1489
+ this.recordMode(r, mode);
1490
+ if (msg.subtype === "init") {
1491
+ const list = slashCommands(msg);
1492
+ if (list) {
1493
+ this.commandLists.set(r.id, { at: this.now(), list });
1494
+ this.lastCommands = list;
1495
+ }
1496
+ }
1497
+ if (msg.subtype === "init" && typeof msg.session_id === "string" && r.sdkSessionId !== msg.session_id) {
1498
+ r.sdkSessionId = msg.session_id;
1499
+ this.save();
1500
+ this.opts.onSessions();
1501
+ }
1502
+ if (msg.subtype === "api_retry" && !live.retried) {
1503
+ live.retried = true;
1504
+ this.append(r.id, { kind: "notice", code: "api_retry" });
1505
+ }
1506
+ return;
1507
+ }
1508
+ case "stream_event": {
1509
+ if (parent)
1510
+ return;
1511
+ const ev = msg.event;
1512
+ if (ev?.type === "message_start" && ev.message?.id) {
1513
+ this.flush(r.id, live);
1514
+ live.current = { msg: ev.message.id, text: "", tools: [], model: r.model, thinkingBlocks: [], nextThinkingBlock: 0 };
1515
+ }
1516
+ else if (ev?.type === "content_block_start" && ev.content_block?.type === "thinking" && live.current) {
1517
+ const index = ev.index ?? 0;
1518
+ if (!live.current.thinkingBlocks.includes(index))
1519
+ live.current.thinkingBlocks.push(index);
1520
+ }
1521
+ else if (ev?.type === "content_block_delta" && live.current) {
1522
+ const thinking = ev.delta?.type === "thinking_delta" ? ev.delta.thinking : undefined;
1523
+ const text = ev.delta?.type === "text_delta" ? ev.delta.text : undefined;
1524
+ if (!thinking && !text)
1525
+ return;
1526
+ const block = ev.index ?? 0;
1527
+ if (thinking && !live.current.thinkingBlocks.includes(block))
1528
+ live.current.thinkingBlocks.push(block);
1529
+ if (live.buffer && (text && live.buffer.thinking || thinking && (!live.buffer.thinking || live.buffer.thinking.block !== block)))
1530
+ this.flush(r.id, live);
1531
+ if (!live.buffer)
1532
+ live.buffer = { msg: live.current.msg, text: "" };
1533
+ if (thinking) {
1534
+ if (!live.buffer.thinking)
1535
+ live.buffer.thinking = { block, text: "" };
1536
+ live.buffer.thinking.text += thinking;
1537
+ }
1538
+ else if (text)
1539
+ live.buffer.text += text;
1540
+ if (!live.flushTimer)
1541
+ live.flushTimer = setTimeout(() => this.flush(r.id, live), this.opts.coalesceMs ?? 80);
1542
+ }
1543
+ return;
1544
+ }
1545
+ case "assistant": {
1546
+ const message = msg.message;
1547
+ if (!message)
1548
+ return;
1549
+ if (parent) {
1550
+ this.subAssistant(r, live, parent, message);
1551
+ return;
1552
+ }
1553
+ this.flush(r.id, live);
1554
+ const id = message.id ?? randomUUID();
1555
+ if (!live.current || live.current.msg !== id)
1556
+ live.current = { msg: id, text: "", tools: [], model: message.model ?? r.model, thinkingBlocks: [], nextThinkingBlock: 0 };
1557
+ const cur = live.current;
1558
+ (message.content ?? []).forEach((block, index) => {
1559
+ if (block.type !== "thinking" || typeof block.thinking !== "string")
1560
+ return;
1561
+ // SDK assistant messages can contain just one completed block: local index 0
1562
+ // is not necessarily the block's index in the original stream.
1563
+ const streamedIndex = cur.thinkingBlocks[cur.nextThinkingBlock++] ?? index;
1564
+ this.append(r.id, { kind: "thinking", msg: id, block: streamedIndex, text: block.thinking });
1565
+ });
1566
+ const converted = messageContent(message.content, data => this.log(r.id).image(data));
1567
+ if (converted.text)
1568
+ cur.text += (cur.text ? "\n\n" : "") + converted.text;
1569
+ cur.images = [...(cur.images ?? []), ...converted.images, ...sendUserFileImages(message.content, r.cwd, data => this.log(r.id).image(data))];
1570
+ for (const b of message.content ?? []) {
1571
+ if (b.type === "tool_use" && b.id && b.name) {
1572
+ const input = b.input ?? {};
1573
+ cur.tools.push({ id: b.id, name: b.name, summary: summarize(b.name, input, r.cwd), input: JSON.stringify(input, null, 2) });
1574
+ if (b.name === "Task" || b.name === "Agent") {
1575
+ const sub = { description: summarize(b.name, input, r.cwd), model: typeof input.model === "string" ? input.model : null, steps: 0 };
1576
+ live.subs.set(b.id, sub);
1577
+ this.append(r.id, { kind: "subagent", toolUseId: b.id, sub: `sub-${b.id}`, description: sub.description, model: sub.model, status: "running", steps: 0 });
1578
+ }
1579
+ }
1580
+ }
1581
+ if (message.model)
1582
+ cur.model = message.model;
1583
+ // Thinking-only SDK block completions must not reserve the answer's position
1584
+ // before subsequent thinking blocks have arrived.
1585
+ if (cur.text || cur.images.length || cur.tools.length)
1586
+ this.append(r.id, { kind: "assistant", msg: cur.msg, model: cur.model, text: cur.text, images: cur.images, tools: cur.tools });
1587
+ return;
1588
+ }
1589
+ case "user": {
1590
+ const message = msg.message;
1591
+ const blocks = Array.isArray(message?.content) ? message.content : [];
1592
+ const converted = messageContent(message?.content, data => this.log(r.id).image(data));
1593
+ // SDK user echoes normally already have a phone event; retain image-bearing echoes only.
1594
+ if (converted.images.length) {
1595
+ const body = { kind: "user", ...converted, clientMsgId: typeof msg.uuid === "string" ? msg.uuid : randomUUID() };
1596
+ if (parent)
1597
+ this.subLog(r.id, `sub-${parent}`)?.append(body);
1598
+ else
1599
+ this.append(r.id, body);
1600
+ }
1601
+ for (const b of blocks) {
1602
+ if (b.type !== "tool_result" || !b.tool_use_id)
1603
+ continue;
1604
+ const body = { kind: "tool_result", toolUseId: b.tool_use_id, ok: !b.is_error, ...messageContent(b.content, data => this.log(r.id).image(data)) };
1605
+ if (parent) {
1606
+ this.subLog(r.id, `sub-${parent}`)?.append(body);
1607
+ continue;
1608
+ }
1609
+ this.append(r.id, body);
1610
+ const sub = live.subs.get(b.tool_use_id);
1611
+ if (sub) {
1612
+ live.subs.delete(b.tool_use_id);
1613
+ this.append(r.id, { kind: "subagent", toolUseId: b.tool_use_id, sub: `sub-${b.tool_use_id}`, description: sub.description, model: sub.model, status: b.is_error ? "failed" : "done", steps: sub.steps });
1614
+ }
1615
+ }
1616
+ return;
1617
+ }
1618
+ case "result": {
1619
+ live.pendingTurns = Math.max(0, live.pendingTurns - 1);
1620
+ this.flush(r.id, live);
1621
+ live.current = null;
1622
+ live.retried = false;
1623
+ if (live.interrupted) {
1624
+ // The person pressed Stop; the "interrupted" notice already says so. A failed-turn line would not be true.
1625
+ live.interrupted = false;
1626
+ this.setState(r, live.pendingTurns > 0 ? "running" : "idle");
1627
+ if (live.pendingTurns === 0)
1628
+ this.handoff(r);
1629
+ if (live.restartAfterTurn)
1630
+ this.stop(r.id);
1631
+ return;
1632
+ }
1633
+ const ok = msg.subtype === "success" && msg.is_error !== true;
1634
+ const usage = msg.modelUsage;
1635
+ const win = usage ? Object.values(usage).map((u) => u.contextWindow ?? 0).find((w) => w > 0) : undefined;
1636
+ if (win)
1637
+ r.contextWindow = win;
1638
+ for (const [model, u] of Object.entries(usage ?? {}))
1639
+ if (typeof u.contextWindow === "number")
1640
+ this.learnWindow(model, u.contextWindow);
1641
+ const error = ok ? undefined : String(msg.result ?? (Array.isArray(msg.errors) ? msg.errors.join("; ") : msg.subtype ?? "error")).slice(0, 500);
1642
+ this.append(r.id, { kind: "result", ok, ...(error ? { error } : {}), ms: typeof msg.duration_ms === "number" ? msg.duration_ms : this.now() - live.turnStart, turns: typeof msg.num_turns === "number" ? msg.num_turns : 0 });
1643
+ this.setState(r, live.pendingTurns > 0 ? "running" : ok ? "idle" : "error");
1644
+ if (live.pendingTurns === 0)
1645
+ this.handoff(r);
1646
+ void this.refreshFigures(r, true);
1647
+ if (live.restartAfterTurn)
1648
+ this.stop(r.id);
1649
+ return;
1650
+ }
1651
+ default:
1652
+ return;
1653
+ }
1654
+ }
1655
+ handoff(r) {
1656
+ const runner = this.opts.handoff;
1657
+ if (!runner || !r.sdkSessionId || r.desktopHandoff !== "pending")
1658
+ return;
1659
+ r.desktopHandoff = "attempted";
1660
+ this.save();
1661
+ const transfer = { messages: [] };
1662
+ this.handingOff.set(r.id, transfer);
1663
+ // Defer past result handling so the SDK can finish its result continuation first.
1664
+ void new Promise(resolve => setImmediate(resolve)).then(async () => {
1665
+ if (this.shuttingDown) {
1666
+ this.handingOff.delete(r.id);
1667
+ return;
1668
+ }
1669
+ try {
1670
+ const result = await runner({ sessionId: r.sdkSessionId, title: r.title, cwd: r.cwd });
1671
+ (this.opts.log ?? console.error)(`[desktop handoff ${r.id}] ${result}`);
1672
+ }
1673
+ catch (error) {
1674
+ (this.opts.log ?? console.error)(`[desktop handoff ${r.id}] failed: ${error.message}`);
1675
+ }
1676
+ finally {
1677
+ this.handingOff.delete(r.id);
1678
+ if (transfer.messages.length && !this.shuttingDown) {
1679
+ const writer = this.live.get(r.id) ?? this.start(r);
1680
+ writer.pendingTurns += transfer.messages.length;
1681
+ for (const message of transfer.messages)
1682
+ writer.input.push(message);
1683
+ }
1684
+ }
1685
+ });
1686
+ }
1687
+ subAssistant(r, live, parent, message) {
1688
+ const log = this.subLog(r.id, `sub-${parent}`);
1689
+ if (!log)
1690
+ return;
1691
+ (message.content ?? []).forEach((block, index) => {
1692
+ if (block.type === "thinking" && typeof block.thinking === "string")
1693
+ log.append({ kind: "thinking", msg: message.id ?? parent, block: index, text: block.thinking });
1694
+ });
1695
+ const converted = messageContent(message.content, data => this.log(r.id).image(data));
1696
+ converted.images.push(...sendUserFileImages(message.content, r.cwd, data => this.log(r.id).image(data)));
1697
+ const tools = (message.content ?? []).filter((b) => b.type === "tool_use" && b.id && b.name).map((b) => ({ id: b.id, name: b.name, summary: summarize(b.name, b.input ?? {}, r.cwd), input: JSON.stringify(b.input ?? {}, null, 2) }));
1698
+ log.append({ kind: "assistant", msg: message.id ?? randomUUID(), model: message.model ?? "", ...converted, tools });
1699
+ const sub = live.subs.get(parent);
1700
+ if (sub) {
1701
+ sub.steps++;
1702
+ if (message.model)
1703
+ sub.model = message.model;
1704
+ }
1705
+ }
1706
+ flush(id, live) {
1707
+ if (live.flushTimer) {
1708
+ clearTimeout(live.flushTimer);
1709
+ live.flushTimer = null;
1710
+ }
1711
+ const b = live.buffer;
1712
+ live.buffer = null;
1713
+ if (b?.thinking?.text)
1714
+ this.append(id, { kind: "thinking", msg: b.msg, block: b.thinking.block, text: b.thinking.text, delta: true });
1715
+ if (b && b.text)
1716
+ this.append(id, { kind: "delta", msg: b.msg, text: b.text });
1717
+ const r = this.records.get(id);
1718
+ if (r && r.state === "running")
1719
+ void this.refreshFigures(r, false);
1720
+ }
1721
+ canUseTool(id) {
1722
+ return async (tool, input, opts) => {
1723
+ const r = this.records.get(id);
1724
+ const live = this.live.get(id);
1725
+ if (live)
1726
+ this.flush(id, live);
1727
+ let detail = permissionDetail(tool, input, r.cwd);
1728
+ if (tool === "Write" && typeof input.file_path === "string") {
1729
+ // What the write replaces, when it is a readable file of a size worth showing on a phone; any
1730
+ // failure keeps the plain listing rather than failing the approval itself.
1731
+ try {
1732
+ const file = path.resolve(r.cwd, input.file_path);
1733
+ if ((await fs.promises.stat(file)).size <= 256 * 1024) {
1734
+ const before = await fs.promises.readFile(file, "utf8");
1735
+ detail = permissionDetail("Edit", { file_path: input.file_path, old_string: before, new_string: input.content }, r.cwd);
1736
+ }
1737
+ }
1738
+ catch { /* new file, unreadable or gone: the plain listing stands */ }
1739
+ }
1740
+ const questions = tool === "AskUserQuestion" ? questionInputs(input) : undefined;
1741
+ const { record, decision } = this.opts.permissions.request({ session: id, tool, title: opts.title ?? "", summary: summarize(tool, input, r.cwd), input }, opts.signal);
1742
+ this.append(id, { kind: "permission", id: record.id, tool, title: opts.title ?? "", summary: record.summary, input: detail, canAlways: tool !== "ExitPlanMode" && tool !== "AskUserQuestion" && Array.isArray(opts.suggestions) && opts.suggestions.length > 0, requestKind: tool === "ExitPlanMode" ? "plan" : tool === "AskUserQuestion" ? "question" : "tool", ...(questions ? { questions } : {}) });
1743
+ this.setState(r, "waiting");
1744
+ const answer = await decision;
1745
+ this.append(id, { kind: "permission_decided", id: record.id, decision: answer.decision, ...(answer.reason ? { reason: answer.reason } : {}) });
1746
+ if (this.live.has(id) && this.opts.permissions.pending(id).length === 0)
1747
+ this.setState(r, "running");
1748
+ else
1749
+ this.opts.onSessions();
1750
+ if (answer.decision === "allow")
1751
+ return { behavior: "allow", updatedInput: answer.answers ? { ...input, answers: answer.answers } : input };
1752
+ if (answer.decision === "allow_always")
1753
+ return { behavior: "allow", updatedInput: answer.answers ? { ...input, answers: answer.answers } : input, ...(opts.suggestions ? { updatedPermissions: opts.suggestions } : {}) };
1754
+ if (answer.decision === "deny")
1755
+ return { behavior: "deny", message: answer.reason ?? "The user denied this from their phone." };
1756
+ return { behavior: "deny", message: "This permission request expired before anyone answered.", interrupt: true };
1757
+ };
1758
+ }
1759
+ /** The mod's figures as events in the same log, so a phone that reconnects sees what it missed. Throttled to one read per 15 s while a turn runs. */
1760
+ async refreshFigures(r, force) {
1761
+ if (!r.sdkSessionId)
1762
+ return;
1763
+ await this.readFigures(r.id, r.sdkSessionId, r.model, r.contextWindow, force, false);
1764
+ }
1765
+ /**
1766
+ * One read of the router's records for a session, appended when it changed. `id` is the host's id
1767
+ * for it, `cliId` the id Claude Code sends the router (`X-Claude-Code-Session-Id`). A Mac session
1768
+ * has no window of its own, so the one learned for its model stands; none known stays null.
1769
+ * `mac`: a session the router has no record of yet (an old one, from before it started) says nothing.
1770
+ */
1771
+ async readFigures(id, cliId, model, contextWindow, force, mac) {
1772
+ const last = this.figuresAt.get(id) ?? 0;
1773
+ if (!force && this.now() - last < 15_000)
1774
+ return;
1775
+ this.figuresAt.set(id, this.now());
1776
+ let figures;
1777
+ try {
1778
+ figures = await this.opts.router.figures(cliId, model, contextWindow ?? this.windows.get(windowKey(model)) ?? null);
1779
+ }
1780
+ catch {
1781
+ return;
1782
+ }
1783
+ if (mac && !figures.route && !figures.effort && !figures.workers.length)
1784
+ return;
1785
+ const { at: _at, ...rest } = figures;
1786
+ const key = JSON.stringify(rest);
1787
+ if (this.lastFigures.get(id) === key)
1788
+ return;
1789
+ this.lastFigures.set(id, key);
1790
+ if (this.records.has(id)) {
1791
+ this.append(id, { kind: "figures", figures });
1792
+ return;
1793
+ }
1794
+ // No record, so no live log: the snapshot a phone is reading gets the event now, and every later snapshot is built with it.
1795
+ this.desktopFigures.set(id, figures);
1796
+ const snapshot = this.desktop?.snapshot(id);
1797
+ if (snapshot)
1798
+ this.opts.onEvent(id, snapshot.append({ kind: "figures", figures }));
1799
+ }
1800
+ }