dsh-dispatch 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1240 @@
1
+ // src/approvals.ts
2
+ import { randomUUID } from "crypto";
3
+
4
+ // ../shared/src/crypto.ts
5
+ import nacl from "tweetnacl";
6
+ var encoder = new TextEncoder();
7
+ var decoder = new TextDecoder();
8
+ var KEY_PREFIX = encoder.encode("dsh-dispatch/key");
9
+ var ROOM_PREFIX = encoder.encode("dsh-dispatch/room");
10
+ var NONCE_LENGTH = nacl.secretbox.nonceLength;
11
+ function concat(a, b) {
12
+ const out = new Uint8Array(a.length + b.length);
13
+ out.set(a, 0);
14
+ out.set(b, a.length);
15
+ return out;
16
+ }
17
+ var B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
18
+ function toBase64(bytes) {
19
+ let out = "";
20
+ for (let i = 0; i < bytes.length; i += 3) {
21
+ const b0 = bytes[i];
22
+ const b1 = bytes[i + 1];
23
+ const b2 = bytes[i + 2];
24
+ out += B64_ALPHABET[b0 >> 2];
25
+ out += B64_ALPHABET[(b0 & 3) << 4 | (b1 ?? 0) >> 4];
26
+ out += b1 === void 0 ? "=" : B64_ALPHABET[(b1 & 15) << 2 | (b2 ?? 0) >> 6];
27
+ out += b2 === void 0 ? "=" : B64_ALPHABET[b2 & 63];
28
+ }
29
+ return out;
30
+ }
31
+ function fromBase64(text) {
32
+ const clean = text.replace(/=+$/, "");
33
+ if (!/^[A-Za-z0-9+/]*$/.test(clean)) return null;
34
+ const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
35
+ let bits = 0;
36
+ let value = 0;
37
+ let index = 0;
38
+ for (const char of clean) {
39
+ value = value << 6 | B64_ALPHABET.indexOf(char);
40
+ bits += 6;
41
+ if (bits >= 8) {
42
+ bits -= 8;
43
+ out[index++] = value >> bits & 255;
44
+ }
45
+ }
46
+ return out.slice(0, index);
47
+ }
48
+ function toBase64Url(bytes) {
49
+ return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
50
+ }
51
+ function generateSecret() {
52
+ return nacl.randomBytes(32);
53
+ }
54
+ function deriveKey(secret) {
55
+ return nacl.hash(concat(KEY_PREFIX, secret)).slice(0, nacl.secretbox.keyLength);
56
+ }
57
+ function deriveRoomId(secret) {
58
+ return toBase64Url(nacl.hash(concat(ROOM_PREFIX, secret)).slice(0, 16));
59
+ }
60
+ function seal(message, key) {
61
+ const nonce = nacl.randomBytes(NONCE_LENGTH);
62
+ const box = nacl.secretbox(encoder.encode(JSON.stringify(message)), nonce, key);
63
+ return toBase64(concat(nonce, box));
64
+ }
65
+ function open(payload, key) {
66
+ const bytes = fromBase64(payload);
67
+ if (bytes === null || bytes.length < NONCE_LENGTH + nacl.secretbox.overheadLength) return null;
68
+ const box = nacl.secretbox.open(bytes.slice(NONCE_LENGTH), bytes.slice(0, NONCE_LENGTH), key);
69
+ if (box === null) return null;
70
+ try {
71
+ return JSON.parse(decoder.decode(box));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ function encodePairing(info) {
77
+ const json = JSON.stringify({
78
+ v: 1,
79
+ relay: info.relay,
80
+ secret: toBase64Url(info.secret),
81
+ machine: info.machine
82
+ });
83
+ return toBase64Url(encoder.encode(json));
84
+ }
85
+
86
+ // ../shared/src/limits.ts
87
+ var MAX_ENVELOPE_BYTES = 16 * 1024;
88
+ var MAX_DETAIL_CHARS = 8 * 1024;
89
+ var MAX_SUMMARY_CHARS = 4 * 1024;
90
+ var MAX_PROMPT_CHARS = 8 * 1024;
91
+ var MAX_PROMPT_BYTES = 10 * 1024;
92
+ var MAX_FIELD_BYTES = 10 * 1024;
93
+ var MAX_PUSH_BYTES = 3 * 1024;
94
+ var TRUNCATION_SUFFIX = "\u2026[truncated]";
95
+ function truncate(text, maxChars) {
96
+ if (text.length <= maxChars) return text;
97
+ return text.slice(0, maxChars - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
98
+ }
99
+ var byteEncoder = new TextEncoder();
100
+ function utf8Length(text) {
101
+ return byteEncoder.encode(text).length;
102
+ }
103
+ function clampBytes(text, maxBytes) {
104
+ if (utf8Length(text) <= maxBytes) return text;
105
+ const budget = maxBytes - utf8Length(TRUNCATION_SUFFIX);
106
+ let low = 0;
107
+ let high = text.length;
108
+ while (low < high) {
109
+ const mid = Math.ceil((low + high) / 2);
110
+ if (utf8Length(text.slice(0, mid)) <= budget) low = mid;
111
+ else high = mid - 1;
112
+ }
113
+ const tail = text.charCodeAt(low - 1);
114
+ if (tail >= 55296 && tail <= 56319) low -= 1;
115
+ return text.slice(0, low) + TRUNCATION_SUFFIX;
116
+ }
117
+
118
+ // src/approvals.ts
119
+ var TITLE_CHARS = 200;
120
+ var REMINDER_MS = 5 * 6e4;
121
+ var MAX_REMINDERS = 6;
122
+ function installApprovals(hub) {
123
+ const pending = /* @__PURE__ */ new Map();
124
+ hub.ctx.on("approval/request", (request, next) => {
125
+ if (!hub.pairing.everPaired || !hub.relay.connected) return next();
126
+ if (request.signal?.aborted === true) return Promise.resolve("cancelled");
127
+ return forward(hub, pending, request, next);
128
+ });
129
+ return {
130
+ respond(message) {
131
+ const entry = pending.get(message.approvalId);
132
+ if (entry === void 0) {
133
+ hub.relay.send(closedMsg(message.approvalId, "expired"));
134
+ return;
135
+ }
136
+ entry.settle(message.decision === "allow" ? "allowed-once" : "rejected", message.decision);
137
+ },
138
+ resendPending() {
139
+ for (const entry of pending.values()) hub.relay.send(entry.frame, entry.push);
140
+ },
141
+ openCount: () => pending.size
142
+ };
143
+ }
144
+ function forward(hub, pending, request, next) {
145
+ const approvalId = randomUUID();
146
+ const sessionId = request.agent.id;
147
+ const frame = buildRequestMsg(approvalId, sessionId, request);
148
+ const push = buildPush(sessionId, frame.title);
149
+ if (!hub.relay.send(frame, push)) {
150
+ hub.report("approval", `\u5BA1\u6279 ${frame.title} \u672A\u80FD\u63A8\u9001\u5230\u624B\u673A\uFF08relay \u672A\u8FDE\u63A5\uFF09\uFF0C\u5DF2\u4EA4\u56DE\u672C\u673A\u5904\u7406`);
151
+ return next();
152
+ }
153
+ return race(hub, pending, { approvalId, sessionId, frame, push }, request, next);
154
+ }
155
+ function race(hub, pending, forwarded, request, next) {
156
+ const { approvalId, sessionId } = forwarded;
157
+ return new Promise((resolve3) => {
158
+ let settled = false;
159
+ const settle = (outcome, resolution) => {
160
+ if (settled) return;
161
+ settled = true;
162
+ const entry2 = pending.get(approvalId);
163
+ if (entry2?.timer !== void 0) clearTimeout(entry2.timer);
164
+ pending.delete(approvalId);
165
+ request.signal?.removeEventListener("abort", onAbort);
166
+ hub.sessions.approvalClosed(sessionId);
167
+ hub.relay.send(closedMsg(approvalId, resolution));
168
+ resolve3(outcome);
169
+ };
170
+ function onAbort() {
171
+ settle("cancelled", "superseded");
172
+ }
173
+ request.signal?.addEventListener("abort", onAbort, { once: true });
174
+ const entry = { ...forwarded, settle, reminders: 0, timer: void 0 };
175
+ pending.set(approvalId, entry);
176
+ hub.sessions.approvalOpened(sessionId);
177
+ armReminder(hub, entry);
178
+ void next().then(
179
+ (outcome) => {
180
+ if (outcome !== "unavailable") settle(outcome, "local");
181
+ },
182
+ (error) => {
183
+ hub.fail(`approval ${approvalId}: local answerer failed`, error);
184
+ }
185
+ );
186
+ });
187
+ }
188
+ function armReminder(hub, entry) {
189
+ entry.timer = setTimeout(() => {
190
+ entry.timer = void 0;
191
+ entry.reminders += 1;
192
+ hub.relay.send(entry.frame, entry.push);
193
+ if (entry.reminders >= MAX_REMINDERS) {
194
+ hub.log.warn(
195
+ "approval %s still unanswered after %d reminders; it stays open (never auto-approved)",
196
+ entry.frame.approvalId,
197
+ entry.reminders
198
+ );
199
+ return;
200
+ }
201
+ armReminder(hub, entry);
202
+ }, REMINDER_MS);
203
+ entry.timer.unref?.();
204
+ }
205
+ function buildRequestMsg(approvalId, sessionId, request) {
206
+ return {
207
+ v: 1,
208
+ ts: Date.now(),
209
+ type: "approval.request",
210
+ approvalId,
211
+ sessionId,
212
+ title: truncate(request.toolName, TITLE_CHARS),
213
+ // Chars first (protocol conformance), then bytes: 8 192 chars of CJK is
214
+ // ~24 KB of UTF-8, which base64 would expand past the 16 KB envelope.
215
+ detail: clampBytes(truncate(buildDetail(request), MAX_DETAIL_CHARS), MAX_FIELD_BYTES),
216
+ createdAt: Date.now()
217
+ };
218
+ }
219
+ function buildDetail(request) {
220
+ const parts = [`\u5DE5\u5177: ${request.toolName}`];
221
+ if (request.reason !== void 0 && request.reason !== "") parts.push(`\u539F\u56E0: ${request.reason}`);
222
+ const args = toolCallArguments(request.agent, request.callId);
223
+ parts.push(args === void 0 ? "\u53C2\u6570: (\u8C03\u7528\u672A\u8BB0\u5F55\u53C2\u6570)" : `\u53C2\u6570:
224
+ ${args}`);
225
+ return parts.join("\n\n");
226
+ }
227
+ function toolCallArguments(agent, callId) {
228
+ if (callId === void 0) return void 0;
229
+ const events = agent.session.events;
230
+ for (let index = events.length - 1; index >= 0; index -= 1) {
231
+ const event = events[index];
232
+ if (event?.type === "tool/call" && event.data.callId === callId) return event.data.arguments;
233
+ }
234
+ return void 0;
235
+ }
236
+ function buildPush(sessionId, title) {
237
+ return { v: 1, ts: Date.now(), type: "push", kind: "approval", sessionId, title };
238
+ }
239
+ function closedMsg(approvalId, resolution) {
240
+ return { v: 1, ts: Date.now(), type: "approval.closed", approvalId, resolution };
241
+ }
242
+
243
+ // src/commands.ts
244
+ function installCommands(hub, deps) {
245
+ hub.ctx.effect(() => hub.ctx.commands.register({
246
+ name: "dispatch-pair",
247
+ description: "Show the dsh-dispatch pairing code for this machine",
248
+ handler: () => pairingResult(hub)
249
+ }), "dsh-dispatch: /dispatch-pair");
250
+ hub.ctx.effect(() => hub.ctx.commands.register({
251
+ name: "dispatch-repair",
252
+ description: "Generate a new dsh-dispatch pairing secret (invalidates every paired phone)",
253
+ handler: () => {
254
+ hub.relay.rekey(hub.pairing.regenerate());
255
+ hub.log.warn("pairing secret regenerated; every previously paired phone is now locked out");
256
+ return pairingResult(hub, "\u65E7\u914D\u5BF9\u7801\u5DF2\u4F5C\u5E9F\uFF0C\u8BF7\u5728\u6BCF\u53F0\u624B\u673A\u4E0A\u91CD\u65B0\u626B\u7801\u3002\n\n");
257
+ }
258
+ }), "dsh-dispatch: /dispatch-repair");
259
+ hub.ctx.effect(() => hub.ctx.commands.register({
260
+ name: "dispatch-status",
261
+ description: "Show the dsh-dispatch relay connection and remote session state",
262
+ handler: () => ({ kind: "success", text: statusText(hub, deps) })
263
+ }), "dsh-dispatch: /dispatch-status");
264
+ }
265
+ function pairingResult(hub, prefix = "") {
266
+ if (hub.config.relay === "") {
267
+ return {
268
+ kind: "error",
269
+ text: "relay \u672A\u914D\u7F6E\uFF1A\u8BF7\u5728 profile \u7684 cordis.patch.yml \u4E2D\u4E3A dsh-dispatch \u8BBE\u7F6E relay: 'wss://\u2026'"
270
+ };
271
+ }
272
+ const code = hub.pairing.pairingCode(hub.config.relay, hub.config.machineName);
273
+ return {
274
+ kind: "success",
275
+ text: `${prefix}\u914D\u5BF9\u7801\uFF08${hub.config.machineName}\uFF09\uFF1A
276
+ ${code}
277
+
278
+ \u6216\u5728\u624B\u673A\u4E0A\u6253\u5F00\uFF1A
279
+ ${hub.config.pwaUrl}/#pair=${code}
280
+
281
+ \u26A0\uFE0F \u6B64\u7801\u7B49\u540C\u4E8E\u672C\u673A\u7684\u63A7\u5236\u6743\uFF1A\u6301\u6709\u8005\u53EF\u4EE5\u6279\u51C6\u5DE5\u5177\u8C03\u7528\u5E76\u5728\u5141\u8BB8\u76EE\u5F55\u4E2D\u6D3E\u53D1\u4EFB\u52A1\u3002\u4E0D\u8981\u53D1\u5230\u7FA4\u91CC\u6216\u622A\u56FE\u5916\u4F20\uFF1B\u4E00\u65E6\u6CC4\u9732\u7ACB\u5373\u8FD0\u884C /dispatch-repair\u3002`
282
+ };
283
+ }
284
+ function statusText(hub, deps) {
285
+ const status = hub.relay.status();
286
+ const link = hub.config.relay === "" ? "\u672A\u914D\u7F6E\uFF08\u8BF7\u8BBE\u7F6E relay\uFF09" : status.connected ? `\u5DF2\u8FDE\u63A5 ${hub.config.relay}` : status.gaveUp ? `\u8FDE\u63A5\u5931\u8D25 ${String(status.consecutiveFailures)} \u6B21\uFF0C\u4ECD\u5728\u6BCF 60s \u91CD\u8BD5 \u2014 \u8BF7\u68C0\u67E5 relay` : `\u672A\u8FDE\u63A5\uFF08\u91CD\u8BD5\u4E2D\uFF0C\u5DF2\u5931\u8D25 ${String(status.consecutiveFailures)} \u6B21\uFF09`;
287
+ return [
288
+ `relay: ${link}`,
289
+ `\u623F\u95F4: ${status.room.slice(0, 8)}\u2026`,
290
+ `\u5DF2\u914D\u5BF9: ${hub.pairing.everPaired ? "yes" : "no"}${status.phoneOnline ? "\uFF08\u624B\u673A\u5728\u7EBF\uFF09" : "\uFF08\u624B\u673A\u79BB\u7EBF\uFF09"}`,
291
+ `\u5F85\u6279\u5BA1\u6279: ${String(deps.approvals.openCount())}`,
292
+ `\u6D3E\u53D1\u4F1A\u8BDD: ${String(deps.dispatch.dispatchedCount())}`,
293
+ `\u89E3\u5BC6\u5931\u8D25: ${String(status.decryptFailures)}${status.decryptFailures > 0 ? " \u2014 \u5BC6\u94A5\u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u914D\u5BF9" : ""}`,
294
+ `allowedRoots: ${hub.config.allowedRoots.length === 0 ? "(\u7A7A \u2014 \u8FDC\u7A0B\u6D3E\u4EFB\u52A1\u5DF2\u7981\u7528)" : hub.config.allowedRoots.join(", ")}`
295
+ ].join("\n");
296
+ }
297
+
298
+ // src/dispatch.ts
299
+ import { randomUUID as randomUUID2 } from "crypto";
300
+ import { isAbsolute, resolve as resolve2, sep } from "path";
301
+ import { installModelSelection } from "@deepseek-ai/dsh-agent";
302
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
303
+ import { SessionId } from "@deepseek-ai/dsh-session";
304
+
305
+ // src/pairing.ts
306
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
307
+ import { homedir } from "os";
308
+ import { join, resolve } from "path";
309
+ var SECRET_FILE = "secret";
310
+ var PAIRED_FILE = "paired.json";
311
+ var SECRET_BYTES = 32;
312
+ function expandHome(input) {
313
+ if (input === "~") return homedir();
314
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
315
+ return resolve(input);
316
+ }
317
+ var PairingStore = class _PairingStore {
318
+ dataDir;
319
+ #secret;
320
+ #pairedAt;
321
+ constructor(dataDir, secret, pairedAt) {
322
+ this.dataDir = dataDir;
323
+ this.#secret = secret;
324
+ this.#pairedAt = pairedAt;
325
+ }
326
+ /** Load the stored secret, generating and persisting one on first start. */
327
+ static open(dataDir, log) {
328
+ const dir = expandHome(dataDir);
329
+ mkdirSync(dir, { recursive: true, mode: 448 });
330
+ const secretPath = join(dir, SECRET_FILE);
331
+ let secret = null;
332
+ if (existsSync(secretPath)) {
333
+ secret = fromBase64(readFileSync(secretPath, "utf8").trim());
334
+ if (secret === null || secret.length !== SECRET_BYTES) {
335
+ throw new Error(
336
+ `dsh-dispatch: ${secretPath} is not a valid pairing secret. Delete the file to generate a new one (this invalidates existing pairings).`
337
+ );
338
+ }
339
+ } else {
340
+ secret = generateSecret();
341
+ writeSecret(secretPath, secret);
342
+ }
343
+ return new _PairingStore(dir, secret, readPairedAt(join(dir, PAIRED_FILE), log));
344
+ }
345
+ get secret() {
346
+ return this.#secret;
347
+ }
348
+ /**
349
+ * Whether a phone has ever completed a pairing with this machine. Approval
350
+ * forwarding stays out of the way entirely until this is true, so an
351
+ * unpaired machine pays zero added approval latency.
352
+ */
353
+ get everPaired() {
354
+ return this.#pairedAt !== void 0;
355
+ }
356
+ get pairedAt() {
357
+ return this.#pairedAt;
358
+ }
359
+ /** Record the first sighting of a phone in this room. Idempotent. */
360
+ markPaired() {
361
+ if (this.#pairedAt !== void 0) return;
362
+ this.#pairedAt = Date.now();
363
+ writeFileSync(join(this.dataDir, PAIRED_FILE), JSON.stringify({ pairedAt: this.#pairedAt }), {
364
+ mode: 384
365
+ });
366
+ }
367
+ /** Replace the secret, invalidating every existing pairing. */
368
+ regenerate() {
369
+ this.#secret = generateSecret();
370
+ writeSecret(join(this.dataDir, SECRET_FILE), this.#secret);
371
+ this.#pairedAt = void 0;
372
+ rmSync(join(this.dataDir, PAIRED_FILE), { force: true });
373
+ return this.#secret;
374
+ }
375
+ /** The out-of-band pairing payload: relay + secret + machine name. */
376
+ pairingCode(relay, machine) {
377
+ return encodePairing({ relay, secret: this.#secret, machine });
378
+ }
379
+ };
380
+ function writeSecret(path, secret) {
381
+ writeFileSync(path, toBase64(secret), { mode: 384 });
382
+ chmodSync(path, 384);
383
+ }
384
+ function readPairedAt(path, log) {
385
+ if (!existsSync(path)) return void 0;
386
+ try {
387
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
388
+ const value = parsed.pairedAt;
389
+ return typeof value === "number" ? value : void 0;
390
+ } catch (error) {
391
+ log.error("pairing: %s is unreadable, treating this machine as never paired: %s", path, error);
392
+ return void 0;
393
+ }
394
+ }
395
+
396
+ // src/worktree.ts
397
+ import { execFile } from "child_process";
398
+ import { randomBytes } from "crypto";
399
+ import { join as join2 } from "path";
400
+ import { promisify } from "util";
401
+ var run = promisify(execFile);
402
+ var STDERR_TAIL = 500;
403
+ var WorktreeError = class extends Error {
404
+ constructor(message) {
405
+ super(message);
406
+ this.name = "WorktreeError";
407
+ }
408
+ };
409
+ async function prepareWorkspace(options) {
410
+ if (!options.worktree) return { cwd: options.cwd };
411
+ if (!await isGitRepo(options.cwd)) {
412
+ return {
413
+ cwd: options.cwd,
414
+ note: `\u5DF2\u964D\u7EA7\uFF1A${options.cwd} \u4E0D\u662F git \u4ED3\u5E93\uFF0C\u4EFB\u52A1\u76F4\u63A5\u5728\u8BE5\u76EE\u5F55\u8FD0\u884C\uFF08\u672A\u521B\u5EFA worktree\uFF09`
415
+ };
416
+ }
417
+ const root = join2(options.dataDir, "worktrees");
418
+ try {
419
+ return await addWorktree(options.cwd, root, freshSlug());
420
+ } catch (error) {
421
+ const stderr = stderrOf(error);
422
+ if (!isCollision(stderr)) throw new WorktreeError(stderr);
423
+ return await addWorktree(options.cwd, root, freshSlug()).catch((retry) => {
424
+ throw new WorktreeError(stderrOf(retry));
425
+ });
426
+ }
427
+ }
428
+ async function addWorktree(cwd, root, slug) {
429
+ const target = join2(root, slug);
430
+ const branch = `dsh-dispatch/${slug}`;
431
+ await run("git", ["-C", cwd, "worktree", "add", target, "-b", branch]);
432
+ return { cwd: target, branch };
433
+ }
434
+ async function isGitRepo(cwd) {
435
+ try {
436
+ const { stdout } = await run("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"]);
437
+ return stdout.trim() === "true";
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+ function freshSlug() {
443
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\..+$/, "");
444
+ return `${stamp}-${randomBytes(3).toString("hex")}`;
445
+ }
446
+ function isCollision(stderr) {
447
+ return /already exists|already used by worktree|already checked out/i.test(stderr);
448
+ }
449
+ function stderrOf(error) {
450
+ const raw = error.stderr;
451
+ const text = typeof raw === "string" && raw.trim() !== "" ? raw.trim() : error instanceof Error ? error.message : String(error);
452
+ return text.length <= STDERR_TAIL ? text : text.slice(-STDERR_TAIL);
453
+ }
454
+
455
+ // src/dispatch.ts
456
+ var SEEN_LIMIT = 256;
457
+ var DISPATCH_DISABLED = "dispatch \u672A\u542F\u7528\uFF1A\u8BF7\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u8BBE\u7F6E allowedRoots";
458
+ var NO_REMOTE_FOLLOWUP = "\u8BE5\u4F1A\u8BDD\u4E0D\u652F\u6301\u8FDC\u7A0B\u8FFD\u52A0";
459
+ function installDispatch(hub) {
460
+ const state = {
461
+ handles: /* @__PURE__ */ new Map(),
462
+ seen: new ResultCache(),
463
+ inflight: /* @__PURE__ */ new Map(),
464
+ loaderReady: void 0
465
+ };
466
+ hub.ctx.on("agent/turn-stopping", ({ agent }) => {
467
+ if (!hub.sessions.isDispatched(agent.id)) return;
468
+ hub.sessions.markTurnComplete(agent.id);
469
+ void reportTurnFinal(hub, agent.id).catch((error) => {
470
+ hub.fail(`turn.final for ${agent.id}`, error);
471
+ });
472
+ });
473
+ hub.ctx.on("agent/disposed", ({ agent }) => {
474
+ state.handles.delete(agent.id);
475
+ });
476
+ return {
477
+ request: (message) => handleRequest(hub, state, message),
478
+ followup: (message) => handleFollowup(hub, state, message),
479
+ dispatchedCount: () => state.handles.size
480
+ };
481
+ }
482
+ async function handleRequest(hub, state, message) {
483
+ const replay = state.seen.get(message.requestId);
484
+ if (replay !== void 0) {
485
+ if (replay !== null) hub.relay.send(replay);
486
+ return;
487
+ }
488
+ const pending = state.inflight.get(message.requestId) ?? startSession(hub, state, message);
489
+ state.inflight.set(message.requestId, pending);
490
+ try {
491
+ const result = await pending;
492
+ state.seen.set(message.requestId, result);
493
+ hub.relay.send(result);
494
+ } finally {
495
+ state.inflight.delete(message.requestId);
496
+ }
497
+ }
498
+ async function startSession(hub, state, message) {
499
+ const prompt = message.prompt;
500
+ const oversized = overLimit("prompt", prompt);
501
+ if (oversized !== void 0) return failure(message.requestId, oversized);
502
+ const target = resolveCwd(hub, message.cwd);
503
+ if (typeof target !== "string") return failure(message.requestId, target.error);
504
+ try {
505
+ const workspace = await prepareWorkspace({
506
+ cwd: target,
507
+ dataDir: hub.pairing.dataDir,
508
+ worktree: message.worktree
509
+ });
510
+ state.loaderReady ??= Promise.resolve(hub.ctx.get("loader")?.await()).then(() => void 0);
511
+ await state.loaderReady;
512
+ const handle = await createSession(hub.ctx, workspace.cwd, prompt);
513
+ state.handles.set(handle.agent.id, handle);
514
+ hub.sessions.markDispatched(handle.agent.id, prompt);
515
+ const result = ok(message.requestId, handle.agent.id);
516
+ return workspace.note === void 0 ? result : { ...result, note: workspace.note };
517
+ } catch (error) {
518
+ const detail = error instanceof WorktreeError ? error.message : String(error);
519
+ hub.log.error("dispatch %s failed: %s", message.requestId, detail);
520
+ return failure(message.requestId, detail);
521
+ }
522
+ }
523
+ async function handleFollowup(hub, state, message) {
524
+ const oversized = overLimit("\u6D88\u606F", message.text);
525
+ if (oversized !== void 0) {
526
+ hub.report(`session.message ${message.sessionId}`, oversized);
527
+ return;
528
+ }
529
+ if (state.seen.get(message.requestId) !== void 0) return;
530
+ state.seen.set(message.requestId, null);
531
+ const agent = state.handles.get(message.sessionId)?.agent ?? hub.sessions.agentOf(message.sessionId);
532
+ if (agent === void 0) {
533
+ hub.report(`session.message ${message.sessionId}`, NO_REMOTE_FOLLOWUP);
534
+ return;
535
+ }
536
+ agent.followup(createUserMessage({
537
+ content: [{ type: "text", text: message.text }],
538
+ source: { kind: "user" }
539
+ }));
540
+ }
541
+ function overLimit(field, text) {
542
+ const tail = "\u8BF7\u5728\u624B\u673A\u7AEF\u7F29\u77ED\u540E\u91CD\u53D1\u3002";
543
+ if (text.length > MAX_PROMPT_CHARS) {
544
+ return `${field} \u8D85\u957F\uFF1A${String(text.length)} \u5B57\u7B26\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_CHARS)} \u5B57\u7B26\u3002${tail}`;
545
+ }
546
+ const bytes = utf8Length(text);
547
+ if (bytes > MAX_PROMPT_BYTES) {
548
+ return `${field} \u8D85\u957F\uFF1A${String(bytes)} \u5B57\u8282\uFF08UTF-8\uFF09\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_BYTES)} \u5B57\u8282\u3002${tail}`;
549
+ }
550
+ return void 0;
551
+ }
552
+ async function composeAgent(ctx, selection) {
553
+ const install = (agentCtx) => {
554
+ if (selection === void 0) return;
555
+ installModelSelection(agentCtx, { current: selection, assembled: void 0 });
556
+ };
557
+ const presets = ctx.get("agentPresets");
558
+ if (presets === void 0) {
559
+ return { setup: (agentCtx) => {
560
+ install(agentCtx);
561
+ return Promise.resolve();
562
+ } };
563
+ }
564
+ const resolvedId = (await presets.resolve()).id;
565
+ return {
566
+ agentPreset: resolvedId,
567
+ setup: async (agentCtx) => {
568
+ install(agentCtx);
569
+ await presets.mount(agentCtx, resolvedId);
570
+ }
571
+ };
572
+ }
573
+ async function createSession(ctx, cwd, prompt) {
574
+ const selection = ctx.get("agentDefaultModel")?.currentSelection();
575
+ const composition = await composeAgent(ctx, selection);
576
+ const handle = await ctx.agents.create({
577
+ sessionId: SessionId(`session-${randomUUID2()}`),
578
+ // The preset is recorded on the header so a later cold resume rebuilds the
579
+ // composition this session's history was actually produced under.
580
+ meta: composition.agentPreset === void 0 ? { cwd } : { cwd, agentPreset: composition.agentPreset },
581
+ agentOptions: selection === void 0 ? void 0 : { provider: selection.provider, model: selection.model },
582
+ setup: composition.setup
583
+ });
584
+ await handle.agent.whenIdle();
585
+ handle.agent.followup(createUserMessage({
586
+ content: [{ type: "text", text: prompt }],
587
+ source: { kind: "user" }
588
+ }));
589
+ return handle;
590
+ }
591
+ function resolveCwd(hub, requested) {
592
+ const roots = hub.config.allowedRoots.map((root) => resolve2(expandHome(root)));
593
+ if (roots.length === 0) return { error: DISPATCH_DISABLED };
594
+ const first = roots[0];
595
+ if (requested === void 0) return first === void 0 ? { error: DISPATCH_DISABLED } : first;
596
+ const target = resolve2(isAbsolute(requested) ? requested : expandHome(requested));
597
+ const inside = roots.some((root) => target === root || target.startsWith(root + sep));
598
+ return inside ? target : { error: `cwd \u4E0D\u5728\u5141\u8BB8\u76EE\u5F55\u5185\uFF1A${target}` };
599
+ }
600
+ async function reportTurnFinal(hub, sessionId) {
601
+ const snapshot = await hub.ctx.sessionQuery.readSurface(SessionId(sessionId));
602
+ const ok2 = !hub.sessions.hasError(sessionId);
603
+ const summary = clampBytes(
604
+ truncate(lastAssistantText(snapshot.events), MAX_SUMMARY_CHARS),
605
+ MAX_FIELD_BYTES
606
+ );
607
+ hub.relay.send(
608
+ { v: 1, ts: Date.now(), type: "turn.final", sessionId, ok: ok2, summary },
609
+ {
610
+ v: 1,
611
+ ts: Date.now(),
612
+ type: "push",
613
+ kind: ok2 ? "done" : "error",
614
+ sessionId,
615
+ title: truncate(summary.split("\n", 1)[0] ?? "", 120)
616
+ }
617
+ );
618
+ }
619
+ function lastAssistantText(events) {
620
+ for (let index = events.length - 1; index >= 0; index -= 1) {
621
+ const event = events[index];
622
+ if (event?.type !== "assistant/message") continue;
623
+ const text = event.data.message.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
624
+ if (text !== "") return text;
625
+ }
626
+ return "\uFF08\u672C\u8F6E\u6CA1\u6709\u4EA7\u751F\u52A9\u624B\u6587\u672C\uFF09";
627
+ }
628
+ function ok(requestId, sessionId) {
629
+ return { v: 1, ts: Date.now(), type: "dispatch.result", requestId, ok: true, sessionId };
630
+ }
631
+ function failure(requestId, error) {
632
+ return { v: 1, ts: Date.now(), type: "dispatch.result", requestId, ok: false, error };
633
+ }
634
+ var ResultCache = class {
635
+ #entries = /* @__PURE__ */ new Map();
636
+ get(requestId) {
637
+ return this.#entries.get(requestId);
638
+ }
639
+ set(requestId, value) {
640
+ this.#entries.delete(requestId);
641
+ this.#entries.set(requestId, value);
642
+ while (this.#entries.size > SEEN_LIMIT) {
643
+ const oldest = this.#entries.keys().next();
644
+ if (oldest.done === true) break;
645
+ this.#entries.delete(oldest.value);
646
+ }
647
+ }
648
+ };
649
+
650
+ // src/hub.ts
651
+ var ERROR_CHARS = 1e3;
652
+ function createHub(parts) {
653
+ const report = (context, message) => {
654
+ parts.log.error("%s: %s", context, message);
655
+ parts.relay.send({
656
+ v: 1,
657
+ ts: Date.now(),
658
+ type: "error",
659
+ message: truncate(message, ERROR_CHARS),
660
+ context
661
+ });
662
+ };
663
+ return {
664
+ ...parts,
665
+ report,
666
+ fail(context, error) {
667
+ report(context, error instanceof Error ? error.message : String(error));
668
+ }
669
+ };
670
+ }
671
+
672
+ // src/relay-client.ts
673
+ var BACKOFF_BASE_MS = 500;
674
+ var BACKOFF_MAX_MS = 3e4;
675
+ var GIVE_UP_AFTER = 10;
676
+ var GIVE_UP_RETRY_MS = 6e4;
677
+ var TAMPER_LOG_WINDOW_MS = 3e4;
678
+ var RelayClient = class {
679
+ #options;
680
+ #key;
681
+ #room;
682
+ #socket;
683
+ #retry;
684
+ #stopped = true;
685
+ #connected = false;
686
+ #phoneOnline = false;
687
+ #failures = 0;
688
+ #gaveUp = false;
689
+ #tamperBurst = 0;
690
+ #tamperWindowStart = 0;
691
+ #tamperTotal = 0;
692
+ constructor(options) {
693
+ this.#options = options;
694
+ this.#key = deriveKey(options.secret);
695
+ this.#room = deriveRoomId(options.secret);
696
+ }
697
+ get connected() {
698
+ return this.#connected;
699
+ }
700
+ get phoneOnline() {
701
+ return this.#phoneOnline;
702
+ }
703
+ status() {
704
+ return {
705
+ connected: this.#connected,
706
+ phoneOnline: this.#phoneOnline,
707
+ consecutiveFailures: this.#failures,
708
+ gaveUp: this.#gaveUp,
709
+ room: this.#room,
710
+ decryptFailures: this.#tamperTotal
711
+ };
712
+ }
713
+ start() {
714
+ if (!this.#stopped) return;
715
+ this.#stopped = false;
716
+ this.#connect();
717
+ }
718
+ stop() {
719
+ this.#stopped = true;
720
+ if (this.#retry !== void 0) clearTimeout(this.#retry);
721
+ this.#retry = void 0;
722
+ this.#teardownSocket();
723
+ this.#connected = false;
724
+ this.#phoneOnline = false;
725
+ }
726
+ /** Adopt a freshly generated secret: new room, new key, new connection. */
727
+ rekey(secret) {
728
+ this.#key = deriveKey(secret);
729
+ this.#room = deriveRoomId(secret);
730
+ this.#failures = 0;
731
+ this.#gaveUp = false;
732
+ if (this.#stopped) return;
733
+ this.stop();
734
+ this.start();
735
+ }
736
+ /**
737
+ * Seal and send one inner message. `push` rides along so the relay can wake
738
+ * an offline phone through Web Push without ever seeing the plaintext.
739
+ * @returns whether the frame reached the socket.
740
+ */
741
+ send(message, push) {
742
+ const socket = this.#socket;
743
+ if (socket === void 0 || !this.#connected) return false;
744
+ const frame = {
745
+ kind: "msg",
746
+ room: this.#room,
747
+ payload: seal(message, this.#key),
748
+ push: push === void 0 ? null : { payload: seal(push, this.#key), tag: push.kind }
749
+ };
750
+ const text = JSON.stringify(frame);
751
+ if (text.length > MAX_ENVELOPE_BYTES) {
752
+ this.#options.log.error(
753
+ "relay: refusing to send an oversized %s envelope (%d bytes > %d); this is a truncation bug",
754
+ message.type,
755
+ text.length,
756
+ MAX_ENVELOPE_BYTES
757
+ );
758
+ return false;
759
+ }
760
+ socket.send(text);
761
+ return true;
762
+ }
763
+ #connect() {
764
+ if (this.#stopped) return;
765
+ let socket;
766
+ try {
767
+ socket = new WebSocket(this.#options.url);
768
+ } catch (error) {
769
+ this.#options.log.error("relay: cannot open %s: %s", this.#options.url, error);
770
+ this.#scheduleRetry();
771
+ return;
772
+ }
773
+ this.#socket = socket;
774
+ socket.addEventListener("open", () => {
775
+ socket.send(JSON.stringify({ kind: "hello", room: this.#room, role: "machine" }));
776
+ });
777
+ socket.addEventListener("message", (event) => {
778
+ this.#onFrame(event.data);
779
+ });
780
+ socket.addEventListener("error", () => {
781
+ });
782
+ socket.addEventListener("close", (event) => {
783
+ if (this.#socket !== socket) return;
784
+ const code = event.code;
785
+ this.#onDown(`socket closed (${String(code ?? "no code")})`);
786
+ });
787
+ }
788
+ #onFrame(data) {
789
+ if (typeof data !== "string") return;
790
+ let frame;
791
+ try {
792
+ frame = JSON.parse(data);
793
+ } catch (error) {
794
+ this.#options.log.error("relay: dropped an unparseable frame: %s", error);
795
+ return;
796
+ }
797
+ const kind = frame.kind;
798
+ if (kind === "hello-ok") this.#onHelloOk(frame);
799
+ else if (kind === "presence") this.#onPresence(frame);
800
+ else if (kind === "msg") this.#onData(frame);
801
+ else if (kind === "error") {
802
+ const code = String(frame.code ?? "unknown");
803
+ this.#options.log.error('relay: rejected our frame with code "%s"', code);
804
+ } else {
805
+ this.#options.log.warn('relay: ignoring unknown frame kind "%s"', String(kind));
806
+ }
807
+ }
808
+ #onHelloOk(frame) {
809
+ this.#connected = true;
810
+ this.#failures = 0;
811
+ this.#gaveUp = false;
812
+ const peers = frame.peers;
813
+ const phones = typeof peers?.phone === "number" ? peers.phone : 0;
814
+ this.#options.log.info("relay: connected (room %s\u2026, phones online: %d)", this.#room.slice(0, 8), phones);
815
+ this.#options.onConnected();
816
+ if (phones > 0) this.#setPhoneOnline(true);
817
+ }
818
+ #onPresence(frame) {
819
+ const { role, online } = frame;
820
+ if (role !== "phone" || typeof online !== "boolean") return;
821
+ this.#setPhoneOnline(online);
822
+ }
823
+ #setPhoneOnline(online) {
824
+ if (this.#phoneOnline === online) return;
825
+ this.#phoneOnline = online;
826
+ this.#options.onPhonePresence(online);
827
+ }
828
+ #onData(frame) {
829
+ const payload = frame.payload;
830
+ if (typeof payload !== "string") {
831
+ this.#options.log.error("relay: dropped a msg frame with no payload");
832
+ return;
833
+ }
834
+ const plain = open(payload, this.#key);
835
+ if (plain === null) {
836
+ this.#onTamper();
837
+ return;
838
+ }
839
+ const message = parseInbound(plain);
840
+ if (message === null) {
841
+ this.#options.log.warn(
842
+ 'relay: ignoring inner message of type "%s" \u2014 unknown type or missing/invalid fields (the phone may be newer than this plugin)',
843
+ String(plain.type)
844
+ );
845
+ return;
846
+ }
847
+ this.#options.onMessage(message);
848
+ }
849
+ /** Decryption failure is never silent: it means tampering or a stale pairing. */
850
+ #onTamper() {
851
+ this.#tamperTotal += 1;
852
+ this.#tamperBurst += 1;
853
+ const now = Date.now();
854
+ if (now - this.#tamperWindowStart < TAMPER_LOG_WINDOW_MS) return;
855
+ this.#tamperWindowStart = now;
856
+ this.#options.log.error(
857
+ "relay: %d message(s) failed to decrypt \u2014 wrong key or tampering. Re-pair with /dispatch-repair and re-scan the QR on every phone.",
858
+ this.#tamperBurst
859
+ );
860
+ this.#tamperBurst = 0;
861
+ }
862
+ #onDown(reason) {
863
+ this.#teardownSocket();
864
+ if (this.#stopped) return;
865
+ this.#connected = false;
866
+ this.#setPhoneOnline(false);
867
+ this.#failures += 1;
868
+ this.#options.log.debug("relay: %s, attempt %d", reason, this.#failures);
869
+ this.#scheduleRetry();
870
+ }
871
+ #scheduleRetry() {
872
+ if (this.#stopped || this.#retry !== void 0) return;
873
+ if (this.#failures >= GIVE_UP_AFTER && !this.#gaveUp) {
874
+ this.#gaveUp = true;
875
+ this.#options.log.error(
876
+ "relay: %d consecutive failures connecting to %s \u2014 check that the relay is reachable. Still retrying every %ds.",
877
+ this.#failures,
878
+ this.#options.url,
879
+ GIVE_UP_RETRY_MS / 1e3
880
+ );
881
+ }
882
+ this.#retry = setTimeout(() => {
883
+ this.#retry = void 0;
884
+ this.#connect();
885
+ }, this.#backoffMs());
886
+ this.#retry.unref?.();
887
+ }
888
+ #backoffMs() {
889
+ if (this.#gaveUp) return GIVE_UP_RETRY_MS;
890
+ const exponential = Math.min(BACKOFF_BASE_MS * 2 ** this.#failures, BACKOFF_MAX_MS);
891
+ return Math.round(exponential * (0.75 + Math.random() * 0.5));
892
+ }
893
+ #teardownSocket() {
894
+ const socket = this.#socket;
895
+ this.#socket = void 0;
896
+ if (socket === void 0) return;
897
+ try {
898
+ socket.close();
899
+ } catch (error) {
900
+ this.#options.log.debug("relay: error closing socket: %s", error);
901
+ }
902
+ }
903
+ };
904
+ var INBOUND_FIELDS = {
905
+ "sessions.get": [],
906
+ "approval.respond": ["requestId", "approvalId", "decision"],
907
+ "dispatch.request": ["requestId", "prompt"],
908
+ "session.message": ["requestId", "sessionId", "text"]
909
+ };
910
+ function parseInbound(value) {
911
+ if (typeof value !== "object" || value === null) return null;
912
+ const record = value;
913
+ if (record["v"] !== 1) return null;
914
+ const type = record["type"];
915
+ if (typeof type !== "string" || !(type in INBOUND_FIELDS)) return null;
916
+ const required = INBOUND_FIELDS[type];
917
+ for (const field of required) {
918
+ if (typeof record[field] !== "string" || record[field] === "") return null;
919
+ }
920
+ if (type === "approval.respond" && record["decision"] !== "allow" && record["decision"] !== "deny") {
921
+ return null;
922
+ }
923
+ if (type === "dispatch.request" && typeof record["worktree"] !== "boolean") return null;
924
+ return record;
925
+ }
926
+
927
+ // src/sessions.ts
928
+ import { SessionId as SessionId2 } from "@deepseek-ai/dsh-session";
929
+ var TITLE_CHARS2 = 80;
930
+ var SessionTracker = class {
931
+ #ctx;
932
+ #relay;
933
+ #log;
934
+ #tracked = /* @__PURE__ */ new Map();
935
+ #published = /* @__PURE__ */ new Map();
936
+ constructor(ctx, relay, log) {
937
+ this.#ctx = ctx;
938
+ this.#relay = relay;
939
+ this.#log = log;
940
+ }
941
+ /** Subscribe the agent lifecycle. Registrations unwind with the plugin. */
942
+ install() {
943
+ this.#ctx.on("agent/created", ({ agent }) => {
944
+ this.#adopt(agent);
945
+ this.#publish(agent.id);
946
+ void this.#refreshTitle(agent);
947
+ });
948
+ this.#ctx.on("agent/session-start", ({ agent }) => {
949
+ void this.#refreshTitle(agent);
950
+ });
951
+ this.#ctx.on("agent/status", ({ agent, status }) => {
952
+ this.#update(agent.id, (entry) => {
953
+ if (status === "idle" && entry.finalized) return;
954
+ entry.finalized = false;
955
+ entry.base = status;
956
+ });
957
+ });
958
+ this.#ctx.on("agent/error", ({ agent, error }) => {
959
+ this.#log.error("session %s: agent error: %s", agent.id, error);
960
+ this.#update(agent.id, (entry) => {
961
+ entry.base = "error";
962
+ });
963
+ });
964
+ this.#ctx.on("agent/turn-stopping", ({ agent }) => {
965
+ void this.#refreshTitle(agent);
966
+ });
967
+ this.#ctx.on("agent/disposed", ({ agent }) => {
968
+ this.#update(agent.id, (entry) => {
969
+ entry.base = "done";
970
+ });
971
+ this.#tracked.delete(agent.id);
972
+ this.#published.delete(agent.id);
973
+ });
974
+ }
975
+ /** Mark a session as started by us, with the dispatch prompt as its title. */
976
+ markDispatched(sessionId, prompt) {
977
+ this.#update(sessionId, (entry) => {
978
+ entry.dispatched = true;
979
+ entry.title = truncate(prompt.trim().split("\n", 1)[0] ?? "", TITLE_CHARS2);
980
+ });
981
+ }
982
+ isDispatched(sessionId) {
983
+ return this.#tracked.get(sessionId)?.dispatched ?? false;
984
+ }
985
+ /**
986
+ * A dispatched turn has closed and its `turn.final` is on its way. Latch the
987
+ * card at done/error so the trailing idle status cannot demote it.
988
+ */
989
+ markTurnComplete(sessionId) {
990
+ this.#update(sessionId, (entry) => {
991
+ entry.finalized = true;
992
+ if (entry.base !== "error") entry.base = "done";
993
+ });
994
+ }
995
+ hasError(sessionId) {
996
+ return this.#tracked.get(sessionId)?.base === "error";
997
+ }
998
+ /** The live agent for a session id, or undefined when it is not live. */
999
+ agentOf(sessionId) {
1000
+ return this.#ctx.agents.get(SessionId2(sessionId));
1001
+ }
1002
+ /** An approval for this session is now waiting on a human. */
1003
+ approvalOpened(sessionId) {
1004
+ this.#update(sessionId, (entry) => {
1005
+ entry.openApprovals += 1;
1006
+ });
1007
+ }
1008
+ approvalClosed(sessionId) {
1009
+ this.#update(sessionId, (entry) => {
1010
+ entry.openApprovals = Math.max(0, entry.openApprovals - 1);
1011
+ });
1012
+ }
1013
+ /** Active sessions only — the phone board never shows cold history. */
1014
+ async snapshot() {
1015
+ const records = await this.#ctx.sessionQuery.listSessions();
1016
+ const wire = [];
1017
+ for (const record of records) {
1018
+ if (!record.live) continue;
1019
+ const id = record.header.id;
1020
+ const known = this.#tracked.get(id) ?? this.#adopt(this.#ctx.agents.get(id));
1021
+ wire.push(known === void 0 ? this.#fallbackWire(id, record.header.cwd ?? "", record.header.createdAt) : toWire(known));
1022
+ }
1023
+ return wire;
1024
+ }
1025
+ #fallbackWire(sessionId, cwd, createdAt) {
1026
+ return {
1027
+ sessionId,
1028
+ title: truncate(sessionId, TITLE_CHARS2),
1029
+ cwd,
1030
+ state: "idle",
1031
+ lastActivity: createdAt,
1032
+ dispatched: false
1033
+ };
1034
+ }
1035
+ #adopt(agent) {
1036
+ if (agent === void 0) return void 0;
1037
+ const existing = this.#tracked.get(agent.id);
1038
+ if (existing !== void 0) return existing;
1039
+ const entry = {
1040
+ sessionId: agent.id,
1041
+ title: truncate(firstPrompt(agent) ?? agent.id, TITLE_CHARS2),
1042
+ cwd: agent.session.header.cwd ?? "",
1043
+ base: agent.status,
1044
+ openApprovals: 0,
1045
+ lastActivity: Date.now(),
1046
+ dispatched: false,
1047
+ finalized: false
1048
+ };
1049
+ this.#tracked.set(agent.id, entry);
1050
+ return entry;
1051
+ }
1052
+ #update(sessionId, mutate) {
1053
+ const entry = this.#tracked.get(sessionId) ?? this.#adopt(this.#ctx.agents.get(SessionId2(sessionId)));
1054
+ if (entry === void 0) return;
1055
+ mutate(entry);
1056
+ entry.lastActivity = Date.now();
1057
+ this.#publish(sessionId);
1058
+ }
1059
+ /** Emit `session.update` only when the phone-visible projection changed. */
1060
+ #publish(sessionId) {
1061
+ const entry = this.#tracked.get(sessionId);
1062
+ if (entry === void 0) return;
1063
+ const session = toWire(entry);
1064
+ const fingerprint = `${session.state}|${session.title}|${session.cwd}|${String(session.dispatched)}`;
1065
+ if (this.#published.get(sessionId) === fingerprint) return;
1066
+ this.#published.set(sessionId, fingerprint);
1067
+ this.#relay.send({ v: 1, ts: Date.now(), type: "session.update", session });
1068
+ }
1069
+ async #refreshTitle(agent) {
1070
+ const entry = this.#tracked.get(agent.id);
1071
+ if (entry === void 0 || entry.dispatched) return;
1072
+ try {
1073
+ const snapshot = await this.#ctx.sessionQuery.readTitle(SessionId2(agent.id));
1074
+ const title = snapshot?.title ?? firstPrompt(agent);
1075
+ if (title === void 0 || title === "") return;
1076
+ entry.title = truncate(title, TITLE_CHARS2);
1077
+ this.#publish(agent.id);
1078
+ } catch (error) {
1079
+ this.#log.error("session %s: could not read title: %s", agent.id, error);
1080
+ }
1081
+ }
1082
+ };
1083
+ function toWire(entry) {
1084
+ return {
1085
+ sessionId: entry.sessionId,
1086
+ title: entry.title,
1087
+ cwd: entry.cwd,
1088
+ state: stateOf(entry),
1089
+ lastActivity: entry.lastActivity,
1090
+ dispatched: entry.dispatched
1091
+ };
1092
+ }
1093
+ function stateOf(entry) {
1094
+ if (entry.base === "done" || entry.base === "error") return entry.base;
1095
+ return entry.openApprovals > 0 ? "awaiting_approval" : entry.base;
1096
+ }
1097
+ function firstPrompt(agent) {
1098
+ for (const event of agent.session.events) {
1099
+ if (event.type !== "user/message") continue;
1100
+ const text = event.data.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
1101
+ if (text !== "") return text.split("\n", 1)[0];
1102
+ }
1103
+ return void 0;
1104
+ }
1105
+
1106
+ // src/version.ts
1107
+ var PLUGIN_VERSION = "0.1.0";
1108
+
1109
+ // src/config.ts
1110
+ import { hostname } from "os";
1111
+ import z from "@deepseek-ai/schemastery";
1112
+ var Config = z.object({
1113
+ relay: z.string().required(),
1114
+ machineName: z.string().default(hostname()),
1115
+ allowedRoots: z.array(z.string()).default([]),
1116
+ dataDir: z.string().default("~/.dsh-dispatch"),
1117
+ pwaUrl: z.string().default("http://localhost:5173")
1118
+ });
1119
+
1120
+ // src/index.ts
1121
+ var name = "dsh-dispatch";
1122
+ var inject = ["agents", "commands", "sessionQuery"];
1123
+ var STATUS_INTERVAL_MS = 6e4;
1124
+ function apply(ctx, config) {
1125
+ const log = ctx.logger("dsh-dispatch");
1126
+ const pairing = PairingStore.open(config.dataDir, log);
1127
+ const deferred = { message(_) {
1128
+ }, hello() {
1129
+ } };
1130
+ const relay = new RelayClient({
1131
+ url: config.relay,
1132
+ secret: pairing.secret,
1133
+ log,
1134
+ onMessage: (message) => {
1135
+ deferred.message(message);
1136
+ },
1137
+ onConnected: () => {
1138
+ deferred.hello();
1139
+ },
1140
+ onPhonePresence: (online) => {
1141
+ if (!online) {
1142
+ log.info("relay: phone left the room");
1143
+ return;
1144
+ }
1145
+ pairing.markPaired();
1146
+ log.info("relay: phone joined the room");
1147
+ deferred.hello();
1148
+ }
1149
+ });
1150
+ const sessions = new SessionTracker(ctx, relay, log);
1151
+ const hub = createHub({ ctx, config, log, relay, sessions, pairing });
1152
+ const approvals = installApprovals(hub);
1153
+ const dispatch = installDispatch(hub);
1154
+ deferred.message = (message) => {
1155
+ route(hub, approvals, dispatch, message);
1156
+ };
1157
+ deferred.hello = () => {
1158
+ publishStatus(hub);
1159
+ approvals.resendPending();
1160
+ void publishSnapshot(hub);
1161
+ };
1162
+ sessions.install();
1163
+ installCommands(hub, { approvals, dispatch });
1164
+ startRelay(hub);
1165
+ }
1166
+ function startRelay(hub) {
1167
+ const url = hub.config.relay;
1168
+ if (url === "") {
1169
+ hub.log.error(
1170
+ "relay is not configured: set `relay: 'wss://\u2026'` on the dsh-dispatch row in your profile's cordis.patch.yml. Approval forwarding and dispatch stay off until then."
1171
+ );
1172
+ return;
1173
+ }
1174
+ if (!/^wss?:\/\//.test(url)) {
1175
+ hub.log.error('relay "%s" is not a ws:// or wss:// URL; refusing to connect', url);
1176
+ return;
1177
+ }
1178
+ hub.ctx.effect(() => {
1179
+ hub.relay.start();
1180
+ return () => {
1181
+ hub.relay.stop();
1182
+ };
1183
+ }, "dsh-dispatch: relay client");
1184
+ hub.ctx.effect(() => {
1185
+ const timer = setInterval(() => {
1186
+ publishStatus(hub);
1187
+ }, STATUS_INTERVAL_MS);
1188
+ timer.unref?.();
1189
+ return () => {
1190
+ clearInterval(timer);
1191
+ };
1192
+ }, "dsh-dispatch: status heartbeat");
1193
+ }
1194
+ function route(hub, approvals, dispatch, message) {
1195
+ switch (message.type) {
1196
+ case "sessions.get":
1197
+ void publishSnapshot(hub);
1198
+ return;
1199
+ case "approval.respond":
1200
+ approvals.respond(message);
1201
+ return;
1202
+ case "dispatch.request":
1203
+ void dispatch.request(message).catch((error) => {
1204
+ hub.fail(`dispatch.request ${message.requestId}`, error);
1205
+ });
1206
+ return;
1207
+ case "session.message":
1208
+ void dispatch.followup(message).catch((error) => {
1209
+ hub.fail(`session.message ${message.requestId}`, error);
1210
+ });
1211
+ }
1212
+ }
1213
+ function publishStatus(hub) {
1214
+ hub.relay.send({
1215
+ v: 1,
1216
+ ts: Date.now(),
1217
+ type: "machine.status",
1218
+ machine: hub.config.machineName,
1219
+ pluginVersion: PLUGIN_VERSION
1220
+ });
1221
+ }
1222
+ async function publishSnapshot(hub) {
1223
+ try {
1224
+ hub.relay.send({
1225
+ v: 1,
1226
+ ts: Date.now(),
1227
+ type: "session.snapshot",
1228
+ sessions: await hub.sessions.snapshot()
1229
+ });
1230
+ } catch (error) {
1231
+ hub.fail("session.snapshot", error);
1232
+ }
1233
+ }
1234
+ export {
1235
+ Config,
1236
+ apply,
1237
+ inject,
1238
+ name
1239
+ };
1240
+ //# sourceMappingURL=index.js.map