offhands 0.1.7 → 0.1.10

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/approval-mcp.mjs CHANGED
@@ -1,18 +1,35 @@
1
1
  // offhand MCP toolkit (plain JS, zero deps — spawned BY the agent CLI).
2
2
  // Newline-delimited JSON-RPC 2.0 over stdio. Started life as a single tool
3
3
  // (`approval_prompt`, still the one named via --permission-prompt-tool
4
- // mcp__offhand__approval_prompt) — Execution Plan 5 adds `offhand_status_update`
5
- // alongside it, since a CLI wired in via --mcp-config sees every tool this
6
- // server exposes, not just the one used as the permission hook. Each call
7
- // is forwarded to a daemon-local HTTP endpoint (localhost-only by
8
- // construction); the daemon does the real work, this file is just the wire
9
- // adapter between JSON-RPC-over-stdio and plain HTTP.
4
+ // mcp__offhand__approval_prompt) — Execution Plan 5 added `offhand_status_update`
5
+ // alongside it, and a later pass added `offhand_send_file`, since a CLI wired
6
+ // in via --mcp-config sees every tool this server exposes, not just the one
7
+ // used as the permission hook. Each call is forwarded to a daemon-local HTTP
8
+ // endpoint (localhost-only by construction) or, for send_file, done directly
9
+ // against the local filesystem once (if wired) that endpoint approves it;
10
+ // the daemon does the real work, this file is just the wire adapter between
11
+ // JSON-RPC-over-stdio and plain HTTP.
10
12
 
11
13
  import { createInterface } from 'node:readline';
14
+ import { copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { basename, join, parse, resolve } from 'node:path';
12
17
 
13
18
  const APPROVAL_URL = process.env.OFFHAND_APPROVAL_URL ?? 'http://127.0.0.1:4317/approval';
14
19
  const STATUS_URL = process.env.OFFHAND_STATUS_URL ?? 'http://127.0.0.1:4317/status';
15
20
  const SESSION_ID = process.env.OFFHAND_SESSION_ID ?? '';
21
+ // Derived from STATUS_URL rather than a fourth env var every runner would
22
+ // need to remember to set — same daemon, same port, whatever STATUS_URL
23
+ // already points at (set for both Claude Code and OpenCode today).
24
+ const CONTEXT_URL = process.env.OFFHAND_CONTEXT_URL ?? STATUS_URL.replace(/\/status$/, '/context');
25
+ // Whether a runner actually wired offhand's own approval broker in for this
26
+ // run (only Claude Code does today — see claude-code.ts). Where it isn't,
27
+ // the CLI's own permission model is already the only gate on every other
28
+ // tool call too (OpenCode routes its own tool approvals separately — see
29
+ // opencode-cli.ts's statusOnlyMcpConfigContent doc comment), so send_file
30
+ // stays consistent with that rather than inventing a broker call nothing
31
+ // is listening on.
32
+ const HAS_APPROVAL_CHANNEL = process.env.OFFHAND_APPROVAL_URL !== undefined;
16
33
 
17
34
  const APPROVAL_TOOL = {
18
35
  name: 'approval_prompt',
@@ -41,7 +58,78 @@ const STATUS_TOOL = {
41
58
  },
42
59
  };
43
60
 
44
- const TOOLS = [APPROVAL_TOOL, STATUS_TOOL];
61
+ const SEND_FILE_TOOL = {
62
+ name: 'offhand_send_file',
63
+ description:
64
+ "Sends a file already on this machine's disk (a screenshot, a generated video, a log, a diff export) to the person's phone. Give an absolute path to a file that exists right now.",
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ path: { type: 'string', description: 'Absolute path to the file to send.' },
69
+ },
70
+ required: ['path'],
71
+ },
72
+ };
73
+
74
+ const CONTEXT_TOOL = {
75
+ name: 'offhand_context',
76
+ description:
77
+ "Looks up locally-relevant repository context (dirty files, recently-changed files, files matching your search terms) before you spend turns rediscovering the same things. This is a best-effort local search, not authoritative — it may be incomplete or wrong. Use it as a starting point, and keep exploring the repository yourself wherever it looks warranted.",
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ query: { type: 'string', description: 'What you are looking for or working on right now (e.g. "authentication middleware", "the task at hand").' },
82
+ },
83
+ required: ['query'],
84
+ },
85
+ };
86
+
87
+ const TOOLS = [APPROVAL_TOOL, STATUS_TOOL, SEND_FILE_TOOL, CONTEXT_TOOL];
88
+
89
+ /** Duplicated from drop.ts on purpose — this file ships standalone (see the
90
+ * HAS_APPROVAL_CHANNEL comment above), so it can't import the bundled
91
+ * daemon's TS modules. Keep in sync by hand if drop.ts's naming rules change. */
92
+ function safeDropName(input) {
93
+ const base = basename(input).replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').replace(/[. ]+$/g, '').trim();
94
+ if (!base || base === '.' || base === '..') return 'drop';
95
+ return base;
96
+ }
97
+
98
+ function dedupeDropName(desiredName, existingNames) {
99
+ const safe = safeDropName(desiredName);
100
+ const existing = new Set(existingNames.map((n) => n.toLowerCase()));
101
+ if (!existing.has(safe.toLowerCase())) return safe;
102
+ const parsed = parse(safe);
103
+ const stem = parsed.name || 'drop';
104
+ const ext = parsed.ext;
105
+ for (let i = 1; ; i++) {
106
+ const candidate = `${stem}-${i}${ext}`;
107
+ if (!existing.has(candidate.toLowerCase())) return candidate;
108
+ }
109
+ }
110
+
111
+ function dropOutboxDir() {
112
+ const home = process.env.OFFHAND_HOME ?? join(homedir(), '.offhand');
113
+ return join(home, 'drop-outbox');
114
+ }
115
+
116
+ /** Copies sourcePath into the daemon's drop outbox — the same directory the
117
+ * daemon's own file watcher polls for `offhand drop <file>` — so the actual
118
+ * relay send is handled by code that already exists and is already tested. */
119
+ function queueFileForPhone(sourcePath) {
120
+ const source = resolve(sourcePath);
121
+ const st = statSync(source); // throws ENOENT if missing — surfaces as a normal tool error below
122
+ if (!st.isFile()) throw new Error(`not a file: ${sourcePath}`);
123
+ const outbox = dropOutboxDir();
124
+ mkdirSync(outbox, { recursive: true });
125
+ let existing = [];
126
+ try {
127
+ existing = readdirSync(outbox);
128
+ } catch {}
129
+ const dest = join(outbox, dedupeDropName(basename(source), existing));
130
+ copyFileSync(source, dest);
131
+ return dest;
132
+ }
45
133
 
46
134
  function send(msg) {
47
135
  process.stdout.write(JSON.stringify(msg) + '\n');
@@ -73,6 +161,16 @@ async function sendStatus(text) {
73
161
  });
74
162
  }
75
163
 
164
+ async function fetchContext(query) {
165
+ const res = await fetch(CONTEXT_URL, {
166
+ method: 'POST',
167
+ headers: { 'content-type': 'application/json' },
168
+ body: JSON.stringify({ sessionId: SESSION_ID, query }),
169
+ });
170
+ if (!res.ok) return { block: null, filesSelected: 0 };
171
+ return res.json();
172
+ }
173
+
76
174
  const rl = createInterface({ input: process.stdin });
77
175
  rl.on('line', (line) => {
78
176
  if (!line.trim()) return;
@@ -101,6 +199,24 @@ async function handle(msg) {
101
199
  reply(id, { tools: TOOLS });
102
200
  return;
103
201
  case 'tools/call': {
202
+ if (params?.name === SEND_FILE_TOOL.name) {
203
+ const path = String(params.arguments?.path ?? '').trim();
204
+ try {
205
+ if (!path) throw new Error('no path given');
206
+ if (HAS_APPROVAL_CHANNEL) {
207
+ const verdict = await callDaemon({ tool_name: SEND_FILE_TOOL.name, input: { path } });
208
+ if (!verdict.approve) {
209
+ reply(id, { content: [{ type: 'text', text: verdict.message ?? 'denied from phone' }] });
210
+ return;
211
+ }
212
+ }
213
+ const dest = queueFileForPhone(path);
214
+ reply(id, { content: [{ type: 'text', text: `queued ${basename(dest)} for the phone` }] });
215
+ } catch (e) {
216
+ reply(id, { content: [{ type: 'text', text: `could not send file: ${e?.message ?? e}` }] });
217
+ }
218
+ return;
219
+ }
104
220
  if (params?.name === STATUS_TOOL.name) {
105
221
  const text = String(params.arguments?.text ?? '').trim();
106
222
  try {
@@ -113,6 +229,29 @@ async function handle(msg) {
113
229
  }
114
230
  return;
115
231
  }
232
+ if (params?.name === CONTEXT_TOOL.name) {
233
+ const query = String(params.arguments?.query ?? '').trim();
234
+ try {
235
+ if (!query) throw new Error('no query given');
236
+ const { block, filesSelected } = await fetchContext(query);
237
+ reply(id, {
238
+ content: [
239
+ {
240
+ type: 'text',
241
+ text:
242
+ block && filesSelected > 0
243
+ ? block
244
+ : 'No local repository signal found for this query — proceed with your own discovery as usual.',
245
+ },
246
+ ],
247
+ });
248
+ } catch (e) {
249
+ // Same posture as offhand_status_update: a dropped lookup is
250
+ // never fatal — the agent just falls back to its own discovery.
251
+ reply(id, { content: [{ type: 'text', text: `context lookup unavailable: ${e?.message ?? e}` }] });
252
+ }
253
+ return;
254
+ }
116
255
  if (params?.name !== APPROVAL_TOOL.name) {
117
256
  replyError(id, -32602, `unknown tool: ${params?.name}`);
118
257
  return;
package/dist/daemon.mjs CHANGED
@@ -4976,6 +4976,11 @@ var ClaudeCodeRunner = class {
4976
4976
  supportsApprovals = true;
4977
4977
  // Real: every tier maps to a MAX_THINKING_TOKENS value below (see start()).
4978
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"] };
4979
4984
  loggedIn() {
4980
4985
  return existsSync(join(homedir(), ".claude", ".credentials.json"));
4981
4986
  }
@@ -5143,6 +5148,7 @@ var CodexCliRunner = class {
5143
5148
  name = "OpenAI Codex CLI";
5144
5149
  models = [];
5145
5150
  supportsApprovals = false;
5151
+ installCommand = { command: "npm", args: ["install", "-g", "@openai/codex"] };
5146
5152
  detect = () => commandExists("codex");
5147
5153
  start = (_run) => notImplemented(this.id);
5148
5154
  };
@@ -5151,7 +5157,18 @@ var CursorAgentRunner = class {
5151
5157
  name = "Cursor";
5152
5158
  models = [];
5153
5159
  supportsApprovals = false;
5154
- 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");
5155
5172
  start = (_run) => notImplemented(this.id);
5156
5173
  };
5157
5174
  var GeminiCliRunner = class {
@@ -5159,6 +5176,7 @@ var GeminiCliRunner = class {
5159
5176
  name = "Gemini CLI";
5160
5177
  models = [];
5161
5178
  supportsApprovals = false;
5179
+ installCommand = { command: "npm", args: ["install", "-g", "@google/gemini-cli"] };
5162
5180
  detect = () => commandExists("gemini");
5163
5181
  start = (_run) => notImplemented(this.id);
5164
5182
  };
@@ -5219,6 +5237,8 @@ var CopilotCliRunner = class {
5219
5237
  // budget flag in Copilot CLI 1.0.31's non-interactive mode. Leaving this
5220
5238
  // undefined (not an empty array) is what tells the phone to hide the
5221
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"] };
5222
5242
  /** [command, ...prefixArgs] resolved once. */
5223
5243
  resolved = null;
5224
5244
  resolveCommand() {
@@ -5347,7 +5367,8 @@ import { spawn as spawn4 } from "node:child_process";
5347
5367
  import { createServer } from "node:net";
5348
5368
  import { existsSync as existsSync3 } from "node:fs";
5349
5369
  import { homedir as homedir3 } from "node:os";
5350
- 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";
5351
5372
 
5352
5373
  // ../daemon/src/runners/opencode-map.ts
5353
5374
  function mapOpenCodeEvent(value) {
@@ -5430,9 +5451,25 @@ function truncate3(s2, max) {
5430
5451
 
5431
5452
  // ../daemon/src/runners/opencode-cli.ts
5432
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
+ }
5433
5469
  var OpenCodeRunner = class {
5434
- constructor(broker2) {
5470
+ constructor(broker2, statusUrl2) {
5435
5471
  this.broker = broker2;
5472
+ this.statusUrl = statusUrl2;
5436
5473
  }
5437
5474
  id = "opencode";
5438
5475
  name = "OpenCode";
@@ -5443,13 +5480,30 @@ var OpenCodeRunner = class {
5443
5480
  // low/medium collide onto minimal/high internally, but all four inputs
5444
5481
  // are genuinely honored, just not 1:1 named.
5445
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"] };
5446
5486
  /** [command, ...prefixArgs] resolved once. */
5447
5487
  resolved = null;
5448
5488
  modelsCache = [];
5449
- // Lazily-started, shared `opencode serve` instance backing startViaHttpApi.
5450
- serverPort = null;
5451
- serverProcess = null;
5452
- 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();
5453
5507
  resolveCommand() {
5454
5508
  if (this.resolved) return this.resolved;
5455
5509
  if (process.platform === "win32") {
@@ -5466,7 +5520,8 @@ var OpenCodeRunner = class {
5466
5520
  if (!pathDir) continue;
5467
5521
  const cmdPath = join3(pathDir, "opencode.cmd");
5468
5522
  if (existsSync3(cmdPath)) {
5469
- this.resolved = [cmdPath];
5523
+ const realExe = join3(dirname2(cmdPath), "node_modules", "opencode-ai", "bin", "opencode.exe");
5524
+ this.resolved = [existsSync3(realExe) ? realExe : cmdPath];
5470
5525
  return this.resolved;
5471
5526
  }
5472
5527
  const nodeModulesBin = join3(pathDir, "node_modules", ".bin", "opencode.cmd");
@@ -5600,13 +5655,17 @@ var OpenCodeRunner = class {
5600
5655
  }
5601
5656
  }
5602
5657
  args2.push("--dir", run.workspace);
5603
- const promptArg = process.platform === "win32" ? `"${run.prompt.replace(/"/g, '\\"')}"` : run.prompt;
5658
+ const viaShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(cmd[0]);
5659
+ const promptArg = viaShell ? `"${run.prompt.replace(/\r?\n/g, " ").replace(/"/g, '\\"')}"` : run.prompt;
5604
5660
  args2.push(promptArg);
5605
5661
  try {
5606
5662
  child = spawn4(cmd[0], args2, {
5607
5663
  cwd: run.workspace,
5608
5664
  stdio: ["ignore", "pipe", "pipe"],
5609
- shell: process.platform === "win32"
5665
+ shell: viaShell,
5666
+ // One-shot process per prompt — no shared-server cross-session risk
5667
+ // here, so the session id can go straight on the env unconditionally.
5668
+ env: { ...process.env, OPENCODE_CONFIG_CONTENT: statusOnlyMcpConfigContent(this.statusUrl, run.sessionId) }
5610
5669
  });
5611
5670
  } catch (e) {
5612
5671
  queue.push({ type: "error", message: `failed to spawn opencode: ${String(e)}` });
@@ -5680,7 +5739,7 @@ stderr: ${stderrTail}` : ""}`
5680
5739
  };
5681
5740
  void (async () => {
5682
5741
  try {
5683
- port2 = await this.ensureServer();
5742
+ port2 = await this.ensureServer(run.sessionId);
5684
5743
  } catch (e) {
5685
5744
  emit([{ type: "error", message: `failed to start opencode server: ${String(e)}` }]);
5686
5745
  cleanup();
@@ -5830,34 +5889,38 @@ stderr: ${stderrTail}` : ""}`
5830
5889
  } catch {
5831
5890
  }
5832
5891
  }
5833
- /** Lazily start (once) the shared `opencode serve` instance used for
5834
- * broker-driven runs, and wait for it to accept connections. */
5835
- async ensureServer() {
5836
- if (this.serverPort) return this.serverPort;
5837
- if (this.serverStarting) return this.serverStarting;
5838
- this.serverStarting = (async () => {
5892
+ /** Lazily start (once per offhand session) the `opencode serve` instance
5893
+ * backing that session's broker-driven runs, and wait for it to accept
5894
+ * connections. Reused across that session's later turns (so
5895
+ * `resumeConversationId` keeps working); a different session never
5896
+ * shares this instance — see the field comment above for why. */
5897
+ async ensureServer(sessionId) {
5898
+ const existing = this.servers.get(sessionId);
5899
+ if (existing) return existing.port;
5900
+ const inFlight = this.serverStarting.get(sessionId);
5901
+ if (inFlight) return inFlight;
5902
+ const starting = (async () => {
5839
5903
  const cmd = this.resolveCommand();
5840
5904
  if (!cmd) throw new Error("opencode CLI not found");
5841
5905
  const port2 = await findFreePort();
5842
5906
  const proc = spawn4(cmd[0], [...cmd.slice(1), "serve", "--port", String(port2), "--hostname", "127.0.0.1"], {
5843
5907
  stdio: ["ignore", "pipe", "pipe"],
5844
- shell: process.platform === "win32"
5908
+ shell: process.platform === "win32",
5909
+ env: { ...process.env, OPENCODE_CONFIG_CONTENT: statusOnlyMcpConfigContent(this.statusUrl, sessionId) }
5845
5910
  });
5846
5911
  proc.on("exit", () => {
5847
- if (this.serverProcess === proc) {
5848
- this.serverProcess = null;
5849
- this.serverPort = null;
5850
- }
5912
+ const cur = this.servers.get(sessionId);
5913
+ if (cur?.process === proc) this.servers.delete(sessionId);
5851
5914
  });
5852
5915
  await waitForServerReady(port2);
5853
- this.serverProcess = proc;
5854
- this.serverPort = port2;
5916
+ this.servers.set(sessionId, { port: port2, process: proc });
5855
5917
  return port2;
5856
5918
  })();
5919
+ this.serverStarting.set(sessionId, starting);
5857
5920
  try {
5858
- return await this.serverStarting;
5921
+ return await starting;
5859
5922
  } finally {
5860
- this.serverStarting = null;
5923
+ this.serverStarting.delete(sessionId);
5861
5924
  }
5862
5925
  }
5863
5926
  };
@@ -5953,8 +6016,10 @@ function sleep(ms) {
5953
6016
  import { randomUUID } from "node:crypto";
5954
6017
  import { hostname, platform as platform2, tmpdir } from "node:os";
5955
6018
  import { existsSync as existsSync7, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
5956
- import { dirname as dirname2, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
5957
- import { fileURLToPath as fileURLToPath2 } from "node:url";
6019
+ import { dirname as dirname3, join as join6, basename as basename3, parse as parse2, resolve as resolve3 } from "node:path";
6020
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
6021
+ import { execFile as execFile4 } from "node:child_process";
6022
+ import { promisify as promisify4 } from "node:util";
5958
6023
 
5959
6024
  // ../daemon/src/receipts.ts
5960
6025
  import { execFile } from "node:child_process";
@@ -5976,6 +6041,7 @@ async function workspaceInfo(row) {
5976
6041
  path: row.path,
5977
6042
  label: row.label,
5978
6043
  policy: row.policy,
6044
+ intelligenceMode: row.intelligenceMode,
5979
6045
  ...branch ? { gitBranch: branch } : {},
5980
6046
  ...status !== null ? { dirty: status.trim() !== "" } : {},
5981
6047
  ...row.devUrl ? { devUrl: row.devUrl } : {}
@@ -6560,10 +6626,247 @@ async function probeWolCapability() {
6560
6626
  }
6561
6627
  }
6562
6628
 
6629
+ // ../daemon/src/intelligence/ripgrep.ts
6630
+ import { execFile as execFile3 } from "node:child_process";
6631
+ import { promisify as promisify3 } from "node:util";
6632
+ var exec3 = promisify3(execFile3);
6633
+ var cachedAvailable = null;
6634
+ async function detectRipgrep() {
6635
+ if (cachedAvailable !== null) return cachedAvailable;
6636
+ try {
6637
+ await exec3("rg", ["--version"], { timeout: 5e3 });
6638
+ cachedAvailable = true;
6639
+ } catch {
6640
+ cachedAvailable = false;
6641
+ }
6642
+ return cachedAvailable;
6643
+ }
6644
+ async function searchTerms(workspace, terms, opts = {}) {
6645
+ if (terms.length === 0) return [];
6646
+ const timeoutMs = opts.timeoutMs ?? 3e3;
6647
+ const maxMatches = opts.maxMatches ?? 40;
6648
+ const args2 = [
6649
+ "--fixed-strings",
6650
+ "--ignore-case",
6651
+ "--line-number",
6652
+ "--no-heading",
6653
+ "--max-count",
6654
+ "3",
6655
+ "--max-filesize",
6656
+ "1M",
6657
+ "--glob",
6658
+ "!.git",
6659
+ "--glob",
6660
+ "!node_modules",
6661
+ ...terms.flatMap((t2) => ["-e", t2]),
6662
+ "."
6663
+ ];
6664
+ try {
6665
+ const { stdout } = await exec3("rg", args2, { cwd: workspace, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 });
6666
+ return parseRipgrepOutput(stdout).slice(0, maxMatches);
6667
+ } catch (e) {
6668
+ const err = e;
6669
+ return err.stdout ? parseRipgrepOutput(err.stdout).slice(0, maxMatches) : [];
6670
+ }
6671
+ }
6672
+ function parseRipgrepOutput(stdout) {
6673
+ const hits = [];
6674
+ for (const line of stdout.split("\n")) {
6675
+ if (!line) continue;
6676
+ const m3 = /^(.+?):(\d+):(.*)$/.exec(line);
6677
+ if (!m3) continue;
6678
+ hits.push({ file: m3[1], line: Number(m3[2]), text: m3[3].trim().slice(0, 200) });
6679
+ }
6680
+ return hits;
6681
+ }
6682
+
6683
+ // ../daemon/src/intelligence/context-inject.ts
6684
+ var DEFAULT_MAX_CHARS = 8e3;
6685
+ var OVERALL_TIMEOUT_MS = 4e3;
6686
+ var STOPWORDS = /* @__PURE__ */ new Set([
6687
+ "the",
6688
+ "a",
6689
+ "an",
6690
+ "to",
6691
+ "for",
6692
+ "in",
6693
+ "on",
6694
+ "of",
6695
+ "and",
6696
+ "or",
6697
+ "is",
6698
+ "are",
6699
+ "be",
6700
+ "been",
6701
+ "add",
6702
+ "please",
6703
+ "can",
6704
+ "you",
6705
+ "we",
6706
+ "this",
6707
+ "that",
6708
+ "it",
6709
+ "with",
6710
+ "from",
6711
+ "when",
6712
+ "where",
6713
+ "how",
6714
+ "what",
6715
+ "i",
6716
+ "my",
6717
+ "our",
6718
+ "some",
6719
+ "make",
6720
+ "sure",
6721
+ "need",
6722
+ "needs",
6723
+ "want",
6724
+ "wants",
6725
+ "like",
6726
+ "using",
6727
+ "use",
6728
+ "so",
6729
+ "up",
6730
+ "as",
6731
+ "at",
6732
+ "by",
6733
+ "not",
6734
+ "do",
6735
+ "does",
6736
+ "did",
6737
+ "have",
6738
+ "has",
6739
+ "had",
6740
+ "will",
6741
+ "would",
6742
+ "should",
6743
+ "could",
6744
+ "also",
6745
+ "into",
6746
+ "if",
6747
+ "then",
6748
+ "than"
6749
+ ]);
6750
+ function extractTerms(prompt, maxTerms = 8) {
6751
+ const raw = prompt.toLowerCase().split(/[^a-z0-9._-]+/).map((t2) => t2.replace(/^[._-]+|[._-]+$/g, "")).filter((t2) => t2.length >= 3 && !STOPWORDS.has(t2) && !/^\d+$/.test(t2));
6752
+ const seen = /* @__PURE__ */ new Set();
6753
+ const deduped = [];
6754
+ for (const t2 of raw) {
6755
+ if (seen.has(t2)) continue;
6756
+ seen.add(t2);
6757
+ deduped.push(t2);
6758
+ }
6759
+ return deduped.slice().sort((a2, b2) => b2.length - a2.length).slice(0, maxTerms);
6760
+ }
6761
+ async function buildContext(workspace, prompt, opts = {}) {
6762
+ const startedAt = Date.now();
6763
+ const maxChars = opts.maxChars ?? (Number(process.env.OFFHAND_INTEL_MAX_CHARS) || DEFAULT_MAX_CHARS);
6764
+ const work = gatherFindings(workspace, prompt);
6765
+ const timeout = new Promise((resolve6) => setTimeout(() => resolve6("timeout"), OVERALL_TIMEOUT_MS));
6766
+ const result = await Promise.race([work, timeout]);
6767
+ const durationMs = Date.now() - startedAt;
6768
+ if (result === "timeout") {
6769
+ return {
6770
+ block: "",
6771
+ meta: {
6772
+ promptChars: prompt.length,
6773
+ contextChars: 0,
6774
+ filesConsidered: 0,
6775
+ filesSelected: 0,
6776
+ retrievalOpsRun: 0,
6777
+ durationMs,
6778
+ ripgrepAvailable: false,
6779
+ timedOut: true
6780
+ }
6781
+ };
6782
+ }
6783
+ const { findings, retrievalOpsRun, ripgrepAvailable, gitState } = result;
6784
+ if (findings.length === 0) return null;
6785
+ findings.sort((a2, b2) => b2.priority - a2.priority);
6786
+ const { text: block, selectedCount } = formatBlock(prompt, findings, gitState, maxChars);
6787
+ if (selectedCount === 0) return null;
6788
+ return {
6789
+ block,
6790
+ meta: {
6791
+ promptChars: prompt.length,
6792
+ contextChars: block.length,
6793
+ filesConsidered: findings.length,
6794
+ filesSelected: selectedCount,
6795
+ retrievalOpsRun,
6796
+ durationMs: Date.now() - startedAt,
6797
+ ripgrepAvailable,
6798
+ timedOut: false
6799
+ }
6800
+ };
6801
+ }
6802
+ async function gatherFindings(workspace, prompt) {
6803
+ let retrievalOpsRun = 0;
6804
+ const byPath = /* @__PURE__ */ new Map();
6805
+ const add = (path, reason, priority) => {
6806
+ const existing = byPath.get(path);
6807
+ if (existing) {
6808
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
6809
+ existing.priority = Math.max(existing.priority, priority);
6810
+ } else {
6811
+ byPath.set(path, { path, reasons: [reason], priority });
6812
+ }
6813
+ };
6814
+ const statusOut = await git(workspace, "status", "--porcelain");
6815
+ retrievalOpsRun++;
6816
+ let gitState = null;
6817
+ if (statusOut !== null) {
6818
+ const dirtyFiles = statusOut.split("\n").filter((l2) => l2.length > 3).map((l2) => l2.slice(3).trim()).filter(Boolean);
6819
+ for (const f2 of dirtyFiles.slice(0, 15)) add(f2, "currently dirty", 3);
6820
+ gitState = dirtyFiles.length > 0 ? `${dirtyFiles.length} file(s) with uncommitted changes` : "clean working tree";
6821
+ }
6822
+ const ripgrepAvailable = await detectRipgrep();
6823
+ retrievalOpsRun++;
6824
+ const terms = extractTerms(prompt);
6825
+ if (ripgrepAvailable && terms.length > 0) {
6826
+ const hits = await searchTerms(workspace, terms);
6827
+ retrievalOpsRun++;
6828
+ for (const hit of hits) {
6829
+ const term = terms.find((t2) => hit.text.toLowerCase().includes(t2));
6830
+ add(hit.file, term ? `matched "${term}"` : "matched search terms", 2);
6831
+ }
6832
+ }
6833
+ const logOut = await git(workspace, "log", "-n", "15", "--name-only", "--pretty=format:");
6834
+ retrievalOpsRun++;
6835
+ if (logOut !== null) {
6836
+ const recentFiles = [...new Set(logOut.split("\n").map((l2) => l2.trim()).filter(Boolean))];
6837
+ for (const f2 of recentFiles.slice(0, 10)) add(f2, "recently changed", 1);
6838
+ }
6839
+ return { findings: [...byPath.values()], retrievalOpsRun, ripgrepAvailable, gitState };
6840
+ }
6841
+ function formatBlock(prompt, rankedFindings, gitState, maxChars) {
6842
+ const header = `TASK
6843
+ ${prompt}
6844
+
6845
+ OFFHAND REPOSITORY CONTEXT
6846
+ `;
6847
+ const gitLine = gitState ? `- Current git state: ${gitState}
6848
+ ` : "";
6849
+ 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.";
6850
+ const budget = maxChars - header.length - gitLine.length - footer.length;
6851
+ const lines = [];
6852
+ let used = 0;
6853
+ let selectedCount = 0;
6854
+ for (const f2 of rankedFindings) {
6855
+ const line = `- ${f2.path} \u2014 ${f2.reasons.join(", ")}
6856
+ `;
6857
+ if (used + line.length > budget) break;
6858
+ lines.push(line);
6859
+ used += line.length;
6860
+ selectedCount++;
6861
+ }
6862
+ const text = header + gitLine + lines.join("") + footer;
6863
+ return { text, selectedCount };
6864
+ }
6865
+
6563
6866
  // ../daemon/src/session-manager.ts
6564
6867
  function resolveDaemonVersion() {
6565
6868
  try {
6566
- const pkgPath = join6(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
6869
+ const pkgPath = join6(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
6567
6870
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
6568
6871
  return pkg.version ?? "0.0.0";
6569
6872
  } catch {
@@ -6571,6 +6874,7 @@ function resolveDaemonVersion() {
6571
6874
  }
6572
6875
  }
6573
6876
  var DAEMON_VERSION = resolveDaemonVersion();
6877
+ var execFileAsync = promisify4(execFile4);
6574
6878
  var SYSTEM_LOG_SESSION_ID = "system";
6575
6879
  var MOBILE_SESSION_PREAMBLE_BASE = "You're being driven remotely from a phone via offhand \u2014 the person who sent this prompt isn't at this keyboard and can't see this terminal. Lean on visual proof (screenshots, diffs, clear file paths) over long text explanations where you can, since they're reading this on a phone screen, not a terminal.";
6576
6880
  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.";
@@ -6586,6 +6890,9 @@ var SessionManager = class {
6586
6890
  // sessionId → workspace
6587
6891
  queues = /* @__PURE__ */ new Map();
6588
6892
  // sessionId → queued prompts
6893
+ /** reviewId → an Offhand Intelligence proposal (Execution Plan 8) awaiting
6894
+ * `intelligence-response` — not tied to any run, since none exists yet. */
6895
+ pendingIntelligence = /* @__PURE__ */ new Map();
6589
6896
  runners = /* @__PURE__ */ new Map();
6590
6897
  runnerAvailability = /* @__PURE__ */ new Map();
6591
6898
  /** handoverId → what was proposed, so `handover-response` (which only
@@ -6656,7 +6963,9 @@ var SessionManager = class {
6656
6963
  ...r2.loggedIn ? { loggedIn: r2.loggedIn() } : {},
6657
6964
  models: r2.models,
6658
6965
  supportsApprovals: r2.supportsApprovals,
6659
- ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {}
6966
+ ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {},
6967
+ ...r2.installCommand ? { installCommand: `${r2.installCommand.command} ${r2.installCommand.args.join(" ")}` } : {},
6968
+ ...r2.installUrl ? { installUrl: r2.installUrl } : {}
6660
6969
  }));
6661
6970
  const workspaces = await Promise.all(
6662
6971
  this.store.listWorkspaces().map((w2) => workspaceInfo(w2))
@@ -6688,28 +6997,86 @@ var SessionManager = class {
6688
6997
  reply({ type: "error", message: `unknown session ${msg.sessionId}` });
6689
6998
  return;
6690
6999
  }
6691
- let prompt = msg.prompt;
6692
- if (msg.attachments?.length && this.attachmentFetcher) {
6693
- try {
6694
- prompt += "\n\nAttached files (read them from disk):";
6695
- for (const a2 of msg.attachments) {
6696
- const bytes = await this.attachmentFetcher(a2.blobId);
6697
- const dir = join6(tmpdir(), "offhand-attachments");
6698
- mkdirSync2(dir, { recursive: true });
6699
- const path = join6(dir, `${a2.blobId.slice(0, 8)}-${basename3(a2.name)}`);
6700
- writeFileSync2(path, bytes);
6701
- prompt += `
6702
- - ${path} (${a2.mime})`;
6703
- }
6704
- } catch (e) {
6705
- reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
6706
- return;
7000
+ let prompt;
7001
+ try {
7002
+ prompt = await this.stageAttachments(msg.prompt, msg.attachments);
7003
+ } catch (e) {
7004
+ reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
7005
+ return;
7006
+ }
7007
+ this.enqueuePrompt(session, prompt);
7008
+ await this.broadcastManifest();
7009
+ return;
7010
+ }
7011
+ case "intelligence-set": {
7012
+ this.store.setWorkspaceIntelligenceMode(msg.workspace, msg.mode);
7013
+ await this.broadcastManifest();
7014
+ return;
7015
+ }
7016
+ case "intelligence-prepare": {
7017
+ const session = this.store.getSession(msg.sessionId);
7018
+ if (!session) {
7019
+ reply({ type: "error", message: `unknown session ${msg.sessionId}` });
7020
+ return;
7021
+ }
7022
+ let prompt;
7023
+ try {
7024
+ prompt = await this.stageAttachments(msg.prompt, msg.attachments);
7025
+ } catch (e) {
7026
+ reply({ type: "error", message: `attachment staging failed: ${String(e)}` });
7027
+ return;
7028
+ }
7029
+ const built = await buildContext(session.workspace, prompt);
7030
+ this.record(session.id, (seq) => ({
7031
+ type: "run-event",
7032
+ sessionId: session.id,
7033
+ runId: "",
7034
+ seq,
7035
+ event: {
7036
+ type: "intelligence-built",
7037
+ promptChars: prompt.length,
7038
+ contextChars: built?.meta.contextChars ?? 0,
7039
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7040
+ filesSelected: built?.meta.filesSelected ?? 0,
7041
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7042
+ durationMs: built?.meta.durationMs ?? 0,
7043
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7044
+ timedOut: built?.meta.timedOut ?? false,
7045
+ attached: built !== null && built.block !== ""
6707
7046
  }
7047
+ }));
7048
+ if (!built || built.block === "") {
7049
+ this.enqueuePrompt(session, prompt);
7050
+ await this.broadcastManifest();
7051
+ return;
6708
7052
  }
6709
- const q2 = this.queues.get(session.id) ?? [];
6710
- q2.push(prompt);
6711
- this.queues.set(session.id, q2);
6712
- this.pump(session.id);
7053
+ const reviewId = randomUUID();
7054
+ this.pendingIntelligence.set(reviewId, { sessionId: session.id, prompt, block: built.block });
7055
+ this.record(session.id, (seq) => ({
7056
+ type: "intelligence-review",
7057
+ sessionId: session.id,
7058
+ seq,
7059
+ reviewId,
7060
+ originalPrompt: prompt,
7061
+ block: built.block,
7062
+ filesSelected: built.meta.filesSelected
7063
+ }));
7064
+ return;
7065
+ }
7066
+ case "intelligence-response": {
7067
+ const pending = this.pendingIntelligence.get(msg.reviewId);
7068
+ if (!pending || pending.sessionId !== msg.sessionId) return;
7069
+ this.pendingIntelligence.delete(msg.reviewId);
7070
+ const session = this.store.getSession(msg.sessionId);
7071
+ if (!session) return;
7072
+ this.record(session.id, (seq) => ({
7073
+ type: "intelligence-resolved",
7074
+ sessionId: session.id,
7075
+ seq,
7076
+ reviewId: msg.reviewId,
7077
+ useContext: msg.useContext
7078
+ }));
7079
+ this.enqueuePrompt(session, pending.prompt, msg.useContext ? pending.block : void 0);
6713
7080
  await this.broadcastManifest();
6714
7081
  return;
6715
7082
  }
@@ -6779,10 +7146,7 @@ var SessionManager = class {
6779
7146
  seq,
6780
7147
  event: { type: "handover-accepted", id: msg.handoverId, fromRunnerId, toRunnerId: pending.toRunnerId }
6781
7148
  }));
6782
- const q2 = this.queues.get(session.id) ?? [];
6783
- q2.push(prompt);
6784
- this.queues.set(session.id, q2);
6785
- this.pump(session.id);
7149
+ this.enqueuePrompt(session, prompt);
6786
7150
  await this.broadcastManifest();
6787
7151
  return;
6788
7152
  }
@@ -6864,6 +7228,36 @@ var SessionManager = class {
6864
7228
  await this.recheckWolCapability();
6865
7229
  reply(this.hello());
6866
7230
  return;
7231
+ case "runner-install": {
7232
+ const runner = this.runners.get(msg.runnerId);
7233
+ if (!runner?.installCommand) {
7234
+ reply({
7235
+ type: "runner-install-response",
7236
+ rpcId: msg.rpcId,
7237
+ ok: false,
7238
+ message: `No one-tap install available for "${msg.runnerId}".`
7239
+ });
7240
+ return;
7241
+ }
7242
+ try {
7243
+ const { stderr } = await execFileAsync(runner.installCommand.command, runner.installCommand.args, {
7244
+ timeout: 18e4
7245
+ });
7246
+ this.runnerAvailability.set(runner.id, await runner.detect());
7247
+ reply({
7248
+ type: "runner-install-response",
7249
+ rpcId: msg.rpcId,
7250
+ ok: true,
7251
+ message: stderr.trim() || "Installed."
7252
+ });
7253
+ await this.broadcastManifest();
7254
+ } catch (e) {
7255
+ const err = e;
7256
+ const detail = (err.stderr?.trim() || err.message || String(e)).slice(0, 500);
7257
+ reply({ type: "runner-install-response", rpcId: msg.rpcId, ok: false, message: detail });
7258
+ }
7259
+ return;
7260
+ }
6867
7261
  }
6868
7262
  }
6869
7263
  /** The daemon's own relay connection dropped — logged durably so a phone
@@ -6886,6 +7280,37 @@ var SessionManager = class {
6886
7280
  event: { type: "status-update", text }
6887
7281
  }));
6888
7282
  }
7283
+ /** Offhand Intelligence Phase 5 (Execution Plan 8) — the agent itself
7284
+ * calls this via the offhand_context MCP tool, mid-run, instead of a
7285
+ * human reviewing on the phone first. Same buildContext() mechanism and
7286
+ * telemetry as the mobile review path (Phase 2); the tool's own response
7287
+ * carries the same "may be incomplete, keep searching" framing so the
7288
+ * agent's discovery is supplemented, never restricted. */
7289
+ async buildContextFor(sessionId, query) {
7290
+ const session = this.store.getSession(sessionId);
7291
+ if (!session) return null;
7292
+ const built = await buildContext(session.workspace, query);
7293
+ this.record(sessionId, (seq) => ({
7294
+ type: "run-event",
7295
+ sessionId,
7296
+ runId: "",
7297
+ seq,
7298
+ event: {
7299
+ type: "intelligence-built",
7300
+ promptChars: query.length,
7301
+ contextChars: built?.meta.contextChars ?? 0,
7302
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7303
+ filesSelected: built?.meta.filesSelected ?? 0,
7304
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7305
+ durationMs: built?.meta.durationMs ?? 0,
7306
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7307
+ timedOut: built?.meta.timedOut ?? false,
7308
+ attached: built !== null && built.block !== ""
7309
+ }
7310
+ }));
7311
+ if (!built || built.block === "") return null;
7312
+ return { block: built.block, filesSelected: built.meta.filesSelected };
7313
+ }
6889
7314
  recordDeviceReconnected(offlineForMs) {
6890
7315
  this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({
6891
7316
  type: "device-reconnected",
@@ -6913,13 +7338,40 @@ var SessionManager = class {
6913
7338
  pump(sessionId) {
6914
7339
  if (this.active.has(sessionId)) return;
6915
7340
  const q2 = this.queues.get(sessionId);
6916
- const prompt = q2?.shift();
6917
- if (!prompt) return;
7341
+ const item = q2?.shift();
7342
+ if (!item) return;
6918
7343
  const session = this.store.getSession(sessionId);
6919
7344
  if (!session) return;
6920
- void this.runOne(session, prompt);
7345
+ void this.runOne(session, item.prompt, item.block);
7346
+ }
7347
+ /** Appends staged-attachment file paths to a prompt — shared by `prompt`
7348
+ * and `intelligence-prepare` so the two paths can't drift. Throws on
7349
+ * fetch/write failure; callers turn that into an `error` reply. */
7350
+ async stageAttachments(prompt, attachments) {
7351
+ if (!attachments?.length || !this.attachmentFetcher) return prompt;
7352
+ let out = prompt + "\n\nAttached files (read them from disk):";
7353
+ for (const a2 of attachments) {
7354
+ const bytes = await this.attachmentFetcher(a2.blobId);
7355
+ const dir = join6(tmpdir(), "offhand-attachments");
7356
+ mkdirSync2(dir, { recursive: true });
7357
+ const path = join6(dir, `${a2.blobId.slice(0, 8)}-${basename3(a2.name)}`);
7358
+ writeFileSync2(path, bytes);
7359
+ out += `
7360
+ - ${path} (${a2.mime})`;
7361
+ }
7362
+ return out;
7363
+ }
7364
+ /** Pushes a prompt onto a session's queue and pumps it — the one place
7365
+ * every prompt (plain, post-review, or a translated handover packet)
7366
+ * enters execution, so none of those callers duplicate queue/pump/
7367
+ * broadcast bookkeeping. */
7368
+ enqueuePrompt(session, prompt, block) {
7369
+ const q2 = this.queues.get(session.id) ?? [];
7370
+ q2.push(block !== void 0 ? { prompt, block } : { prompt });
7371
+ this.queues.set(session.id, q2);
7372
+ this.pump(session.id);
6921
7373
  }
6922
- async runOne(session, prompt) {
7374
+ async runOne(session, prompt, precomputedBlock) {
6923
7375
  const runner = this.runners.get(session.runnerId);
6924
7376
  if (!runner || !(this.runnerAvailability.get(session.runnerId) ?? false)) {
6925
7377
  this.record(session.id, (seq) => ({
@@ -6939,9 +7391,36 @@ var SessionManager = class {
6939
7391
  let limitHitEmitted = false;
6940
7392
  let budgetWarned = false;
6941
7393
  const hasStatusTool = runner.id === "claude-code" && session.permissionMode !== "bypass";
6942
- const sentPrompt = session.conversationId ? prompt : `${MOBILE_SESSION_PREAMBLE_BASE}${hasStatusTool ? MOBILE_SESSION_PREAMBLE_STATUS_TOOL : ""}
7394
+ const preambled = session.conversationId ? prompt : `${MOBILE_SESSION_PREAMBLE_BASE}${hasStatusTool ? MOBILE_SESSION_PREAMBLE_STATUS_TOOL : ""}
6943
7395
 
6944
7396
  ${prompt}`;
7397
+ let sentPrompt = preambled;
7398
+ if (precomputedBlock !== void 0) {
7399
+ sentPrompt = preambled.replace(prompt, precomputedBlock);
7400
+ } else if (process.env.OFFHAND_INTEL_DEBUG === "1") {
7401
+ const built = await buildContext(session.workspace, prompt);
7402
+ this.record(session.id, (seq) => ({
7403
+ type: "run-event",
7404
+ sessionId: session.id,
7405
+ runId,
7406
+ seq,
7407
+ event: {
7408
+ type: "intelligence-built",
7409
+ promptChars: prompt.length,
7410
+ contextChars: built?.meta.contextChars ?? 0,
7411
+ filesConsidered: built?.meta.filesConsidered ?? 0,
7412
+ filesSelected: built?.meta.filesSelected ?? 0,
7413
+ retrievalOpsRun: built?.meta.retrievalOpsRun ?? 0,
7414
+ durationMs: built?.meta.durationMs ?? 0,
7415
+ ripgrepAvailable: built?.meta.ripgrepAvailable ?? false,
7416
+ timedOut: built?.meta.timedOut ?? false,
7417
+ attached: built !== null && built.block !== ""
7418
+ }
7419
+ }));
7420
+ if (built && built.block !== "") {
7421
+ sentPrompt = preambled.replace(prompt, built.block);
7422
+ }
7423
+ }
6945
7424
  const handle = runner.start(
6946
7425
  {
6947
7426
  runId,
@@ -7098,7 +7577,7 @@ function listFolders(path) {
7098
7577
  const full = join6(current, entry.name);
7099
7578
  return { name: entry.name, path: full, isGit: existsSync7(join6(full, ".git")) };
7100
7579
  }).sort((a2, b2) => Number(b2.isGit) - Number(a2.isGit) || a2.name.localeCompare(b2.name)).slice(0, 200);
7101
- return { path: current, parent: isRoot(current) ? null : dirname2(current), dirs };
7580
+ return { path: current, parent: isRoot(current) ? null : dirname3(current), dirs };
7102
7581
  }
7103
7582
  function driveRoots() {
7104
7583
  if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync7("/.git") }];
@@ -7200,6 +7679,10 @@ var Store = class {
7200
7679
  this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_minutes_cap INTEGER`);
7201
7680
  } catch {
7202
7681
  }
7682
+ try {
7683
+ this.db.exec(`ALTER TABLE workspaces ADD COLUMN intelligence_mode TEXT NOT NULL DEFAULT 'off'`);
7684
+ } catch {
7685
+ }
7203
7686
  }
7204
7687
  // ---- sessions -------------------------------------------------------------
7205
7688
  createSession(workspace, runnerId, model, label) {
@@ -7342,12 +7825,16 @@ var Store = class {
7342
7825
  path: r2.path,
7343
7826
  label: r2.label,
7344
7827
  devUrl: r2.dev_url ?? null,
7345
- policy: r2.policy ?? "balanced"
7828
+ policy: r2.policy ?? "balanced",
7829
+ intelligenceMode: r2.intelligence_mode ?? "off"
7346
7830
  }));
7347
7831
  }
7348
7832
  setWorkspacePolicy(path, policy) {
7349
7833
  this.db.prepare(`UPDATE workspaces SET policy = ? WHERE path = ?`).run(policy, path);
7350
7834
  }
7835
+ setWorkspaceIntelligenceMode(path, mode) {
7836
+ this.db.prepare(`UPDATE workspaces SET intelligence_mode = ? WHERE path = ?`).run(mode, path);
7837
+ }
7351
7838
  // ---- device capability (Execution Plan 7) ----------------------------------
7352
7839
  getWolCapability() {
7353
7840
  const row = this.db.prepare(`SELECT * FROM device WHERE id = 1`).get();
@@ -11585,6 +12072,26 @@ var RunEventSchema = external_exports.discriminatedUnion("type", [
11585
12072
  external_exports.object({
11586
12073
  type: external_exports.literal("status-update"),
11587
12074
  text: external_exports.string()
12075
+ }),
12076
+ /** Telemetry for Offhand Intelligence's Phase 1 (Execution Plan 8) — one
12077
+ * record per run where the deterministic context-injection step actually
12078
+ * ran, so Intelligence-ON vs Intelligence-OFF is eventually a real
12079
+ * measurement, not a guess. Never carries the context block's own text —
12080
+ * just the shape of what happened — reusing the existing event-log spine
12081
+ * rather than a parallel telemetry system. */
12082
+ external_exports.object({
12083
+ type: external_exports.literal("intelligence-built"),
12084
+ promptChars: external_exports.number().int(),
12085
+ contextChars: external_exports.number().int(),
12086
+ filesConsidered: external_exports.number().int(),
12087
+ filesSelected: external_exports.number().int(),
12088
+ retrievalOpsRun: external_exports.number().int(),
12089
+ durationMs: external_exports.number().int(),
12090
+ ripgrepAvailable: external_exports.boolean(),
12091
+ timedOut: external_exports.boolean(),
12092
+ /** Whether the block was actually attached to the run (false when
12093
+ * buildContext found nothing worth attaching, or timed out). */
12094
+ attached: external_exports.boolean()
11588
12095
  })
11589
12096
  ]);
11590
12097
  var PermissionModeSchema = external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]);
@@ -11623,9 +12130,18 @@ var RunnerInfoSchema = external_exports.object({
11623
12130
  /** Thinking-budget tiers this runner actually honors (Execution Plan 3) —
11624
12131
  * undefined means the phone should hide the effort control entirely for
11625
12132
  * this runner, not show one that silently does nothing. */
11626
- effortTiers: external_exports.array(external_exports.enum(["low", "medium", "high", "max"])).optional()
12133
+ effortTiers: external_exports.array(external_exports.enum(["low", "medium", "high", "max"])).optional(),
12134
+ /** Display string for the vendor's own install command (e.g. "npm install
12135
+ * -g @anthropic-ai/claude-code") — present only when the daemon can run
12136
+ * it unattended via `runner-install`. */
12137
+ installCommand: external_exports.string().optional(),
12138
+ /** Where to send the user instead, when there's no safe one-tap install
12139
+ * (a piped shell-script installer, a GUI app, etc.) — never set alongside
12140
+ * installCommand. */
12141
+ installUrl: external_exports.string().optional()
11627
12142
  });
11628
12143
  var ApprovalPolicySchema = external_exports.enum(["paranoid", "balanced", "trusting"]);
12144
+ var IntelligenceModeSchema = external_exports.enum(["off", "automatic", "manual"]);
11629
12145
  var WorkspaceInfoSchema = external_exports.object({
11630
12146
  path: external_exports.string(),
11631
12147
  label: external_exports.string(),
@@ -11634,7 +12150,8 @@ var WorkspaceInfoSchema = external_exports.object({
11634
12150
  devUrl: external_exports.string().optional(),
11635
12151
  /** Approval policy: paranoid = ask everything the CLI would ask; balanced =
11636
12152
  * same (default); trusting = auto-approve low-risk, ask only high-risk. */
11637
- policy: ApprovalPolicySchema
12153
+ policy: ApprovalPolicySchema,
12154
+ intelligenceMode: IntelligenceModeSchema
11638
12155
  });
11639
12156
  var SessionInfoSchema = external_exports.object({
11640
12157
  id: external_exports.string(),
@@ -11749,6 +12266,29 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11749
12266
  workspace: external_exports.string(),
11750
12267
  policy: ApprovalPolicySchema
11751
12268
  }),
12269
+ /** Set a workspace's Offhand Intelligence mode (Execution Plan 8). */
12270
+ external_exports.object({
12271
+ type: external_exports.literal("intelligence-set"),
12272
+ workspace: external_exports.string(),
12273
+ mode: IntelligenceModeSchema
12274
+ }),
12275
+ /** Like `prompt`, but routes through Offhand Intelligence first — the
12276
+ * daemon replies with `intelligence-review` instead of queueing
12277
+ * immediately, and queueing happens only after `intelligence-response`. */
12278
+ external_exports.object({
12279
+ type: external_exports.literal("intelligence-prepare"),
12280
+ sessionId: external_exports.string(),
12281
+ prompt: external_exports.string().min(1),
12282
+ attachments: external_exports.array(external_exports.object({ blobId: external_exports.string(), name: external_exports.string(), mime: external_exports.string() })).optional()
12283
+ }),
12284
+ /** Resolves a pending `intelligence-review` — useContext=false sends the
12285
+ * plain original prompt, never a dead end that discards it. */
12286
+ external_exports.object({
12287
+ type: external_exports.literal("intelligence-response"),
12288
+ sessionId: external_exports.string(),
12289
+ reviewId: external_exports.string(),
12290
+ useContext: external_exports.boolean()
12291
+ }),
11752
12292
  // History RPC (request/response by rpcId; responses are not in the seq log)
11753
12293
  external_exports.object({
11754
12294
  type: external_exports.literal("history-request"),
@@ -11785,7 +12325,12 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11785
12325
  /** Re-run the Wake-on-LAN/WiFi capability probe (Execution Plan 7) — an
11786
12326
  * explicit "check again" action, since drivers/BIOS settings/network type
11787
12327
  * can change after first setup. */
11788
- external_exports.object({ type: external_exports.literal("wol-recheck") })
12328
+ external_exports.object({ type: external_exports.literal("wol-recheck") }),
12329
+ /** One-tap install: run a runner's own vendor install command on the
12330
+ * daemon's machine. Only valid for a runner whose manifest entry carries
12331
+ * `installCommand` — the daemon refuses (with a reply, not a hang) for
12332
+ * any other runnerId. */
12333
+ external_exports.object({ type: external_exports.literal("runner-install"), rpcId: external_exports.string(), runnerId: external_exports.string() })
11789
12334
  ]);
11790
12335
  var ReceiptSchema = external_exports.object({
11791
12336
  runId: external_exports.string(),
@@ -11854,6 +12399,26 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11854
12399
  atMs: external_exports.number().int(),
11855
12400
  offlineForMs: external_exports.number().int().nonnegative()
11856
12401
  }),
12402
+ /** Offhand Intelligence (Execution Plan 8) proposed a context block for a
12403
+ * prompt not yet queued to any runner — not a RunEvent, since no run
12404
+ * exists at this point. `filesSelected` is a quick summary for the sheet
12405
+ * header; the full reasoning lives in `block` itself. */
12406
+ external_exports.object({
12407
+ type: external_exports.literal("intelligence-review"),
12408
+ sessionId: external_exports.string(),
12409
+ seq: external_exports.number().int(),
12410
+ reviewId: external_exports.string(),
12411
+ originalPrompt: external_exports.string(),
12412
+ block: external_exports.string(),
12413
+ filesSelected: external_exports.number().int()
12414
+ }),
12415
+ external_exports.object({
12416
+ type: external_exports.literal("intelligence-resolved"),
12417
+ sessionId: external_exports.string(),
12418
+ seq: external_exports.number().int(),
12419
+ reviewId: external_exports.string(),
12420
+ useContext: external_exports.boolean()
12421
+ }),
11857
12422
  // RPC responses (not seq-logged)
11858
12423
  external_exports.object({
11859
12424
  type: external_exports.literal("history-response"),
@@ -11886,6 +12451,15 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11886
12451
  * routed back to whoever asked, with the listing unchanged either way. */
11887
12452
  error: external_exports.string().optional()
11888
12453
  }),
12454
+ /** Reply to `runner-install` — always sent, success or failure, never a
12455
+ * silent hang. On failure `message` carries the real error (e.g. npm's
12456
+ * own stderr tail), not a generic "install failed". */
12457
+ external_exports.object({
12458
+ type: external_exports.literal("runner-install-response"),
12459
+ rpcId: external_exports.string(),
12460
+ ok: external_exports.boolean(),
12461
+ message: external_exports.string()
12462
+ }),
11889
12463
  external_exports.object({ type: external_exports.literal("error"), message: external_exports.string() })
11890
12464
  ]);
11891
12465
  var parseClientMessage = (raw) => ClientMessageSchema.parse(JSON.parse(raw));
@@ -42550,6 +43124,20 @@ var LocalSessionServer = class {
42550
43124
  }
42551
43125
  return;
42552
43126
  }
43127
+ if (req.method === "POST" && req.url === "/context") {
43128
+ try {
43129
+ const chunks = [];
43130
+ for await (const c2 of req) chunks.push(c2);
43131
+ const body = JSON.parse(Buffer.concat(chunks).toString());
43132
+ const result = typeof body.sessionId === "string" && typeof body.query === "string" && body.query.trim() ? await this.manager.buildContextFor(body.sessionId, body.query.trim()) : null;
43133
+ res.writeHead(200, { "content-type": "application/json" });
43134
+ res.end(JSON.stringify(result ?? { block: null, filesSelected: 0 }));
43135
+ } catch (e) {
43136
+ res.writeHead(400, { "content-type": "application/json" });
43137
+ res.end(JSON.stringify({ block: null, filesSelected: 0, message: `bad context request: ${String(e)}` }));
43138
+ }
43139
+ return;
43140
+ }
42553
43141
  res.writeHead(404);
42554
43142
  res.end();
42555
43143
  }
@@ -42926,6 +43514,7 @@ var ApprovalBroker = class {
42926
43514
  }
42927
43515
  };
42928
43516
  function classifyRisk(toolName, input) {
43517
+ if (toolName === "offhand_send_file") return "high";
42929
43518
  const i2 = input ?? {};
42930
43519
  const text = [toolName, i2.command, i2.file_path, i2.path, i2.filepath].filter((x2) => typeof x2 === "string").join(" ");
42931
43520
  return /\b(rm|del|rmdir|rd|format|mkfs|shutdown|reboot|kill|drop\s+table|truncate|git\s+push\s+--force|--hard)\b/i.test(
@@ -43021,12 +43610,12 @@ async function downloadArtifact(relayUrl2, sessionId, blobId, keys) {
43021
43610
 
43022
43611
  // ../daemon/src/autostart.ts
43023
43612
  import { homedir as homedir8 } from "node:os";
43024
- import { join as join10, resolve as resolve4, dirname as dirname3 } from "node:path";
43613
+ import { join as join10, resolve as resolve4, dirname as dirname4 } from "node:path";
43025
43614
  import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, rmSync } from "node:fs";
43026
- import { fileURLToPath as fileURLToPath3 } from "node:url";
43615
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
43027
43616
  import { spawnSync } from "node:child_process";
43028
43617
  import { tmpdir as tmpdir3 } from "node:os";
43029
- var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
43618
+ var __dirname2 = dirname4(fileURLToPath4(import.meta.url));
43030
43619
  var SHORTCUT_NAME = "OffhandDaemon.lnk";
43031
43620
  var OFFHAND_HOME = process.env.OFFHAND_HOME ?? join10(homedir8(), ".offhand");
43032
43621
  function getStartupDir() {
@@ -43166,10 +43755,11 @@ for (const w2 of wsArgs) {
43166
43755
  if (store.listWorkspaces().length === 0) store.upsertWorkspace(process.cwd(), devUrl);
43167
43756
  var broker = new ApprovalBroker(approvalTimeoutMs);
43168
43757
  var approvalUrl = `http://127.0.0.1:${port}/approval`;
43758
+ var statusUrl = approvalUrl.replace(/\/approval$/, "/status");
43169
43759
  var runners = [
43170
43760
  new ClaudeCodeRunner(broker, approvalUrl),
43171
43761
  new CopilotCliRunner(),
43172
- new OpenCodeRunner(broker),
43762
+ new OpenCodeRunner(broker, statusUrl),
43173
43763
  new CodexCliRunner(),
43174
43764
  new CursorAgentRunner(),
43175
43765
  new GeminiCliRunner()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "offhands",
3
- "version": "0.1.7",
4
- "description": "Phone coding-agent relay. Daemon runs on your laptop, PWA on your phone. E2E encrypted.",
3
+ "version": "0.1.10",
4
+ "description": "Phone → coding-agent relay. Daemon runs on your laptop, PWA on your phone. E2E encrypted.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {