clawleash 0.1.0
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/LICENSE +201 -0
- package/README.md +143 -0
- package/bin/clawleash.js +56 -0
- package/package.json +46 -0
- package/skill/SKILL.md +44 -0
- package/src/config.js +48 -0
- package/src/daemon.js +121 -0
- package/src/hooks-install.js +77 -0
- package/src/mobile.js +84 -0
- package/src/netinfo.js +29 -0
- package/src/notify.js +30 -0
- package/src/permissions.js +70 -0
- package/src/status.js +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
95
|
+
Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and do
|
|
117
|
+
not modify the License. You may add Your own attribution notices
|
|
118
|
+
within Derivative Works that You distribute, alongside or as an
|
|
119
|
+
addendum to the NOTICE text from the Work, provided that such
|
|
120
|
+
additional attribution notices cannot be construed as modifying
|
|
121
|
+
the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 clawleash contributors
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# 🦀 clawleash — approve Claude Code from your phone
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/clawleash)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](#connectivity)
|
|
6
|
+
|
|
7
|
+
> **clawleash is an open-source CLI that lets you approve or deny Claude Code permission prompts from your phone — so long autonomous runs never stall while you're away from the desk.** Self-hosted and token-gated, it reaches your Mac over Tailscale or the same Wi‑Fi. No cloud relay, no account, no subscription.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx clawleash
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. Scan the printed URL on your phone, tap **Allow** or **Deny**, and your agent keeps moving.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The problem
|
|
18
|
+
|
|
19
|
+
You kick off a big refactor, grab a coffee, come back ten minutes later — and Claude Code has been sitting idle the whole time, **stuck waiting for permission to run `mkdir`**. Long autonomous runs stall on a single prompt the moment you step away from the keyboard.
|
|
20
|
+
|
|
21
|
+
`clawleash` puts that Allow/Deny button in your pocket.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# In any terminal on the machine where you run Claude Code:
|
|
27
|
+
npx clawleash
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On first run it:
|
|
31
|
+
|
|
32
|
+
1. Installs Claude Code hooks into `~/.claude/settings.json` (idempotent, removable).
|
|
33
|
+
2. Starts a tiny local server and prints a **phone URL** (Tailscale first, then LAN).
|
|
34
|
+
|
|
35
|
+
Open that URL on your phone → **Add to Home Screen** → done. Next time Claude Code asks for permission while you're away, the prompt shows up on your phone with **Allow / Deny** buttons.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx clawleash url # print the phone URL(s) again
|
|
39
|
+
npx clawleash uninstall # remove the hooks
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Claude Code (CLI)
|
|
46
|
+
│ PermissionRequest hook (http, blocks up to 600s) ─────────────┐
|
|
47
|
+
│ SessionStart / PreToolUse / Stop … (status) ──────────┐ │
|
|
48
|
+
▼ ▼ ▼
|
|
49
|
+
clawleash daemon (local, 0.0.0.0:4271)
|
|
50
|
+
├─ holds the request open until you answer
|
|
51
|
+
├─ token-gated phone page (installable PWA)
|
|
52
|
+
└─ optional ntfy push
|
|
53
|
+
phone ◀──── Tailscale / same Wi-Fi ────┘ tap Allow / Deny
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The `PermissionRequest` hook **blocks** while clawleash holds the HTTP request open. Your phone tap returns the decision (`allow` / `deny`) to Claude Code, which then proceeds or blocks the tool. If nobody answers before the timeout, it **falls back to the normal terminal prompt** — so an offline phone never wedges your session.
|
|
57
|
+
|
|
58
|
+
## Connectivity
|
|
59
|
+
|
|
60
|
+
| Where you are | What to use | Setup |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| Same Wi‑Fi (at home/office) | the LAN URL (`192.168.x…`) | none — works immediately |
|
|
63
|
+
| Out and about | the Tailscale URL (`100.x…`) | install [Tailscale](https://tailscale.com) on your Mac **and** phone, same account, same tailnet |
|
|
64
|
+
|
|
65
|
+
**Push notifications (optional):** set an [ntfy](https://ntfy.sh) topic and subscribe to it in the ntfy app to get pinged the moment a prompt needs you.
|
|
66
|
+
|
|
67
|
+
## clawleash vs the alternatives
|
|
68
|
+
|
|
69
|
+
| | **clawleash** | ntfy-only hook | Anthropic Remote Control | clawd-on-desk |
|
|
70
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
71
|
+
| Approve/Deny from phone | ✅ | ❌ (notify only) | ✅ | ✅ |
|
|
72
|
+
| Live agent status on phone | ✅ | ❌ | partial | ✅ |
|
|
73
|
+
| Self-hosted, no cloud relay | ✅ | ✅ | ❌ | ✅ |
|
|
74
|
+
| Tailscale / LAN (no public exposure) | ✅ | n/a | ❌ | ✅ |
|
|
75
|
+
| One-command `npx` install | ✅ | manual | n/a | ❌ (desktop app) |
|
|
76
|
+
| Headless / GUI-free | ✅ | ✅ | ✅ | ❌ |
|
|
77
|
+
| Needs a Claude subscription/tier | ❌ | ❌ | varies | ❌ |
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Config lives in `~/.config/clawleash/config.json` (or the OS equivalent):
|
|
82
|
+
|
|
83
|
+
| Key | Default | Meaning |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `token` | random | secret in the phone URL (`?k=…`) |
|
|
86
|
+
| `port` | `4271` | daemon port (`CLAWLEASH_PORT` env overrides) |
|
|
87
|
+
| `approvals` | `true` | mirror permission prompts to the phone |
|
|
88
|
+
| `ntfyTopic` | `""` | ntfy topic for push (empty = off) |
|
|
89
|
+
|
|
90
|
+
## Security & threat model
|
|
91
|
+
|
|
92
|
+
- **Off by default for outsiders.** Every phone-facing route is gated by a secret token; without `?k=<token>` you get a `403`.
|
|
93
|
+
- **Hook ingress is loopback-only.** `/hook/*` rejects any request that isn't from `127.0.0.1`.
|
|
94
|
+
- **You only resolve existing prompts.** The phone can tap Allow/Deny on a prompt Claude Code already raised — it cannot inject arbitrary commands.
|
|
95
|
+
- **Headless sessions** (`claude -p`) are not eligible, and **no response → fall back** to the terminal prompt. An offline phone never blocks you.
|
|
96
|
+
- **Keep it on your tailnet.** Prefer Tailscale (private mesh) over exposing the port publicly.
|
|
97
|
+
|
|
98
|
+
## FAQ
|
|
99
|
+
|
|
100
|
+
### How do I approve Claude Code permission requests from my phone?
|
|
101
|
+
Run `npx clawleash` on the machine running Claude Code, open the printed URL on your phone, and tap Allow/Deny when a prompt appears.
|
|
102
|
+
|
|
103
|
+
### Can I control Claude Code remotely from my phone?
|
|
104
|
+
Yes — clawleash mirrors permission prompts and live agent status to a phone web page over your own Tailscale network or LAN.
|
|
105
|
+
|
|
106
|
+
### Do I need a Claude subscription or Anthropic's Remote Control?
|
|
107
|
+
No. clawleash is self-hosted and works with your local Claude Code CLI; nothing runs in the cloud.
|
|
108
|
+
|
|
109
|
+
### Is it safe to approve Claude Code permissions from my phone?
|
|
110
|
+
The page is token-gated, hook ingress is loopback-only, and you can only Allow/Deny prompts Claude Code already raised. Run it over Tailscale rather than the public internet.
|
|
111
|
+
|
|
112
|
+
### How is this different from ntfy notifications?
|
|
113
|
+
ntfy can *tell* you Claude needs you; clawleash lets you *answer* — Allow/Deny right from the phone — without walking back to the desk.
|
|
114
|
+
|
|
115
|
+
### What happens if my phone is offline?
|
|
116
|
+
After a timeout the permission hook falls back to Claude Code's normal terminal prompt, so your session never gets stuck.
|
|
117
|
+
|
|
118
|
+
## Roadmap
|
|
119
|
+
|
|
120
|
+
- Onboarding wizard (one-screen local settings UI: install/connection/QR).
|
|
121
|
+
- Optional **hosted relay** for zero-config access from any network (freemium).
|
|
122
|
+
- Provider-agnostic support beyond Claude Code.
|
|
123
|
+
|
|
124
|
+
## Claude Code skill
|
|
125
|
+
|
|
126
|
+
A thin Claude Code skill ([`skill/SKILL.md`](./skill/SKILL.md)) wraps the CLI so you can just ask Claude *"set up phone approval for Claude Code"* and it runs `npx clawleash` and walks you through it.
|
|
127
|
+
|
|
128
|
+
## Requirements
|
|
129
|
+
|
|
130
|
+
- Node.js ≥ 18
|
|
131
|
+
- Claude Code with hooks (default in recent versions)
|
|
132
|
+
- For on-the-go access: Tailscale on your Mac and phone
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
|
|
136
|
+
Issues and PRs welcome. Run `npm test` for the unit tests.
|
|
137
|
+
|
|
138
|
+
## License & trademark
|
|
139
|
+
|
|
140
|
+
Code: [Apache-2.0](./LICENSE). The **clawleash** name/logo are not covered by the
|
|
141
|
+
code license — see [TRADEMARK.md](./TRADEMARK.md). clawleash is an independent
|
|
142
|
+
community companion for Claude Code and is **not affiliated with Anthropic**;
|
|
143
|
+
"Claude" and "Claude Code" are trademarks of Anthropic, used here descriptively.
|
package/bin/clawleash.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// clawleash — approve/deny Claude Code permission prompts from your phone.
|
|
4
|
+
//
|
|
5
|
+
// npx clawleash start the daemon (installs hooks on first run)
|
|
6
|
+
// npx clawleash url print the phone URL(s) and exit
|
|
7
|
+
// npx clawleash uninstall remove clawleash hooks from ~/.claude/settings.json
|
|
8
|
+
const config = require("../src/config");
|
|
9
|
+
const hooks = require("../src/hooks-install");
|
|
10
|
+
const { startDaemon } = require("../src/daemon");
|
|
11
|
+
const { phoneUrls } = require("../src/netinfo");
|
|
12
|
+
|
|
13
|
+
function printUrls(cfg) {
|
|
14
|
+
const urls = phoneUrls(cfg.port, cfg.token);
|
|
15
|
+
if (!urls.length) {
|
|
16
|
+
console.log(" No network address found — connect Wi-Fi or start Tailscale.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.log(" Open on your phone (Tailscale first):");
|
|
20
|
+
for (const u of urls) console.log(` ${u.url}${u.tailscale ? " · Tailscale" : " · same Wi-Fi only"}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
const cmd = process.argv[2] || "start";
|
|
25
|
+
const cfg = config.ensure();
|
|
26
|
+
|
|
27
|
+
if (cmd === "uninstall") {
|
|
28
|
+
hooks.uninstall();
|
|
29
|
+
console.log("✔ Removed clawleash hooks from ~/.claude/settings.json");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (cmd === "url") {
|
|
33
|
+
printUrls(cfg);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// start
|
|
38
|
+
if (!hooks.isInstalled(cfg.port)) {
|
|
39
|
+
hooks.install(cfg.port);
|
|
40
|
+
console.log("✔ Installed clawleash hooks into ~/.claude/settings.json");
|
|
41
|
+
}
|
|
42
|
+
const d = startDaemon({
|
|
43
|
+
getConfig: () => config.load(),
|
|
44
|
+
onLog: (m) => console.log("clawleash:", m),
|
|
45
|
+
});
|
|
46
|
+
d.listen(cfg.port, () => {
|
|
47
|
+
const c = config.load();
|
|
48
|
+
console.log(`\n🦀 clawleash running on 0.0.0.0:${cfg.port}\n`);
|
|
49
|
+
printUrls(cfg);
|
|
50
|
+
console.log(`\n Approvals: ${c.approvals ? "ON" : "off"} | ntfy push: ${c.ntfyTopic || "(not set)"}`);
|
|
51
|
+
console.log(" Scan the URL on your phone and Add to Home Screen.");
|
|
52
|
+
console.log(" Ctrl-C to stop · `npx clawleash uninstall` to remove hooks.\n");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clawleash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Approve or deny Claude Code permission prompts from your phone — so long autonomous runs never stall while you're away from the desk. Self-hosted, token-gated, over Tailscale or LAN.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude",
|
|
8
|
+
"remote-approval",
|
|
9
|
+
"permission",
|
|
10
|
+
"hooks",
|
|
11
|
+
"tailscale",
|
|
12
|
+
"ntfy",
|
|
13
|
+
"pwa",
|
|
14
|
+
"human-in-the-loop",
|
|
15
|
+
"devtools",
|
|
16
|
+
"ai-agent",
|
|
17
|
+
"mobile"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/wilsonwang0713/clawleash#readme",
|
|
20
|
+
"bugs": "https://github.com/wilsonwang0713/clawleash/issues",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/wilsonwang0713/clawleash.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"author": "",
|
|
27
|
+
"type": "commonjs",
|
|
28
|
+
"bin": {
|
|
29
|
+
"clawleash": "bin/clawleash.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"src/",
|
|
34
|
+
"skill/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"start": "node bin/clawleash.js",
|
|
40
|
+
"test": "node --test test/*.test.js"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {}
|
|
46
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clawleash
|
|
3
|
+
description: Set up phone approval for Claude Code — let the user approve/deny Claude Code permission prompts and see live agent status from their phone, so long autonomous runs don't stall while they're away. Use when the user asks to control Claude Code remotely, approve prompts from their phone, get mobile notifications when Claude needs permission, or set up clawleash.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# clawleash — phone approval for Claude Code
|
|
7
|
+
|
|
8
|
+
clawleash is a tiny self-hosted CLI: it installs Claude Code hooks, holds each
|
|
9
|
+
permission prompt open, and serves a token-gated phone page where the user taps
|
|
10
|
+
**Allow / Deny**. The phone reaches the Mac over Tailscale or the same Wi-Fi.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
1. **Start it** (installs hooks on first run, prints the phone URL):
|
|
15
|
+
```
|
|
16
|
+
npx clawleash
|
|
17
|
+
```
|
|
18
|
+
It runs in the foreground. Tell the user to keep it running (or run it under
|
|
19
|
+
tmux / a process manager). Re-print the URL anytime with `npx clawleash url`.
|
|
20
|
+
|
|
21
|
+
2. **Read the printed phone URL(s).** Tailscale (`100.x…`) works on the go; LAN
|
|
22
|
+
(`192.168.x…`) works on the same Wi-Fi only.
|
|
23
|
+
|
|
24
|
+
3. **Guide the phone:** open the URL in the phone browser → **Add to Home
|
|
25
|
+
Screen**. When Claude Code next needs permission while the user is away, the
|
|
26
|
+
prompt appears on the phone with Allow/Deny buttons.
|
|
27
|
+
|
|
28
|
+
4. **On the go needs Tailscale:** if there is no `100.x` URL, have the user
|
|
29
|
+
install Tailscale on both the Mac and the phone, signed into the **same
|
|
30
|
+
account / same tailnet**. (A fresh personal account does this automatically.)
|
|
31
|
+
|
|
32
|
+
5. **Optional push:** set an ntfy topic in `~/.config/clawleash/config.json`
|
|
33
|
+
(`ntfyTopic`) and subscribe to it in the ntfy app for a buzz when a prompt
|
|
34
|
+
needs them.
|
|
35
|
+
|
|
36
|
+
To remove: `npx clawleash uninstall` (strips clawleash hooks from
|
|
37
|
+
`~/.claude/settings.json`).
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- Off by default for outsiders: the page is token-gated (403 without `?k=`),
|
|
42
|
+
hook ingress is loopback-only, headless sessions are not eligible, and a
|
|
43
|
+
no-response prompt falls back to the terminal — an offline phone never wedges
|
|
44
|
+
the session. Prefer Tailscale over exposing the port publicly.
|
package/src/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Tiny JSON config under the OS config dir. Holds the secret token, port, and
|
|
3
|
+
// toggles. The token gates the phone-facing routes.
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PORT = 4271;
|
|
10
|
+
|
|
11
|
+
function configDir() {
|
|
12
|
+
const home = os.homedir();
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "clawleash");
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === "darwin") {
|
|
17
|
+
return path.join(home, "Library", "Application Support", "clawleash");
|
|
18
|
+
}
|
|
19
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "clawleash");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function configPath() {
|
|
23
|
+
return path.join(configDir(), "config.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function load() {
|
|
27
|
+
try { return JSON.parse(fs.readFileSync(configPath(), "utf8")); }
|
|
28
|
+
catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function save(cfg) {
|
|
32
|
+
try { fs.mkdirSync(configDir(), { recursive: true }); } catch { /* ignore */ }
|
|
33
|
+
fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Load + fill defaults (generate a token on first run). Idempotent.
|
|
37
|
+
function ensure() {
|
|
38
|
+
const cfg = load();
|
|
39
|
+
let changed = false;
|
|
40
|
+
if (!cfg.token) { cfg.token = crypto.randomBytes(12).toString("hex"); changed = true; }
|
|
41
|
+
if (!cfg.port) { cfg.port = Number(process.env.CLAWLEASH_PORT) || DEFAULT_PORT; changed = true; }
|
|
42
|
+
if (typeof cfg.approvals !== "boolean") { cfg.approvals = true; changed = true; }
|
|
43
|
+
if (typeof cfg.ntfyTopic !== "string") { cfg.ntfyTopic = ""; changed = true; }
|
|
44
|
+
if (changed) save(cfg);
|
|
45
|
+
return cfg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { configDir, configPath, load, save, ensure, DEFAULT_PORT };
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The clawleash daemon: one HTTP server bound to 0.0.0.0.
|
|
3
|
+
// - /hook/event, /hook/permission ← Claude Code posts here (LOOPBACK ONLY)
|
|
4
|
+
// - /, /api/status, /api/permission ← the phone (TOKEN-GATED, any interface)
|
|
5
|
+
// - /manifest.webmanifest ← public PWA asset
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const { renderPage, manifestFor } = require("./mobile");
|
|
8
|
+
const { createRegistry } = require("./permissions");
|
|
9
|
+
const { createStatus } = require("./status");
|
|
10
|
+
const { pushNtfy } = require("./notify");
|
|
11
|
+
|
|
12
|
+
// Settle a held permission well before Claude Code's 600s hook timeout so we
|
|
13
|
+
// can cleanly fall back to the terminal prompt if nobody answers.
|
|
14
|
+
const PERMISSION_TIMEOUT_MS = 9 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function isLoopback(req) {
|
|
17
|
+
const a = (req.socket && req.socket.remoteAddress) || "";
|
|
18
|
+
return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readBody(req, cap = 1 << 20) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let data = "";
|
|
24
|
+
let over = false;
|
|
25
|
+
req.on("data", (c) => { if (over) return; data += c; if (data.length > cap) over = true; });
|
|
26
|
+
req.on("end", () => { try { resolve(over ? {} : JSON.parse(data || "{}")); } catch { resolve({}); } });
|
|
27
|
+
req.on("error", () => resolve({}));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startDaemon({ getConfig, onLog } = {}) {
|
|
32
|
+
const registry = createRegistry();
|
|
33
|
+
const status = createStatus();
|
|
34
|
+
const cfg = () => (typeof getConfig === "function" ? getConfig() : {}) || {};
|
|
35
|
+
|
|
36
|
+
const server = http.createServer(async (req, res) => {
|
|
37
|
+
let url;
|
|
38
|
+
try { url = new URL(req.url, "http://localhost"); } catch { res.writeHead(400); res.end(); return; }
|
|
39
|
+
const p = url.pathname;
|
|
40
|
+
|
|
41
|
+
// ── Hook ingress — loopback only (Claude Code on the same machine) ──
|
|
42
|
+
if (p === "/hook/event") {
|
|
43
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end(); return; }
|
|
44
|
+
const body = await readBody(req);
|
|
45
|
+
try { status.event(body); } catch { /* ignore */ }
|
|
46
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end("{}");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (p === "/hook/permission") {
|
|
50
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end(); return; }
|
|
51
|
+
const body = await readBody(req);
|
|
52
|
+
const c = cfg();
|
|
53
|
+
if (!c.approvals) { res.destroy(); return; } // off → Claude Code prompts in the terminal
|
|
54
|
+
const tool = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
55
|
+
const input = body.tool_input && typeof body.tool_input === "object" ? body.tool_input : {};
|
|
56
|
+
const sessionId = body.session_id || "default";
|
|
57
|
+
const decision = await registry.request(
|
|
58
|
+
{ tool, input, sessionId, project: "" },
|
|
59
|
+
PERMISSION_TIMEOUT_MS,
|
|
60
|
+
(pend) => {
|
|
61
|
+
if (c.ntfyTopic) pushNtfy(c.ntfyTopic, "Permission needed", `${pend.tool}: ${pend.summary}`, { priority: "high", tags: "warning" });
|
|
62
|
+
if (onLog) onLog(`permission pending — ${pend.tool} (${pend.id})`);
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
if (decision.decision === "timeout") { res.destroy(); return; } // fall back to terminal prompt
|
|
66
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
67
|
+
res.end(JSON.stringify({
|
|
68
|
+
hookSpecificOutput: {
|
|
69
|
+
hookEventName: "PermissionRequest",
|
|
70
|
+
decision: {
|
|
71
|
+
behavior: decision.decision === "allow" ? "allow" : "deny",
|
|
72
|
+
...(decision.message ? { message: decision.message } : {}),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Token gate for everything phone-facing ──
|
|
80
|
+
const token = cfg().token || "";
|
|
81
|
+
if (!token || url.searchParams.get("k") !== token) {
|
|
82
|
+
res.writeHead(403, { "Content-Type": "text/plain" }); res.end("forbidden");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Token-gated so start_url can carry ?k= (fixes Home Screen 403).
|
|
86
|
+
if (p === "/manifest.webmanifest") {
|
|
87
|
+
res.writeHead(200, { "Content-Type": "application/manifest+json; charset=utf-8", "Cache-Control": "no-store" });
|
|
88
|
+
res.end(manifestFor(token));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (p === "/api/status") {
|
|
93
|
+
const snap = status.snapshot();
|
|
94
|
+
snap.pending = registry.list();
|
|
95
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
96
|
+
res.end(JSON.stringify(snap));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (p === "/api/permission" && req.method === "POST") {
|
|
100
|
+
const id = url.searchParams.get("id") || "";
|
|
101
|
+
const behavior = url.searchParams.get("decision") === "allow" ? "allow" : "deny";
|
|
102
|
+
const ok = registry.resolve(id, behavior);
|
|
103
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// default → the mobile page
|
|
107
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
108
|
+
res.end(renderPage(token));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
server.on("error", (e) => { if (onLog) onLog("server error: " + (e && e.message)); });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
server,
|
|
115
|
+
registry,
|
|
116
|
+
status,
|
|
117
|
+
listen(port, cb) { server.listen(port, "0.0.0.0", cb); return server; },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { startDaemon, PERMISSION_TIMEOUT_MS };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Idempotently install/remove clawleash's Claude Code hooks in ~/.claude/settings.json.
|
|
3
|
+
// All clawleash hooks are http hooks tagged with ?clawleash=1 so they can be
|
|
4
|
+
// found and removed cleanly without touching the user's other hooks.
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
const MARK = "clawleash=1";
|
|
10
|
+
const STATUS_EVENTS = [
|
|
11
|
+
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse",
|
|
12
|
+
"Stop", "SubagentStart", "SubagentStop", "SessionEnd",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function settingsPath() {
|
|
16
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function read() {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(settingsPath(), "utf8")); }
|
|
21
|
+
catch { return {}; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function write(settings) {
|
|
25
|
+
try { fs.mkdirSync(path.dirname(settingsPath()), { recursive: true }); } catch { /* ignore */ }
|
|
26
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isOurs(entry) {
|
|
30
|
+
return entry && Array.isArray(entry.hooks) &&
|
|
31
|
+
entry.hooks.some((h) => h && typeof h.url === "string" && h.url.includes("/hook/") && h.url.includes(MARK));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Remove every clawleash-tagged hook entry from a settings object (mutates).
|
|
35
|
+
function strip(settings) {
|
|
36
|
+
const hooks = settings.hooks;
|
|
37
|
+
if (!hooks || typeof hooks !== "object") return settings;
|
|
38
|
+
for (const evt of Object.keys(hooks)) {
|
|
39
|
+
if (!Array.isArray(hooks[evt])) continue;
|
|
40
|
+
hooks[evt] = hooks[evt].filter((entry) => !isOurs(entry));
|
|
41
|
+
if (hooks[evt].length === 0) delete hooks[evt];
|
|
42
|
+
}
|
|
43
|
+
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
44
|
+
return settings;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function install(port) {
|
|
48
|
+
const settings = strip(read());
|
|
49
|
+
settings.hooks = settings.hooks || {};
|
|
50
|
+
const add = (evt, entry) => { (settings.hooks[evt] = settings.hooks[evt] || []).push(entry); };
|
|
51
|
+
|
|
52
|
+
add("PermissionRequest", {
|
|
53
|
+
matcher: "*",
|
|
54
|
+
hooks: [{ type: "http", url: `http://127.0.0.1:${port}/hook/permission?${MARK}`, timeout: 600 }],
|
|
55
|
+
});
|
|
56
|
+
for (const evt of STATUS_EVENTS) {
|
|
57
|
+
add(evt, {
|
|
58
|
+
matcher: "*",
|
|
59
|
+
hooks: [{ type: "http", url: `http://127.0.0.1:${port}/hook/event?${MARK}`, timeout: 5 }],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
write(settings);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function uninstall() {
|
|
66
|
+
write(strip(read()));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInstalled(port) {
|
|
70
|
+
const hooks = read().hooks;
|
|
71
|
+
if (!hooks || !Array.isArray(hooks.PermissionRequest)) return false;
|
|
72
|
+
return hooks.PermissionRequest.some((entry) =>
|
|
73
|
+
entry && Array.isArray(entry.hooks) &&
|
|
74
|
+
entry.hooks.some((h) => h && typeof h.url === "string" && h.url.includes(`:${port}/hook/`) && h.url.includes(MARK)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { install, uninstall, isInstalled, settingsPath };
|
package/src/mobile.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The phone-facing page: a token-gated, installable PWA. Polls /api/status and
|
|
3
|
+
// renders pending allow/deny permission cards + live session bubbles. Tapping a
|
|
4
|
+
// button POSTs to /api/permission.
|
|
5
|
+
|
|
6
|
+
const FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='88'%3E%F0%9F%A6%80%3C/text%3E%3C/svg%3E";
|
|
7
|
+
|
|
8
|
+
function manifestFor(token) {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
name: "clawleash",
|
|
11
|
+
short_name: "clawleash",
|
|
12
|
+
display: "standalone",
|
|
13
|
+
background_color: "#1c1c1f",
|
|
14
|
+
theme_color: "#1c1c1f",
|
|
15
|
+
scope: "/",
|
|
16
|
+
// Bake the token in: the Home Screen launch uses start_url, NOT the URL you
|
|
17
|
+
// added — without it the launched PWA would hit a tokenless URL and 403.
|
|
18
|
+
start_url: "/?k=" + encodeURIComponent(token || ""),
|
|
19
|
+
icons: [{ src: FAVICON, sizes: "any", type: "image/svg+xml" }],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderPage(token) {
|
|
24
|
+
return `<!doctype html><html lang="en"><head>
|
|
25
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
26
|
+
<title>clawleash</title>
|
|
27
|
+
<link rel="icon" href="${FAVICON}">
|
|
28
|
+
<link rel="apple-touch-icon" href="${FAVICON}">
|
|
29
|
+
<link rel="manifest" href="/manifest.webmanifest?k=${encodeURIComponent(token || "")}">
|
|
30
|
+
<meta name="theme-color" content="#1c1c1f">
|
|
31
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
32
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
33
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
34
|
+
<meta name="apple-mobile-web-app-title" content="clawleash">
|
|
35
|
+
<style>
|
|
36
|
+
:root{--bg:#1c1c1f;--card:#232327;--fg:#f4f4f5;--mut:#a1a1aa;--dim:#71717a;--acc:#d97757;--ok:#3fb950;--bad:#f85149;--bd:rgba(255,255,255,.08)}
|
|
37
|
+
@media(prefers-color-scheme:light){:root{--bg:#f5f5f7;--card:#fff;--fg:#18181b;--mut:#6b6b70;--dim:#9b9ba0;--bd:rgba(0,0,0,.08)}}
|
|
38
|
+
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:env(safe-area-inset-top) 0 40px}
|
|
39
|
+
header{padding:18px 16px 6px}h1{font-size:18px;margin:0}#sub{font-size:12px;color:var(--mut);margin:4px 0 0}
|
|
40
|
+
.card{background:var(--card);border:1px solid var(--bd);border-radius:14px;margin:12px 16px;padding:14px 16px}
|
|
41
|
+
.card h2{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:0 0 10px;font-weight:600}
|
|
42
|
+
.row{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-top:1px solid var(--bd);font-size:14px}
|
|
43
|
+
.row:first-of-type{border-top:none}.run .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--acc);margin-right:8px;animation:p 1s infinite}
|
|
44
|
+
@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}.muted{color:var(--mut)}.none{color:var(--dim);font-size:14px;padding:6px 0}
|
|
45
|
+
#ts{font-size:11px;color:var(--dim);text-align:center;margin-top:16px}
|
|
46
|
+
.bub{display:flex;align-items:flex-start;gap:8px;margin:12px 0}.bub:first-child{margin-top:0}
|
|
47
|
+
.crab{font-size:24px;line-height:1.1;flex:none}
|
|
48
|
+
.say{position:relative;background:var(--bg);border:1px solid var(--bd);border-radius:14px;padding:9px 12px;flex:1;min-width:0}
|
|
49
|
+
.say:before{content:"";position:absolute;left:-7px;top:13px;border:6px solid transparent;border-right-color:var(--bd);border-left:0}
|
|
50
|
+
.ttl{font-size:14px;font-weight:600;word-break:break-word}.subl{font-size:12px;color:var(--mut);margin-top:2px;word-break:break-word}
|
|
51
|
+
.st{font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 6px;border-radius:6px;margin-left:6px}
|
|
52
|
+
.s-working,.s-thinking{background:rgba(217,119,87,.18);color:var(--acc)}.s-idle{background:var(--bd);color:var(--dim)}
|
|
53
|
+
.perm{border-color:var(--acc)}.ptool{font-size:11px;color:var(--acc);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
|
|
54
|
+
.psum{font-size:14px;margin:5px 0 11px;word-break:break-word;font-family:ui-monospace,Menlo,monospace}
|
|
55
|
+
.pbtns{display:flex;gap:8px}.pbtns button{flex:1;border:0;border-radius:10px;padding:12px;font-size:15px;font-weight:600;color:#fff}
|
|
56
|
+
.allow{background:var(--ok)}.deny{background:var(--bad)}.pbtns button:active{opacity:.65}
|
|
57
|
+
</style></head><body>
|
|
58
|
+
<header><h1>🦀 clawleash</h1><p id="sub">connecting…</p></header>
|
|
59
|
+
<div id="perms"></div>
|
|
60
|
+
<div class="card"><h2>Sessions</h2><div id="sessions"><div class="none">—</div></div></div>
|
|
61
|
+
<div class="card"><h2>Running now</h2><div id="running"><div class="none">—</div></div></div>
|
|
62
|
+
<div id="ts"></div>
|
|
63
|
+
<script>
|
|
64
|
+
var K=${JSON.stringify(token || "")};
|
|
65
|
+
function esc(s){var d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML}
|
|
66
|
+
function decide(id,d){fetch('/api/permission?k='+encodeURIComponent(K)+'&id='+encodeURIComponent(id)+'&decision='+d,{method:'POST'}).then(function(){tick()}).catch(function(){});}
|
|
67
|
+
document.getElementById('perms').addEventListener('click',function(e){var b=e.target.closest&&e.target.closest('button[data-id]');if(!b)return;b.disabled=true;decide(b.getAttribute('data-id'),b.getAttribute('data-d'));});
|
|
68
|
+
function renderPerms(list){var el=document.getElementById('perms');if(!list||!list.length){el.innerHTML='';return}
|
|
69
|
+
el.innerHTML=list.map(function(p){return '<div class="card perm"><div class="ptool">'+esc(p.tool)+(p.project?' · '+esc(p.project):'')+'</div><div class="psum">'+esc(p.summary)+'</div><div class="pbtns"><button class="allow" data-id="'+esc(p.id)+'" data-d="allow">Allow</button><button class="deny" data-id="'+esc(p.id)+'" data-d="deny">Deny</button></div></div>';}).join('');}
|
|
70
|
+
function renderSessions(list){var el=document.getElementById('sessions');if(!list||!list.length){el.innerHTML='<div class="none">no live sessions</div>';return}
|
|
71
|
+
el.innerHTML=list.map(function(s){var st=s.state||'idle';var sub=(s.agents&&s.agents.length)?s.agents.join(', '):st;
|
|
72
|
+
return '<div class="bub"><div class="crab">🦀</div><div class="say"><div class="ttl">'+esc(s.project||'session')+'<span class="st s-'+esc(st)+'">'+esc(st)+'</span></div><div class="subl">'+esc(sub)+'</div></div></div>';}).join('');}
|
|
73
|
+
function tick(){fetch('/api/status?k='+encodeURIComponent(K)).then(function(r){return r.json()}).then(function(d){
|
|
74
|
+
var run=d.running||[];var p=d.pending||[];
|
|
75
|
+
document.getElementById('sub').textContent=(d.liveSessions||0)+' live session'+(d.liveSessions===1?'':'s')+(p.length?' · '+p.length+' awaiting you':'');
|
|
76
|
+
renderPerms(p);renderSessions(d.sessions);
|
|
77
|
+
document.getElementById('running').innerHTML=run.length?run.map(function(x){return '<div class="row run"><span><span class="dot"></span>'+esc(x.type)+'</span><span class="muted">'+(x.count>1?'×'+x.count:'running')+'</span></div>'}).join(''):'<div class="none">nothing running</div>';
|
|
78
|
+
document.getElementById('ts').textContent='updated '+new Date().toLocaleTimeString();
|
|
79
|
+
}).catch(function(){document.getElementById('sub').textContent='disconnected — is your Mac awake?'});}
|
|
80
|
+
tick();setInterval(tick,5000);
|
|
81
|
+
</script></body></html>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { renderPage, manifestFor };
|
package/src/netinfo.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local network introspection: enumerate reachable IPs and build phone URLs,
|
|
3
|
+
// Tailscale (100.64.0.0/10) first, then LAN.
|
|
4
|
+
const os = require("os");
|
|
5
|
+
|
|
6
|
+
function isTailscaleIp(ip) {
|
|
7
|
+
const m = /^100\.(\d+)\./.exec(ip || "");
|
|
8
|
+
return !!m && +m[1] >= 64 && +m[1] <= 127;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function phoneUrls(port, token) {
|
|
12
|
+
const ifs = os.networkInterfaces() || {};
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const name of Object.keys(ifs)) {
|
|
15
|
+
for (const ni of ifs[name] || []) {
|
|
16
|
+
if (ni.family !== "IPv4" || ni.internal) continue;
|
|
17
|
+
const tailscale = isTailscaleIp(ni.address);
|
|
18
|
+
out.push({
|
|
19
|
+
ip: ni.address,
|
|
20
|
+
tailscale,
|
|
21
|
+
kind: tailscale ? "tailscale" : "lan",
|
|
22
|
+
url: `http://${ni.address}:${port}/?k=${token}`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out.sort((a, b) => (b.tailscale ? 1 : 0) - (a.tailscale ? 1 : 0));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { isTailscaleIp, phoneUrls };
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Push a short notification to a phone via ntfy.sh (free, open-source).
|
|
3
|
+
// The user installs the ntfy app and subscribes to their topic; we POST to it.
|
|
4
|
+
const https = require("https");
|
|
5
|
+
|
|
6
|
+
function pushNtfy(topic, title, message, opts = {}) {
|
|
7
|
+
if (!topic || typeof topic !== "string") return;
|
|
8
|
+
try {
|
|
9
|
+
const body = Buffer.from(String(message == null ? "" : message), "utf8");
|
|
10
|
+
// Title header must be ASCII; keep details in the (UTF-8) body.
|
|
11
|
+
const asciiTitle = String(title || "clawleash").replace(/[^\x20-\x7E]/g, "").slice(0, 80) || "clawleash";
|
|
12
|
+
const headers = {
|
|
13
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
14
|
+
"Content-Length": body.length,
|
|
15
|
+
"Title": asciiTitle,
|
|
16
|
+
};
|
|
17
|
+
if (opts.tags) headers.Tags = String(opts.tags);
|
|
18
|
+
if (opts.priority) headers.Priority = String(opts.priority);
|
|
19
|
+
const req = https.request(
|
|
20
|
+
{ hostname: "ntfy.sh", path: "/" + encodeURIComponent(topic), method: "POST", headers, timeout: 4000 },
|
|
21
|
+
(res) => { res.resume(); }
|
|
22
|
+
);
|
|
23
|
+
req.on("error", () => {});
|
|
24
|
+
req.on("timeout", () => { try { req.destroy(); } catch {} });
|
|
25
|
+
req.write(body);
|
|
26
|
+
req.end();
|
|
27
|
+
} catch { /* best-effort */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { pushNtfy };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pending permission registry. A PermissionRequest hook holds its HTTP request
|
|
3
|
+
// open; we park a resolver here and settle it when the phone (or a timeout)
|
|
4
|
+
// answers. The phone calls resolve(id, "allow"|"deny"); a timeout settles as
|
|
5
|
+
// "timeout" so the caller can let Claude Code fall back to its terminal prompt.
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
function summarize(tool, input) {
|
|
9
|
+
const ti = input && typeof input === "object" ? input : {};
|
|
10
|
+
const trim = (s, n) => {
|
|
11
|
+
const t = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
|
12
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
13
|
+
};
|
|
14
|
+
if (tool === "Bash") return trim(ti.command, 90) || "Bash command";
|
|
15
|
+
if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
|
|
16
|
+
const f = ti.file_path || ti.notebook_path || "";
|
|
17
|
+
return f ? `${tool} ${path.basename(String(f))}` : tool;
|
|
18
|
+
}
|
|
19
|
+
if (tool === "WebFetch") return trim(ti.url, 90) || "WebFetch";
|
|
20
|
+
if (tool === "Read" && ti.file_path) return `Read ${path.basename(String(ti.file_path))}`;
|
|
21
|
+
return tool || "Tool";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createRegistry() {
|
|
25
|
+
const pending = new Map(); // id -> { resolve, timer, tool, input, sessionId, project, createdAt }
|
|
26
|
+
let counter = 0;
|
|
27
|
+
|
|
28
|
+
function request({ tool, input, sessionId, project }, timeoutMs, onPending) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const id = `p${++counter}`;
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
if (pending.delete(id)) resolve({ decision: "timeout" });
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
pending.set(id, { resolve, timer, tool, input, sessionId, project, createdAt: Date.now() });
|
|
35
|
+
if (onPending) {
|
|
36
|
+
try { onPending({ id, tool, summary: summarize(tool, input), project: project || "", sessionId: sessionId || "" }); }
|
|
37
|
+
catch { /* best-effort */ }
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function list() {
|
|
43
|
+
return [...pending.entries()].map(([id, p]) => ({
|
|
44
|
+
id,
|
|
45
|
+
tool: p.tool,
|
|
46
|
+
summary: summarize(p.tool, p.input),
|
|
47
|
+
project: p.project || "",
|
|
48
|
+
sessionId: p.sessionId || "",
|
|
49
|
+
createdAt: p.createdAt,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolve(id, decision) {
|
|
54
|
+
const p = pending.get(id);
|
|
55
|
+
if (!p) return false;
|
|
56
|
+
clearTimeout(p.timer);
|
|
57
|
+
pending.delete(id);
|
|
58
|
+
p.resolve({
|
|
59
|
+
decision: decision === "allow" ? "allow" : "deny",
|
|
60
|
+
message: decision === "allow" ? undefined : "Denied from phone",
|
|
61
|
+
});
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function size() { return pending.size; }
|
|
66
|
+
|
|
67
|
+
return { request, list, resolve, size };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { createRegistry, summarize };
|
package/src/status.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// In-memory live status fed by Claude Code hook events. Tracks per-session state
|
|
3
|
+
// and running subagents so the phone page can show "what's happening now".
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const STATE_BY_EVENT = {
|
|
7
|
+
SessionStart: "idle",
|
|
8
|
+
UserPromptSubmit: "thinking",
|
|
9
|
+
PreToolUse: "working",
|
|
10
|
+
PostToolUse: "working",
|
|
11
|
+
Stop: "idle",
|
|
12
|
+
SubagentStart: "working",
|
|
13
|
+
SubagentStop: "working",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function createStatus() {
|
|
17
|
+
const sessions = new Map(); // sessionId -> { project, state, headless, updatedAt }
|
|
18
|
+
const subagents = new Map(); // toolUseId -> { type, sessionId }
|
|
19
|
+
|
|
20
|
+
function event(e) {
|
|
21
|
+
const evt = e.event || e.hook_event_name || "";
|
|
22
|
+
const sid = e.session_id || "default";
|
|
23
|
+
|
|
24
|
+
if (evt === "SessionEnd") {
|
|
25
|
+
sessions.delete(sid);
|
|
26
|
+
for (const [k, v] of subagents) if (v.sessionId === sid) subagents.delete(k);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const s = sessions.get(sid) || { project: "", state: "idle", headless: false, updatedAt: 0 };
|
|
31
|
+
if (e.cwd) s.project = path.basename(String(e.cwd));
|
|
32
|
+
if (e.headless) s.headless = true;
|
|
33
|
+
if (STATE_BY_EVENT[evt]) s.state = STATE_BY_EVENT[evt];
|
|
34
|
+
s.updatedAt = Date.now();
|
|
35
|
+
sessions.set(sid, s);
|
|
36
|
+
|
|
37
|
+
const subType =
|
|
38
|
+
(e.tool_input && typeof e.tool_input.subagent_type === "string" && e.tool_input.subagent_type) ||
|
|
39
|
+
(typeof e.agent_type === "string" && e.agent_type) || "";
|
|
40
|
+
if (subType && e.tool_use_id && (evt === "PreToolUse" || evt === "SubagentStart")) {
|
|
41
|
+
subagents.set(e.tool_use_id, { type: subType, sessionId: sid });
|
|
42
|
+
}
|
|
43
|
+
if (e.tool_use_id && (evt === "PostToolUse" || evt === "SubagentStop")) {
|
|
44
|
+
subagents.delete(e.tool_use_id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function snapshot() {
|
|
49
|
+
const running = {};
|
|
50
|
+
const agentsBySession = new Map();
|
|
51
|
+
for (const [, v] of subagents) {
|
|
52
|
+
if (!v.type) continue;
|
|
53
|
+
running[v.type] = (running[v.type] || 0) + 1;
|
|
54
|
+
const a = agentsBySession.get(v.sessionId) || [];
|
|
55
|
+
a.push(v.type);
|
|
56
|
+
agentsBySession.set(v.sessionId, a);
|
|
57
|
+
}
|
|
58
|
+
const sess = [];
|
|
59
|
+
for (const [id, s] of sessions) {
|
|
60
|
+
if (s.headless) continue;
|
|
61
|
+
sess.push({ id, project: s.project, state: s.state, agents: agentsBySession.get(id) || [], updatedAt: s.updatedAt });
|
|
62
|
+
}
|
|
63
|
+
sess.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
64
|
+
return {
|
|
65
|
+
running: Object.entries(running).map(([type, count]) => ({ type, count })),
|
|
66
|
+
sessions: sess,
|
|
67
|
+
liveSessions: sess.length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { event, snapshot };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { createStatus };
|