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 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
+ [![npm](https://img.shields.io/npm/v/clawleash.svg)](https://www.npmjs.com/package/clawleash)
4
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
5
+ [![Tailscale ready](https://img.shields.io/badge/Tailscale-ready-7b61ff.svg)](#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.
@@ -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 };