offhands 0.1.5 → 0.1.9

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,21 @@ 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"];
4979
+ // The vendor's own supported install path (code.claude.com/docs/en/setup
4980
+ // lists this as the "advanced installation option" alongside their native
4981
+ // curl/PowerShell installer — npm works fine and is what the daemon can
4982
+ // run unattended). Verified Sept 2026; re-check if this ever 404s.
4983
+ installCommand = { command: "npm", args: ["install", "-g", "@anthropic-ai/claude-code"] };
4946
4984
  loggedIn() {
4947
4985
  return existsSync(join(homedir(), ".claude", ".credentials.json"));
4948
4986
  }
@@ -4988,7 +5026,15 @@ var ClaudeCodeRunner = class {
4988
5026
  offhand: {
4989
5027
  command: process.execPath,
4990
5028
  args: [APPROVAL_MCP_PATH],
4991
- env: { OFFHAND_APPROVAL_URL: this.approvalUrl }
5029
+ // OFFHAND_STATUS_URL is derived from approvalUrl rather than a
5030
+ // second constructor param — same daemon, same port, one fewer
5031
+ // thing to keep in sync as this.approvalUrl already carries the
5032
+ // host:port everything else here needs.
5033
+ env: {
5034
+ OFFHAND_APPROVAL_URL: this.approvalUrl,
5035
+ OFFHAND_STATUS_URL: this.approvalUrl.replace(/\/approval$/, "/status"),
5036
+ OFFHAND_SESSION_ID: run.sessionId
5037
+ }
4992
5038
  }
4993
5039
  }
4994
5040
  };
@@ -5102,6 +5148,7 @@ var CodexCliRunner = class {
5102
5148
  name = "OpenAI Codex CLI";
5103
5149
  models = [];
5104
5150
  supportsApprovals = false;
5151
+ installCommand = { command: "npm", args: ["install", "-g", "@openai/codex"] };
5105
5152
  detect = () => commandExists("codex");
5106
5153
  start = (_run) => notImplemented(this.id);
5107
5154
  };
@@ -5110,7 +5157,18 @@ var CursorAgentRunner = class {
5110
5157
  name = "Cursor";
5111
5158
  models = [];
5112
5159
  supportsApprovals = false;
5113
- detect = () => commandExists("cursor-agent");
5160
+ // No npm package exists — the vendor's real installer is a piped shell
5161
+ // script (`curl https://cursor.com/install | bash` / an iwr|iex on
5162
+ // Windows). Never auto-execute a remote script on the user's behalf;
5163
+ // link out instead of offering a one-tap install here.
5164
+ installUrl = "https://cursor.com/docs/cli/installation";
5165
+ // The installed binary is actually named `agent`, not `cursor-agent`
5166
+ // (confirmed against the vendor's current docs, Sept 2026) — detect()
5167
+ // was checking the wrong command name and would never find a real
5168
+ // install. Fixed alongside the install-hint work since the two are
5169
+ // directly related: there's no point offering install guidance for a
5170
+ // runner whose own detection can never succeed.
5171
+ detect = () => commandExists("agent");
5114
5172
  start = (_run) => notImplemented(this.id);
5115
5173
  };
5116
5174
  var GeminiCliRunner = class {
@@ -5118,6 +5176,7 @@ var GeminiCliRunner = class {
5118
5176
  name = "Gemini CLI";
5119
5177
  models = [];
5120
5178
  supportsApprovals = false;
5179
+ installCommand = { command: "npm", args: ["install", "-g", "@google/gemini-cli"] };
5121
5180
  detect = () => commandExists("gemini");
5122
5181
  start = (_run) => notImplemented(this.id);
5123
5182
  };
@@ -5174,6 +5233,12 @@ var CopilotCliRunner = class {
5174
5233
  models = [];
5175
5234
  // no headless enumeration in 1.0.31; CLI default
5176
5235
  supportsApprovals = false;
5236
+ // No effortTiers: start() never reads run.effort — there's no thinking-
5237
+ // budget flag in Copilot CLI 1.0.31's non-interactive mode. Leaving this
5238
+ // undefined (not an empty array) is what tells the phone to hide the
5239
+ // effort control here rather than show one that silently does nothing.
5240
+ // Vendor's own docs (docs.github.com/copilot/how-tos/copilot-cli/install-copilot-cli).
5241
+ installCommand = { command: "npm", args: ["install", "-g", "@github/copilot"] };
5177
5242
  /** [command, ...prefixArgs] resolved once. */
5178
5243
  resolved = null;
5179
5244
  resolveCommand() {
@@ -5302,7 +5367,8 @@ import { spawn as spawn4 } from "node:child_process";
5302
5367
  import { createServer } from "node:net";
5303
5368
  import { existsSync as existsSync3 } from "node:fs";
5304
5369
  import { homedir as homedir3 } from "node:os";
5305
- import { delimiter as delimiter2, join as join3 } from "node:path";
5370
+ import { delimiter as delimiter2, join as join3, dirname as dirname2 } from "node:path";
5371
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5306
5372
 
5307
5373
  // ../daemon/src/runners/opencode-map.ts
5308
5374
  function mapOpenCodeEvent(value) {
@@ -5385,22 +5451,59 @@ function truncate3(s2, max) {
5385
5451
 
5386
5452
  // ../daemon/src/runners/opencode-cli.ts
5387
5453
  var POLL_INTERVAL_MS = 500;
5454
+ var APPROVAL_MCP_PATH2 = join3(dirname2(fileURLToPath2(import.meta.url)), "..", "approval-mcp.mjs");
5455
+ function statusOnlyMcpConfigContent(statusUrl2, sessionId) {
5456
+ return JSON.stringify({
5457
+ mcp: {
5458
+ "offhand-status": {
5459
+ type: "local",
5460
+ command: [process.execPath, APPROVAL_MCP_PATH2],
5461
+ environment: {
5462
+ OFFHAND_STATUS_URL: statusUrl2 ?? "",
5463
+ OFFHAND_SESSION_ID: sessionId
5464
+ }
5465
+ }
5466
+ }
5467
+ });
5468
+ }
5388
5469
  var OpenCodeRunner = class {
5389
- constructor(broker2) {
5470
+ constructor(broker2, statusUrl2) {
5390
5471
  this.broker = broker2;
5472
+ this.statusUrl = statusUrl2;
5391
5473
  }
5392
5474
  id = "opencode";
5393
5475
  name = "OpenCode";
5394
5476
  supportsApprovals = true;
5395
5477
  models = [];
5396
5478
  // populated dynamically via `opencode models`
5479
+ // Real: every tier maps to a --variant value below (see startViaCli()) —
5480
+ // low/medium collide onto minimal/high internally, but all four inputs
5481
+ // are genuinely honored, just not 1:1 named.
5482
+ effortTiers = ["low", "medium", "high", "max"];
5483
+ // Package name is `opencode-ai` (opencode.ai/docs), not `opencode` or
5484
+ // `@opencode/cli` — verified against the vendor's own install docs.
5485
+ installCommand = { command: "npm", args: ["install", "-g", "opencode-ai"] };
5397
5486
  /** [command, ...prefixArgs] resolved once. */
5398
5487
  resolved = null;
5399
5488
  modelsCache = [];
5400
- // Lazily-started, shared `opencode serve` instance backing startViaHttpApi.
5401
- serverPort = null;
5402
- serverProcess = null;
5403
- serverStarting = null;
5489
+ // Lazily-started `opencode serve` instances backing startViaHttpApi — ONE
5490
+ // PER OFFHAND SESSION, not a single shared process. A shared server was
5491
+ // the original design (simpler, one cold-start instead of many), but its
5492
+ // MCP config is fixed at spawn time via an env var, and the server itself
5493
+ // is genuinely multi-tenant (concurrent offhand sessions each get their
5494
+ // own opencode-internal session id on the SAME shared HTTP server — see
5495
+ // the `POST /session` call in startViaHttpApi). A single shared server
5496
+ // therefore cannot carry a correct OFFHAND_SESSION_ID for
5497
+ // offhand_status_update: two sessions running at once would have the
5498
+ // second one's status updates silently attributed to the first. Keying
5499
+ // by session id costs an extra `opencode serve` cold-start per session
5500
+ // (kept alive for that session's lifetime, matching how
5501
+ // `resumeConversationId` already expects the SAME server instance across
5502
+ // that session's later turns) in exchange for actually-correct
5503
+ // attribution — worth it; a silently wrong session id is a Rule #2
5504
+ // violation, a slower first prompt is not.
5505
+ servers = /* @__PURE__ */ new Map();
5506
+ serverStarting = /* @__PURE__ */ new Map();
5404
5507
  resolveCommand() {
5405
5508
  if (this.resolved) return this.resolved;
5406
5509
  if (process.platform === "win32") {
@@ -5462,15 +5565,15 @@ var OpenCodeRunner = class {
5462
5565
  stdio: ["ignore", "pipe", "ignore"],
5463
5566
  shell: process.platform === "win32"
5464
5567
  });
5465
- let output = "";
5568
+ let output2 = "";
5466
5569
  p2.stdout.setEncoding("utf8");
5467
5570
  p2.stdout.on("data", (chunk) => {
5468
- output += chunk;
5571
+ output2 += chunk;
5469
5572
  });
5470
5573
  p2.on("close", (code) => {
5471
5574
  if (code === 0) {
5472
5575
  try {
5473
- const lines = output.trim().split("\n");
5576
+ const lines = output2.trim().split("\n");
5474
5577
  const models = lines.map((l2) => l2.trim()).filter((l2) => l2.length > 0);
5475
5578
  this.modelsCache = models;
5476
5579
  Object.defineProperty(this, "models", {
@@ -5557,7 +5660,10 @@ var OpenCodeRunner = class {
5557
5660
  child = spawn4(cmd[0], args2, {
5558
5661
  cwd: run.workspace,
5559
5662
  stdio: ["ignore", "pipe", "pipe"],
5560
- shell: process.platform === "win32"
5663
+ shell: process.platform === "win32",
5664
+ // One-shot process per prompt — no shared-server cross-session risk
5665
+ // here, so the session id can go straight on the env unconditionally.
5666
+ env: { ...process.env, OPENCODE_CONFIG_CONTENT: statusOnlyMcpConfigContent(this.statusUrl, run.sessionId) }
5561
5667
  });
5562
5668
  } catch (e) {
5563
5669
  queue.push({ type: "error", message: `failed to spawn opencode: ${String(e)}` });
@@ -5631,7 +5737,7 @@ stderr: ${stderrTail}` : ""}`
5631
5737
  };
5632
5738
  void (async () => {
5633
5739
  try {
5634
- port2 = await this.ensureServer();
5740
+ port2 = await this.ensureServer(run.sessionId);
5635
5741
  } catch (e) {
5636
5742
  emit([{ type: "error", message: `failed to start opencode server: ${String(e)}` }]);
5637
5743
  cleanup();
@@ -5652,6 +5758,14 @@ stderr: ${stderrTail}` : ""}`
5652
5758
  sessionId = session.id;
5653
5759
  }
5654
5760
  callbacks?.onConversationId?.(sessionId);
5761
+ const baselineMessageIds = /* @__PURE__ */ new Set();
5762
+ if (run.resumeConversationId) {
5763
+ try {
5764
+ const existing = await httpJson(`${base}/session/${sessionId}/message?directory=${encDir}`, "GET");
5765
+ for (const row of existing) baselineMessageIds.add(row.info.id);
5766
+ } catch {
5767
+ }
5768
+ }
5655
5769
  const parts = [{ type: "text", text: run.prompt }];
5656
5770
  for (const file of run.attachments ?? []) {
5657
5771
  parts.push({ type: "file", mime: "application/octet-stream", url: fileUrl(file) });
@@ -5660,7 +5774,7 @@ stderr: ${stderrTail}` : ""}`
5660
5774
  const modelRef = parseModelRef(run.model);
5661
5775
  if (modelRef) promptBody.model = { providerID: modelRef.providerId, modelID: modelRef.modelId };
5662
5776
  await httpJson(`${base}/session/${sessionId}/prompt_async?directory=${encDir}`, "POST", promptBody, true);
5663
- await this.pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, () => cancelled);
5777
+ await this.pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, () => cancelled);
5664
5778
  } catch (e) {
5665
5779
  if (!cancelled) emit([{ type: "error", message: `opencode HTTP API error: ${String(e)}` }]);
5666
5780
  } finally {
@@ -5689,7 +5803,7 @@ stderr: ${stderrTail}` : ""}`
5689
5803
  };
5690
5804
  }
5691
5805
  /** Poll session messages + pending permissions/questions until the turn finishes. */
5692
- async pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, isCancelled) {
5806
+ async pollUntilDone(base, sessionId, encDir, broker2, emit, pendingQuestions, baselineMessageIds, isCancelled) {
5693
5807
  const seenPermissions = /* @__PURE__ */ new Set();
5694
5808
  const seenQuestions = /* @__PURE__ */ new Set();
5695
5809
  const emittedParts = /* @__PURE__ */ new Set();
@@ -5721,21 +5835,9 @@ stderr: ${stderrTail}` : ""}`
5721
5835
  } catch {
5722
5836
  continue;
5723
5837
  }
5724
- for (const row of messages) {
5725
- if (row.info.role !== "assistant") continue;
5726
- for (const part of row.parts) {
5727
- if (!part.id || emittedParts.has(part.id)) continue;
5728
- if ((part.type === "text" || part.type === "reasoning") && !part.time?.end) continue;
5729
- emittedParts.add(part.id);
5730
- const events = mapOpenCodeEvent({ part });
5731
- emit(events);
5732
- if (events.some((e) => e.type === "done" || e.type === "error")) sawTerminal = true;
5733
- }
5734
- if (!sawTerminal && row.info.error) {
5735
- sawTerminal = true;
5736
- emit([{ type: "error", message: row.info.error.data?.message ?? "run failed" }]);
5737
- }
5738
- }
5838
+ const result = processMessages(messages, baselineMessageIds, emittedParts);
5839
+ emit(result.events);
5840
+ if (result.sawTerminal) sawTerminal = true;
5739
5841
  }
5740
5842
  }
5741
5843
  /** Route one pending permission through the shared broker, then reply. */
@@ -5785,37 +5887,61 @@ stderr: ${stderrTail}` : ""}`
5785
5887
  } catch {
5786
5888
  }
5787
5889
  }
5788
- /** Lazily start (once) the shared `opencode serve` instance used for
5789
- * broker-driven runs, and wait for it to accept connections. */
5790
- async ensureServer() {
5791
- if (this.serverPort) return this.serverPort;
5792
- if (this.serverStarting) return this.serverStarting;
5793
- this.serverStarting = (async () => {
5890
+ /** Lazily start (once per offhand session) the `opencode serve` instance
5891
+ * backing that session's broker-driven runs, and wait for it to accept
5892
+ * connections. Reused across that session's later turns (so
5893
+ * `resumeConversationId` keeps working); a different session never
5894
+ * shares this instance — see the field comment above for why. */
5895
+ async ensureServer(sessionId) {
5896
+ const existing = this.servers.get(sessionId);
5897
+ if (existing) return existing.port;
5898
+ const inFlight = this.serverStarting.get(sessionId);
5899
+ if (inFlight) return inFlight;
5900
+ const starting = (async () => {
5794
5901
  const cmd = this.resolveCommand();
5795
5902
  if (!cmd) throw new Error("opencode CLI not found");
5796
5903
  const port2 = await findFreePort();
5797
5904
  const proc = spawn4(cmd[0], [...cmd.slice(1), "serve", "--port", String(port2), "--hostname", "127.0.0.1"], {
5798
5905
  stdio: ["ignore", "pipe", "pipe"],
5799
- shell: process.platform === "win32"
5906
+ shell: process.platform === "win32",
5907
+ env: { ...process.env, OPENCODE_CONFIG_CONTENT: statusOnlyMcpConfigContent(this.statusUrl, sessionId) }
5800
5908
  });
5801
5909
  proc.on("exit", () => {
5802
- if (this.serverProcess === proc) {
5803
- this.serverProcess = null;
5804
- this.serverPort = null;
5805
- }
5910
+ const cur = this.servers.get(sessionId);
5911
+ if (cur?.process === proc) this.servers.delete(sessionId);
5806
5912
  });
5807
5913
  await waitForServerReady(port2);
5808
- this.serverProcess = proc;
5809
- this.serverPort = port2;
5914
+ this.servers.set(sessionId, { port: port2, process: proc });
5810
5915
  return port2;
5811
5916
  })();
5917
+ this.serverStarting.set(sessionId, starting);
5812
5918
  try {
5813
- return await this.serverStarting;
5919
+ return await starting;
5814
5920
  } finally {
5815
- this.serverStarting = null;
5921
+ this.serverStarting.delete(sessionId);
5816
5922
  }
5817
5923
  }
5818
5924
  };
5925
+ function processMessages(messages, baselineMessageIds, emittedParts) {
5926
+ const events = [];
5927
+ let sawTerminal = false;
5928
+ for (const row of messages) {
5929
+ if (row.info.role !== "assistant" || baselineMessageIds.has(row.info.id)) continue;
5930
+ for (const part of row.parts) {
5931
+ if (!part.id || emittedParts.has(part.id)) continue;
5932
+ if ((part.type === "text" || part.type === "reasoning") && !part.time?.end) continue;
5933
+ emittedParts.add(part.id);
5934
+ const partEvents = mapOpenCodeEvent({ part });
5935
+ events.push(...partEvents);
5936
+ if (partEvents.some((e) => e.type === "done" || e.type === "error")) sawTerminal = true;
5937
+ }
5938
+ if (!sawTerminal && row.info.error) {
5939
+ sawTerminal = true;
5940
+ events.push({ type: "error", message: row.info.error.data?.message ?? "run failed" });
5941
+ }
5942
+ }
5943
+ return { events, sawTerminal };
5944
+ }
5819
5945
  function buildPermissionRuleset(mode) {
5820
5946
  const base = [{ permission: "*", pattern: "*", action: "allow" }];
5821
5947
  if (mode === "acceptEdits") {
@@ -5886,10 +6012,12 @@ function sleep(ms) {
5886
6012
 
5887
6013
  // ../daemon/src/session-manager.ts
5888
6014
  import { randomUUID } from "node:crypto";
5889
- import { hostname, platform, tmpdir } from "node:os";
5890
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
5891
- import { dirname as dirname2, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
5892
- import { fileURLToPath as fileURLToPath2 } from "node:url";
6015
+ import { hostname, platform as platform2, tmpdir } from "node:os";
6016
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
6017
+ import { dirname as dirname3, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
6018
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
6019
+ import { execFile as execFile4 } from "node:child_process";
6020
+ import { promisify as promisify4 } from "node:util";
5893
6021
 
5894
6022
  // ../daemon/src/receipts.ts
5895
6023
  import { execFile } from "node:child_process";
@@ -5911,6 +6039,7 @@ async function workspaceInfo(row) {
5911
6039
  path: row.path,
5912
6040
  label: row.label,
5913
6041
  policy: row.policy,
6042
+ intelligenceMode: row.intelligenceMode,
5914
6043
  ...branch ? { gitBranch: branch } : {},
5915
6044
  ...status !== null ? { dirty: status.trim() !== "" } : {},
5916
6045
  ...row.devUrl ? { devUrl: row.devUrl } : {}
@@ -6234,10 +6363,508 @@ function mimeFromName(path) {
6234
6363
  }[ext] ?? "application/octet-stream";
6235
6364
  }
6236
6365
 
6366
+ // ../daemon/src/handover.ts
6367
+ var MAX_DIFF_BYTES2 = 48 * 1024;
6368
+ var MAX_TOOL_SUMMARIES = 30;
6369
+ var MAX_RECENT_MESSAGES = 10;
6370
+ async function liveDiff(workspace) {
6371
+ const diff = await git(workspace, "diff", "HEAD");
6372
+ if (!diff) return "";
6373
+ return diff.length > MAX_DIFF_BYTES2 ? diff.slice(0, MAX_DIFF_BYTES2) + "\n\u2026 diff truncated \u2026" : diff;
6374
+ }
6375
+ function assembleHandoverPacket(items, diff) {
6376
+ const toolSummaries = [];
6377
+ let todos;
6378
+ const recentMessages = [];
6379
+ const resolvedApprovalIds = /* @__PURE__ */ new Set();
6380
+ const approvalsById = /* @__PURE__ */ new Map();
6381
+ for (const msg of items) {
6382
+ if (msg.type === "run-started") {
6383
+ recentMessages.push({ role: "user", text: msg.prompt });
6384
+ continue;
6385
+ }
6386
+ if (msg.type !== "run-event") continue;
6387
+ const ev = msg.event;
6388
+ if (ev.type === "tool") {
6389
+ toolSummaries.push(ev.summary);
6390
+ if (ev.todos) todos = ev.todos;
6391
+ } else if (ev.type === "text") {
6392
+ const last = recentMessages[recentMessages.length - 1];
6393
+ if (last?.role === "assistant") last.text += ev.chunk;
6394
+ else recentMessages.push({ role: "assistant", text: ev.chunk });
6395
+ } else if (ev.type === "approval") {
6396
+ approvalsById.set(ev.id, { action: ev.action, detail: ev.detail, risk: ev.risk });
6397
+ } else if (ev.type === "approval-result") {
6398
+ resolvedApprovalIds.add(ev.id);
6399
+ }
6400
+ }
6401
+ const pendingApprovals = [...approvalsById.entries()].filter(([id]) => !resolvedApprovalIds.has(id)).map(([, a2]) => a2);
6402
+ return {
6403
+ diff,
6404
+ toolSummaries: toolSummaries.slice(-MAX_TOOL_SUMMARIES),
6405
+ ...todos ? { todos } : {},
6406
+ pendingApprovals,
6407
+ recentMessages: recentMessages.slice(-MAX_RECENT_MESSAGES)
6408
+ };
6409
+ }
6410
+ function translatePacketToPrompt(packet, fromRunnerName) {
6411
+ const sections = [
6412
+ `You're continuing a task handed off from ${fromRunnerName}, which hit its usage limit mid-task. Here is the context so far.`
6413
+ ];
6414
+ if (packet.recentMessages.length > 0) {
6415
+ sections.push(
6416
+ "## Recent conversation\n" + packet.recentMessages.map((m3) => `**${m3.role}:** ${m3.text}`).join("\n\n")
6417
+ );
6418
+ }
6419
+ if (packet.toolSummaries.length > 0) {
6420
+ sections.push("## Actions already taken\n" + packet.toolSummaries.map((t2) => `- ${t2}`).join("\n"));
6421
+ }
6422
+ if (packet.todos && packet.todos.length > 0) {
6423
+ sections.push(
6424
+ "## Remaining plan\n" + packet.todos.map((t2) => `- [${t2.status === "completed" ? "x" : " "}] ${t2.content}`).join("\n")
6425
+ );
6426
+ }
6427
+ if (packet.pendingApprovals.length > 0) {
6428
+ sections.push(
6429
+ "## Not yet approved (ask again if still needed)\n" + packet.pendingApprovals.map((a2) => `- ${a2.action}: ${a2.detail} (${a2.risk} risk)`).join("\n")
6430
+ );
6431
+ }
6432
+ if (packet.diff.trim()) {
6433
+ sections.push("## Uncommitted changes so far (git diff)\n```diff\n" + packet.diff + "\n```");
6434
+ }
6435
+ if (packet.agentSummary) {
6436
+ sections.push("## Outgoing agent's own summary\n" + packet.agentSummary);
6437
+ }
6438
+ sections.push("Please continue the task from here.");
6439
+ return sections.join("\n\n");
6440
+ }
6441
+
6442
+ // ../daemon/src/wol.ts
6443
+ import { execFile as execFile2 } from "node:child_process";
6444
+ import { promisify as promisify2 } from "node:util";
6445
+ import { networkInterfaces, platform } from "node:os";
6446
+ import { existsSync as existsSync6 } from "node:fs";
6447
+ var exec2 = promisify2(execFile2);
6448
+ function pickPrimaryInterface(ifaces) {
6449
+ for (const [name, infos] of Object.entries(ifaces)) {
6450
+ if (!infos) continue;
6451
+ const ipv4 = infos.find((i2) => i2.family === "IPv4" && !i2.internal);
6452
+ if (ipv4?.mac && ipv4.mac !== "00:00:00:00:00:00") {
6453
+ return { name, mac: ipv4.mac, address: ipv4.address, netmask: ipv4.netmask };
6454
+ }
6455
+ }
6456
+ return null;
6457
+ }
6458
+ function pickInterfaceByName(ifaces, name) {
6459
+ const infos = ifaces[name];
6460
+ if (!infos) return null;
6461
+ const ipv4 = infos.find((i2) => i2.family === "IPv4" && !i2.internal);
6462
+ if (!ipv4?.mac || ipv4.mac === "00:00:00:00:00:00") return null;
6463
+ return { name, mac: ipv4.mac, address: ipv4.address, netmask: ipv4.netmask };
6464
+ }
6465
+ function computeBroadcastAddress(address, netmask) {
6466
+ const a2 = address.split(".").map(Number);
6467
+ const m3 = netmask.split(".").map(Number);
6468
+ if (a2.length !== 4 || m3.length !== 4 || a2.some(Number.isNaN) || m3.some(Number.isNaN)) return null;
6469
+ return a2.map((octet, i2) => (octet | ~m3[i2] & 255) & 255).join(".");
6470
+ }
6471
+ function normalizeMac(mac) {
6472
+ return (mac ?? "").toLowerCase().replace(/[:-]/g, "");
6473
+ }
6474
+ function classifyWindowsMediaType(mediaType) {
6475
+ const t2 = (mediaType ?? "").toLowerCase();
6476
+ if (t2.includes("802.11") || t2.includes("wireless") || t2.includes("native 802.11")) return "wifi";
6477
+ if (t2.includes("802.3") || t2.includes("ethernet")) return "ethernet";
6478
+ return "unknown";
6479
+ }
6480
+ function parseWindowsAdapterJson(json) {
6481
+ let parsed;
6482
+ try {
6483
+ parsed = JSON.parse(json);
6484
+ } catch {
6485
+ return [];
6486
+ }
6487
+ const arr = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
6488
+ return arr.filter((x2) => !!x2 && typeof x2 === "object").map((x2) => ({
6489
+ name: String(x2.Name ?? ""),
6490
+ macAddress: x2.MacAddress ? String(x2.MacAddress) : null,
6491
+ networkType: classifyWindowsMediaType(x2.MediaType),
6492
+ wakeOnMagicPacket: x2.WakeOnMagicPacket === "Enabled" || x2.WakeOnMagicPacket === "Disabled" || x2.WakeOnMagicPacket === "NotSupported" ? x2.WakeOnMagicPacket : null
6493
+ }));
6494
+ }
6495
+ function parseMacPmset(output2) {
6496
+ const m3 = output2.match(/^\s*womp\s+(\d)/m);
6497
+ return m3 ? m3[1] === "1" : false;
6498
+ }
6499
+ function classifyMacHardwarePort(listing, deviceName) {
6500
+ const blocks = listing.split(/\n\n+/);
6501
+ for (const block of blocks) {
6502
+ if (!new RegExp(`Device:\\s*${deviceName}\\b`).test(block)) continue;
6503
+ if (/Hardware Port:\s*Wi-Fi/i.test(block)) return "wifi";
6504
+ if (/Hardware Port:\s*(Ethernet|Thunderbolt Ethernet)/i.test(block)) return "ethernet";
6505
+ }
6506
+ return "unknown";
6507
+ }
6508
+ function parseLinuxEthtool(output2) {
6509
+ let supports = "";
6510
+ let current = "";
6511
+ for (const line of output2.split(/\r?\n/)) {
6512
+ const supportsMatch = line.match(/^\s*Supports Wake-on:\s*([a-zA-Z]*)/);
6513
+ if (supportsMatch) {
6514
+ supports = supportsMatch[1];
6515
+ continue;
6516
+ }
6517
+ const currentMatch = line.match(/^\s*Wake-on:\s*([a-zA-Z]*)/);
6518
+ if (currentMatch) current = currentMatch[1];
6519
+ }
6520
+ return { supportsWakeOnG: supports.toLowerCase().includes("g"), wakeOnGEnabled: current.toLowerCase().includes("g") };
6521
+ }
6522
+ function computeWolCapability(networkType, wolWorks) {
6523
+ if (!wolWorks) {
6524
+ return {
6525
+ capability: "unsupported",
6526
+ 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."
6527
+ };
6528
+ }
6529
+ if (networkType === "wifi") {
6530
+ return {
6531
+ capability: "wifi-limited",
6532
+ reason: "Wake-on-Wireless-LAN is enabled, but WiFi wake support depends on the router too \u2014 less reliable than a wired connection."
6533
+ };
6534
+ }
6535
+ if (networkType === "ethernet") {
6536
+ return { capability: "supported", reason: "Wake-on-LAN is enabled on this wired connection." };
6537
+ }
6538
+ return { capability: "unsupported", reason: "Couldn't determine this machine's network connection type." };
6539
+ }
6540
+ 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";
6541
+ async function detectWindows(primary) {
6542
+ const { stdout } = await exec2("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_ADAPTER_SCRIPT], {
6543
+ timeout: 8e3
6544
+ });
6545
+ const adapters = parseWindowsAdapterJson(stdout);
6546
+ const match = adapters.find((a2) => normalizeMac(a2.macAddress) === normalizeMac(primary.mac)) ?? adapters[0];
6547
+ if (!match) throw new Error("no active physical adapter reported by Get-NetAdapter");
6548
+ return { networkType: match.networkType, wolWorks: match.wakeOnMagicPacket === "Enabled" };
6549
+ }
6550
+ async function detectMac(primary) {
6551
+ const { stdout: hardwarePorts } = await exec2("networksetup", ["-listallhardwareports"], { timeout: 8e3 });
6552
+ const networkType = classifyMacHardwarePort(hardwarePorts, primary.name);
6553
+ const { stdout: pmsetOut } = await exec2("pmset", ["-g"], { timeout: 8e3 });
6554
+ return { networkType, wolWorks: parseMacPmset(pmsetOut) };
6555
+ }
6556
+ async function detectLinux(primary) {
6557
+ const networkType = existsSync6(`/sys/class/net/${primary.name}/wireless`) ? "wifi" : "ethernet";
6558
+ const { stdout } = await exec2("ethtool", [primary.name], { timeout: 8e3 });
6559
+ const { supportsWakeOnG, wakeOnGEnabled } = parseLinuxEthtool(stdout);
6560
+ return { networkType, wolWorks: supportsWakeOnG && wakeOnGEnabled };
6561
+ }
6562
+ async function getDefaultRouteInterfaceName(plat) {
6563
+ try {
6564
+ if (plat === "win32") {
6565
+ const { stdout } = await exec2(
6566
+ "powershell",
6567
+ [
6568
+ "-NoProfile",
6569
+ "-NonInteractive",
6570
+ "-Command",
6571
+ "Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias"
6572
+ ],
6573
+ { timeout: 8e3 }
6574
+ );
6575
+ return stdout.trim() || null;
6576
+ }
6577
+ if (plat === "darwin") {
6578
+ const { stdout } = await exec2("route", ["-n", "get", "default"], { timeout: 8e3 });
6579
+ const m3 = stdout.match(/interface:\s*(\S+)/);
6580
+ return m3 ? m3[1] : null;
6581
+ }
6582
+ if (plat === "linux") {
6583
+ const { stdout } = await exec2("ip", ["route", "show", "default"], { timeout: 8e3 });
6584
+ const m3 = stdout.match(/\bdev\s+(\S+)/);
6585
+ return m3 ? m3[1] : null;
6586
+ }
6587
+ return null;
6588
+ } catch {
6589
+ return null;
6590
+ }
6591
+ }
6592
+ async function probeWolCapability() {
6593
+ const checkedAtMs = Date.now();
6594
+ const plat = platform();
6595
+ const ifaces = networkInterfaces();
6596
+ const routeIfaceName = await getDefaultRouteInterfaceName(plat);
6597
+ const primary = routeIfaceName && pickInterfaceByName(ifaces, routeIfaceName) || pickPrimaryInterface(ifaces);
6598
+ if (!primary) {
6599
+ return {
6600
+ capability: "unsupported",
6601
+ reason: "Couldn't find an active network interface to check.",
6602
+ networkType: "unknown",
6603
+ mac: null,
6604
+ broadcast: null,
6605
+ checkedAtMs
6606
+ };
6607
+ }
6608
+ const broadcast = computeBroadcastAddress(primary.address, primary.netmask);
6609
+ try {
6610
+ const detected = plat === "win32" ? await detectWindows(primary) : plat === "darwin" ? await detectMac(primary) : plat === "linux" ? await detectLinux(primary) : (() => {
6611
+ throw new Error(`unsupported OS: ${plat}`);
6612
+ })();
6613
+ const { capability, reason } = computeWolCapability(detected.networkType, detected.wolWorks);
6614
+ return { capability, reason, networkType: detected.networkType, mac: primary.mac, broadcast, checkedAtMs };
6615
+ } catch (e) {
6616
+ return {
6617
+ capability: "unsupported",
6618
+ reason: `Couldn't check Wake-on-LAN support: ${e instanceof Error ? e.message : String(e)}`,
6619
+ networkType: "unknown",
6620
+ mac: primary.mac,
6621
+ broadcast,
6622
+ checkedAtMs
6623
+ };
6624
+ }
6625
+ }
6626
+
6627
+ // ../daemon/src/intelligence/ripgrep.ts
6628
+ import { execFile as execFile3 } from "node:child_process";
6629
+ import { promisify as promisify3 } from "node:util";
6630
+ var exec3 = promisify3(execFile3);
6631
+ var cachedAvailable = null;
6632
+ async function detectRipgrep() {
6633
+ if (cachedAvailable !== null) return cachedAvailable;
6634
+ try {
6635
+ await exec3("rg", ["--version"], { timeout: 5e3 });
6636
+ cachedAvailable = true;
6637
+ } catch {
6638
+ cachedAvailable = false;
6639
+ }
6640
+ return cachedAvailable;
6641
+ }
6642
+ async function searchTerms(workspace, terms, opts = {}) {
6643
+ if (terms.length === 0) return [];
6644
+ const timeoutMs = opts.timeoutMs ?? 3e3;
6645
+ const maxMatches = opts.maxMatches ?? 40;
6646
+ const args2 = [
6647
+ "--fixed-strings",
6648
+ "--ignore-case",
6649
+ "--line-number",
6650
+ "--no-heading",
6651
+ "--max-count",
6652
+ "3",
6653
+ "--max-filesize",
6654
+ "1M",
6655
+ "--glob",
6656
+ "!.git",
6657
+ "--glob",
6658
+ "!node_modules",
6659
+ ...terms.flatMap((t2) => ["-e", t2]),
6660
+ "."
6661
+ ];
6662
+ try {
6663
+ const { stdout } = await exec3("rg", args2, { cwd: workspace, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 });
6664
+ return parseRipgrepOutput(stdout).slice(0, maxMatches);
6665
+ } catch (e) {
6666
+ const err = e;
6667
+ return err.stdout ? parseRipgrepOutput(err.stdout).slice(0, maxMatches) : [];
6668
+ }
6669
+ }
6670
+ function parseRipgrepOutput(stdout) {
6671
+ const hits = [];
6672
+ for (const line of stdout.split("\n")) {
6673
+ if (!line) continue;
6674
+ const m3 = /^(.+?):(\d+):(.*)$/.exec(line);
6675
+ if (!m3) continue;
6676
+ hits.push({ file: m3[1], line: Number(m3[2]), text: m3[3].trim().slice(0, 200) });
6677
+ }
6678
+ return hits;
6679
+ }
6680
+
6681
+ // ../daemon/src/intelligence/context-inject.ts
6682
+ var DEFAULT_MAX_CHARS = 8e3;
6683
+ var OVERALL_TIMEOUT_MS = 4e3;
6684
+ var STOPWORDS = /* @__PURE__ */ new Set([
6685
+ "the",
6686
+ "a",
6687
+ "an",
6688
+ "to",
6689
+ "for",
6690
+ "in",
6691
+ "on",
6692
+ "of",
6693
+ "and",
6694
+ "or",
6695
+ "is",
6696
+ "are",
6697
+ "be",
6698
+ "been",
6699
+ "add",
6700
+ "please",
6701
+ "can",
6702
+ "you",
6703
+ "we",
6704
+ "this",
6705
+ "that",
6706
+ "it",
6707
+ "with",
6708
+ "from",
6709
+ "when",
6710
+ "where",
6711
+ "how",
6712
+ "what",
6713
+ "i",
6714
+ "my",
6715
+ "our",
6716
+ "some",
6717
+ "make",
6718
+ "sure",
6719
+ "need",
6720
+ "needs",
6721
+ "want",
6722
+ "wants",
6723
+ "like",
6724
+ "using",
6725
+ "use",
6726
+ "so",
6727
+ "up",
6728
+ "as",
6729
+ "at",
6730
+ "by",
6731
+ "not",
6732
+ "do",
6733
+ "does",
6734
+ "did",
6735
+ "have",
6736
+ "has",
6737
+ "had",
6738
+ "will",
6739
+ "would",
6740
+ "should",
6741
+ "could",
6742
+ "also",
6743
+ "into",
6744
+ "if",
6745
+ "then",
6746
+ "than"
6747
+ ]);
6748
+ function extractTerms(prompt, maxTerms = 8) {
6749
+ const raw = prompt.toLowerCase().split(/[^a-z0-9._-]+/).map((t2) => t2.replace(/^[._-]+|[._-]+$/g, "")).filter((t2) => t2.length >= 3 && !STOPWORDS.has(t2) && !/^\d+$/.test(t2));
6750
+ const seen = /* @__PURE__ */ new Set();
6751
+ const deduped = [];
6752
+ for (const t2 of raw) {
6753
+ if (seen.has(t2)) continue;
6754
+ seen.add(t2);
6755
+ deduped.push(t2);
6756
+ }
6757
+ return deduped.slice().sort((a2, b2) => b2.length - a2.length).slice(0, maxTerms);
6758
+ }
6759
+ async function buildContext(workspace, prompt, opts = {}) {
6760
+ const startedAt = Date.now();
6761
+ const maxChars = opts.maxChars ?? (Number(process.env.OFFHAND_INTEL_MAX_CHARS) || DEFAULT_MAX_CHARS);
6762
+ const work = gatherFindings(workspace, prompt);
6763
+ const timeout = new Promise((resolve6) => setTimeout(() => resolve6("timeout"), OVERALL_TIMEOUT_MS));
6764
+ const result = await Promise.race([work, timeout]);
6765
+ const durationMs = Date.now() - startedAt;
6766
+ if (result === "timeout") {
6767
+ return {
6768
+ block: "",
6769
+ meta: {
6770
+ promptChars: prompt.length,
6771
+ contextChars: 0,
6772
+ filesConsidered: 0,
6773
+ filesSelected: 0,
6774
+ retrievalOpsRun: 0,
6775
+ durationMs,
6776
+ ripgrepAvailable: false,
6777
+ timedOut: true
6778
+ }
6779
+ };
6780
+ }
6781
+ const { findings, retrievalOpsRun, ripgrepAvailable, gitState } = result;
6782
+ if (findings.length === 0) return null;
6783
+ findings.sort((a2, b2) => b2.priority - a2.priority);
6784
+ const { text: block, selectedCount } = formatBlock(prompt, findings, gitState, maxChars);
6785
+ if (selectedCount === 0) return null;
6786
+ return {
6787
+ block,
6788
+ meta: {
6789
+ promptChars: prompt.length,
6790
+ contextChars: block.length,
6791
+ filesConsidered: findings.length,
6792
+ filesSelected: selectedCount,
6793
+ retrievalOpsRun,
6794
+ durationMs: Date.now() - startedAt,
6795
+ ripgrepAvailable,
6796
+ timedOut: false
6797
+ }
6798
+ };
6799
+ }
6800
+ async function gatherFindings(workspace, prompt) {
6801
+ let retrievalOpsRun = 0;
6802
+ const byPath = /* @__PURE__ */ new Map();
6803
+ const add = (path, reason, priority) => {
6804
+ const existing = byPath.get(path);
6805
+ if (existing) {
6806
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
6807
+ existing.priority = Math.max(existing.priority, priority);
6808
+ } else {
6809
+ byPath.set(path, { path, reasons: [reason], priority });
6810
+ }
6811
+ };
6812
+ const statusOut = await git(workspace, "status", "--porcelain");
6813
+ retrievalOpsRun++;
6814
+ let gitState = null;
6815
+ if (statusOut !== null) {
6816
+ const dirtyFiles = statusOut.split("\n").filter((l2) => l2.length > 3).map((l2) => l2.slice(3).trim()).filter(Boolean);
6817
+ for (const f2 of dirtyFiles.slice(0, 15)) add(f2, "currently dirty", 3);
6818
+ gitState = dirtyFiles.length > 0 ? `${dirtyFiles.length} file(s) with uncommitted changes` : "clean working tree";
6819
+ }
6820
+ const ripgrepAvailable = await detectRipgrep();
6821
+ retrievalOpsRun++;
6822
+ const terms = extractTerms(prompt);
6823
+ if (ripgrepAvailable && terms.length > 0) {
6824
+ const hits = await searchTerms(workspace, terms);
6825
+ retrievalOpsRun++;
6826
+ for (const hit of hits) {
6827
+ const term = terms.find((t2) => hit.text.toLowerCase().includes(t2));
6828
+ add(hit.file, term ? `matched "${term}"` : "matched search terms", 2);
6829
+ }
6830
+ }
6831
+ const logOut = await git(workspace, "log", "-n", "15", "--name-only", "--pretty=format:");
6832
+ retrievalOpsRun++;
6833
+ if (logOut !== null) {
6834
+ const recentFiles = [...new Set(logOut.split("\n").map((l2) => l2.trim()).filter(Boolean))];
6835
+ for (const f2 of recentFiles.slice(0, 10)) add(f2, "recently changed", 1);
6836
+ }
6837
+ return { findings: [...byPath.values()], retrievalOpsRun, ripgrepAvailable, gitState };
6838
+ }
6839
+ function formatBlock(prompt, rankedFindings, gitState, maxChars) {
6840
+ const header = `TASK
6841
+ ${prompt}
6842
+
6843
+ OFFHAND REPOSITORY CONTEXT
6844
+ `;
6845
+ const gitLine = gitState ? `- Current git state: ${gitState}
6846
+ ` : "";
6847
+ const footer = "\nIMPORTANT: This context was generated from local repository analysis and may be incomplete or wrong. Verify against the workspace and search further wherever it looks warranted \u2014 do not treat this as the complete picture.";
6848
+ const budget = maxChars - header.length - gitLine.length - footer.length;
6849
+ const lines = [];
6850
+ let used = 0;
6851
+ let selectedCount = 0;
6852
+ for (const f2 of rankedFindings) {
6853
+ const line = `- ${f2.path} \u2014 ${f2.reasons.join(", ")}
6854
+ `;
6855
+ if (used + line.length > budget) break;
6856
+ lines.push(line);
6857
+ used += line.length;
6858
+ selectedCount++;
6859
+ }
6860
+ const text = header + gitLine + lines.join("") + footer;
6861
+ return { text, selectedCount };
6862
+ }
6863
+
6237
6864
  // ../daemon/src/session-manager.ts
6238
6865
  function resolveDaemonVersion() {
6239
6866
  try {
6240
- const pkgPath = join6(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
6867
+ const pkgPath = join6(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
6241
6868
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
6242
6869
  return pkg.version ?? "0.0.0";
6243
6870
  } catch {
@@ -6245,6 +6872,10 @@ function resolveDaemonVersion() {
6245
6872
  }
6246
6873
  }
6247
6874
  var DAEMON_VERSION = resolveDaemonVersion();
6875
+ var execFileAsync = promisify4(execFile4);
6876
+ var SYSTEM_LOG_SESSION_ID = "system";
6877
+ 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.";
6878
+ var MOBILE_SESSION_PREAMBLE_STATUS_TOOL = " You have an offhand_status_update tool available \u2014 call it with a short plain-English note at meaningful checkpoints (starting a long step, hitting a snag, finishing a phase) so they see progress without waiting for the whole run to end.";
6248
6879
  var SessionManager = class {
6249
6880
  constructor(store2, runners2) {
6250
6881
  this.store = store2;
@@ -6257,8 +6888,19 @@ var SessionManager = class {
6257
6888
  // sessionId → workspace
6258
6889
  queues = /* @__PURE__ */ new Map();
6259
6890
  // sessionId → queued prompts
6891
+ /** reviewId → an Offhand Intelligence proposal (Execution Plan 8) awaiting
6892
+ * `intelligence-response` — not tied to any run, since none exists yet. */
6893
+ pendingIntelligence = /* @__PURE__ */ new Map();
6260
6894
  runners = /* @__PURE__ */ new Map();
6261
6895
  runnerAvailability = /* @__PURE__ */ new Map();
6896
+ /** handoverId → what was proposed, so `handover-response` (which only
6897
+ * carries the id back) knows what to act on. */
6898
+ pendingHandovers = /* @__PURE__ */ new Map();
6899
+ /** sessionId → the most recent offhand_status_update note (Execution Plan
6900
+ * 5) — becomes a Handover Packet's optional agentSummary when a handover
6901
+ * is proposed afterward. Still zero hosted AI: the agent chose to send
6902
+ * this, on its own tokens, no different from any other tool call. */
6903
+ latestStatusUpdate = /* @__PURE__ */ new Map();
6262
6904
  /** Optional artifact plumbing (set by index when capture is possible). */
6263
6905
  uploader = null;
6264
6906
  capture = null;
@@ -6276,6 +6918,21 @@ var SessionManager = class {
6276
6918
  for (const [id, r2] of this.runners) {
6277
6919
  this.runnerAvailability.set(id, await r2.detect());
6278
6920
  }
6921
+ void this.recheckWolCapability();
6922
+ }
6923
+ /** Runs the real Wake-on-LAN/WiFi probe and persists the result — called
6924
+ * once at startup and again on-demand via the phone's "check again"
6925
+ * action (Execution Plan 7), since drivers/BIOS/network type can change. */
6926
+ async recheckWolCapability() {
6927
+ const result = await probeWolCapability();
6928
+ this.store.setWolCapability({
6929
+ capability: result.capability,
6930
+ reason: result.reason,
6931
+ networkType: result.networkType,
6932
+ mac: result.mac,
6933
+ broadcast: result.broadcast,
6934
+ checkedAtMs: result.checkedAtMs
6935
+ });
6279
6936
  }
6280
6937
  // ---- transport attachment --------------------------------------------------
6281
6938
  attach(sink) {
@@ -6283,10 +6940,16 @@ var SessionManager = class {
6283
6940
  return () => this.sinks.delete(sink);
6284
6941
  }
6285
6942
  hello() {
6943
+ const wol = this.store.getWolCapability();
6286
6944
  return {
6287
6945
  type: "hello",
6288
6946
  protocol: 2,
6289
- host: { hostname: hostname(), os: platform(), daemonVersion: DAEMON_VERSION },
6947
+ host: {
6948
+ hostname: hostname(),
6949
+ os: platform2(),
6950
+ daemonVersion: DAEMON_VERSION,
6951
+ ...wol ? { wolCapability: wol.capability, wolReason: wol.reason, wolCheckedAtMs: wol.checkedAtMs } : {}
6952
+ },
6290
6953
  lastSeq: this.store.lastSeq()
6291
6954
  };
6292
6955
  }
@@ -6297,7 +6960,10 @@ var SessionManager = class {
6297
6960
  available: this.runnerAvailability.get(r2.id) ?? false,
6298
6961
  ...r2.loggedIn ? { loggedIn: r2.loggedIn() } : {},
6299
6962
  models: r2.models,
6300
- supportsApprovals: r2.supportsApprovals
6963
+ supportsApprovals: r2.supportsApprovals,
6964
+ ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {},
6965
+ ...r2.installCommand ? { installCommand: `${r2.installCommand.command} ${r2.installCommand.args.join(" ")}` } : {},
6966
+ ...r2.installUrl ? { installUrl: r2.installUrl } : {}
6301
6967
  }));
6302
6968
  const workspaces = await Promise.all(
6303
6969
  this.store.listWorkspaces().map((w2) => workspaceInfo(w2))
@@ -6329,28 +6995,86 @@ var SessionManager = class {
6329
6995
  reply({ type: "error", message: `unknown session ${msg.sessionId}` });
6330
6996
  return;
6331
6997
  }
6332
- let prompt = msg.prompt;
6333
- if (msg.attachments?.length && this.attachmentFetcher) {
6334
- try {
6335
- prompt += "\n\nAttached files (read them from disk):";
6336
- for (const a2 of msg.attachments) {
6337
- const bytes = await this.attachmentFetcher(a2.blobId);
6338
- const dir = join6(tmpdir(), "offhand-attachments");
6339
- mkdirSync2(dir, { recursive: true });
6340
- const path = join6(dir, `${a2.blobId.slice(0, 8)}-${basename3(a2.name)}`);
6341
- writeFileSync2(path, bytes);
6342
- prompt += `
6343
- - ${path} (${a2.mime})`;
6344
- }
6345
- } catch (e) {
6346
- reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
6347
- return;
6998
+ let prompt;
6999
+ try {
7000
+ prompt = await this.stageAttachments(msg.prompt, msg.attachments);
7001
+ } catch (e) {
7002
+ reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
7003
+ return;
7004
+ }
7005
+ this.enqueuePrompt(session, prompt);
7006
+ await this.broadcastManifest();
7007
+ return;
7008
+ }
7009
+ case "intelligence-set": {
7010
+ this.store.setWorkspaceIntelligenceMode(msg.workspace, msg.mode);
7011
+ await this.broadcastManifest();
7012
+ return;
7013
+ }
7014
+ case "intelligence-prepare": {
7015
+ const session = this.store.getSession(msg.sessionId);
7016
+ if (!session) {
7017
+ reply({ type: "error", message: `unknown session ${msg.sessionId}` });
7018
+ return;
7019
+ }
7020
+ let prompt;
7021
+ try {
7022
+ prompt = await this.stageAttachments(msg.prompt, msg.attachments);
7023
+ } catch (e) {
7024
+ reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
7025
+ return;
7026
+ }
7027
+ const built = await buildContext(session.workspace, prompt);
7028
+ this.record(session.id, (seq) => ({
7029
+ type: "run-event",
7030
+ sessionId: session.id,
7031
+ runId: "",
7032
+ seq,
7033
+ event: {
7034
+ type: "intelligence-built",
7035
+ promptChars: prompt.length,
7036
+ contextChars: built?.meta.contextChars ?? 0,
7037
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7038
+ filesSelected: built?.meta.filesSelected ?? 0,
7039
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7040
+ durationMs: built?.meta.durationMs ?? 0,
7041
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7042
+ timedOut: built?.meta.timedOut ?? false,
7043
+ attached: built !== null && built.block !== ""
6348
7044
  }
7045
+ }));
7046
+ if (!built || built.block === "") {
7047
+ this.enqueuePrompt(session, prompt);
7048
+ await this.broadcastManifest();
7049
+ return;
6349
7050
  }
6350
- const q2 = this.queues.get(session.id) ?? [];
6351
- q2.push(prompt);
6352
- this.queues.set(session.id, q2);
6353
- this.pump(session.id);
7051
+ const reviewId = randomUUID();
7052
+ this.pendingIntelligence.set(reviewId, { sessionId: session.id, prompt, block: built.block });
7053
+ this.record(session.id, (seq) => ({
7054
+ type: "intelligence-review",
7055
+ sessionId: session.id,
7056
+ seq,
7057
+ reviewId,
7058
+ originalPrompt: prompt,
7059
+ block: built.block,
7060
+ filesSelected: built.meta.filesSelected
7061
+ }));
7062
+ return;
7063
+ }
7064
+ case "intelligence-response": {
7065
+ const pending = this.pendingIntelligence.get(msg.reviewId);
7066
+ if (!pending || pending.sessionId !== msg.sessionId) return;
7067
+ this.pendingIntelligence.delete(msg.reviewId);
7068
+ const session = this.store.getSession(msg.sessionId);
7069
+ if (!session) return;
7070
+ this.record(session.id, (seq) => ({
7071
+ type: "intelligence-resolved",
7072
+ sessionId: session.id,
7073
+ seq,
7074
+ reviewId: msg.reviewId,
7075
+ useContext: msg.useContext
7076
+ }));
7077
+ this.enqueuePrompt(session, pending.prompt, msg.useContext ? pending.block : void 0);
6354
7078
  await this.broadcastManifest();
6355
7079
  return;
6356
7080
  }
@@ -6392,6 +7116,38 @@ var SessionManager = class {
6392
7116
  case "resume":
6393
7117
  for (const m3 of this.store.replayAfter(msg.afterSeq)) reply(m3);
6394
7118
  return;
7119
+ case "handover-response": {
7120
+ const pending = this.pendingHandovers.get(msg.handoverId);
7121
+ if (!pending || pending.sessionId !== msg.sessionId) return;
7122
+ this.pendingHandovers.delete(msg.handoverId);
7123
+ if (!msg.accept) {
7124
+ this.record(msg.sessionId, (seq) => ({
7125
+ type: "run-event",
7126
+ sessionId: msg.sessionId,
7127
+ runId: "",
7128
+ seq,
7129
+ event: { type: "handover-declined", id: msg.handoverId }
7130
+ }));
7131
+ return;
7132
+ }
7133
+ const session = this.store.getSession(msg.sessionId);
7134
+ const toRunner = this.runners.get(pending.toRunnerId);
7135
+ if (!session || !toRunner) return;
7136
+ const fromRunnerId = session.runnerId;
7137
+ const fromRunner = this.runners.get(fromRunnerId);
7138
+ const prompt = translatePacketToPrompt(pending.packet, fromRunner?.name ?? fromRunnerId);
7139
+ this.store.updateSession(session.id, { runnerId: pending.toRunnerId, conversationId: null });
7140
+ this.record(session.id, (seq) => ({
7141
+ type: "run-event",
7142
+ sessionId: session.id,
7143
+ runId: "",
7144
+ seq,
7145
+ event: { type: "handover-accepted", id: msg.handoverId, fromRunnerId, toRunnerId: pending.toRunnerId }
7146
+ }));
7147
+ this.enqueuePrompt(session, prompt);
7148
+ await this.broadcastManifest();
7149
+ return;
7150
+ }
6395
7151
  case "session-create": {
6396
7152
  const row = this.store.createSession(msg.workspace, msg.runnerId, msg.model, msg.label);
6397
7153
  this.store.upsertWorkspace(msg.workspace);
@@ -6415,7 +7171,9 @@ var SessionManager = class {
6415
7171
  ...msg.model !== void 0 ? { model: msg.model || null } : {},
6416
7172
  ...msg.archived !== void 0 ? { archived: msg.archived } : {},
6417
7173
  ...msg.permissionMode !== void 0 ? { permissionMode: msg.permissionMode } : {},
6418
- ...msg.effort !== void 0 ? { effort: msg.effort } : {}
7174
+ ...msg.effort !== void 0 ? { effort: msg.effort } : {},
7175
+ ...msg.budgetCostUsdCap !== void 0 ? { budgetCostUsdCap: msg.budgetCostUsdCap } : {},
7176
+ ...msg.budgetMinutesCap !== void 0 ? { budgetMinutesCap: msg.budgetMinutesCap } : {}
6419
7177
  });
6420
7178
  await this.broadcastManifest();
6421
7179
  return;
@@ -6448,8 +7206,117 @@ var SessionManager = class {
6448
7206
  case "fs-list":
6449
7207
  reply({ type: "fs-response", rpcId: msg.rpcId, ...listFolders(msg.path) });
6450
7208
  return;
7209
+ case "fs-create-folder": {
7210
+ try {
7211
+ const safeName = safeDropName(msg.name);
7212
+ mkdirSync2(join6(resolve3(msg.path), safeName), { recursive: false });
7213
+ } catch (e) {
7214
+ reply({
7215
+ type: "fs-response",
7216
+ rpcId: msg.rpcId,
7217
+ ...listFolders(msg.path),
7218
+ error: `couldn't create folder: ${e instanceof Error ? e.message : String(e)}`
7219
+ });
7220
+ return;
7221
+ }
7222
+ reply({ type: "fs-response", rpcId: msg.rpcId, ...listFolders(msg.path) });
7223
+ return;
7224
+ }
7225
+ case "wol-recheck":
7226
+ await this.recheckWolCapability();
7227
+ reply(this.hello());
7228
+ return;
7229
+ case "runner-install": {
7230
+ const runner = this.runners.get(msg.runnerId);
7231
+ if (!runner?.installCommand) {
7232
+ reply({
7233
+ type: "runner-install-response",
7234
+ rpcId: msg.rpcId,
7235
+ ok: false,
7236
+ message: `No one-tap install available for "${msg.runnerId}".`
7237
+ });
7238
+ return;
7239
+ }
7240
+ try {
7241
+ const { stderr } = await execFileAsync(runner.installCommand.command, runner.installCommand.args, {
7242
+ timeout: 18e4
7243
+ });
7244
+ this.runnerAvailability.set(runner.id, await runner.detect());
7245
+ reply({
7246
+ type: "runner-install-response",
7247
+ rpcId: msg.rpcId,
7248
+ ok: true,
7249
+ message: stderr.trim() || "Installed."
7250
+ });
7251
+ await this.broadcastManifest();
7252
+ } catch (e) {
7253
+ const err = e;
7254
+ const detail = (err.stderr?.trim() || err.message || String(e)).slice(0, 500);
7255
+ reply({ type: "runner-install-response", rpcId: msg.rpcId, ok: false, message: detail });
7256
+ }
7257
+ return;
7258
+ }
6451
7259
  }
6452
7260
  }
7261
+ /** The daemon's own relay connection dropped — logged durably so a phone
7262
+ * that wasn't listening live can still see it in the "left off" digest
7263
+ * (Execution Plan 6), unlike the relay's ephemeral `presence` frame. */
7264
+ recordDeviceOffline() {
7265
+ this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({ type: "device-offline", seq, atMs: Date.now() }));
7266
+ }
7267
+ /** A running agent proactively pushed a status note (Execution Plan 5's
7268
+ * offhand_status_update MCP tool). Recorded into the normal session log
7269
+ * (so it shows in the transcript and the run timeline) and cached as the
7270
+ * latest note for this session, ready to enrich a Handover Packet later. */
7271
+ recordStatusUpdate(sessionId, text) {
7272
+ this.latestStatusUpdate.set(sessionId, text);
7273
+ this.record(sessionId, (seq) => ({
7274
+ type: "run-event",
7275
+ sessionId,
7276
+ runId: "",
7277
+ seq,
7278
+ event: { type: "status-update", text }
7279
+ }));
7280
+ }
7281
+ /** Offhand Intelligence Phase 5 (Execution Plan 8) — the agent itself
7282
+ * calls this via the offhand_context MCP tool, mid-run, instead of a
7283
+ * human reviewing on the phone first. Same buildContext() mechanism and
7284
+ * telemetry as the mobile review path (Phase 2); the tool's own response
7285
+ * carries the same "may be incomplete, keep searching" framing so the
7286
+ * agent's discovery is supplemented, never restricted. */
7287
+ async buildContextFor(sessionId, query) {
7288
+ const session = this.store.getSession(sessionId);
7289
+ if (!session) return null;
7290
+ const built = await buildContext(session.workspace, query);
7291
+ this.record(sessionId, (seq) => ({
7292
+ type: "run-event",
7293
+ sessionId,
7294
+ runId: "",
7295
+ seq,
7296
+ event: {
7297
+ type: "intelligence-built",
7298
+ promptChars: query.length,
7299
+ contextChars: built?.meta.contextChars ?? 0,
7300
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7301
+ filesSelected: built?.meta.filesSelected ?? 0,
7302
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7303
+ durationMs: built?.meta.durationMs ?? 0,
7304
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7305
+ timedOut: built?.meta.timedOut ?? false,
7306
+ attached: built !== null && built.block !== ""
7307
+ }
7308
+ }));
7309
+ if (!built || built.block === "") return null;
7310
+ return { block: built.block, filesSelected: built.meta.filesSelected };
7311
+ }
7312
+ recordDeviceReconnected(offlineForMs) {
7313
+ this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({
7314
+ type: "device-reconnected",
7315
+ seq,
7316
+ atMs: Date.now(),
7317
+ offlineForMs
7318
+ }));
7319
+ }
6453
7320
  async sendDropToPhone(path) {
6454
7321
  if (!this.uploader) throw new Error("drop upload unavailable: start daemon with --relay");
6455
7322
  const file = readOutgoingDrop(path);
@@ -6469,13 +7336,40 @@ var SessionManager = class {
6469
7336
  pump(sessionId) {
6470
7337
  if (this.active.has(sessionId)) return;
6471
7338
  const q2 = this.queues.get(sessionId);
6472
- const prompt = q2?.shift();
6473
- if (!prompt) return;
7339
+ const item = q2?.shift();
7340
+ if (!item) return;
6474
7341
  const session = this.store.getSession(sessionId);
6475
7342
  if (!session) return;
6476
- void this.runOne(session, prompt);
7343
+ void this.runOne(session, item.prompt, item.block);
7344
+ }
7345
+ /** Appends staged-attachment file paths to a prompt — shared by `prompt`
7346
+ * and `intelligence-prepare` so the two paths can't drift. Throws on
7347
+ * fetch/write failure; callers turn that into an `error` reply. */
7348
+ async stageAttachments(prompt, attachments) {
7349
+ if (!attachments?.length || !this.attachmentFetcher) return prompt;
7350
+ let out = prompt + "\n\nAttached files (read them from disk):";
7351
+ for (const a2 of attachments) {
7352
+ const bytes = await this.attachmentFetcher(a2.blobId);
7353
+ const dir = join6(tmpdir(), "offhand-attachments");
7354
+ mkdirSync2(dir, { recursive: true });
7355
+ const path = join6(dir, `${a2.blobId.slice(0, 8)}-${basename3(a2.name)}`);
7356
+ writeFileSync2(path, bytes);
7357
+ out += `
7358
+ - ${path} (${a2.mime})`;
7359
+ }
7360
+ return out;
7361
+ }
7362
+ /** Pushes a prompt onto a session's queue and pumps it — the one place
7363
+ * every prompt (plain, post-review, or a translated handover packet)
7364
+ * enters execution, so none of those callers duplicate queue/pump/
7365
+ * broadcast bookkeeping. */
7366
+ enqueuePrompt(session, prompt, block) {
7367
+ const q2 = this.queues.get(session.id) ?? [];
7368
+ q2.push(block !== void 0 ? { prompt, block } : { prompt });
7369
+ this.queues.set(session.id, q2);
7370
+ this.pump(session.id);
6477
7371
  }
6478
- async runOne(session, prompt) {
7372
+ async runOne(session, prompt, precomputedBlock) {
6479
7373
  const runner = this.runners.get(session.runnerId);
6480
7374
  if (!runner || !(this.runnerAvailability.get(session.runnerId) ?? false)) {
6481
7375
  this.record(session.id, (seq) => ({
@@ -6492,10 +7386,44 @@ var SessionManager = class {
6492
7386
  const touchedFiles = [];
6493
7387
  let toolCount = 0;
6494
7388
  let succeeded = false;
7389
+ let limitHitEmitted = false;
7390
+ let budgetWarned = false;
7391
+ const hasStatusTool = runner.id === "claude-code" && session.permissionMode !== "bypass";
7392
+ const preambled = session.conversationId ? prompt : `${MOBILE_SESSION_PREAMBLE_BASE}${hasStatusTool ? MOBILE_SESSION_PREAMBLE_STATUS_TOOL : ""}
7393
+
7394
+ ${prompt}`;
7395
+ let sentPrompt = preambled;
7396
+ if (precomputedBlock !== void 0) {
7397
+ sentPrompt = preambled.replace(prompt, precomputedBlock);
7398
+ } else if (process.env.OFFHAND_INTEL_DEBUG === "1") {
7399
+ const built = await buildContext(session.workspace, prompt);
7400
+ this.record(session.id, (seq) => ({
7401
+ type: "run-event",
7402
+ sessionId: session.id,
7403
+ runId,
7404
+ seq,
7405
+ event: {
7406
+ type: "intelligence-built",
7407
+ promptChars: prompt.length,
7408
+ contextChars: built?.meta.contextChars ?? 0,
7409
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7410
+ filesSelected: built?.meta.filesSelected ?? 0,
7411
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7412
+ durationMs: built?.meta.durationMs ?? 0,
7413
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7414
+ timedOut: built?.meta.timedOut ?? false,
7415
+ attached: built !== null && built.block !== ""
7416
+ }
7417
+ }));
7418
+ if (built && built.block !== "") {
7419
+ sentPrompt = preambled.replace(prompt, built.block);
7420
+ }
7421
+ }
6495
7422
  const handle = runner.start(
6496
7423
  {
6497
7424
  runId,
6498
- prompt,
7425
+ sessionId: session.id,
7426
+ prompt: sentPrompt,
6499
7427
  workspace: session.workspace,
6500
7428
  ...session.model ? { model: session.model } : {},
6501
7429
  ...session.conversationId ? { resumeConversationId: session.conversationId } : {},
@@ -6528,6 +7456,17 @@ var SessionManager = class {
6528
7456
  seq,
6529
7457
  event
6530
7458
  }));
7459
+ const limitHit = checkLimitHit(event, limitHitEmitted);
7460
+ if (limitHit) {
7461
+ limitHitEmitted = true;
7462
+ this.record(session.id, (seq) => ({ type: "run-event", sessionId: session.id, runId, seq, event: limitHit }));
7463
+ void this.proposeHandover(session, runId, limitHit.reason);
7464
+ }
7465
+ const budgetHit = checkBudgetWarning(event, session, budgetWarned) ?? checkTimeBudget(session, Date.now() - startedAt, budgetWarned);
7466
+ if (budgetHit) {
7467
+ budgetWarned = true;
7468
+ this.record(session.id, (seq) => ({ type: "run-event", sessionId: session.id, runId, seq, event: budgetHit }));
7469
+ }
6531
7470
  }
6532
7471
  } finally {
6533
7472
  this.active.delete(session.id);
@@ -6559,7 +7498,65 @@ var SessionManager = class {
6559
7498
  }));
6560
7499
  }
6561
7500
  }
7501
+ /** Builds and offers a Handover Packet once a limit-hit fires. Entirely
7502
+ * deterministic (live git diff + this session's own log) — no cooperation
7503
+ * needed from the agent that just hit its limit, which is the point:
7504
+ * this must still work when that agent is already dead or blocked, not
7505
+ * only the graceful case. Silent (no card at all) when there's no other
7506
+ * detected, available runner to hand off to — never a dead-end proposal. */
7507
+ async proposeHandover(session, runId, reason) {
7508
+ const target = this.pickHandoverTarget(session.runnerId);
7509
+ if (!target) return;
7510
+ const diff = await liveDiff(session.workspace);
7511
+ const { items } = this.store.historyPage(session.id, 0, 500);
7512
+ const packet = assembleHandoverPacket(items, diff);
7513
+ const agentSummary = this.latestStatusUpdate.get(session.id);
7514
+ if (agentSummary) packet.agentSummary = agentSummary;
7515
+ const id = randomUUID();
7516
+ this.pendingHandovers.set(id, { sessionId: session.id, toRunnerId: target.id, packet });
7517
+ this.record(session.id, (seq) => ({
7518
+ type: "run-event",
7519
+ sessionId: session.id,
7520
+ runId,
7521
+ seq,
7522
+ event: { type: "handover-proposed", id, fromRunnerId: session.runnerId, toRunnerId: target.id, reason, packet }
7523
+ }));
7524
+ }
7525
+ /** First other detected+available runner — good enough for v1 (single
7526
+ * candidate, not a picker); a real choice among several becomes relevant
7527
+ * once more than one alternative is commonly available at once. */
7528
+ pickHandoverTarget(currentRunnerId) {
7529
+ for (const [id, runner] of this.runners) {
7530
+ if (id === currentRunnerId) continue;
7531
+ if (!(this.runnerAvailability.get(id) ?? false)) continue;
7532
+ return runner;
7533
+ }
7534
+ return null;
7535
+ }
6562
7536
  };
7537
+ var LIMIT_HIT_THRESHOLD = 0.9;
7538
+ function checkLimitHit(event, alreadyEmitted) {
7539
+ if (alreadyEmitted || event.type !== "rate-limit") return null;
7540
+ const utilization = Math.max(event.fiveHourUtilization, event.sevenDayUtilization);
7541
+ if (utilization < LIMIT_HIT_THRESHOLD) return null;
7542
+ return { type: "limit-hit", reason: "rate_limit", utilization };
7543
+ }
7544
+ function checkBudgetWarning(event, session, alreadyWarned) {
7545
+ if (alreadyWarned || event.type !== "usage" || !session.budgetCostUsdCap) return null;
7546
+ if (event.costUsd === void 0 || event.costUsd < session.budgetCostUsdCap) return null;
7547
+ return { type: "budget-warning", kind: "cost", capUsd: session.budgetCostUsdCap, actualUsd: event.costUsd };
7548
+ }
7549
+ function checkTimeBudget(session, elapsedMs, alreadyWarned) {
7550
+ if (alreadyWarned || !session.budgetMinutesCap) return null;
7551
+ const minutes = elapsedMs / 6e4;
7552
+ if (minutes < session.budgetMinutesCap) return null;
7553
+ return {
7554
+ type: "budget-warning",
7555
+ kind: "time",
7556
+ capMinutes: session.budgetMinutesCap,
7557
+ actualMinutes: Math.round(minutes)
7558
+ };
7559
+ }
6563
7560
  function trackTouches(event, touched) {
6564
7561
  if (event.type !== "tool") return;
6565
7562
  if (!/^(Edit|Write|MultiEdit|NotebookEdit)/.test(event.name)) return;
@@ -6576,16 +7573,16 @@ function listFolders(path) {
6576
7573
  const current = resolve3(path);
6577
7574
  const dirs = safeReadDir2(current).filter((entry) => entry.isDirectory() && !skipDir(entry.name)).map((entry) => {
6578
7575
  const full = join6(current, entry.name);
6579
- return { name: entry.name, path: full, isGit: existsSync6(join6(full, ".git")) };
7576
+ return { name: entry.name, path: full, isGit: existsSync7(join6(full, ".git")) };
6580
7577
  }).sort((a2, b2) => Number(b2.isGit) - Number(a2.isGit) || a2.name.localeCompare(b2.name)).slice(0, 200);
6581
- return { path: current, parent: isRoot(current) ? null : dirname2(current), dirs };
7578
+ return { path: current, parent: isRoot(current) ? null : dirname3(current), dirs };
6582
7579
  }
6583
7580
  function driveRoots() {
6584
- if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync6("/.git") }];
7581
+ if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync7("/.git") }];
6585
7582
  const roots = [];
6586
7583
  for (let code = 67; code <= 90; code++) {
6587
7584
  const name = `${String.fromCharCode(code)}:\\`;
6588
- if (existsSync6(name)) roots.push({ name, path: name, isGit: existsSync6(join6(name, ".git")) });
7585
+ if (existsSync7(name)) roots.push({ name, path: name, isGit: existsSync7(join6(name, ".git")) });
6589
7586
  }
6590
7587
  return roots.sort((a2, b2) => a2.name.localeCompare(b2.name)).slice(0, 200);
6591
7588
  }
@@ -6650,6 +7647,15 @@ var Store = class {
6650
7647
  dev_url TEXT
6651
7648
  );
6652
7649
  CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(text, session_id UNINDEXED, seq UNINDEXED);
7650
+ CREATE TABLE IF NOT EXISTS device (
7651
+ id INTEGER PRIMARY KEY CHECK (id = 1),
7652
+ wol_capability TEXT,
7653
+ wol_reason TEXT,
7654
+ wol_network_type TEXT,
7655
+ wol_mac TEXT,
7656
+ wol_broadcast TEXT,
7657
+ wol_checked_at_ms INTEGER
7658
+ );
6653
7659
  `);
6654
7660
  try {
6655
7661
  this.db.exec(`ALTER TABLE workspaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'balanced'`);
@@ -6663,6 +7669,18 @@ var Store = class {
6663
7669
  this.db.exec(`ALTER TABLE sessions ADD COLUMN effort TEXT`);
6664
7670
  } catch {
6665
7671
  }
7672
+ try {
7673
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_cost_usd_cap REAL`);
7674
+ } catch {
7675
+ }
7676
+ try {
7677
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_minutes_cap INTEGER`);
7678
+ } catch {
7679
+ }
7680
+ try {
7681
+ this.db.exec(`ALTER TABLE workspaces ADD COLUMN intelligence_mode TEXT NOT NULL DEFAULT 'off'`);
7682
+ } catch {
7683
+ }
6666
7684
  }
6667
7685
  // ---- sessions -------------------------------------------------------------
6668
7686
  createSession(workspace, runnerId, model, label) {
@@ -6676,7 +7694,9 @@ var Store = class {
6676
7694
  archived: false,
6677
7695
  conversationId: null,
6678
7696
  permissionMode: "guarded",
6679
- effort: null
7697
+ effort: null,
7698
+ budgetCostUsdCap: null,
7699
+ budgetMinutesCap: null
6680
7700
  };
6681
7701
  this.db.prepare(
6682
7702
  `INSERT INTO sessions (id, workspace, runner_id, model, label, created_at_ms, archived, conversation_id)
@@ -6696,7 +7716,9 @@ var Store = class {
6696
7716
  archived: Boolean(r2.archived),
6697
7717
  conversationId: r2.conversation_id ?? null,
6698
7718
  permissionMode: r2.permission_mode ?? "guarded",
6699
- effort: r2.effort ?? null
7719
+ effort: r2.effort ?? null,
7720
+ budgetCostUsdCap: r2.budget_cost_usd_cap ?? null,
7721
+ budgetMinutesCap: r2.budget_minutes_cap ?? null
6700
7722
  }));
6701
7723
  }
6702
7724
  getSession(id) {
@@ -6707,8 +7729,20 @@ var Store = class {
6707
7729
  if (!current) return;
6708
7730
  const next = { ...current, ...patch };
6709
7731
  this.db.prepare(
6710
- `UPDATE sessions SET label = ?, model = ?, archived = ?, conversation_id = ?, permission_mode = ?, effort = ? WHERE id = ?`
6711
- ).run(next.label, next.model, next.archived ? 1 : 0, next.conversationId, next.permissionMode, next.effort, id);
7732
+ `UPDATE sessions SET label = ?, model = ?, archived = ?, conversation_id = ?, permission_mode = ?, effort = ?,
7733
+ budget_cost_usd_cap = ?, budget_minutes_cap = ?, runner_id = ? WHERE id = ?`
7734
+ ).run(
7735
+ next.label,
7736
+ next.model,
7737
+ next.archived ? 1 : 0,
7738
+ next.conversationId,
7739
+ next.permissionMode,
7740
+ next.effort,
7741
+ next.budgetCostUsdCap,
7742
+ next.budgetMinutesCap,
7743
+ next.runnerId,
7744
+ id
7745
+ );
6712
7746
  }
6713
7747
  toSessionInfo(row, busy, queued) {
6714
7748
  return {
@@ -6722,7 +7756,9 @@ var Store = class {
6722
7756
  permissionMode: row.permissionMode,
6723
7757
  effort: row.effort ?? void 0,
6724
7758
  busy,
6725
- queuedPrompts: queued
7759
+ queuedPrompts: queued,
7760
+ budgetCostUsdCap: row.budgetCostUsdCap ?? void 0,
7761
+ budgetMinutesCap: row.budgetMinutesCap ?? void 0
6726
7762
  };
6727
7763
  }
6728
7764
  // ---- transcript log ---------------------------------------------------------
@@ -6787,12 +7823,42 @@ var Store = class {
6787
7823
  path: r2.path,
6788
7824
  label: r2.label,
6789
7825
  devUrl: r2.dev_url ?? null,
6790
- policy: r2.policy ?? "balanced"
7826
+ policy: r2.policy ?? "balanced",
7827
+ intelligenceMode: r2.intelligence_mode ?? "off"
6791
7828
  }));
6792
7829
  }
6793
7830
  setWorkspacePolicy(path, policy) {
6794
7831
  this.db.prepare(`UPDATE workspaces SET policy = ? WHERE path = ?`).run(policy, path);
6795
7832
  }
7833
+ setWorkspaceIntelligenceMode(path, mode) {
7834
+ this.db.prepare(`UPDATE workspaces SET intelligence_mode = ? WHERE path = ?`).run(mode, path);
7835
+ }
7836
+ // ---- device capability (Execution Plan 7) ----------------------------------
7837
+ getWolCapability() {
7838
+ const row = this.db.prepare(`SELECT * FROM device WHERE id = 1`).get();
7839
+ if (!row || row.wol_capability == null) return null;
7840
+ return {
7841
+ capability: row.wol_capability,
7842
+ reason: row.wol_reason,
7843
+ networkType: row.wol_network_type,
7844
+ mac: row.wol_mac ?? null,
7845
+ broadcast: row.wol_broadcast ?? null,
7846
+ checkedAtMs: row.wol_checked_at_ms
7847
+ };
7848
+ }
7849
+ setWolCapability(v2) {
7850
+ this.db.prepare(
7851
+ `INSERT INTO device (id, wol_capability, wol_reason, wol_network_type, wol_mac, wol_broadcast, wol_checked_at_ms)
7852
+ VALUES (1, ?, ?, ?, ?, ?, ?)
7853
+ ON CONFLICT(id) DO UPDATE SET
7854
+ wol_capability = excluded.wol_capability,
7855
+ wol_reason = excluded.wol_reason,
7856
+ wol_network_type = excluded.wol_network_type,
7857
+ wol_mac = excluded.wol_mac,
7858
+ wol_broadcast = excluded.wol_broadcast,
7859
+ wol_checked_at_ms = excluded.wol_checked_at_ms`
7860
+ ).run(v2.capability, v2.reason, v2.networkType, v2.mac, v2.broadcast, v2.checkedAtMs);
7861
+ }
6796
7862
  };
6797
7863
  function searchableText(msg) {
6798
7864
  switch (msg.type) {
@@ -6803,6 +7869,7 @@ function searchableText(msg) {
6803
7869
  if (msg.event.type === "tool") return msg.event.summary;
6804
7870
  if (msg.event.type === "done") return msg.event.summary;
6805
7871
  if (msg.event.type === "error") return msg.event.message;
7872
+ if (msg.event.type === "status-update") return msg.event.text;
6806
7873
  return "";
6807
7874
  case "drop":
6808
7875
  return msg.name;
@@ -10872,11 +11939,42 @@ var ApprovalQuestionSchema = external_exports.object({
10872
11939
  options: external_exports.array(external_exports.object({ label: external_exports.string(), description: external_exports.string().optional() })),
10873
11940
  multiSelect: external_exports.boolean().optional()
10874
11941
  });
11942
+ var TodoItemSchema = external_exports.object({
11943
+ content: external_exports.string(),
11944
+ status: external_exports.enum(["pending", "in_progress", "completed"]),
11945
+ activeForm: external_exports.string().optional()
11946
+ });
11947
+ var HandoverPacketSchema = external_exports.object({
11948
+ /** Live `git diff HEAD` against the workspace, captured at handover time. */
11949
+ diff: external_exports.string(),
11950
+ /** Tool-call summaries this session made, oldest first, capped to the most recent. */
11951
+ toolSummaries: external_exports.array(external_exports.string()),
11952
+ /** The last TodoWrite call's task list, verbatim — never summarized. */
11953
+ todos: external_exports.array(TodoItemSchema).optional(),
11954
+ /** Approvals raised but never resolved (still pending or timed out) at handover time. */
11955
+ pendingApprovals: external_exports.array(
11956
+ external_exports.object({ action: external_exports.string(), detail: external_exports.string(), risk: external_exports.enum(["low", "high"]) })
11957
+ ),
11958
+ /** Recent prompt/response turns, oldest first, capped — for tone/context continuity. */
11959
+ recentMessages: external_exports.array(external_exports.object({ role: external_exports.enum(["user", "assistant"]), text: external_exports.string() })),
11960
+ /** Optional free-text self-summary from the outgoing agent — enrichment
11961
+ * only, attempted when there was runway before a hard cutoff. Its absence
11962
+ * changes nothing; the packet above is already sufficient on its own. */
11963
+ agentSummary: external_exports.string().optional()
11964
+ });
10875
11965
  var RunEventSchema = external_exports.discriminatedUnion("type", [
10876
11966
  external_exports.object({ type: external_exports.literal("text"), chunk: external_exports.string() }),
10877
11967
  /** Extended-thinking content, streamed the same way as text (medium+ effort tiers). */
10878
11968
  external_exports.object({ type: external_exports.literal("thinking"), chunk: external_exports.string() }),
10879
- external_exports.object({ type: external_exports.literal("tool"), name: external_exports.string(), summary: external_exports.string() }),
11969
+ external_exports.object({
11970
+ type: external_exports.literal("tool"),
11971
+ name: external_exports.string(),
11972
+ summary: external_exports.string(),
11973
+ /** Structured payload for calls that matter to continuity (TodoWrite's
11974
+ * actual task list). Optional: most tool calls have none, and omitting
11975
+ * it is always safe — `summary` alone still renders the transcript. */
11976
+ todos: external_exports.array(TodoItemSchema).optional()
11977
+ }),
10880
11978
  external_exports.object({
10881
11979
  type: external_exports.literal("approval"),
10882
11980
  id: external_exports.string(),
@@ -10921,12 +12019,87 @@ var RunEventSchema = external_exports.discriminatedUnion("type", [
10921
12019
  fiveHourResetsAtMs: external_exports.number(),
10922
12020
  sevenDayUtilization: external_exports.number(),
10923
12021
  sevenDayResetsAtMs: external_exports.number()
12022
+ }),
12023
+ /** Emitted once, when the daemon's own threshold check crosses before the
12024
+ * CLI itself refuses — the phone's cue to propose a handover (Execution
12025
+ * Plan 5) while there's still runway to do it gracefully. */
12026
+ external_exports.object({
12027
+ type: external_exports.literal("limit-hit"),
12028
+ reason: external_exports.enum(["rate_limit", "context_window", "cost_cap"]),
12029
+ /** 0-1 utilization that crossed the threshold, when known. */
12030
+ utilization: external_exports.number().optional()
12031
+ }),
12032
+ /** Session cost or wall-clock time crossed the cap set on it (Execution
12033
+ * Plan 1's budget feature) — informational, the run is not cancelled. */
12034
+ external_exports.object({
12035
+ type: external_exports.literal("budget-warning"),
12036
+ kind: external_exports.enum(["cost", "time"]),
12037
+ capUsd: external_exports.number().optional(),
12038
+ capMinutes: external_exports.number().optional(),
12039
+ actualUsd: external_exports.number().optional(),
12040
+ actualMinutes: external_exports.number().optional()
12041
+ }),
12042
+ /** The daemon is offering to move this session to another runner
12043
+ * (Execution Plan 4) — built from the log/diff alone, never from asking
12044
+ * this agent to cooperate (it may already be unable to). */
12045
+ external_exports.object({
12046
+ type: external_exports.literal("handover-proposed"),
12047
+ id: external_exports.string(),
12048
+ fromRunnerId: external_exports.string(),
12049
+ toRunnerId: external_exports.string(),
12050
+ reason: external_exports.enum(["rate_limit", "context_window", "cost_cap", "crash", "manual"]),
12051
+ packet: HandoverPacketSchema
12052
+ }),
12053
+ external_exports.object({
12054
+ type: external_exports.literal("handover-declined"),
12055
+ id: external_exports.string()
12056
+ }),
12057
+ external_exports.object({
12058
+ type: external_exports.literal("handover-accepted"),
12059
+ id: external_exports.string(),
12060
+ fromRunnerId: external_exports.string(),
12061
+ toRunnerId: external_exports.string()
12062
+ }),
12063
+ /** A proactive note the agent chose to push mid-run (Execution Plan 5's
12064
+ * offhand_status_update MCP tool) — outside the normal streamed
12065
+ * text/thinking, and never fabricated: only exists when the agent itself
12066
+ * called the tool. The most recent one on a session also becomes the
12067
+ * Handover Packet's optional `agentSummary` (Execution Plan 4) when a
12068
+ * handover is proposed afterward — still zero hosted AI, since it's the
12069
+ * same agent, on its own tokens, choosing to leave a note. */
12070
+ external_exports.object({
12071
+ type: external_exports.literal("status-update"),
12072
+ text: external_exports.string()
12073
+ }),
12074
+ /** Telemetry for Offhand Intelligence's Phase 1 (Execution Plan 8) — one
12075
+ * record per run where the deterministic context-injection step actually
12076
+ * ran, so Intelligence-ON vs Intelligence-OFF is eventually a real
12077
+ * measurement, not a guess. Never carries the context block's own text —
12078
+ * just the shape of what happened — reusing the existing event-log spine
12079
+ * rather than a parallel telemetry system. */
12080
+ external_exports.object({
12081
+ type: external_exports.literal("intelligence-built"),
12082
+ promptChars: external_exports.number().int(),
12083
+ contextChars: external_exports.number().int(),
12084
+ filesConsidered: external_exports.number().int(),
12085
+ filesSelected: external_exports.number().int(),
12086
+ retrievalOpsRun: external_exports.number().int(),
12087
+ durationMs: external_exports.number().int(),
12088
+ ripgrepAvailable: external_exports.boolean(),
12089
+ timedOut: external_exports.boolean(),
12090
+ /** Whether the block was actually attached to the run (false when
12091
+ * buildContext found nothing worth attaching, or timed out). */
12092
+ attached: external_exports.boolean()
10924
12093
  })
10925
12094
  ]);
10926
12095
  var PermissionModeSchema = external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]);
10927
12096
  var EffortSchema = external_exports.enum(["low", "medium", "high", "max"]);
10928
12097
  var RunSpecSchema = external_exports.object({
10929
12098
  runId: external_exports.string(),
12099
+ /** The offhand session this run belongs to — lets an MCP bridge tool
12100
+ * (Execution Plan 5) tag a proactive call (status update, dropped file)
12101
+ * with the right session without a global single-listener broker. */
12102
+ sessionId: external_exports.string(),
10930
12103
  prompt: external_exports.string().min(1),
10931
12104
  /** Absolute path of the workspace the agent runs in. */
10932
12105
  workspace: external_exports.string(),
@@ -10951,9 +12124,22 @@ var RunnerInfoSchema = external_exports.object({
10951
12124
  version: external_exports.string().optional(),
10952
12125
  /** Model ids the phone may offer; empty = CLI default only. */
10953
12126
  models: external_exports.array(external_exports.string()),
10954
- supportsApprovals: external_exports.boolean()
12127
+ supportsApprovals: external_exports.boolean(),
12128
+ /** Thinking-budget tiers this runner actually honors (Execution Plan 3) —
12129
+ * undefined means the phone should hide the effort control entirely for
12130
+ * this runner, not show one that silently does nothing. */
12131
+ effortTiers: external_exports.array(external_exports.enum(["low", "medium", "high", "max"])).optional(),
12132
+ /** Display string for the vendor's own install command (e.g. "npm install
12133
+ * -g @anthropic-ai/claude-code") — present only when the daemon can run
12134
+ * it unattended via `runner-install`. */
12135
+ installCommand: external_exports.string().optional(),
12136
+ /** Where to send the user instead, when there's no safe one-tap install
12137
+ * (a piped shell-script installer, a GUI app, etc.) — never set alongside
12138
+ * installCommand. */
12139
+ installUrl: external_exports.string().optional()
10955
12140
  });
10956
12141
  var ApprovalPolicySchema = external_exports.enum(["paranoid", "balanced", "trusting"]);
12142
+ var IntelligenceModeSchema = external_exports.enum(["off", "automatic", "manual"]);
10957
12143
  var WorkspaceInfoSchema = external_exports.object({
10958
12144
  path: external_exports.string(),
10959
12145
  label: external_exports.string(),
@@ -10962,7 +12148,8 @@ var WorkspaceInfoSchema = external_exports.object({
10962
12148
  devUrl: external_exports.string().optional(),
10963
12149
  /** Approval policy: paranoid = ask everything the CLI would ask; balanced =
10964
12150
  * same (default); trusting = auto-approve low-risk, ask only high-risk. */
10965
- policy: ApprovalPolicySchema
12151
+ policy: ApprovalPolicySchema,
12152
+ intelligenceMode: IntelligenceModeSchema
10966
12153
  });
10967
12154
  var SessionInfoSchema = external_exports.object({
10968
12155
  id: external_exports.string(),
@@ -10978,12 +12165,23 @@ var SessionInfoSchema = external_exports.object({
10978
12165
  effort: external_exports.enum(["low", "medium", "high", "max"]).optional(),
10979
12166
  /** Whether a run is active or prompts are queued right now. */
10980
12167
  busy: external_exports.boolean(),
10981
- queuedPrompts: external_exports.number().int()
12168
+ queuedPrompts: external_exports.number().int(),
12169
+ /** Per-session budget caps (Execution Plan 1) — undefined means no cap set. */
12170
+ budgetCostUsdCap: external_exports.number().optional(),
12171
+ budgetMinutesCap: external_exports.number().int().optional()
10982
12172
  });
10983
12173
  var HostInfoSchema = external_exports.object({
10984
12174
  hostname: external_exports.string(),
10985
12175
  os: external_exports.string(),
10986
- daemonVersion: external_exports.string()
12176
+ daemonVersion: external_exports.string(),
12177
+ /** Wake-on-LAN/WiFi capability, detected per device (Execution Plan 7) —
12178
+ * never assumed from the OS alone. Absent only if no probe has completed
12179
+ * yet (e.g. mid-startup). */
12180
+ wolCapability: external_exports.enum(["supported", "wifi-limited", "unsupported"]).optional(),
12181
+ /** Human-readable reason behind wolCapability — always present alongside
12182
+ * it, since a bare enum can't explain itself. */
12183
+ wolReason: external_exports.string().optional(),
12184
+ wolCheckedAtMs: external_exports.number().int().optional()
10987
12185
  });
10988
12186
 
10989
12187
  // ../shared/src/protocol-v2.ts
@@ -11017,6 +12215,12 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11017
12215
  answer: external_exports.string().optional()
11018
12216
  }),
11019
12217
  external_exports.object({ type: external_exports.literal("resume"), afterSeq: external_exports.number().int().nonnegative() }),
12218
+ external_exports.object({
12219
+ type: external_exports.literal("handover-response"),
12220
+ sessionId: external_exports.string(),
12221
+ handoverId: external_exports.string(),
12222
+ accept: external_exports.boolean()
12223
+ }),
11020
12224
  external_exports.object({
11021
12225
  type: external_exports.literal("drop-send"),
11022
12226
  blobId: external_exports.string(),
@@ -11047,7 +12251,10 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11047
12251
  model: external_exports.string().optional(),
11048
12252
  archived: external_exports.boolean().optional(),
11049
12253
  permissionMode: external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]).optional(),
11050
- effort: external_exports.enum(["low", "medium", "high", "max"]).optional()
12254
+ effort: external_exports.enum(["low", "medium", "high", "max"]).optional(),
12255
+ /** Per-session budget caps (Execution Plan 1). null clears an existing cap. */
12256
+ budgetCostUsdCap: external_exports.number().positive().nullable().optional(),
12257
+ budgetMinutesCap: external_exports.number().int().positive().nullable().optional()
11051
12258
  }),
11052
12259
  /** Start a fresh agent conversation inside the session. */
11053
12260
  external_exports.object({ type: external_exports.literal("session-reset"), sessionId: external_exports.string() }),
@@ -11057,6 +12264,29 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11057
12264
  workspace: external_exports.string(),
11058
12265
  policy: ApprovalPolicySchema
11059
12266
  }),
12267
+ /** Set a workspace's Offhand Intelligence mode (Execution Plan 8). */
12268
+ external_exports.object({
12269
+ type: external_exports.literal("intelligence-set"),
12270
+ workspace: external_exports.string(),
12271
+ mode: IntelligenceModeSchema
12272
+ }),
12273
+ /** Like `prompt`, but routes through Offhand Intelligence first — the
12274
+ * daemon replies with `intelligence-review` instead of queueing
12275
+ * immediately, and queueing happens only after `intelligence-response`. */
12276
+ external_exports.object({
12277
+ type: external_exports.literal("intelligence-prepare"),
12278
+ sessionId: external_exports.string(),
12279
+ prompt: external_exports.string().min(1),
12280
+ attachments: external_exports.array(external_exports.object({ blobId: external_exports.string(), name: external_exports.string(), mime: external_exports.string() })).optional()
12281
+ }),
12282
+ /** Resolves a pending `intelligence-review` — useContext=false sends the
12283
+ * plain original prompt, never a dead end that discards it. */
12284
+ external_exports.object({
12285
+ type: external_exports.literal("intelligence-response"),
12286
+ sessionId: external_exports.string(),
12287
+ reviewId: external_exports.string(),
12288
+ useContext: external_exports.boolean()
12289
+ }),
11060
12290
  // History RPC (request/response by rpcId; responses are not in the seq log)
11061
12291
  external_exports.object({
11062
12292
  type: external_exports.literal("history-request"),
@@ -11081,7 +12311,24 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11081
12311
  type: external_exports.literal("fs-list"),
11082
12312
  rpcId: external_exports.string(),
11083
12313
  path: external_exports.string().optional()
11084
- })
12314
+ }),
12315
+ /** Mobile-first session bootstrap (Execution Plan 5): create a new project
12316
+ * folder from the phone's workspace browser, no laptop needed first. */
12317
+ external_exports.object({
12318
+ type: external_exports.literal("fs-create-folder"),
12319
+ rpcId: external_exports.string(),
12320
+ path: external_exports.string(),
12321
+ name: external_exports.string().min(1)
12322
+ }),
12323
+ /** Re-run the Wake-on-LAN/WiFi capability probe (Execution Plan 7) — an
12324
+ * explicit "check again" action, since drivers/BIOS settings/network type
12325
+ * can change after first setup. */
12326
+ external_exports.object({ type: external_exports.literal("wol-recheck") }),
12327
+ /** One-tap install: run a runner's own vendor install command on the
12328
+ * daemon's machine. Only valid for a runner whose manifest entry carries
12329
+ * `installCommand` — the daemon refuses (with a reply, not a hang) for
12330
+ * any other runnerId. */
12331
+ external_exports.object({ type: external_exports.literal("runner-install"), rpcId: external_exports.string(), runnerId: external_exports.string() })
11085
12332
  ]);
11086
12333
  var ReceiptSchema = external_exports.object({
11087
12334
  runId: external_exports.string(),
@@ -11139,6 +12386,37 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11139
12386
  size: external_exports.number().int().nonnegative(),
11140
12387
  direction: external_exports.enum(["to-phone", "to-pc"])
11141
12388
  }),
12389
+ /** The daemon's own relay connection dropped/came back — logged durably
12390
+ * (unlike the relay's live `presence` frame) so a phone that wasn't
12391
+ * listening at the time can still reconstruct "laptop asleep since 11pm"
12392
+ * from history (Execution Plan 6's digest). Not tied to any run. */
12393
+ external_exports.object({ type: external_exports.literal("device-offline"), seq: external_exports.number().int(), atMs: external_exports.number().int() }),
12394
+ external_exports.object({
12395
+ type: external_exports.literal("device-reconnected"),
12396
+ seq: external_exports.number().int(),
12397
+ atMs: external_exports.number().int(),
12398
+ offlineForMs: external_exports.number().int().nonnegative()
12399
+ }),
12400
+ /** Offhand Intelligence (Execution Plan 8) proposed a context block for a
12401
+ * prompt not yet queued to any runner — not a RunEvent, since no run
12402
+ * exists at this point. `filesSelected` is a quick summary for the sheet
12403
+ * header; the full reasoning lives in `block` itself. */
12404
+ external_exports.object({
12405
+ type: external_exports.literal("intelligence-review"),
12406
+ sessionId: external_exports.string(),
12407
+ seq: external_exports.number().int(),
12408
+ reviewId: external_exports.string(),
12409
+ originalPrompt: external_exports.string(),
12410
+ block: external_exports.string(),
12411
+ filesSelected: external_exports.number().int()
12412
+ }),
12413
+ external_exports.object({
12414
+ type: external_exports.literal("intelligence-resolved"),
12415
+ sessionId: external_exports.string(),
12416
+ seq: external_exports.number().int(),
12417
+ reviewId: external_exports.string(),
12418
+ useContext: external_exports.boolean()
12419
+ }),
11142
12420
  // RPC responses (not seq-logged)
11143
12421
  external_exports.object({
11144
12422
  type: external_exports.literal("history-response"),
@@ -11164,7 +12442,21 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11164
12442
  rpcId: external_exports.string(),
11165
12443
  path: external_exports.string(),
11166
12444
  parent: external_exports.string().nullable(),
11167
- dirs: external_exports.array(FsDirSchema)
12445
+ dirs: external_exports.array(FsDirSchema),
12446
+ /** Set only by fs-create-folder on failure — the generic top-level
12447
+ * `error` message carries no rpcId, so it can never resolve a specific
12448
+ * pending RPC (see client.ts's rpc()); this keeps the failure correctly
12449
+ * routed back to whoever asked, with the listing unchanged either way. */
12450
+ error: external_exports.string().optional()
12451
+ }),
12452
+ /** Reply to `runner-install` — always sent, success or failure, never a
12453
+ * silent hang. On failure `message` carries the real error (e.g. npm's
12454
+ * own stderr tail), not a generic "install failed". */
12455
+ external_exports.object({
12456
+ type: external_exports.literal("runner-install-response"),
12457
+ rpcId: external_exports.string(),
12458
+ ok: external_exports.boolean(),
12459
+ message: external_exports.string()
11168
12460
  }),
11169
12461
  external_exports.object({ type: external_exports.literal("error"), message: external_exports.string() })
11170
12462
  ]);
@@ -11188,7 +12480,8 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
11188
12480
  */
11189
12481
  external_exports.object({
11190
12482
  kind: external_exports.literal("notify"),
11191
- notice: external_exports.enum(["approval", "drop", "done", "error", "progress"]),
12483
+ notice: external_exports.enum(["approval", "drop", "done", "error", "progress", "handover", "limit-hit"]),
12484
+ /** 'approval'/'handover' only — the approval/handover id being acted on. */
11192
12485
  id: external_exports.string().optional(),
11193
12486
  sessionId: external_exports.string().optional(),
11194
12487
  /** 'progress' only — a bare action count, never the action itself (no content in push). */
@@ -11196,6 +12489,16 @@ var RelayFrameSchema = external_exports.discriminatedUnion("kind", [
11196
12489
  }),
11197
12490
  /** relay → daemon: verdict delivered via push-notification action button. */
11198
12491
  external_exports.object({ kind: external_exports.literal("verdict"), approvalId: external_exports.string(), approve: external_exports.boolean() }),
12492
+ /** relay → daemon: handover accept/decline from a push-notification action
12493
+ * button (Execution Plan 6) — same "opaque ids only" shape as `verdict`,
12494
+ * with `chatSessionId` since handover-response needs it to look up the
12495
+ * pending proposal. */
12496
+ external_exports.object({
12497
+ kind: external_exports.literal("handover-verdict"),
12498
+ handoverId: external_exports.string(),
12499
+ chatSessionId: external_exports.string(),
12500
+ accept: external_exports.boolean()
12501
+ }),
11199
12502
  external_exports.object({ kind: external_exports.literal("ping") }),
11200
12503
  external_exports.object({ kind: external_exports.literal("pong") })
11201
12504
  ]);
@@ -41803,6 +43106,36 @@ var LocalSessionServer = class {
41803
43106
  }
41804
43107
  return;
41805
43108
  }
43109
+ if (req.method === "POST" && req.url === "/status") {
43110
+ try {
43111
+ const chunks = [];
43112
+ for await (const c2 of req) chunks.push(c2);
43113
+ const body = JSON.parse(Buffer.concat(chunks).toString());
43114
+ if (typeof body.sessionId === "string" && typeof body.text === "string" && body.text.trim()) {
43115
+ this.manager.recordStatusUpdate(body.sessionId, body.text.trim());
43116
+ }
43117
+ res.writeHead(200, { "content-type": "application/json" });
43118
+ res.end(JSON.stringify({ ok: true }));
43119
+ } catch (e) {
43120
+ res.writeHead(400, { "content-type": "application/json" });
43121
+ res.end(JSON.stringify({ ok: false, message: `bad status request: ${String(e)}` }));
43122
+ }
43123
+ return;
43124
+ }
43125
+ if (req.method === "POST" && req.url === "/context") {
43126
+ try {
43127
+ const chunks = [];
43128
+ for await (const c2 of req) chunks.push(c2);
43129
+ const body = JSON.parse(Buffer.concat(chunks).toString());
43130
+ const result = typeof body.sessionId === "string" && typeof body.query === "string" && body.query.trim() ? await this.manager.buildContextFor(body.sessionId, body.query.trim()) : null;
43131
+ res.writeHead(200, { "content-type": "application/json" });
43132
+ res.end(JSON.stringify(result ?? { block: null, filesSelected: 0 }));
43133
+ } catch (e) {
43134
+ res.writeHead(400, { "content-type": "application/json" });
43135
+ res.end(JSON.stringify({ block: null, filesSelected: 0, message: `bad context request: ${String(e)}` }));
43136
+ }
43137
+ return;
43138
+ }
41806
43139
  res.writeHead(404);
41807
43140
  res.end();
41808
43141
  }
@@ -41838,8 +43171,19 @@ var RelayClient = class {
41838
43171
  heartbeat = null;
41839
43172
  detach = null;
41840
43173
  stopped = false;
43174
+ /** Set when a heartbeat ping goes out; cleared by ANY inbound frame. If
43175
+ * still set on the next tick, nothing has answered in a full interval —
43176
+ * the connection is a zombie (readyState still reports OPEN even though
43177
+ * the underlying TCP connection died silently, e.g. after laptop sleep or
43178
+ * a NAT/firewall idle timeout — no close/error event fires on its own). */
43179
+ awaitingPongSince = null;
41841
43180
  /** sessionId → tool-call count for the in-flight run (opaque count only, never the action). */
41842
43181
  toolCounts = /* @__PURE__ */ new Map();
43182
+ /** True once we've been online at least once — a failed first connection
43183
+ * attempt isn't "going offline" (there was nothing to lose), so the very
43184
+ * first successful open never logs a reconnect. */
43185
+ everConnected = false;
43186
+ offlineSinceMs = null;
41843
43187
  start() {
41844
43188
  this.connect();
41845
43189
  }
@@ -41854,8 +43198,13 @@ var RelayClient = class {
41854
43198
  }
41855
43199
  connect() {
41856
43200
  if (this.stopped) return;
43201
+ console.log(`relay: connecting (${this.relayUrl})`);
41857
43202
  const ws = new wrapper_default(this.url());
41858
43203
  this.ws = ws;
43204
+ const slowStartTimer = setTimeout(() => {
43205
+ console.log("relay: still connecting \u2014 the hosted relay may be waking up from a cold start (can take up to a minute)");
43206
+ }, 8e3);
43207
+ const clearSlowStartTimer = () => clearTimeout(slowStartTimer);
41859
43208
  const send = (msg) => {
41860
43209
  if (ws.readyState === wrapper_default.OPEN) {
41861
43210
  const envelope = seal(msg, this.keys.tx);
@@ -41895,24 +43244,52 @@ var RelayClient = class {
41895
43244
  JSON.stringify({ kind: "notify", notice: "error", sessionId: msg.sessionId })
41896
43245
  );
41897
43246
  }
43247
+ if (msg.type === "run-event" && msg.event.type === "handover-proposed") {
43248
+ ws.send(
43249
+ JSON.stringify({
43250
+ kind: "notify",
43251
+ notice: "handover",
43252
+ id: msg.event.id,
43253
+ sessionId: msg.sessionId
43254
+ })
43255
+ );
43256
+ }
43257
+ if (msg.type === "run-event" && msg.event.type === "limit-hit") {
43258
+ ws.send(
43259
+ JSON.stringify({ kind: "notify", notice: "limit-hit", sessionId: msg.sessionId })
43260
+ );
43261
+ }
41898
43262
  if (msg.type === "drop" && msg.direction === "to-phone") {
41899
43263
  ws.send(JSON.stringify({ kind: "notify", notice: "drop" }));
41900
43264
  }
41901
43265
  }
41902
43266
  };
41903
43267
  ws.on("open", () => {
43268
+ clearSlowStartTimer();
41904
43269
  this.backoffMs = 500;
43270
+ this.awaitingPongSince = null;
41905
43271
  console.log(`relay: connected (${this.relayUrl})`);
43272
+ if (this.everConnected && this.offlineSinceMs !== null) {
43273
+ this.manager.recordDeviceReconnected(Date.now() - this.offlineSinceMs);
43274
+ }
43275
+ this.everConnected = true;
43276
+ this.offlineSinceMs = null;
41906
43277
  this.detach = this.manager.attach(send);
41907
43278
  send(this.manager.hello());
41908
43279
  void this.manager.manifest().then(send);
41909
43280
  this.heartbeat = setInterval(() => {
41910
- if (ws.readyState === wrapper_default.OPEN) {
41911
- ws.send(JSON.stringify({ kind: "ping" }));
43281
+ if (ws.readyState !== wrapper_default.OPEN) return;
43282
+ if (this.awaitingPongSince !== null) {
43283
+ console.log("relay: no response since last heartbeat \u2014 reconnecting");
43284
+ ws.terminate();
43285
+ return;
41912
43286
  }
43287
+ this.awaitingPongSince = Date.now();
43288
+ ws.send(JSON.stringify({ kind: "ping" }));
41913
43289
  }, HEARTBEAT_MS);
41914
43290
  });
41915
43291
  ws.on("message", (raw) => {
43292
+ this.awaitingPongSince = null;
41916
43293
  let frame;
41917
43294
  try {
41918
43295
  frame = parseRelayFrame(raw.toString());
@@ -41926,6 +43303,18 @@ var RelayClient = class {
41926
43303
  );
41927
43304
  return;
41928
43305
  }
43306
+ if (frame.kind === "handover-verdict") {
43307
+ this.manager.handle(
43308
+ {
43309
+ type: "handover-response",
43310
+ sessionId: frame.chatSessionId,
43311
+ handoverId: frame.handoverId,
43312
+ accept: frame.accept
43313
+ },
43314
+ send
43315
+ );
43316
+ return;
43317
+ }
41929
43318
  if (frame.kind !== "peer") return;
41930
43319
  try {
41931
43320
  const envelope = EnvelopeSchema.parse(frame.payload);
@@ -41938,7 +43327,12 @@ var RelayClient = class {
41938
43327
  console.error(`relay: ${err.message}`);
41939
43328
  });
41940
43329
  ws.on("close", () => {
43330
+ clearSlowStartTimer();
41941
43331
  this.cleanup();
43332
+ if (this.everConnected && this.offlineSinceMs === null) {
43333
+ this.offlineSinceMs = Date.now();
43334
+ this.manager.recordDeviceOffline();
43335
+ }
41942
43336
  if (this.stopped) return;
41943
43337
  console.log(`relay: disconnected \u2014 retrying in ${this.backoffMs}ms`);
41944
43338
  setTimeout(() => this.connect(), this.backoffMs);
@@ -41954,7 +43348,7 @@ var RelayClient = class {
41954
43348
  };
41955
43349
 
41956
43350
  // ../daemon/src/pairing.ts
41957
- import { existsSync as existsSync7, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "node:fs";
43351
+ import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "node:fs";
41958
43352
  import { homedir as homedir7 } from "node:os";
41959
43353
  import { join as join8 } from "node:path";
41960
43354
  import { randomInt } from "node:crypto";
@@ -41964,7 +43358,7 @@ var POLL_MS = 2e3;
41964
43358
  var POLL_TIMEOUT_MS = 10 * 60 * 1e3;
41965
43359
  async function ensurePairing(relayUrl2, forceNew = false, webUrl2 = "https://offhand-web.onrender.com") {
41966
43360
  await ready;
41967
- if (!forceNew && existsSync7(PAIRING_PATH)) {
43361
+ if (!forceNew && existsSync8(PAIRING_PATH)) {
41968
43362
  const f2 = JSON.parse(readFileSync4(PAIRING_PATH, "utf8"));
41969
43363
  const kp2 = { publicKey: fromB64u(f2.daemonPublicKey), secretKey: fromB64u(f2.daemonSecretKey) };
41970
43364
  const phonePk = fromB64u(f2.phonePublicKey);
@@ -42118,6 +43512,7 @@ var ApprovalBroker = class {
42118
43512
  }
42119
43513
  };
42120
43514
  function classifyRisk(toolName, input) {
43515
+ if (toolName === "offhand_send_file") return "high";
42121
43516
  const i2 = input ?? {};
42122
43517
  const text = [toolName, i2.command, i2.file_path, i2.path, i2.filepath].filter((x2) => typeof x2 === "string").join(" ");
42123
43518
  return /\b(rm|del|rmdir|rd|format|mkfs|shutdown|reboot|kill|drop\s+table|truncate|git\s+push\s+--force|--hard)\b/i.test(
@@ -42171,9 +43566,9 @@ function buildPreview(toolName, input) {
42171
43566
  return void 0;
42172
43567
  }
42173
43568
  function formatUnifiedDiff(diff) {
42174
- const lines = diff.split("\n");
42175
- const hunkStart = lines.findIndex((l2) => l2.startsWith("@@"));
42176
- return (hunkStart >= 0 ? lines.slice(hunkStart) : lines).join("\n").trimEnd();
43569
+ return diff.split("\n").filter(
43570
+ (l2) => !l2.startsWith("index ") && !l2.startsWith("Index:") && !/^={3,}$/.test(l2) && !/^(new|deleted) file mode /.test(l2) && !/^(old|new) mode /.test(l2)
43571
+ ).join("\n").trimEnd();
42177
43572
  }
42178
43573
  function truncate6(s2, max) {
42179
43574
  return s2.length <= max ? s2 : s2.slice(0, max - 1) + "\u2026";
@@ -42213,12 +43608,12 @@ async function downloadArtifact(relayUrl2, sessionId, blobId, keys) {
42213
43608
 
42214
43609
  // ../daemon/src/autostart.ts
42215
43610
  import { homedir as homedir8 } from "node:os";
42216
- import { join as join10, resolve as resolve4, dirname as dirname3 } from "node:path";
42217
- import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync } from "node:fs";
42218
- import { fileURLToPath as fileURLToPath3 } from "node:url";
43611
+ import { join as join10, resolve as resolve4, dirname as dirname4 } from "node:path";
43612
+ import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync } from "node:fs";
43613
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
42219
43614
  import { spawnSync } from "node:child_process";
42220
43615
  import { tmpdir as tmpdir3 } from "node:os";
42221
- var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
43616
+ var __dirname2 = dirname4(fileURLToPath4(import.meta.url));
42222
43617
  var SHORTCUT_NAME = "OffhandDaemon.lnk";
42223
43618
  var OFFHAND_HOME = process.env.OFFHAND_HOME ?? join10(homedir8(), ".offhand");
42224
43619
  function getStartupDir() {
@@ -42235,27 +43630,23 @@ function getWrapperPath() {
42235
43630
  function getShortcutPath() {
42236
43631
  return join10(getStartupDir(), SHORTCUT_NAME);
42237
43632
  }
42238
- function buildWrapperScript(config) {
42239
- const projectRoot = resolve4(__dirname2, "..", "..");
42240
- const wsArgs2 = config.workspaces.map((w2) => `--workspace "${w2}"`).join(" ");
42241
- const relayArg = config.relayUrl ? `--relay "${config.relayUrl}"` : "";
42242
- const devUrlArg = config.devUrl ? `--dev-url "${config.devUrl}"` : "";
42243
- const portArg = `--port ${config.port}`;
42244
- const approvalArg = `--approval-timeout ${config.approvalTimeout ?? 300}`;
42245
- const webUrlArg = `--web-url "${config.webUrl ?? "https://offhand-web.onrender.com"}"`;
42246
- const parts = [
42247
- "pnpm --filter @offhand/daemon dev",
42248
- wsArgs2,
42249
- relayArg,
42250
- devUrlArg,
42251
- portArg,
42252
- approvalArg,
42253
- webUrlArg
42254
- ].filter(Boolean);
42255
- const cmd = parts.join(" ");
42256
- return `@echo off
42257
- cd /d "${projectRoot}"
42258
- ${cmd}
43633
+ function buildWrapperScript(config2, entry = process.argv[1] ?? "", execPath = process.execPath) {
43634
+ const wsArgs2 = config2.workspaces.map((w2) => `--workspace "${w2}"`).join(" ");
43635
+ const relayArg = config2.relayUrl ? `--relay "${config2.relayUrl}"` : "";
43636
+ const devUrlArg = config2.devUrl ? `--dev-url "${config2.devUrl}"` : "";
43637
+ const portArg = `--port ${config2.port}`;
43638
+ const approvalArg = `--approval-timeout ${config2.approvalTimeout ?? 300}`;
43639
+ const webUrlArg = `--web-url "${config2.webUrl ?? "https://offhand-web.onrender.com"}"`;
43640
+ const args2 = [wsArgs2, relayArg, devUrlArg, portArg, approvalArg, webUrlArg].filter(Boolean).join(" ");
43641
+ if (entry.endsWith(".ts")) {
43642
+ const projectRoot = resolve4(__dirname2, "..", "..");
43643
+ return `@echo off\r
43644
+ cd /d "${projectRoot}"\r
43645
+ pnpm --filter @offhand/daemon dev -- ${args2}\r
43646
+ `;
43647
+ }
43648
+ return `@echo off\r
43649
+ "${execPath}" "${entry}" ${args2}\r
42259
43650
  `;
42260
43651
  }
42261
43652
  function createShortcut(targetPath, shortcutPath) {
@@ -42276,11 +43667,11 @@ $Shortcut.Save()`;
42276
43667
  return { success: result.status === 0, output: result.stdout + result.stderr };
42277
43668
  }
42278
43669
  function checkShortcutExists(shortcutPath) {
42279
- return existsSync8(shortcutPath);
43670
+ return existsSync9(shortcutPath);
42280
43671
  }
42281
- function installAutostart(config) {
43672
+ function installAutostart(config2) {
42282
43673
  const wrapperPath = getWrapperPath();
42283
- const script = buildWrapperScript(config);
43674
+ const script = buildWrapperScript(config2);
42284
43675
  writeFileSync4(wrapperPath, script);
42285
43676
  const shortcutPath = getShortcutPath();
42286
43677
  const result = createShortcut(wrapperPath, shortcutPath);
@@ -42296,7 +43687,7 @@ function getAutostartStatus() {
42296
43687
  const shortcutPath = getShortcutPath();
42297
43688
  const installed = checkShortcutExists(shortcutPath);
42298
43689
  const wrapperPath = getWrapperPath();
42299
- const wrapperExists = existsSync8(wrapperPath);
43690
+ const wrapperExists = existsSync9(wrapperPath);
42300
43691
  let details = `Shortcut: ${shortcutPath} (${installed ? "EXISTS" : "MISSING"})`;
42301
43692
  if (wrapperExists) {
42302
43693
  details += `
@@ -42304,14 +43695,13 @@ Wrapper: ${wrapperPath} (EXISTS)`;
42304
43695
  }
42305
43696
  return { installed, details };
42306
43697
  }
42307
- function saveConfig(config) {
43698
+ function saveConfig(config2) {
42308
43699
  const configPath = join10(OFFHAND_HOME, "autostart.json");
42309
- writeFileSync4(configPath, JSON.stringify(config, null, 2));
43700
+ writeFileSync4(configPath, JSON.stringify(config2, null, 2));
42310
43701
  }
42311
43702
  function getConfigFromStore() {
42312
- const workspaces = ["C:\\Users\\udbha\\dev\\offhand"];
42313
43703
  return {
42314
- workspaces,
43704
+ workspaces: [],
42315
43705
  port: 4317,
42316
43706
  relayUrl: void 0,
42317
43707
  devUrl: void 0,
@@ -42354,7 +43744,7 @@ var devUrl = argValue("--dev-url");
42354
43744
  var store = new Store();
42355
43745
  var wsArgs = argValues("--workspace").map((w2) => resolve5(w2));
42356
43746
  for (const w2 of wsArgs) {
42357
- if (!existsSync9(w2)) {
43747
+ if (!existsSync10(w2)) {
42358
43748
  console.error(`workspace does not exist: ${w2}`);
42359
43749
  process.exit(1);
42360
43750
  }
@@ -42363,10 +43753,11 @@ for (const w2 of wsArgs) {
42363
43753
  if (store.listWorkspaces().length === 0) store.upsertWorkspace(process.cwd(), devUrl);
42364
43754
  var broker = new ApprovalBroker(approvalTimeoutMs);
42365
43755
  var approvalUrl = `http://127.0.0.1:${port}/approval`;
43756
+ var statusUrl = approvalUrl.replace(/\/approval$/, "/status");
42366
43757
  var runners = [
42367
43758
  new ClaudeCodeRunner(broker, approvalUrl),
42368
43759
  new CopilotCliRunner(),
42369
- new OpenCodeRunner(broker),
43760
+ new OpenCodeRunner(broker, statusUrl),
42370
43761
  new CodexCliRunner(),
42371
43762
  new CursorAgentRunner(),
42372
43763
  new GeminiCliRunner()
@@ -42387,25 +43778,20 @@ if (m2.type === "manifest") {
42387
43778
  new LocalSessionServer(manager, port, broker);
42388
43779
  console.log(` local : ws://127.0.0.1:${port}`);
42389
43780
  console.log(` approvals : timeout ${approvalTimeoutMs / 1e3}s then auto-deny`);
42390
- var autostartStatus = getAutostartStatus();
42391
- if (!autostartStatus.installed) {
42392
- console.log(` autostart : installing...`);
42393
- const config = getConfigFromStore();
42394
- config.workspaces = store.listWorkspaces().map((w2) => w2.path);
42395
- config.port = port;
42396
- config.relayUrl = relayUrl;
42397
- config.devUrl = devUrl;
42398
- config.approvalTimeout = approvalTimeoutMs / 1e3;
42399
- config.webUrl = webUrl;
42400
- saveConfig(config);
42401
- const { success, output } = installAutostart(config);
42402
- if (success) {
42403
- console.log(` autostart : installed (runs on login)`);
42404
- } else {
42405
- console.warn(` autostart : failed to install: ${output}`);
42406
- }
43781
+ var wasInstalled = getAutostartStatus().installed;
43782
+ var config = getConfigFromStore();
43783
+ config.workspaces = store.listWorkspaces().map((w2) => w2.path);
43784
+ config.port = port;
43785
+ config.relayUrl = relayUrl;
43786
+ config.devUrl = devUrl;
43787
+ config.approvalTimeout = approvalTimeoutMs / 1e3;
43788
+ config.webUrl = webUrl;
43789
+ saveConfig(config);
43790
+ var { success, output } = installAutostart(config);
43791
+ if (success) {
43792
+ console.log(` autostart : ${wasInstalled ? "refreshed" : "installed"} (runs on login)`);
42407
43793
  } else {
42408
- console.log(` autostart : already installed`);
43794
+ console.warn(` autostart : failed to ${wasInstalled ? "refresh" : "install"}: ${output}`);
42409
43795
  }
42410
43796
  if (relayUrl) {
42411
43797
  const pairing = await ensurePairing(relayUrl, forceRepair, webUrl);