offhands 0.1.5 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/approval-mcp.mjs +189 -10
- package/dist/daemon.mjs +1567 -181
- package/package.json +1 -1
- package/README.internal.md +0 -49
package/approval-mcp.mjs
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
// offhand
|
|
2
|
-
// Newline-delimited JSON-RPC 2.0 over stdio.
|
|
3
|
-
// `approval_prompt`, named via --permission-prompt-tool
|
|
4
|
-
// mcp__offhand__approval_prompt
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// offhand MCP toolkit (plain JS, zero deps — spawned BY the agent CLI).
|
|
2
|
+
// Newline-delimited JSON-RPC 2.0 over stdio. Started life as a single tool
|
|
3
|
+
// (`approval_prompt`, still the one named via --permission-prompt-tool
|
|
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.
|
|
7
12
|
|
|
8
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';
|
|
9
17
|
|
|
10
18
|
const APPROVAL_URL = process.env.OFFHAND_APPROVAL_URL ?? 'http://127.0.0.1:4317/approval';
|
|
19
|
+
const STATUS_URL = process.env.OFFHAND_STATUS_URL ?? 'http://127.0.0.1:4317/status';
|
|
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;
|
|
11
33
|
|
|
12
|
-
const
|
|
34
|
+
const APPROVAL_TOOL = {
|
|
13
35
|
name: 'approval_prompt',
|
|
14
36
|
description: 'Forwards a permission request to the offhand phone client and waits for the verdict.',
|
|
15
37
|
inputSchema: {
|
|
@@ -23,6 +45,92 @@ const TOOL = {
|
|
|
23
45
|
},
|
|
24
46
|
};
|
|
25
47
|
|
|
48
|
+
const STATUS_TOOL = {
|
|
49
|
+
name: 'offhand_status_update',
|
|
50
|
+
description:
|
|
51
|
+
"Sends a short plain-English progress note to the person's phone, outside the normal response — use it at meaningful checkpoints (starting a long step, hitting a snag, finishing a phase) so they see progress without waiting for the whole run to finish.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
text: { type: 'string', description: 'A short, human-readable status note.' },
|
|
56
|
+
},
|
|
57
|
+
required: ['text'],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
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
|
+
}
|
|
133
|
+
|
|
26
134
|
function send(msg) {
|
|
27
135
|
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
28
136
|
}
|
|
@@ -45,6 +153,24 @@ async function callDaemon(args) {
|
|
|
45
153
|
return res.json();
|
|
46
154
|
}
|
|
47
155
|
|
|
156
|
+
async function sendStatus(text) {
|
|
157
|
+
await fetch(STATUS_URL, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ sessionId: SESSION_ID, text }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
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
|
+
|
|
48
174
|
const rl = createInterface({ input: process.stdin });
|
|
49
175
|
rl.on('line', (line) => {
|
|
50
176
|
if (!line.trim()) return;
|
|
@@ -66,14 +192,67 @@ async function handle(msg) {
|
|
|
66
192
|
reply(id, {
|
|
67
193
|
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
68
194
|
capabilities: { tools: {} },
|
|
69
|
-
serverInfo: { name: 'offhand
|
|
195
|
+
serverInfo: { name: 'offhand', version: '0.1.0' },
|
|
70
196
|
});
|
|
71
197
|
return;
|
|
72
198
|
case 'tools/list':
|
|
73
|
-
reply(id, { tools:
|
|
199
|
+
reply(id, { tools: TOOLS });
|
|
74
200
|
return;
|
|
75
201
|
case 'tools/call': {
|
|
76
|
-
if (params?.name
|
|
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
|
+
}
|
|
220
|
+
if (params?.name === STATUS_TOOL.name) {
|
|
221
|
+
const text = String(params.arguments?.text ?? '').trim();
|
|
222
|
+
try {
|
|
223
|
+
if (text) await sendStatus(text);
|
|
224
|
+
reply(id, { content: [{ type: 'text', text: text ? 'sent' : 'nothing to send (empty text)' }] });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// A dropped status note is never fatal to the run — it's a nice-
|
|
227
|
+
// to-have channel, not a control one like approvals.
|
|
228
|
+
reply(id, { content: [{ type: 'text', text: `status channel error: ${e?.message ?? e}` }] });
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
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
|
+
}
|
|
255
|
+
if (params?.name !== APPROVAL_TOOL.name) {
|
|
77
256
|
replyError(id, -32602, `unknown tool: ${params?.name}`);
|
|
78
257
|
return;
|
|
79
258
|
}
|