offhands 0.1.1

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.
@@ -0,0 +1,49 @@
1
+ # offhands — internal README (do not publish)
2
+
3
+ The public npm README is intentionally blank ("Reserved.") until launch —
4
+ founder decision 2026-09-07: stay lowkey, don't hand the idea to copycats.
5
+ This file is the real one; swap it back into README.md at launch.
6
+
7
+ ---
8
+
9
+ Drive your coding agents from your phone. `offhands` runs a small daemon on
10
+ your computer that wraps headless agent CLIs (Claude Code today, GitHub
11
+ Copilot CLI, more coming) and pairs with a phone app over an end-to-end
12
+ encrypted relay — the server only ever sees ciphertext.
13
+
14
+ From your phone you can:
15
+
16
+ - start sessions in any workspace and prompt any installed agent
17
+ - **approve or deny risky actions** with a diff preview before they run
18
+ - answer the agent's questions with real option buttons
19
+ - switch model / permission mode / thinking effort mid-session
20
+ - watch context-window usage and per-session cost
21
+ - get receipts (files changed, diffs, screenshots) for every run
22
+ - **drop files and text** between phone and PC, encrypted both ways
23
+
24
+ ## Quick start
25
+
26
+ ```bash
27
+ cd your-project
28
+ npx offhands
29
+ ```
30
+
31
+ Scan the QR it prints with your phone (or open the web app and type the
32
+ code), match the fingerprint shown on both screens, and you're paired.
33
+
34
+ Requires Node 22.5+ and at least one agent CLI installed.
35
+
36
+ ## Options
37
+
38
+ ```
39
+ offhands [--workspace <path>]... [--dev-url <url>] [--relay <url>]
40
+ [--repair] [--approval-timeout <seconds>] [--local]
41
+ offhands drop <file> # send a file to your paired phone
42
+ ```
43
+
44
+ ## Security
45
+
46
+ - X25519 key exchange at pairing; all traffic is libsodium secretbox
47
+ - pairing codes are one-time; both screens show a fingerprint to verify
48
+ - the relay stores nothing and can read nothing
49
+ - approvals are enforced in the daemon, not in the UI
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # offhands
2
+
3
+ Reserved.
@@ -0,0 +1,98 @@
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.
7
+
8
+ import { createInterface } from 'node:readline';
9
+
10
+ const APPROVAL_URL = process.env.OFFHAND_APPROVAL_URL ?? 'http://127.0.0.1:4317/approval';
11
+
12
+ const TOOL = {
13
+ name: 'approval_prompt',
14
+ description: 'Forwards a permission request to the offhand phone client and waits for the verdict.',
15
+ inputSchema: {
16
+ type: 'object',
17
+ properties: {
18
+ tool_name: { type: 'string' },
19
+ input: { type: 'object' },
20
+ tool_use_id: { type: 'string' },
21
+ },
22
+ required: ['tool_name', 'input'],
23
+ },
24
+ };
25
+
26
+ function send(msg) {
27
+ process.stdout.write(JSON.stringify(msg) + '\n');
28
+ }
29
+
30
+ function reply(id, result) {
31
+ send({ jsonrpc: '2.0', id, result });
32
+ }
33
+
34
+ function replyError(id, code, message) {
35
+ send({ jsonrpc: '2.0', id, error: { code, message } });
36
+ }
37
+
38
+ async function callDaemon(args) {
39
+ const res = await fetch(APPROVAL_URL, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ toolName: args.tool_name, input: args.input ?? {} }),
43
+ });
44
+ if (!res.ok) return { approve: false, message: `daemon said ${res.status}` };
45
+ return res.json();
46
+ }
47
+
48
+ const rl = createInterface({ input: process.stdin });
49
+ rl.on('line', (line) => {
50
+ if (!line.trim()) return;
51
+ let msg;
52
+ try {
53
+ msg = JSON.parse(line);
54
+ } catch {
55
+ return;
56
+ }
57
+ void handle(msg);
58
+ });
59
+
60
+ async function handle(msg) {
61
+ const { id, method, params } = msg;
62
+ if (id === undefined || id === null) return; // notification — nothing to do
63
+
64
+ switch (method) {
65
+ case 'initialize':
66
+ reply(id, {
67
+ protocolVersion: params?.protocolVersion ?? '2024-11-05',
68
+ capabilities: { tools: {} },
69
+ serverInfo: { name: 'offhand-approvals', version: '0.0.1' },
70
+ });
71
+ return;
72
+ case 'tools/list':
73
+ reply(id, { tools: [TOOL] });
74
+ return;
75
+ case 'tools/call': {
76
+ if (params?.name !== TOOL.name) {
77
+ replyError(id, -32602, `unknown tool: ${params?.name}`);
78
+ return;
79
+ }
80
+ let decision;
81
+ try {
82
+ const verdict = await callDaemon(params.arguments ?? {});
83
+ decision = verdict.approve
84
+ ? { behavior: 'allow', updatedInput: params.arguments?.input ?? {} }
85
+ : { behavior: 'deny', message: verdict.message ?? 'denied from phone' };
86
+ } catch (e) {
87
+ decision = { behavior: 'deny', message: `approval channel error: ${e?.message ?? e}` };
88
+ }
89
+ reply(id, { content: [{ type: 'text', text: JSON.stringify(decision) }] });
90
+ return;
91
+ }
92
+ case 'ping':
93
+ reply(id, {});
94
+ return;
95
+ default:
96
+ replyError(id, -32601, `method not found: ${method}`);
97
+ }
98
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // offhands launcher: friendly guard rails BEFORE loading the real daemon
3
+ // (which needs node:sqlite, Node >= 22.5 — a raw import error would be
4
+ // unreadable for someone who just ran `npx offhands`).
5
+
6
+ const [major, minor] = process.versions.node.split('.').map(Number);
7
+ if (major < 22 || (major === 22 && minor < 5)) {
8
+ console.error(
9
+ `offhands needs Node 22.5 or newer (you have ${process.versions.node}).\n` +
10
+ ` → with nvm: nvm install 22 && nvm use 22\n` +
11
+ ` → or download: https://nodejs.org`,
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ // Default to the hosted relay so `npx offhands` pairs out of the box;
17
+ // explicit --relay (or --local for localhost-only use) still wins.
18
+ if (!process.argv.includes('--relay') && !process.argv.includes('--local')) {
19
+ process.argv.push('--relay', 'https://offhand-relay.onrender.com');
20
+ }
21
+
22
+ await import('../dist/daemon.mjs');