patchwork-os 0.2.0-beta.0 → 0.2.0-beta.2

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.
Files changed (143) hide show
  1. package/README.md +4 -1
  2. package/deploy/deploy-dashboard.sh +25 -1
  3. package/deploy/macos/README.md +153 -0
  4. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  5. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  6. package/deploy/macos/install-mac-bridge.sh +244 -0
  7. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  8. package/dist/analyticsAggregator.d.ts +5 -1
  9. package/dist/analyticsAggregator.js +15 -4
  10. package/dist/analyticsAggregator.js.map +1 -1
  11. package/dist/analyticsPrefs.d.ts +11 -0
  12. package/dist/analyticsPrefs.js +33 -0
  13. package/dist/analyticsPrefs.js.map +1 -1
  14. package/dist/approvalHttp.d.ts +14 -0
  15. package/dist/approvalHttp.js +172 -1
  16. package/dist/approvalHttp.js.map +1 -1
  17. package/dist/approvalQueue.d.ts +27 -2
  18. package/dist/approvalQueue.js +44 -7
  19. package/dist/approvalQueue.js.map +1 -1
  20. package/dist/automation.d.ts +34 -3
  21. package/dist/automation.js +85 -10
  22. package/dist/automation.js.map +1 -1
  23. package/dist/bridge.js +39 -27
  24. package/dist/bridge.js.map +1 -1
  25. package/dist/claudeDriver.d.ts +0 -16
  26. package/dist/claudeDriver.js +19 -20
  27. package/dist/claudeDriver.js.map +1 -1
  28. package/dist/claudeMdPatch.d.ts +9 -3
  29. package/dist/claudeMdPatch.js +79 -13
  30. package/dist/claudeMdPatch.js.map +1 -1
  31. package/dist/claudeOrchestrator.d.ts +12 -0
  32. package/dist/claudeOrchestrator.js +7 -2
  33. package/dist/claudeOrchestrator.js.map +1 -1
  34. package/dist/commands/marketplace.d.ts +15 -10
  35. package/dist/commands/marketplace.js +27 -115
  36. package/dist/commands/marketplace.js.map +1 -1
  37. package/dist/commands/recipe.js +10 -1
  38. package/dist/commands/recipe.js.map +1 -1
  39. package/dist/commitIssueLinkLog.d.ts +8 -0
  40. package/dist/commitIssueLinkLog.js +53 -1
  41. package/dist/commitIssueLinkLog.js.map +1 -1
  42. package/dist/config.d.ts +11 -3
  43. package/dist/config.js +32 -2
  44. package/dist/config.js.map +1 -1
  45. package/dist/connectorRoutes.js +63 -372
  46. package/dist/connectorRoutes.js.map +1 -1
  47. package/dist/connectors/baseConnector.js +25 -3
  48. package/dist/connectors/baseConnector.js.map +1 -1
  49. package/dist/connectors/jira.js +18 -1
  50. package/dist/connectors/jira.js.map +1 -1
  51. package/dist/drivers/claude/subprocess.d.ts +12 -2
  52. package/dist/drivers/claude/subprocess.js +79 -6
  53. package/dist/drivers/claude/subprocess.js.map +1 -1
  54. package/dist/drivers/gemini/api.d.ts +18 -0
  55. package/dist/drivers/gemini/api.js +29 -0
  56. package/dist/drivers/gemini/api.js.map +1 -0
  57. package/dist/drivers/gemini/index.d.ts +22 -0
  58. package/dist/drivers/gemini/index.js +240 -129
  59. package/dist/drivers/gemini/index.js.map +1 -1
  60. package/dist/drivers/index.d.ts +3 -1
  61. package/dist/drivers/index.js +9 -1
  62. package/dist/drivers/index.js.map +1 -1
  63. package/dist/drivers/local/index.d.ts +43 -0
  64. package/dist/drivers/local/index.js +140 -0
  65. package/dist/drivers/local/index.js.map +1 -0
  66. package/dist/drivers/openai/index.js +30 -2
  67. package/dist/drivers/openai/index.js.map +1 -1
  68. package/dist/extensionClient.d.ts +8 -0
  69. package/dist/extensionClient.js +24 -2
  70. package/dist/extensionClient.js.map +1 -1
  71. package/dist/fp/automationInterpreter.d.ts +9 -1
  72. package/dist/fp/automationInterpreter.js +151 -34
  73. package/dist/fp/automationInterpreter.js.map +1 -1
  74. package/dist/fp/automationProgram.d.ts +30 -0
  75. package/dist/fp/automationProgram.js.map +1 -1
  76. package/dist/fp/automationState.d.ts +23 -4
  77. package/dist/fp/automationState.js +28 -4
  78. package/dist/fp/automationState.js.map +1 -1
  79. package/dist/fp/interpreterContext.d.ts +66 -1
  80. package/dist/fp/interpreterContext.js +140 -1
  81. package/dist/fp/interpreterContext.js.map +1 -1
  82. package/dist/fp/policyParser.js +29 -1
  83. package/dist/fp/policyParser.js.map +1 -1
  84. package/dist/httpErrorResponse.d.ts +36 -0
  85. package/dist/httpErrorResponse.js +46 -0
  86. package/dist/httpErrorResponse.js.map +1 -0
  87. package/dist/inboxRoutes.js +90 -11
  88. package/dist/inboxRoutes.js.map +1 -1
  89. package/dist/index.d.ts +1 -1
  90. package/dist/index.js +3 -2
  91. package/dist/index.js.map +1 -1
  92. package/dist/oauth.d.ts +22 -0
  93. package/dist/oauth.js +46 -0
  94. package/dist/oauth.js.map +1 -1
  95. package/dist/oauthRoutes.js +3 -8
  96. package/dist/oauthRoutes.js.map +1 -1
  97. package/dist/patchworkConfig.d.ts +30 -1
  98. package/dist/patchworkConfig.js +99 -4
  99. package/dist/patchworkConfig.js.map +1 -1
  100. package/dist/preToolUseHook.js +7 -1
  101. package/dist/preToolUseHook.js.map +1 -1
  102. package/dist/prompts.js +4 -0
  103. package/dist/prompts.js.map +1 -1
  104. package/dist/recipeOrchestration.js +13 -3
  105. package/dist/recipeOrchestration.js.map +1 -1
  106. package/dist/recipeRoutes.d.ts +5 -0
  107. package/dist/recipeRoutes.js +57 -33
  108. package/dist/recipeRoutes.js.map +1 -1
  109. package/dist/recipes/agentExecutor.d.ts +10 -1
  110. package/dist/recipes/agentExecutor.js +5 -4
  111. package/dist/recipes/agentExecutor.js.map +1 -1
  112. package/dist/recipes/scheduler.d.ts +7 -0
  113. package/dist/recipes/scheduler.js +30 -13
  114. package/dist/recipes/scheduler.js.map +1 -1
  115. package/dist/recipes/schema.d.ts +6 -0
  116. package/dist/recipes/tools/file.js +5 -2
  117. package/dist/recipes/tools/file.js.map +1 -1
  118. package/dist/recipes/tools/gmail.js +18 -1
  119. package/dist/recipes/tools/gmail.js.map +1 -1
  120. package/dist/recipes/yamlRunner.d.ts +32 -2
  121. package/dist/recipes/yamlRunner.js +71 -6
  122. package/dist/recipes/yamlRunner.js.map +1 -1
  123. package/dist/recipesHttp.d.ts +17 -1
  124. package/dist/recipesHttp.js +68 -4
  125. package/dist/recipesHttp.js.map +1 -1
  126. package/dist/server.d.ts +52 -1
  127. package/dist/server.js +427 -248
  128. package/dist/server.js.map +1 -1
  129. package/dist/streamableHttp.d.ts +9 -4
  130. package/dist/streamableHttp.js +17 -9
  131. package/dist/streamableHttp.js.map +1 -1
  132. package/dist/tools/openInBrowser.js +6 -1
  133. package/dist/tools/openInBrowser.js.map +1 -1
  134. package/dist/tools/runCommand.js +5 -0
  135. package/dist/tools/runCommand.js.map +1 -1
  136. package/dist/tools/terminal.js +4 -0
  137. package/dist/tools/terminal.js.map +1 -1
  138. package/dist/tools/utils.d.ts +4 -0
  139. package/dist/tools/utils.js +62 -0
  140. package/dist/tools/utils.js.map +1 -1
  141. package/package.json +2 -2
  142. package/scripts/start-all.sh +4 -2
  143. package/templates/recipes/approval-queue-ui-test.yaml +205 -0
package/README.md CHANGED
@@ -33,6 +33,8 @@ Same codebase. Bridge is the foundation; Patchwork OS is the optional layer on t
33
33
 
34
34
  ## 🔌 Claude IDE Bridge — Quick Start
35
35
 
36
+ **Prerequisites:** a supported code editor — **VS Code, Cursor, Windsurf, or Google Antigravity** (or JetBrains via the [companion plugin](#jetbrains-plugin)) — plus Node.js 20+ and the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code). The bridge's LSP, debugger, and editor-state tools run through the editor extension; without one you're limited to the headless CLI subset.
37
+
36
38
  ```bash
37
39
  # 1. Install the npm package
38
40
  npm install -g patchwork-os
@@ -302,7 +304,8 @@ Systemd service and deploy scripts in [`deploy/`](deploy/). Full guide: [docs/re
302
304
  | Connectors: Linear, Sentry, Slack, Google Calendar, Intercom, HubSpot, Datadog, Stripe | **shipped** |
303
305
  | Cross-session memory (traces, handoff notes) | **shipped** |
304
306
  | Mobile oversight PWA (push approvals) | **shipped (beta)** |
305
- | Community recipe marketplace | Q3 2026 |
307
+ | Community recipe bundles (`patchwork recipe install github:<org>/<repo>`) | **shipped** |
308
+ | Community recipe registry / discovery UI | TBD |
306
309
 
307
310
  ---
308
311
 
@@ -39,9 +39,17 @@ echo "==> Copying tarball to VPS..."
39
39
  scp "$TARBALL" "$VPS:/tmp/patchwork-dashboard.tar.gz"
40
40
 
41
41
  echo "==> Deploying on VPS..."
42
+ # Pass secrets as positional args (NOT inside the heredoc body) so the
43
+ # single-quoted heredoc still preserves remote-shell `$X` references but
44
+ # the operator's local env reaches the VPS. Without this, the previous
45
+ # `${PATCHWORK_BRIDGE_TOKEN:-REPLACE_ME}` inside the heredoc evaluated on
46
+ # the remote, where the var doesn't exist, and always wrote REPLACE_ME.
42
47
  # shellcheck disable=SC2087
43
- ssh "$VPS" bash <<'REMOTE'
48
+ ssh "$VPS" bash -s -- "${PATCHWORK_BRIDGE_TOKEN:-REPLACE_ME}" "${DASHBOARD_PASSWORD:-}" <<'REMOTE'
44
49
  set -euo pipefail
50
+ # `${N:-}` so an empty/missing positional arg doesn't trip `set -u`.
51
+ PATCHWORK_BRIDGE_TOKEN="${1:-REPLACE_ME}"
52
+ DASHBOARD_PASSWORD="${2:-}"
45
53
  REMOTE_DIR="/opt/patchwork-dashboard"
46
54
  PM2_NAME="patchwork-dashboard"
47
55
  PORT=3200
@@ -52,10 +60,26 @@ if pm2 list | grep -q "$PM2_NAME"; then
52
60
  pm2 delete "$PM2_NAME" || true
53
61
  fi
54
62
 
63
+ # Preserve .env.local across the deploy. Without this stash/restore, the
64
+ # `rm -rf "$REMOTE_DIR"` below blows away every secret the operator pasted
65
+ # (VAPID, PATCHWORK_PUSH_TOKEN, custom DASHBOARD_PASSWORD), and the "if
66
+ # already exists, preserve" branch later in this script never fires —
67
+ # the file no longer exists by then.
68
+ ENV_BACKUP=""
69
+ if [ -f "$REMOTE_DIR/.env.local" ]; then
70
+ ENV_BACKUP="$(mktemp /tmp/patchwork-env.XXXXXX)"
71
+ cp -p "$REMOTE_DIR/.env.local" "$ENV_BACKUP"
72
+ fi
73
+
55
74
  # Wipe and recreate deploy dir
56
75
  rm -rf "$REMOTE_DIR"
57
76
  mkdir -p "$REMOTE_DIR"
58
77
 
78
+ if [ -n "$ENV_BACKUP" ] && [ -f "$ENV_BACKUP" ]; then
79
+ cp -p "$ENV_BACKUP" "$REMOTE_DIR/.env.local"
80
+ rm -f "$ENV_BACKUP"
81
+ fi
82
+
59
83
  # Extract
60
84
  tar -xzf /tmp/patchwork-dashboard.tar.gz -C "$REMOTE_DIR"
61
85
  rm /tmp/patchwork-dashboard.tar.gz
@@ -0,0 +1,153 @@
1
+ # macOS launchd setup for the bridge
2
+
3
+ Make the local `claude-ide-bridge` and the SSH reverse tunnel to your
4
+ self-hosted dashboard persistent on your Mac:
5
+
6
+ - Auto-start at login
7
+ - Auto-restart on crash (bridge has its own `--watch` supervisor; launchd
8
+ is the supervisor of the supervisor)
9
+ - Auto-reconnect on network change / sleep / wake (autossh)
10
+ - No tmux session to keep alive, no terminal to leave open
11
+
12
+ ## One-time setup
13
+
14
+ 1. **Install the patchwork CLI globally** (not via `npm link`). LaunchAgents
15
+ run in a tighter sandbox; symlinked installs into `~/Documents` /
16
+ `~/Desktop` / `~/Downloads` fail with `EPERM`:
17
+ ```bash
18
+ npm install -g patchwork-os
19
+ # or, from a local repo:
20
+ # npm pack && npm install -g patchwork-os-*.tgz
21
+ ```
22
+
23
+ 2. **Make sure you can SSH to the VPS without a password prompt.** The
24
+ tunnel runs unattended; if SSH would prompt for a key passphrase,
25
+ add the key to your agent (`ssh-add ~/.ssh/id_ed25519`) or use a
26
+ passphrase-less key.
27
+
28
+ 3. **(Recommended) Pin the SSH target in `~/.ssh/config` first.** Using
29
+ the public domain as the SSH target is fragile — DNS drifts during
30
+ redeploys (CDN edges, transient IPs), so SSH lands on a different
31
+ machine and the host key changes. Add an alias once and use it
32
+ everywhere:
33
+
34
+ ```ssh-config
35
+ # ~/.ssh/config
36
+ Host pw-bridge
37
+ HostName 185.167.97.141 # your VPS IP — stable across DNS shifts
38
+ User wesh # or root, whatever your VPS allows
39
+ IdentityFile ~/.ssh/id_ed25519
40
+ ServerAliveInterval 30
41
+ ServerAliveCountMax 3
42
+ UserKnownHostsFile ~/.ssh/known_hosts.patchwork
43
+ ```
44
+
45
+ Verify it works once: `ssh pw-bridge "echo ok"`. The host key is
46
+ accepted into the dedicated `known_hosts.patchwork` file and stays
47
+ there even if you `ssh-keygen -R` your global known_hosts later.
48
+
49
+ 4. **Run the installer** with the alias (cleanest):
50
+ ```bash
51
+ VPS_HOST=pw-bridge bash deploy/macos/install-mac-bridge.sh
52
+ ```
53
+ The installer detects the alias via `ssh -G` and lets ssh_config
54
+ own user + identity + hostname resolution. On a VPS rebuild, you
55
+ only update `~/.ssh/config` — the LaunchAgents pick it up.
56
+
57
+ Without an alias (interactive, prompts for VPS host):
58
+ ```bash
59
+ bash deploy/macos/install-mac-bridge.sh
60
+ # → prompts; recommend the IP (185.167.97.141) over the domain
61
+ ```
62
+ Or fully env-driven:
63
+ ```bash
64
+ VPS_HOST=185.167.97.141 VPS_USER=root \
65
+ BRIDGE_PORT=63906 VPS_PORT=3285 \
66
+ bash deploy/macos/install-mac-bridge.sh
67
+ ```
68
+
69
+ 5. **Sync the bridge token to the VPS** (the installer prints the exact
70
+ `ssh … sed … pm2 restart` command — copy-paste).
71
+
72
+ ## What runs after install
73
+
74
+ ```
75
+ ~/Library/LaunchAgents/com.patchwork.bridge.plist
76
+ ~/Library/LaunchAgents/com.patchwork.tunnel.plist
77
+ ```
78
+
79
+ Both are loaded into the per-user `gui/$UID` launchd domain at install
80
+ time and at every login.
81
+
82
+ ```
83
+ [claude-ide-bridge --port 63906 --fixed-token <…> --watch]
84
+
85
+ │ 127.0.0.1:63906
86
+
87
+ [autossh -R 127.0.0.1:3285:localhost:63906]
88
+
89
+ │ encrypted SSH reverse tunnel
90
+
91
+ VPS:3285 ──── nginx ──── dashboard `/api/bridge/*`
92
+ ```
93
+
94
+ ## Logs
95
+
96
+ ```bash
97
+ tail -f ~/Library/Logs/patchwork-bridge.log
98
+ tail -f ~/Library/Logs/patchwork-tunnel.log
99
+ ```
100
+
101
+ ## Status
102
+
103
+ ```bash
104
+ launchctl print gui/$UID/com.patchwork.bridge
105
+ launchctl print gui/$UID/com.patchwork.tunnel
106
+ ```
107
+
108
+ ## Restart manually
109
+
110
+ ```bash
111
+ launchctl kickstart -k gui/$UID/com.patchwork.bridge
112
+ launchctl kickstart -k gui/$UID/com.patchwork.tunnel
113
+ ```
114
+
115
+ ## Uninstall
116
+
117
+ ```bash
118
+ bash deploy/macos/uninstall-mac-bridge.sh
119
+ ```
120
+
121
+ ## Troubleshooting
122
+
123
+ - **`com.patchwork.bridge` exits with status 78 / `EPERM`** — the bridge
124
+ CLI is installed via `npm link` and points into `~/Documents`. The
125
+ macOS sandbox blocks LaunchAgents from reading that tree. Reinstall
126
+ globally as a real package (see step 1).
127
+ - **`com.patchwork.tunnel` keeps respawning** — check the SSH key is
128
+ added to `ssh-agent` (`ssh-add -l`). LaunchAgents run before the
129
+ user's shell rc, so an interactive `ssh-add` from `.zshrc` doesn't
130
+ apply. Use a passphrase-less key, or store the key in macOS Keychain
131
+ with `ssh-add --apple-use-keychain`.
132
+ - **Tunnel succeeds but dashboard says offline** — VPS port already in
133
+ use by an old leftover bridge. SSH to the VPS and `pkill -f "autossh\|sshd: .*\@notty"`,
134
+ or pick a different `VPS_PORT`.
135
+ - **Dashboard 401s on every bridge call** — `PATCHWORK_BRIDGE_TOKEN`
136
+ on the VPS doesn't match the `--fixed-token` value the bridge is
137
+ using. Re-run the install script's printed `sed … pm2 restart`
138
+ command.
139
+ - **`Host key for X has changed and you have requested strict
140
+ checking`** every now and then — the SSH target is moving. Two
141
+ causes:
142
+ 1. **DNS drift.** The public domain resolves to a different IP than
143
+ it did last time (CDN edge, redeploy churn). `dig +short
144
+ bridge.your.tld` vs your known VPS IP — if those differ, switch
145
+ the `VPS_HOST` to the IP or a `~/.ssh/config` alias and re-run
146
+ the installer.
147
+ 2. **VPS rebuild.** A reimage regenerates `/etc/ssh/ssh_host_*_key`
148
+ files. Either back them up before the rebuild and restore after,
149
+ or accept the new key once with `ssh-keygen -R <host> && ssh
150
+ <host>`. The launchd tunnel uses `StrictHostKeyChecking=accept-new`
151
+ (only auto-trusts on first connect, not after change), so you'll
152
+ see the warning, accept the new key interactively once, and the
153
+ tunnel resumes.
@@ -0,0 +1,54 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.patchwork.bridge</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>{{BRIDGE_BIN}}</string>
11
+ <string>--port</string>
12
+ <string>{{BRIDGE_PORT}}</string>
13
+ <string>--fixed-token</string>
14
+ <string>{{BRIDGE_TOKEN}}</string>
15
+ <string>--watch</string>
16
+ <string>--workspace</string>
17
+ <string>{{WORKSPACE}}</string>
18
+ </array>
19
+
20
+ <key>RunAtLoad</key>
21
+ <true/>
22
+ <!-- KeepAlive is redundant with `--watch` (which has its own
23
+ exponential-backoff supervisor and recovers from crashes faster
24
+ than launchd's per-second relaunch). Leave it true as belt-and-
25
+ suspenders in case `--watch` itself dies (its supervisor process
26
+ is a single Node, and that's the one launchd is keeping alive). -->
27
+ <key>KeepAlive</key>
28
+ <true/>
29
+
30
+ <key>StandardOutPath</key>
31
+ <string>{{HOME}}/Library/Logs/patchwork-bridge.log</string>
32
+ <key>StandardErrorPath</key>
33
+ <string>{{HOME}}/Library/Logs/patchwork-bridge.log</string>
34
+
35
+ <key>WorkingDirectory</key>
36
+ <string>{{WORKSPACE}}</string>
37
+
38
+ <key>EnvironmentVariables</key>
39
+ <dict>
40
+ <!-- launchd's default PATH doesn't include Homebrew. Bridge spawns
41
+ child processes (claude CLI, git, npm) via PATH lookup, so
42
+ missing /opt/homebrew/bin breaks half the tools. -->
43
+ <key>PATH</key>
44
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
45
+ <key>HOME</key>
46
+ <string>{{HOME}}</string>
47
+ </dict>
48
+
49
+ <!-- Throttle: don't relaunch faster than every 10 s on rapid crash
50
+ loops, prevents spinning up if the binary is broken. -->
51
+ <key>ThrottleInterval</key>
52
+ <integer>10</integer>
53
+ </dict>
54
+ </plist>
@@ -0,0 +1,76 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.patchwork.tunnel</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>{{AUTOSSH_BIN}}</string>
11
+ <!-- AUTOSSH_PORT=0 disables the legacy monitoring-port heartbeat in
12
+ favor of SSH's own ServerAliveInterval/CountMax. Modern autossh
13
+ docs recommend this; the monitoring port is unreliable behind
14
+ NAT and consumes another tunnel slot. -->
15
+ <string>-M</string>
16
+ <string>0</string>
17
+ <!-- -N: no remote command -T: no pseudo-tty -->
18
+ <string>-N</string>
19
+ <string>-T</string>
20
+ <string>-R</string>
21
+ <string>127.0.0.1:{{VPS_PORT}}:localhost:{{BRIDGE_PORT}}</string>
22
+ <!-- ServerAliveInterval=30 + CountMax=3 = ssh kills the link if
23
+ the server doesn't respond for 90 s, autossh restarts. Critical
24
+ for laptop sleep/wake and Wi-Fi switches. -->
25
+ <string>-o</string>
26
+ <string>ServerAliveInterval=30</string>
27
+ <string>-o</string>
28
+ <string>ServerAliveCountMax=3</string>
29
+ <!-- ExitOnForwardFailure=yes makes autossh exit + retry if the
30
+ remote port is already bound. Otherwise the tunnel "succeeds"
31
+ but no traffic flows. -->
32
+ <string>-o</string>
33
+ <string>ExitOnForwardFailure=yes</string>
34
+ <!-- Don't auto-add new host keys (security: surface key changes
35
+ instead of silently trusting). Initial setup expects the user
36
+ to have already SSH'd at least once and accepted the key. -->
37
+ <string>-o</string>
38
+ <string>StrictHostKeyChecking=accept-new</string>
39
+ <string>-i</string>
40
+ <string>{{SSH_KEY}}</string>
41
+ <!-- SSH_TARGET is rendered by install-mac-bridge.sh:
42
+ - "user@host" form when host is an IP or hostname
43
+ - bare alias when host matches a `~/.ssh/config` Host entry
44
+ (so ssh_config's User + HostName + IdentityFile resolution
45
+ works as designed; passing user@alias would override the
46
+ config's User directive and confuse the alias case). -->
47
+ <string>{{SSH_TARGET}}</string>
48
+ </array>
49
+
50
+ <key>RunAtLoad</key>
51
+ <true/>
52
+ <key>KeepAlive</key>
53
+ <true/>
54
+
55
+ <key>StandardOutPath</key>
56
+ <string>{{HOME}}/Library/Logs/patchwork-tunnel.log</string>
57
+ <key>StandardErrorPath</key>
58
+ <string>{{HOME}}/Library/Logs/patchwork-tunnel.log</string>
59
+
60
+ <key>EnvironmentVariables</key>
61
+ <dict>
62
+ <key>PATH</key>
63
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
64
+ <key>HOME</key>
65
+ <string>{{HOME}}</string>
66
+ <!-- AUTOSSH_GATETIME=0: don't require a "stable" connection time
67
+ before counting reconnect attempts. Default 30 s makes startup
68
+ after a long sleep slower than necessary. -->
69
+ <key>AUTOSSH_GATETIME</key>
70
+ <string>0</string>
71
+ </dict>
72
+
73
+ <key>ThrottleInterval</key>
74
+ <integer>10</integer>
75
+ </dict>
76
+ </plist>
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env bash
2
+ # install-mac-bridge.sh — make the bridge + reverse tunnel persistent on macOS
3
+ # via launchd LaunchAgents (auto-start at login, auto-restart on crash,
4
+ # auto-reconnect on network change via autossh).
5
+ #
6
+ # Usage (interactive):
7
+ # bash deploy/macos/install-mac-bridge.sh
8
+ #
9
+ # Usage (env-driven, idempotent re-install):
10
+ # VPS_HOST=185.167.97.141 VPS_USER=root BRIDGE_PORT=63906 \
11
+ # VPS_PORT=3285 BRIDGE_TOKEN="$(uuidgen | tr '[:upper:]' '[:lower:]')" \
12
+ # bash deploy/macos/install-mac-bridge.sh
13
+ #
14
+ # Tip: prefer the VPS IP (or a `~/.ssh/config` Host alias) over the
15
+ # public domain. The domain may resolve via DNS that drifts, putting
16
+ # you on a different machine after a redeploy and breaking host-key
17
+ # verification. The IP is whatever your deploy script targets — a
18
+ # stable identity across rebuilds.
19
+ #
20
+ # Using a ~/.ssh/config alias (cleanest):
21
+ # # in ~/.ssh/config:
22
+ # # Host pw-bridge
23
+ # # HostName 185.167.97.141
24
+ # # User wesh
25
+ # # IdentityFile ~/.ssh/id_ed25519
26
+ # VPS_HOST=pw-bridge VPS_USER=wesh \
27
+ # bash deploy/macos/install-mac-bridge.sh
28
+ # # → tunnel.plist uses `pw-bridge` as the SSH target (User from
29
+ # # ssh_config wins over the plist's user@host form when an alias
30
+ # # exists; we still set both so the resolution is explicit).
31
+ #
32
+ # After install:
33
+ # tail -f ~/Library/Logs/patchwork-bridge.log
34
+ # tail -f ~/Library/Logs/patchwork-tunnel.log
35
+ #
36
+ # Update VPS .env.local PATCHWORK_BRIDGE_TOKEN to match the printed token,
37
+ # then `pm2 restart patchwork-dashboard` on the VPS.
38
+ #
39
+ # Uninstall: bash deploy/macos/uninstall-mac-bridge.sh
40
+
41
+ set -euo pipefail
42
+
43
+ # ──────────────────────────────────────────────────────────────────────
44
+ # Sanity checks
45
+ # ──────────────────────────────────────────────────────────────────────
46
+
47
+ if [[ "$OSTYPE" != "darwin"* ]]; then
48
+ echo "This script is for macOS (launchd). Detected: $OSTYPE" >&2
49
+ exit 1
50
+ fi
51
+
52
+ if ! command -v claude-ide-bridge >/dev/null 2>&1; then
53
+ echo "claude-ide-bridge not found on PATH." >&2
54
+ echo "Install with: npm install -g patchwork-os" >&2
55
+ exit 1
56
+ fi
57
+
58
+ # Real-path resolution: a symlinked global install (npm link) breaks the
59
+ # LaunchAgent because launchd's sandbox follows the symlink target into
60
+ # ~/Documents and EPERMs on file IO. Recommend a real install.
61
+ BRIDGE_REAL="$(readlink -f "$(command -v claude-ide-bridge)" 2>/dev/null || \
62
+ python3 -c "import os,sys; print(os.path.realpath('$(command -v claude-ide-bridge)'))")"
63
+ case "$BRIDGE_REAL" in
64
+ *Documents*|*Desktop*|*Downloads*)
65
+ echo "⚠️ claude-ide-bridge is symlinked into a sandboxed user-data folder:"
66
+ echo " $BRIDGE_REAL"
67
+ echo " LaunchAgent startup will fail with EPERM under the macOS sandbox."
68
+ echo " Fix: cd to the patchwork-os repo, run \`npm pack && npm install -g patchwork-os-*.tgz\`"
69
+ echo " Or: \`npm install -g patchwork-os\` from the public registry."
70
+ echo ""
71
+ read -p " Continue anyway? [y/N] " -r yn
72
+ [[ "$yn" =~ ^[Yy]$ ]] || exit 1
73
+ ;;
74
+ esac
75
+
76
+ if ! command -v autossh >/dev/null 2>&1; then
77
+ echo "autossh not found. Installing via Homebrew…"
78
+ if ! command -v brew >/dev/null 2>&1; then
79
+ echo "Homebrew not found. Install from https://brew.sh and re-run." >&2
80
+ exit 1
81
+ fi
82
+ brew install autossh
83
+ fi
84
+
85
+ # ──────────────────────────────────────────────────────────────────────
86
+ # Gather config (env-driven with interactive fallback)
87
+ # ──────────────────────────────────────────────────────────────────────
88
+
89
+ VPS_HOST="${VPS_HOST:-}"
90
+ VPS_USER="${VPS_USER:-$USER}"
91
+ BRIDGE_PORT="${BRIDGE_PORT:-63906}"
92
+ VPS_PORT="${VPS_PORT:-3285}"
93
+ BRIDGE_TOKEN="${BRIDGE_TOKEN:-}"
94
+ WORKSPACE="${WORKSPACE:-$PWD}"
95
+ SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
96
+ [[ -f "$SSH_KEY" ]] || SSH_KEY="$HOME/.ssh/id_rsa"
97
+
98
+ if [[ -z "$VPS_HOST" ]]; then
99
+ cat <<'PROMPT'
100
+
101
+ VPS target — three choices, in increasing order of stability:
102
+
103
+ (1) Public domain e.g. bridge.your.tld
104
+ DNS-resolved each connection. Drifts during redeploys → host-key churn.
105
+
106
+ (2) VPS IP e.g. 185.167.97.141
107
+ Stable per VPS. Doesn't survive a VPS rebuild but doesn't drift between.
108
+
109
+ (3) ~/.ssh/config alias e.g. pw-bridge
110
+ Most stable. Pins IP + identity in one place; the rest of the
111
+ stack (this script, deploy.sh, manual ssh) all use the alias.
112
+ Update one ssh_config entry on a rebuild, everything follows.
113
+
114
+ PROMPT
115
+ read -p "VPS host (IP or ssh alias preferred): " VPS_HOST
116
+ fi
117
+ if [[ -z "$VPS_HOST" ]]; then
118
+ echo "VPS host is required." >&2
119
+ exit 1
120
+ fi
121
+
122
+ # When VPS_HOST is a ~/.ssh/config alias, ssh resolves both User and
123
+ # HostName from the config, so VPS_USER becomes redundant. The plist
124
+ # still passes user@host to be explicit (and to support the IP-direct
125
+ # case where ssh_config has nothing). If `ssh -G $VPS_HOST` reports a
126
+ # resolved hostname different from VPS_HOST, treat that as confirmation
127
+ # the value is an alias.
128
+ if ssh -G "$VPS_HOST" 2>/dev/null | awk '$1 == "hostname"' | grep -qv "^hostname $VPS_HOST$"; then
129
+ echo "Detected '$VPS_HOST' is a ~/.ssh/config alias — host + identity resolved from there."
130
+ IS_SSH_ALIAS=1
131
+ else
132
+ IS_SSH_ALIAS=0
133
+ fi
134
+
135
+ if [[ -z "$BRIDGE_TOKEN" ]]; then
136
+ if command -v uuidgen >/dev/null 2>&1; then
137
+ BRIDGE_TOKEN="$(uuidgen)"
138
+ else
139
+ BRIDGE_TOKEN="$(python3 -c 'import uuid; print(uuid.uuid4())')"
140
+ fi
141
+ echo "Generated bridge token: $BRIDGE_TOKEN"
142
+ fi
143
+
144
+ if [[ ! -f "$SSH_KEY" ]]; then
145
+ echo "SSH key not found: $SSH_KEY" >&2
146
+ echo "Either set SSH_KEY=... or generate one with \`ssh-keygen -t ed25519\`." >&2
147
+ exit 1
148
+ fi
149
+
150
+ # ──────────────────────────────────────────────────────────────────────
151
+ # Render templates
152
+ # ──────────────────────────────────────────────────────────────────────
153
+
154
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
155
+ LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
156
+ LOGS="$HOME/Library/Logs"
157
+ BRIDGE_BIN="$(command -v claude-ide-bridge)"
158
+ AUTOSSH_BIN="$(command -v autossh)"
159
+
160
+ mkdir -p "$LAUNCH_AGENTS" "$LOGS"
161
+
162
+ # SSH target string: bare alias when VPS_HOST is a `~/.ssh/config` Host
163
+ # entry, "user@host" form otherwise. The alias case lets ssh_config own
164
+ # user + identity + hostname resolution, which is the most stable setup
165
+ # (one place to update on a VPS rebuild).
166
+ if [ "$IS_SSH_ALIAS" = "1" ]; then
167
+ SSH_TARGET="$VPS_HOST"
168
+ echo "SSH target: $SSH_TARGET (resolved via ~/.ssh/config)"
169
+ else
170
+ SSH_TARGET="$VPS_USER@$VPS_HOST"
171
+ echo "SSH target: $SSH_TARGET"
172
+ fi
173
+
174
+ render() {
175
+ local tmpl="$1" out="$2"
176
+ sed \
177
+ -e "s|{{BRIDGE_BIN}}|$BRIDGE_BIN|g" \
178
+ -e "s|{{AUTOSSH_BIN}}|$AUTOSSH_BIN|g" \
179
+ -e "s|{{BRIDGE_PORT}}|$BRIDGE_PORT|g" \
180
+ -e "s|{{VPS_PORT}}|$VPS_PORT|g" \
181
+ -e "s|{{BRIDGE_TOKEN}}|$BRIDGE_TOKEN|g" \
182
+ -e "s|{{VPS_HOST}}|$VPS_HOST|g" \
183
+ -e "s|{{SSH_USER}}|$VPS_USER|g" \
184
+ -e "s|{{SSH_TARGET}}|$SSH_TARGET|g" \
185
+ -e "s|{{SSH_KEY}}|$SSH_KEY|g" \
186
+ -e "s|{{WORKSPACE}}|$WORKSPACE|g" \
187
+ -e "s|{{HOME}}|$HOME|g" \
188
+ "$tmpl" >"$out"
189
+ }
190
+
191
+ BRIDGE_PLIST="$LAUNCH_AGENTS/com.patchwork.bridge.plist"
192
+ TUNNEL_PLIST="$LAUNCH_AGENTS/com.patchwork.tunnel.plist"
193
+
194
+ render "$SCRIPT_DIR/com.patchwork.bridge.plist.template" "$BRIDGE_PLIST"
195
+ render "$SCRIPT_DIR/com.patchwork.tunnel.plist.template" "$TUNNEL_PLIST"
196
+
197
+ # Tighten permissions — these contain the bridge token.
198
+ chmod 600 "$BRIDGE_PLIST" "$TUNNEL_PLIST"
199
+
200
+ # ──────────────────────────────────────────────────────────────────────
201
+ # (Re)load
202
+ # ──────────────────────────────────────────────────────────────────────
203
+
204
+ # bootout removes the old service if present (idempotent re-install).
205
+ # Errors are non-fatal — first run won't have anything to bootout.
206
+ launchctl bootout "gui/$UID" "$BRIDGE_PLIST" 2>/dev/null || true
207
+ launchctl bootout "gui/$UID" "$TUNNEL_PLIST" 2>/dev/null || true
208
+
209
+ launchctl bootstrap "gui/$UID" "$BRIDGE_PLIST"
210
+ launchctl bootstrap "gui/$UID" "$TUNNEL_PLIST"
211
+
212
+ launchctl enable "gui/$UID/com.patchwork.bridge"
213
+ launchctl enable "gui/$UID/com.patchwork.tunnel"
214
+
215
+ # ──────────────────────────────────────────────────────────────────────
216
+ # Status
217
+ # ──────────────────────────────────────────────────────────────────────
218
+
219
+ cat <<INFO
220
+
221
+ ────────────────────────────────────────────────────────────────────────
222
+ Installed.
223
+
224
+ Bridge: 127.0.0.1:$BRIDGE_PORT
225
+ Tunnel: -> $SSH_TARGET:$VPS_PORT
226
+ Token: $BRIDGE_TOKEN
227
+
228
+ Logs:
229
+ ~/Library/Logs/patchwork-bridge.log
230
+ ~/Library/Logs/patchwork-tunnel.log
231
+
232
+ Status:
233
+ launchctl print gui/$UID/com.patchwork.bridge
234
+ launchctl print gui/$UID/com.patchwork.tunnel
235
+
236
+ Update the VPS dashboard's PATCHWORK_BRIDGE_TOKEN to match the token
237
+ above, then \`pm2 restart patchwork-dashboard\` on the VPS so the new
238
+ token takes effect:
239
+
240
+ ssh $SSH_TARGET 'sed -i "s|^PATCHWORK_BRIDGE_TOKEN=.*|PATCHWORK_BRIDGE_TOKEN=$BRIDGE_TOKEN|" /opt/patchwork-dashboard/.env.local && pm2 restart patchwork-dashboard'
241
+
242
+ Uninstall: bash deploy/macos/uninstall-mac-bridge.sh
243
+ ────────────────────────────────────────────────────────────────────────
244
+ INFO
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # uninstall-mac-bridge.sh — stop + remove the patchwork bridge and tunnel
3
+ # LaunchAgents installed by install-mac-bridge.sh.
4
+
5
+ set -euo pipefail
6
+
7
+ if [[ "$OSTYPE" != "darwin"* ]]; then
8
+ echo "macOS only." >&2
9
+ exit 1
10
+ fi
11
+
12
+ BRIDGE_PLIST="$HOME/Library/LaunchAgents/com.patchwork.bridge.plist"
13
+ TUNNEL_PLIST="$HOME/Library/LaunchAgents/com.patchwork.tunnel.plist"
14
+
15
+ # bootout: stop + remove the service registration. `|| true` so the
16
+ # script doesn't fail when one of them isn't installed.
17
+ launchctl bootout "gui/$UID" "$BRIDGE_PLIST" 2>/dev/null || true
18
+ launchctl bootout "gui/$UID" "$TUNNEL_PLIST" 2>/dev/null || true
19
+
20
+ rm -f "$BRIDGE_PLIST" "$TUNNEL_PLIST"
21
+
22
+ echo "Uninstalled. Logs preserved at ~/Library/Logs/patchwork-{bridge,tunnel}.log"
@@ -20,9 +20,13 @@ export interface AnalyticsSummary {
20
20
  /**
21
21
  * Build an anonymized summary from raw tool call entries.
22
22
  * Accepts the same shape as ActivityLog.stats() plus raw duration arrays.
23
+ *
24
+ * `salt` is mixed into the SHA256 of plugin tool names so the same plugin
25
+ * hashes differently across installs. Pass `getAnalyticsSalt()` from
26
+ * analyticsPrefs.ts in production. Defaults to "" for tests/back-compat.
23
27
  */
24
28
  export declare function buildSummary(entries: Array<{
25
29
  tool: string;
26
30
  durationMs: number;
27
31
  status: "success" | "error";
28
- }>, sessionDurationMs: number, bridgeVersion: string): AnalyticsSummary;
32
+ }>, sessionDurationMs: number, bridgeVersion: string, salt?: string): AnalyticsSummary;
@@ -73,14 +73,21 @@ const BUILTIN_TOOL_NAMES = new Set([
73
73
  "writeHandoffNote",
74
74
  "logging",
75
75
  ]);
76
- /** Returns the safe tool name to include in analytics. */
77
- function safeToolName(tool) {
76
+ /**
77
+ * Returns the safe tool name to include in analytics.
78
+ * Plugin names are hashed with a per-install salt so the same plugin produces
79
+ * a different hash on a different machine — receivers can't correlate plugin
80
+ * usage across installs. Salt defaults to "" only for tests; production callers
81
+ * MUST pass `getAnalyticsSalt()` from analyticsPrefs.ts.
82
+ */
83
+ function safeToolName(tool, salt) {
78
84
  if (BUILTIN_TOOL_NAMES.has(tool))
79
85
  return tool;
80
86
  // Plugin tool: extract prefix (everything before first underscore) and hash it
81
87
  const prefix = tool.includes("_") ? (tool.split("_")[0] ?? tool) : tool;
82
88
  const hash = crypto
83
89
  .createHash("sha256")
90
+ .update(salt)
84
91
  .update(prefix)
85
92
  .digest("hex")
86
93
  .slice(0, 8);
@@ -97,12 +104,16 @@ function percentiles(sorted) {
97
104
  /**
98
105
  * Build an anonymized summary from raw tool call entries.
99
106
  * Accepts the same shape as ActivityLog.stats() plus raw duration arrays.
107
+ *
108
+ * `salt` is mixed into the SHA256 of plugin tool names so the same plugin
109
+ * hashes differently across installs. Pass `getAnalyticsSalt()` from
110
+ * analyticsPrefs.ts in production. Defaults to "" for tests/back-compat.
100
111
  */
101
- export function buildSummary(entries, sessionDurationMs, bridgeVersion) {
112
+ export function buildSummary(entries, sessionDurationMs, bridgeVersion, salt = "") {
102
113
  // Group by safe tool name
103
114
  const map = new Map();
104
115
  for (const entry of entries) {
105
- const name = safeToolName(entry.tool);
116
+ const name = safeToolName(entry.tool, salt);
106
117
  const s = map.get(name) ?? { calls: 0, errors: 0, durations: [] };
107
118
  s.calls++;
108
119
  if (entry.status === "error")