workoffdesk 0.0.0-stage → 1.0.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
The first public release.
|
|
6
|
+
|
|
7
|
+
- Chat with the Claude Code sessions on your Mac from your phone, in a web app you can add to the home
|
|
8
|
+
screen. The interface is in English or Chinese, following the phone.
|
|
9
|
+
- Claude's questions, permission prompts and panels such as `/usage` become cards you answer with a tap.
|
|
10
|
+
- Sessions run in tmux on the Mac and outlive the phone; `workoffdesk attach` joins one from the Mac's
|
|
11
|
+
terminal.
|
|
12
|
+
- One user per install, with a password and passkeys; sign-ins can be revoked device by device.
|
|
13
|
+
- Web push when Claude waits for you or finishes a turn.
|
|
14
|
+
- `install.sh` installs the dependencies and the npm package; `workoffdesk install` runs the service under
|
|
15
|
+
launchd; `workoffdesk deploy` and `rollback` upgrade it and switch back when the new version does not start.
|
|
16
|
+
- `workoffdesk doctor` checks tmux, Claude Code, the service, the address, idle sleep and the push key, and says
|
|
17
|
+
what to do about each problem. `setup` and `doctor` print the address as a QR code.
|
|
18
|
+
- While the service runs, the Mac does not go to idle sleep.
|
|
19
|
+
- `workoffdesk --version`.
|
|
20
|
+
- An iPhone app you build with your own Apple Developer account, with notifications through APNs. It keeps
|
|
21
|
+
every account you sign in to, and you move between them from Settings.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 workoffdesk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,200 @@
|
|
|
1
|
-
#
|
|
1
|
+
# workoffdesk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Chat with the Claude Code and Codex sessions on your Mac from your phone.**
|
|
4
|
+
|
|
5
|
+
English · [简体中文](https://github.com/workoffdesk/workoffdesk/blob/main/README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
<p align="center"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/demo.gif" alt="Giving a session on the Mac work from the phone, and answering its question with a tap" width="320"></p>
|
|
8
|
+
|
|
9
|
+
## What makes it different
|
|
10
|
+
|
|
11
|
+
- **No account, no relay of ours.** It is a small web app on your own Mac; your phone reaches it over the
|
|
12
|
+
path you choose, such as Tailscale, a Cloudflare Tunnel or your own server.
|
|
13
|
+
- **Sessions outlive the phone.** Each one runs in tmux on the Mac: closing the page, locking the phone or
|
|
14
|
+
restarting the service does not end it.
|
|
15
|
+
- **Leaving the desk, take it with you; back at it, keep going.** A Claude Code conversation running in a
|
|
16
|
+
terminal on the Mac can be taken over from the phone, and `workoffdesk attach` joins any session from the
|
|
17
|
+
Mac's terminal.
|
|
18
|
+
- **Tap instead of typing keys.** Claude's questions, permission prompts and panels like `/usage` become
|
|
19
|
+
cards on the phone.
|
|
20
|
+
- **Claude Code or Codex.** Start either in a project folder; each session is named after what its
|
|
21
|
+
conversation is about, so the list says what each one does.
|
|
22
|
+
|
|
23
|
+
## Screens
|
|
24
|
+
|
|
25
|
+
<table width="100%">
|
|
26
|
+
<tr><th width="33%">Chat</th><th width="33%">A question from Claude</th><th width="33%">Sessions</th></tr>
|
|
27
|
+
<tr><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/chat.png" alt="Chat" width="100%"></td><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/choice.png" alt="A choice card" width="100%"></td><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/sessions.png" alt="The session list" width="100%"></td></tr>
|
|
28
|
+
</table>
|
|
29
|
+
|
|
30
|
+
## How it fits together
|
|
31
|
+
|
|
32
|
+
<p align="center"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/how-it-works.gif" alt="How it fits together: the phone, an HTTPS tunnel you pick, the service on your Mac, sessions in tmux" width="100%"></p>
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
phone: the web app, or the iPhone app
|
|
36
|
+
│ HTTPS, over the path you choose: Tailscale, a Cloudflare Tunnel, your own server
|
|
37
|
+
▼
|
|
38
|
+
the workoffdesk service on your Mac (listens on 127.0.0.1, runs as a launchd agent)
|
|
39
|
+
│
|
|
40
|
+
▼
|
|
41
|
+
tmux sessions: Claude Code or Codex, each in a project folder
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Everything runs on your Mac, and everything it keeps is in `~/.workoffdesk`. You look after the service from
|
|
45
|
+
any of four places; they run the same checks:
|
|
46
|
+
|
|
47
|
+
| Where | What it is for |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `workoffdesk` in a terminal | Set up, install, start, stop, check, join a session, upgrade and roll back. See [Commands](#commands). |
|
|
50
|
+
| The setup page (`workoffdesk setup --web`) | The first setup, step by step in your browser: the checks, your account, the address, the service, and the address as a QR code. |
|
|
51
|
+
| The menu bar app (`apps/mac`, built from source) | Whether the service runs at a glance; failed checks with their fixes, start, stop and restart, the QR code, the Claude account. |
|
|
52
|
+
| Settings → **This Mac**, on the phone or the web | The checks, the version, the newest warnings and errors, Restart and Roll back, and which Claude account new sessions run on. |
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
On the Mac (macOS, with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed, and
|
|
57
|
+
[Codex](https://github.com/openai/codex) if you use it):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
curl -fsSL https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/install.sh | bash
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`install.sh` installs tmux and Node with Homebrew if they are missing and never uses `sudo`. Run in a terminal
|
|
64
|
+
on a Mac not set up yet, it then opens the setup page in your browser. Without the script, or by hand:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm i -g workoffdesk # needs Node 22.13+ and tmux 3.3+
|
|
68
|
+
workoffdesk setup --origin https://workoffdesk.example.com
|
|
69
|
+
workoffdesk install # then run the launchctl line it prints
|
|
70
|
+
workoffdesk doctor
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`setup` asks for a username and a password; `install` writes a launchd service and prints the command that
|
|
74
|
+
starts it; `doctor` checks everything and says what to do about anything that fails. See
|
|
75
|
+
[Install](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/install.md).
|
|
76
|
+
|
|
77
|
+
## Reach it from your phone
|
|
78
|
+
|
|
79
|
+
The server listens on `127.0.0.1` only. Put HTTPS in front of it: `tailscale serve` keeps the traffic
|
|
80
|
+
between your own devices, while a Cloudflare Tunnel works from any network but Cloudflare sees the traffic.
|
|
81
|
+
[Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md) compares the options and sets each one up.
|
|
82
|
+
|
|
83
|
+
> [!WARNING]
|
|
84
|
+
> Whoever signs in can run any command on your Mac as you. Use a strong password, keep the address to
|
|
85
|
+
> yourself, and read [SECURITY.md](https://github.com/workoffdesk/workoffdesk/blob/main/SECURITY.md).
|
|
86
|
+
|
|
87
|
+
## Using it
|
|
88
|
+
|
|
89
|
+
A typical day: start a task at the desk with `workoffdesk claude`, or in a terminal as usual and take it over
|
|
90
|
+
from the phone later. Away from the desk, answer Claude's questions and send the next steps from the phone.
|
|
91
|
+
Back at the desk, `workoffdesk attach` joins the same session, with everything the phone did in it.
|
|
92
|
+
|
|
93
|
+
### On the phone
|
|
94
|
+
|
|
95
|
+
1. Open your address (`setup` prints it as a QR code) and sign in.
|
|
96
|
+
2. Add it to the home screen.
|
|
97
|
+
3. In Settings, add a passkey and turn on notifications.
|
|
98
|
+
|
|
99
|
+
Then:
|
|
100
|
+
|
|
101
|
+
- **Start or go on.** ⊕ starts Claude or Codex in a project folder, or opens a recent conversation from the
|
|
102
|
+
Mac to read and continue. One still open in a terminal on the Mac can be taken over, now or once its
|
|
103
|
+
current step is done.
|
|
104
|
+
- **Read it as a chat.** Claude's replies are rendered as Markdown and tool calls are folded. Questions,
|
|
105
|
+
permission prompts and panels like `/usage` are cards to tap; **Screen** shows the Mac's screen as text
|
|
106
|
+
when the chat cannot show what Claude waits on.
|
|
107
|
+
- **Send.** Type, hold to talk, or add pictures, videos and files. What you send while Claude works goes
|
|
108
|
+
into Claude's own queue. **Stop** interrupts it; **Rewind** goes back to an earlier point.
|
|
109
|
+
- **Be told.** A notification comes when Claude waits for you or finishes a turn.
|
|
110
|
+
|
|
111
|
+
Prefer a native app? [Build the iPhone app](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/ios-app.md) with your own Apple Developer account.
|
|
112
|
+
Everything else is in [Usage](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/usage.md).
|
|
113
|
+
|
|
114
|
+
### At the Mac
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
workoffdesk claude # start Claude in this folder as a session, and join it
|
|
118
|
+
workoffdesk codex # the same for Codex
|
|
119
|
+
workoffdesk attach # join a session, picked from a list
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
It is the same tmux session the phone shows, so both can be on it at once. `Ctrl-b d` detaches and leaves
|
|
123
|
+
it running. In a session on the phone, ⋯ → **Continue on the Mac** shows the `attach` command to copy.
|
|
124
|
+
|
|
125
|
+
### Looking after the service
|
|
126
|
+
|
|
127
|
+
`workoffdesk doctor` checks macOS, Node, tmux, Claude Code, Codex, the setup, the service, your address, idle
|
|
128
|
+
sleep and the iPhone push key, and prints the fix for each failure. The same checks, with the service's
|
|
129
|
+
newest warnings and errors, are on Settings → **This Mac** and in the menu bar app. A new version is installed
|
|
130
|
+
with `workoffdesk deploy`, which smoke-tests it before switching and switches back if it does not come up;
|
|
131
|
+
`workoffdesk rollback` goes back one release. See [Upgrade](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/upgrade.md) and
|
|
132
|
+
[Troubleshooting](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/troubleshooting.md).
|
|
133
|
+
|
|
134
|
+
While the service runs it keeps the Mac from idle sleep; the display still sleeps, and closing the lid still
|
|
135
|
+
sleeps the Mac.
|
|
136
|
+
|
|
137
|
+
## Commands
|
|
138
|
+
|
|
139
|
+
Each is run as `workoffdesk <command>`.
|
|
140
|
+
|
|
141
|
+
| Command | What it does |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `setup` | Asks for a username and a password (12 characters or more) and writes the config. Run again, it sets a new password and signs every device out. |
|
|
144
|
+
| `setup --web` | The setup as a page in your browser, including the address and the service. |
|
|
145
|
+
| `install` | Writes the launchd agent and `~/.local/bin/workoffdesk`, starts nothing, and prints the commands to run. `--tunnel <name>` also sets up an existing Cloudflare tunnel. |
|
|
146
|
+
| `start` | Starts the service and waits up to 30 seconds for it to answer. |
|
|
147
|
+
| `stop` | Stops the service until the next login or `start`. |
|
|
148
|
+
| `restart` | Restarts the service. Sessions keep running. |
|
|
149
|
+
| `doctor` | Runs every check, prints the fix for each failure, and exits 1 while anything fails. |
|
|
150
|
+
| `status --json` | The checks, the version, the releases, the address, whether the service runs, and its newest 20 warnings and errors. |
|
|
151
|
+
| `claude [--resume <session id>]` | Starts Claude in this folder as a session and joins it; with `--resume`, going on with that conversation. |
|
|
152
|
+
| `codex [--resume <thread id>]` | The same for Codex. |
|
|
153
|
+
| `attach [id]` | Joins a session from this terminal; without an id, lists them to pick one. |
|
|
154
|
+
| `serve` | Runs the server in this terminal instead of as the service. |
|
|
155
|
+
| `deploy <build folder>` | Installs a build as a new release, smoke-tests it, switches to it and restarts the service; keeps the newest 3. |
|
|
156
|
+
| `rollback` | Goes back to the release before. |
|
|
157
|
+
| `--version` | Prints the installed version. |
|
|
158
|
+
| `--help` | Prints this list, one line for each command; so does `workoffdesk` with no command. |
|
|
159
|
+
|
|
160
|
+
Everything is kept in `~/.workoffdesk`; set `WORKOFFDESK_HOME` to keep it elsewhere. Settings such as
|
|
161
|
+
`pinnedDirs` are in [Configuration](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/configuration.md).
|
|
162
|
+
|
|
163
|
+
## Compared with similar tools
|
|
164
|
+
|
|
165
|
+
Checked on 2026-10-07. These products change quickly; check again before quoting this table.
|
|
166
|
+
|
|
167
|
+
| | Works with | Traffic goes through | Account needed | After the network drops | Native apps | License |
|
|
168
|
+
|---|---|---|---|---|---|---|
|
|
169
|
+
| **workoffdesk** | Claude Code, Codex | Your Mac and the path you choose | None | Session keeps running in tmux | iPhone (build it yourself); web app | MIT |
|
|
170
|
+
| [Claude Code Remote Control](https://code.claude.com/docs/en/remote-control) | Claude Code | Anthropic API | Claude Pro, Max, Team or Enterprise | Reconnects; the local process must keep running | Claude iOS and Android | Not stated |
|
|
171
|
+
| [Happy](https://github.com/slopus/happy) | Claude Code, Codex | Happy Server, end-to-end encrypted | Not stated | Not stated | iOS, Android, macOS, web | MIT |
|
|
172
|
+
| [zalify/offdesk](https://github.com/zalify/offdesk) | Anything in tmux | A hub, end-to-end encrypted | None to self-host | Session keeps running in tmux | iOS (TestFlight), Android, desktop | MIT |
|
|
173
|
+
| [VibeTunnel](https://github.com/amantus-ai/vibetunnel) | Any terminal program | The tunnel you choose | Not stated | Not stated | macOS; iOS in progress | MIT |
|
|
174
|
+
|
|
175
|
+
<!--
|
|
176
|
+
Sources, read 2026-10-07:
|
|
177
|
+
- workoffdesk: docs/facts.md (Codex: Sessions)
|
|
178
|
+
- Claude Code Remote Control: https://code.claude.com/docs/en/remote-control
|
|
179
|
+
(traffic through the Anthropic API; Pro/Max/Team/Enterprise plan, API keys not supported;
|
|
180
|
+
"reconnects automatically"; the local process must keep running; Claude iOS and Android apps)
|
|
181
|
+
- Happy: https://github.com/slopus/happy ("happy claude", "happy codex"; Happy Server with encrypted sync;
|
|
182
|
+
iOS, Android, macOS, web; MIT)
|
|
183
|
+
- zalify/offdesk: https://github.com/zalify/offdesk ("anything that runs in tmux"; hub with end-to-end
|
|
184
|
+
encryption; "Self-hosting needs no vendor account"; tmux keeps sessions alive; releases:
|
|
185
|
+
https://github.com/zalify/offdesk/releases; MIT)
|
|
186
|
+
- VibeTunnel: https://github.com/amantus-ai/vibetunnel ("Turn any browser into your Mac terminal";
|
|
187
|
+
Tailscale, ngrok, Cloudflare Quick Tunnel; native macOS app, iOS app in progress; MIT)
|
|
188
|
+
-->
|
|
189
|
+
|
|
190
|
+
## Documentation
|
|
191
|
+
|
|
192
|
+
- [Install](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/install.md) · [Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md) · [Usage](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/usage.md)
|
|
193
|
+
- [Configuration](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/configuration.md) · [Upgrade](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/upgrade.md) ·
|
|
194
|
+
[Troubleshooting](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/troubleshooting.md)
|
|
195
|
+
- [How it works](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/how-it-works.md) · [iPhone app](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/ios-app.md)
|
|
196
|
+
- [Contributing](https://github.com/workoffdesk/workoffdesk/blob/main/CONTRIBUTING.md) · [Changelog](https://github.com/workoffdesk/workoffdesk/blob/main/apps/server/CHANGELOG.md) · [Security](https://github.com/workoffdesk/workoffdesk/blob/main/SECURITY.md)
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT, see [LICENSE](https://github.com/workoffdesk/workoffdesk/blob/main/LICENSE). Not affiliated with Anthropic. Claude is a trademark of Anthropic.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## What you are putting on the internet
|
|
4
|
+
|
|
5
|
+
workoffdesk is a remote terminal. **Whoever signs in can run anything your macOS user can run**: read your
|
|
6
|
+
files, use your SSH keys and cloud credentials, start Claude Code with your account. Treat the password
|
|
7
|
+
like your login password and the site like an SSH port.
|
|
8
|
+
|
|
9
|
+
## What it does to keep others out
|
|
10
|
+
|
|
11
|
+
- **One user.** There is exactly one account per install, made with `workoffdesk setup`. Running `setup`
|
|
12
|
+
again replaces the password and signs every device out.
|
|
13
|
+
- **Password and passkeys.** Passwords (12 characters or more) are stored as scrypt hashes. After signing in
|
|
14
|
+
you can add passkeys (WebAuthn, e.g. Face ID) and sign in with those instead.
|
|
15
|
+
- **Lockout.** Five failed sign-ins from one address within 15 minutes lock that address for 15 minutes;
|
|
16
|
+
twenty failures from anywhere within an hour lock sign-in for everyone for an hour. Every attempt is
|
|
17
|
+
written to `logs/audit.log`.
|
|
18
|
+
- **Sessions you can revoke.** A sign-in lasts 30 days. Each signed-in device is listed in Settings and can
|
|
19
|
+
be revoked; it is sent back to the sign-in page on its next request.
|
|
20
|
+
- **Origin checks.** Requests that change anything, and every WebSocket, must come from the configured
|
|
21
|
+
`origin`; anything else is refused.
|
|
22
|
+
- **Content Security Policy.** Pages load scripts, styles and images only from the site itself; images
|
|
23
|
+
from the web inside Claude's replies are shown as links, never loaded.
|
|
24
|
+
- **Loopback only.** The server listens on `127.0.0.1` and does not trust `X-Forwarded-*` headers.
|
|
25
|
+
|
|
26
|
+
## Who can read your traffic
|
|
27
|
+
|
|
28
|
+
The path between your phone and the Mac decides who else sees what you type and what Claude answers. See
|
|
29
|
+
[Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md).
|
|
30
|
+
|
|
31
|
+
- **Tailscale** (`tailscale serve`): TLS ends on your Mac; only your own devices take part.
|
|
32
|
+
- **A Cloudflare Tunnel**: Cloudflare terminates TLS at its edge, so Cloudflare can read the traffic.
|
|
33
|
+
- **Your own server** with a reverse proxy: that server terminates TLS and can read the traffic.
|
|
34
|
+
|
|
35
|
+
## How to run it safely
|
|
36
|
+
|
|
37
|
+
- **Always serve it over HTTPS.** Never expose the port directly or forward it on your router: cookies are
|
|
38
|
+
`Secure` and passkeys need HTTPS, and a plain-HTTP site sends your password in the clear.
|
|
39
|
+
- Choose a long password, add a passkey, and revoke devices you no longer use.
|
|
40
|
+
- An access layer in front, such as a VPN or Tailscale, adds a second lock: only your devices can reach the
|
|
41
|
+
sign-in page at all.
|
|
42
|
+
- **`claudeArgs` widens what a session does without you.** `--dangerously-skip-permissions` lets Claude run
|
|
43
|
+
any command and edit any file without asking; combined with remote access, a prompt injection in
|
|
44
|
+
something Claude reads can act on your Mac while you are away. Add it only if you accept that.
|
|
45
|
+
|
|
46
|
+
## Reporting a vulnerability
|
|
47
|
+
|
|
48
|
+
Report it privately through the repository's
|
|
49
|
+
[security advisories](https://github.com/workoffdesk/workoffdesk/security/advisories/new). Please do not open a
|
|
50
|
+
public issue for anything that lets someone sign in, read files or run commands without the password.
|
package/dist/build.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"1.0.0","commit":"25838a75d2ca44fe42cfbfff87f8a10964beda03","dirty":false}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const FRESH_MS = 10_000;
|
|
6
|
+
const SECURITY_MS = 5_000;
|
|
7
|
+
const real = (p) => {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(p);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function readAccount(file) {
|
|
16
|
+
try {
|
|
17
|
+
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
18
|
+
const email = j.oauthAccount?.emailAddress;
|
|
19
|
+
return typeof email === 'string' && email ? { email, ready: j.hasCompletedOnboarding === true } : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Only the item's attributes are read: no prompt, and no secret leaves the Keychain.
|
|
26
|
+
const keychainHas = (service) => new Promise((resolve) => execFile('/usr/bin/security', ['find-generic-password', '-s', service], { timeout: SECURITY_MS }, (err) => resolve(!err)));
|
|
27
|
+
export class ClaudeAccounts {
|
|
28
|
+
o;
|
|
29
|
+
last = null;
|
|
30
|
+
reading = null;
|
|
31
|
+
constructor(o) {
|
|
32
|
+
this.o = o;
|
|
33
|
+
}
|
|
34
|
+
// Read anew once the last read is FRESH_MS old, one read at a time.
|
|
35
|
+
list() {
|
|
36
|
+
if (this.last && Date.now() - this.last.at < FRESH_MS)
|
|
37
|
+
return Promise.resolve(this.last.accounts);
|
|
38
|
+
this.reading ??= this.read().finally(() => (this.reading = null));
|
|
39
|
+
return this.reading;
|
|
40
|
+
}
|
|
41
|
+
// The last read, for what cannot wait on one.
|
|
42
|
+
known() {
|
|
43
|
+
return this.last?.accounts ?? [];
|
|
44
|
+
}
|
|
45
|
+
pickable(a) {
|
|
46
|
+
return a.signedIn && a.ready && a.shared;
|
|
47
|
+
}
|
|
48
|
+
// The account a config dir is, however it is spelled; unset is the default. undefined: a dir that is gone.
|
|
49
|
+
idOf(dir) {
|
|
50
|
+
if (!dir)
|
|
51
|
+
return '';
|
|
52
|
+
const r = real(dir);
|
|
53
|
+
if (r === null)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (r === real(join(this.o.home, '.claude')))
|
|
56
|
+
return '';
|
|
57
|
+
return this.known().find((a) => a.id && real(a.id) === r)?.id ?? r;
|
|
58
|
+
}
|
|
59
|
+
// The account this server's own environment gives a Claude.
|
|
60
|
+
ownId() {
|
|
61
|
+
return this.idOf(this.o.own) ?? '';
|
|
62
|
+
}
|
|
63
|
+
folder(dir) {
|
|
64
|
+
return dir.startsWith(`${this.o.home}/`) ? `~${dir.slice(this.o.home.length)}` : dir;
|
|
65
|
+
}
|
|
66
|
+
async read() {
|
|
67
|
+
const has = this.o.keychainHas ?? keychainHas;
|
|
68
|
+
const projects = real(this.o.projectsRoot);
|
|
69
|
+
const home = join(this.o.home, '.claude');
|
|
70
|
+
const dirs = this.o.own ? [this.o.own] : [];
|
|
71
|
+
for (const parent of [this.o.home, join(this.o.home, '.config')]) {
|
|
72
|
+
let names = [];
|
|
73
|
+
try {
|
|
74
|
+
names = readdirSync(parent);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const name of names)
|
|
80
|
+
if (parent !== this.o.home || name.startsWith('.'))
|
|
81
|
+
dirs.push(join(parent, name));
|
|
82
|
+
}
|
|
83
|
+
const accounts = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const add = async (id, dir, file, service) => {
|
|
86
|
+
const r = real(dir);
|
|
87
|
+
if (r === null || seen.has(r))
|
|
88
|
+
return;
|
|
89
|
+
const found = readAccount(file);
|
|
90
|
+
if (!found)
|
|
91
|
+
return;
|
|
92
|
+
seen.add(r);
|
|
93
|
+
const signedIn = (await has(service)) || existsSync(join(dir, '.credentials.json'));
|
|
94
|
+
accounts.push({ id, folder: this.folder(dir), email: found.email, signedIn, ready: found.ready, shared: projects !== null && real(join(dir, 'projects')) === projects });
|
|
95
|
+
};
|
|
96
|
+
await add('', home, join(this.o.home, '.claude.json'), 'Claude Code-credentials');
|
|
97
|
+
for (const dir of dirs) {
|
|
98
|
+
if (dir === home)
|
|
99
|
+
continue;
|
|
100
|
+
await add(dir, dir, join(dir, '.claude.json'), `Claude Code-credentials-${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`);
|
|
101
|
+
}
|
|
102
|
+
this.last = { at: Date.now(), accounts };
|
|
103
|
+
return accounts;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { toolSummary } from '../transcript/chat.js';
|
|
3
|
+
// Tools whose dialog says more than yes or no (Claude's questions, its plan): left to the screen.
|
|
4
|
+
const ON_SCREEN = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
|
5
|
+
const DETAIL_MAX = 4000;
|
|
6
|
+
// A prompt is let go a little before its hook would be stopped (PERMISSION_HOLD_S in hooks.ts), so the
|
|
7
|
+
// hook ends with no word rather than being killed, which reads as the Mac's No.
|
|
8
|
+
export const HOLD_MS = 3500_000;
|
|
9
|
+
const DECLINED = 'The user declined this from their phone.';
|
|
10
|
+
// Whether the prompt's dialog is one the phone leaves to the Mac's screen.
|
|
11
|
+
export const leftToScreen = (payload) => typeof payload.tool_name === 'string' && ON_SCREEN.has(payload.tool_name);
|
|
12
|
+
// What a prompt asks of the person: Claude's question, its plan, or the tool and what it would do with it.
|
|
13
|
+
export function askedOf(payload) {
|
|
14
|
+
const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
15
|
+
const input = isRecord(payload.tool_input) ? payload.tool_input : {};
|
|
16
|
+
if (tool === 'AskUserQuestion') {
|
|
17
|
+
const first = Array.isArray(input.questions) ? input.questions[0] : null;
|
|
18
|
+
return isRecord(first) ? str(first.question) : '';
|
|
19
|
+
}
|
|
20
|
+
if (tool === 'ExitPlanMode')
|
|
21
|
+
return str(input.plan);
|
|
22
|
+
return tool && `${tool} ${toolSummary(tool, input)}`.trim();
|
|
23
|
+
}
|
|
24
|
+
// The permission prompts Claude Code's PermissionRequest hooks hold open for the phone, per session. The
|
|
25
|
+
// dialog stays on the Mac's screen all the while: whichever answers first is the answer, and a prompt is
|
|
26
|
+
// over when the phone answers it, the Mac does, or the turn moves on without it.
|
|
27
|
+
export class PermissionAsks {
|
|
28
|
+
changed;
|
|
29
|
+
held = new Map();
|
|
30
|
+
// changed: a session's asks came or went.
|
|
31
|
+
constructor(changed = () => { }) {
|
|
32
|
+
this.changed = changed;
|
|
33
|
+
}
|
|
34
|
+
// The ask the phone is shown for a prompt, or null when it is one left to the screen (and the hook has
|
|
35
|
+
// been answered with nothing).
|
|
36
|
+
hold(termId, payload, hook) {
|
|
37
|
+
const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
38
|
+
const input = isRecord(payload.tool_input) ? payload.tool_input : null;
|
|
39
|
+
if (!tool || !input || ON_SCREEN.has(tool)) {
|
|
40
|
+
hook.reply(null);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const suggestions = Array.isArray(payload.permission_suggestions) ? payload.permission_suggestions.filter(isRecord) : [];
|
|
44
|
+
const ask = { id: randomUUID(), kind: 'permission', tool, title: toolSummary(tool, input), detail: detailOf(tool, input), always: alwaysOf(suggestions) };
|
|
45
|
+
const timer = setTimeout(() => this.take(termId, (h) => h.ask.id === ask.id)?.hook.reply(null), HOLD_MS).unref();
|
|
46
|
+
const list = this.held.get(termId) ?? [];
|
|
47
|
+
list.push({ ask, call: callKey(tool, input), suggestions, hook, timer });
|
|
48
|
+
this.held.set(termId, list);
|
|
49
|
+
this.changed(termId);
|
|
50
|
+
return ask;
|
|
51
|
+
}
|
|
52
|
+
list(termId) {
|
|
53
|
+
return (this.held.get(termId) ?? []).map((h) => h.ask);
|
|
54
|
+
}
|
|
55
|
+
// False when the ask is over. "From now on" takes every suggestion Claude Code made, as its own dialog's
|
|
56
|
+
// option does.
|
|
57
|
+
answer(termId, askId, answer) {
|
|
58
|
+
const now = this.take(termId, (h) => h.ask.id === askId);
|
|
59
|
+
if (!now)
|
|
60
|
+
return false;
|
|
61
|
+
const keep = answer.allow && answer.always && now.suggestions.length ? { updatedPermissions: now.suggestions } : {};
|
|
62
|
+
now.hook.reply(decision(answer.allow ? { behavior: 'allow', ...keep } : { behavior: 'deny', message: DECLINED, interrupt: true }));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
// The hook ended without an answer from here: Claude Code stopped it. True when it was still held.
|
|
66
|
+
gone(termId, askId) {
|
|
67
|
+
return !!this.take(termId, (h) => h.ask.id === askId);
|
|
68
|
+
}
|
|
69
|
+
// The tool of a prompt ran (or failed): it was allowed at the Mac, and its hook waits on nothing.
|
|
70
|
+
toolDone(termId, tool, input) {
|
|
71
|
+
if (typeof tool !== 'string' || !isRecord(input))
|
|
72
|
+
return;
|
|
73
|
+
const call = callKey(tool, input);
|
|
74
|
+
this.take(termId, (h) => h.call === call)?.hook.reply(null);
|
|
75
|
+
}
|
|
76
|
+
// The turn is over or the session gone: nothing it held is asked any more.
|
|
77
|
+
endAll(termId) {
|
|
78
|
+
for (const h of this.held.get(termId) ?? []) {
|
|
79
|
+
clearTimeout(h.timer);
|
|
80
|
+
h.hook.reply(null);
|
|
81
|
+
}
|
|
82
|
+
if (this.held.delete(termId))
|
|
83
|
+
this.changed(termId);
|
|
84
|
+
}
|
|
85
|
+
take(termId, which) {
|
|
86
|
+
const list = this.held.get(termId) ?? [];
|
|
87
|
+
const i = list.findIndex(which);
|
|
88
|
+
if (i < 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const [h] = list.splice(i, 1);
|
|
91
|
+
if (!list.length)
|
|
92
|
+
this.held.delete(termId);
|
|
93
|
+
clearTimeout(h.timer);
|
|
94
|
+
this.changed(termId);
|
|
95
|
+
return h;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const decision = (d) => ({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: d } });
|
|
99
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
100
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
101
|
+
const clip = (s) => (s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}…` : s);
|
|
102
|
+
const marked = (mark, s) => s.split('\n').map((l) => `${mark} ${l}`).join('\n');
|
|
103
|
+
// The whole of what the tool is asked to do, as far as the card has room for it.
|
|
104
|
+
function detailOf(tool, input) {
|
|
105
|
+
if (tool === 'Bash')
|
|
106
|
+
return clip(str(input.command));
|
|
107
|
+
if (tool === 'Write')
|
|
108
|
+
return clip(str(input.content));
|
|
109
|
+
if (tool === 'Edit')
|
|
110
|
+
return clip(`${marked('-', str(input.old_string))}\n${marked('+', str(input.new_string))}`);
|
|
111
|
+
return clip(JSON.stringify(input, null, 2));
|
|
112
|
+
}
|
|
113
|
+
// What Claude Code's suggestions would allow from now on; null when it made none the phone can name.
|
|
114
|
+
function alwaysOf(suggestions) {
|
|
115
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
116
|
+
const always = { rules: [], dirs: [], mode: null };
|
|
117
|
+
for (const s of suggestions) {
|
|
118
|
+
if (s.type === 'addRules' && s.behavior === 'allow') {
|
|
119
|
+
for (const r of list(s.rules).filter(isRecord))
|
|
120
|
+
always.rules.push(str(r.ruleContent) ? `${str(r.toolName)}(${str(r.ruleContent)})` : str(r.toolName));
|
|
121
|
+
}
|
|
122
|
+
else if (s.type === 'addDirectories')
|
|
123
|
+
always.dirs.push(...list(s.directories).map(str).filter(Boolean));
|
|
124
|
+
else if (s.type === 'setMode' && str(s.mode))
|
|
125
|
+
always.mode = str(s.mode);
|
|
126
|
+
else
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return always.rules.length || always.dirs.length || always.mode ? always : null;
|
|
130
|
+
}
|
|
131
|
+
// A tool call as the same text however its input's keys are ordered, to know a PostToolUse for it.
|
|
132
|
+
function callKey(tool, input) {
|
|
133
|
+
const sorted = (v) => Array.isArray(v) ? v.map(sorted) : isRecord(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
|
|
134
|
+
return JSON.stringify([tool, sorted(input)]);
|
|
135
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { writeJsonAtomic } from '../../../system/paths.js';
|
|
4
|
+
import { HOOK_EVENTS } from './status.js';
|
|
5
|
+
const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
6
|
+
const hookCommand = (sockPath) => `curl -s -o /dev/null --max-time 2 --unix-socket ${shq(sockPath)} ` +
|
|
7
|
+
`-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/hook || true`;
|
|
8
|
+
// How long a permission prompt's hook waits for the phone. The dialog is on the Mac's screen meanwhile.
|
|
9
|
+
const PERMISSION_HOLD_S = 3600;
|
|
10
|
+
// Prints what the server answers, which is the hook's decision; nothing when it cannot be reached.
|
|
11
|
+
const permissionCommand = (sockPath) => `curl -s --max-time ${PERMISSION_HOLD_S} --unix-socket ${shq(sockPath)} ` +
|
|
12
|
+
`-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/permission || true`;
|
|
13
|
+
const QUOTED = /^'(?:[^']|'\\'')*'$/;
|
|
14
|
+
const OWN = [hookCommand, permissionCommand].map((make) => make('\0').split(shq('\0')));
|
|
15
|
+
// Whether a hook command is one workoffdesk installs, for a service home anywhere.
|
|
16
|
+
export function isOwnHookCommand(command) {
|
|
17
|
+
return OWN.some(([head, tail]) => command.startsWith(head) && command.endsWith(tail) && QUOTED.test(command.slice(head.length, command.length - tail.length)));
|
|
18
|
+
}
|
|
19
|
+
const statusPost = (sockPath) => `curl -s -o /dev/null --max-time 2 --unix-socket ${shq(sockPath)} ` +
|
|
20
|
+
`-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/status`;
|
|
21
|
+
// The status line the user set for Claude Code themselves, which keeps drawing the terminal's.
|
|
22
|
+
export function userStatusLine(settingsPath) {
|
|
23
|
+
try {
|
|
24
|
+
const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
25
|
+
const l = s.statusLine;
|
|
26
|
+
if (l?.type !== 'command' || typeof l.command !== 'string' || !l.command)
|
|
27
|
+
return undefined;
|
|
28
|
+
return typeof l.padding === 'number' ? { type: 'command', command: l.command, padding: l.padding } : { type: 'command', command: l.command };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// What the status line is given goes to workoffdesk too, and the user's own status line still draws it.
|
|
35
|
+
export function statusLineCommand(sockPath, own) {
|
|
36
|
+
const post = statusPost(sockPath);
|
|
37
|
+
if (!own)
|
|
38
|
+
return `${post} >/dev/null 2>&1 || true`;
|
|
39
|
+
return `input=$(cat); { printf '%s' "$input" | ${post} >/dev/null 2>&1 & } ; printf '%s' "$input" | { ${own}
|
|
40
|
+
}`;
|
|
41
|
+
}
|
|
42
|
+
export function writeClaudeSettings(path, sockPath, own) {
|
|
43
|
+
const command = hookCommand(sockPath);
|
|
44
|
+
const hooks = {
|
|
45
|
+
...Object.fromEntries(HOOK_EVENTS.map((event) => [event, [{ hooks: [{ type: 'command', command, timeout: 5 }] }]])),
|
|
46
|
+
PermissionRequest: [{ hooks: [{ type: 'command', command: permissionCommand(sockPath), timeout: PERMISSION_HOLD_S }] }],
|
|
47
|
+
};
|
|
48
|
+
const statusLine = { type: 'command', command: statusLineCommand(sockPath, own?.command) };
|
|
49
|
+
if (own?.padding !== undefined)
|
|
50
|
+
statusLine.padding = own.padding;
|
|
51
|
+
// Agent view moves a conversation out of its window, on quitting or by /background, into a session the
|
|
52
|
+
// phone cannot follow; what is typed afterwards starts new ones.
|
|
53
|
+
writeJsonAtomic(path, { hooks, statusLine, disableAgentView: true });
|
|
54
|
+
}
|
|
55
|
+
const MAX_BODY = 1 << 20;
|
|
56
|
+
export async function startHookServer(sockPath, onEvent, onStatus = () => { },
|
|
57
|
+
// A permission prompt, held open until `hook` is answered.
|
|
58
|
+
onPermission = (_id, _p, hook) => hook.reply(null)) {
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
const termId = req.headers['x-workoffdesk-session'];
|
|
61
|
+
let size = 0;
|
|
62
|
+
const chunks = [];
|
|
63
|
+
req.on('data', (c) => {
|
|
64
|
+
size += c.length;
|
|
65
|
+
if (size > MAX_BODY)
|
|
66
|
+
req.destroy();
|
|
67
|
+
else
|
|
68
|
+
chunks.push(c);
|
|
69
|
+
});
|
|
70
|
+
req.on('end', () => {
|
|
71
|
+
const held = req.url === '/permission';
|
|
72
|
+
if (!held)
|
|
73
|
+
res.end();
|
|
74
|
+
let payload;
|
|
75
|
+
try {
|
|
76
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
payload = null;
|
|
80
|
+
}
|
|
81
|
+
if (typeof termId !== 'string' || !termId || !payload || typeof payload !== 'object') {
|
|
82
|
+
if (held)
|
|
83
|
+
res.end();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (held) {
|
|
87
|
+
onPermission(termId, payload, {
|
|
88
|
+
reply: (output) => res.writableEnded || res.end(output ? JSON.stringify(output) : ''),
|
|
89
|
+
onGone: (cb) => res.on('close', () => res.writableEnded || cb()),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else if (req.url === '/status')
|
|
93
|
+
onStatus(termId, payload);
|
|
94
|
+
else
|
|
95
|
+
onEvent(termId, payload);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return listenOwnerOnly(server, sockPath);
|
|
99
|
+
}
|
|
100
|
+
// The umask makes the socket owner-only from the moment it exists.
|
|
101
|
+
export async function listenOwnerOnly(server, sockPath) {
|
|
102
|
+
rmSync(sockPath, { force: true });
|
|
103
|
+
const oldMask = process.umask(0o177);
|
|
104
|
+
try {
|
|
105
|
+
await new Promise((resolve, reject) => {
|
|
106
|
+
server.once('error', reject);
|
|
107
|
+
server.listen(sockPath, () => resolve());
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
process.umask(oldMask);
|
|
112
|
+
}
|
|
113
|
+
return server;
|
|
114
|
+
}
|