@duckmind/dm-darwin-arm64 0.13.6 → 0.13.8

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 (78) hide show
  1. package/dm +0 -0
  2. package/extensions/.dm-extensions.json +26 -2
  3. package/extensions/dm-phone/README.md +23 -0
  4. package/extensions/dm-phone/index.ts +12 -0
  5. package/extensions/dm-phone/node_modules/.package-lock.json +29 -0
  6. package/extensions/dm-phone/node_modules/ws/LICENSE +20 -0
  7. package/extensions/dm-phone/node_modules/ws/README.md +548 -0
  8. package/extensions/dm-phone/node_modules/ws/browser.js +8 -0
  9. package/extensions/dm-phone/node_modules/ws/index.js +22 -0
  10. package/extensions/dm-phone/node_modules/ws/lib/buffer-util.js +131 -0
  11. package/extensions/dm-phone/node_modules/ws/lib/constants.js +19 -0
  12. package/extensions/dm-phone/node_modules/ws/lib/event-target.js +292 -0
  13. package/extensions/dm-phone/node_modules/ws/lib/extension.js +203 -0
  14. package/extensions/dm-phone/node_modules/ws/lib/limiter.js +55 -0
  15. package/extensions/dm-phone/node_modules/ws/lib/permessage-deflate.js +528 -0
  16. package/extensions/dm-phone/node_modules/ws/lib/receiver.js +706 -0
  17. package/extensions/dm-phone/node_modules/ws/lib/sender.js +602 -0
  18. package/extensions/dm-phone/node_modules/ws/lib/stream.js +161 -0
  19. package/extensions/dm-phone/node_modules/ws/lib/subprotocol.js +62 -0
  20. package/extensions/dm-phone/node_modules/ws/lib/validation.js +152 -0
  21. package/extensions/dm-phone/node_modules/ws/lib/websocket-server.js +554 -0
  22. package/extensions/dm-phone/node_modules/ws/lib/websocket.js +1393 -0
  23. package/extensions/dm-phone/node_modules/ws/package.json +70 -0
  24. package/extensions/dm-phone/node_modules/ws/wrapper.mjs +21 -0
  25. package/extensions/dm-phone/package-lock.json +66 -0
  26. package/extensions/dm-phone/package.json +35 -0
  27. package/extensions/dm-phone/phone-session-pool.ts +8 -0
  28. package/extensions/dm-phone/public/app/attachments.js +233 -0
  29. package/extensions/dm-phone/public/app/autocomplete-controller.js +81 -0
  30. package/extensions/dm-phone/public/app/autocomplete.js +135 -0
  31. package/extensions/dm-phone/public/app/bindings.js +178 -0
  32. package/extensions/dm-phone/public/app/command-catalog.js +76 -0
  33. package/extensions/dm-phone/public/app/commands.js +370 -0
  34. package/extensions/dm-phone/public/app/constants.js +60 -0
  35. package/extensions/dm-phone/public/app/formatters.js +131 -0
  36. package/extensions/dm-phone/public/app/handlers.js +442 -0
  37. package/extensions/dm-phone/public/app/main.js +6 -0
  38. package/extensions/dm-phone/public/app/markdown.js +105 -0
  39. package/extensions/dm-phone/public/app/messages.js +418 -0
  40. package/extensions/dm-phone/public/app/sheet-actions.js +113 -0
  41. package/extensions/dm-phone/public/app/sheet-navigation.js +19 -0
  42. package/extensions/dm-phone/public/app/sheets-view.js +272 -0
  43. package/extensions/dm-phone/public/app/state.js +95 -0
  44. package/extensions/dm-phone/public/app/tool-rendering.js +562 -0
  45. package/extensions/dm-phone/public/app/transport.js +176 -0
  46. package/extensions/dm-phone/public/app/ui.js +409 -0
  47. package/extensions/dm-phone/public/app.js +1 -0
  48. package/extensions/dm-phone/public/icon.svg +15 -0
  49. package/extensions/dm-phone/public/index.html +147 -0
  50. package/extensions/dm-phone/public/manifest.webmanifest +17 -0
  51. package/extensions/dm-phone/public/styles.css +1139 -0
  52. package/extensions/dm-phone/public/sw.js +78 -0
  53. package/extensions/dm-phone/src/extension/phone-args.ts +121 -0
  54. package/extensions/dm-phone/src/extension/phone-paths.ts +250 -0
  55. package/extensions/dm-phone/src/extension/phone-quota.ts +188 -0
  56. package/extensions/dm-phone/src/extension/phone-runtime.ts +154 -0
  57. package/extensions/dm-phone/src/extension/phone-server-runtime.ts +1217 -0
  58. package/extensions/dm-phone/src/extension/phone-sessions.ts +139 -0
  59. package/extensions/dm-phone/src/extension/phone-static.ts +30 -0
  60. package/extensions/dm-phone/src/extension/phone-tailscale.ts +148 -0
  61. package/extensions/dm-phone/src/extension/phone-theme.ts +85 -0
  62. package/extensions/dm-phone/src/extension/register-phone-child-extension.ts +112 -0
  63. package/extensions/dm-phone/src/extension/register-phone-extension.ts +106 -0
  64. package/extensions/dm-phone/src/extension/types.ts +73 -0
  65. package/extensions/dm-phone/src/session-pool/parent-session-worker.ts +881 -0
  66. package/extensions/dm-phone/src/session-pool/session-pool.ts +470 -0
  67. package/extensions/dm-phone/src/session-pool/session-worker.ts +734 -0
  68. package/extensions/dm-phone/src/session-pool/types.ts +105 -0
  69. package/extensions/dm-phone/src/session-pool/utils.ts +23 -0
  70. package/extensions/dm-subagents/agent-management.ts +15 -6
  71. package/extensions/dm-subagents/agent-manager-detail.ts +12 -2
  72. package/extensions/dm-subagents/agent-manager-edit.ts +75 -23
  73. package/extensions/dm-subagents/agent-manager-list.ts +9 -2
  74. package/extensions/dm-subagents/agent-manager.ts +199 -11
  75. package/extensions/dm-subagents/agents.ts +315 -20
  76. package/extensions/dm-ultrathink/README.md +5 -0
  77. package/extensions/dm-ultrathink/src/naming.ts +75 -3
  78. package/package.json +1 -1
@@ -0,0 +1,1217 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ InputEvent,
6
+ InputEventResult,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import { randomBytes } from "node:crypto";
9
+ import { existsSync, statSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12
+ import { extname } from "node:path";
13
+ import { WebSocketServer, type RawData, type WebSocket } from "ws";
14
+ import { PhoneParentSessionWorker } from "../session-pool/parent-session-worker";
15
+ import { PhoneSessionPool } from "../session-pool/session-pool";
16
+ import { PhoneSessionWorker } from "../session-pool/session-worker";
17
+ import type { SessionController } from "../session-pool/types";
18
+ import { parsePhoneStartArgs } from "./phone-args";
19
+ import { listPhonePathSuggestions, resolvePhoneCdTargetPath } from "./phone-paths";
20
+ import { getQuotaForModel } from "./phone-quota";
21
+ import {
22
+ isLoopbackAddress,
23
+ phoneControlStopPath,
24
+ readPersistedRuntimeState,
25
+ removePersistedRuntimeState,
26
+ stopPersistedRuntime,
27
+ writePersistedRuntimeState,
28
+ } from "./phone-runtime";
29
+ import {
30
+ createBranchSessionFromEntry,
31
+ getTreeStateFromSessionFile,
32
+ listSessionsForCwd,
33
+ } from "./phone-sessions";
34
+ import { mimeTypes, publicFilePath, sanitizePublicPath } from "./phone-static";
35
+ import { disableMatchingTailscaleServe, enableTailscaleServe, getTailscaleServeInfo } from "./phone-tailscale";
36
+ import { buildThemePayload } from "./phone-theme";
37
+ import type { PhoneConfig } from "./types";
38
+
39
+ type AnyCtx = ExtensionContext | ExtensionCommandContext;
40
+
41
+ type SlashCommandMatch = {
42
+ text: string;
43
+ name: string;
44
+ source: string;
45
+ };
46
+
47
+ const DEFAULT_IDLE_TIMEOUT_MS = 2 * 60 * 60_000;
48
+
49
+ function isAddressInUseError(error: unknown) {
50
+ const err = error as NodeJS.ErrnoException | null;
51
+ return Boolean(err && (err.code === "EADDRINUSE" || err.message?.includes("EADDRINUSE")));
52
+ }
53
+
54
+ function parseSlashCommandText(text: unknown) {
55
+ const value = typeof text === "string" ? text.trim() : "";
56
+ if (!value.startsWith("/")) return null;
57
+
58
+ const body = value.slice(1).trim();
59
+ if (!body) return null;
60
+
61
+ const spaceIndex = body.indexOf(" ");
62
+ const name = spaceIndex === -1 ? body : body.slice(0, spaceIndex);
63
+
64
+ return {
65
+ text: `/${body}`,
66
+ name,
67
+ };
68
+ }
69
+
70
+ export class PhoneServerRuntime {
71
+ private latestCtx: AnyCtx | null = null;
72
+ private latestError = "";
73
+ private config: PhoneConfig = {
74
+ host: "127.0.0.1",
75
+ port: 8787,
76
+ token: process.env.DM_PHONE_TOKEN || process.env.PI_PHONE_TOKEN || "",
77
+ cwd: process.cwd(),
78
+ idleTimeoutMs: Number.isFinite(Number(process.env.DM_PHONE_IDLE_MINUTES || process.env.PI_PHONE_IDLE_MINUTES))
79
+ ? Math.max(0, Math.round(Number(process.env.DM_PHONE_IDLE_MINUTES || process.env.PI_PHONE_IDLE_MINUTES) * 60_000))
80
+ : DEFAULT_IDLE_TIMEOUT_MS,
81
+ };
82
+ private server: Server | null = null;
83
+ private wss: WebSocketServer | null = null;
84
+ private sessionPool: PhoneSessionPool | null = null;
85
+ private parentWorker: PhoneParentSessionWorker | null = null;
86
+ private latestCommandCtx: ExtensionCommandContext | null = null;
87
+ private controlOwner: "cli" | "phone" = "cli";
88
+ private phoneSelectedSessionId: string | null = null;
89
+ private idleStopTimer: NodeJS.Timeout | null = null;
90
+ private lastActivityAt = Date.now();
91
+ private runtimeControlToken = "";
92
+ private activeRuntimeStatePath: string | null = null;
93
+
94
+ constructor(private readonly pi: ExtensionAPI) {}
95
+
96
+ captureCtx(ctx: AnyCtx) {
97
+ this.latestCtx = ctx;
98
+ if (typeof (ctx as ExtensionCommandContext).waitForIdle === "function") {
99
+ this.latestCommandCtx = ctx as ExtensionCommandContext;
100
+ }
101
+ }
102
+
103
+ private activeCwd() {
104
+ return this.latestCtx?.cwd || this.config.cwd || process.cwd();
105
+ }
106
+
107
+ private buildStatus() {
108
+ if (this.sessionPool) {
109
+ return this.sessionPool.buildOverallStatus();
110
+ }
111
+
112
+ const theme = buildThemePayload(this.latestCtx?.ui.theme);
113
+
114
+ return {
115
+ cwd: this.config.cwd,
116
+ hasToken: Boolean(this.config.token),
117
+ isRunning: Boolean(this.server),
118
+ childRunning: false,
119
+ isStreaming: false,
120
+ lastError: this.latestError,
121
+ pid: process.pid,
122
+ childPid: null,
123
+ piCommand: "live cli + parallel dm --mode rpc",
124
+ connectedClients: 0,
125
+ sessionCount: 0,
126
+ host: this.config.host,
127
+ port: this.config.port,
128
+ idleTimeoutMs: this.config.idleTimeoutMs,
129
+ lastActivityAt: this.lastActivityAt,
130
+ singleClientMode: true,
131
+ controlOwner: this.controlOwner,
132
+ ...(theme ? { theme } : {}),
133
+ };
134
+ }
135
+
136
+ private generateToken() {
137
+ const raw = randomBytes(12).toString("base64url");
138
+ return `${raw.slice(0, 6)}-${raw.slice(6, 12)}-${raw.slice(12, 16)}`;
139
+ }
140
+
141
+ private send(ws: WebSocket, payload: unknown) {
142
+ if (ws.readyState === ws.OPEN) {
143
+ ws.send(JSON.stringify(payload));
144
+ }
145
+ }
146
+
147
+ private broadcast(payload: unknown) {
148
+ for (const client of this.sessionPool?.getClients() || []) {
149
+ this.send(client, payload);
150
+ }
151
+ }
152
+
153
+ broadcastStatus() {
154
+ this.sessionPool?.broadcastStatus();
155
+ }
156
+
157
+ private clearIdleStopTimer() {
158
+ if (this.idleStopTimer) {
159
+ clearTimeout(this.idleStopTimer);
160
+ this.idleStopTimer = null;
161
+ }
162
+ }
163
+
164
+ markActivity() {
165
+ this.lastActivityAt = Date.now();
166
+ this.scheduleIdleStop();
167
+ this.broadcastStatus();
168
+ }
169
+
170
+ private scheduleIdleStop() {
171
+ this.clearIdleStopTimer();
172
+ if (!this.server || this.config.idleTimeoutMs <= 0) return;
173
+
174
+ this.idleStopTimer = setTimeout(async () => {
175
+ if (!this.server) return;
176
+ const elapsed = Date.now() - this.lastActivityAt;
177
+ if (elapsed < this.config.idleTimeoutMs) {
178
+ this.scheduleIdleStop();
179
+ return;
180
+ }
181
+
182
+ const idlePayload = {
183
+ channel: "server",
184
+ event: "idle-timeout",
185
+ data: { message: `DM Phone stopped after ${Math.round(this.config.idleTimeoutMs / 60000) || 1} minute(s) of inactivity.` },
186
+ };
187
+
188
+ if (this.sessionPool) {
189
+ await this.sessionPool.closeAllClients({ payload: idlePayload, code: 4010, reason: "idle-timeout" });
190
+ } else {
191
+ this.broadcast(idlePayload);
192
+ }
193
+
194
+ await this.stopServer();
195
+ await disableMatchingTailscaleServe(this.pi, this.config.port);
196
+ }, this.config.idleTimeoutMs);
197
+ }
198
+
199
+ private async getActiveWorkerForClient(ws: WebSocket) {
200
+ if (!this.sessionPool) {
201
+ throw new Error("DM Phone session pool is not running.");
202
+ }
203
+ return this.sessionPool.getActiveWorker(ws);
204
+ }
205
+
206
+ private async getCurrentSessionFileForWorker(worker: SessionController) {
207
+ const stateResponse = await worker.request({ type: "get_state" });
208
+ return stateResponse.data?.sessionFile as string | undefined;
209
+ }
210
+
211
+ private async getTreeStateForWorker(worker: SessionController) {
212
+ const sessionFile = await this.getCurrentSessionFileForWorker(worker);
213
+ if (!sessionFile) {
214
+ throw new Error("No session file available for tree view.");
215
+ }
216
+ return getTreeStateFromSessionFile(sessionFile);
217
+ }
218
+
219
+ private async createBranchSessionFromEntryForWorker(worker: SessionController, entryId: string) {
220
+ const sessionFile = await this.getCurrentSessionFileForWorker(worker);
221
+ if (!sessionFile) {
222
+ throw new Error("No active session file.");
223
+ }
224
+ return createBranchSessionFromEntry(sessionFile, entryId);
225
+ }
226
+
227
+ private async resolveRemoteSlashCommandForWorker(worker: SessionController, text: unknown): Promise<SlashCommandMatch | null> {
228
+ const parsed = parseSlashCommandText(text);
229
+ if (!parsed) return null;
230
+
231
+ const commandsResponse = await worker.request({ type: "get_commands" });
232
+ if (!commandsResponse?.success) {
233
+ throw new Error(commandsResponse?.error || "Failed to read available slash commands.");
234
+ }
235
+
236
+ const match = (commandsResponse.data?.commands || []).find((command: any) => command?.name === parsed.name);
237
+ if (!match) return null;
238
+
239
+ return {
240
+ ...parsed,
241
+ source: typeof match.source === "string" ? match.source : "extension",
242
+ };
243
+ }
244
+
245
+ private async dispatchRemoteSlashCommandForWorker(
246
+ worker: SessionController,
247
+ ws: WebSocket,
248
+ input: {
249
+ text: string;
250
+ images?: unknown[];
251
+ streamingBehavior?: "steer" | "followUp";
252
+ },
253
+ options: {
254
+ responseCommand?: string;
255
+ responseData?: Record<string, unknown>;
256
+ onSuccess?: (payload?: unknown) => void;
257
+ onError?: (payload?: unknown) => void;
258
+ } = {},
259
+ ) {
260
+ const slashCommand = await this.resolveRemoteSlashCommandForWorker(worker, input.text);
261
+ if (!slashCommand) {
262
+ this.send(ws, {
263
+ channel: "rpc",
264
+ payload: {
265
+ type: "response",
266
+ command: options.responseCommand || "slash_command",
267
+ success: false,
268
+ error: `Unknown slash command: ${typeof input.text === "string" ? input.text : ""}`.trim() || "Unknown slash command.",
269
+ },
270
+ });
271
+ return false;
272
+ }
273
+
274
+ const images = Array.isArray(input.images) ? input.images : [];
275
+ if (slashCommand.source === "extension" && images.length > 0) {
276
+ this.send(ws, {
277
+ channel: "rpc",
278
+ payload: {
279
+ type: "response",
280
+ command: options.responseCommand || "slash_command",
281
+ success: false,
282
+ error: "Extension slash commands do not support image attachments.",
283
+ },
284
+ });
285
+ return false;
286
+ }
287
+
288
+ const childCommand: Record<string, unknown> = {
289
+ type: "prompt",
290
+ message: slashCommand.text,
291
+ };
292
+
293
+ if (images.length > 0) {
294
+ childCommand.images = images;
295
+ }
296
+
297
+ if (slashCommand.source !== "extension" && (input.streamingBehavior === "steer" || input.streamingBehavior === "followUp")) {
298
+ childCommand.streamingBehavior = input.streamingBehavior;
299
+ }
300
+
301
+ await worker.sendClientCommand(childCommand, {
302
+ ws,
303
+ responseCommand: options.responseCommand || "slash_command",
304
+ responseData: {
305
+ name: slashCommand.name,
306
+ source: slashCommand.source,
307
+ ...(options.responseData || {}),
308
+ },
309
+ onSuccess: options.onSuccess,
310
+ onError: options.onError,
311
+ });
312
+
313
+ return true;
314
+ }
315
+
316
+ private rememberPhoneSelection(session: SessionController | null) {
317
+ if (!session) return;
318
+ this.phoneSelectedSessionId = session.id;
319
+ }
320
+
321
+ private selectedSessionId() {
322
+ if (this.phoneSelectedSessionId && this.sessionPool?.getSession(this.phoneSelectedSessionId)) {
323
+ return this.phoneSelectedSessionId;
324
+ }
325
+ return this.sessionPool?.getSelectedSessionId() || this.parentWorker?.id || null;
326
+ }
327
+
328
+ private selectedSession() {
329
+ return this.sessionPool?.getSession(this.selectedSessionId()) || this.parentWorker || null;
330
+ }
331
+
332
+ private setControlOwner(owner: "cli" | "phone") {
333
+ if (this.controlOwner === owner) return;
334
+ this.controlOwner = owner;
335
+ this.broadcastStatus();
336
+ }
337
+
338
+ private parentBusy() {
339
+ const status = this.parentWorker?.getStatus();
340
+ return Boolean(status?.isStreaming || status?.isCompacting || this.parentWorker?.pendingUiRequest);
341
+ }
342
+
343
+ private releaseParentOwnershipIfAvailable() {
344
+ if (!this.parentBusy() && this.selectedSession()?.kind !== "parent") {
345
+ this.setControlOwner("cli");
346
+ }
347
+ }
348
+
349
+ private async ensurePhoneCanWrite(ws: WebSocket, worker: SessionController) {
350
+ if (worker.kind !== "parent") {
351
+ this.releaseParentOwnershipIfAvailable();
352
+ return true;
353
+ }
354
+
355
+ if (this.controlOwner === "cli" && this.parentBusy()) {
356
+ this.send(ws, {
357
+ channel: "server",
358
+ event: "client-error",
359
+ data: { message: "Wait for the current CLI parent response to finish before editing the parent session from the phone." },
360
+ });
361
+ return false;
362
+ }
363
+
364
+ this.setControlOwner("phone");
365
+ return true;
366
+ }
367
+
368
+ handleInput(event: InputEvent, ctx: ExtensionContext): InputEventResult | Promise<InputEventResult> {
369
+ this.captureCtx(ctx);
370
+ if (!this.server || !this.sessionPool || event.source !== "interactive") {
371
+ return { action: "continue" };
372
+ }
373
+ if (this.controlOwner !== "phone") {
374
+ return { action: "continue" };
375
+ }
376
+
377
+ const selected = this.selectedSession();
378
+ if (!selected) {
379
+ this.setControlOwner("cli");
380
+ return { action: "continue" };
381
+ }
382
+
383
+ if (selected.kind !== "parent") {
384
+ this.releaseParentOwnershipIfAvailable();
385
+ return { action: "continue" };
386
+ }
387
+
388
+ this.rememberPhoneSelection(selected);
389
+ if (this.parentBusy()) {
390
+ ctx.ui.notify("Wait for the current phone parent response to finish before taking control back in the CLI.", "warning");
391
+ return { action: "handled" };
392
+ }
393
+
394
+ this.setControlOwner("cli");
395
+ return { action: "continue" };
396
+ }
397
+
398
+ handleParentAgentStart(ctx: ExtensionContext) {
399
+ this.captureCtx(ctx);
400
+ this.parentWorker?.handleAgentStart(ctx);
401
+ }
402
+
403
+ handleParentAgentEnd(ctx: ExtensionContext) {
404
+ this.captureCtx(ctx);
405
+ this.parentWorker?.handleAgentEnd(ctx);
406
+ this.releaseParentOwnershipIfAvailable();
407
+ }
408
+
409
+ handleParentMessageStart(event: any, ctx: ExtensionContext) {
410
+ this.captureCtx(ctx);
411
+ this.parentWorker?.handleMessageStart(event, ctx);
412
+ }
413
+
414
+ handleParentMessageUpdate(event: any, ctx: ExtensionContext) {
415
+ this.captureCtx(ctx);
416
+ this.parentWorker?.handleMessageUpdate(event, ctx);
417
+ }
418
+
419
+ handleParentMessageEnd(event: any, ctx: ExtensionContext) {
420
+ this.captureCtx(ctx);
421
+ this.parentWorker?.handleMessageEnd(event, ctx);
422
+ }
423
+
424
+ handleParentToolExecutionStart(event: any, ctx: ExtensionContext) {
425
+ this.captureCtx(ctx);
426
+ this.parentWorker?.handleToolExecutionStart(event);
427
+ }
428
+
429
+ handleParentToolExecutionUpdate(event: any, ctx: ExtensionContext) {
430
+ this.captureCtx(ctx);
431
+ this.parentWorker?.handleToolExecutionUpdate(event);
432
+ }
433
+
434
+ handleParentToolExecutionEnd(event: any, ctx: ExtensionContext) {
435
+ this.captureCtx(ctx);
436
+ this.parentWorker?.handleToolExecutionEnd(event);
437
+ }
438
+
439
+ handleParentCompactionStart(ctx: ExtensionContext) {
440
+ this.captureCtx(ctx);
441
+ this.parentWorker?.setCompacting(true, ctx);
442
+ }
443
+
444
+ handleParentCompactionEnd(ctx: ExtensionContext) {
445
+ this.captureCtx(ctx);
446
+ this.parentWorker?.setCompacting(false, ctx);
447
+ this.releaseParentOwnershipIfAvailable();
448
+ }
449
+
450
+ private async handleHttp(req: IncomingMessage, res: ServerResponse) {
451
+ this.markActivity();
452
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
453
+
454
+ if (url.pathname === phoneControlStopPath) {
455
+ if (req.method !== "POST") {
456
+ res.writeHead(405, { "Content-Type": "application/json; charset=utf-8" });
457
+ res.end(JSON.stringify({ error: "Method not allowed" }));
458
+ return;
459
+ }
460
+
461
+ if (!this.runtimeControlToken || url.searchParams.get("token") !== this.runtimeControlToken || !isLoopbackAddress(req.socket.remoteAddress)) {
462
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
463
+ res.end(JSON.stringify({ error: "Forbidden" }));
464
+ return;
465
+ }
466
+
467
+ res.writeHead(200, {
468
+ "Content-Type": "application/json; charset=utf-8",
469
+ "Cache-Control": "no-store",
470
+ });
471
+ res.end(JSON.stringify({ ok: true }));
472
+ setTimeout(() => {
473
+ this.stopServer().catch((error) => {
474
+ this.latestError = error instanceof Error ? error.message : String(error);
475
+ this.broadcastStatus();
476
+ });
477
+ }, 0);
478
+ return;
479
+ }
480
+
481
+ if (url.pathname === "/api/health") {
482
+ res.writeHead(200, {
483
+ "Content-Type": "application/json; charset=utf-8",
484
+ "Cache-Control": "no-store",
485
+ });
486
+ res.end(JSON.stringify(this.buildStatus()));
487
+ return;
488
+ }
489
+
490
+ if (url.pathname === "/api/quota") {
491
+ if (req.method !== "GET" && req.method !== "HEAD") {
492
+ res.writeHead(405, { "Content-Type": "application/json; charset=utf-8" });
493
+ res.end(JSON.stringify({ error: "Method not allowed" }));
494
+ return;
495
+ }
496
+
497
+ const quota = await getQuotaForModel(url.searchParams.get("provider"), url.searchParams.get("modelId"));
498
+ res.writeHead(200, {
499
+ "Content-Type": "application/json; charset=utf-8",
500
+ "Cache-Control": "no-store",
501
+ });
502
+ if (req.method === "HEAD") {
503
+ res.end();
504
+ } else {
505
+ res.end(JSON.stringify(quota));
506
+ }
507
+ return;
508
+ }
509
+
510
+ if (req.method !== "GET" && req.method !== "HEAD") {
511
+ res.writeHead(405, { "Content-Type": "application/json; charset=utf-8" });
512
+ res.end(JSON.stringify({ error: "Method not allowed" }));
513
+ return;
514
+ }
515
+
516
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
517
+ const filePath = sanitizePublicPath(pathname);
518
+ if (!filePath) {
519
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
520
+ res.end(JSON.stringify({ error: "Forbidden" }));
521
+ return;
522
+ }
523
+
524
+ try {
525
+ const body = await readFile(filePath);
526
+ const extension = extname(filePath);
527
+ const cacheControl = [".html", ".js", ".css", ".webmanifest", ".json"].includes(extension) || pathname === "/sw.js"
528
+ ? "no-store"
529
+ : "public, max-age=60";
530
+ res.writeHead(200, {
531
+ "Content-Type": mimeTypes[extension] || "application/octet-stream",
532
+ "Cache-Control": cacheControl,
533
+ });
534
+ if (req.method === "GET") res.end(body);
535
+ else res.end();
536
+ } catch {
537
+ try {
538
+ const body = await readFile(publicFilePath("index.html"));
539
+ res.writeHead(200, {
540
+ "Content-Type": "text/html; charset=utf-8",
541
+ "Cache-Control": "no-store",
542
+ });
543
+ res.end(body);
544
+ } catch (error) {
545
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
546
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : "Failed to serve file" }));
547
+ }
548
+ }
549
+ }
550
+
551
+ async startServer() {
552
+ if (this.server) return;
553
+
554
+ this.sessionPool = new PhoneSessionPool({
555
+ cwd: this.config.cwd,
556
+ send: (ws, payload) => this.send(ws, payload),
557
+ onActivity: () => this.markActivity(),
558
+ buildStatusMeta: () => {
559
+ const theme = buildThemePayload(this.latestCtx?.ui.theme);
560
+ return {
561
+ cwd: this.config.cwd,
562
+ hasToken: Boolean(this.config.token),
563
+ host: this.config.host,
564
+ port: this.config.port,
565
+ idleTimeoutMs: this.config.idleTimeoutMs,
566
+ lastActivityAt: this.lastActivityAt,
567
+ singleClientMode: true,
568
+ controlOwner: this.controlOwner,
569
+ pid: process.pid,
570
+ piCommand: "live cli + parallel dm --mode rpc",
571
+ serverRunning: Boolean(this.server),
572
+ ...(theme ? { theme } : {}),
573
+ };
574
+ },
575
+ createDefaultSession: () => {
576
+ const worker = new PhoneParentSessionWorker(
577
+ {
578
+ cwd: this.config.cwd,
579
+ send: (ws, payload) => this.send(ws, payload),
580
+ onActivity: () => this.markActivity(),
581
+ onStateChange: () => {
582
+ this.sessionPool?.notifySessionStateChanged(worker);
583
+ if ((this.sessionPool?.clientCount || 0) === 0) {
584
+ this.setControlOwner("cli");
585
+ }
586
+ },
587
+ onEnvelope: (_currentWorker, envelope) => {
588
+ this.sessionPool?.forwardSessionEnvelope(worker, envelope);
589
+ },
590
+ shouldAutoRestart: () => false,
591
+ getCtx: () => this.latestCtx,
592
+ getCommandCtx: () => this.latestCommandCtx,
593
+ },
594
+ this.pi,
595
+ );
596
+ this.parentWorker = worker;
597
+ if (!this.phoneSelectedSessionId) {
598
+ this.rememberPhoneSelection(worker);
599
+ }
600
+ return worker;
601
+ },
602
+ createParallelSession: (sessionFile) => {
603
+ let worker: PhoneSessionWorker;
604
+ worker = new PhoneSessionWorker(
605
+ {
606
+ cwd: this.config.cwd,
607
+ send: (ws, payload) => this.send(ws, payload),
608
+ onActivity: () => this.markActivity(),
609
+ onStateChange: () => {
610
+ this.sessionPool?.notifySessionStateChanged(worker);
611
+ if ((this.sessionPool?.clientCount || 0) === 0) {
612
+ this.setControlOwner("cli");
613
+ }
614
+ },
615
+ onEnvelope: (_currentWorker, envelope) => {
616
+ this.sessionPool?.forwardSessionEnvelope(worker, envelope);
617
+ },
618
+ shouldAutoRestart: (currentWorker) => Boolean(this.sessionPool && this.sessionPool.clientCount > 0 && this.sessionPool.getSession(currentWorker.id)),
619
+ },
620
+ sessionFile,
621
+ );
622
+ return worker;
623
+ },
624
+ });
625
+
626
+ this.server = createServer((req, res) => {
627
+ this.handleHttp(req, res).catch((error) => {
628
+ this.latestError = error instanceof Error ? error.message : String(error);
629
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
630
+ res.end(JSON.stringify({ error: this.latestError }));
631
+ this.broadcastStatus();
632
+ });
633
+ });
634
+
635
+ this.wss = new WebSocketServer({ noServer: true });
636
+
637
+ this.wss.on("connection", (ws: WebSocket) => {
638
+ if (this.sessionPool && this.sessionPool.clientCount > 0) {
639
+ this.sessionPool.closeAllClients({
640
+ payload: {
641
+ channel: "server",
642
+ event: "single-client-replaced",
643
+ data: { message: "This DM Phone instance was opened from another device or tab." },
644
+ },
645
+ code: 4009,
646
+ reason: "replaced-by-new-client",
647
+ }).catch(() => {});
648
+ }
649
+
650
+ this.markActivity();
651
+ this.sessionPool?.addClient(ws).catch((error) => {
652
+ this.send(ws, {
653
+ channel: "server",
654
+ event: "snapshot-error",
655
+ data: { message: error instanceof Error ? error.message : String(error) },
656
+ });
657
+ });
658
+ this.broadcastStatus();
659
+
660
+ ws.on("close", () => {
661
+ this.sessionPool?.removeClient(ws);
662
+ if ((this.sessionPool?.clientCount || 0) === 0) {
663
+ this.setControlOwner("cli");
664
+ }
665
+ this.markActivity();
666
+ this.broadcastStatus();
667
+ });
668
+
669
+ ws.on("message", (raw: RawData) => {
670
+ this.markActivity();
671
+ this.handleClientMessage(ws, raw.toString()).catch((error) => {
672
+ this.send(ws, {
673
+ channel: "server",
674
+ event: "client-error",
675
+ data: { message: error instanceof Error ? error.message : String(error) },
676
+ });
677
+ });
678
+ });
679
+ });
680
+
681
+ this.server.on("upgrade", (req, socket, head) => {
682
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
683
+ if (url.pathname !== "/ws") {
684
+ socket.destroy();
685
+ return;
686
+ }
687
+
688
+ const tokenMismatch = Boolean(this.config.token && url.searchParams.get("token") !== this.config.token);
689
+
690
+ this.wss?.handleUpgrade(req, socket, head, (ws) => {
691
+ if (tokenMismatch) {
692
+ ws.close(1008, "invalid-token");
693
+ return;
694
+ }
695
+
696
+ this.wss?.emit("connection", ws, req);
697
+ });
698
+ });
699
+
700
+ try {
701
+ await new Promise<void>((resolvePromise, rejectPromise) => {
702
+ this.server?.once("error", rejectPromise);
703
+ this.server?.listen(this.config.port, this.config.host, () => resolvePromise());
704
+ });
705
+
706
+ this.latestError = "";
707
+ this.runtimeControlToken = this.generateToken();
708
+ this.markActivity();
709
+ await this.sessionPool.ensureDefaultWorker();
710
+ this.activeRuntimeStatePath = await writePersistedRuntimeState(this.config.host, this.config.port, this.runtimeControlToken);
711
+ this.broadcastStatus();
712
+ this.syncStatusUi();
713
+ } catch (error) {
714
+ const message = error instanceof Error ? error.message : String(error);
715
+ await this.stopServer();
716
+ this.latestError = message;
717
+ this.broadcastStatus();
718
+ this.syncStatusUi();
719
+ throw error;
720
+ }
721
+ }
722
+
723
+ async stopServer() {
724
+ this.clearIdleStopTimer();
725
+
726
+ const runtimeStatePath = this.activeRuntimeStatePath;
727
+ this.runtimeControlToken = "";
728
+
729
+ if (this.sessionPool) {
730
+ await this.sessionPool.dispose();
731
+ this.sessionPool = null;
732
+ }
733
+ this.parentWorker = null;
734
+ this.controlOwner = "cli";
735
+ this.phoneSelectedSessionId = null;
736
+
737
+ if (this.wss) {
738
+ const runningWss = this.wss;
739
+ await new Promise<void>((resolvePromise) => {
740
+ runningWss.close(() => resolvePromise());
741
+ });
742
+ this.wss = null;
743
+ }
744
+
745
+ if (this.server) {
746
+ const runningServer = this.server;
747
+ await new Promise<void>((resolvePromise) => {
748
+ try {
749
+ runningServer.close(() => resolvePromise());
750
+ } catch {
751
+ resolvePromise();
752
+ }
753
+ });
754
+ this.server = null;
755
+ }
756
+
757
+ await removePersistedRuntimeState(runtimeStatePath);
758
+ this.activeRuntimeStatePath = null;
759
+ this.latestError = "";
760
+ this.broadcastStatus();
761
+ this.syncStatusUi();
762
+ }
763
+
764
+ private async handleClientMessage(ws: WebSocket, raw: string) {
765
+ let message: any;
766
+ try {
767
+ message = JSON.parse(raw);
768
+ } catch {
769
+ this.send(ws, { channel: "server", event: "client-error", data: { message: "Invalid JSON from client." } });
770
+ return;
771
+ }
772
+
773
+ if (!this.sessionPool) {
774
+ throw new Error("DM Phone session pool is not running.");
775
+ }
776
+
777
+ if (message.kind === "refresh") {
778
+ await this.sessionPool.refreshActiveSnapshot(ws);
779
+ return;
780
+ }
781
+
782
+ if (message.kind === "session-select") {
783
+ const sessionId = String(message.sessionId || "");
784
+ await this.sessionPool.selectSession(ws, sessionId);
785
+ this.rememberPhoneSelection(this.sessionPool.getSession(sessionId));
786
+ this.releaseParentOwnershipIfAvailable();
787
+ return;
788
+ }
789
+
790
+ if (message.kind === "session-parent-new") {
791
+ if (!this.parentWorker) {
792
+ await this.sessionPool.ensureDefaultWorker();
793
+ }
794
+ const worker = this.parentWorker;
795
+ if (!worker || worker.kind !== "parent") {
796
+ throw new Error("Parent session is not available.");
797
+ }
798
+ if (!(await this.ensurePhoneCanWrite(ws, worker))) {
799
+ return;
800
+ }
801
+ await this.sessionPool.selectSession(ws, worker.id);
802
+ this.sessionPool.setDefaultWorker(worker.id);
803
+ this.rememberPhoneSelection(worker);
804
+ await worker.sendClientCommand({ type: "new_session" }, {
805
+ ws,
806
+ responseCommand: "new_parent_session",
807
+ });
808
+ return;
809
+ }
810
+
811
+ if (message.kind === "session-spawn") {
812
+ const worker = await this.sessionPool.spawnSession(ws);
813
+ this.rememberPhoneSelection(worker);
814
+ this.releaseParentOwnershipIfAvailable();
815
+ this.send(ws, { channel: "server", event: "session-spawned", data: { message: "Opened new parallel session." } });
816
+ return;
817
+ }
818
+
819
+ if (message.kind === "local-command") {
820
+ const worker = await this.getActiveWorkerForClient(ws);
821
+ this.rememberPhoneSelection(worker);
822
+ const localCommandType = message.command && typeof message.command === "object" ? message.command.type : message.command;
823
+ const localCommandMutates = localCommandType === "reload" || localCommandType === "cd" || localCommandType === "slash-command";
824
+ if (localCommandMutates && !(await this.ensurePhoneCanWrite(ws, worker))) {
825
+ return;
826
+ }
827
+
828
+ if (message.command === "reload") {
829
+ try {
830
+ await worker.reload();
831
+ this.send(ws, {
832
+ channel: "rpc",
833
+ payload: {
834
+ type: "response",
835
+ command: "reload",
836
+ success: true,
837
+ data: { sessionFile: worker.currentSessionFile },
838
+ },
839
+ });
840
+ await this.sessionPool.refreshActiveSnapshot(ws);
841
+ } catch (error) {
842
+ this.send(ws, {
843
+ channel: "rpc",
844
+ payload: {
845
+ type: "response",
846
+ command: "reload",
847
+ success: false,
848
+ error: error instanceof Error ? error.message : String(error),
849
+ },
850
+ });
851
+ }
852
+ return;
853
+ }
854
+
855
+ if (message.command && typeof message.command === "object" && message.command.type === "path-suggestions") {
856
+ try {
857
+ const mode = message.command.mode === "cd" ? "cd" : "mention";
858
+ const query = typeof message.command.query === "string" ? message.command.query : "";
859
+ const suggestions = listPhonePathSuggestions(mode, query, worker.cwd, worker.previousCwd);
860
+
861
+ this.send(ws, {
862
+ channel: "rpc",
863
+ payload: {
864
+ type: "response",
865
+ command: "path_suggestions",
866
+ success: true,
867
+ data: {
868
+ mode,
869
+ query,
870
+ cwd: worker.cwd,
871
+ requestId: Number(message.command.requestId) || 0,
872
+ suggestions,
873
+ },
874
+ },
875
+ });
876
+ } catch (error) {
877
+ this.send(ws, {
878
+ channel: "rpc",
879
+ payload: {
880
+ type: "response",
881
+ command: "path_suggestions",
882
+ success: false,
883
+ error: error instanceof Error ? error.message : String(error),
884
+ },
885
+ });
886
+ }
887
+ return;
888
+ }
889
+
890
+ if (message.command && typeof message.command === "object" && message.command.type === "cd") {
891
+ try {
892
+ const args = typeof message.command.args === "string" ? message.command.args : "";
893
+ const nextCwd = resolvePhoneCdTargetPath(args, worker.cwd, worker.previousCwd);
894
+
895
+ if (!existsSync(nextCwd)) {
896
+ throw new Error(`Directory does not exist: ${nextCwd}`);
897
+ }
898
+ if (!statSync(nextCwd).isDirectory()) {
899
+ throw new Error(`Not a directory: ${nextCwd}`);
900
+ }
901
+
902
+ const previousCwd = worker.cwd;
903
+ const slashText = args.trim() ? `/cd ${args}` : "/cd";
904
+ const dispatched = await this.dispatchRemoteSlashCommandForWorker(
905
+ worker,
906
+ ws,
907
+ { text: slashText },
908
+ {
909
+ responseCommand: "cd",
910
+ responseData: { cwd: nextCwd, previousCwd },
911
+ onSuccess: () => {
912
+ worker.setTrackedCwd?.(nextCwd, previousCwd);
913
+ this.sessionPool?.setCwd(nextCwd);
914
+ this.config.cwd = nextCwd;
915
+ },
916
+ },
917
+ );
918
+
919
+ if (!dispatched) return;
920
+ } catch (error) {
921
+ this.send(ws, {
922
+ channel: "rpc",
923
+ payload: {
924
+ type: "response",
925
+ command: "cd",
926
+ success: false,
927
+ error: error instanceof Error ? error.message : String(error),
928
+ },
929
+ });
930
+ }
931
+ return;
932
+ }
933
+
934
+ if (message.command && typeof message.command === "object" && message.command.type === "slash-command") {
935
+ try {
936
+ await this.dispatchRemoteSlashCommandForWorker(worker, ws, {
937
+ text: String(message.command.text || ""),
938
+ images: Array.isArray(message.command.images) ? message.command.images : [],
939
+ streamingBehavior: message.command.streamingBehavior === "steer"
940
+ ? "steer"
941
+ : message.command.streamingBehavior === "followUp"
942
+ ? "followUp"
943
+ : undefined,
944
+ });
945
+ } catch (error) {
946
+ this.send(ws, {
947
+ channel: "rpc",
948
+ payload: {
949
+ type: "response",
950
+ command: "slash_command",
951
+ success: false,
952
+ error: error instanceof Error ? error.message : String(error),
953
+ },
954
+ });
955
+ }
956
+ return;
957
+ }
958
+
959
+ this.send(ws, { channel: "server", event: "client-error", data: { message: "Unsupported local command." } });
960
+ return;
961
+ }
962
+
963
+ if (message.kind !== "rpc" || !message.command || typeof message.command !== "object") {
964
+ this.send(ws, { channel: "server", event: "client-error", data: { message: "Unsupported client command." } });
965
+ return;
966
+ }
967
+
968
+ const command = { ...message.command };
969
+
970
+ if (command.type === "phone_list_sessions") {
971
+ const worker = await this.getActiveWorkerForClient(ws);
972
+ const sessions = await listSessionsForCwd(worker.cwd || this.config.cwd);
973
+ this.send(ws, {
974
+ channel: "rpc",
975
+ payload: {
976
+ type: "response",
977
+ command: "phone_list_sessions",
978
+ success: true,
979
+ data: { sessions, cwd: worker.cwd || this.config.cwd },
980
+ ...(command.id ? { id: command.id } : {}),
981
+ },
982
+ });
983
+ return;
984
+ }
985
+
986
+ const worker = await this.getActiveWorkerForClient(ws);
987
+ this.rememberPhoneSelection(worker);
988
+ const readOnlyCommandTypes = new Set(["get_state", "get_messages", "get_commands", "get_available_models", "get_session_stats", "phone_get_tree", "phone_list_sessions"]);
989
+ if (!readOnlyCommandTypes.has(String(command.type || "")) && !(await this.ensurePhoneCanWrite(ws, worker))) {
990
+ return;
991
+ }
992
+
993
+ if (command.type === "phone_get_tree") {
994
+ const tree = await this.getTreeStateForWorker(worker);
995
+ this.send(ws, {
996
+ channel: "rpc",
997
+ payload: {
998
+ type: "response",
999
+ command: "phone_get_tree",
1000
+ success: true,
1001
+ data: tree,
1002
+ ...(command.id ? { id: command.id } : {}),
1003
+ },
1004
+ });
1005
+ return;
1006
+ }
1007
+
1008
+ if (command.type === "phone_open_branch_path") {
1009
+ const nextPath = await this.createBranchSessionFromEntryForWorker(worker, String(command.entryId || ""));
1010
+ const switchResponse = await worker.request({ type: "switch_session", sessionPath: nextPath });
1011
+ this.send(ws, {
1012
+ channel: "rpc",
1013
+ payload: {
1014
+ type: "response",
1015
+ command: "phone_open_branch_path",
1016
+ success: true,
1017
+ data: { path: nextPath, switchResult: switchResponse.data },
1018
+ ...(command.id ? { id: command.id } : {}),
1019
+ },
1020
+ });
1021
+ await this.sessionPool.refreshActiveSnapshot(ws);
1022
+ this.broadcastStatus();
1023
+ return;
1024
+ }
1025
+
1026
+ await worker.sendClientCommand(command, { ws });
1027
+ }
1028
+
1029
+ updateStatusUi(ctx: AnyCtx) {
1030
+ const theme = ctx.ui.theme;
1031
+ if (this.server) {
1032
+ const dot = theme.fg("success", "●");
1033
+ const label = theme.fg("muted", " phone on");
1034
+ ctx.ui.setStatus("dm-phone", `📱 ${dot}${label}`);
1035
+ } else {
1036
+ const dot = theme.fg("dim", "○");
1037
+ const label = theme.fg("dim", " phone off");
1038
+ ctx.ui.setStatus("dm-phone", `📱 ${dot}${label}`);
1039
+ }
1040
+ }
1041
+
1042
+ syncStatusUi() {
1043
+ if (!this.latestCtx) return;
1044
+ this.updateStatusUi(this.latestCtx);
1045
+ }
1046
+
1047
+ statusText() {
1048
+ const url = `http://${this.config.host}:${this.config.port}`;
1049
+ const idleMinutes = this.config.idleTimeoutMs > 0 ? `${Math.max(1, Math.round(this.config.idleTimeoutMs / 60_000))}m idle auto-stop` : "idle auto-stop disabled";
1050
+ return this.server
1051
+ ? `DM Phone running at ${url} for ${this.config.cwd}${this.config.token ? " (token enabled)" : " (no token)"} · mirroring the current CLI session with optional parallel sessions · owner: ${this.controlOwner} · ${idleMinutes}`
1052
+ : "DM Phone is stopped";
1053
+ }
1054
+
1055
+ async handlePhoneStart(args: string | undefined, ctx: ExtensionCommandContext) {
1056
+ this.captureCtx(ctx);
1057
+ this.config.cwd = this.activeCwd();
1058
+ const parsed = parsePhoneStartArgs(args, this.config);
1059
+ const nextConfig = parsed.config;
1060
+
1061
+ if (!nextConfig.token && !parsed.tokenSpecified) {
1062
+ nextConfig.token = this.generateToken();
1063
+ }
1064
+
1065
+ const changed = ["host", "port", "token", "cwd", "idleTimeoutMs"].some(
1066
+ (key) => nextConfig[key as keyof PhoneConfig] !== this.config[key as keyof PhoneConfig],
1067
+ );
1068
+ const generatedToken = nextConfig.token && nextConfig.token !== this.config.token && !parsed.tokenSpecified;
1069
+ this.config = nextConfig;
1070
+
1071
+ if (this.server && changed) {
1072
+ await this.stopServer();
1073
+ }
1074
+
1075
+ if (!this.server) {
1076
+ try {
1077
+ await this.startServer();
1078
+ } catch (error) {
1079
+ if (isAddressInUseError(error)) {
1080
+ this.latestError = error instanceof Error ? error.message : String(error);
1081
+ this.updateStatusUi(ctx);
1082
+ const existingRuntime = await readPersistedRuntimeState(this.config.host, this.config.port);
1083
+ ctx.ui.notify(
1084
+ existingRuntime
1085
+ ? `Another DM Phone instance is already using ${this.config.host}:${this.config.port}. Run /phone-stop, then /phone-start again.`
1086
+ : `Port ${this.config.host}:${this.config.port} is already in use. If it is another DM Phone instance, run /phone-stop, then /phone-start again.`,
1087
+ "warning",
1088
+ );
1089
+ return;
1090
+ }
1091
+ throw error;
1092
+ }
1093
+ }
1094
+
1095
+ await this.sessionPool?.ensureDefaultWorker();
1096
+ const tailscale = await enableTailscaleServe(this.pi, this.config.port);
1097
+ this.updateStatusUi(ctx);
1098
+ ctx.ui.notify(this.statusText(), "info");
1099
+ if (tailscale.enabled) {
1100
+ if (tailscale.changed) {
1101
+ ctx.ui.notify(`Tailscale Serve ready${tailscale.url ? `: ${tailscale.url}` : " for this device."}`, "info");
1102
+ if (tailscale.replacedExisting) {
1103
+ ctx.ui.notify("Updated the current Tailscale Serve web route to point to DM Phone.", "warning");
1104
+ }
1105
+ } else {
1106
+ ctx.ui.notify(`Tailscale Serve already points to DM Phone${tailscale.url ? `: ${tailscale.url}` : "."}`, "info");
1107
+ }
1108
+ } else if (tailscale.error) {
1109
+ ctx.ui.notify(`Could not configure Tailscale Serve automatically: ${tailscale.error}`, "warning");
1110
+ ctx.ui.notify(`Manual fallback: tailscale serve --bg --https=443 http://127.0.0.1:${this.config.port}`, "info");
1111
+ }
1112
+ if (generatedToken) {
1113
+ ctx.ui.notify(`Generated token: ${this.config.token}`, "warning");
1114
+ } else if (this.config.token) {
1115
+ ctx.ui.notify("Token required: use the token you started this server with.", "info");
1116
+ }
1117
+ }
1118
+
1119
+ async handlePhoneStop(ctx: ExtensionCommandContext) {
1120
+ this.captureCtx(ctx);
1121
+ const hadLocalServer = Boolean(this.server);
1122
+ await this.stopServer();
1123
+ const externalStop = hadLocalServer ? null : await stopPersistedRuntime(this.config.host, this.config.port);
1124
+ const tailscale = await disableMatchingTailscaleServe(this.pi, this.config.port);
1125
+ this.updateStatusUi(ctx);
1126
+
1127
+ if (hadLocalServer || externalStop?.stopped) {
1128
+ if (tailscale.disabled) {
1129
+ ctx.ui.notify("DM Phone stopped and matching Tailscale Serve route disabled", "info");
1130
+ } else {
1131
+ ctx.ui.notify("DM Phone stopped", "info");
1132
+ if (tailscale.error) {
1133
+ ctx.ui.notify(`Could not disable Tailscale Serve automatically: ${tailscale.error}`, "warning");
1134
+ }
1135
+ }
1136
+ return;
1137
+ }
1138
+
1139
+ if (externalStop?.found && externalStop.message) {
1140
+ const kind = externalStop.message.startsWith("Removed stale") ? "info" : "warning";
1141
+ ctx.ui.notify(externalStop.message, kind);
1142
+ } else {
1143
+ ctx.ui.notify("DM Phone is already stopped.", "info");
1144
+ }
1145
+
1146
+ if (tailscale.disabled) {
1147
+ ctx.ui.notify("Disabled the matching Tailscale Serve route.", "info");
1148
+ } else if (tailscale.error) {
1149
+ ctx.ui.notify(`Could not disable Tailscale Serve automatically: ${tailscale.error}`, "warning");
1150
+ }
1151
+ }
1152
+
1153
+ async handlePhoneStatus(ctx: ExtensionCommandContext) {
1154
+ this.captureCtx(ctx);
1155
+ this.updateStatusUi(ctx);
1156
+ ctx.ui.notify(this.statusText(), this.server ? "info" : "warning");
1157
+
1158
+ const tailscale = await getTailscaleServeInfo(this.pi, this.config.port);
1159
+ if (tailscale.active) {
1160
+ if (this.server) {
1161
+ ctx.ui.notify(`Tailscale Serve: ${tailscale.url || "enabled for DM Phone"}`, "info");
1162
+ } else {
1163
+ ctx.ui.notify(`Tailscale Serve is still pointing at DM Phone${tailscale.url ? `: ${tailscale.url}` : "."}`, "warning");
1164
+ }
1165
+ } else if (this.server) {
1166
+ if (tailscale.error) {
1167
+ ctx.ui.notify(`Tailscale Serve check failed: ${tailscale.error}`, "warning");
1168
+ } else {
1169
+ ctx.ui.notify("Tailscale Serve is not currently pointing to DM Phone.", "warning");
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ handlePhoneToken(ctx: ExtensionCommandContext) {
1175
+ this.captureCtx(ctx);
1176
+ if (this.config.token) {
1177
+ ctx.ui.notify(`DM Phone token: ${this.config.token}`, "warning");
1178
+ } else {
1179
+ ctx.ui.notify("DM Phone token is disabled for this server.", "info");
1180
+ }
1181
+ }
1182
+
1183
+ async handleSessionStart(ctx: ExtensionContext) {
1184
+ this.captureCtx(ctx);
1185
+ if (!this.server) {
1186
+ this.config.cwd = this.activeCwd();
1187
+ } else {
1188
+ this.parentWorker?.captureContext(ctx, { emitSnapshot: true });
1189
+ if (!this.phoneSelectedSessionId || !this.sessionPool?.getSession(this.phoneSelectedSessionId)) {
1190
+ this.rememberPhoneSelection(this.parentWorker);
1191
+ }
1192
+ }
1193
+ this.updateStatusUi(ctx);
1194
+ this.broadcastStatus();
1195
+ }
1196
+
1197
+ async handleSessionSwitch(ctx: ExtensionContext) {
1198
+ this.captureCtx(ctx);
1199
+ if (!this.server) {
1200
+ this.config.cwd = this.activeCwd();
1201
+ } else {
1202
+ this.parentWorker?.captureContext(ctx, { emitSnapshot: true });
1203
+ if (!this.phoneSelectedSessionId || this.phoneSelectedSessionId === this.parentWorker?.id) {
1204
+ this.rememberPhoneSelection(this.parentWorker);
1205
+ }
1206
+ }
1207
+ this.updateStatusUi(ctx);
1208
+ this.broadcastStatus();
1209
+ }
1210
+
1211
+ async handleSessionShutdown(ctx: ExtensionContext) {
1212
+ this.captureCtx(ctx);
1213
+ await this.stopServer();
1214
+ await disableMatchingTailscaleServe(this.pi, this.config.port);
1215
+ this.updateStatusUi(ctx);
1216
+ }
1217
+ }