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 +146 -7
- package/dist/daemon.mjs +654 -66
- package/package.json +1 -1
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") {
|
|
@@ -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
|
|
5834
|
-
* broker-driven runs, and wait for it to accept
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
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
|
-
|
|
5848
|
-
|
|
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.
|
|
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
|
|
5919
|
+
return await starting;
|
|
5859
5920
|
} finally {
|
|
5860
|
-
this.serverStarting
|
|
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
|
|
5957
|
-
import { fileURLToPath as
|
|
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(
|
|
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
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
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
|
|
6710
|
-
|
|
6711
|
-
this.
|
|
6712
|
-
|
|
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
|
-
|
|
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
|
|
6917
|
-
if (!
|
|
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
|
|
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 :
|
|
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
|
|
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
|
|
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 =
|
|
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()
|