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 +146 -7
- package/dist/daemon.mjs +658 -68
- package/package.json +2 -2
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
|
|
5
|
-
// alongside it,
|
|
6
|
-
// server exposes, not just the one
|
|
7
|
-
// is forwarded to a daemon-local HTTP
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
5834
|
-
* broker-driven runs, and wait for it to accept
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
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
|
-
|
|
5848
|
-
|
|
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.
|
|
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
|
|
5921
|
+
return await starting;
|
|
5859
5922
|
} finally {
|
|
5860
|
-
this.serverStarting
|
|
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
|
|
5957
|
-
import { fileURLToPath as
|
|
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(
|
|
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
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
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
|
|
6710
|
-
|
|
6711
|
-
this.
|
|
6712
|
-
|
|
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
|
-
|
|
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
|
|
6917
|
-
if (!
|
|
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
|
|
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 :
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
4
|
-
"description": "Phone
|
|
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": {
|