wherdr 1.3.3 → 2.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.
Files changed (252) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/BDNMzG2s.js +1 -0
  3. package/.output/public/_nuxt/BETID3_E.js +1 -0
  4. package/.output/public/_nuxt/BHenDMeH.js +1 -0
  5. package/.output/public/_nuxt/BYZVrreW.js +1 -0
  6. package/.output/public/_nuxt/BhgehRdJ.js +1 -0
  7. package/.output/public/_nuxt/{yzABg5xl.js → Bpoxvzh6.js} +1 -1
  8. package/.output/public/_nuxt/BtH82ZuZ.js +9 -0
  9. package/.output/public/_nuxt/C25ROUa9.js +3 -0
  10. package/.output/public/_nuxt/C3Bkn4ac.js +62 -0
  11. package/.output/public/_nuxt/CDWjMk9C.js +2 -0
  12. package/.output/public/_nuxt/{Ci8N4mWR.js → CXJnLmWv.js} +1 -1
  13. package/.output/public/_nuxt/CuUQ4mEf.js +1 -0
  14. package/.output/public/_nuxt/DFGEl0v2.js +23 -0
  15. package/.output/public/_nuxt/{CKhgznq4.js → DFmgQVme.js} +42 -39
  16. package/.output/public/_nuxt/DZGKxROq.js +6 -0
  17. package/.output/public/_nuxt/DmkWvmnE.js +2 -0
  18. package/.output/public/_nuxt/DrKKeFFE.js +31 -0
  19. package/.output/public/_nuxt/DyfFwwGq.js +1 -0
  20. package/.output/public/_nuxt/FaNto0ga.js +105 -0
  21. package/.output/public/_nuxt/NIF0VwHk.js +25 -0
  22. package/.output/public/_nuxt/R0Y4VoeX.js +1 -0
  23. package/.output/public/_nuxt/board.CEEcFc6M.css +1 -0
  24. package/.output/public/_nuxt/builds/latest.json +1 -1
  25. package/.output/public/_nuxt/builds/meta/0b28b3e9-664e-4347-a35b-afb0a711d5c5.json +1 -0
  26. package/.output/public/_nuxt/entry.CKoXcwRa.css +2 -0
  27. package/.output/public/_nuxt/pdf.min.t82Z80JU.mjs +28 -0
  28. package/.output/public/_nuxt/pdf.worker.min.wgc6bjNh.mjs +28 -0
  29. package/.output/public/_nuxt/settings.Cdb21nNw.css +1 -0
  30. package/.output/public/sw.js +1 -1
  31. package/.output/server/chunks/_/branding.mjs +1 -1
  32. package/.output/server/chunks/_/changes.mjs +4 -4
  33. package/.output/server/chunks/_/fileDownload.mjs +278 -0
  34. package/.output/server/chunks/_/files.mjs +60 -20
  35. package/.output/server/chunks/_/machineOrder.mjs +1 -1
  36. package/.output/server/chunks/_/onboarding.mjs +1 -1
  37. package/.output/server/chunks/_/package.mjs +11 -3
  38. package/.output/server/chunks/_/plugins.mjs +92 -122
  39. package/.output/server/chunks/_/restart.mjs +1 -1
  40. package/.output/server/chunks/_/selfupdate.mjs +1 -1
  41. package/.output/server/chunks/_/settingstabs.mjs +36 -0
  42. package/.output/server/chunks/_/updates.mjs +1 -1
  43. package/.output/server/chunks/_/worktrees.mjs +1 -1
  44. package/.output/server/chunks/_/ws.mjs +43 -6
  45. package/.output/server/chunks/nitro/nitro.mjs +14126 -10238
  46. package/.output/server/chunks/raw/check-doc-links.mjs +5 -0
  47. package/.output/server/chunks/raw/check-leaks.mjs +1 -1
  48. package/.output/server/chunks/raw/check-release-notes.mjs +5 -0
  49. package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
  50. package/.output/server/chunks/raw/install-claude-statusline.mjs +1 -1
  51. package/.output/server/chunks/raw/prerelease-summary.mjs +5 -0
  52. package/.output/server/chunks/raw/prerelease.mjs +7 -0
  53. package/.output/server/chunks/raw/reveal.mjs +1 -1
  54. package/.output/server/chunks/raw/test-affected.mjs +5 -0
  55. package/.output/server/chunks/routes/api/_..._.mjs +6 -5
  56. package/.output/server/chunks/routes/api/agents.post.mjs +6 -5
  57. package/.output/server/chunks/routes/api/ask-tab.post.mjs +6 -5
  58. package/.output/server/chunks/routes/api/attach.post.mjs +6 -5
  59. package/.output/server/chunks/routes/api/auth/disable.post.mjs +6 -5
  60. package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +6 -5
  61. package/.output/server/chunks/routes/api/auth/lock.post.mjs +6 -5
  62. package/.output/server/chunks/routes/api/auth/login/options.post.mjs +6 -5
  63. package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +6 -5
  64. package/.output/server/chunks/routes/api/auth/max-session.post.mjs +6 -5
  65. package/.output/server/chunks/routes/api/auth/register/options.post.mjs +6 -5
  66. package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +6 -5
  67. package/.output/server/chunks/routes/api/auth/status.get.mjs +6 -5
  68. package/.output/server/chunks/routes/api/changes.get.mjs +7 -6
  69. package/.output/server/chunks/routes/api/chat/image.get.mjs +7 -6
  70. package/.output/server/chunks/routes/api/chat.get.mjs +44 -11
  71. package/.output/server/chunks/routes/api/choose.post.mjs +44 -30
  72. package/.output/server/chunks/routes/api/claude-setup.post.mjs +42 -0
  73. package/.output/server/chunks/routes/api/claude-statusline.post.mjs +7 -6
  74. package/.output/server/chunks/routes/api/clear-queued-input.post.mjs +42 -0
  75. package/.output/server/chunks/routes/api/close/status.get.mjs +7 -6
  76. package/.output/server/chunks/routes/api/close.post.mjs +6 -5
  77. package/.output/server/chunks/routes/api/codex-update.post.mjs +7 -6
  78. package/.output/server/chunks/routes/api/commands.get.mjs +9 -7
  79. package/.output/server/chunks/routes/api/config.get.mjs +10 -13
  80. package/.output/server/chunks/routes/api/dirs.get.mjs +7 -6
  81. package/.output/server/chunks/routes/api/dismiss-stopped.post.mjs +7 -6
  82. package/.output/server/chunks/routes/api/dismiss.post.mjs +7 -6
  83. package/.output/server/chunks/routes/api/effort.post.mjs +7 -6
  84. package/.output/server/chunks/routes/api/efforts.get.mjs +7 -6
  85. package/.output/server/chunks/routes/api/file.post.mjs +7 -6
  86. package/.output/server/chunks/routes/api/files/download.get.mjs +12 -24
  87. package/.output/server/chunks/routes/api/files/download.head.mjs +41 -0
  88. package/.output/server/chunks/routes/api/files/read.get.mjs +8 -7
  89. package/.output/server/chunks/routes/api/files.get.mjs +7 -6
  90. package/.output/server/chunks/routes/api/health.get.mjs +6 -5
  91. package/.output/server/chunks/routes/api/input.post.mjs +33 -15
  92. package/.output/server/chunks/routes/api/interrupt.post.mjs +24 -21
  93. package/.output/server/chunks/routes/api/isgit.get.mjs +8 -8
  94. package/.output/server/chunks/routes/api/machine/awake.get.mjs +7 -6
  95. package/.output/server/chunks/routes/api/machine/awake.post.mjs +7 -6
  96. package/.output/server/chunks/routes/api/machine/order.get.mjs +6 -5
  97. package/.output/server/chunks/routes/api/machine/order.post.mjs +6 -5
  98. package/.output/server/chunks/routes/api/machine/reconnect.post.mjs +40 -0
  99. package/.output/server/chunks/routes/api/machine/rename.post.mjs +7 -6
  100. package/.output/server/chunks/routes/api/machine/status.post.mjs +39 -0
  101. package/.output/server/chunks/routes/api/menu-left.post.mjs +40 -0
  102. package/.output/server/chunks/routes/api/menu.post.mjs +10 -6
  103. package/.output/server/chunks/routes/api/model.post.mjs +7 -6
  104. package/.output/server/chunks/routes/api/models.get.mjs +7 -6
  105. package/.output/server/chunks/routes/api/nav.post.mjs +40 -26
  106. package/.output/server/chunks/routes/api/onboarding.get.mjs +6 -5
  107. package/.output/server/chunks/routes/api/onboarding.post.mjs +6 -5
  108. package/.output/server/chunks/routes/api/phone.get.mjs +8 -10
  109. package/.output/server/chunks/routes/api/phone.post.mjs +8 -10
  110. package/.output/server/chunks/routes/api/plugins/actions.get.mjs +6 -8
  111. package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +6 -8
  112. package/.output/server/chunks/routes/api/plugins/projects.get.mjs +10 -8
  113. package/.output/server/chunks/routes/api/plugins/projects.post.mjs +11 -7
  114. package/.output/server/chunks/routes/api/prompt.post.mjs +22 -13
  115. package/.output/server/chunks/routes/api/push/quiet.post.mjs +7 -6
  116. package/.output/server/chunks/routes/api/push/subscribe.post.mjs +7 -7
  117. package/.output/server/chunks/routes/api/push/test.post.mjs +7 -6
  118. package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +7 -6
  119. package/.output/server/chunks/routes/api/quotas.get.mjs +7 -6
  120. package/.output/server/chunks/routes/api/rename.post.mjs +6 -5
  121. package/.output/server/chunks/routes/api/requeue.post.mjs +7 -6
  122. package/.output/server/chunks/routes/api/restart.get.mjs +6 -5
  123. package/.output/server/chunks/routes/api/restart.post.mjs +6 -5
  124. package/.output/server/chunks/routes/api/reveal.post.mjs +91 -8
  125. package/.output/server/chunks/routes/api/screen-key.post.mjs +19 -15
  126. package/.output/server/chunks/routes/api/screen.get.mjs +8 -40
  127. package/.output/server/chunks/routes/api/search.get.mjs +7 -6
  128. package/.output/server/chunks/routes/api/seen.post.mjs +7 -6
  129. package/.output/server/chunks/routes/api/sessions.get.mjs +7 -6
  130. package/.output/server/chunks/routes/api/sessions.post.mjs +7 -6
  131. package/.output/server/chunks/routes/api/settings-tab.post.mjs +90 -0
  132. package/.output/server/chunks/routes/api/{plugins/thread-limits.get.mjs → settings.get.mjs} +10 -9
  133. package/.output/server/chunks/routes/api/settings.post.mjs +40 -0
  134. package/.output/server/chunks/routes/api/space.post.mjs +34 -18
  135. package/.output/server/chunks/routes/api/state.get.mjs +7 -6
  136. package/.output/server/chunks/routes/api/tasks/action.post.mjs +39 -0
  137. package/.output/server/chunks/routes/api/{plugins/profiles.get.mjs → tasks/add.post.mjs} +12 -14
  138. package/.output/server/chunks/routes/api/tasks/after.post.mjs +39 -0
  139. package/.output/server/chunks/routes/api/tasks/approve.post.mjs +39 -0
  140. package/.output/server/chunks/routes/api/{project/report.get.mjs → tasks/archived.get.mjs} +12 -13
  141. package/.output/server/chunks/routes/api/{gitroot.get.mjs → tasks/autopilot.get.mjs} +13 -14
  142. package/.output/server/chunks/routes/api/tasks/autopilot.post.mjs +39 -0
  143. package/.output/server/chunks/routes/api/{index.get.mjs → tasks/board-settings.get.mjs} +12 -13
  144. package/.output/server/chunks/routes/api/tasks/board-settings.post.mjs +39 -0
  145. package/.output/server/chunks/routes/api/tasks/coordinator.get.mjs +40 -0
  146. package/.output/server/chunks/routes/api/tasks/coordinator.post.mjs +39 -0
  147. package/.output/server/chunks/routes/api/tasks/details.get.mjs +40 -0
  148. package/.output/server/chunks/routes/api/tasks/limit.post.mjs +39 -0
  149. package/.output/server/chunks/routes/api/tasks/links.get.mjs +39 -0
  150. package/.output/server/chunks/routes/api/tasks/models.get.mjs +40 -0
  151. package/.output/server/chunks/routes/api/tasks/pick.post.mjs +39 -0
  152. package/.output/server/chunks/routes/api/tasks/question.post.mjs +39 -0
  153. package/.output/server/chunks/routes/api/tasks/scheduled.get.mjs +40 -0
  154. package/.output/server/chunks/routes/api/{project → tasks}/settings.get.mjs +10 -11
  155. package/.output/server/chunks/routes/api/tasks/start.get.mjs +40 -0
  156. package/.output/server/chunks/routes/api/tasks/tell.post.mjs +39 -0
  157. package/.output/server/chunks/routes/api/tasks.get.mjs +39 -0
  158. package/.output/server/chunks/routes/api/unqueue.post.mjs +7 -6
  159. package/.output/server/chunks/routes/api/update/status.get.mjs +6 -5
  160. package/.output/server/chunks/routes/api/update.get.mjs +8 -10
  161. package/.output/server/chunks/routes/api/update.post.mjs +8 -10
  162. package/.output/server/chunks/routes/api/upload.post.mjs +7 -6
  163. package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +6 -5
  164. package/.output/server/chunks/routes/api/worktrees.get.mjs +6 -5
  165. package/.output/server/chunks/routes/favicon.ico.get.mjs +6 -5
  166. package/.output/server/chunks/routes/icons/_name_.get.mjs +7 -6
  167. package/.output/server/chunks/routes/renderer.mjs +8 -7
  168. package/.output/server/chunks/routes/uploads/_...name_.get.mjs +7 -6
  169. package/.output/server/chunks/routes/ws/_..._.mjs +7 -6
  170. package/.output/server/chunks/routes/ws/events.mjs +9 -7
  171. package/.output/server/chunks/routes/ws/term.mjs +7 -6
  172. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  173. package/.output/server/index.mjs +7 -6
  174. package/.output/server/package.json +1 -1
  175. package/README.md +44 -1424
  176. package/bin/lib/commands.mjs +44 -9
  177. package/bin/lib/panel.mjs +3 -11
  178. package/bin/lib/service.mjs +41 -2
  179. package/bin/lib/skill.mjs +1 -1
  180. package/bin/lib/tailnet.d.mts +6 -3
  181. package/bin/lib/tailnet.mjs +70 -32
  182. package/bin/lib/userpath.d.mts +14 -0
  183. package/bin/lib/userpath.mjs +142 -0
  184. package/docs/README.md +58 -0
  185. package/docs/RELEASING.md +174 -0
  186. package/docs/board.md +164 -0
  187. package/docs/command.md +58 -0
  188. package/docs/configuration.md +39 -0
  189. package/docs/conversation.md +132 -0
  190. package/docs/development.md +38 -0
  191. package/docs/devices.md +58 -0
  192. package/docs/files.md +29 -0
  193. package/docs/herdr-projects-1x.md +278 -0
  194. package/docs/home.md +52 -0
  195. package/docs/installation.md +174 -0
  196. package/docs/machines.md +69 -0
  197. package/docs/migrate-from-1.x-docker.md +160 -0
  198. package/docs/networks.md +154 -0
  199. package/docs/notifications.md +83 -0
  200. package/docs/overview.md +71 -0
  201. package/docs/phone.md +26 -0
  202. package/docs/plugins.md +109 -0
  203. package/docs/quick-start.md +89 -0
  204. package/docs/quotas.md +65 -0
  205. package/docs/release-notes-2.0.0.md +84 -0
  206. package/docs/security.md +117 -0
  207. package/docs/terminal.md +15 -0
  208. package/docs/troubleshooting.md +45 -0
  209. package/package.json +11 -3
  210. package/skills/wherdr/SKILL.md +24 -167
  211. package/.output/public/_nuxt/B4Yk2tB1.js +0 -23
  212. package/.output/public/_nuxt/BBw_0a4M.js +0 -6
  213. package/.output/public/_nuxt/BSy6OCIY.js +0 -1
  214. package/.output/public/_nuxt/BX5PM2JQ.js +0 -1
  215. package/.output/public/_nuxt/BgPDnf2S.js +0 -1
  216. package/.output/public/_nuxt/BkOxQzbS.js +0 -2
  217. package/.output/public/_nuxt/CP8o925U.js +0 -1
  218. package/.output/public/_nuxt/CWwyK2XK.js +0 -2
  219. package/.output/public/_nuxt/CkbmecwS.js +0 -1
  220. package/.output/public/_nuxt/Cr3SrGky.js +0 -1
  221. package/.output/public/_nuxt/DAg9fiVh.js +0 -89
  222. package/.output/public/_nuxt/DT8qZ0Wv.js +0 -1
  223. package/.output/public/_nuxt/DaaJIRB3.js +0 -1
  224. package/.output/public/_nuxt/DcJi16XA.js +0 -1
  225. package/.output/public/_nuxt/SiL1FJ5s.js +0 -3
  226. package/.output/public/_nuxt/VpjCgSXu.js +0 -36
  227. package/.output/public/_nuxt/builds/meta/c735b6c0-1bd0-47d7-a208-d310f0a06287.json +0 -1
  228. package/.output/public/_nuxt/dPxB2L94.js +0 -89
  229. package/.output/public/_nuxt/entry.CGWKmYU3.css +0 -2
  230. package/.output/public/_nuxt/xYX85SPI.js +0 -1
  231. package/.output/server/chunks/_/pluginPolicy.mjs +0 -93
  232. package/.output/server/chunks/_/projectBoard.mjs +0 -366
  233. package/.output/server/chunks/_/projectDocs.mjs +0 -106
  234. package/.output/server/chunks/_/projectInbox.mjs +0 -135
  235. package/.output/server/chunks/_/projectThreads.mjs +0 -46
  236. package/.output/server/chunks/_/projectThreads2.mjs +0 -61
  237. package/.output/server/chunks/_/projectsActions.mjs +0 -143
  238. package/.output/server/chunks/_/projectsPlugin.mjs +0 -56
  239. package/.output/server/chunks/_/reveal.mjs +0 -115
  240. package/.output/server/chunks/raw/dev.wherdr.open.mjs +0 -7
  241. package/.output/server/chunks/raw/docker-entrypoint.mjs +0 -7
  242. package/.output/server/chunks/raw/install-host-open.mjs +0 -7
  243. package/.output/server/chunks/raw/wherdr-open.mjs +0 -7
  244. package/.output/server/chunks/routes/api/board.get.mjs +0 -147
  245. package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +0 -45
  246. package/.output/server/chunks/routes/api/project/inbox.get.mjs +0 -43
  247. package/.output/server/chunks/routes/api/project/inbox.post.mjs +0 -43
  248. package/.output/server/chunks/routes/api/project/memory.get.mjs +0 -42
  249. package/.output/server/chunks/routes/api/project/routines.get.mjs +0 -43
  250. package/.output/server/chunks/routes/api/project/thread.get.mjs +0 -44
  251. package/.output/server/chunks/routes/api/project/thread.post.mjs +0 -45
  252. package/bin/install-docker-skill.sh +0 -20
@@ -10,7 +10,8 @@ import { pathToFileURL } from 'node:url'
10
10
  import { BIN, CliError, SERVER, VERSION, bad, c, homebrewInstall, localUrl, ok, portTaken, probe, row, say, serverEnv, settingsFile, warn, wherdrDir, withSettings } from './core.mjs'
11
11
  import { RUNTIME_HELP, findRuntime, isBun, runtimeOk, runtimeVersion, temporaryInstall } from './runtime.mjs'
12
12
  import { brewDoctor, brewRedirect, brewService, brewStatusRows } from './brew.mjs'
13
- import { controlService, installService, serviceFile, servicePlatform, serviceState, uninstallService } from './service.mjs'
13
+ import { controlService, enableLinger, installService, lingerOn, serviceFile, servicePlatform, serviceState, uninstallService } from './service.mjs'
14
+ import { loginShellPath, userPath } from './userpath.mjs'
14
15
  import { refreshSkill } from './skill.mjs'
15
16
  import { LINKS, inspect, publishCommand, reachable } from './tailnet.mjs'
16
17
 
@@ -33,7 +34,7 @@ Commands:
33
34
 
34
35
  Options:
35
36
  --port <n> Listening port (default: 7683, or $PORT)
36
- --host <addr> Listening address (default: 127.0.0.1, or $HOST). Keep it on loopback.
37
+ --host <addr> Listening address (default: 127.0.0.1, or $HOST): loopback or this machine's tailnet address.
37
38
  --data-dir <dir> Passkeys, push keys and settings (default: ~/wherdr/data, or $DATA_DIR)
38
39
  --session <name> Named Herdr session to drive (default: Herdr's default session)
39
40
  -v, --version Print the version
@@ -224,7 +225,7 @@ export async function stop(opts, { quiet = false } = {}) {
224
225
  }
225
226
  if (quiet) return false
226
227
  const other = await probe(ctx.port, ctx.host)
227
- if (other) warn(`the wherdr on port ${ctx.port} was not started by \`wherdr start\` (Docker, plugin, a terminal…): left running.`)
228
+ if (other) warn(`the wherdr on port ${ctx.port} was not started by \`wherdr start\` (plugin, a terminal…): left running.`)
228
229
  else say('wherdr is not running.')
229
230
  return false
230
231
  }
@@ -254,7 +255,7 @@ export async function status(opts) {
254
255
  const hb = brew && brewStatusRows(brew)
255
256
  say(`${c.bold('wherdr')} ${VERSION}`)
256
257
  row('Address', answer === 'wherdr' ? `${c.green('●')} ${c.cyan(ctx.url)} answers` : answer ? `${c.yellow('●')} port ${ctx.port} is taken by another program` : `${c.dim('○')} port ${ctx.port} free: not running`)
257
- row('Process', hb?.process ? hb.process : svc.running ? 'login service' : pid ? `pid ${pid} (wherdr start)` : answer === 'wherdr' ? 'started another way (Docker, plugin, a terminal…)' : 'none')
258
+ row('Process', hb?.process ? hb.process : svc.running ? 'login service' : pid ? `pid ${pid} (wherdr start)` : answer === 'wherdr' ? 'started another way (plugin, a terminal…)' : 'none')
258
259
  row('Service', hb ? hb.service : svc.installed ? `installed (${serviceFile()})${svc.running ? '' : ', not running'}` : 'not installed · wherdr service install')
259
260
  row('Data', ctx.env.DATA_DIR)
260
261
  row('Log', hb ? hb.log : ctx.files.log)
@@ -403,20 +404,51 @@ export async function service(opts) {
403
404
  const runtime = findRuntime(ctx.env)
404
405
  if (!runtime) throw new CliError(RUNTIME_HELP)
405
406
  // The background server of `wherdr start` would hold the port.
406
- if (await stop(opts, { quiet: true })) ok('background wherdr stopped (the service takes over).')
407
+ const stopped = await stop(opts, { quiet: true })
408
+ if (stopped) ok('background wherdr stopped (the service takes over).')
407
409
  await checkPort(ctx)
408
410
  remember(ctx)
409
411
  const args = [bin, 'run', '--port', ctx.port, '--host', ctx.host, '--data-dir', ctx.env.DATA_DIR]
410
412
  if (ctx.env.HERDR_WEB_SESSION) args.push('--session', ctx.env.HERDR_WEB_SESSION)
411
- const PATH = [...new Set([path.dirname(runtime), path.join(os.homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'])].join(':')
413
+ // launchd and systemd do not see the shell's PATH: the service gets the
414
+ // user's, so agents in user folders are found by the Board's commands.
415
+ const PATH = servicePath(runtime, ctx.env)
412
416
  const env = { PATH, WHERDR_DIR: ctx.dir, WHERDR_RUNTIME: runtime }
413
- for (const k of ['APP_URL', 'HERDR_BIN', 'HOST_LABEL', 'CLAUDE_CONFIG_DIR', 'WHERDR_SKILL']) if (ctx.env[k]) env[k] = ctx.env[k]
414
- const file = installService({ runtime, args, env, log: ctx.files.log })
417
+ for (const k of ['APP_URL', 'HERDR_BIN', 'HOST_LABEL', 'CLAUDE_CONFIG_DIR', 'WHERDR_SKILL', 'HERDR_PROJECTS_DATA_DIR']) if (ctx.env[k]) env[k] = ctx.env[k]
418
+ let file
419
+ try {
420
+ file = installService({ runtime, args, env, log: ctx.files.log })
421
+ } catch (e) {
422
+ // wherdr was stopped above for the service to take over: started again,
423
+ // as it was (the previous service, or in the background).
424
+ let back = ''
425
+ if (stopped) {
426
+ try {
427
+ await start(opts)
428
+ back = '\n wherdr was started again as before.'
429
+ } catch (err) { back = `\n wherdr could not be started again: ${err.message}` }
430
+ }
431
+ throw new CliError(`${e.message}${back}`)
432
+ }
415
433
  ok(`login service installed: ${file}`)
416
434
  say(c.dim(` ${runtime} ${args.join(' ')}`))
417
435
  if (await waitUp(ctx, () => true)) ok(`wherdr runs: ${c.cyan(ctx.url)} (and starts at every login)`)
418
436
  else failWithLog(`the service did not answer on port ${ctx.port} after 45 s.`, ctx.files.log, 0)
419
- if (servicePlatform() === 'systemd') say(c.dim(' To keep it running while you are logged out: loginctl enable-linger'))
437
+ if (servicePlatform() === 'systemd') {
438
+ // Without lingering, systemd stops the user's services at logout (and
439
+ // starts them only at the next login): a server reached over SSH or from
440
+ // the phone would go away. Allowed for one's own user, without sudo, on
441
+ // most systems; otherwise the command to run is printed.
442
+ const linger = enableLinger()
443
+ if (linger.ok) ok(linger.already ? 'lingering is on: wherdr keeps running when you log out.' : 'lingering enabled (loginctl enable-linger): wherdr keeps running when you log out.')
444
+ else warn(`lingering is off: wherdr stops when you log out. To keep it running, run once: ${c.cyan(`sudo loginctl enable-linger ${os.userInfo().username}`)}`)
445
+ }
446
+ }
447
+
448
+ // The service's PATH: the runtime's folder, then the user's PATH (the login
449
+ // shell's, asked once, herdr-projects' record and the usual folders).
450
+ export function servicePath(runtime, env = process.env, { home = os.homedir(), login = loginShellPath(env), exists } = {}) {
451
+ return userPath({ env, home, first: [path.dirname(runtime)], login, exists })
420
452
  }
421
453
 
422
454
  // ---------------------------------------------------------------- doctor
@@ -478,6 +510,9 @@ export async function doctor(opts) {
478
510
  else if (!svc.installed) warn('Login service: not installed (wherdr service install starts wherdr at login).')
479
511
  else if (svc.running) ok(`Login service: running (${serviceFile()})`)
480
512
  else fail(`Login service: installed but not running (${serviceFile()}). See wherdr logs.`)
513
+ if (!brew && svc.installed && servicePlatform() === 'systemd' && !lingerOn()) {
514
+ warn(`Lingering is off: wherdr stops when you log out (sudo loginctl enable-linger ${os.userInfo().username}).`)
515
+ }
481
516
 
482
517
  try {
483
518
  mkdirSync(ctx.env.DATA_DIR, { recursive: true })
package/bin/lib/panel.mjs CHANGED
@@ -1,10 +1,3 @@
1
- // `wherdr panel`: the Herdr plugin's "panel" action opens it in a popup.
2
- // The state, and two main keys: O opens wherdr, P opens its phone setup
3
- // (Settings › Phone) in the browser. The other commands stay as a fallback,
4
- // in a quieter line. Refreshed every few seconds.
5
- //
6
- // Commands run through a controller: `wherdr` itself, or the Herdr plugin
7
- // script when WHERDR_CONTROL holds its argv as JSON (it knows Docker mode).
8
1
  import { spawn } from 'node:child_process'
9
2
  import os from 'node:os'
10
3
  import path from 'node:path'
@@ -43,7 +36,7 @@ export async function tailnet(port) {
43
36
 
44
37
  export async function gather(ctx, env = process.env, net = null) {
45
38
  const answer = await probe(ctx.port, ctx.host)
46
- const mode = env.WHERDR_MODE === 'docker' ? 'docker' : 'native'
39
+ const mode = 'native'
47
40
  return {
48
41
  version: VERSION,
49
42
  mode,
@@ -55,7 +48,7 @@ export async function gather(ctx, env = process.env, net = null) {
55
48
  port: ctx.port,
56
49
  answer,
57
50
  pid: livePid(ctx.files.pid),
58
- service: mode === 'docker' ? { installed: false, running: false } : serviceState(),
51
+ service: serviceState(),
59
52
  serviceFile: serviceFile(),
60
53
  dir: ctx.dir,
61
54
  tailnet: net ?? await tailnet(ctx.port),
@@ -95,14 +88,13 @@ export function render(state, qrText = '') {
95
88
  out.push(` ${MAIN_KEYS.map(([k, label]) => `${c.bold(k.toUpperCase())} ${label}`).join(' ')}`)
96
89
  out.push('')
97
90
  out.push(` ${c.dim(OTHER_KEYS.map(([k, label]) => `${k.toUpperCase()} ${label}`).join(' '))}`)
98
- out.push(` ${c.dim(`${state.mode === 'docker' ? 'Docker' : `native · ${state.runtime}`} · auto-start ${autostart(state)}`)}`)
91
+ out.push(` ${c.dim(`native · ${state.runtime} · auto-start ${autostart(state)}`)}`)
99
92
  if (state.hotkey) out.push(` ${c.dim(`Herdr key ${state.hotkey} opens this panel`)}`)
100
93
  out.push(` ${c.dim(removeHint(state))}`)
101
94
  return out.join('\n')
102
95
  }
103
96
 
104
97
  function autostart(state) {
105
- if (state.mode === 'docker') return 'yes · Docker restart policy'
106
98
  if (state.service.installed) return `at login (${state.service.running ? 'running' : 'not running'}) · A turns it off`
107
99
  if (state.plugin) return 'with Herdr (plugin startup) · A: also at login'
108
100
  return 'no · A: at login'
@@ -1,7 +1,7 @@
1
1
  // `wherdr service install|uninstall`: start wherdr at login, with the absolute
2
2
  // path of the runtime. macOS: a LaunchAgent; Linux: a systemd --user unit.
3
3
  import { spawnSync } from 'node:child_process'
4
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
5
  import os from 'node:os'
6
6
  import path from 'node:path'
7
7
 
@@ -88,11 +88,24 @@ export function serviceState(home = os.homedir()) {
88
88
  return { installed: true, running: sh('systemctl', ['--user', 'is-active', '--quiet', UNIT]).ok }
89
89
  }
90
90
 
91
- export function installService(spec, home = os.homedir()) {
91
+ // A failure leaves things as they were: the previous service file back (and
92
+ // loaded again), or no file at all, so a later run does not take a half-made
93
+ // service for an installed one. `run` is injectable for the tests.
94
+ export function installService(spec, home = os.homedir(), run = sh) {
92
95
  const file = serviceFile(home)
93
96
  if (!file) throw new Error(`no login service on ${process.platform}: keep wherdr running with your own service manager.`)
94
97
  mkdirSync(path.dirname(file), { recursive: true })
95
98
  mkdirSync(path.dirname(spec.log), { recursive: true })
99
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : null
100
+ try {
101
+ return writeService(file, spec, run)
102
+ } catch (e) {
103
+ restoreService(file, before, run)
104
+ throw e
105
+ }
106
+ }
107
+
108
+ function writeService(file, spec, sh) {
96
109
  if (servicePlatform() === 'launchd') {
97
110
  writeFileSync(file, launchdPlist(spec))
98
111
  sh('launchctl', ['bootout', `${domain()}/${LABEL}`])
@@ -109,6 +122,18 @@ export function installService(spec, home = os.homedir()) {
109
122
  return file
110
123
  }
111
124
 
125
+ function restoreService(file, before, sh) {
126
+ try {
127
+ if (before === null) rmSync(file, { force: true })
128
+ else writeFileSync(file, before)
129
+ if (servicePlatform() === 'launchd') {
130
+ if (before !== null) sh('launchctl', ['bootstrap', domain(), file])
131
+ } else {
132
+ sh('systemctl', ['--user', 'daemon-reload'])
133
+ }
134
+ } catch { /* best effort: the original error is the one to report */ }
135
+ }
136
+
112
137
  export function uninstallService(home = os.homedir()) {
113
138
  const file = serviceFile(home)
114
139
  if (!file || !existsSync(file)) return null
@@ -119,6 +144,20 @@ export function uninstallService(home = os.homedir()) {
119
144
  return file
120
145
  }
121
146
 
147
+ // systemd: is lingering on for this user (its services run without a login)?
148
+ export function lingerOn(user = os.userInfo().username, run = sh) {
149
+ const r = run('loginctl', ['show-user', user, '--property=Linger', '--value'])
150
+ return r.ok && r.out.trim() === 'yes'
151
+ }
152
+
153
+ // Turns lingering on for this user, without sudo (logind allows it for one's
154
+ // own user on most systems). { ok, already }: ok false when it was refused.
155
+ export function enableLinger(user = os.userInfo().username, run = sh) {
156
+ if (lingerOn(user, run)) return { ok: true, already: true }
157
+ const r = run('loginctl', ['enable-linger', user])
158
+ return { ok: r.ok && lingerOn(user, run), already: false }
159
+ }
160
+
122
161
  // start | stop | restart through the service manager.
123
162
  export function controlService(action) {
124
163
  if (servicePlatform() === 'launchd') {
package/bin/lib/skill.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // The shell helper is also used without Node by the Docker / Herdr installer.
1
+ // The shell helper is also used without Node by the Herdr installer.
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
@@ -4,7 +4,6 @@ export interface PhoneAddress { url: string, served: boolean, httpsPort: string,
4
4
  export interface Tailnet {
5
5
  installed: boolean
6
6
  bin?: string | null
7
- // Tailscale's socket, when the state was read there (no CLI: Docker).
8
7
  socket?: string | null
9
8
  name?: string | null
10
9
  connected?: boolean
@@ -30,12 +29,16 @@ export function servedPorts(serveJson: string): string[]
30
29
  export function publishCommand(port: string | number, httpsPort?: string | number): string
31
30
  export function publishArgs(port: string | number): string[]
32
31
  export function unpublishArgs(httpsPort: string): string[]
33
- export function tailnetUrl(text: unknown): string | null
34
32
  export function serveError(output: string): { code: ServeErrorCode, link: string } | null
35
33
  export function inspect(port: string | number, bin?: string | null, socket?: string | null): Promise<Tailnet>
36
- export function tailnetDomain(resolvConf: string): string | null
37
34
  export function reachable(url: string, timeoutMs?: number): Promise<Reach>
38
35
  export function healthReach(status: number, text: string): 'ok' | 'host' | null
39
36
  export function probe(url: string, timeoutMs?: number): Promise<Probe>
40
37
  export function failureCause(err: unknown): ReachCause
41
38
  export function runTailscale(bin: string, args: string[], timeoutMs?: number): Promise<{ code: number, output: string }>
39
+ export interface TailnetPeers { readable: boolean, state: string | null, running: boolean, selfIps: string[], peerIps: string[] }
40
+ export interface TailnetFacts extends Omit<TailnetPeers, 'state'> { installed: boolean, funnel: boolean | null }
41
+ export function tailnetPeers(statusJson: string): TailnetPeers
42
+ export function targetPort(target: unknown): number | null
43
+ export function funnelExposes(port: string | number, serveJson: string): boolean | null
44
+ export function tailnetFacts(port: string | number, bin?: string | null, socket?: string | null): Promise<TailnetFacts>
@@ -27,11 +27,7 @@ export function tailscaleBin(env = process.env, exists = existsSync) {
27
27
  return dirs.find(p => exists(p)) || null
28
28
  }
29
29
 
30
- // Tailscale's own socket on Linux. Its local API answers the same JSON as the
31
- // CLI: a Docker container has no `tailscale` command, but docker-compose.yml
32
- // mounts this folder, so wherdr reads the state there. Anyone may read; the
33
- // API also accepts changes from Tailscale's operator, but wherdr only ever
34
- // sends GET requests to it (localApi below).
30
+ // The local read-only API is useful when the CLI is absent from PATH.
35
31
  export const TAILSCALED_SOCKET = '/var/run/tailscale/tailscaled.sock'
36
32
  export function tailscaledSocket(env = process.env, exists = existsSync) {
37
33
  const file = env.WHERDR_TAILSCALED_SOCKET || TAILSCALED_SOCKET
@@ -67,6 +63,75 @@ export function tailnetStatus(statusJson) {
67
63
  }
68
64
  }
69
65
 
66
+ // What wherdr's network check needs from `tailscale status --json` (with
67
+ // peers): whether the answer could be read at all, Tailscale's state, this
68
+ // machine's tailnet addresses and those of its peers. Read from Tailscale
69
+ // itself, never guessed from an interface address in 100.64.0.0/10.
70
+ // readable: false for an empty or broken answer (a failed command, a socket
71
+ // that did not answer): nothing is known then, not "stopped".
72
+ export function tailnetPeers(statusJson) {
73
+ let s = null
74
+ try { s = JSON.parse(statusJson) } catch {}
75
+ const ips = node => (Array.isArray(node?.TailscaleIPs) ? node.TailscaleIPs.filter(ip => typeof ip === 'string') : [])
76
+ const readable = Boolean(s && typeof s === 'object' && typeof s.BackendState === 'string')
77
+ return {
78
+ readable,
79
+ state: readable ? s.BackendState : null,
80
+ running: readable && s.BackendState === 'Running',
81
+ selfIps: readable ? ips(s.Self) : [],
82
+ peerIps: readable ? Object.values(s.Peer || {}).flatMap(ips) : [],
83
+ }
84
+ }
85
+
86
+ // States in which tailscaled relays nothing, Funnel included.
87
+ const IDLE_STATES = new Set(['Stopped', 'NeedsLogin', 'NoState', 'NeedsMachineAuth'])
88
+
89
+ // The port a Serve or Funnel target leads to, or null when it cannot be told
90
+ // ("127.0.0.1:7683", "0.0.0.0:7683", "[::1]:7683", "http://localhost:7683/x",
91
+ // "https+insecure://host", "7683"…). The host is deliberately not compared:
92
+ // 0.0.0.0, another spelling of loopback, a LAN address of this machine or a
93
+ // name may all reach wherdr, so only the port can rule a target out.
94
+ export function targetPort(target) {
95
+ const t = String(target ?? '').trim()
96
+ if (/^\d{1,5}$/.test(t)) return Number(t)
97
+ const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\[[^\]]*\]|[^/:\s]*)(?::(\d{1,5}))?(?:[/?#].*)?$/i.exec(t)
98
+ if (!m || !m[2]) return null
99
+ if (m[3]) return Number(m[3])
100
+ const scheme = (m[1] || '').toLowerCase().replace(/\+insecure$/, '')
101
+ return scheme === 'http' ? 80 : scheme === 'https' ? 443 : null
102
+ }
103
+
104
+ export function funnelExposes(port, serveJson) {
105
+ let config = null
106
+ try { config = JSON.parse(serveJson) } catch {}
107
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return null
108
+ const reaches = target => targetPort(target) === null || targetPort(target) === Number(port)
109
+ for (const part of [config, ...Object.values(config.Foreground || {})]) {
110
+ for (const [hostPort, on] of Object.entries(part?.AllowFunnel || {})) {
111
+ if (!on) continue
112
+ const tcpPort = hostPort.slice(hostPort.lastIndexOf(':') + 1)
113
+ const tcp = part?.TCP?.[tcpPort]
114
+ if (tcp?.TCPForward !== undefined && reaches(tcp.TCPForward)) return true
115
+ const handlers = Object.values(part?.Web?.[hostPort]?.Handlers || {})
116
+ if (handlers.some(h => h && typeof h === 'object' && h.Proxy !== undefined && reaches(h.Proxy))) return true
117
+ // Funnel on a port with neither a TCP forward nor web handlers we can read.
118
+ if (tcp?.TCPForward === undefined && !part?.Web?.[hostPort] && !tcp?.HTTPS && !tcp?.HTTP) return true
119
+ }
120
+ }
121
+ return false
122
+ }
123
+
124
+ // Only a valid reading may prove the absence of Funnel on this port.
125
+ export async function tailnetFacts(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
126
+ if (!bin && !socket) return { installed: false, readable: true, running: false, selfIps: [], peerIps: [], funnel: false }
127
+ const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
128
+ const { state, ...peers } = tailnetPeers(bin ? await run(['status', '--json']) : await localApi(socket, 'status'))
129
+ if (!peers.readable) return { installed: true, ...peers, funnel: null }
130
+ if (IDLE_STATES.has(state)) return { installed: true, ...peers, funnel: false }
131
+ const serve = bin ? await run(['serve', 'status', '--json']) : await localApi(socket, 'serve-config')
132
+ return { installed: true, ...peers, funnel: funnelExposes(port, serve) }
133
+ }
134
+
70
135
  // The HTTPS entries of a serve configuration that proxy to wherdr's local
71
136
  // port on this machine's own name: [{ hostPort, httpsPort, funnel }], or null
72
137
  // when the configuration could not be read (Tailscale stopped, not JSON).
@@ -120,22 +185,10 @@ export function servedPorts(serveJson) {
120
185
  } catch { return [] }
121
186
  }
122
187
 
123
- // The command that publishes wherdr's port, as typed by hand (Docker mode).
124
- // `httpsPort`: the tailnet port, wherdr's own by default.
125
188
  export const publishCommand = (port, httpsPort = port) => `tailscale serve --bg --https=${httpsPort} http://127.0.0.1:${port}`
126
189
  export const publishArgs = port => ['serve', '--bg', `--https=${port}`, `http://127.0.0.1:${port}`]
127
190
  export const unpublishArgs = httpsPort => ['serve', `--https=${httpsPort}`, 'off']
128
191
 
129
- // A tailnet address typed by hand: https://machine.tailnet.ts.net[:port]/ → its
130
- // normalized form, or null.
131
- export function tailnetUrl(text) {
132
- try {
133
- const u = new URL(String(text || '').trim())
134
- if (u.protocol !== 'https:' || !/^[a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net$/i.test(u.hostname) || u.username || u.password) return null
135
- return `https://${u.host.toLowerCase()}/`
136
- } catch { return null }
137
- }
138
-
139
192
  // A failed `tailscale serve` → { code, link } for the known causes, else null.
140
193
  // code: 'operator' (Linux: the user may not change Tailscale's settings),
141
194
  // 'https' (no HTTPS certificates on the tailnet), 'offline' (not connected).
@@ -148,9 +201,6 @@ export function serveError(output) {
148
201
  return null
149
202
  }
150
203
 
151
- // Tailscale's state for wherdr's port: { installed, bin, socket, connected,
152
- // https, name, phone, taken, served }. Read with the CLI, or without one
153
- // (Docker) from Tailscale's socket.
154
204
  export async function inspect(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
155
205
  if (!bin && !socket) return { installed: false }
156
206
  const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
@@ -162,18 +212,6 @@ export async function inspect(port, bin = tailscaleBin(), socket = bin ? null :
162
212
  return { installed: true, bin, socket, ...status, phone, taken, served: servedAddresses(status.name, port, serve) }
163
213
  }
164
214
 
165
- // The tailnet's DNS suffix (tailnet.ts.net) from /etc/resolv.conf's search
166
- // line: a Docker container copies it from the host when MagicDNS is on.
167
- export function tailnetDomain(resolvConf) {
168
- for (const line of String(resolvConf || '').split('\n')) {
169
- const m = /^\s*search\s+(.+)$/.exec(line)
170
- const hit = m?.[1].split(/\s+/).find(d => /^[a-z0-9-]+\.ts\.net$/i.test(d))
171
- if (hit) return hit.toLowerCase()
172
- }
173
- return null
174
- }
175
-
176
- // Tailscale's DNS (MagicDNS): a Docker container does not use it, but reaches it.
177
215
  const MAGIC_DNS = '100.100.100.100'
178
216
 
179
217
  // The system's lookup, then MagicDNS for *.ts.net names.
@@ -0,0 +1,14 @@
1
+ // Types of userpath.mjs, for the server (server/utils/env.ts, projectsTasks.ts).
2
+ export const USER_BIN_DIRS: string[]
3
+ export const LEAD_BIN_DIRS: string[]
4
+ export const TAIL_BIN_DIRS: string[]
5
+ export const SYSTEM_BIN_DIRS: string[]
6
+ export function mergePath(...parts: Array<string | string[] | undefined | null>): string
7
+ export function knownBinDirs(home?: string, exists?: (p: string) => boolean): string[]
8
+ export function projectsDataDir(env?: NodeJS.ProcessEnv, home?: string): string
9
+ export function projectsEnvPath(env?: NodeJS.ProcessEnv, home?: string): string
10
+ export function pathFromEnvOutput(text: string): string
11
+ export function loginShellPath(env?: NodeJS.ProcessEnv, run?: unknown): string
12
+ export function userPath(opts?: { env?: NodeJS.ProcessEnv, home?: string, first?: string[], login?: string, exists?: (p: string) => boolean }): string
13
+ export const JSON_PATH_AWK: string
14
+ export const USER_PATH_SH: string
@@ -0,0 +1,142 @@
1
+ // The PATH of the user, for what wherdr runs without a login shell: the login
2
+ // service (systemd --user, launchd), the server it starts, and the
3
+ // herdr-projects commands of the Board, on this machine and over SSH.
4
+ //
5
+ // systemd and launchd start wherdr with a short system PATH, and an SSH command
6
+ // gets the remote shell's non-login PATH: agents installed in a user folder
7
+ // (~/.local/bin, ~/.bun/bin, ~/.cargo/bin, ~/.npm-global/bin, Homebrew) are
8
+ // then not found, and herdr-projects refuses with "claude is not installed
9
+ // here". herdr-projects only widens its own PATH when Herdr runs it as a plugin
10
+ // command; wherdr runs it directly. So wherdr gives it the PATH an interactive
11
+ // login shell would have, in this order:
12
+ // 1. at `wherdr service install`: the PATH of the login shell, asked once;
13
+ // 2. the PATH herdr-projects recorded from a login shell
14
+ // ($HERDR_PROJECTS_DATA_DIR/env.json, default ~/.herdr-projects/data);
15
+ // 3. the usual user folders and Homebrew's, when they exist;
16
+ // 4. the PATH it was given;
17
+ // 5. the system folders, when they exist.
18
+ // The user's own folders come before the system ones, as in their shell: an
19
+ // old system-wide copy of an agent never wins over theirs. Each folder once.
20
+ import { spawnSync } from 'node:child_process'
21
+ import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+
25
+ // Where agent CLIs install themselves for one user (relative to $HOME).
26
+ export const USER_BIN_DIRS = ['.local/bin', '.bun/bin', '.cargo/bin', '.npm-global/bin', '.deno/bin', '.volta/bin', 'bin']
27
+ // Homebrew (Apple silicon, Intel, Linux) and /usr/local/bin, where a user's
28
+ // own installs go too: before the PATH given, with the user folders.
29
+ export const LEAD_BIN_DIRS = ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/home/linuxbrew/.linuxbrew/bin', '/usr/local/bin']
30
+ // The system's own: after the PATH given.
31
+ export const TAIL_BIN_DIRS = ['/usr/bin', '/bin', '/usr/local/sbin', '/usr/sbin', '/sbin']
32
+ export const SYSTEM_BIN_DIRS = [...LEAD_BIN_DIRS, ...TAIL_BIN_DIRS]
33
+
34
+ // Joins PATH strings or folder lists into one PATH: absolute folders only,
35
+ // each once, in the order first seen.
36
+ export function mergePath(...parts) {
37
+ const seen = new Set()
38
+ for (const part of parts) {
39
+ const list = Array.isArray(part) ? part : String(part || '').split(':')
40
+ for (const raw of list) {
41
+ const dir = String(raw || '').trim()
42
+ if (dir && dir.startsWith('/') && !dir.includes('\n')) seen.add(dir.replace(/(.)\/+$/, '$1'))
43
+ }
44
+ }
45
+ return [...seen].join(':')
46
+ }
47
+
48
+ const isDir = (p) => { try { return statSync(p).isDirectory() } catch { return false } }
49
+
50
+ // The usual folders that exist on this machine.
51
+ export function knownBinDirs(home = os.homedir(), exists = isDir) {
52
+ return [...USER_BIN_DIRS.map(d => path.join(home, d)), ...SYSTEM_BIN_DIRS].filter(d => exists(d))
53
+ }
54
+
55
+ // herdr-projects' data folder, as it finds it: HERDR_PROJECTS_DATA_DIR, else
56
+ // HERDR_PROJECTS_HOME/data, else ~/.herdr-projects/data.
57
+ export function projectsDataDir(env = process.env, home = os.homedir()) {
58
+ return env.HERDR_PROJECTS_DATA_DIR || path.join(env.HERDR_PROJECTS_HOME || path.join(home, '.herdr-projects'), 'data')
59
+ }
60
+
61
+ // The PATH herdr-projects recorded from a login shell, '' when there is none.
62
+ export function projectsEnvPath(env = process.env, home = os.homedir()) {
63
+ const file = path.join(projectsDataDir(env, home), 'env.json')
64
+ if (!existsSync(file)) return ''
65
+ try {
66
+ const p = JSON.parse(readFileSync(file, 'utf8'))?.PATH
67
+ return typeof p === 'string' ? p : ''
68
+ } catch { return '' }
69
+ }
70
+
71
+ // The PATH line of `env` output; what a shell prints before it is ignored.
72
+ export function pathFromEnvOutput(text) {
73
+ const line = String(text || '').split('\n').find(l => l.startsWith('PATH='))
74
+ return line ? line.slice(5).trim() : ''
75
+ }
76
+
77
+ // The login shell's PATH ($SHELL -lc env), '' when it cannot be had. Never
78
+ // interactive: no terminal, a few seconds at most.
79
+ export function loginShellPath(env = process.env, run = spawnSync) {
80
+ const shell = env.SHELL && path.isAbsolute(env.SHELL) ? env.SHELL : '/bin/sh'
81
+ try {
82
+ const r = run(shell, ['-lc', 'env'], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], env })
83
+ return r.status === 0 ? pathFromEnvOutput(r.stdout) : ''
84
+ } catch { return '' }
85
+ }
86
+
87
+ // The PATH for the server and the commands it runs.
88
+ // `first`: folders to put before everything (the runtime's own folder).
89
+ // `login`: the login shell's PATH, when it was asked.
90
+ export function userPath({ env = process.env, home = os.homedir(), first = [], login = '', exists = isDir } = {}) {
91
+ const lead = [...USER_BIN_DIRS.map(d => path.join(home, d)), ...LEAD_BIN_DIRS].filter(d => exists(d))
92
+ return mergePath(first, login, projectsEnvPath(env, home), lead, env.PATH || '', TAIL_BIN_DIRS.filter(d => exists(d)))
93
+ }
94
+
95
+ // The value of the first "PATH" key of a JSON file (env.json), its escapes
96
+ // decoded as JSON.parse does (\" \\ \/ \b \f \n \r \t \uXXXX, surrogate
97
+ // pairs as UTF-8), then an `x` so that the shell keeps a trailing newline.
98
+ // Nothing when there is no such string, or when it is cut short, holds a NUL
99
+ // or a bad escape. POSIX awk (BusyBox, mawk, BSD, gawk), run with LC_ALL=C so
100
+ // that %c writes one byte. No single quote in it: the script quotes it so.
101
+ export const JSON_PATH_AWK = String.raw`{ s = s $0 "\n" } `
102
+ + String.raw`function b(v) { return sprintf("%c", v) } `
103
+ + String.raw`function hex(t, j, x, v) { if (length(t) != 4) return -1; v = 0; for (j = 1; j <= 4; j++) { x = index("0123456789abcdef", tolower(substr(t, j, 1))); if (!x) return -1; v = v * 16 + x - 1 } return v } `
104
+ + String.raw`END { if (!match(s, /"PATH"[ \t\r\n]*:[ \t\r\n]*"/)) exit; s = substr(s, RSTART + RLENGTH); n = length(s); o = ""; `
105
+ + String.raw`for (k = 1; k <= n; k++) { c = substr(s, k, 1); `
106
+ + String.raw`if (c == "\"") { printf "%sx", o; exit } `
107
+ + String.raw`if (c != "\\") { o = o c; continue } `
108
+ + String.raw`c = substr(s, ++k, 1); `
109
+ + String.raw`if (c == "\"" || c == "\\" || c == "/") o = o c; else if (c == "b") o = o "\b"; else if (c == "f") o = o "\f"; else if (c == "n") o = o "\n"; else if (c == "r") o = o "\r"; else if (c == "t") o = o "\t"; `
110
+ + String.raw`else if (c == "u") { v = hex(substr(s, k + 1, 4)); k += 4; `
111
+ + String.raw`if (v >= 55296 && v < 56320) { if (substr(s, k + 1, 2) != "\\u") exit; w = hex(substr(s, k + 3, 4)); if (w < 56320 || w >= 57344) exit; k += 6; v = 65536 + (v - 55296) * 1024 + w - 56320 } `
112
+ + String.raw`if (v <= 0 || (v >= 56320 && v < 57344)) exit; `
113
+ + String.raw`if (v < 128) o = o b(v); else if (v < 2048) o = o b(192 + int(v / 64)) b(128 + v % 64); `
114
+ + String.raw`else if (v < 65536) o = o b(224 + int(v / 4096)) b(128 + int(v / 64) % 64) b(128 + v % 64); `
115
+ + String.raw`else o = o b(240 + int(v / 262144)) b(128 + int(v / 4096) % 64) b(128 + int(v / 64) % 64) b(128 + v % 64) } `
116
+ + String.raw`else exit } }`
117
+
118
+ // The same for a shell on another machine (SSH), by the rules of userPath:
119
+ // the PATH herdr-projects recorded there, the usual user folders that exist
120
+ // there, the shell's own PATH, then the system folders; each folder trimmed,
121
+ // without a trailing slash, absolute, on one line and once. Fixed text, POSIX
122
+ // sh and awk only; the recorded value is only split and assigned (no
123
+ // globbing), never run.
124
+ const ADD_SH = [
125
+ ' while :; do case "$d" in [$ws]*) d=${d#?} ;; *[$ws]) d=${d%?} ;; ?*/) d=${d%/} ;; *) break ;; esac; done',
126
+ ' case "$d" in /*) ;; *) continue ;; esac; case "$d" in *"$nl"*) continue ;; esac',
127
+ ' case ":$n:" in *":$d:"*) ;; *) n="${n:+$n:}$d" ;; esac',
128
+ 'done',
129
+ ]
130
+ const DIRS_SH = (dirs, home = false) => `for d in ${dirs.map(d => (home ? `"$HOME/${d}"` : d)).join(' ')}; do [ -d "$d" ] || continue`
131
+ export const USER_PATH_SH = [
132
+ 'f="${HERDR_PROJECTS_DATA_DIR:-${HERDR_PROJECTS_HOME:-$HOME/.herdr-projects}/data}/env.json"',
133
+ // awk where the system keeps it, even from a PATH without /usr/bin.
134
+ `p=""; [ -r "$f" ] && p=$(PATH="\${PATH:+$PATH:}/usr/bin:/bin" LC_ALL=C awk '${JSON_PATH_AWK}' "$f" 2>/dev/null); p=\${p%x}`,
135
+ "nl='\n'; ws=\" \t\r$nl\"; n=''; old_ifs=$IFS; IFS=:; set -f",
136
+ 'for d in $p; do', ...ADD_SH,
137
+ DIRS_SH(USER_BIN_DIRS, true), ...ADD_SH,
138
+ DIRS_SH(LEAD_BIN_DIRS), ...ADD_SH,
139
+ 'for d in $PATH; do', ...ADD_SH,
140
+ DIRS_SH(TAIL_BIN_DIRS), ...ADD_SH,
141
+ 'set +f; IFS=$old_ifs; [ -n "$n" ] && PATH=$n; export PATH; ',
142
+ ].join('\n')
package/docs/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Documentation
2
+
3
+ **Upgrading to 2.0.0?** Read the [release notes](release-notes-2.0.0.md) for breaking changes and the 1.x migration checklist.
4
+
5
+ Read only the page relevant to your task. Start with [Installation and updates](installation.md) for setup,
6
+ [Security](security.md) for remote access, or [Development and tests](development.md) for repository changes.
7
+
8
+ - [Overview and architecture](overview.md) — What wherdr does, why it exists and how it connects to Herdr. Includes the launch film and license.
9
+ - [First launch](quick-start.md) — Install the native service and follow the setup guide. Includes the desktop, Board and phone screenshots.
10
+ - [Home and agent list](home.md) — Find agents, create a session and filter machines or projects. Covers compact rows and state grouping.
11
+ - [Conversation and replies](conversation.md) — Read transcripts, answer questions, attach files and manage drafts. Includes quoted replies and decision tables.
12
+ - [Terminal](terminal.md) — Use the real terminal, copy text, open links and paste images. Explains take-over and size restoration.
13
+ - [Files and changes](files.md) — Review Git changes and comment on lines or blocks. Compose a review for the agent.
14
+ - [Computer, phone and iPad](devices.md) — Use split panes, keyboard shortcuts and touch controls. Describes device-specific layouts.
15
+ - [Installation and updates](installation.md) — Requirements, native installer, agent PATH, updates and migration from Docker.
16
+ - [Phone access and private networks](networks.md) — Set up Tailscale or another private network with HTTPS. Includes always-on servers and keep-awake.
17
+ - [The wherdr command](command.md) — Install through npm or Homebrew and manage the native service from the command line.
18
+ - [Herdr plugins](plugins.md) — Install and configure the wherdr plugin, its panel key and plugin actions.
19
+ - [Install as an app](phone.md) — Add wherdr to your home screen on a phone or install it in a desktop browser.
20
+ - [Settings and configuration](configuration.md) — Configure the service through environment variables and the app through shared and device-specific preferences.
21
+ - [Machines and SSH](machines.md) — Register remote machines and use their agents through SSH. Includes sessions and connection troubleshooting.
22
+ - [Claude and Codex quotas](quotas.md) — Show remaining usage and handle Codex updates, limits and restarts.
23
+ - [Notifications](notifications.md) — Enable Web Push, relay Herdr notifications and configure Do not disturb.
24
+ - [Board and coordinators](board.md) — Work with herdr-projects tasks, reviews, approvals and coordinators across machines.
25
+ - [Security](security.md) — Understand remote-shell access, passkeys and network restrictions. Read this before enabling remote access.
26
+ - [Development and tests](development.md) — Run from source and follow the contribution and agent test rules.
27
+ - [Troubleshooting](troubleshooting.md) — Resolve startup, permissions, network, SSH and conversation problems.
28
+
29
+ Also see [herdr-projects 1.x migration](herdr-projects-1x.md), [Releasing](RELEASING.md),
30
+ [keyboard shortcut specification](product/keyboard-shortcuts.md) and [Contributing](../CONTRIBUTING.md).
31
+
32
+ ## Section reference
33
+
34
+ - [Quick start](quick-start.md#quick-start)
35
+ - [Why wherdr](overview.md#why-wherdr)
36
+ - [Features](home.md#features)
37
+ - [How it works](overview.md#how-it-works)
38
+ - [Requirements](installation.md#requirements)
39
+ - [Installation](installation.md#installation): [ask your AI agent](installation.md#ask-your-ai-agent) ·
40
+ [or run the installer yourself](installation.md#the-one-command) ·
41
+ [which setup?](installation.md#which-setup) ·
42
+ [always-on server + Tailscale](networks.md#recommended-always-on-server--tailscale) ·
43
+ [other private networks](networks.md#other-private-networks) ·
44
+ [as a Herdr plugin](plugins.md#install-as-a-herdr-plugin) ·
45
+ [the `wherdr` command (npm, Homebrew)](command.md#the-wherdr-command) ·
46
+ [from source](development.md#from-source) ·
47
+ [moving from Docker to the native service](migrate-from-1.x-docker.md)
48
+ - [Updating](installation.md#updating)
49
+ - [Install it as an app](phone.md#install-it-as-an-app)
50
+ - [Configuration](configuration.md#configuration)
51
+ - [Several machines (SSH)](machines.md#several-machines-ssh)
52
+ - [Claude and Codex quotas](quotas.md#claude-and-codex-quotas)
53
+ - [Notifications](notifications.md#notifications)
54
+ - [Works great with herdr-projects](board.md#works-great-with-herdr-projects)
55
+ - [Herdr plugins](plugins.md#herdr-plugins)
56
+ - [Security](security.md#security)
57
+ - [Troubleshooting](troubleshooting.md#troubleshooting)
58
+ - [Contributing](development.md#contributing) · [License](overview.md#license)