offhands 0.1.5 → 0.1.7

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/daemon.mjs CHANGED
@@ -79,9 +79,9 @@ var require_buffer_util = __commonJS({
79
79
  }
80
80
  return target;
81
81
  }
82
- function _mask(source, mask, output, offset, length) {
82
+ function _mask(source, mask, output2, offset, length) {
83
83
  for (let i2 = 0; i2 < length; i2++) {
84
- output[offset + i2] = source[i2] ^ mask[i2 & 3];
84
+ output2[offset + i2] = source[i2] ^ mask[i2 & 3];
85
85
  }
86
86
  }
87
87
  function _unmask(buffer, mask) {
@@ -119,9 +119,9 @@ var require_buffer_util = __commonJS({
119
119
  if (!process.env.WS_NO_BUFFER_UTIL) {
120
120
  try {
121
121
  const bufferUtil = __require("bufferutil");
122
- module2.exports.mask = function(source, mask, output, offset, length) {
123
- if (length < 48) _mask(source, mask, output, offset, length);
124
- else bufferUtil.mask(source, mask, output, offset, length);
122
+ module2.exports.mask = function(source, mask, output2, offset, length) {
123
+ if (length < 48) _mask(source, mask, output2, offset, length);
124
+ else bufferUtil.mask(source, mask, output2, offset, length);
125
125
  };
126
126
  module2.exports.unmask = function(buffer, mask) {
127
127
  if (buffer.length < 32) _unmask(buffer, mask);
@@ -4719,7 +4719,7 @@ var require_main = __commonJS({
4719
4719
  var qrcode = new QRCode(-1, this.error);
4720
4720
  qrcode.addData(input);
4721
4721
  qrcode.make();
4722
- var output = "";
4722
+ var output2 = "";
4723
4723
  if (opts && opts.small) {
4724
4724
  var BLACK = true, WHITE = false;
4725
4725
  var moduleCount = qrcode.getModuleCount();
@@ -4736,37 +4736,37 @@ var require_main = __commonJS({
4736
4736
  };
4737
4737
  var borderTop = repeat(platte.BLACK_WHITE).times(moduleCount + 3);
4738
4738
  var borderBottom = repeat(platte.WHITE_BLACK).times(moduleCount + 3);
4739
- output += borderTop + "\n";
4739
+ output2 += borderTop + "\n";
4740
4740
  for (var row = 0; row < moduleCount; row += 2) {
4741
- output += platte.WHITE_ALL;
4741
+ output2 += platte.WHITE_ALL;
4742
4742
  for (var col = 0; col < moduleCount; col++) {
4743
4743
  if (moduleData[row][col] === WHITE && moduleData[row + 1][col] === WHITE) {
4744
- output += platte.WHITE_ALL;
4744
+ output2 += platte.WHITE_ALL;
4745
4745
  } else if (moduleData[row][col] === WHITE && moduleData[row + 1][col] === BLACK) {
4746
- output += platte.WHITE_BLACK;
4746
+ output2 += platte.WHITE_BLACK;
4747
4747
  } else if (moduleData[row][col] === BLACK && moduleData[row + 1][col] === WHITE) {
4748
- output += platte.BLACK_WHITE;
4748
+ output2 += platte.BLACK_WHITE;
4749
4749
  } else {
4750
- output += platte.BLACK_ALL;
4750
+ output2 += platte.BLACK_ALL;
4751
4751
  }
4752
4752
  }
4753
- output += platte.WHITE_ALL + "\n";
4753
+ output2 += platte.WHITE_ALL + "\n";
4754
4754
  }
4755
4755
  if (!oddRow) {
4756
- output += borderBottom;
4756
+ output2 += borderBottom;
4757
4757
  }
4758
4758
  } else {
4759
4759
  var border = repeat(white).times(qrcode.getModuleCount() + 3);
4760
- output += border + "\n";
4760
+ output2 += border + "\n";
4761
4761
  qrcode.modules.forEach(function(row2) {
4762
- output += white;
4763
- output += row2.map(toCell).join("");
4764
- output += white + "\n";
4762
+ output2 += white;
4763
+ output2 += row2.map(toCell).join("");
4764
+ output2 += white + "\n";
4765
4765
  });
4766
- output += border;
4766
+ output2 += border;
4767
4767
  }
4768
- if (cb) cb(output);
4769
- else console.log(output);
4768
+ if (cb) cb(output2);
4769
+ else console.log(output2);
4770
4770
  },
4771
4771
  setErrorLevel: function(error) {
4772
4772
  this.error = QRErrorCorrectLevel[error] || this.error;
@@ -4777,7 +4777,7 @@ var require_main = __commonJS({
4777
4777
 
4778
4778
  // ../daemon/src/index.ts
4779
4779
  import { resolve as resolve5 } from "node:path";
4780
- import { existsSync as existsSync9 } from "node:fs";
4780
+ import { existsSync as existsSync10 } from "node:fs";
4781
4781
 
4782
4782
  // ../daemon/src/runners/claude-code.ts
4783
4783
  import { spawn } from "node:child_process";
@@ -4862,10 +4862,12 @@ function mapClaudeEvent(value) {
4862
4862
  for (const block of content) {
4863
4863
  if (block?.type === "tool_use" && typeof block.name === "string") {
4864
4864
  if (block.name.startsWith("mcp__offhand__")) continue;
4865
+ const todos = block.name === "TodoWrite" ? extractTodos(block.input) : void 0;
4865
4866
  events.push({
4866
4867
  type: "tool",
4867
4868
  name: block.name,
4868
- summary: summariseToolInput(block.name, block.input)
4869
+ summary: todos ? summariseTodos(todos) : summariseToolInput(block.name, block.input),
4870
+ ...todos ? { todos } : {}
4869
4871
  });
4870
4872
  }
4871
4873
  }
@@ -4894,6 +4896,29 @@ function contextUsage(usage, costUsd) {
4894
4896
  if (contextTokens <= 0) return null;
4895
4897
  return { type: "usage", contextTokens, contextWindow: 2e5, ...costUsd !== void 0 ? { costUsd } : {} };
4896
4898
  }
4899
+ function extractTodos(input) {
4900
+ if (typeof input !== "object" || input === null) return void 0;
4901
+ const list = input.todos;
4902
+ if (!Array.isArray(list)) return void 0;
4903
+ const todos = [];
4904
+ for (const item of list) {
4905
+ if (typeof item !== "object" || item === null) return void 0;
4906
+ const t2 = item;
4907
+ if (typeof t2.content !== "string") return void 0;
4908
+ if (t2.status !== "pending" && t2.status !== "in_progress" && t2.status !== "completed") return void 0;
4909
+ todos.push({
4910
+ content: t2.content,
4911
+ status: t2.status,
4912
+ ...typeof t2.activeForm === "string" ? { activeForm: t2.activeForm } : {}
4913
+ });
4914
+ }
4915
+ return todos;
4916
+ }
4917
+ function summariseTodos(todos) {
4918
+ const active = todos.find((t2) => t2.status === "in_progress");
4919
+ const done = todos.filter((t2) => t2.status === "completed").length;
4920
+ return active ? `TodoWrite: ${active.activeForm ?? active.content} (${done}/${todos.length} done)` : `TodoWrite: ${done}/${todos.length} done`;
4921
+ }
4897
4922
  function summariseToolInput(name, input) {
4898
4923
  if (typeof input !== "object" || input === null) return name;
4899
4924
  const i2 = input;
@@ -4941,8 +4966,16 @@ var ClaudeCodeRunner = class {
4941
4966
  }
4942
4967
  id = "claude-code";
4943
4968
  name = "Claude Code";
4944
- models = ["sonnet", "opus", "haiku"];
4969
+ // No enumeration command exists (checked `claude --help` in full against
4970
+ // CLI v2.1.263 — no `models` subcommand, no --list-models flag). These are
4971
+ // the aliases `--model`'s own help text names as examples ('fable', 'opus',
4972
+ // 'sonnet'), plus 'haiku' (long-standing, not superseded by anything found
4973
+ // here) — hardcoded because there is genuinely nothing to query, not out
4974
+ // of laziness. Re-verify against `claude --help` when bumping this list.
4975
+ models = ["sonnet", "opus", "haiku", "fable"];
4945
4976
  supportsApprovals = true;
4977
+ // Real: every tier maps to a MAX_THINKING_TOKENS value below (see start()).
4978
+ effortTiers = ["low", "medium", "high", "max"];
4946
4979
  loggedIn() {
4947
4980
  return existsSync(join(homedir(), ".claude", ".credentials.json"));
4948
4981
  }
@@ -4988,7 +5021,15 @@ var ClaudeCodeRunner = class {
4988
5021
  offhand: {
4989
5022
  command: process.execPath,
4990
5023
  args: [APPROVAL_MCP_PATH],
4991
- env: { OFFHAND_APPROVAL_URL: this.approvalUrl }
5024
+ // OFFHAND_STATUS_URL is derived from approvalUrl rather than a
5025
+ // second constructor param — same daemon, same port, one fewer
5026
+ // thing to keep in sync as this.approvalUrl already carries the
5027
+ // host:port everything else here needs.
5028
+ env: {
5029
+ OFFHAND_APPROVAL_URL: this.approvalUrl,
5030
+ OFFHAND_STATUS_URL: this.approvalUrl.replace(/\/approval$/, "/status"),
5031
+ OFFHAND_SESSION_ID: run.sessionId
5032
+ }
4992
5033
  }
4993
5034
  }
4994
5035
  };
@@ -5174,6 +5215,10 @@ var CopilotCliRunner = class {
5174
5215
  models = [];
5175
5216
  // no headless enumeration in 1.0.31; CLI default
5176
5217
  supportsApprovals = false;
5218
+ // No effortTiers: start() never reads run.effort — there's no thinking-
5219
+ // budget flag in Copilot CLI 1.0.31's non-interactive mode. Leaving this
5220
+ // undefined (not an empty array) is what tells the phone to hide the
5221
+ // effort control here rather than show one that silently does nothing.
5177
5222
  /** [command, ...prefixArgs] resolved once. */
5178
5223
  resolved = null;
5179
5224
  resolveCommand() {
@@ -5394,6 +5439,10 @@ var OpenCodeRunner = class {
5394
5439
  supportsApprovals = true;
5395
5440
  models = [];
5396
5441
  // populated dynamically via `opencode models`
5442
+ // Real: every tier maps to a --variant value below (see startViaCli()) —
5443
+ // low/medium collide onto minimal/high internally, but all four inputs
5444
+ // are genuinely honored, just not 1:1 named.
5445
+ effortTiers = ["low", "medium", "high", "max"];
5397
5446
  /** [command, ...prefixArgs] resolved once. */
5398
5447
  resolved = null;
5399
5448
  modelsCache = [];
@@ -5462,15 +5511,15 @@ var OpenCodeRunner = class {
5462
5511
  stdio: ["ignore", "pipe", "ignore"],
5463
5512
  shell: process.platform === "win32"
5464
5513
  });
5465
- let output = "";
5514
+ let output2 = "";
5466
5515
  p2.stdout.setEncoding("utf8");
5467
5516
  p2.stdout.on("data", (chunk) => {
5468
- output += chunk;
5517
+ output2 += chunk;
5469
5518
  });
5470
5519
  p2.on("close", (code) => {
5471
5520
  if (code === 0) {
5472
5521
  try {
5473
- const lines = output.trim().split("\n");
5522
+ const lines = output2.trim().split("\n");
5474
5523
  const models = lines.map((l2) => l2.trim()).filter((l2) => l2.length > 0);
5475
5524
  this.modelsCache = models;
5476
5525
  Object.defineProperty(this, "models", {
@@ -5652,6 +5701,14 @@ stderr: ${stderrTail}` : ""}`
5652
5701
  sessionId = session.id;
5653
5702
  }
5654
5703
  callbacks?.onConversationId?.(sessionId);
5704
+ const baselineMessageIds = /* @__PURE__ */ new Set();
5705
+ if (run.resumeConversationId) {
5706
+ try {
5707
+ const existing = await httpJson(`${base}/session/${sessionId}/message?directory=${encDir}`, "GET");
5708
+ for (const row of existing) baselineMessageIds.add(row.info.id);
5709
+ } catch {
5710
+ }
5711
+ }
5655
5712
  const parts = [{ type: "text", text: run.prompt }];
5656
5713
  for (const file of run.attachments ?? []) {
5657
5714
  parts.push({ type: "file", mime: "application/octet-stream", url: fileUrl(file) });
@@ -5660,7 +5717,7 @@ stderr: ${stderrTail}` : ""}`
5660
5717
  const modelRef = parseModelRef(run.model);
5661
5718
  if (modelRef) promptBody.model = { providerID: modelRef.providerId, modelID: modelRef.modelId };
5662
5719
  await httpJson(`${base}/session/${sessionId}/prompt_async?directory=${encDir}`, "POST", promptBody, true);
5663
- await this.pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, () => cancelled);
5720
+ await this.pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, () => cancelled);
5664
5721
  } catch (e) {
5665
5722
  if (!cancelled) emit([{ type: "error", message: `opencode HTTP API error: ${String(e)}` }]);
5666
5723
  } finally {
@@ -5689,7 +5746,7 @@ stderr: ${stderrTail}` : ""}`
5689
5746
  };
5690
5747
  }
5691
5748
  /** Poll session messages + pending permissions/questions until the turn finishes. */
5692
- async pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, isCancelled) {
5749
+ async pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, isCancelled) {
5693
5750
  const seenPermissions = /* @__PURE__ */ new Set();
5694
5751
  const seenQuestions = /* @__PURE__ */ new Set();
5695
5752
  const emittedParts = /* @__PURE__ */ new Set();
@@ -5721,21 +5778,9 @@ stderr: ${stderrTail}` : ""}`
5721
5778
  } catch {
5722
5779
  continue;
5723
5780
  }
5724
- for (const row of messages) {
5725
- if (row.info.role !== "assistant") continue;
5726
- for (const part of row.parts) {
5727
- if (!part.id || emittedParts.has(part.id)) continue;
5728
- if ((part.type === "text" || part.type === "reasoning") && !part.time?.end) continue;
5729
- emittedParts.add(part.id);
5730
- const events = mapOpenCodeEvent({ part });
5731
- emit(events);
5732
- if (events.some((e) => e.type === "done" || e.type === "error")) sawTerminal = true;
5733
- }
5734
- if (!sawTerminal && row.info.error) {
5735
- sawTerminal = true;
5736
- emit([{ type: "error", message: row.info.error.data?.message ?? "run failed" }]);
5737
- }
5738
- }
5781
+ const result = processMessages(messages, baselineMessageIds, emittedParts);
5782
+ emit(result.events);
5783
+ if (result.sawTerminal) sawTerminal = true;
5739
5784
  }
5740
5785
  }
5741
5786
  /** Route one pending permission through the shared broker, then reply. */
@@ -5816,6 +5861,26 @@ stderr: ${stderrTail}` : ""}`
5816
5861
  }
5817
5862
  }
5818
5863
  };
5864
+ function processMessages(messages, baselineMessageIds, emittedParts) {
5865
+ const events = [];
5866
+ let sawTerminal = false;
5867
+ for (const row of messages) {
5868
+ if (row.info.role !== "assistant" || baselineMessageIds.has(row.info.id)) continue;
5869
+ for (const part of row.parts) {
5870
+ if (!part.id || emittedParts.has(part.id)) continue;
5871
+ if ((part.type === "text" || part.type === "reasoning") && !part.time?.end) continue;
5872
+ emittedParts.add(part.id);
5873
+ const partEvents = mapOpenCodeEvent({ part });
5874
+ events.push(...partEvents);
5875
+ if (partEvents.some((e) => e.type === "done" || e.type === "error")) sawTerminal = true;
5876
+ }
5877
+ if (!sawTerminal && row.info.error) {
5878
+ sawTerminal = true;
5879
+ events.push({ type: "error", message: row.info.error.data?.message ?? "run failed" });
5880
+ }
5881
+ }
5882
+ return { events, sawTerminal };
5883
+ }
5819
5884
  function buildPermissionRuleset(mode) {
5820
5885
  const base = [{ permission: "*", pattern: "*", action: "allow" }];
5821
5886
  if (mode === "acceptEdits") {
@@ -5886,8 +5951,8 @@ function sleep(ms) {
5886
5951
 
5887
5952
  // ../daemon/src/session-manager.ts
5888
5953
  import { randomUUID } from "node:crypto";
5889
- import { hostname, platform, tmpdir } from "node:os";
5890
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
5954
+ import { hostname, platform as platform2, tmpdir } from "node:os";
5955
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
5891
5956
  import { dirname as dirname2, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
5892
5957
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5893
5958
 
@@ -6234,6 +6299,267 @@ function mimeFromName(path) {
6234
6299
  }[ext] ?? "application/octet-stream";
6235
6300
  }
6236
6301
 
6302
+ // ../daemon/src/handover.ts
6303
+ var MAX_DIFF_BYTES2 = 48 * 1024;
6304
+ var MAX_TOOL_SUMMARIES = 30;
6305
+ var MAX_RECENT_MESSAGES = 10;
6306
+ async function liveDiff(workspace) {
6307
+ const diff = await git(workspace, "diff", "HEAD");
6308
+ if (!diff) return "";
6309
+ return diff.length > MAX_DIFF_BYTES2 ? diff.slice(0, MAX_DIFF_BYTES2) + "\n\u2026 diff truncated \u2026" : diff;
6310
+ }
6311
+ function assembleHandoverPacket(items, diff) {
6312
+ const toolSummaries = [];
6313
+ let todos;
6314
+ const recentMessages = [];
6315
+ const resolvedApprovalIds = /* @__PURE__ */ new Set();
6316
+ const approvalsById = /* @__PURE__ */ new Map();
6317
+ for (const msg of items) {
6318
+ if (msg.type === "run-started") {
6319
+ recentMessages.push({ role: "user", text: msg.prompt });
6320
+ continue;
6321
+ }
6322
+ if (msg.type !== "run-event") continue;
6323
+ const ev = msg.event;
6324
+ if (ev.type === "tool") {
6325
+ toolSummaries.push(ev.summary);
6326
+ if (ev.todos) todos = ev.todos;
6327
+ } else if (ev.type === "text") {
6328
+ const last = recentMessages[recentMessages.length - 1];
6329
+ if (last?.role === "assistant") last.text += ev.chunk;
6330
+ else recentMessages.push({ role: "assistant", text: ev.chunk });
6331
+ } else if (ev.type === "approval") {
6332
+ approvalsById.set(ev.id, { action: ev.action, detail: ev.detail, risk: ev.risk });
6333
+ } else if (ev.type === "approval-result") {
6334
+ resolvedApprovalIds.add(ev.id);
6335
+ }
6336
+ }
6337
+ const pendingApprovals = [...approvalsById.entries()].filter(([id]) => !resolvedApprovalIds.has(id)).map(([, a2]) => a2);
6338
+ return {
6339
+ diff,
6340
+ toolSummaries: toolSummaries.slice(-MAX_TOOL_SUMMARIES),
6341
+ ...todos ? { todos } : {},
6342
+ pendingApprovals,
6343
+ recentMessages: recentMessages.slice(-MAX_RECENT_MESSAGES)
6344
+ };
6345
+ }
6346
+ function translatePacketToPrompt(packet, fromRunnerName) {
6347
+ const sections = [
6348
+ `You're continuing a task handed off from ${fromRunnerName}, which hit its usage limit mid-task. Here is the context so far.`
6349
+ ];
6350
+ if (packet.recentMessages.length > 0) {
6351
+ sections.push(
6352
+ "## Recent conversation\n" + packet.recentMessages.map((m3) => `**${m3.role}:** ${m3.text}`).join("\n\n")
6353
+ );
6354
+ }
6355
+ if (packet.toolSummaries.length > 0) {
6356
+ sections.push("## Actions already taken\n" + packet.toolSummaries.map((t2) => `- ${t2}`).join("\n"));
6357
+ }
6358
+ if (packet.todos && packet.todos.length > 0) {
6359
+ sections.push(
6360
+ "## Remaining plan\n" + packet.todos.map((t2) => `- [${t2.status === "completed" ? "x" : " "}] ${t2.content}`).join("\n")
6361
+ );
6362
+ }
6363
+ if (packet.pendingApprovals.length > 0) {
6364
+ sections.push(
6365
+ "## Not yet approved (ask again if still needed)\n" + packet.pendingApprovals.map((a2) => `- ${a2.action}: ${a2.detail} (${a2.risk} risk)`).join("\n")
6366
+ );
6367
+ }
6368
+ if (packet.diff.trim()) {
6369
+ sections.push("## Uncommitted changes so far (git diff)\n```diff\n" + packet.diff + "\n```");
6370
+ }
6371
+ if (packet.agentSummary) {
6372
+ sections.push("## Outgoing agent's own summary\n" + packet.agentSummary);
6373
+ }
6374
+ sections.push("Please continue the task from here.");
6375
+ return sections.join("\n\n");
6376
+ }
6377
+
6378
+ // ../daemon/src/wol.ts
6379
+ import { execFile as execFile2 } from "node:child_process";
6380
+ import { promisify as promisify2 } from "node:util";
6381
+ import { networkInterfaces, platform } from "node:os";
6382
+ import { existsSync as existsSync6 } from "node:fs";
6383
+ var exec2 = promisify2(execFile2);
6384
+ function pickPrimaryInterface(ifaces) {
6385
+ for (const [name, infos] of Object.entries(ifaces)) {
6386
+ if (!infos) continue;
6387
+ const ipv4 = infos.find((i2) => i2.family === "IPv4" && !i2.internal);
6388
+ if (ipv4?.mac && ipv4.mac !== "00:00:00:00:00:00") {
6389
+ return { name, mac: ipv4.mac, address: ipv4.address, netmask: ipv4.netmask };
6390
+ }
6391
+ }
6392
+ return null;
6393
+ }
6394
+ function pickInterfaceByName(ifaces, name) {
6395
+ const infos = ifaces[name];
6396
+ if (!infos) return null;
6397
+ const ipv4 = infos.find((i2) => i2.family === "IPv4" && !i2.internal);
6398
+ if (!ipv4?.mac || ipv4.mac === "00:00:00:00:00:00") return null;
6399
+ return { name, mac: ipv4.mac, address: ipv4.address, netmask: ipv4.netmask };
6400
+ }
6401
+ function computeBroadcastAddress(address, netmask) {
6402
+ const a2 = address.split(".").map(Number);
6403
+ const m3 = netmask.split(".").map(Number);
6404
+ if (a2.length !== 4 || m3.length !== 4 || a2.some(Number.isNaN) || m3.some(Number.isNaN)) return null;
6405
+ return a2.map((octet, i2) => (octet | ~m3[i2] & 255) & 255).join(".");
6406
+ }
6407
+ function normalizeMac(mac) {
6408
+ return (mac ?? "").toLowerCase().replace(/[:-]/g, "");
6409
+ }
6410
+ function classifyWindowsMediaType(mediaType) {
6411
+ const t2 = (mediaType ?? "").toLowerCase();
6412
+ if (t2.includes("802.11") || t2.includes("wireless") || t2.includes("native 802.11")) return "wifi";
6413
+ if (t2.includes("802.3") || t2.includes("ethernet")) return "ethernet";
6414
+ return "unknown";
6415
+ }
6416
+ function parseWindowsAdapterJson(json) {
6417
+ let parsed;
6418
+ try {
6419
+ parsed = JSON.parse(json);
6420
+ } catch {
6421
+ return [];
6422
+ }
6423
+ const arr = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
6424
+ return arr.filter((x2) => !!x2 && typeof x2 === "object").map((x2) => ({
6425
+ name: String(x2.Name ?? ""),
6426
+ macAddress: x2.MacAddress ? String(x2.MacAddress) : null,
6427
+ networkType: classifyWindowsMediaType(x2.MediaType),
6428
+ wakeOnMagicPacket: x2.WakeOnMagicPacket === "Enabled" || x2.WakeOnMagicPacket === "Disabled" || x2.WakeOnMagicPacket === "NotSupported" ? x2.WakeOnMagicPacket : null
6429
+ }));
6430
+ }
6431
+ function parseMacPmset(output2) {
6432
+ const m3 = output2.match(/^\s*womp\s+(\d)/m);
6433
+ return m3 ? m3[1] === "1" : false;
6434
+ }
6435
+ function classifyMacHardwarePort(listing, deviceName) {
6436
+ const blocks = listing.split(/\n\n+/);
6437
+ for (const block of blocks) {
6438
+ if (!new RegExp(`Device:\\s*${deviceName}\\b`).test(block)) continue;
6439
+ if (/Hardware Port:\s*Wi-Fi/i.test(block)) return "wifi";
6440
+ if (/Hardware Port:\s*(Ethernet|Thunderbolt Ethernet)/i.test(block)) return "ethernet";
6441
+ }
6442
+ return "unknown";
6443
+ }
6444
+ function parseLinuxEthtool(output2) {
6445
+ let supports = "";
6446
+ let current = "";
6447
+ for (const line of output2.split(/\r?\n/)) {
6448
+ const supportsMatch = line.match(/^\s*Supports Wake-on:\s*([a-zA-Z]*)/);
6449
+ if (supportsMatch) {
6450
+ supports = supportsMatch[1];
6451
+ continue;
6452
+ }
6453
+ const currentMatch = line.match(/^\s*Wake-on:\s*([a-zA-Z]*)/);
6454
+ if (currentMatch) current = currentMatch[1];
6455
+ }
6456
+ return { supportsWakeOnG: supports.toLowerCase().includes("g"), wakeOnGEnabled: current.toLowerCase().includes("g") };
6457
+ }
6458
+ function computeWolCapability(networkType, wolWorks) {
6459
+ if (!wolWorks) {
6460
+ return {
6461
+ capability: "unsupported",
6462
+ reason: networkType === "wifi" ? "Wake-on-Wireless-LAN isn't enabled on this network adapter." : "Wake-on-LAN isn't enabled (or isn't supported) on this network adapter."
6463
+ };
6464
+ }
6465
+ if (networkType === "wifi") {
6466
+ return {
6467
+ capability: "wifi-limited",
6468
+ reason: "Wake-on-Wireless-LAN is enabled, but WiFi wake support depends on the router too \u2014 less reliable than a wired connection."
6469
+ };
6470
+ }
6471
+ if (networkType === "ethernet") {
6472
+ return { capability: "supported", reason: "Wake-on-LAN is enabled on this wired connection." };
6473
+ }
6474
+ return { capability: "unsupported", reason: "Couldn't determine this machine's network connection type." };
6475
+ }
6476
+ var WINDOWS_ADAPTER_SCRIPT = "Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | ForEach-Object { $pm = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue; [PSCustomObject]@{ Name = $_.Name; MediaType = [string]$_.PhysicalMediaType; MacAddress = $_.MacAddress; WakeOnMagicPacket = [string]$pm.WakeOnMagicPacket } } | ConvertTo-Json";
6477
+ async function detectWindows(primary) {
6478
+ const { stdout } = await exec2("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_ADAPTER_SCRIPT], {
6479
+ timeout: 8e3
6480
+ });
6481
+ const adapters = parseWindowsAdapterJson(stdout);
6482
+ const match = adapters.find((a2) => normalizeMac(a2.macAddress) === normalizeMac(primary.mac)) ?? adapters[0];
6483
+ if (!match) throw new Error("no active physical adapter reported by Get-NetAdapter");
6484
+ return { networkType: match.networkType, wolWorks: match.wakeOnMagicPacket === "Enabled" };
6485
+ }
6486
+ async function detectMac(primary) {
6487
+ const { stdout: hardwarePorts } = await exec2("networksetup", ["-listallhardwareports"], { timeout: 8e3 });
6488
+ const networkType = classifyMacHardwarePort(hardwarePorts, primary.name);
6489
+ const { stdout: pmsetOut } = await exec2("pmset", ["-g"], { timeout: 8e3 });
6490
+ return { networkType, wolWorks: parseMacPmset(pmsetOut) };
6491
+ }
6492
+ async function detectLinux(primary) {
6493
+ const networkType = existsSync6(`/sys/class/net/${primary.name}/wireless`) ? "wifi" : "ethernet";
6494
+ const { stdout } = await exec2("ethtool", [primary.name], { timeout: 8e3 });
6495
+ const { supportsWakeOnG, wakeOnGEnabled } = parseLinuxEthtool(stdout);
6496
+ return { networkType, wolWorks: supportsWakeOnG && wakeOnGEnabled };
6497
+ }
6498
+ async function getDefaultRouteInterfaceName(plat) {
6499
+ try {
6500
+ if (plat === "win32") {
6501
+ const { stdout } = await exec2(
6502
+ "powershell",
6503
+ [
6504
+ "-NoProfile",
6505
+ "-NonInteractive",
6506
+ "-Command",
6507
+ "Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias"
6508
+ ],
6509
+ { timeout: 8e3 }
6510
+ );
6511
+ return stdout.trim() || null;
6512
+ }
6513
+ if (plat === "darwin") {
6514
+ const { stdout } = await exec2("route", ["-n", "get", "default"], { timeout: 8e3 });
6515
+ const m3 = stdout.match(/interface:\s*(\S+)/);
6516
+ return m3 ? m3[1] : null;
6517
+ }
6518
+ if (plat === "linux") {
6519
+ const { stdout } = await exec2("ip", ["route", "show", "default"], { timeout: 8e3 });
6520
+ const m3 = stdout.match(/\bdev\s+(\S+)/);
6521
+ return m3 ? m3[1] : null;
6522
+ }
6523
+ return null;
6524
+ } catch {
6525
+ return null;
6526
+ }
6527
+ }
6528
+ async function probeWolCapability() {
6529
+ const checkedAtMs = Date.now();
6530
+ const plat = platform();
6531
+ const ifaces = networkInterfaces();
6532
+ const routeIfaceName = await getDefaultRouteInterfaceName(plat);
6533
+ const primary = routeIfaceName && pickInterfaceByName(ifaces, routeIfaceName) || pickPrimaryInterface(ifaces);
6534
+ if (!primary) {
6535
+ return {
6536
+ capability: "unsupported",
6537
+ reason: "Couldn't find an active network interface to check.",
6538
+ networkType: "unknown",
6539
+ mac: null,
6540
+ broadcast: null,
6541
+ checkedAtMs
6542
+ };
6543
+ }
6544
+ const broadcast = computeBroadcastAddress(primary.address, primary.netmask);
6545
+ try {
6546
+ const detected = plat === "win32" ? await detectWindows(primary) : plat === "darwin" ? await detectMac(primary) : plat === "linux" ? await detectLinux(primary) : (() => {
6547
+ throw new Error(`unsupported OS: ${plat}`);
6548
+ })();
6549
+ const { capability, reason } = computeWolCapability(detected.networkType, detected.wolWorks);
6550
+ return { capability, reason, networkType: detected.networkType, mac: primary.mac, broadcast, checkedAtMs };
6551
+ } catch (e) {
6552
+ return {
6553
+ capability: "unsupported",
6554
+ reason: `Couldn't check Wake-on-LAN support: ${e instanceof Error ? e.message : String(e)}`,
6555
+ networkType: "unknown",
6556
+ mac: primary.mac,
6557
+ broadcast,
6558
+ checkedAtMs
6559
+ };
6560
+ }
6561
+ }
6562
+
6237
6563
  // ../daemon/src/session-manager.ts
6238
6564
  function resolveDaemonVersion() {
6239
6565
  try {
@@ -6245,6 +6571,9 @@ function resolveDaemonVersion() {
6245
6571
  }
6246
6572
  }
6247
6573
  var DAEMON_VERSION = resolveDaemonVersion();
6574
+ var SYSTEM_LOG_SESSION_ID = "system";
6575
+ var MOBILE_SESSION_PREAMBLE_BASE = "You're being driven remotely from a phone via offhand \u2014 the person who sent this prompt isn't at this keyboard and can't see this terminal. Lean on visual proof (screenshots, diffs, clear file paths) over long text explanations where you can, since they're reading this on a phone screen, not a terminal.";
6576
+ var MOBILE_SESSION_PREAMBLE_STATUS_TOOL = " You have an offhand_status_update tool available \u2014 call it with a short plain-English note at meaningful checkpoints (starting a long step, hitting a snag, finishing a phase) so they see progress without waiting for the whole run to end.";
6248
6577
  var SessionManager = class {
6249
6578
  constructor(store2, runners2) {
6250
6579
  this.store = store2;
@@ -6259,6 +6588,14 @@ var SessionManager = class {
6259
6588
  // sessionId → queued prompts
6260
6589
  runners = /* @__PURE__ */ new Map();
6261
6590
  runnerAvailability = /* @__PURE__ */ new Map();
6591
+ /** handoverId → what was proposed, so `handover-response` (which only
6592
+ * carries the id back) knows what to act on. */
6593
+ pendingHandovers = /* @__PURE__ */ new Map();
6594
+ /** sessionId → the most recent offhand_status_update note (Execution Plan
6595
+ * 5) — becomes a Handover Packet's optional agentSummary when a handover
6596
+ * is proposed afterward. Still zero hosted AI: the agent chose to send
6597
+ * this, on its own tokens, no different from any other tool call. */
6598
+ latestStatusUpdate = /* @__PURE__ */ new Map();
6262
6599
  /** Optional artifact plumbing (set by index when capture is possible). */
6263
6600
  uploader = null;
6264
6601
  capture = null;
@@ -6276,6 +6613,21 @@ var SessionManager = class {
6276
6613
  for (const [id, r2] of this.runners) {
6277
6614
  this.runnerAvailability.set(id, await r2.detect());
6278
6615
  }
6616
+ void this.recheckWolCapability();
6617
+ }
6618
+ /** Runs the real Wake-on-LAN/WiFi probe and persists the result — called
6619
+ * once at startup and again on-demand via the phone's "check again"
6620
+ * action (Execution Plan 7), since drivers/BIOS/network type can change. */
6621
+ async recheckWolCapability() {
6622
+ const result = await probeWolCapability();
6623
+ this.store.setWolCapability({
6624
+ capability: result.capability,
6625
+ reason: result.reason,
6626
+ networkType: result.networkType,
6627
+ mac: result.mac,
6628
+ broadcast: result.broadcast,
6629
+ checkedAtMs: result.checkedAtMs
6630
+ });
6279
6631
  }
6280
6632
  // ---- transport attachment --------------------------------------------------
6281
6633
  attach(sink) {
@@ -6283,10 +6635,16 @@ var SessionManager = class {
6283
6635
  return () => this.sinks.delete(sink);
6284
6636
  }
6285
6637
  hello() {
6638
+ const wol = this.store.getWolCapability();
6286
6639
  return {
6287
6640
  type: "hello",
6288
6641
  protocol: 2,
6289
- host: { hostname: hostname(), os: platform(), daemonVersion: DAEMON_VERSION },
6642
+ host: {
6643
+ hostname: hostname(),
6644
+ os: platform2(),
6645
+ daemonVersion: DAEMON_VERSION,
6646
+ ...wol ? { wolCapability: wol.capability, wolReason: wol.reason, wolCheckedAtMs: wol.checkedAtMs } : {}
6647
+ },
6290
6648
  lastSeq: this.store.lastSeq()
6291
6649
  };
6292
6650
  }
@@ -6297,7 +6655,8 @@ var SessionManager = class {
6297
6655
  available: this.runnerAvailability.get(r2.id) ?? false,
6298
6656
  ...r2.loggedIn ? { loggedIn: r2.loggedIn() } : {},
6299
6657
  models: r2.models,
6300
- supportsApprovals: r2.supportsApprovals
6658
+ supportsApprovals: r2.supportsApprovals,
6659
+ ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {}
6301
6660
  }));
6302
6661
  const workspaces = await Promise.all(
6303
6662
  this.store.listWorkspaces().map((w2) => workspaceInfo(w2))
@@ -6392,6 +6751,41 @@ var SessionManager = class {
6392
6751
  case "resume":
6393
6752
  for (const m3 of this.store.replayAfter(msg.afterSeq)) reply(m3);
6394
6753
  return;
6754
+ case "handover-response": {
6755
+ const pending = this.pendingHandovers.get(msg.handoverId);
6756
+ if (!pending || pending.sessionId !== msg.sessionId) return;
6757
+ this.pendingHandovers.delete(msg.handoverId);
6758
+ if (!msg.accept) {
6759
+ this.record(msg.sessionId, (seq) => ({
6760
+ type: "run-event",
6761
+ sessionId: msg.sessionId,
6762
+ runId: "",
6763
+ seq,
6764
+ event: { type: "handover-declined", id: msg.handoverId }
6765
+ }));
6766
+ return;
6767
+ }
6768
+ const session = this.store.getSession(msg.sessionId);
6769
+ const toRunner = this.runners.get(pending.toRunnerId);
6770
+ if (!session || !toRunner) return;
6771
+ const fromRunnerId = session.runnerId;
6772
+ const fromRunner = this.runners.get(fromRunnerId);
6773
+ const prompt = translatePacketToPrompt(pending.packet, fromRunner?.name ?? fromRunnerId);
6774
+ this.store.updateSession(session.id, { runnerId: pending.toRunnerId, conversationId: null });
6775
+ this.record(session.id, (seq) => ({
6776
+ type: "run-event",
6777
+ sessionId: session.id,
6778
+ runId: "",
6779
+ seq,
6780
+ event: { type: "handover-accepted", id: msg.handoverId, fromRunnerId, toRunnerId: pending.toRunnerId }
6781
+ }));
6782
+ const q2 = this.queues.get(session.id) ?? [];
6783
+ q2.push(prompt);
6784
+ this.queues.set(session.id, q2);
6785
+ this.pump(session.id);
6786
+ await this.broadcastManifest();
6787
+ return;
6788
+ }
6395
6789
  case "session-create": {
6396
6790
  const row = this.store.createSession(msg.workspace, msg.runnerId, msg.model, msg.label);
6397
6791
  this.store.upsertWorkspace(msg.workspace);
@@ -6415,7 +6809,9 @@ var SessionManager = class {
6415
6809
  ...msg.model !== void 0 ? { model: msg.model || null } : {},
6416
6810
  ...msg.archived !== void 0 ? { archived: msg.archived } : {},
6417
6811
  ...msg.permissionMode !== void 0 ? { permissionMode: msg.permissionMode } : {},
6418
- ...msg.effort !== void 0 ? { effort: msg.effort } : {}
6812
+ ...msg.effort !== void 0 ? { effort: msg.effort } : {},
6813
+ ...msg.budgetCostUsdCap !== void 0 ? { budgetCostUsdCap: msg.budgetCostUsdCap } : {},
6814
+ ...msg.budgetMinutesCap !== void 0 ? { budgetMinutesCap: msg.budgetMinutesCap } : {}
6419
6815
  });
6420
6816
  await this.broadcastManifest();
6421
6817
  return;
@@ -6448,8 +6844,56 @@ var SessionManager = class {
6448
6844
  case "fs-list":
6449
6845
  reply({ type: "fs-response", rpcId: msg.rpcId, ...listFolders(msg.path) });
6450
6846
  return;
6847
+ case "fs-create-folder": {
6848
+ try {
6849
+ const safeName = safeDropName(msg.name);
6850
+ mkdirSync2(join6(resolve3(msg.path), safeName), { recursive: false });
6851
+ } catch (e) {
6852
+ reply({
6853
+ type: "fs-response",
6854
+ rpcId: msg.rpcId,
6855
+ ...listFolders(msg.path),
6856
+ error: `couldn't create folder: ${e instanceof Error ? e.message : String(e)}`
6857
+ });
6858
+ return;
6859
+ }
6860
+ reply({ type: "fs-response", rpcId: msg.rpcId, ...listFolders(msg.path) });
6861
+ return;
6862
+ }
6863
+ case "wol-recheck":
6864
+ await this.recheckWolCapability();
6865
+ reply(this.hello());
6866
+ return;
6451
6867
  }
6452
6868
  }
6869
+ /** The daemon's own relay connection dropped — logged durably so a phone
6870
+ * that wasn't listening live can still see it in the "left off" digest
6871
+ * (Execution Plan 6), unlike the relay's ephemeral `presence` frame. */
6872
+ recordDeviceOffline() {
6873
+ this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({ type: "device-offline", seq, atMs: Date.now() }));
6874
+ }
6875
+ /** A running agent proactively pushed a status note (Execution Plan 5's
6876
+ * offhand_status_update MCP tool). Recorded into the normal session log
6877
+ * (so it shows in the transcript and the run timeline) and cached as the
6878
+ * latest note for this session, ready to enrich a Handover Packet later. */
6879
+ recordStatusUpdate(sessionId, text) {
6880
+ this.latestStatusUpdate.set(sessionId, text);
6881
+ this.record(sessionId, (seq) => ({
6882
+ type: "run-event",
6883
+ sessionId,
6884
+ runId: "",
6885
+ seq,
6886
+ event: { type: "status-update", text }
6887
+ }));
6888
+ }
6889
+ recordDeviceReconnected(offlineForMs) {
6890
+ this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({
6891
+ type: "device-reconnected",
6892
+ seq,
6893
+ atMs: Date.now(),
6894
+ offlineForMs
6895
+ }));
6896
+ }
6453
6897
  async sendDropToPhone(path) {
6454
6898
  if (!this.uploader) throw new Error("drop upload unavailable: start daemon with --relay");
6455
6899
  const file = readOutgoingDrop(path);
@@ -6492,10 +6936,17 @@ var SessionManager = class {
6492
6936
  const touchedFiles = [];
6493
6937
  let toolCount = 0;
6494
6938
  let succeeded = false;
6939
+ let limitHitEmitted = false;
6940
+ let budgetWarned = false;
6941
+ const hasStatusTool = runner.id === "claude-code" && session.permissionMode !== "bypass";
6942
+ const sentPrompt = session.conversationId ? prompt : `${MOBILE_SESSION_PREAMBLE_BASE}${hasStatusTool ? MOBILE_SESSION_PREAMBLE_STATUS_TOOL : ""}
6943
+
6944
+ ${prompt}`;
6495
6945
  const handle = runner.start(
6496
6946
  {
6497
6947
  runId,
6498
- prompt,
6948
+ sessionId: session.id,
6949
+ prompt: sentPrompt,
6499
6950
  workspace: session.workspace,
6500
6951
  ...session.model ? { model: session.model } : {},
6501
6952
  ...session.conversationId ? { resumeConversationId: session.conversationId } : {},
@@ -6528,6 +6979,17 @@ var SessionManager = class {
6528
6979
  seq,
6529
6980
  event
6530
6981
  }));
6982
+ const limitHit = checkLimitHit(event, limitHitEmitted);
6983
+ if (limitHit) {
6984
+ limitHitEmitted = true;
6985
+ this.record(session.id, (seq) => ({ type: "run-event", sessionId: session.id, runId, seq, event: limitHit }));
6986
+ void this.proposeHandover(session, runId, limitHit.reason);
6987
+ }
6988
+ const budgetHit = checkBudgetWarning(event, session, budgetWarned) ?? checkTimeBudget(session, Date.now() - startedAt, budgetWarned);
6989
+ if (budgetHit) {
6990
+ budgetWarned = true;
6991
+ this.record(session.id, (seq) => ({ type: "run-event", sessionId: session.id, runId, seq, event: budgetHit }));
6992
+ }
6531
6993
  }
6532
6994
  } finally {
6533
6995
  this.active.delete(session.id);
@@ -6559,7 +7021,65 @@ var SessionManager = class {
6559
7021
  }));
6560
7022
  }
6561
7023
  }
7024
+ /** Builds and offers a Handover Packet once a limit-hit fires. Entirely
7025
+ * deterministic (live git diff + this session's own log) — no cooperation
7026
+ * needed from the agent that just hit its limit, which is the point:
7027
+ * this must still work when that agent is already dead or blocked, not
7028
+ * only the graceful case. Silent (no card at all) when there's no other
7029
+ * detected, available runner to hand off to — never a dead-end proposal. */
7030
+ async proposeHandover(session, runId, reason) {
7031
+ const target = this.pickHandoverTarget(session.runnerId);
7032
+ if (!target) return;
7033
+ const diff = await liveDiff(session.workspace);
7034
+ const { items } = this.store.historyPage(session.id, 0, 500);
7035
+ const packet = assembleHandoverPacket(items, diff);
7036
+ const agentSummary = this.latestStatusUpdate.get(session.id);
7037
+ if (agentSummary) packet.agentSummary = agentSummary;
7038
+ const id = randomUUID();
7039
+ this.pendingHandovers.set(id, { sessionId: session.id, toRunnerId: target.id, packet });
7040
+ this.record(session.id, (seq) => ({
7041
+ type: "run-event",
7042
+ sessionId: session.id,
7043
+ runId,
7044
+ seq,
7045
+ event: { type: "handover-proposed", id, fromRunnerId: session.runnerId, toRunnerId: target.id, reason, packet }
7046
+ }));
7047
+ }
7048
+ /** First other detected+available runner — good enough for v1 (single
7049
+ * candidate, not a picker); a real choice among several becomes relevant
7050
+ * once more than one alternative is commonly available at once. */
7051
+ pickHandoverTarget(currentRunnerId) {
7052
+ for (const [id, runner] of this.runners) {
7053
+ if (id === currentRunnerId) continue;
7054
+ if (!(this.runnerAvailability.get(id) ?? false)) continue;
7055
+ return runner;
7056
+ }
7057
+ return null;
7058
+ }
6562
7059
  };
7060
+ var LIMIT_HIT_THRESHOLD = 0.9;
7061
+ function checkLimitHit(event, alreadyEmitted) {
7062
+ if (alreadyEmitted || event.type !== "rate-limit") return null;
7063
+ const utilization = Math.max(event.fiveHourUtilization, event.sevenDayUtilization);
7064
+ if (utilization < LIMIT_HIT_THRESHOLD) return null;
7065
+ return { type: "limit-hit", reason: "rate_limit", utilization };
7066
+ }
7067
+ function checkBudgetWarning(event, session, alreadyWarned) {
7068
+ if (alreadyWarned || event.type !== "usage" || !session.budgetCostUsdCap) return null;
7069
+ if (event.costUsd === void 0 || event.costUsd < session.budgetCostUsdCap) return null;
7070
+ return { type: "budget-warning", kind: "cost", capUsd: session.budgetCostUsdCap, actualUsd: event.costUsd };
7071
+ }
7072
+ function checkTimeBudget(session, elapsedMs, alreadyWarned) {
7073
+ if (alreadyWarned || !session.budgetMinutesCap) return null;
7074
+ const minutes = elapsedMs / 6e4;
7075
+ if (minutes < session.budgetMinutesCap) return null;
7076
+ return {
7077
+ type: "budget-warning",
7078
+ kind: "time",
7079
+ capMinutes: session.budgetMinutesCap,
7080
+ actualMinutes: Math.round(minutes)
7081
+ };
7082
+ }
6563
7083
  function trackTouches(event, touched) {
6564
7084
  if (event.type !== "tool") return;
6565
7085
  if (!/^(Edit|Write|MultiEdit|NotebookEdit)/.test(event.name)) return;
@@ -6576,16 +7096,16 @@ function listFolders(path) {
6576
7096
  const current = resolve3(path);
6577
7097
  const dirs = safeReadDir2(current).filter((entry) => entry.isDirectory() && !skipDir(entry.name)).map((entry) => {
6578
7098
  const full = join6(current, entry.name);
6579
- return { name: entry.name, path: full, isGit: existsSync6(join6(full, ".git")) };
7099
+ return { name: entry.name, path: full, isGit: existsSync7(join6(full, ".git")) };
6580
7100
  }).sort((a2, b2) => Number(b2.isGit) - Number(a2.isGit) || a2.name.localeCompare(b2.name)).slice(0, 200);
6581
7101
  return { path: current, parent: isRoot(current) ? null : dirname2(current), dirs };
6582
7102
  }
6583
7103
  function driveRoots() {
6584
- if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync6("/.git") }];
7104
+ if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync7("/.git") }];
6585
7105
  const roots = [];
6586
7106
  for (let code = 67; code <= 90; code++) {
6587
7107
  const name = `${String.fromCharCode(code)}:\\`;
6588
- if (existsSync6(name)) roots.push({ name, path: name, isGit: existsSync6(join6(name, ".git")) });
7108
+ if (existsSync7(name)) roots.push({ name, path: name, isGit: existsSync7(join6(name, ".git")) });
6589
7109
  }
6590
7110
  return roots.sort((a2, b2) => a2.name.localeCompare(b2.name)).slice(0, 200);
6591
7111
  }
@@ -6650,6 +7170,15 @@ var Store = class {
6650
7170
  dev_url TEXT
6651
7171
  );
6652
7172
  CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(text, session_id UNINDEXED, seq UNINDEXED);
7173
+ CREATE TABLE IF NOT EXISTS device (
7174
+ id INTEGER PRIMARY KEY CHECK (id = 1),
7175
+ wol_capability TEXT,
7176
+ wol_reason TEXT,
7177
+ wol_network_type TEXT,
7178
+ wol_mac TEXT,
7179
+ wol_broadcast TEXT,
7180
+ wol_checked_at_ms INTEGER
7181
+ );
6653
7182
  `);
6654
7183
  try {
6655
7184
  this.db.exec(`ALTER TABLE workspaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'balanced'`);
@@ -6663,6 +7192,14 @@ var Store = class {
6663
7192
  this.db.exec(`ALTER TABLE sessions ADD COLUMN effort TEXT`);
6664
7193
  } catch {
6665
7194
  }
7195
+ try {
7196
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_cost_usd_cap REAL`);
7197
+ } catch {
7198
+ }
7199
+ try {
7200
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_minutes_cap INTEGER`);
7201
+ } catch {
7202
+ }
6666
7203
  }
6667
7204
  // ---- sessions -------------------------------------------------------------
6668
7205
  createSession(workspace, runnerId, model, label) {
@@ -6676,7 +7213,9 @@ var Store = class {
6676
7213
  archived: false,
6677
7214
  conversationId: null,
6678
7215
  permissionMode: "guarded",
6679
- effort: null
7216
+ effort: null,
7217
+ budgetCostUsdCap: null,
7218
+ budgetMinutesCap: null
6680
7219
  };
6681
7220
  this.db.prepare(
6682
7221
  `INSERT INTO sessions (id, workspace, runner_id, model, label, created_at_ms, archived, conversation_id)
@@ -6696,7 +7235,9 @@ var Store = class {
6696
7235
  archived: Boolean(r2.archived),
6697
7236
  conversationId: r2.conversation_id ?? null,
6698
7237
  permissionMode: r2.permission_mode ?? "guarded",
6699
- effort: r2.effort ?? null
7238
+ effort: r2.effort ?? null,
7239
+ budgetCostUsdCap: r2.budget_cost_usd_cap ?? null,
7240
+ budgetMinutesCap: r2.budget_minutes_cap ?? null
6700
7241
  }));
6701
7242
  }
6702
7243
  getSession(id) {
@@ -6707,8 +7248,20 @@ var Store = class {
6707
7248
  if (!current) return;
6708
7249
  const next = { ...current, ...patch };
6709
7250
  this.db.prepare(
6710
- `UPDATE sessions SET label = ?, model = ?, archived = ?, conversation_id = ?, permission_mode = ?, effort = ? WHERE id = ?`
6711
- ).run(next.label, next.model, next.archived ? 1 : 0, next.conversationId, next.permissionMode, next.effort, id);
7251
+ `UPDATE sessions SET label = ?, model = ?, archived = ?, conversation_id = ?, permission_mode = ?, effort = ?,
7252
+ budget_cost_usd_cap = ?, budget_minutes_cap = ?, runner_id = ? WHERE id = ?`
7253
+ ).run(
7254
+ next.label,
7255
+ next.model,
7256
+ next.archived ? 1 : 0,
7257
+ next.conversationId,
7258
+ next.permissionMode,
7259
+ next.effort,
7260
+ next.budgetCostUsdCap,
7261
+ next.budgetMinutesCap,
7262
+ next.runnerId,
7263
+ id
7264
+ );
6712
7265
  }
6713
7266
  toSessionInfo(row, busy, queued) {
6714
7267
  return {
@@ -6722,7 +7275,9 @@ var Store = class {
6722
7275
  permissionMode: row.permissionMode,
6723
7276
  effort: row.effort ?? void 0,
6724
7277
  busy,
6725
- queuedPrompts: queued
7278
+ queuedPrompts: queued,
7279
+ budgetCostUsdCap: row.budgetCostUsdCap ?? void 0,
7280
+ budgetMinutesCap: row.budgetMinutesCap ?? void 0
6726
7281
  };
6727
7282
  }
6728
7283
  // ---- transcript log ---------------------------------------------------------
@@ -6793,6 +7348,32 @@ var Store = class {
6793
7348
  setWorkspacePolicy(path, policy) {
6794
7349
  this.db.prepare(`UPDATE workspaces SET policy = ? WHERE path = ?`).run(policy, path);
6795
7350
  }
7351
+ // ---- device capability (Execution Plan 7) ----------------------------------
7352
+ getWolCapability() {
7353
+ const row = this.db.prepare(`SELECT * FROM device WHERE id = 1`).get();
7354
+ if (!row || row.wol_capability == null) return null;
7355
+ return {
7356
+ capability: row.wol_capability,
7357
+ reason: row.wol_reason,
7358
+ networkType: row.wol_network_type,
7359
+ mac: row.wol_mac ?? null,
7360
+ broadcast: row.wol_broadcast ?? null,
7361
+ checkedAtMs: row.wol_checked_at_ms
7362
+ };
7363
+ }
7364
+ setWolCapability(v2) {
7365
+ this.db.prepare(
7366
+ `INSERT INTO device (id, wol_capability, wol_reason, wol_network_type, wol_mac, wol_broadcast, wol_checked_at_ms)
7367
+ VALUES (1, ?, ?, ?, ?, ?, ?)
7368
+ ON CONFLICT(id) DO UPDATE SET
7369
+ wol_capability = excluded.wol_capability,
7370
+ wol_reason = excluded.wol_reason,
7371
+ wol_network_type = excluded.wol_network_type,
7372
+ wol_mac = excluded.wol_mac,
7373
+ wol_broadcast = excluded.wol_broadcast,
7374
+ wol_checked_at_ms = excluded.wol_checked_at_ms`
7375
+ ).run(v2.capability, v2.reason, v2.networkType, v2.mac, v2.broadcast, v2.checkedAtMs);
7376
+ }
6796
7377
  };
6797
7378
  function searchableText(msg) {
6798
7379
  switch (msg.type) {
@@ -6803,6 +7384,7 @@ function searchableText(msg) {
6803
7384
  if (msg.event.type === "tool") return msg.event.summary;
6804
7385
  if (msg.event.type === "done") return msg.event.summary;
6805
7386
  if (msg.event.type === "error") return msg.event.message;
7387
+ if (msg.event.type === "status-update") return msg.event.text;
6806
7388
  return "";
6807
7389
  case "drop":
6808
7390
  return msg.name;
@@ -10872,11 +11454,42 @@ var ApprovalQuestionSchema = external_exports.object({
10872
11454
  options: external_exports.array(external_exports.object({ label: external_exports.string(), description: external_exports.string().optional() })),
10873
11455
  multiSelect: external_exports.boolean().optional()
10874
11456
  });
11457
+ var TodoItemSchema = external_exports.object({
11458
+ content: external_exports.string(),
11459
+ status: external_exports.enum(["pending", "in_progress", "completed"]),
11460
+ activeForm: external_exports.string().optional()
11461
+ });
11462
+ var HandoverPacketSchema = external_exports.object({
11463
+ /** Live `git diff HEAD` against the workspace, captured at handover time. */
11464
+ diff: external_exports.string(),
11465
+ /** Tool-call summaries this session made, oldest first, capped to the most recent. */
11466
+ toolSummaries: external_exports.array(external_exports.string()),
11467
+ /** The last TodoWrite call's task list, verbatim — never summarized. */
11468
+ todos: external_exports.array(TodoItemSchema).optional(),
11469
+ /** Approvals raised but never resolved (still pending or timed out) at handover time. */
11470
+ pendingApprovals: external_exports.array(
11471
+ external_exports.object({ action: external_exports.string(), detail: external_exports.string(), risk: external_exports.enum(["low", "high"]) })
11472
+ ),
11473
+ /** Recent prompt/response turns, oldest first, capped — for tone/context continuity. */
11474
+ recentMessages: external_exports.array(external_exports.object({ role: external_exports.enum(["user", "assistant"]), text: external_exports.string() })),
11475
+ /** Optional free-text self-summary from the outgoing agent — enrichment
11476
+ * only, attempted when there was runway before a hard cutoff. Its absence
11477
+ * changes nothing; the packet above is already sufficient on its own. */
11478
+ agentSummary: external_exports.string().optional()
11479
+ });
10875
11480
  var RunEventSchema = external_exports.discriminatedUnion("type", [
10876
11481
  external_exports.object({ type: external_exports.literal("text"), chunk: external_exports.string() }),
10877
11482
  /** Extended-thinking content, streamed the same way as text (medium+ effort tiers). */
10878
11483
  external_exports.object({ type: external_exports.literal("thinking"), chunk: external_exports.string() }),
10879
- external_exports.object({ type: external_exports.literal("tool"), name: external_exports.string(), summary: external_exports.string() }),
11484
+ external_exports.object({
11485
+ type: external_exports.literal("tool"),
11486
+ name: external_exports.string(),
11487
+ summary: external_exports.string(),
11488
+ /** Structured payload for calls that matter to continuity (TodoWrite's
11489
+ * actual task list). Optional: most tool calls have none, and omitting
11490
+ * it is always safe — `summary` alone still renders the transcript. */
11491
+ todos: external_exports.array(TodoItemSchema).optional()
11492
+ }),
10880
11493
  external_exports.object({
10881
11494
  type: external_exports.literal("approval"),
10882
11495
  id: external_exports.string(),
@@ -10921,12 +11534,67 @@ var RunEventSchema = external_exports.discriminatedUnion("type", [
10921
11534
  fiveHourResetsAtMs: external_exports.number(),
10922
11535
  sevenDayUtilization: external_exports.number(),
10923
11536
  sevenDayResetsAtMs: external_exports.number()
11537
+ }),
11538
+ /** Emitted once, when the daemon's own threshold check crosses before the
11539
+ * CLI itself refuses — the phone's cue to propose a handover (Execution
11540
+ * Plan 5) while there's still runway to do it gracefully. */
11541
+ external_exports.object({
11542
+ type: external_exports.literal("limit-hit"),
11543
+ reason: external_exports.enum(["rate_limit", "context_window", "cost_cap"]),
11544
+ /** 0-1 utilization that crossed the threshold, when known. */
11545
+ utilization: external_exports.number().optional()
11546
+ }),
11547
+ /** Session cost or wall-clock time crossed the cap set on it (Execution
11548
+ * Plan 1's budget feature) — informational, the run is not cancelled. */
11549
+ external_exports.object({
11550
+ type: external_exports.literal("budget-warning"),
11551
+ kind: external_exports.enum(["cost", "time"]),
11552
+ capUsd: external_exports.number().optional(),
11553
+ capMinutes: external_exports.number().optional(),
11554
+ actualUsd: external_exports.number().optional(),
11555
+ actualMinutes: external_exports.number().optional()
11556
+ }),
11557
+ /** The daemon is offering to move this session to another runner
11558
+ * (Execution Plan 4) — built from the log/diff alone, never from asking
11559
+ * this agent to cooperate (it may already be unable to). */
11560
+ external_exports.object({
11561
+ type: external_exports.literal("handover-proposed"),
11562
+ id: external_exports.string(),
11563
+ fromRunnerId: external_exports.string(),
11564
+ toRunnerId: external_exports.string(),
11565
+ reason: external_exports.enum(["rate_limit", "context_window", "cost_cap", "crash", "manual"]),
11566
+ packet: HandoverPacketSchema
11567
+ }),
11568
+ external_exports.object({
11569
+ type: external_exports.literal("handover-declined"),
11570
+ id: external_exports.string()
11571
+ }),
11572
+ external_exports.object({
11573
+ type: external_exports.literal("handover-accepted"),
11574
+ id: external_exports.string(),
11575
+ fromRunnerId: external_exports.string(),
11576
+ toRunnerId: external_exports.string()
11577
+ }),
11578
+ /** A proactive note the agent chose to push mid-run (Execution Plan 5's
11579
+ * offhand_status_update MCP tool) — outside the normal streamed
11580
+ * text/thinking, and never fabricated: only exists when the agent itself
11581
+ * called the tool. The most recent one on a session also becomes the
11582
+ * Handover Packet's optional `agentSummary` (Execution Plan 4) when a
11583
+ * handover is proposed afterward — still zero hosted AI, since it's the
11584
+ * same agent, on its own tokens, choosing to leave a note. */
11585
+ external_exports.object({
11586
+ type: external_exports.literal("status-update"),
11587
+ text: external_exports.string()
10924
11588
  })
10925
11589
  ]);
10926
11590
  var PermissionModeSchema = external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]);
10927
11591
  var EffortSchema = external_exports.enum(["low", "medium", "high", "max"]);
10928
11592
  var RunSpecSchema = external_exports.object({
10929
11593
  runId: external_exports.string(),
11594
+ /** The offhand session this run belongs to — lets an MCP bridge tool
11595
+ * (Execution Plan 5) tag a proactive call (status update, dropped file)
11596
+ * with the right session without a global single-listener broker. */
11597
+ sessionId: external_exports.string(),
10930
11598
  prompt: external_exports.string().min(1),
10931
11599
  /** Absolute path of the workspace the agent runs in. */
10932
11600
  workspace: external_exports.string(),
@@ -10951,7 +11619,11 @@ var RunnerInfoSchema = external_exports.object({
10951
11619
  version: external_exports.string().optional(),
10952
11620
  /** Model ids the phone may offer; empty = CLI default only. */
10953
11621
  models: external_exports.array(external_exports.string()),
10954
- supportsApprovals: external_exports.boolean()
11622
+ supportsApprovals: external_exports.boolean(),
11623
+ /** Thinking-budget tiers this runner actually honors (Execution Plan 3) —
11624
+ * undefined means the phone should hide the effort control entirely for
11625
+ * this runner, not show one that silently does nothing. */
11626
+ effortTiers: external_exports.array(external_exports.enum(["low", "medium", "high", "max"])).optional()
10955
11627
  });
10956
11628
  var ApprovalPolicySchema = external_exports.enum(["paranoid", "balanced", "trusting"]);
10957
11629
  var WorkspaceInfoSchema = external_exports.object({
@@ -10978,12 +11650,23 @@ var SessionInfoSchema = external_exports.object({
10978
11650
  effort: external_exports.enum(["low", "medium", "high", "max"]).optional(),
10979
11651
  /** Whether a run is active or prompts are queued right now. */
10980
11652
  busy: external_exports.boolean(),
10981
- queuedPrompts: external_exports.number().int()
11653
+ queuedPrompts: external_exports.number().int(),
11654
+ /** Per-session budget caps (Execution Plan 1) — undefined means no cap set. */
11655
+ budgetCostUsdCap: external_exports.number().optional(),
11656
+ budgetMinutesCap: external_exports.number().int().optional()
10982
11657
  });
10983
11658
  var HostInfoSchema = external_exports.object({
10984
11659
  hostname: external_exports.string(),
10985
11660
  os: external_exports.string(),
10986
- daemonVersion: external_exports.string()
11661
+ daemonVersion: external_exports.string(),
11662
+ /** Wake-on-LAN/WiFi capability, detected per device (Execution Plan 7) —
11663
+ * never assumed from the OS alone. Absent only if no probe has completed
11664
+ * yet (e.g. mid-startup). */
11665
+ wolCapability: external_exports.enum(["supported", "wifi-limited", "unsupported"]).optional(),
11666
+ /** Human-readable reason behind wolCapability — always present alongside
11667
+ * it, since a bare enum can't explain itself. */
11668
+ wolReason: external_exports.string().optional(),
11669
+ wolCheckedAtMs: external_exports.number().int().optional()
10987
11670
  });
10988
11671
 
10989
11672
  // ../shared/src/protocol-v2.ts
@@ -11017,6 +11700,12 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11017
11700
  answer: external_exports.string().optional()
11018
11701
  }),
11019
11702
  external_exports.object({ type: external_exports.literal("resume"), afterSeq: external_exports.number().int().nonnegative() }),
11703
+ external_exports.object({
11704
+ type: external_exports.literal("handover-response"),
11705
+ sessionId: external_exports.string(),
11706
+ handoverId: external_exports.string(),
11707
+ accept: external_exports.boolean()
11708
+ }),
11020
11709
  external_exports.object({
11021
11710
  type: external_exports.literal("drop-send"),
11022
11711
  blobId: external_exports.string(),
@@ -11047,7 +11736,10 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11047
11736
  model: external_exports.string().optional(),
11048
11737
  archived: external_exports.boolean().optional(),
11049
11738
  permissionMode: external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]).optional(),
11050
- effort: external_exports.enum(["low", "medium", "high", "max"]).optional()
11739
+ effort: external_exports.enum(["low", "medium", "high", "max"]).optional(),
11740
+ /** Per-session budget caps (Execution Plan 1). null clears an existing cap. */
11741
+ budgetCostUsdCap: external_exports.number().positive().nullable().optional(),
11742
+ budgetMinutesCap: external_exports.number().int().positive().nullable().optional()
11051
11743
  }),
11052
11744
  /** Start a fresh agent conversation inside the session. */
11053
11745
  external_exports.object({ type: external_exports.literal("session-reset"), sessionId: external_exports.string() }),
@@ -11081,7 +11773,19 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11081
11773
  type: external_exports.literal("fs-list"),
11082
11774
  rpcId: external_exports.string(),
11083
11775
  path: external_exports.string().optional()
11084
- })
11776
+ }),
11777
+ /** Mobile-first session bootstrap (Execution Plan 5): create a new project
11778
+ * folder from the phone's workspace browser, no laptop needed first. */
11779
+ external_exports.object({
11780
+ type: external_exports.literal("fs-create-folder"),
11781
+ rpcId: external_exports.string(),
11782
+ path: external_exports.string(),
11783
+ name: external_exports.string().min(1)
11784
+ }),
11785
+ /** Re-run the Wake-on-LAN/WiFi capability probe (Execution Plan 7) — an
11786
+ * explicit "check again" action, since drivers/BIOS settings/network type
11787
+ * can change after first setup. */
11788
+ external_exports.object({ type: external_exports.literal("wol-recheck") })
11085
11789
  ]);
11086
11790
  var ReceiptSchema = external_exports.object({
11087
11791
  runId: external_exports.string(),
@@ -11139,6 +11843,17 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11139
11843
  size: external_exports.number().int().nonnegative(),
11140
11844
  direction: external_exports.enum(["to-phone", "to-pc"])
11141
11845
  }),
11846
+ /** The daemon's own relay connection dropped/came back — logged durably
11847
+ * (unlike the relay's live `presence` frame) so a phone that wasn't
11848
+ * listening at the time can still reconstruct "laptop asleep since 11pm"
11849
+ * from history (Execution Plan 6's digest). Not tied to any run. */
11850
+ external_exports.object({ type: external_exports.literal("device-offline"), seq: external_exports.number().int(), atMs: external_exports.number().int() }),
11851
+ external_exports.object({
11852
+ type: external_exports.literal("device-reconnected"),
11853
+ seq: external_exports.number().int(),
11854
+ atMs: external_exports.number().int(),
11855
+ offlineForMs: external_exports.number().int().nonnegative()
11856
+ }),
11142
11857
  // RPC responses (not seq-logged)
11143
11858
  external_exports.object({
11144
11859
  type: external_exports.literal("history-response"),
@@ -11164,7 +11879,12 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11164
11879
  rpcId: external_exports.string(),
11165
11880
  path: external_exports.string(),
11166
11881
  parent: external_exports.string().nullable(),
11167
- dirs: external_exports.array(FsDirSchema)
11882
+ dirs: external_exports.array(FsDirSchema),
11883
+ /** Set only by fs-create-folder on failure — the generic top-level
11884
+ * `error` message carries no rpcId, so it can never resolve a specific
11885
+ * pending RPC (see client.ts's rpc()); this keeps the failure correctly
11886
+ * routed back to whoever asked, with the listing unchanged either way. */
11887
+ error: external_exports.string().optional()
11168
11888
  }),
11169
11889
  external_exports.object({ type: external_exports.literal("error"), message: external_exports.string() })
11170
11890
  ]);
@@ -11188,7 +11908,8 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
11188
11908
  */
11189
11909
  external_exports.object({
11190
11910
  kind: external_exports.literal("notify"),
11191
- notice: external_exports.enum(["approval", "drop", "done", "error", "progress"]),
11911
+ notice: external_exports.enum(["approval", "drop", "done", "error", "progress", "handover", "limit-hit"]),
11912
+ /** 'approval'/'handover' only — the approval/handover id being acted on. */
11192
11913
  id: external_exports.string().optional(),
11193
11914
  sessionId: external_exports.string().optional(),
11194
11915
  /** 'progress' only — a bare action count, never the action itself (no content in push). */
@@ -11196,6 +11917,16 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
11196
11917
  }),
11197
11918
  /** relay → daemon: verdict delivered via push-notification action button. */
11198
11919
  external_exports.object({ kind: external_exports.literal("verdict"), approvalId: external_exports.string(), approve: external_exports.boolean() }),
11920
+ /** relay → daemon: handover accept/decline from a push-notification action
11921
+ * button (Execution Plan 6) — same "opaque ids only" shape as `verdict`,
11922
+ * with `chatSessionId` since handover-response needs it to look up the
11923
+ * pending proposal. */
11924
+ external_exports.object({
11925
+ kind: external_exports.literal("handover-verdict"),
11926
+ handoverId: external_exports.string(),
11927
+ chatSessionId: external_exports.string(),
11928
+ accept: external_exports.boolean()
11929
+ }),
11199
11930
  external_exports.object({ kind: external_exports.literal("ping") }),
11200
11931
  external_exports.object({ kind: external_exports.literal("pong") })
11201
11932
  ]);
@@ -41803,6 +42534,22 @@ var LocalSessionServer = class {
41803
42534
  }
41804
42535
  return;
41805
42536
  }
42537
+ if (req.method === "POST" && req.url === "/status") {
42538
+ try {
42539
+ const chunks = [];
42540
+ for await (const c2 of req) chunks.push(c2);
42541
+ const body = JSON.parse(Buffer.concat(chunks).toString());
42542
+ if (typeof body.sessionId === "string" && typeof body.text === "string" && body.text.trim()) {
42543
+ this.manager.recordStatusUpdate(body.sessionId, body.text.trim());
42544
+ }
42545
+ res.writeHead(200, { "content-type": "application/json" });
42546
+ res.end(JSON.stringify({ ok: true }));
42547
+ } catch (e) {
42548
+ res.writeHead(400, { "content-type": "application/json" });
42549
+ res.end(JSON.stringify({ ok: false, message: `bad status request: ${String(e)}` }));
42550
+ }
42551
+ return;
42552
+ }
41806
42553
  res.writeHead(404);
41807
42554
  res.end();
41808
42555
  }
@@ -41838,8 +42585,19 @@ var RelayClient = class {
41838
42585
  heartbeat = null;
41839
42586
  detach = null;
41840
42587
  stopped = false;
42588
+ /** Set when a heartbeat ping goes out; cleared by ANY inbound frame. If
42589
+ * still set on the next tick, nothing has answered in a full interval —
42590
+ * the connection is a zombie (readyState still reports OPEN even though
42591
+ * the underlying TCP connection died silently, e.g. after laptop sleep or
42592
+ * a NAT/firewall idle timeout — no close/error event fires on its own). */
42593
+ awaitingPongSince = null;
41841
42594
  /** sessionId → tool-call count for the in-flight run (opaque count only, never the action). */
41842
42595
  toolCounts = /* @__PURE__ */ new Map();
42596
+ /** True once we've been online at least once — a failed first connection
42597
+ * attempt isn't "going offline" (there was nothing to lose), so the very
42598
+ * first successful open never logs a reconnect. */
42599
+ everConnected = false;
42600
+ offlineSinceMs = null;
41843
42601
  start() {
41844
42602
  this.connect();
41845
42603
  }
@@ -41854,8 +42612,13 @@ var RelayClient = class {
41854
42612
  }
41855
42613
  connect() {
41856
42614
  if (this.stopped) return;
42615
+ console.log(`relay: connecting (${this.relayUrl})`);
41857
42616
  const ws = new wrapper_default(this.url());
41858
42617
  this.ws = ws;
42618
+ const slowStartTimer = setTimeout(() => {
42619
+ console.log("relay: still connecting \u2014 the hosted relay may be waking up from a cold start (can take up to a minute)");
42620
+ }, 8e3);
42621
+ const clearSlowStartTimer = () => clearTimeout(slowStartTimer);
41859
42622
  const send = (msg) => {
41860
42623
  if (ws.readyState === wrapper_default.OPEN) {
41861
42624
  const envelope = seal(msg, this.keys.tx);
@@ -41895,24 +42658,52 @@ var RelayClient = class {
41895
42658
  JSON.stringify({ kind: "notify", notice: "error", sessionId: msg.sessionId })
41896
42659
  );
41897
42660
  }
42661
+ if (msg.type === "run-event" && msg.event.type === "handover-proposed") {
42662
+ ws.send(
42663
+ JSON.stringify({
42664
+ kind: "notify",
42665
+ notice: "handover",
42666
+ id: msg.event.id,
42667
+ sessionId: msg.sessionId
42668
+ })
42669
+ );
42670
+ }
42671
+ if (msg.type === "run-event" && msg.event.type === "limit-hit") {
42672
+ ws.send(
42673
+ JSON.stringify({ kind: "notify", notice: "limit-hit", sessionId: msg.sessionId })
42674
+ );
42675
+ }
41898
42676
  if (msg.type === "drop" && msg.direction === "to-phone") {
41899
42677
  ws.send(JSON.stringify({ kind: "notify", notice: "drop" }));
41900
42678
  }
41901
42679
  }
41902
42680
  };
41903
42681
  ws.on("open", () => {
42682
+ clearSlowStartTimer();
41904
42683
  this.backoffMs = 500;
42684
+ this.awaitingPongSince = null;
41905
42685
  console.log(`relay: connected (${this.relayUrl})`);
42686
+ if (this.everConnected && this.offlineSinceMs !== null) {
42687
+ this.manager.recordDeviceReconnected(Date.now() - this.offlineSinceMs);
42688
+ }
42689
+ this.everConnected = true;
42690
+ this.offlineSinceMs = null;
41906
42691
  this.detach = this.manager.attach(send);
41907
42692
  send(this.manager.hello());
41908
42693
  void this.manager.manifest().then(send);
41909
42694
  this.heartbeat = setInterval(() => {
41910
- if (ws.readyState === wrapper_default.OPEN) {
41911
- ws.send(JSON.stringify({ kind: "ping" }));
42695
+ if (ws.readyState !== wrapper_default.OPEN) return;
42696
+ if (this.awaitingPongSince !== null) {
42697
+ console.log("relay: no response since last heartbeat \u2014 reconnecting");
42698
+ ws.terminate();
42699
+ return;
41912
42700
  }
42701
+ this.awaitingPongSince = Date.now();
42702
+ ws.send(JSON.stringify({ kind: "ping" }));
41913
42703
  }, HEARTBEAT_MS);
41914
42704
  });
41915
42705
  ws.on("message", (raw) => {
42706
+ this.awaitingPongSince = null;
41916
42707
  let frame;
41917
42708
  try {
41918
42709
  frame = parseRelayFrame(raw.toString());
@@ -41926,6 +42717,18 @@ var RelayClient = class {
41926
42717
  );
41927
42718
  return;
41928
42719
  }
42720
+ if (frame.kind === "handover-verdict") {
42721
+ this.manager.handle(
42722
+ {
42723
+ type: "handover-response",
42724
+ sessionId: frame.chatSessionId,
42725
+ handoverId: frame.handoverId,
42726
+ accept: frame.accept
42727
+ },
42728
+ send
42729
+ );
42730
+ return;
42731
+ }
41929
42732
  if (frame.kind !== "peer") return;
41930
42733
  try {
41931
42734
  const envelope = EnvelopeSchema.parse(frame.payload);
@@ -41938,7 +42741,12 @@ var RelayClient = class {
41938
42741
  console.error(`relay: ${err.message}`);
41939
42742
  });
41940
42743
  ws.on("close", () => {
42744
+ clearSlowStartTimer();
41941
42745
  this.cleanup();
42746
+ if (this.everConnected && this.offlineSinceMs === null) {
42747
+ this.offlineSinceMs = Date.now();
42748
+ this.manager.recordDeviceOffline();
42749
+ }
41942
42750
  if (this.stopped) return;
41943
42751
  console.log(`relay: disconnected \u2014 retrying in ${this.backoffMs}ms`);
41944
42752
  setTimeout(() => this.connect(), this.backoffMs);
@@ -41954,7 +42762,7 @@ var RelayClient = class {
41954
42762
  };
41955
42763
 
41956
42764
  // ../daemon/src/pairing.ts
41957
- import { existsSync as existsSync7, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "node:fs";
42765
+ import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "node:fs";
41958
42766
  import { homedir as homedir7 } from "node:os";
41959
42767
  import { join as join8 } from "node:path";
41960
42768
  import { randomInt } from "node:crypto";
@@ -41964,7 +42772,7 @@ var POLL_MS = 2e3;
41964
42772
  var POLL_TIMEOUT_MS = 10 * 60 * 1e3;
41965
42773
  async function ensurePairing(relayUrl2, forceNew = false, webUrl2 = "https://offhand-web.onrender.com") {
41966
42774
  await ready;
41967
- if (!forceNew && existsSync7(PAIRING_PATH)) {
42775
+ if (!forceNew && existsSync8(PAIRING_PATH)) {
41968
42776
  const f2 = JSON.parse(readFileSync4(PAIRING_PATH, "utf8"));
41969
42777
  const kp2 = { publicKey: fromB64u(f2.daemonPublicKey), secretKey: fromB64u(f2.daemonSecretKey) };
41970
42778
  const phonePk = fromB64u(f2.phonePublicKey);
@@ -42171,9 +42979,9 @@ function buildPreview(toolName, input) {
42171
42979
  return void 0;
42172
42980
  }
42173
42981
  function formatUnifiedDiff(diff) {
42174
- const lines = diff.split("\n");
42175
- const hunkStart = lines.findIndex((l2) => l2.startsWith("@@"));
42176
- return (hunkStart >= 0 ? lines.slice(hunkStart) : lines).join("\n").trimEnd();
42982
+ return diff.split("\n").filter(
42983
+ (l2) => !l2.startsWith("index ") && !l2.startsWith("Index:") && !/^={3,}$/.test(l2) && !/^(new|deleted) file mode /.test(l2) && !/^(old|new) mode /.test(l2)
42984
+ ).join("\n").trimEnd();
42177
42985
  }
42178
42986
  function truncate6(s2, max) {
42179
42987
  return s2.length <= max ? s2 : s2.slice(0, max - 1) + "\u2026";
@@ -42214,7 +43022,7 @@ async function downloadArtifact(relayUrl2, sessionId, blobId, keys) {
42214
43022
  // ../daemon/src/autostart.ts
42215
43023
  import { homedir as homedir8 } from "node:os";
42216
43024
  import { join as join10, resolve as resolve4, dirname as dirname3 } from "node:path";
42217
- import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync } from "node:fs";
43025
+ import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync } from "node:fs";
42218
43026
  import { fileURLToPath as fileURLToPath3 } from "node:url";
42219
43027
  import { spawnSync } from "node:child_process";
42220
43028
  import { tmpdir as tmpdir3 } from "node:os";
@@ -42235,27 +43043,23 @@ function getWrapperPath() {
42235
43043
  function getShortcutPath() {
42236
43044
  return join10(getStartupDir(), SHORTCUT_NAME);
42237
43045
  }
42238
- function buildWrapperScript(config) {
42239
- const projectRoot = resolve4(__dirname2, "..", "..");
42240
- const wsArgs2 = config.workspaces.map((w2) => `--workspace "${w2}"`).join(" ");
42241
- const relayArg = config.relayUrl ? `--relay "${config.relayUrl}"` : "";
42242
- const devUrlArg = config.devUrl ? `--dev-url "${config.devUrl}"` : "";
42243
- const portArg = `--port ${config.port}`;
42244
- const approvalArg = `--approval-timeout ${config.approvalTimeout ?? 300}`;
42245
- const webUrlArg = `--web-url "${config.webUrl ?? "https://offhand-web.onrender.com"}"`;
42246
- const parts = [
42247
- "pnpm --filter @offhand/daemon dev",
42248
- wsArgs2,
42249
- relayArg,
42250
- devUrlArg,
42251
- portArg,
42252
- approvalArg,
42253
- webUrlArg
42254
- ].filter(Boolean);
42255
- const cmd = parts.join(" ");
42256
- return `@echo off
42257
- cd /d "${projectRoot}"
42258
- ${cmd}
43046
+ function buildWrapperScript(config2, entry = process.argv[1] ?? "", execPath = process.execPath) {
43047
+ const wsArgs2 = config2.workspaces.map((w2) => `--workspace "${w2}"`).join(" ");
43048
+ const relayArg = config2.relayUrl ? `--relay "${config2.relayUrl}"` : "";
43049
+ const devUrlArg = config2.devUrl ? `--dev-url "${config2.devUrl}"` : "";
43050
+ const portArg = `--port ${config2.port}`;
43051
+ const approvalArg = `--approval-timeout ${config2.approvalTimeout ?? 300}`;
43052
+ const webUrlArg = `--web-url "${config2.webUrl ?? "https://offhand-web.onrender.com"}"`;
43053
+ const args2 = [wsArgs2, relayArg, devUrlArg, portArg, approvalArg, webUrlArg].filter(Boolean).join(" ");
43054
+ if (entry.endsWith(".ts")) {
43055
+ const projectRoot = resolve4(__dirname2, "..", "..");
43056
+ return `@echo off\r
43057
+ cd /d "${projectRoot}"\r
43058
+ pnpm --filter @offhand/daemon dev -- ${args2}\r
43059
+ `;
43060
+ }
43061
+ return `@echo off\r
43062
+ "${execPath}" "${entry}" ${args2}\r
42259
43063
  `;
42260
43064
  }
42261
43065
  function createShortcut(targetPath, shortcutPath) {
@@ -42276,11 +43080,11 @@ $Shortcut.Save()`;
42276
43080
  return { success: result.status === 0, output: result.stdout + result.stderr };
42277
43081
  }
42278
43082
  function checkShortcutExists(shortcutPath) {
42279
- return existsSync8(shortcutPath);
43083
+ return existsSync9(shortcutPath);
42280
43084
  }
42281
- function installAutostart(config) {
43085
+ function installAutostart(config2) {
42282
43086
  const wrapperPath = getWrapperPath();
42283
- const script = buildWrapperScript(config);
43087
+ const script = buildWrapperScript(config2);
42284
43088
  writeFileSync4(wrapperPath, script);
42285
43089
  const shortcutPath = getShortcutPath();
42286
43090
  const result = createShortcut(wrapperPath, shortcutPath);
@@ -42296,7 +43100,7 @@ function getAutostartStatus() {
42296
43100
  const shortcutPath = getShortcutPath();
42297
43101
  const installed = checkShortcutExists(shortcutPath);
42298
43102
  const wrapperPath = getWrapperPath();
42299
- const wrapperExists = existsSync8(wrapperPath);
43103
+ const wrapperExists = existsSync9(wrapperPath);
42300
43104
  let details = `Shortcut: ${shortcutPath} (${installed ? "EXISTS" : "MISSING"})`;
42301
43105
  if (wrapperExists) {
42302
43106
  details += `
@@ -42304,14 +43108,13 @@ Wrapper: ${wrapperPath} (EXISTS)`;
42304
43108
  }
42305
43109
  return { installed, details };
42306
43110
  }
42307
- function saveConfig(config) {
43111
+ function saveConfig(config2) {
42308
43112
  const configPath = join10(OFFHAND_HOME, "autostart.json");
42309
- writeFileSync4(configPath, JSON.stringify(config, null, 2));
43113
+ writeFileSync4(configPath, JSON.stringify(config2, null, 2));
42310
43114
  }
42311
43115
  function getConfigFromStore() {
42312
- const workspaces = ["C:\\Users\\udbha\\dev\\offhand"];
42313
43116
  return {
42314
- workspaces,
43117
+ workspaces: [],
42315
43118
  port: 4317,
42316
43119
  relayUrl: void 0,
42317
43120
  devUrl: void 0,
@@ -42354,7 +43157,7 @@ var devUrl = argValue("--dev-url");
42354
43157
  var store = new Store();
42355
43158
  var wsArgs = argValues("--workspace").map((w2) => resolve5(w2));
42356
43159
  for (const w2 of wsArgs) {
42357
- if (!existsSync9(w2)) {
43160
+ if (!existsSync10(w2)) {
42358
43161
  console.error(`workspace does not exist: ${w2}`);
42359
43162
  process.exit(1);
42360
43163
  }
@@ -42387,25 +43190,20 @@ if (m2.type === "manifest") {
42387
43190
  new LocalSessionServer(manager, port, broker);
42388
43191
  console.log(` local : ws://127.0.0.1:${port}`);
42389
43192
  console.log(` approvals : timeout ${approvalTimeoutMs / 1e3}s then auto-deny`);
42390
- var autostartStatus = getAutostartStatus();
42391
- if (!autostartStatus.installed) {
42392
- console.log(` autostart : installing...`);
42393
- const config = getConfigFromStore();
42394
- config.workspaces = store.listWorkspaces().map((w2) => w2.path);
42395
- config.port = port;
42396
- config.relayUrl = relayUrl;
42397
- config.devUrl = devUrl;
42398
- config.approvalTimeout = approvalTimeoutMs / 1e3;
42399
- config.webUrl = webUrl;
42400
- saveConfig(config);
42401
- const { success, output } = installAutostart(config);
42402
- if (success) {
42403
- console.log(` autostart : installed (runs on login)`);
42404
- } else {
42405
- console.warn(` autostart : failed to install: ${output}`);
42406
- }
43193
+ var wasInstalled = getAutostartStatus().installed;
43194
+ var config = getConfigFromStore();
43195
+ config.workspaces = store.listWorkspaces().map((w2) => w2.path);
43196
+ config.port = port;
43197
+ config.relayUrl = relayUrl;
43198
+ config.devUrl = devUrl;
43199
+ config.approvalTimeout = approvalTimeoutMs / 1e3;
43200
+ config.webUrl = webUrl;
43201
+ saveConfig(config);
43202
+ var { success, output } = installAutostart(config);
43203
+ if (success) {
43204
+ console.log(` autostart : ${wasInstalled ? "refreshed" : "installed"} (runs on login)`);
42407
43205
  } else {
42408
- console.log(` autostart : already installed`);
43206
+ console.warn(` autostart : failed to ${wasInstalled ? "refresh" : "install"}: ${output}`);
42409
43207
  }
42410
43208
  if (relayUrl) {
42411
43209
  const pairing = await ensurePairing(relayUrl, forceRepair, webUrl);