@monster-orange/den 0.4.21 → 0.4.22
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 +68 -0
- package/docs/distributions.md +95 -12
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/doctor-command.ts +72 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +217 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +4 -0
- package/src/ui/server/index.ts +4 -0
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/i18n/es.js +14 -0
- package/src/ui/server/public/i18n/ja.js +14 -0
- package/src/ui/server/public/shell.js +14 -0
- package/src/ui/server/session-routes.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,74 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.4.22] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Opt-in: agents meant to keep running across logout (D18, D20).**
|
|
12
|
+
`service install --mode always` installs the web server and the session
|
|
13
|
+
host as LaunchDaemons that run as you (`UserName`): they start at boot
|
|
14
|
+
and are meant to keep running when you log out of the desktop, and so
|
|
15
|
+
every terminal and agent. It is not yet proven across a logout: `service
|
|
16
|
+
help`, `--plan`, install, `doctor` and the agents page say so. It takes your admin password once (sudo); `--mode login`, the
|
|
17
|
+
default, keeps LaunchAgents in the desktop session (they stop at
|
|
18
|
+
logout). Without `--mode`, install keeps the mode already installed.
|
|
19
|
+
Switching removes the other mode's jobs.
|
|
20
|
+
- **The session host is its own launchd job** (`<label>` with `.web` →
|
|
21
|
+
`.sessions`), in both modes, so it runs in the mode's session whoever
|
|
22
|
+
starts the first terminal: before, it inherited the launchd session of
|
|
23
|
+
whatever started it, and one started from the desktop ended at logout
|
|
24
|
+
with every agent in it. `<cli> sessions` and agents wait for the job.
|
|
25
|
+
- **Full Disk Access for Den, not for every node program.** The host job
|
|
26
|
+
runs a copy of node Den owns, `<stateDir>/bin/<cli>-sessions-host`
|
|
27
|
+
(0700), the path to grant Full Disk Access to (protected folders: other
|
|
28
|
+
volumes, Desktop, Documents); agents inherit it. Install refreshes the
|
|
29
|
+
copy when node changes and says when the grant must be given again (a
|
|
30
|
+
new signer, an ad-hoc signed node). A node that can't be copied
|
|
31
|
+
(Homebrew's) is run directly, and install says so.
|
|
32
|
+
- **Where it runs, shown honestly.** Every session records the launchd
|
|
33
|
+
session its host ran in (`launchctl managername`). The Agents page tags
|
|
34
|
+
each agent **Stops at logout** (the desktop session; tap for the fix) or
|
|
35
|
+
**No GUI** (kept across logout: no login keychain, no GUI apps; 1Password
|
|
36
|
+
through the service-account token, headless browsers, Full Disk Access
|
|
37
|
+
for protected folders). `doctor` ends with **where it runs**: the
|
|
38
|
+
server's and the host's session, Full Disk Access and the path to grant,
|
|
39
|
+
and the service's mode. `web up` from an ssh (Background) shell says what
|
|
40
|
+
a server started there means.
|
|
41
|
+
- **Rename, visible (D25).** **Rename…** in an agent pane's ⋯ menu and
|
|
42
|
+
**Rename** on a stopped agent's row, by touch or keyboard, and
|
|
43
|
+
`<cli> agents rename <id> <name>` (`--clear`: back to its id). Before,
|
|
44
|
+
only a double-click on an open tab renamed one.
|
|
45
|
+
- **Agents tell apart in a list of sessions (D24).** Den names each
|
|
46
|
+
Claude Code agent's session `<cli>-<ws>:<name, else id>` (`--name`) at
|
|
47
|
+
start and resume, so another session listing its peers sees which agent
|
|
48
|
+
is which, not a generic name. A rename shows at its next start or resume.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **`service install` said running while its job crash-looped (D26).** A
|
|
53
|
+
plain `web up` server of the same install on the port answered the
|
|
54
|
+
check, while the job failed to start on the taken port. Install now
|
|
55
|
+
stops a plain server of this install first, and says running only when
|
|
56
|
+
the server answering is the job's own process; `service status` says
|
|
57
|
+
when another process holds the port. `--plan` says when install would
|
|
58
|
+
stop one.
|
|
59
|
+
- **`service status` names a job whose plist is present but not loaded**,
|
|
60
|
+
with the `launchctl bootstrap` that loads it (`sudo` for a LaunchDaemon);
|
|
61
|
+
`doctor` shows it too.
|
|
62
|
+
- **Tests never reach this machine's launchctl, gh or vendor CLIs**, even
|
|
63
|
+
through a spawn without `env`: the inert stand-ins are now first on the
|
|
64
|
+
real PATH (jest's globalSetup), not only on each test file's sandboxed
|
|
65
|
+
`process.env`.
|
|
66
|
+
|
|
67
|
+
### Migrations
|
|
68
|
+
|
|
69
|
+
- None required. An existing install keeps its mode (login). To try
|
|
70
|
+
keeping agents running across logout (not yet proven), run
|
|
71
|
+
`<cli> service install --mode always` once (one sudo), then grant Full
|
|
72
|
+
Disk Access to the path `doctor` names if agents read protected folders.
|
|
73
|
+
- `<cli> service` with no subcommand prints its help (before, an error).
|
|
74
|
+
|
|
7
75
|
## [0.4.21] - 2026-11-09
|
|
8
76
|
|
|
9
77
|
### Added
|
package/docs/distributions.md
CHANGED
|
@@ -1957,16 +1957,88 @@ Apps › New GitHub App):
|
|
|
1957
1957
|
|
|
1958
1958
|
## Running the web UI as a service (macOS)
|
|
1959
1959
|
|
|
1960
|
-
Den ships a launchd installer that runs the web UI
|
|
1961
|
-
|
|
1960
|
+
Den ships a launchd installer that runs the web UI and the session host (every
|
|
1961
|
+
terminal and agent) as launchd jobs, restarted if they exit:
|
|
1962
1962
|
|
|
1963
1963
|
```bash
|
|
1964
|
-
./bin/acme service install
|
|
1964
|
+
./bin/acme service install # install (or refresh) and start them
|
|
1965
|
+
./bin/acme service install --mode always # meant to keep them running across logout (opt-in, not yet proven)
|
|
1965
1966
|
./bin/acme service install --plan # what install would do; writes and loads nothing
|
|
1966
1967
|
./bin/acme service status # launchd's view: state, pid, last exit code
|
|
1967
|
-
./bin/acme service print # the
|
|
1968
|
-
./bin/acme service uninstall # stop
|
|
1969
|
-
```
|
|
1968
|
+
./bin/acme service print # the web plist it would install; installs nothing
|
|
1969
|
+
./bin/acme service uninstall # stop them and remove their plists
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
### Login or always
|
|
1973
|
+
|
|
1974
|
+
| `--mode` | Jobs | At logout |
|
|
1975
|
+
| ----------------- | ----------------------------------------------------- | ------------------------------------ |
|
|
1976
|
+
| `login` (default) | LaunchAgents in the desktop session (`gui/<uid>`) | stop, with every agent |
|
|
1977
|
+
| `always` | LaunchDaemons that run as you (`UserName`), from boot | meant to keep running, and so agents |
|
|
1978
|
+
|
|
1979
|
+
`always` is opt-in and not yet proven across a logout: no logout has
|
|
1980
|
+
shown it keeping an agent on the same conversation yet. Use `login` unless
|
|
1981
|
+
you are testing it.
|
|
1982
|
+
|
|
1983
|
+
`always` takes your admin password once (sudo writes and loads the
|
|
1984
|
+
LaunchDaemons in `/Library/LaunchDaemons`); it is never asked for again.
|
|
1985
|
+
Without `--mode`, install keeps the mode already installed, so re-running it
|
|
1986
|
+
after an upgrade never changes it. Switching mode removes the other mode's
|
|
1987
|
+
jobs: never both. Use `always` on a machine you reach from elsewhere (ssh, a
|
|
1988
|
+
browser on another machine); `login` on a laptop.
|
|
1989
|
+
|
|
1990
|
+
A process outside the desktop session has no login keychain and no GUI. So
|
|
1991
|
+
under `always`, agents use:
|
|
1992
|
+
|
|
1993
|
+
- **1Password**: the service-account token (`secrets.onepassword.token`),
|
|
1994
|
+
not the desktop app. `doctor` warns when this process has none.
|
|
1995
|
+
- **Browsers**: headless Chromium and Firefox. WebKit on macOS needs a
|
|
1996
|
+
desktop session; run it from Playwright's Linux image instead
|
|
1997
|
+
(CONTRIBUTING.md, `DEN_PW_WS`).
|
|
1998
|
+
- **Protected folders** (other volumes, Desktop, Documents): Full Disk
|
|
1999
|
+
Access, granted once (below).
|
|
2000
|
+
|
|
2001
|
+
Each session records the launchd session its host ran in, and the Agents page
|
|
2002
|
+
tags it: **Stops at logout** (the desktop session; tap it for the fix) or
|
|
2003
|
+
**No GUI** (kept across logout). `web up` from an ssh shell (a Background
|
|
2004
|
+
session) says the same before it starts a server there.
|
|
2005
|
+
|
|
2006
|
+
### The session host job and Full Disk Access
|
|
2007
|
+
|
|
2008
|
+
Both modes install the session host as its own job, `<label>` with `.web`
|
|
2009
|
+
replaced by `.sessions` (`local.acme.sessions`), so it runs in the mode's
|
|
2010
|
+
session whoever starts the first terminal; `<cli> sessions` and agents then
|
|
2011
|
+
wait for it rather than starting a host in the caller's session. It runs a
|
|
2012
|
+
copy of node Den owns, `<stateDir>/bin/<cli>-sessions-host` (0700), and
|
|
2013
|
+
macOS grants Full Disk Access to that path: never to the shared node, which
|
|
2014
|
+
would give it to every node program. To grant it: System Settings → Privacy &
|
|
2015
|
+
Security → Full Disk Access → + → that path (⌘⇧G to type it), then
|
|
2016
|
+
`<cli> sessions host --stop` (it restarts with the grant). Agents inherit it.
|
|
2017
|
+
|
|
2018
|
+
Install refreshes the copy when node changes. An official node build
|
|
2019
|
+
(nodejs.org, nvm) is signed by the Node.js project, so the grant holds across
|
|
2020
|
+
upgrades; install says when the signer changed and it must be granted again.
|
|
2021
|
+
An ad-hoc signed node needs a new grant after every change, and Homebrew's
|
|
2022
|
+
node loads libraries beside it and can't be copied: install then runs it
|
|
2023
|
+
directly and says so. `doctor` shows whether the host has Full Disk Access,
|
|
2024
|
+
and the path to grant.
|
|
2025
|
+
|
|
2026
|
+
### What doctor shows
|
|
2027
|
+
|
|
2028
|
+
`doctor` ends with **where it runs**: the web server's and the session host's
|
|
2029
|
+
launchd session (a warning with the fix when it stops at logout), Full Disk
|
|
2030
|
+
Access, and the service: its mode, or `plist present, job not loaded:
|
|
2031
|
+
launchctl bootstrap <domain> <plist>` (`sudo` for a LaunchDaemon) when launchd
|
|
2032
|
+
has dropped a job whose plist is still there.
|
|
2033
|
+
|
|
2034
|
+
### Running means the job's own process
|
|
2035
|
+
|
|
2036
|
+
A plain `web up` server of the same install on the port would answer for the
|
|
2037
|
+
job, which then fails to start on the taken port and restarts forever. So
|
|
2038
|
+
install stops a plain server of this install first (and says so), and says
|
|
2039
|
+
`running` only when the server answering is the job's own process (its pid is
|
|
2040
|
+
launchd's). Otherwise it says `port <N> is served by pid <P>, not the job`
|
|
2041
|
+
and exits 1; `status` says the same.
|
|
1970
2042
|
|
|
1971
2043
|
The label and log directory come from the org config:
|
|
1972
2044
|
|
|
@@ -2011,12 +2083,14 @@ node_modules/@monster-orange/den/service/install.sh uninstall
|
|
|
2011
2083
|
node_modules/@monster-orange/den/service/install.sh print # the plist only
|
|
2012
2084
|
```
|
|
2013
2085
|
|
|
2014
|
-
| Variable
|
|
2015
|
-
|
|
|
2016
|
-
| `DEN_ROOT`
|
|
2017
|
-
| `DEN_SERVICE_LABEL`
|
|
2018
|
-
| `DEN_SERVICE_LOGS`
|
|
2019
|
-
| `DEN_SERVICE_NODE`
|
|
2086
|
+
| Variable | Default |
|
|
2087
|
+
| ---------------------- | ----------------------------------------------------------------------- |
|
|
2088
|
+
| `DEN_ROOT` | required in a distribution |
|
|
2089
|
+
| `DEN_SERVICE_LABEL` | `local.<distribution dir name>.web` |
|
|
2090
|
+
| `DEN_SERVICE_LOGS` | `~/Library/Logs/<distribution dir name>` |
|
|
2091
|
+
| `DEN_SERVICE_NODE` | see below |
|
|
2092
|
+
| `DEN_SERVICE_MODE` | `login` (or `always`) |
|
|
2093
|
+
| `DEN_SERVICE_HOST_EXE` | none: the host runs that node (`<cli> service install` sets Den's copy) |
|
|
2020
2094
|
|
|
2021
2095
|
The service runs the engine's server, from the distribution root, with the
|
|
2022
2096
|
`node` on your `PATH` at install time. A version manager's node (nvm, fnm,
|
|
@@ -2495,6 +2569,15 @@ server scoped to its workspace.
|
|
|
2495
2569
|
start and resume answers). 0.4.12 sent the name shown as `sessionName`;
|
|
2496
2570
|
read `sessionTitle` instead. Records are unchanged, so none written
|
|
2497
2571
|
before needs migrating.
|
|
2572
|
+
- **Renaming.** `agents rename <id> <name>` (or `--clear`, back to its
|
|
2573
|
+
id) names an agent; so do **Rename…** in its pane's ⋯ menu and **Rename**
|
|
2574
|
+
on a stopped agent's row, by touch or keyboard, as well as a double-click
|
|
2575
|
+
on its tab. The id never changes.
|
|
2576
|
+
- **The name other sessions see.** Den names a Claude Code agent's session
|
|
2577
|
+
`<cli>-<ws>:<name, else id>` (`--name`) at every start and resume, so
|
|
2578
|
+
another session listing its peers tells Den's agents apart. A rename
|
|
2579
|
+
shows at its next start or resume. The record's `peerName` is the name
|
|
2580
|
+
last given, so reading it back is never taken for a `/rename`.
|
|
2498
2581
|
- `list` shows agents only: sessions with an agent record. A session from
|
|
2499
2582
|
`sessions start` is not an agent; `sessions list` shows it.
|
|
2500
2583
|
- `stop` sends its session TERM and marks it stopped. `resume` works only
|
package/package.json
CHANGED
package/service/install.sh
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Run the web UI as
|
|
3
|
-
# started at login, restarted if
|
|
2
|
+
# Run the web UI and the session host as long-lived macOS launchd jobs:
|
|
3
|
+
# started at boot or login, restarted if they exit, not tied to a terminal.
|
|
4
4
|
#
|
|
5
|
-
# service/install.sh install (or refresh) and start
|
|
6
|
-
# service/install.sh uninstall stop
|
|
7
|
-
# service/install.sh status launchd's view of
|
|
8
|
-
# service/install.sh print write the
|
|
5
|
+
# service/install.sh install (or refresh) and start them
|
|
6
|
+
# service/install.sh uninstall stop them and remove their plists
|
|
7
|
+
# service/install.sh status launchd's view of them
|
|
8
|
+
# service/install.sh print write the web plist to stdout only
|
|
9
|
+
# service/install.sh print-host write the session host plist to stdout only
|
|
10
|
+
#
|
|
11
|
+
# Two modes (D18), DEN_SERVICE_MODE:
|
|
12
|
+
# login (default) LaunchAgents in the desktop session (gui/<uid>): they
|
|
13
|
+
# start at login and stop at logout, with every agent.
|
|
14
|
+
# always LaunchDaemons that run as you (UserName): they start at boot and
|
|
15
|
+
# keep running across logout, so agents do too. One sudo writes
|
|
16
|
+
# and loads them. No login keychain, no GUI there: agents use the
|
|
17
|
+
# 1Password service-account token, headless browsers, and Full
|
|
18
|
+
# Disk Access for protected folders.
|
|
19
|
+
#
|
|
20
|
+
# The session host job runs DEN_SERVICE_HOST_EXE, a copy of node Den owns
|
|
21
|
+
# (<stateDir>/bin/<cli>-sessions-host): Full Disk Access is granted to that
|
|
22
|
+
# path, so it never goes to the shared node. A node change replaces the
|
|
23
|
+
# copy; the grant holds when the code signature's requirement is the same
|
|
24
|
+
# (an official node build), and install says to grant it again when not.
|
|
9
25
|
#
|
|
10
26
|
# Works from Den's checkout and from a distribution, where it lives in
|
|
11
27
|
# node_modules/@monster-orange/den/service/. The distribution root is
|
|
12
|
-
# DEN_ROOT, else this checkout when it holds den.yml.
|
|
13
|
-
# engine's server with the node found on PATH now, from the distribution
|
|
14
|
-
# root; re-run install after changing node versions.
|
|
28
|
+
# DEN_ROOT, else this checkout when it holds den.yml.
|
|
15
29
|
#
|
|
16
|
-
# DEN_SERVICE_LABEL
|
|
17
|
-
#
|
|
18
|
-
#
|
|
30
|
+
# DEN_SERVICE_LABEL web job label (default local.<dist dir name>.web)
|
|
31
|
+
# DEN_SERVICE_HOST_LABEL host job label (default: the web label's .web → .sessions)
|
|
32
|
+
# DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
|
|
33
|
+
# DEN_SERVICE_NODE the node the jobs run (default: see pick_node)
|
|
34
|
+
# DEN_SERVICE_HOST_EXE the host's node copy (default: none, the host runs that node)
|
|
19
35
|
# DEN_SERVICE_READY_CHECK=external install does not print "running": the
|
|
20
36
|
# caller (`<cli> service install`) checks the server
|
|
21
37
|
# answers first.
|
|
38
|
+
# DEN_SERVICE_DAEMON_DIR, DEN_SERVICE_SUDO (tests) where LaunchDaemons go
|
|
39
|
+
# and what runs as root (default /Library/LaunchDaemons, sudo)
|
|
22
40
|
set -euo pipefail
|
|
23
41
|
|
|
24
42
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -34,9 +52,31 @@ fi
|
|
|
34
52
|
[ -f "$DIST/den.yml" ] || { echo "✗ $DIST has no den.yml" >&2; exit 1; }
|
|
35
53
|
NAME="$(basename "$DIST")"
|
|
36
54
|
LABEL="${DEN_SERVICE_LABEL:-local.$NAME.web}"
|
|
55
|
+
HOST_LABEL="${DEN_SERVICE_HOST_LABEL:-${LABEL%.web}.sessions}"
|
|
37
56
|
LOGS="${DEN_SERVICE_LOGS:-$HOME/Library/Logs/$NAME}"
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
SVC_MODE="${DEN_SERVICE_MODE:-login}"
|
|
58
|
+
case "$SVC_MODE" in
|
|
59
|
+
login|always) ;;
|
|
60
|
+
*) echo "✗ DEN_SERVICE_MODE must be login or always, not \"$SVC_MODE\"" >&2; exit 2 ;;
|
|
61
|
+
esac
|
|
62
|
+
UID_N="$(id -u)"
|
|
63
|
+
USER_N="$(id -un)"
|
|
64
|
+
AGENT_DIR="$HOME/Library/LaunchAgents"
|
|
65
|
+
DAEMON_DIR="${DEN_SERVICE_DAEMON_DIR:-/Library/LaunchDaemons}"
|
|
66
|
+
SUDO="${DEN_SERVICE_SUDO-sudo}"
|
|
67
|
+
GUI="gui/$UID_N"
|
|
68
|
+
|
|
69
|
+
# The plist and domain for a label in a mode.
|
|
70
|
+
plist_for() { # $1 label $2 mode
|
|
71
|
+
if [ "$2" = always ]; then echo "$DAEMON_DIR/$1.plist"; else echo "$AGENT_DIR/$1.plist"; fi
|
|
72
|
+
}
|
|
73
|
+
domain_for() { if [ "$1" = always ]; then echo system; else echo "$GUI"; fi; }
|
|
74
|
+
other_mode() { if [ "$1" = always ]; then echo login; else echo always; fi; }
|
|
75
|
+
# Root runs it in the system domain (a LaunchDaemon); else as you.
|
|
76
|
+
as_mode() { # $1 mode, then the command
|
|
77
|
+
local m="$1"; shift
|
|
78
|
+
if [ "$m" = always ] && [ -n "$SUDO" ]; then "$SUDO" "$@"; else "$@"; fi
|
|
79
|
+
}
|
|
40
80
|
|
|
41
81
|
# tsx: the nearest node_modules/tsx at or above the engine (hoisted installs).
|
|
42
82
|
find_tsx() {
|
|
@@ -76,20 +116,75 @@ pick_node() {
|
|
|
76
116
|
echo "$node"
|
|
77
117
|
}
|
|
78
118
|
|
|
79
|
-
|
|
119
|
+
# The code signature's designated requirement, or "" (unsigned, no codesign).
|
|
120
|
+
requirement() { codesign -d -r- "$1" 2>&1 | sed -n 's/^designated => //p' || true; }
|
|
121
|
+
|
|
122
|
+
# The host's node copy (D18): refreshed when node changed. Prints the path
|
|
123
|
+
# the host job runs: the copy, or node itself when it cannot be copied.
|
|
124
|
+
# A node linked to libraries beside it (Homebrew's) does not run copied.
|
|
125
|
+
self_contained() {
|
|
126
|
+
! { command -v otool >/dev/null 2>&1 && otool -L "$1" 2>/dev/null | grep -q '@rpath/\|/opt/homebrew/\|/usr/local/opt/'; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
host_exe() { # $1 node
|
|
130
|
+
local node="$1" exe="${DEN_SERVICE_HOST_EXE:-}" c src=""
|
|
131
|
+
if [ -z "$exe" ]; then echo "$node"; return; fi
|
|
132
|
+
# The node the jobs run, else (unless DEN_SERVICE_NODE names one) the
|
|
133
|
+
# PATH's: a version manager's is fine here, since the copy outlives it.
|
|
134
|
+
for c in "$node" $([ -z "${DEN_SERVICE_NODE:-}" ] && command -v node 2>/dev/null); do
|
|
135
|
+
if self_contained "$c"; then src="$c"; break; fi
|
|
136
|
+
done
|
|
137
|
+
if [ -z "$src" ]; then
|
|
138
|
+
echo "⚠ $node is not self-contained (it loads libraries beside it), so the session host" >&2
|
|
139
|
+
echo " runs it directly: grant Full Disk Access to $node, and again after each node" >&2
|
|
140
|
+
echo " upgrade. An official node build (nodejs.org, nvm) avoids that: set DEN_SERVICE_NODE." >&2
|
|
141
|
+
echo "$node"; return
|
|
142
|
+
fi
|
|
143
|
+
node="$src"
|
|
144
|
+
if [ -f "$exe" ] && cmp -s "$node" "$exe"; then echo "$exe"; return; fi
|
|
145
|
+
local before="" after
|
|
146
|
+
[ -f "$exe" ] && before="$(requirement "$exe")"
|
|
147
|
+
mkdir -p "$(dirname "$exe")"
|
|
148
|
+
chmod 700 "$(dirname "$exe")"
|
|
149
|
+
cp "$node" "$exe.new"
|
|
150
|
+
chmod 700 "$exe.new"
|
|
151
|
+
mv -f "$exe.new" "$exe"
|
|
152
|
+
after="$(requirement "$exe")"
|
|
153
|
+
case "$after" in
|
|
154
|
+
"" | cdhash*)
|
|
155
|
+
echo "⚠ $exe is a copy of $node, which has no stable code signature: Full Disk" >&2
|
|
156
|
+
echo " Access must be granted to it again after every node change. An official" >&2
|
|
157
|
+
echo " node build (nodejs.org, nvm) keeps the grant: set DEN_SERVICE_NODE." >&2 ;;
|
|
158
|
+
*)
|
|
159
|
+
if [ -n "$before" ] && [ "$before" != "$after" ]; then
|
|
160
|
+
echo "⚠ $exe changed signer: grant it Full Disk Access again" >&2
|
|
161
|
+
echo " (System Settings → Privacy & Security → Full Disk Access)." >&2
|
|
162
|
+
fi ;;
|
|
163
|
+
esac
|
|
164
|
+
echo "$exe"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
write_plist() { # $1 = output file, $2 = web|host, $3 = mode
|
|
80
168
|
NODE="$(pick_node "$MODE")"
|
|
81
169
|
TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
|
|
170
|
+
local exe="$NODE" label="$LABEL" log="web.log"
|
|
171
|
+
if [ "$2" = host ]; then
|
|
172
|
+
if [ "$MODE" = install ]; then exe="$(host_exe "$NODE")"; else exe="${DEN_SERVICE_HOST_EXE:-$NODE}"; fi
|
|
173
|
+
label="$HOST_LABEL"; log="sessions-host.log"
|
|
174
|
+
fi
|
|
82
175
|
DOCKER_DIR="$(dirname "$(command -v docker || echo /usr/local/bin/docker)")"
|
|
83
176
|
SVC_PATH="$(dirname "$NODE"):$DOCKER_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
84
|
-
python3 -I - "$1" "$
|
|
177
|
+
python3 -I - "$1" "$2" "$3" "$label" "$exe" "$TSX" "$ENGINE" "$DIST" "$SVC_PATH" "$HOME" "$LOGS/$log" "$USER_N" <<'PY'
|
|
85
178
|
import plistlib, sys
|
|
86
|
-
out, label,
|
|
179
|
+
out, kind, mode, label, exe, tsx, engine, dist, path, home, log, user = sys.argv[1:]
|
|
180
|
+
entry = (f"{engine}/src/ui/server/index.ts", "--silent") if kind == "web" \
|
|
181
|
+
else (f"{engine}/src/ui/cli/index.ts", "sessions", "host", "--stand-by")
|
|
87
182
|
data = {
|
|
88
183
|
"Label": label,
|
|
89
|
-
"ProgramArguments": [
|
|
90
|
-
f"{engine}/src/ui/server/index.ts", "--silent"],
|
|
184
|
+
"ProgramArguments": [exe, "--import", f"{tsx}/dist/loader.mjs", *entry],
|
|
91
185
|
"WorkingDirectory": dist,
|
|
92
|
-
"EnvironmentVariables": {"PATH": path, "HOME": home,
|
|
186
|
+
"EnvironmentVariables": {"PATH": path, "HOME": home, "USER": user,
|
|
187
|
+
"LOGNAME": user,
|
|
93
188
|
"DEN_ROOT": dist, "DEN_ENGINE_ROOT": engine},
|
|
94
189
|
"RunAtLoad": True,
|
|
95
190
|
"KeepAlive": True,
|
|
@@ -97,9 +192,12 @@ data = {
|
|
|
97
192
|
# Interactive, not Background: macOS gives Background agents efficiency
|
|
98
193
|
# cores and throttled I/O, and the UI stalled for seconds under load.
|
|
99
194
|
"ProcessType": "Interactive",
|
|
100
|
-
"StandardOutPath":
|
|
101
|
-
"StandardErrorPath":
|
|
195
|
+
"StandardOutPath": log,
|
|
196
|
+
"StandardErrorPath": log,
|
|
102
197
|
}
|
|
198
|
+
if mode == "always":
|
|
199
|
+
# A LaunchDaemon runs as root unless told otherwise: run as the person.
|
|
200
|
+
data["UserName"] = user
|
|
103
201
|
if out == "-":
|
|
104
202
|
sys.stdout.buffer.write(plistlib.dumps(data))
|
|
105
203
|
else:
|
|
@@ -108,58 +206,110 @@ else:
|
|
|
108
206
|
PY
|
|
109
207
|
}
|
|
110
208
|
|
|
209
|
+
# Stop and remove a job (any mode), quietly when it is not there.
|
|
210
|
+
remove_job() { # $1 label $2 mode
|
|
211
|
+
local plist; plist="$(plist_for "$1" "$2")"
|
|
212
|
+
if [ "$2" = always ]; then
|
|
213
|
+
[ -f "$plist" ] || return 0
|
|
214
|
+
fi
|
|
215
|
+
as_mode "$2" launchctl bootout "$(domain_for "$2")/$1" 2>/dev/null || true
|
|
216
|
+
[ -f "$plist" ] && as_mode "$2" rm -f "$plist"
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Install and start one job in SVC_MODE.
|
|
221
|
+
load_job() { # $1 label $2 web|host
|
|
222
|
+
local label="$1" plist domain tmp
|
|
223
|
+
plist="$(plist_for "$label" "$SVC_MODE")"
|
|
224
|
+
domain="$(domain_for "$SVC_MODE")"
|
|
225
|
+
tmp="$(mktemp "${TMPDIR:-/tmp}/den-plist.XXXXXX")"
|
|
226
|
+
write_plist "$tmp" "$2" "$SVC_MODE"
|
|
227
|
+
plutil -lint "$tmp" >/dev/null
|
|
228
|
+
# The other mode's job is the same service: never both.
|
|
229
|
+
remove_job "$label" "$(other_mode "$SVC_MODE")"
|
|
230
|
+
as_mode "$SVC_MODE" launchctl bootout "$domain/$label" 2>/dev/null || true
|
|
231
|
+
# bootout returns before launchd has let go of the label; a bootstrap
|
|
232
|
+
# straight after fails ("Bootstrap failed: 5") and leaves NO service.
|
|
233
|
+
# Wait for it to go, then retry bootstrap a few times.
|
|
234
|
+
for _ in $(seq 1 50); do
|
|
235
|
+
as_mode "$SVC_MODE" launchctl print "$domain/$label" >/dev/null 2>&1 || break
|
|
236
|
+
sleep 0.2
|
|
237
|
+
done
|
|
238
|
+
if [ "$SVC_MODE" = always ]; then
|
|
239
|
+
as_mode always install -o root -g wheel -m 644 "$tmp" "$plist"
|
|
240
|
+
rm -f "$tmp"
|
|
241
|
+
else
|
|
242
|
+
mkdir -p "$(dirname "$plist")"
|
|
243
|
+
mv "$tmp" "$plist"
|
|
244
|
+
fi
|
|
245
|
+
local loaded=0
|
|
246
|
+
for _ in 1 2 3 4 5; do
|
|
247
|
+
if as_mode "$SVC_MODE" launchctl bootstrap "$domain" "$plist" 2>/dev/null; then loaded=1; break; fi
|
|
248
|
+
sleep 1
|
|
249
|
+
done
|
|
250
|
+
[ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $label (plist: $plist)" >&2; exit 1; }
|
|
251
|
+
as_mode "$SVC_MODE" launchctl kickstart -k "$domain/$label"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# The job's state, as status shows it.
|
|
255
|
+
job_status() { # $1 label
|
|
256
|
+
local m plist domain svc_node out
|
|
257
|
+
for m in always login; do
|
|
258
|
+
plist="$(plist_for "$1" "$m")"
|
|
259
|
+
[ -f "$plist" ] || continue
|
|
260
|
+
domain="$(domain_for "$m")"
|
|
261
|
+
# The node it was installed with may be gone (a removed nvm version):
|
|
262
|
+
# launchd then restarts it forever with no useful message.
|
|
263
|
+
if command -v plutil >/dev/null 2>&1; then
|
|
264
|
+
svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$plist" 2>/dev/null || true)"
|
|
265
|
+
if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
|
|
266
|
+
echo "✗ $1 runs $svc_node, which no longer exists: re-run service install" >&2
|
|
267
|
+
return 1
|
|
268
|
+
fi
|
|
269
|
+
fi
|
|
270
|
+
echo "$1 ($m: $domain)"
|
|
271
|
+
if out="$(launchctl print "$domain/$1" 2>/dev/null)"; then
|
|
272
|
+
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
273
|
+
else
|
|
274
|
+
echo " plist present, job not loaded: $([ "$m" = always ] && echo "sudo ")launchctl bootstrap $domain $plist"
|
|
275
|
+
fi
|
|
276
|
+
return 0
|
|
277
|
+
done
|
|
278
|
+
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
279
|
+
echo "$1 is not loaded"
|
|
280
|
+
}
|
|
281
|
+
|
|
111
282
|
MODE="${1:-install}"
|
|
112
283
|
case "$MODE" in
|
|
113
284
|
install)
|
|
114
|
-
mkdir -p "$LOGS"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# Wait for it to go, then retry bootstrap a few times.
|
|
121
|
-
for _ in $(seq 1 50); do
|
|
122
|
-
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break
|
|
123
|
-
sleep 0.2
|
|
124
|
-
done
|
|
125
|
-
mv "$PLIST.new" "$PLIST"
|
|
126
|
-
loaded=0
|
|
127
|
-
for _ in 1 2 3 4 5; do
|
|
128
|
-
if launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null; then loaded=1; break; fi
|
|
129
|
-
sleep 1
|
|
130
|
-
done
|
|
131
|
-
[ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $LABEL (plist: $PLIST)" >&2; exit 1; }
|
|
132
|
-
launchctl kickstart -k "$DOMAIN/$LABEL"
|
|
285
|
+
mkdir -p "$LOGS"
|
|
286
|
+
if [ "$SVC_MODE" = always ] && [ -n "$SUDO" ]; then
|
|
287
|
+
echo "… $SVC_MODE: LaunchDaemons running as $USER_N need your admin password once (sudo)"
|
|
288
|
+
fi
|
|
289
|
+
load_job "$HOST_LABEL" host
|
|
290
|
+
load_job "$LABEL" web
|
|
133
291
|
if [ "${DEN_SERVICE_READY_CHECK:-}" = external ]; then
|
|
134
|
-
echo "… $LABEL started; waiting for it to answer"
|
|
292
|
+
echo "… $LABEL started ($SVC_MODE); waiting for it to answer"
|
|
135
293
|
else
|
|
136
|
-
echo "✓ $LABEL running (logs: $LOGS/web.log)"
|
|
294
|
+
echo "✓ $LABEL running ($SVC_MODE; logs: $LOGS/web.log)"
|
|
137
295
|
fi
|
|
138
296
|
;;
|
|
139
297
|
uninstall)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
298
|
+
for m in login always; do
|
|
299
|
+
remove_job "$LABEL" "$m"
|
|
300
|
+
remove_job "$HOST_LABEL" "$m"
|
|
301
|
+
done
|
|
302
|
+
echo "✓ $LABEL and $HOST_LABEL removed"
|
|
143
303
|
;;
|
|
144
304
|
print)
|
|
145
|
-
write_plist -
|
|
305
|
+
write_plist - web "$SVC_MODE"
|
|
306
|
+
;;
|
|
307
|
+
print-host)
|
|
308
|
+
write_plist - host "$SVC_MODE"
|
|
146
309
|
;;
|
|
147
310
|
status)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if [ -f "$PLIST" ] && command -v plutil >/dev/null 2>&1; then
|
|
151
|
-
svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$PLIST" 2>/dev/null || true)"
|
|
152
|
-
if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
|
|
153
|
-
echo "✗ $LABEL runs $svc_node, which no longer exists: re-run service install" >&2
|
|
154
|
-
exit 1
|
|
155
|
-
fi
|
|
156
|
-
fi
|
|
157
|
-
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
158
|
-
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
159
|
-
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
160
|
-
else
|
|
161
|
-
echo "$LABEL is not loaded"
|
|
162
|
-
fi
|
|
311
|
+
job_status "$LABEL"
|
|
312
|
+
job_status "$HOST_LABEL"
|
|
163
313
|
;;
|
|
164
|
-
*) echo "usage: $0 [install|uninstall|status|print]" >&2; exit 2 ;;
|
|
314
|
+
*) echo "usage: $0 [install|uninstall|status|print|print-host]" >&2; exit 2 ;;
|
|
165
315
|
esac
|
|
@@ -455,6 +455,11 @@ export function firstPromptArg(text: string | undefined): string[] {
|
|
|
455
455
|
return [t.startsWith("-") ? `Prompt: ${t}` : t];
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/** D24: `--name <label>`, so peers tell Den's agents apart; or nothing. */
|
|
459
|
+
function nameArg(label: string | undefined): string[] {
|
|
460
|
+
return label ? ["--name", label] : [];
|
|
461
|
+
}
|
|
462
|
+
|
|
458
463
|
const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
459
464
|
|
|
460
465
|
/**
|
|
@@ -503,6 +508,7 @@ export const denClaude: Runtime = {
|
|
|
503
508
|
bin,
|
|
504
509
|
"--session-id",
|
|
505
510
|
id,
|
|
511
|
+
...nameArg(input.sessionLabel),
|
|
506
512
|
...c.argv,
|
|
507
513
|
...firstPromptArg(input.firstPrompt),
|
|
508
514
|
],
|
|
@@ -517,7 +523,13 @@ export const denClaude: Runtime = {
|
|
|
517
523
|
const bin = binFor(input);
|
|
518
524
|
const c = common(input);
|
|
519
525
|
return {
|
|
520
|
-
argv: [
|
|
526
|
+
argv: [
|
|
527
|
+
bin,
|
|
528
|
+
"--resume",
|
|
529
|
+
input.resumeToken,
|
|
530
|
+
...nameArg(input.sessionLabel),
|
|
531
|
+
...c.argv,
|
|
532
|
+
],
|
|
521
533
|
env: {},
|
|
522
534
|
files: c.files,
|
|
523
535
|
warnings: c.warnings,
|
|
@@ -86,6 +86,12 @@ export interface AgentRecord {
|
|
|
86
86
|
displayName?: string;
|
|
87
87
|
/** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
|
|
88
88
|
displayNameAt?: string;
|
|
89
|
+
/**
|
|
90
|
+
* D24: the name Den last gave its runtime session (Claude Code's
|
|
91
|
+
* `--name`, peerSessionName), so reading it back is never taken for a
|
|
92
|
+
* rename.
|
|
93
|
+
*/
|
|
94
|
+
peerName?: string;
|
|
89
95
|
/**
|
|
90
96
|
* The model version it runs (AG-12.3): "latest" (the model's newest), or
|
|
91
97
|
* a pinned version. Absent: before AG-12, `model` as given.
|
|
@@ -120,6 +120,12 @@ export interface LaunchInput {
|
|
|
120
120
|
* Only a runtime with `firstPrompt: true` is given one.
|
|
121
121
|
*/
|
|
122
122
|
firstPrompt?: string;
|
|
123
|
+
/**
|
|
124
|
+
* D24: the name other sessions see this one by (peerSessionName:
|
|
125
|
+
* `<cli>-<ws>:<name>`). A runtime that can name its session (den-claude:
|
|
126
|
+
* `--name`) does, at launch and at resume.
|
|
127
|
+
*/
|
|
128
|
+
sessionLabel?: string;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export interface LaunchOutput {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* token: Start fresh forgets it). An adapter that fails or throws is no
|
|
13
13
|
* name; it never breaks the list.
|
|
14
14
|
*
|
|
15
|
-
* Den
|
|
15
|
+
* D24: Den names the runtime's session for other sessions to see
|
|
16
|
+
* (peerSessionName: `<cli>-<ws>:<name>`, Claude Code's `--name`) at launch
|
|
17
|
+
* and resume. That name read back is Den's own (the record's peerName),
|
|
18
|
+
* never a rename.
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "fs";
|
|
18
21
|
import * as path from "path";
|
|
@@ -71,6 +74,8 @@ function runtimeName(
|
|
|
71
74
|
} catch {
|
|
72
75
|
name = null;
|
|
73
76
|
}
|
|
77
|
+
// D24: Den's own --name, read back, is not the runtime's rename.
|
|
78
|
+
if (name && name === rec.peerName) name = null;
|
|
74
79
|
// null: nothing new (not caught up, not found); the last name stays.
|
|
75
80
|
if (name && name !== seen?.name) {
|
|
76
81
|
seen = { token, name, at: now() };
|
|
@@ -83,6 +88,21 @@ function runtimeName(
|
|
|
83
88
|
return seen;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* D24: the name other sessions (ListAgents) see agent `rec` by:
|
|
93
|
+
* `<cli>-<ws>:<its name, else its id>`. No control characters: it is a
|
|
94
|
+
* process argument.
|
|
95
|
+
*/
|
|
96
|
+
export function peerSessionName(
|
|
97
|
+
cli: string,
|
|
98
|
+
ws: string,
|
|
99
|
+
rec: Pick<AgentRecord, "id" | "displayName">,
|
|
100
|
+
): string {
|
|
101
|
+
// eslint-disable-next-line no-control-regex
|
|
102
|
+
const name = (rec.displayName ?? "").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
103
|
+
return `${cli}-${ws}:${name.trim() || rec.id}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/**
|
|
87
107
|
* The agent's session name: the latest of Den's rename and the runtime's,
|
|
88
108
|
* or undefined when neither has one. Never throws.
|