offhands 0.1.4 → 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() {
@@ -5299,6 +5344,7 @@ stderr: ${stderrTail}` : ""}`
5299
5344
 
5300
5345
  // ../daemon/src/runners/opencode-cli.ts
5301
5346
  import { spawn as spawn4 } from "node:child_process";
5347
+ import { createServer } from "node:net";
5302
5348
  import { existsSync as existsSync3 } from "node:fs";
5303
5349
  import { homedir as homedir3 } from "node:os";
5304
5350
  import { delimiter as delimiter2, join as join3 } from "node:path";
@@ -5307,79 +5353,61 @@ import { delimiter as delimiter2, join as join3 } from "node:path";
5307
5353
  function mapOpenCodeEvent(value) {
5308
5354
  if (typeof value !== "object" || value === null) return [];
5309
5355
  const v2 = value;
5310
- const type = typeof v2.type === "string" ? v2.type : "";
5311
5356
  const part = v2.part ?? {};
5312
- const partType = typeof part.type === "string" ? part.type : "";
5313
- if (type === "text") {
5357
+ const partType = typeof part.type === "string" ? part.type : typeof v2.type === "string" ? v2.type : "";
5358
+ if (partType === "text") {
5314
5359
  const text = typeof part.text === "string" ? part.text : "";
5315
5360
  return text ? [{ type: "text", chunk: text }] : [];
5316
5361
  }
5317
- if (type === "thinking" || partType === "thinking") {
5318
- const thinking = typeof part.thinking === "string" ? part.thinking : typeof part.text === "string" ? part.text : "";
5362
+ if (partType === "reasoning") {
5363
+ const thinking = typeof part.text === "string" ? part.text : "";
5319
5364
  return thinking ? [{ type: "thinking", chunk: thinking }] : [];
5320
5365
  }
5321
- if (type === "tool_call" || partType === "tool_call") {
5322
- const name = firstString2(part, "toolName", "name", "tool") ?? "tool";
5323
- const input = part.input;
5324
- const summary = input ? firstString2(input, "command", "path", "file", "summary", "description", "query") ?? "" : "";
5366
+ if (partType === "tool") {
5367
+ const name = typeof part.tool === "string" ? part.tool : "tool";
5368
+ const state = part.state ?? {};
5369
+ const input = state.input;
5370
+ const summary = input ? firstString2(input, "command", "filePath", "path", "file", "pattern", "query", "url") ?? "" : "";
5371
+ const status = typeof state.status === "string" ? state.status : void 0;
5372
+ if (status === "error") {
5373
+ const errMsg = typeof state.error === "string" ? state.error : "tool call failed";
5374
+ return [{ type: "tool", name, summary: `${name}: ${truncate3(errMsg, 120)}` }];
5375
+ }
5325
5376
  return [{ type: "tool", name, summary: summary ? `${name}: ${truncate3(summary, 120)}` : name }];
5326
5377
  }
5327
- if (type === "step_start" || partType === "step-start") {
5328
- return [];
5329
- }
5330
- if (type === "step_finish" || partType === "step-finish") {
5378
+ if (partType === "step-start") return [];
5379
+ if (partType === "step-finish") {
5331
5380
  const reason = typeof part.reason === "string" ? part.reason : "stop";
5332
- const isError = reason === "error" || reason === "failed";
5333
5381
  const usage = extractUsageFromPart(part);
5334
- if (isError) {
5335
- const message = typeof part.error === "string" ? part.error : "run failed";
5382
+ if (reason === "error") {
5383
+ const message = firstString2(part, "error", "message") ?? "run failed";
5336
5384
  const events2 = [{ type: "error", message }];
5337
5385
  if (usage) events2.push(usage);
5338
5386
  return events2;
5339
5387
  }
5388
+ if (reason !== "stop") return usage ? [usage] : [];
5340
5389
  const events = [{ type: "done", summary: "" }];
5341
5390
  if (usage) events.push(usage);
5342
5391
  return events;
5343
5392
  }
5344
- if (type === "permission_request" || type === "permission.request") {
5345
- const partData = part.data;
5346
- const id = firstString2(partData, "id", "permissionId", "id") ?? crypto.randomUUID();
5347
- const action = firstString2(partData, "toolName", "action", "command") ?? "action";
5348
- const detail = firstString2(partData, "detail", "description", "message") ?? "";
5349
- const risk = firstString2(partData, "risk") === "high" ? "high" : "low";
5350
- const preview = firstString2(partData, "preview", "diff", "command") ?? void 0;
5351
- const question = partData?.question ? mapQuestion(partData.question) : void 0;
5352
- return [{
5353
- type: "approval",
5354
- id,
5355
- action,
5356
- detail,
5357
- risk,
5358
- preview,
5359
- question
5360
- }];
5361
- }
5362
- if (type === "error") {
5363
- const message = firstString2(v2, "message", "error", "detail", "result") ?? firstString2(part, "message", "error") ?? "unknown error";
5393
+ if (!part.type && typeof v2.type === "string" && v2.type === "error") {
5394
+ const message = firstString2(v2, "message", "error", "detail", "result") ?? "unknown error";
5364
5395
  return [{ type: "error", message }];
5365
5396
  }
5366
- if (type === "session_created" || type === "session.updated" || type === "session.created") {
5367
- return [];
5368
- }
5369
5397
  return [];
5370
5398
  }
5371
5399
  function extractOpenCodeSessionId(value) {
5372
5400
  if (typeof value !== "object" || value === null) return null;
5373
5401
  const v2 = value;
5374
5402
  const part = v2.part ?? {};
5375
- return firstString2(v2, "sessionId", "session_id", "sessionID", "id") ?? firstString2(part, "sessionId", "session_id", "sessionID", "id") ?? null;
5403
+ return firstString2(v2, "sessionID", "sessionId", "session_id") ?? firstString2(part, "sessionID", "sessionId", "session_id") ?? null;
5376
5404
  }
5377
5405
  function extractUsageFromPart(part) {
5378
5406
  const tokens = part.tokens;
5379
- const costUsd = typeof part.cost === "number" ? part.cost : typeof part.costUsd === "number" ? part.costUsd : void 0;
5407
+ const costUsd = typeof part.cost === "number" ? part.cost : void 0;
5380
5408
  if (!tokens && costUsd === void 0) return null;
5381
5409
  const n2 = (k2) => typeof tokens?.[k2] === "number" ? tokens[k2] : 0;
5382
- const contextTokens = n2("total") + n2("input") + n2("inputTokens") + n2("input_tokens") + n2("cacheReadInputTokens") + n2("cache_read_input_tokens") + n2("cacheCreationInputTokens") + n2("cache_creation_input_tokens") + n2("output") + n2("outputTokens") + n2("output_tokens") + n2("reasoning") + n2("reasoningTokens") + n2("reasoning_tokens");
5410
+ const contextTokens = tokens && typeof tokens.total === "number" ? tokens.total : n2("input") + n2("output") + n2("reasoning");
5383
5411
  if (contextTokens <= 0 && costUsd === void 0) return null;
5384
5412
  return {
5385
5413
  type: "usage",
@@ -5388,19 +5416,8 @@ function extractUsageFromPart(part) {
5388
5416
  ...costUsd !== void 0 ? { costUsd } : {}
5389
5417
  };
5390
5418
  }
5391
- function mapQuestion(q2) {
5392
- if (typeof q2 !== "object" || q2 === null) return void 0;
5393
- const qq = q2;
5394
- const text = typeof qq.text === "string" ? qq.text : "";
5395
- const options = Array.isArray(qq.options) ? qq.options.filter((o2) => typeof o2 === "object" && o2 !== null).map((o2) => ({
5396
- label: typeof o2.label === "string" ? o2.label : "",
5397
- description: typeof o2.description === "string" ? o2.description : void 0
5398
- })) : [];
5399
- const multiSelect = typeof qq.multiSelect === "boolean" ? qq.multiSelect : void 0;
5400
- if (!text && options.length === 0) return void 0;
5401
- return { text, options, multiSelect };
5402
- }
5403
5419
  function firstString2(obj, ...keys) {
5420
+ if (!obj) return void 0;
5404
5421
  for (const k2 of keys) {
5405
5422
  const val = obj[k2];
5406
5423
  if (typeof val === "string" && val !== "") return val;
@@ -5412,15 +5429,27 @@ function truncate3(s2, max) {
5412
5429
  }
5413
5430
 
5414
5431
  // ../daemon/src/runners/opencode-cli.ts
5432
+ var POLL_INTERVAL_MS = 500;
5415
5433
  var OpenCodeRunner = class {
5434
+ constructor(broker2) {
5435
+ this.broker = broker2;
5436
+ }
5416
5437
  id = "opencode";
5417
5438
  name = "OpenCode";
5418
5439
  supportsApprovals = true;
5419
5440
  models = [];
5420
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"];
5421
5446
  /** [command, ...prefixArgs] resolved once. */
5422
5447
  resolved = null;
5423
5448
  modelsCache = [];
5449
+ // Lazily-started, shared `opencode serve` instance backing startViaHttpApi.
5450
+ serverPort = null;
5451
+ serverProcess = null;
5452
+ serverStarting = null;
5424
5453
  resolveCommand() {
5425
5454
  if (this.resolved) return this.resolved;
5426
5455
  if (process.platform === "win32") {
@@ -5482,16 +5511,16 @@ var OpenCodeRunner = class {
5482
5511
  stdio: ["ignore", "pipe", "ignore"],
5483
5512
  shell: process.platform === "win32"
5484
5513
  });
5485
- let output = "";
5514
+ let output2 = "";
5486
5515
  p2.stdout.setEncoding("utf8");
5487
5516
  p2.stdout.on("data", (chunk) => {
5488
- output += chunk;
5517
+ output2 += chunk;
5489
5518
  });
5490
5519
  p2.on("close", (code) => {
5491
5520
  if (code === 0) {
5492
5521
  try {
5493
- const lines = output.trim().split("\n");
5494
- const models = lines.map((l2) => l2.trim()).filter((l2) => l2 && !l2.startsWith("opencode/"));
5522
+ const lines = output2.trim().split("\n");
5523
+ const models = lines.map((l2) => l2.trim()).filter((l2) => l2.length > 0);
5495
5524
  this.modelsCache = models;
5496
5525
  Object.defineProperty(this, "models", {
5497
5526
  value: models,
@@ -5513,6 +5542,13 @@ var OpenCodeRunner = class {
5513
5542
  return existsSync3(join3(homedir3(), ".local", "share", "opencode", "auth.json"));
5514
5543
  }
5515
5544
  start(run, callbacks) {
5545
+ const mode = run.permissionMode ?? "guarded";
5546
+ if (this.broker && (mode === "guarded" || mode === "acceptEdits")) {
5547
+ return this.startViaHttpApi(run, mode, callbacks);
5548
+ }
5549
+ return this.startViaCli(run, mode, callbacks);
5550
+ }
5551
+ startViaCli(run, mode, callbacks) {
5516
5552
  const queue = new AsyncEventQueue();
5517
5553
  const parser = new NdjsonParser();
5518
5554
  let child;
@@ -5537,8 +5573,7 @@ var OpenCodeRunner = class {
5537
5573
  ...cmd.slice(1),
5538
5574
  "run",
5539
5575
  "--format",
5540
- "json",
5541
- "--no-color"
5576
+ "json"
5542
5577
  ];
5543
5578
  if (run.model) {
5544
5579
  args2.push("--model", run.model);
@@ -5546,12 +5581,10 @@ var OpenCodeRunner = class {
5546
5581
  if (run.resumeConversationId) {
5547
5582
  args2.push("--session", run.resumeConversationId);
5548
5583
  }
5549
- const mode = run.permissionMode ?? "guarded";
5550
- if (mode === "bypass") {
5551
- args2.push("--auto");
5552
- } else if (mode === "plan") {
5584
+ if (mode === "plan") {
5553
5585
  args2.push("--agent", "plan");
5554
5586
  } else {
5587
+ args2.push("--auto");
5555
5588
  }
5556
5589
  if (run.effort) {
5557
5590
  const variantMap = { low: "minimal", medium: "high", high: "max", max: "max" };
@@ -5622,17 +5655,304 @@ stderr: ${stderrTail}` : ""}`
5622
5655
  });
5623
5656
  return {
5624
5657
  events: queue,
5625
- respond: (approvalId, ok, answer) => {
5658
+ // No broker (or bypass/plan, which never ask) nothing to answer.
5659
+ respond: () => {
5626
5660
  },
5627
5661
  cancel: () => child.kill()
5628
5662
  };
5629
5663
  }
5664
+ startViaHttpApi(run, mode, callbacks) {
5665
+ const queue = new AsyncEventQueue();
5666
+ const emit = (events) => {
5667
+ for (const e of events) queue.push(e);
5668
+ };
5669
+ const broker2 = this.broker;
5670
+ const directory = run.workspace;
5671
+ const encDir = encodeURIComponent(directory);
5672
+ let cancelled = false;
5673
+ let sessionId = run.resumeConversationId ?? null;
5674
+ let port2 = null;
5675
+ let detachBroker;
5676
+ const pendingQuestions = /* @__PURE__ */ new Map();
5677
+ const cleanup = () => {
5678
+ detachBroker?.();
5679
+ queue.close();
5680
+ };
5681
+ void (async () => {
5682
+ try {
5683
+ port2 = await this.ensureServer();
5684
+ } catch (e) {
5685
+ emit([{ type: "error", message: `failed to start opencode server: ${String(e)}` }]);
5686
+ cleanup();
5687
+ return;
5688
+ }
5689
+ if (cancelled) {
5690
+ cleanup();
5691
+ return;
5692
+ }
5693
+ const base = `http://127.0.0.1:${port2}`;
5694
+ detachBroker = broker2.attach((ev) => queue.push(ev));
5695
+ try {
5696
+ if (!sessionId) {
5697
+ const modelRef2 = parseModelRef(run.model);
5698
+ const body = { permission: buildPermissionRuleset(mode) };
5699
+ if (modelRef2) body.model = { id: modelRef2.modelId, providerID: modelRef2.providerId };
5700
+ const session = await httpJson(`${base}/session?directory=${encDir}`, "POST", body);
5701
+ sessionId = session.id;
5702
+ }
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
+ }
5712
+ const parts = [{ type: "text", text: run.prompt }];
5713
+ for (const file of run.attachments ?? []) {
5714
+ parts.push({ type: "file", mime: "application/octet-stream", url: fileUrl(file) });
5715
+ }
5716
+ const promptBody = { parts };
5717
+ const modelRef = parseModelRef(run.model);
5718
+ if (modelRef) promptBody.model = { providerID: modelRef.providerId, modelID: modelRef.modelId };
5719
+ await httpJson(`${base}/session/${sessionId}/prompt_async?directory=${encDir}`, "POST", promptBody, true);
5720
+ await this.pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, () => cancelled);
5721
+ } catch (e) {
5722
+ if (!cancelled) emit([{ type: "error", message: `opencode HTTP API error: ${String(e)}` }]);
5723
+ } finally {
5724
+ cleanup();
5725
+ }
5726
+ })();
5727
+ return {
5728
+ events: queue,
5729
+ respond: (approvalId, ok, answer) => {
5730
+ const settleQuestion = pendingQuestions.get(approvalId);
5731
+ if (settleQuestion) {
5732
+ pendingQuestions.delete(approvalId);
5733
+ settleQuestion(ok, answer);
5734
+ return;
5735
+ }
5736
+ broker2.resolve(approvalId, ok, answer);
5737
+ },
5738
+ cancel: () => {
5739
+ cancelled = true;
5740
+ if (port2 && sessionId) {
5741
+ const abortUrl = `http://127.0.0.1:${port2}/session/${sessionId}/abort?directory=${encDir}`;
5742
+ void httpJson(abortUrl, "POST", {}, true).catch(() => {
5743
+ });
5744
+ }
5745
+ }
5746
+ };
5747
+ }
5748
+ /** Poll session messages + pending permissions/questions until the turn finishes. */
5749
+ async pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, isCancelled) {
5750
+ const seenPermissions = /* @__PURE__ */ new Set();
5751
+ const seenQuestions = /* @__PURE__ */ new Set();
5752
+ const emittedParts = /* @__PURE__ */ new Set();
5753
+ let sawTerminal = false;
5754
+ while (!isCancelled() && !sawTerminal) {
5755
+ await sleep(POLL_INTERVAL_MS);
5756
+ if (isCancelled()) break;
5757
+ try {
5758
+ const pending = await httpJson(`${base}/permission?directory=${encDir}`, "GET");
5759
+ for (const p2 of pending) {
5760
+ if (p2.sessionID !== sessionId || seenPermissions.has(p2.id)) continue;
5761
+ seenPermissions.add(p2.id);
5762
+ void this.answerPermission(base, encDir, p2, broker2);
5763
+ }
5764
+ } catch {
5765
+ }
5766
+ try {
5767
+ const pendingQ = await httpJson(`${base}/question?directory=${encDir}`, "GET");
5768
+ for (const q2 of pendingQ) {
5769
+ if (q2.sessionID !== sessionId || seenQuestions.has(q2.id)) continue;
5770
+ seenQuestions.add(q2.id);
5771
+ void this.answerQuestion(base, encDir, q2, emit, pendingQuestions);
5772
+ }
5773
+ } catch {
5774
+ }
5775
+ let messages;
5776
+ try {
5777
+ messages = await httpJson(`${base}/session/${sessionId}/message?directory=${encDir}`, "GET");
5778
+ } catch {
5779
+ continue;
5780
+ }
5781
+ const result = processMessages(messages, baselineMessageIds, emittedParts);
5782
+ emit(result.events);
5783
+ if (result.sawTerminal) sawTerminal = true;
5784
+ }
5785
+ }
5786
+ /** Route one pending permission through the shared broker, then reply. */
5787
+ async answerPermission(base, encDir, p2, broker2) {
5788
+ const input = { ...p2.metadata };
5789
+ const verdict = await broker2.submit(p2.permission, input);
5790
+ try {
5791
+ await httpJson(`${base}/permission/${p2.id}/reply?directory=${encDir}`, "POST", {
5792
+ reply: verdict.approve ? "once" : "reject",
5793
+ ...verdict.message ? { message: verdict.message } : {}
5794
+ }, true);
5795
+ } catch {
5796
+ }
5797
+ }
5798
+ /** Surface an OpenCode "question" (its AskUserQuestion equivalent) as a
5799
+ * real approval-with-options card, wait for the phone's pick, then answer
5800
+ * through the question API's own reply/reject endpoints. */
5801
+ async answerQuestion(base, encDir, q2, emit, pendingQuestions) {
5802
+ const first = q2.questions[0];
5803
+ if (!first) return;
5804
+ const options = first.options.map((o2) => ({ label: o2.label, description: o2.description }));
5805
+ const preview = [first.question, ...options.map((o2, n2) => `${n2 + 1}. ${o2.label}${o2.description ? ` \u2014 ${o2.description}` : ""}`)].join("\n");
5806
+ emit([{
5807
+ type: "approval",
5808
+ id: q2.id,
5809
+ action: "question",
5810
+ detail: truncate4(first.question, 200),
5811
+ risk: "low",
5812
+ preview: truncate4(preview, 600),
5813
+ question: { text: first.question, options, multiSelect: first.multiple }
5814
+ }]);
5815
+ const verdict = await new Promise((resolve6) => {
5816
+ pendingQuestions.set(q2.id, (ok, answer) => resolve6({ ok, answer }));
5817
+ });
5818
+ emit([{
5819
+ type: "approval-result",
5820
+ id: q2.id,
5821
+ approve: verdict.ok,
5822
+ ...verdict.answer ? { answer: verdict.answer } : {}
5823
+ }]);
5824
+ try {
5825
+ if (verdict.ok && verdict.answer) {
5826
+ await httpJson(`${base}/question/${q2.id}/reply?directory=${encDir}`, "POST", { answers: [[verdict.answer]] }, true);
5827
+ } else {
5828
+ await httpJson(`${base}/question/${q2.id}/reject?directory=${encDir}`, "POST", {}, true);
5829
+ }
5830
+ } catch {
5831
+ }
5832
+ }
5833
+ /** Lazily start (once) the shared `opencode serve` instance used for
5834
+ * broker-driven runs, and wait for it to accept connections. */
5835
+ async ensureServer() {
5836
+ if (this.serverPort) return this.serverPort;
5837
+ if (this.serverStarting) return this.serverStarting;
5838
+ this.serverStarting = (async () => {
5839
+ const cmd = this.resolveCommand();
5840
+ if (!cmd) throw new Error("opencode CLI not found");
5841
+ const port2 = await findFreePort();
5842
+ const proc = spawn4(cmd[0], [...cmd.slice(1), "serve", "--port", String(port2), "--hostname", "127.0.0.1"], {
5843
+ stdio: ["ignore", "pipe", "pipe"],
5844
+ shell: process.platform === "win32"
5845
+ });
5846
+ proc.on("exit", () => {
5847
+ if (this.serverProcess === proc) {
5848
+ this.serverProcess = null;
5849
+ this.serverPort = null;
5850
+ }
5851
+ });
5852
+ await waitForServerReady(port2);
5853
+ this.serverProcess = proc;
5854
+ this.serverPort = port2;
5855
+ return port2;
5856
+ })();
5857
+ try {
5858
+ return await this.serverStarting;
5859
+ } finally {
5860
+ this.serverStarting = null;
5861
+ }
5862
+ }
5630
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
+ }
5884
+ function buildPermissionRuleset(mode) {
5885
+ const base = [{ permission: "*", pattern: "*", action: "allow" }];
5886
+ if (mode === "acceptEdits") {
5887
+ return [
5888
+ ...base,
5889
+ { permission: "edit", pattern: "*", action: "allow" },
5890
+ { permission: "bash", pattern: "*", action: "ask" },
5891
+ { permission: "webfetch", pattern: "*", action: "ask" }
5892
+ ];
5893
+ }
5894
+ return [
5895
+ ...base,
5896
+ { permission: "edit", pattern: "*", action: "ask" },
5897
+ { permission: "bash", pattern: "*", action: "ask" },
5898
+ { permission: "webfetch", pattern: "*", action: "ask" }
5899
+ ];
5900
+ }
5901
+ function parseModelRef(model) {
5902
+ if (!model) return null;
5903
+ const idx = model.indexOf("/");
5904
+ if (idx < 0) return null;
5905
+ return { providerId: model.slice(0, idx), modelId: model.slice(idx + 1) };
5906
+ }
5907
+ function fileUrl(path) {
5908
+ const normalised = path.replace(/\\/g, "/");
5909
+ return `file://${normalised.startsWith("/") ? "" : "/"}${normalised}`;
5910
+ }
5911
+ function truncate4(s2, max) {
5912
+ return s2.length <= max ? s2 : s2.slice(0, max - 1) + "\u2026";
5913
+ }
5914
+ async function httpJson(url, method, body, expectNoContent = false) {
5915
+ const res = await fetch(url, {
5916
+ method,
5917
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
5918
+ body: body !== void 0 ? JSON.stringify(body) : void 0
5919
+ });
5920
+ if (!res.ok) throw new Error(`opencode API ${method} ${url} -> ${res.status}`);
5921
+ if (expectNoContent || res.status === 204) return void 0;
5922
+ return res.json();
5923
+ }
5924
+ function findFreePort() {
5925
+ return new Promise((resolve6, reject) => {
5926
+ const srv = createServer();
5927
+ srv.unref();
5928
+ srv.on("error", reject);
5929
+ srv.listen(0, "127.0.0.1", () => {
5930
+ const address = srv.address();
5931
+ const port2 = typeof address === "object" && address ? address.port : 0;
5932
+ srv.close(() => resolve6(port2));
5933
+ });
5934
+ });
5935
+ }
5936
+ async function waitForServerReady(port2, timeoutMs = 2e4) {
5937
+ const deadline = Date.now() + timeoutMs;
5938
+ while (Date.now() < deadline) {
5939
+ try {
5940
+ const res = await fetch(`http://127.0.0.1:${port2}/doc`);
5941
+ if (res.ok) return;
5942
+ } catch {
5943
+ }
5944
+ await sleep(150);
5945
+ }
5946
+ throw new Error(`opencode serve did not become ready on port ${port2}`);
5947
+ }
5948
+ function sleep(ms) {
5949
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
5950
+ }
5631
5951
 
5632
5952
  // ../daemon/src/session-manager.ts
5633
5953
  import { randomUUID } from "node:crypto";
5634
- import { hostname, platform, tmpdir } from "node:os";
5635
- 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";
5636
5956
  import { dirname as dirname2, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
5637
5957
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5638
5958
 
@@ -5748,7 +6068,7 @@ function summarizeConversation(path, workspace) {
5748
6068
  return {
5749
6069
  conversationId: basename(path, ".jsonl"),
5750
6070
  workspace,
5751
- firstPrompt: truncate4(firstPrompt || "(empty prompt)", 120),
6071
+ firstPrompt: truncate5(firstPrompt || "(empty prompt)", 120),
5752
6072
  lastActiveMs: Math.floor(statSync(path).mtimeMs),
5753
6073
  messageCount
5754
6074
  };
@@ -5774,7 +6094,7 @@ function userMessageText(value) {
5774
6094
  function collapseText(text) {
5775
6095
  return text.replace(/\s+/g, " ").trim();
5776
6096
  }
5777
- function truncate4(text, max) {
6097
+ function truncate5(text, max) {
5778
6098
  return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
5779
6099
  }
5780
6100
  function safeReadDir(path) {
@@ -5979,6 +6299,267 @@ function mimeFromName(path) {
5979
6299
  }[ext] ?? "application/octet-stream";
5980
6300
  }
5981
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
+
5982
6563
  // ../daemon/src/session-manager.ts
5983
6564
  function resolveDaemonVersion() {
5984
6565
  try {
@@ -5990,6 +6571,9 @@ function resolveDaemonVersion() {
5990
6571
  }
5991
6572
  }
5992
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.";
5993
6577
  var SessionManager = class {
5994
6578
  constructor(store2, runners2) {
5995
6579
  this.store = store2;
@@ -6004,6 +6588,14 @@ var SessionManager = class {
6004
6588
  // sessionId → queued prompts
6005
6589
  runners = /* @__PURE__ */ new Map();
6006
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();
6007
6599
  /** Optional artifact plumbing (set by index when capture is possible). */
6008
6600
  uploader = null;
6009
6601
  capture = null;
@@ -6021,6 +6613,21 @@ var SessionManager = class {
6021
6613
  for (const [id, r2] of this.runners) {
6022
6614
  this.runnerAvailability.set(id, await r2.detect());
6023
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
+ });
6024
6631
  }
6025
6632
  // ---- transport attachment --------------------------------------------------
6026
6633
  attach(sink) {
@@ -6028,10 +6635,16 @@ var SessionManager = class {
6028
6635
  return () => this.sinks.delete(sink);
6029
6636
  }
6030
6637
  hello() {
6638
+ const wol = this.store.getWolCapability();
6031
6639
  return {
6032
6640
  type: "hello",
6033
6641
  protocol: 2,
6034
- 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
+ },
6035
6648
  lastSeq: this.store.lastSeq()
6036
6649
  };
6037
6650
  }
@@ -6042,7 +6655,8 @@ var SessionManager = class {
6042
6655
  available: this.runnerAvailability.get(r2.id) ?? false,
6043
6656
  ...r2.loggedIn ? { loggedIn: r2.loggedIn() } : {},
6044
6657
  models: r2.models,
6045
- supportsApprovals: r2.supportsApprovals
6658
+ supportsApprovals: r2.supportsApprovals,
6659
+ ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {}
6046
6660
  }));
6047
6661
  const workspaces = await Promise.all(
6048
6662
  this.store.listWorkspaces().map((w2) => workspaceInfo(w2))
@@ -6137,6 +6751,41 @@ var SessionManager = class {
6137
6751
  case "resume":
6138
6752
  for (const m3 of this.store.replayAfter(msg.afterSeq)) reply(m3);
6139
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
+ }
6140
6789
  case "session-create": {
6141
6790
  const row = this.store.createSession(msg.workspace, msg.runnerId, msg.model, msg.label);
6142
6791
  this.store.upsertWorkspace(msg.workspace);
@@ -6160,7 +6809,9 @@ var SessionManager = class {
6160
6809
  ...msg.model !== void 0 ? { model: msg.model || null } : {},
6161
6810
  ...msg.archived !== void 0 ? { archived: msg.archived } : {},
6162
6811
  ...msg.permissionMode !== void 0 ? { permissionMode: msg.permissionMode } : {},
6163
- ...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 } : {}
6164
6815
  });
6165
6816
  await this.broadcastManifest();
6166
6817
  return;
@@ -6193,8 +6844,56 @@ var SessionManager = class {
6193
6844
  case "fs-list":
6194
6845
  reply({ type: "fs-response", rpcId: msg.rpcId, ...listFolders(msg.path) });
6195
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;
6196
6867
  }
6197
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
+ }
6198
6897
  async sendDropToPhone(path) {
6199
6898
  if (!this.uploader) throw new Error("drop upload unavailable: start daemon with --relay");
6200
6899
  const file = readOutgoingDrop(path);
@@ -6237,10 +6936,17 @@ var SessionManager = class {
6237
6936
  const touchedFiles = [];
6238
6937
  let toolCount = 0;
6239
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}`;
6240
6945
  const handle = runner.start(
6241
6946
  {
6242
6947
  runId,
6243
- prompt,
6948
+ sessionId: session.id,
6949
+ prompt: sentPrompt,
6244
6950
  workspace: session.workspace,
6245
6951
  ...session.model ? { model: session.model } : {},
6246
6952
  ...session.conversationId ? { resumeConversationId: session.conversationId } : {},
@@ -6273,6 +6979,17 @@ var SessionManager = class {
6273
6979
  seq,
6274
6980
  event
6275
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
+ }
6276
6993
  }
6277
6994
  } finally {
6278
6995
  this.active.delete(session.id);
@@ -6304,7 +7021,65 @@ var SessionManager = class {
6304
7021
  }));
6305
7022
  }
6306
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
+ }
6307
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
+ }
6308
7083
  function trackTouches(event, touched) {
6309
7084
  if (event.type !== "tool") return;
6310
7085
  if (!/^(Edit|Write|MultiEdit|NotebookEdit)/.test(event.name)) return;
@@ -6321,16 +7096,16 @@ function listFolders(path) {
6321
7096
  const current = resolve3(path);
6322
7097
  const dirs = safeReadDir2(current).filter((entry) => entry.isDirectory() && !skipDir(entry.name)).map((entry) => {
6323
7098
  const full = join6(current, entry.name);
6324
- return { name: entry.name, path: full, isGit: existsSync6(join6(full, ".git")) };
7099
+ return { name: entry.name, path: full, isGit: existsSync7(join6(full, ".git")) };
6325
7100
  }).sort((a2, b2) => Number(b2.isGit) - Number(a2.isGit) || a2.name.localeCompare(b2.name)).slice(0, 200);
6326
7101
  return { path: current, parent: isRoot(current) ? null : dirname2(current), dirs };
6327
7102
  }
6328
7103
  function driveRoots() {
6329
- if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync6("/.git") }];
7104
+ if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync7("/.git") }];
6330
7105
  const roots = [];
6331
7106
  for (let code = 67; code <= 90; code++) {
6332
7107
  const name = `${String.fromCharCode(code)}:\\`;
6333
- 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")) });
6334
7109
  }
6335
7110
  return roots.sort((a2, b2) => a2.name.localeCompare(b2.name)).slice(0, 200);
6336
7111
  }
@@ -6395,6 +7170,15 @@ var Store = class {
6395
7170
  dev_url TEXT
6396
7171
  );
6397
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
+ );
6398
7182
  `);
6399
7183
  try {
6400
7184
  this.db.exec(`ALTER TABLE workspaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'balanced'`);
@@ -6408,6 +7192,14 @@ var Store = class {
6408
7192
  this.db.exec(`ALTER TABLE sessions ADD COLUMN effort TEXT`);
6409
7193
  } catch {
6410
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
+ }
6411
7203
  }
6412
7204
  // ---- sessions -------------------------------------------------------------
6413
7205
  createSession(workspace, runnerId, model, label) {
@@ -6421,7 +7213,9 @@ var Store = class {
6421
7213
  archived: false,
6422
7214
  conversationId: null,
6423
7215
  permissionMode: "guarded",
6424
- effort: null
7216
+ effort: null,
7217
+ budgetCostUsdCap: null,
7218
+ budgetMinutesCap: null
6425
7219
  };
6426
7220
  this.db.prepare(
6427
7221
  `INSERT INTO sessions (id, workspace, runner_id, model, label, created_at_ms, archived, conversation_id)
@@ -6441,7 +7235,9 @@ var Store = class {
6441
7235
  archived: Boolean(r2.archived),
6442
7236
  conversationId: r2.conversation_id ?? null,
6443
7237
  permissionMode: r2.permission_mode ?? "guarded",
6444
- effort: r2.effort ?? null
7238
+ effort: r2.effort ?? null,
7239
+ budgetCostUsdCap: r2.budget_cost_usd_cap ?? null,
7240
+ budgetMinutesCap: r2.budget_minutes_cap ?? null
6445
7241
  }));
6446
7242
  }
6447
7243
  getSession(id) {
@@ -6452,8 +7248,20 @@ var Store = class {
6452
7248
  if (!current) return;
6453
7249
  const next = { ...current, ...patch };
6454
7250
  this.db.prepare(
6455
- `UPDATE sessions SET label = ?, model = ?, archived = ?, conversation_id = ?, permission_mode = ?, effort = ? WHERE id = ?`
6456
- ).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
+ );
6457
7265
  }
6458
7266
  toSessionInfo(row, busy, queued) {
6459
7267
  return {
@@ -6467,7 +7275,9 @@ var Store = class {
6467
7275
  permissionMode: row.permissionMode,
6468
7276
  effort: row.effort ?? void 0,
6469
7277
  busy,
6470
- queuedPrompts: queued
7278
+ queuedPrompts: queued,
7279
+ budgetCostUsdCap: row.budgetCostUsdCap ?? void 0,
7280
+ budgetMinutesCap: row.budgetMinutesCap ?? void 0
6471
7281
  };
6472
7282
  }
6473
7283
  // ---- transcript log ---------------------------------------------------------
@@ -6538,6 +7348,32 @@ var Store = class {
6538
7348
  setWorkspacePolicy(path, policy) {
6539
7349
  this.db.prepare(`UPDATE workspaces SET policy = ? WHERE path = ?`).run(policy, path);
6540
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
+ }
6541
7377
  };
6542
7378
  function searchableText(msg) {
6543
7379
  switch (msg.type) {
@@ -6548,6 +7384,7 @@ function searchableText(msg) {
6548
7384
  if (msg.event.type === "tool") return msg.event.summary;
6549
7385
  if (msg.event.type === "done") return msg.event.summary;
6550
7386
  if (msg.event.type === "error") return msg.event.message;
7387
+ if (msg.event.type === "status-update") return msg.event.text;
6551
7388
  return "";
6552
7389
  case "drop":
6553
7390
  return msg.name;
@@ -6557,7 +7394,7 @@ function searchableText(msg) {
6557
7394
  }
6558
7395
 
6559
7396
  // ../daemon/src/local-server.ts
6560
- import { createServer } from "node:http";
7397
+ import { createServer as createServer2 } from "node:http";
6561
7398
 
6562
7399
  // ../node_modules/.pnpm/ws@8.21.3/node_modules/ws/wrapper.mjs
6563
7400
  var import_stream = __toESM(require_stream(), 1);
@@ -10617,11 +11454,42 @@ var ApprovalQuestionSchema = external_exports.object({
10617
11454
  options: external_exports.array(external_exports.object({ label: external_exports.string(), description: external_exports.string().optional() })),
10618
11455
  multiSelect: external_exports.boolean().optional()
10619
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
+ });
10620
11480
  var RunEventSchema = external_exports.discriminatedUnion("type", [
10621
11481
  external_exports.object({ type: external_exports.literal("text"), chunk: external_exports.string() }),
10622
11482
  /** Extended-thinking content, streamed the same way as text (medium+ effort tiers). */
10623
11483
  external_exports.object({ type: external_exports.literal("thinking"), chunk: external_exports.string() }),
10624
- 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
+ }),
10625
11493
  external_exports.object({
10626
11494
  type: external_exports.literal("approval"),
10627
11495
  id: external_exports.string(),
@@ -10666,12 +11534,67 @@ var RunEventSchema = external_exports.discriminatedUnion("type", [
10666
11534
  fiveHourResetsAtMs: external_exports.number(),
10667
11535
  sevenDayUtilization: external_exports.number(),
10668
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()
10669
11588
  })
10670
11589
  ]);
10671
11590
  var PermissionModeSchema = external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]);
10672
11591
  var EffortSchema = external_exports.enum(["low", "medium", "high", "max"]);
10673
11592
  var RunSpecSchema = external_exports.object({
10674
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(),
10675
11598
  prompt: external_exports.string().min(1),
10676
11599
  /** Absolute path of the workspace the agent runs in. */
10677
11600
  workspace: external_exports.string(),
@@ -10696,7 +11619,11 @@ var RunnerInfoSchema = external_exports.object({
10696
11619
  version: external_exports.string().optional(),
10697
11620
  /** Model ids the phone may offer; empty = CLI default only. */
10698
11621
  models: external_exports.array(external_exports.string()),
10699
- 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()
10700
11627
  });
10701
11628
  var ApprovalPolicySchema = external_exports.enum(["paranoid", "balanced", "trusting"]);
10702
11629
  var WorkspaceInfoSchema = external_exports.object({
@@ -10723,12 +11650,23 @@ var SessionInfoSchema = external_exports.object({
10723
11650
  effort: external_exports.enum(["low", "medium", "high", "max"]).optional(),
10724
11651
  /** Whether a run is active or prompts are queued right now. */
10725
11652
  busy: external_exports.boolean(),
10726
- 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()
10727
11657
  });
10728
11658
  var HostInfoSchema = external_exports.object({
10729
11659
  hostname: external_exports.string(),
10730
11660
  os: external_exports.string(),
10731
- 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()
10732
11670
  });
10733
11671
 
10734
11672
  // ../shared/src/protocol-v2.ts
@@ -10762,6 +11700,12 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
10762
11700
  answer: external_exports.string().optional()
10763
11701
  }),
10764
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
+ }),
10765
11709
  external_exports.object({
10766
11710
  type: external_exports.literal("drop-send"),
10767
11711
  blobId: external_exports.string(),
@@ -10792,7 +11736,10 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
10792
11736
  model: external_exports.string().optional(),
10793
11737
  archived: external_exports.boolean().optional(),
10794
11738
  permissionMode: external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]).optional(),
10795
- 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()
10796
11743
  }),
10797
11744
  /** Start a fresh agent conversation inside the session. */
10798
11745
  external_exports.object({ type: external_exports.literal("session-reset"), sessionId: external_exports.string() }),
@@ -10826,7 +11773,19 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
10826
11773
  type: external_exports.literal("fs-list"),
10827
11774
  rpcId: external_exports.string(),
10828
11775
  path: external_exports.string().optional()
10829
- })
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") })
10830
11789
  ]);
10831
11790
  var ReceiptSchema = external_exports.object({
10832
11791
  runId: external_exports.string(),
@@ -10884,6 +11843,17 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
10884
11843
  size: external_exports.number().int().nonnegative(),
10885
11844
  direction: external_exports.enum(["to-phone", "to-pc"])
10886
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
+ }),
10887
11857
  // RPC responses (not seq-logged)
10888
11858
  external_exports.object({
10889
11859
  type: external_exports.literal("history-response"),
@@ -10909,7 +11879,12 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
10909
11879
  rpcId: external_exports.string(),
10910
11880
  path: external_exports.string(),
10911
11881
  parent: external_exports.string().nullable(),
10912
- 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()
10913
11888
  }),
10914
11889
  external_exports.object({ type: external_exports.literal("error"), message: external_exports.string() })
10915
11890
  ]);
@@ -10933,7 +11908,8 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
10933
11908
  */
10934
11909
  external_exports.object({
10935
11910
  kind: external_exports.literal("notify"),
10936
- 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. */
10937
11913
  id: external_exports.string().optional(),
10938
11914
  sessionId: external_exports.string().optional(),
10939
11915
  /** 'progress' only — a bare action count, never the action itself (no content in push). */
@@ -10941,6 +11917,16 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
10941
11917
  }),
10942
11918
  /** relay → daemon: verdict delivered via push-notification action button. */
10943
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
+ }),
10944
11930
  external_exports.object({ kind: external_exports.literal("ping") }),
10945
11931
  external_exports.object({ kind: external_exports.literal("pong") })
10946
11932
  ]);
@@ -11014,9 +12000,9 @@ if (typeof Module.getRandomValue === "undefined") {
11014
12000
  Module.getRandomValue = randomValuesStandard;
11015
12001
  } catch (e) {
11016
12002
  try {
11017
- crypto2 = null;
12003
+ crypto = null;
11018
12004
  randomValueNodeJS = function() {
11019
- var buf = crypto2["randomBytes"](4);
12005
+ var buf = crypto["randomBytes"](4);
11020
12006
  return (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
11021
12007
  };
11022
12008
  randomValueNodeJS();
@@ -11029,7 +12015,7 @@ if (typeof Module.getRandomValue === "undefined") {
11029
12015
  var window_;
11030
12016
  var crypto_;
11031
12017
  var randomValuesStandard;
11032
- var crypto2;
12018
+ var crypto;
11033
12019
  var randomValueNodeJS;
11034
12020
  var _Module = Module;
11035
12021
  Module.ready = new Promise(function(resolve6, reject) {
@@ -37923,7 +38909,7 @@ Module.ready = new Promise(function(resolve6, reject) {
37923
38909
  try {
37924
38910
  var window_ = "object" === typeof window ? window : self;
37925
38911
  var crypto_ = typeof window_.crypto !== "undefined" ? window_.crypto : window_.msCrypto;
37926
- crypto_ = crypto_ === void 0 ? crypto2 : crypto_;
38912
+ crypto_ = crypto_ === void 0 ? crypto : crypto_;
37927
38913
  var randomValuesStandard = function() {
37928
38914
  var buf = new Uint32Array(1);
37929
38915
  crypto_.getRandomValues(buf);
@@ -37933,9 +38919,9 @@ Module.ready = new Promise(function(resolve6, reject) {
37933
38919
  Module3.getRandomValue = randomValuesStandard;
37934
38920
  } catch (e) {
37935
38921
  try {
37936
- var crypto2 = null;
38922
+ var crypto = null;
37937
38923
  var randomValueNodeJS = function() {
37938
- var buf = crypto2["randomBytes"](4);
38924
+ var buf = crypto["randomBytes"](4);
37939
38925
  return (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
37940
38926
  };
37941
38927
  randomValueNodeJS();
@@ -38581,7 +39567,7 @@ Module.ready = new Promise(function(resolve6, reject) {
38581
39567
  try {
38582
39568
  var window_ = "object" === typeof window ? window : self;
38583
39569
  var crypto_ = typeof window_.crypto !== "undefined" ? window_.crypto : window_.msCrypto;
38584
- crypto_ = crypto_ === void 0 ? crypto2 : crypto_;
39570
+ crypto_ = crypto_ === void 0 ? crypto : crypto_;
38585
39571
  var randomValuesStandard = function() {
38586
39572
  var buf = new Uint32Array(1);
38587
39573
  crypto_.getRandomValues(buf);
@@ -38591,9 +39577,9 @@ Module.ready = new Promise(function(resolve6, reject) {
38591
39577
  Module2.getRandomValue = randomValuesStandard;
38592
39578
  } catch (e) {
38593
39579
  try {
38594
- var crypto2 = null;
39580
+ var crypto = null;
38595
39581
  var randomValueNodeJS = function() {
38596
- var buf = crypto2["randomBytes"](4);
39582
+ var buf = crypto["randomBytes"](4);
38597
39583
  return (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
38598
39584
  };
38599
39585
  randomValueNodeJS();
@@ -41515,7 +42501,7 @@ var LocalSessionServer = class {
41515
42501
  constructor(manager2, port2, broker2) {
41516
42502
  this.manager = manager2;
41517
42503
  this.broker = broker2;
41518
- const http = createServer((req, res) => void this.onRequest(req, res));
42504
+ const http = createServer2((req, res) => void this.onRequest(req, res));
41519
42505
  this.wss = new import_websocket_server.default({ server: http });
41520
42506
  this.wss.on("connection", (ws) => this.onConnection(ws));
41521
42507
  http.on("error", (err) => {
@@ -41548,6 +42534,22 @@ var LocalSessionServer = class {
41548
42534
  }
41549
42535
  return;
41550
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
+ }
41551
42553
  res.writeHead(404);
41552
42554
  res.end();
41553
42555
  }
@@ -41583,8 +42585,19 @@ var RelayClient = class {
41583
42585
  heartbeat = null;
41584
42586
  detach = null;
41585
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;
41586
42594
  /** sessionId → tool-call count for the in-flight run (opaque count only, never the action). */
41587
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;
41588
42601
  start() {
41589
42602
  this.connect();
41590
42603
  }
@@ -41599,8 +42612,13 @@ var RelayClient = class {
41599
42612
  }
41600
42613
  connect() {
41601
42614
  if (this.stopped) return;
42615
+ console.log(`relay: connecting (${this.relayUrl})`);
41602
42616
  const ws = new wrapper_default(this.url());
41603
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);
41604
42622
  const send = (msg) => {
41605
42623
  if (ws.readyState === wrapper_default.OPEN) {
41606
42624
  const envelope = seal(msg, this.keys.tx);
@@ -41640,24 +42658,52 @@ var RelayClient = class {
41640
42658
  JSON.stringify({ kind: "notify", notice: "error", sessionId: msg.sessionId })
41641
42659
  );
41642
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
+ }
41643
42676
  if (msg.type === "drop" && msg.direction === "to-phone") {
41644
42677
  ws.send(JSON.stringify({ kind: "notify", notice: "drop" }));
41645
42678
  }
41646
42679
  }
41647
42680
  };
41648
42681
  ws.on("open", () => {
42682
+ clearSlowStartTimer();
41649
42683
  this.backoffMs = 500;
42684
+ this.awaitingPongSince = null;
41650
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;
41651
42691
  this.detach = this.manager.attach(send);
41652
42692
  send(this.manager.hello());
41653
42693
  void this.manager.manifest().then(send);
41654
42694
  this.heartbeat = setInterval(() => {
41655
- if (ws.readyState === wrapper_default.OPEN) {
41656
- 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;
41657
42700
  }
42701
+ this.awaitingPongSince = Date.now();
42702
+ ws.send(JSON.stringify({ kind: "ping" }));
41658
42703
  }, HEARTBEAT_MS);
41659
42704
  });
41660
42705
  ws.on("message", (raw) => {
42706
+ this.awaitingPongSince = null;
41661
42707
  let frame;
41662
42708
  try {
41663
42709
  frame = parseRelayFrame(raw.toString());
@@ -41671,6 +42717,18 @@ var RelayClient = class {
41671
42717
  );
41672
42718
  return;
41673
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
+ }
41674
42732
  if (frame.kind !== "peer") return;
41675
42733
  try {
41676
42734
  const envelope = EnvelopeSchema.parse(frame.payload);
@@ -41683,7 +42741,12 @@ var RelayClient = class {
41683
42741
  console.error(`relay: ${err.message}`);
41684
42742
  });
41685
42743
  ws.on("close", () => {
42744
+ clearSlowStartTimer();
41686
42745
  this.cleanup();
42746
+ if (this.everConnected && this.offlineSinceMs === null) {
42747
+ this.offlineSinceMs = Date.now();
42748
+ this.manager.recordDeviceOffline();
42749
+ }
41687
42750
  if (this.stopped) return;
41688
42751
  console.log(`relay: disconnected \u2014 retrying in ${this.backoffMs}ms`);
41689
42752
  setTimeout(() => this.connect(), this.backoffMs);
@@ -41699,7 +42762,7 @@ var RelayClient = class {
41699
42762
  };
41700
42763
 
41701
42764
  // ../daemon/src/pairing.ts
41702
- 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";
41703
42766
  import { homedir as homedir7 } from "node:os";
41704
42767
  import { join as join8 } from "node:path";
41705
42768
  import { randomInt } from "node:crypto";
@@ -41709,7 +42772,7 @@ var POLL_MS = 2e3;
41709
42772
  var POLL_TIMEOUT_MS = 10 * 60 * 1e3;
41710
42773
  async function ensurePairing(relayUrl2, forceNew = false, webUrl2 = "https://offhand-web.onrender.com") {
41711
42774
  await ready;
41712
- if (!forceNew && existsSync7(PAIRING_PATH)) {
42775
+ if (!forceNew && existsSync8(PAIRING_PATH)) {
41713
42776
  const f2 = JSON.parse(readFileSync4(PAIRING_PATH, "utf8"));
41714
42777
  const kp2 = { publicKey: fromB64u(f2.daemonPublicKey), secretKey: fromB64u(f2.daemonSecretKey) };
41715
42778
  const phonePk = fromB64u(f2.phonePublicKey);
@@ -41864,7 +42927,7 @@ var ApprovalBroker = class {
41864
42927
  };
41865
42928
  function classifyRisk(toolName, input) {
41866
42929
  const i2 = input ?? {};
41867
- const text = [toolName, i2.command, i2.file_path, i2.path].filter((x2) => typeof x2 === "string").join(" ");
42930
+ const text = [toolName, i2.command, i2.file_path, i2.path, i2.filepath].filter((x2) => typeof x2 === "string").join(" ");
41868
42931
  return /\b(rm|del|rmdir|rd|format|mkfs|shutdown|reboot|kill|drop\s+table|truncate|git\s+push\s+--force|--hard)\b/i.test(
41869
42932
  text
41870
42933
  ) ? "high" : "low";
@@ -41873,11 +42936,11 @@ function summariseInput(input) {
41873
42936
  if (typeof input !== "object" || input === null) return String(input ?? "");
41874
42937
  const i2 = input;
41875
42938
  const q2 = firstQuestion(i2);
41876
- if (q2) return truncate5(q2.text, 200);
41877
- for (const key of ["command", "file_path", "path", "url", "pattern", "description"]) {
41878
- if (typeof i2[key] === "string" && i2[key] !== "") return truncate5(`${key}: ${i2[key]}`, 200);
42939
+ if (q2) return truncate6(q2.text, 200);
42940
+ for (const key of ["command", "file_path", "filepath", "path", "url", "pattern", "description"]) {
42941
+ if (typeof i2[key] === "string" && i2[key] !== "") return truncate6(`${key}: ${i2[key]}`, 200);
41879
42942
  }
41880
- return truncate5(JSON.stringify(input), 200);
42943
+ return truncate6(JSON.stringify(input), 200);
41881
42944
  }
41882
42945
  function firstQuestion(i2) {
41883
42946
  if (!Array.isArray(i2.questions) || i2.questions.length === 0) return void 0;
@@ -41895,7 +42958,7 @@ function buildPreview(toolName, input) {
41895
42958
  const q2 = firstQuestion(i2);
41896
42959
  if (q2) {
41897
42960
  const lines = [q2.text, ...q2.options.map((o2, n2) => `${n2 + 1}. ${o2.label}${o2.description ? ` \u2014 ${o2.description}` : ""}`)];
41898
- return truncate5(lines.join("\n"), 600);
42961
+ return truncate6(lines.join("\n"), 600);
41899
42962
  }
41900
42963
  if (/^(Edit|MultiEdit)/.test(toolName) && typeof i2.new_string === "string") {
41901
42964
  const oldS = typeof i2.old_string === "string" ? i2.old_string : "";
@@ -41903,15 +42966,24 @@ function buildPreview(toolName, input) {
41903
42966
  ...oldS.split("\n").map((l2) => `- ${l2}`),
41904
42967
  ...i2.new_string.split("\n").map((l2) => `+ ${l2}`)
41905
42968
  ];
41906
- return truncate5(lines.join("\n"), 600);
42969
+ return truncate6(lines.join("\n"), 600);
41907
42970
  }
41908
42971
  if (/^Write/.test(toolName) && typeof i2.content === "string") {
41909
- return truncate5(i2.content.split("\n").map((l2) => `+ ${l2}`).join("\n"), 600);
42972
+ return truncate6(i2.content.split("\n").map((l2) => `+ ${l2}`).join("\n"), 600);
42973
+ }
42974
+ if (typeof i2.diff === "string" && i2.diff.trim()) {
42975
+ return truncate6(formatUnifiedDiff(i2.diff), 600);
41910
42976
  }
41911
- if (typeof i2.command === "string") return truncate5(`$ ${i2.command}`, 600);
42977
+ if (typeof i2.url === "string") return truncate6(`\u2192 ${i2.url}`, 600);
42978
+ if (typeof i2.command === "string") return truncate6(`$ ${i2.command}`, 600);
41912
42979
  return void 0;
41913
42980
  }
41914
- function truncate5(s2, max) {
42981
+ function formatUnifiedDiff(diff) {
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();
42985
+ }
42986
+ function truncate6(s2, max) {
41915
42987
  return s2.length <= max ? s2 : s2.slice(0, max - 1) + "\u2026";
41916
42988
  }
41917
42989
 
@@ -41950,7 +43022,7 @@ async function downloadArtifact(relayUrl2, sessionId, blobId, keys) {
41950
43022
  // ../daemon/src/autostart.ts
41951
43023
  import { homedir as homedir8 } from "node:os";
41952
43024
  import { join as join10, resolve as resolve4, dirname as dirname3 } from "node:path";
41953
- 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";
41954
43026
  import { fileURLToPath as fileURLToPath3 } from "node:url";
41955
43027
  import { spawnSync } from "node:child_process";
41956
43028
  import { tmpdir as tmpdir3 } from "node:os";
@@ -41971,27 +43043,23 @@ function getWrapperPath() {
41971
43043
  function getShortcutPath() {
41972
43044
  return join10(getStartupDir(), SHORTCUT_NAME);
41973
43045
  }
41974
- function buildWrapperScript(config) {
41975
- const projectRoot = resolve4(__dirname2, "..", "..");
41976
- const wsArgs2 = config.workspaces.map((w2) => `--workspace "${w2}"`).join(" ");
41977
- const relayArg = config.relayUrl ? `--relay "${config.relayUrl}"` : "";
41978
- const devUrlArg = config.devUrl ? `--dev-url "${config.devUrl}"` : "";
41979
- const portArg = `--port ${config.port}`;
41980
- const approvalArg = `--approval-timeout ${config.approvalTimeout ?? 300}`;
41981
- const webUrlArg = `--web-url "${config.webUrl ?? "https://offhand-web.onrender.com"}"`;
41982
- const parts = [
41983
- "pnpm --filter @offhand/daemon dev",
41984
- wsArgs2,
41985
- relayArg,
41986
- devUrlArg,
41987
- portArg,
41988
- approvalArg,
41989
- webUrlArg
41990
- ].filter(Boolean);
41991
- const cmd = parts.join(" ");
41992
- return `@echo off
41993
- cd /d "${projectRoot}"
41994
- ${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
41995
43063
  `;
41996
43064
  }
41997
43065
  function createShortcut(targetPath, shortcutPath) {
@@ -42012,11 +43080,11 @@ $Shortcut.Save()`;
42012
43080
  return { success: result.status === 0, output: result.stdout + result.stderr };
42013
43081
  }
42014
43082
  function checkShortcutExists(shortcutPath) {
42015
- return existsSync8(shortcutPath);
43083
+ return existsSync9(shortcutPath);
42016
43084
  }
42017
- function installAutostart(config) {
43085
+ function installAutostart(config2) {
42018
43086
  const wrapperPath = getWrapperPath();
42019
- const script = buildWrapperScript(config);
43087
+ const script = buildWrapperScript(config2);
42020
43088
  writeFileSync4(wrapperPath, script);
42021
43089
  const shortcutPath = getShortcutPath();
42022
43090
  const result = createShortcut(wrapperPath, shortcutPath);
@@ -42032,7 +43100,7 @@ function getAutostartStatus() {
42032
43100
  const shortcutPath = getShortcutPath();
42033
43101
  const installed = checkShortcutExists(shortcutPath);
42034
43102
  const wrapperPath = getWrapperPath();
42035
- const wrapperExists = existsSync8(wrapperPath);
43103
+ const wrapperExists = existsSync9(wrapperPath);
42036
43104
  let details = `Shortcut: ${shortcutPath} (${installed ? "EXISTS" : "MISSING"})`;
42037
43105
  if (wrapperExists) {
42038
43106
  details += `
@@ -42040,14 +43108,13 @@ Wrapper: ${wrapperPath} (EXISTS)`;
42040
43108
  }
42041
43109
  return { installed, details };
42042
43110
  }
42043
- function saveConfig(config) {
43111
+ function saveConfig(config2) {
42044
43112
  const configPath = join10(OFFHAND_HOME, "autostart.json");
42045
- writeFileSync4(configPath, JSON.stringify(config, null, 2));
43113
+ writeFileSync4(configPath, JSON.stringify(config2, null, 2));
42046
43114
  }
42047
43115
  function getConfigFromStore() {
42048
- const workspaces = ["C:\\Users\\udbha\\dev\\offhand"];
42049
43116
  return {
42050
- workspaces,
43117
+ workspaces: [],
42051
43118
  port: 4317,
42052
43119
  relayUrl: void 0,
42053
43120
  devUrl: void 0,
@@ -42090,7 +43157,7 @@ var devUrl = argValue("--dev-url");
42090
43157
  var store = new Store();
42091
43158
  var wsArgs = argValues("--workspace").map((w2) => resolve5(w2));
42092
43159
  for (const w2 of wsArgs) {
42093
- if (!existsSync9(w2)) {
43160
+ if (!existsSync10(w2)) {
42094
43161
  console.error(`workspace does not exist: ${w2}`);
42095
43162
  process.exit(1);
42096
43163
  }
@@ -42102,7 +43169,7 @@ var approvalUrl = `http://127.0.0.1:${port}/approval`;
42102
43169
  var runners = [
42103
43170
  new ClaudeCodeRunner(broker, approvalUrl),
42104
43171
  new CopilotCliRunner(),
42105
- new OpenCodeRunner(),
43172
+ new OpenCodeRunner(broker),
42106
43173
  new CodexCliRunner(),
42107
43174
  new CursorAgentRunner(),
42108
43175
  new GeminiCliRunner()
@@ -42123,25 +43190,20 @@ if (m2.type === "manifest") {
42123
43190
  new LocalSessionServer(manager, port, broker);
42124
43191
  console.log(` local : ws://127.0.0.1:${port}`);
42125
43192
  console.log(` approvals : timeout ${approvalTimeoutMs / 1e3}s then auto-deny`);
42126
- var autostartStatus = getAutostartStatus();
42127
- if (!autostartStatus.installed) {
42128
- console.log(` autostart : installing...`);
42129
- const config = getConfigFromStore();
42130
- config.workspaces = store.listWorkspaces().map((w2) => w2.path);
42131
- config.port = port;
42132
- config.relayUrl = relayUrl;
42133
- config.devUrl = devUrl;
42134
- config.approvalTimeout = approvalTimeoutMs / 1e3;
42135
- config.webUrl = webUrl;
42136
- saveConfig(config);
42137
- const { success, output } = installAutostart(config);
42138
- if (success) {
42139
- console.log(` autostart : installed (runs on login)`);
42140
- } else {
42141
- console.warn(` autostart : failed to install: ${output}`);
42142
- }
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)`);
42143
43205
  } else {
42144
- console.log(` autostart : already installed`);
43206
+ console.warn(` autostart : failed to ${wasInstalled ? "refresh" : "install"}: ${output}`);
42145
43207
  }
42146
43208
  if (relayUrl) {
42147
43209
  const pairing = await ensurePairing(relayUrl, forceRepair, webUrl);