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 CHANGED
@@ -1,15 +1,37 @@
1
- // offhand approval MCP server (plain JS, zero deps — spawned BY claude).
2
- // Newline-delimited JSON-RPC 2.0 over stdio. Exposes one tool,
3
- // `approval_prompt`, named via --permission-prompt-tool
4
- // mcp__offhand__approval_prompt. Each call is forwarded to the daemon's
5
- // local HTTP endpoint (OFFHAND_APPROVAL_URL) which long-polls the phone's
6
- // verdict, then this returns allow/deny to claude.
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 TOOL = {
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-approvals', version: '0.0.1' },
195
+ serverInfo: { name: 'offhand', version: '0.1.0' },
70
196
  });
71
197
  return;
72
198
  case 'tools/list':
73
- reply(id, { tools: [TOOL] });
199
+ reply(id, { tools: TOOLS });
74
200
  return;
75
201
  case 'tools/call': {
76
- if (params?.name !== TOOL.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
  }