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.
- package/README.internal.md +49 -0
- package/README.md +3 -0
- package/approval-mcp.mjs +98 -0
- package/bin/offhands.mjs +22 -0
- package/dist/daemon.mjs +41602 -0
- package/package.json +28 -0
|
@@ -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
package/approval-mcp.mjs
ADDED
|
@@ -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
|
+
}
|
package/bin/offhands.mjs
ADDED
|
@@ -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');
|