offhands 0.1.7 → 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/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") {
@@ -5606,7 +5660,10 @@ var OpenCodeRunner = class {
5606
5660
  child = spawn4(cmd[0], args2, {
5607
5661
  cwd: run.workspace,
5608
5662
  stdio: ["ignore", "pipe", "pipe"],
5609
- 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) }
5610
5667
  });
5611
5668
  } catch (e) {
5612
5669
  queue.push({ type: "error", message: `failed to spawn opencode: ${String(e)}` });
@@ -5680,7 +5737,7 @@ stderr: ${stderrTail}` : ""}`
5680
5737
  };
5681
5738
  void (async () => {
5682
5739
  try {
5683
- port2 = await this.ensureServer();
5740
+ port2 = await this.ensureServer(run.sessionId);
5684
5741
  } catch (e) {
5685
5742
  emit([{ type: "error", message: `failed to start opencode server: ${String(e)}` }]);
5686
5743
  cleanup();
@@ -5830,34 +5887,38 @@ stderr: ${stderrTail}` : ""}`
5830
5887
  } catch {
5831
5888
  }
5832
5889
  }
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 () => {
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 () => {
5839
5901
  const cmd = this.resolveCommand();
5840
5902
  if (!cmd) throw new Error("opencode CLI not found");
5841
5903
  const port2 = await findFreePort();
5842
5904
  const proc = spawn4(cmd[0], [...cmd.slice(1), "serve", "--port", String(port2), "--hostname", "127.0.0.1"], {
5843
5905
  stdio: ["ignore", "pipe", "pipe"],
5844
- shell: process.platform === "win32"
5906
+ shell: process.platform === "win32",
5907
+ env: { ...process.env, OPENCODE_CONFIG_CONTENT: statusOnlyMcpConfigContent(this.statusUrl, sessionId) }
5845
5908
  });
5846
5909
  proc.on("exit", () => {
5847
- if (this.serverProcess === proc) {
5848
- this.serverProcess = null;
5849
- this.serverPort = null;
5850
- }
5910
+ const cur = this.servers.get(sessionId);
5911
+ if (cur?.process === proc) this.servers.delete(sessionId);
5851
5912
  });
5852
5913
  await waitForServerReady(port2);
5853
- this.serverProcess = proc;
5854
- this.serverPort = port2;
5914
+ this.servers.set(sessionId, { port: port2, process: proc });
5855
5915
  return port2;
5856
5916
  })();
5917
+ this.serverStarting.set(sessionId, starting);
5857
5918
  try {
5858
- return await this.serverStarting;
5919
+ return await starting;
5859
5920
  } finally {
5860
- this.serverStarting = null;
5921
+ this.serverStarting.delete(sessionId);
5861
5922
  }
5862
5923
  }
5863
5924
  };
@@ -5953,8 +6014,10 @@ function sleep(ms) {
5953
6014
  import { randomUUID } from "node:crypto";
5954
6015
  import { hostname, platform as platform2, tmpdir } from "node:os";
5955
6016
  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";
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";
5958
6021
 
5959
6022
  // ../daemon/src/receipts.ts
5960
6023
  import { execFile } from "node:child_process";
@@ -5976,6 +6039,7 @@ async function workspaceInfo(row) {
5976
6039
  path: row.path,
5977
6040
  label: row.label,
5978
6041
  policy: row.policy,
6042
+ intelligenceMode: row.intelligenceMode,
5979
6043
  ...branch ? { gitBranch: branch } : {},
5980
6044
  ...status !== null ? { dirty: status.trim() !== "" } : {},
5981
6045
  ...row.devUrl ? { devUrl: row.devUrl } : {}
@@ -6560,10 +6624,247 @@ async function probeWolCapability() {
6560
6624
  }
6561
6625
  }
6562
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
+
6563
6864
  // ../daemon/src/session-manager.ts
6564
6865
  function resolveDaemonVersion() {
6565
6866
  try {
6566
- const pkgPath = join6(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
6867
+ const pkgPath = join6(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
6567
6868
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
6568
6869
  return pkg.version ?? "0.0.0";
6569
6870
  } catch {
@@ -6571,6 +6872,7 @@ function resolveDaemonVersion() {
6571
6872
  }
6572
6873
  }
6573
6874
  var DAEMON_VERSION = resolveDaemonVersion();
6875
+ var execFileAsync = promisify4(execFile4);
6574
6876
  var SYSTEM_LOG_SESSION_ID = "system";
6575
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.";
6576
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.";
@@ -6586,6 +6888,9 @@ var SessionManager = class {
6586
6888
  // sessionId → workspace
6587
6889
  queues = /* @__PURE__ */ new Map();
6588
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();
6589
6894
  runners = /* @__PURE__ */ new Map();
6590
6895
  runnerAvailability = /* @__PURE__ */ new Map();
6591
6896
  /** handoverId → what was proposed, so `handover-response` (which only
@@ -6656,7 +6961,9 @@ var SessionManager = class {
6656
6961
  ...r2.loggedIn ? { loggedIn: r2.loggedIn() } : {},
6657
6962
  models: r2.models,
6658
6963
  supportsApprovals: r2.supportsApprovals,
6659
- ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {}
6964
+ ...r2.effortTiers ? { effortTiers: [...r2.effortTiers] } : {},
6965
+ ...r2.installCommand ? { installCommand: `${r2.installCommand.command} ${r2.installCommand.args.join(" ")}` } : {},
6966
+ ...r2.installUrl ? { installUrl: r2.installUrl } : {}
6660
6967
  }));
6661
6968
  const workspaces = await Promise.all(
6662
6969
  this.store.listWorkspaces().map((w2) => workspaceInfo(w2))
@@ -6688,28 +6995,86 @@ var SessionManager = class {
6688
6995
  reply({ type: "error", message: `unknown session ${msg.sessionId}` });
6689
6996
  return;
6690
6997
  }
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;
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 !== ""
6707
7044
  }
7045
+ }));
7046
+ if (!built || built.block === "") {
7047
+ this.enqueuePrompt(session, prompt);
7048
+ await this.broadcastManifest();
7049
+ return;
6708
7050
  }
6709
- const q2 = this.queues.get(session.id) ?? [];
6710
- q2.push(prompt);
6711
- this.queues.set(session.id, q2);
6712
- 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);
6713
7078
  await this.broadcastManifest();
6714
7079
  return;
6715
7080
  }
@@ -6779,10 +7144,7 @@ var SessionManager = class {
6779
7144
  seq,
6780
7145
  event: { type: "handover-accepted", id: msg.handoverId, fromRunnerId, toRunnerId: pending.toRunnerId }
6781
7146
  }));
6782
- const q2 = this.queues.get(session.id) ?? [];
6783
- q2.push(prompt);
6784
- this.queues.set(session.id, q2);
6785
- this.pump(session.id);
7147
+ this.enqueuePrompt(session, prompt);
6786
7148
  await this.broadcastManifest();
6787
7149
  return;
6788
7150
  }
@@ -6864,6 +7226,36 @@ var SessionManager = class {
6864
7226
  await this.recheckWolCapability();
6865
7227
  reply(this.hello());
6866
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
+ }
6867
7259
  }
6868
7260
  }
6869
7261
  /** The daemon's own relay connection dropped — logged durably so a phone
@@ -6886,6 +7278,37 @@ var SessionManager = class {
6886
7278
  event: { type: "status-update", text }
6887
7279
  }));
6888
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
+ }
6889
7312
  recordDeviceReconnected(offlineForMs) {
6890
7313
  this.record(SYSTEM_LOG_SESSION_ID, (seq) => ({
6891
7314
  type: "device-reconnected",
@@ -6913,13 +7336,40 @@ var SessionManager = class {
6913
7336
  pump(sessionId) {
6914
7337
  if (this.active.has(sessionId)) return;
6915
7338
  const q2 = this.queues.get(sessionId);
6916
- const prompt = q2?.shift();
6917
- if (!prompt) return;
7339
+ const item = q2?.shift();
7340
+ if (!item) return;
6918
7341
  const session = this.store.getSession(sessionId);
6919
7342
  if (!session) return;
6920
- 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);
6921
7371
  }
6922
- async runOne(session, prompt) {
7372
+ async runOne(session, prompt, precomputedBlock) {
6923
7373
  const runner = this.runners.get(session.runnerId);
6924
7374
  if (!runner || !(this.runnerAvailability.get(session.runnerId) ?? false)) {
6925
7375
  this.record(session.id, (seq) => ({
@@ -6939,9 +7389,36 @@ var SessionManager = class {
6939
7389
  let limitHitEmitted = false;
6940
7390
  let budgetWarned = false;
6941
7391
  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 : ""}
7392
+ const preambled = session.conversationId ? prompt : `${MOBILE_SESSION_PREAMBLE_BASE}${hasStatusTool ? MOBILE_SESSION_PREAMBLE_STATUS_TOOL : ""}
6943
7393
 
6944
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
+ }
6945
7422
  const handle = runner.start(
6946
7423
  {
6947
7424
  runId,
@@ -7098,7 +7575,7 @@ function listFolders(path) {
7098
7575
  const full = join6(current, entry.name);
7099
7576
  return { name: entry.name, path: full, isGit: existsSync7(join6(full, ".git")) };
7100
7577
  }).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 };
7578
+ return { path: current, parent: isRoot(current) ? null : dirname3(current), dirs };
7102
7579
  }
7103
7580
  function driveRoots() {
7104
7581
  if (process.platform !== "win32") return [{ name: "/", path: "/", isGit: existsSync7("/.git") }];
@@ -7200,6 +7677,10 @@ var Store = class {
7200
7677
  this.db.exec(`ALTER TABLE sessions ADD COLUMN budget_minutes_cap INTEGER`);
7201
7678
  } catch {
7202
7679
  }
7680
+ try {
7681
+ this.db.exec(`ALTER TABLE workspaces ADD COLUMN intelligence_mode TEXT NOT NULL DEFAULT 'off'`);
7682
+ } catch {
7683
+ }
7203
7684
  }
7204
7685
  // ---- sessions -------------------------------------------------------------
7205
7686
  createSession(workspace, runnerId, model, label) {
@@ -7342,12 +7823,16 @@ var Store = class {
7342
7823
  path: r2.path,
7343
7824
  label: r2.label,
7344
7825
  devUrl: r2.dev_url ?? null,
7345
- policy: r2.policy ?? "balanced"
7826
+ policy: r2.policy ?? "balanced",
7827
+ intelligenceMode: r2.intelligence_mode ?? "off"
7346
7828
  }));
7347
7829
  }
7348
7830
  setWorkspacePolicy(path, policy) {
7349
7831
  this.db.prepare(`UPDATE workspaces SET policy = ? WHERE path = ?`).run(policy, path);
7350
7832
  }
7833
+ setWorkspaceIntelligenceMode(path, mode) {
7834
+ this.db.prepare(`UPDATE workspaces SET intelligence_mode = ? WHERE path = ?`).run(mode, path);
7835
+ }
7351
7836
  // ---- device capability (Execution Plan 7) ----------------------------------
7352
7837
  getWolCapability() {
7353
7838
  const row = this.db.prepare(`SELECT * FROM device WHERE id = 1`).get();
@@ -11585,6 +12070,26 @@ var RunEventSchema = external_exports.discriminatedUnion("type", [
11585
12070
  external_exports.object({
11586
12071
  type: external_exports.literal("status-update"),
11587
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()
11588
12093
  })
11589
12094
  ]);
11590
12095
  var PermissionModeSchema = external_exports.enum(["guarded", "plan", "acceptEdits", "bypass"]);
@@ -11623,9 +12128,18 @@ var RunnerInfoSchema = external_exports.object({
11623
12128
  /** Thinking-budget tiers this runner actually honors (Execution Plan 3) —
11624
12129
  * undefined means the phone should hide the effort control entirely for
11625
12130
  * this runner, not show one that silently does nothing. */
11626
- effortTiers: external_exports.array(external_exports.enum(["low", "medium", "high", "max"])).optional()
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()
11627
12140
  });
11628
12141
  var ApprovalPolicySchema = external_exports.enum(["paranoid", "balanced", "trusting"]);
12142
+ var IntelligenceModeSchema = external_exports.enum(["off", "automatic", "manual"]);
11629
12143
  var WorkspaceInfoSchema = external_exports.object({
11630
12144
  path: external_exports.string(),
11631
12145
  label: external_exports.string(),
@@ -11634,7 +12148,8 @@ var WorkspaceInfoSchema = external_exports.object({
11634
12148
  devUrl: external_exports.string().optional(),
11635
12149
  /** Approval policy: paranoid = ask everything the CLI would ask; balanced =
11636
12150
  * same (default); trusting = auto-approve low-risk, ask only high-risk. */
11637
- policy: ApprovalPolicySchema
12151
+ policy: ApprovalPolicySchema,
12152
+ intelligenceMode: IntelligenceModeSchema
11638
12153
  });
11639
12154
  var SessionInfoSchema = external_exports.object({
11640
12155
  id: external_exports.string(),
@@ -11749,6 +12264,29 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11749
12264
  workspace: external_exports.string(),
11750
12265
  policy: ApprovalPolicySchema
11751
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
+ }),
11752
12290
  // History RPC (request/response by rpcId; responses are not in the seq log)
11753
12291
  external_exports.object({
11754
12292
  type: external_exports.literal("history-request"),
@@ -11785,7 +12323,12 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
11785
12323
  /** Re-run the Wake-on-LAN/WiFi capability probe (Execution Plan 7) — an
11786
12324
  * explicit "check again" action, since drivers/BIOS settings/network type
11787
12325
  * can change after first setup. */
11788
- external_exports.object({ type: external_exports.literal("wol-recheck") })
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() })
11789
12332
  ]);
11790
12333
  var ReceiptSchema = external_exports.object({
11791
12334
  runId: external_exports.string(),
@@ -11854,6 +12397,26 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11854
12397
  atMs: external_exports.number().int(),
11855
12398
  offlineForMs: external_exports.number().int().nonnegative()
11856
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
+ }),
11857
12420
  // RPC responses (not seq-logged)
11858
12421
  external_exports.object({
11859
12422
  type: external_exports.literal("history-response"),
@@ -11886,6 +12449,15 @@ var ServerMessageSchema = external_exports.discriminatedUnion("type", [
11886
12449
  * routed back to whoever asked, with the listing unchanged either way. */
11887
12450
  error: external_exports.string().optional()
11888
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()
12460
+ }),
11889
12461
  external_exports.object({ type: external_exports.literal("error"), message: external_exports.string() })
11890
12462
  ]);
11891
12463
  var parseClientMessage = (raw) => ClientMessageSchema.parse(JSON.parse(raw));
@@ -42550,6 +43122,20 @@ var LocalSessionServer = class {
42550
43122
  }
42551
43123
  return;
42552
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
+ }
42553
43139
  res.writeHead(404);
42554
43140
  res.end();
42555
43141
  }
@@ -42926,6 +43512,7 @@ var ApprovalBroker = class {
42926
43512
  }
42927
43513
  };
42928
43514
  function classifyRisk(toolName, input) {
43515
+ if (toolName === "offhand_send_file") return "high";
42929
43516
  const i2 = input ?? {};
42930
43517
  const text = [toolName, i2.command, i2.file_path, i2.path, i2.filepath].filter((x2) => typeof x2 === "string").join(" ");
42931
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(
@@ -43021,12 +43608,12 @@ async function downloadArtifact(relayUrl2, sessionId, blobId, keys) {
43021
43608
 
43022
43609
  // ../daemon/src/autostart.ts
43023
43610
  import { homedir as homedir8 } from "node:os";
43024
- import { join as join10, resolve as resolve4, dirname as dirname3 } from "node:path";
43611
+ import { join as join10, resolve as resolve4, dirname as dirname4 } from "node:path";
43025
43612
  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";
43613
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
43027
43614
  import { spawnSync } from "node:child_process";
43028
43615
  import { tmpdir as tmpdir3 } from "node:os";
43029
- var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
43616
+ var __dirname2 = dirname4(fileURLToPath4(import.meta.url));
43030
43617
  var SHORTCUT_NAME = "OffhandDaemon.lnk";
43031
43618
  var OFFHAND_HOME = process.env.OFFHAND_HOME ?? join10(homedir8(), ".offhand");
43032
43619
  function getStartupDir() {
@@ -43166,10 +43753,11 @@ for (const w2 of wsArgs) {
43166
43753
  if (store.listWorkspaces().length === 0) store.upsertWorkspace(process.cwd(), devUrl);
43167
43754
  var broker = new ApprovalBroker(approvalTimeoutMs);
43168
43755
  var approvalUrl = `http://127.0.0.1:${port}/approval`;
43756
+ var statusUrl = approvalUrl.replace(/\/approval$/, "/status");
43169
43757
  var runners = [
43170
43758
  new ClaudeCodeRunner(broker, approvalUrl),
43171
43759
  new CopilotCliRunner(),
43172
- new OpenCodeRunner(broker),
43760
+ new OpenCodeRunner(broker, statusUrl),
43173
43761
  new CodexCliRunner(),
43174
43762
  new CursorAgentRunner(),
43175
43763
  new GeminiCliRunner()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offhands",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
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",