@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 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
@@ -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 at login and restarts it if
1961
- it exits:
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 # install (or refresh) and start it
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 LaunchAgent plist it would install; installs nothing
1968
- ./bin/acme service uninstall # stop it and remove the LaunchAgent
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 | Default |
2015
- | ------------------- | ---------------------------------------- |
2016
- | `DEN_ROOT` | required in a distribution |
2017
- | `DEN_SERVICE_LABEL` | `local.<distribution dir name>.web` |
2018
- | `DEN_SERVICE_LOGS` | `~/Library/Logs/<distribution dir name>` |
2019
- | `DEN_SERVICE_NODE` | see below |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,24 +1,42 @@
1
1
  #!/usr/bin/env bash
2
- # Run the web UI as a long-lived macOS service (a launchd LaunchAgent):
3
- # started at login, restarted if it exits, not tied to any terminal.
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 it
6
- # service/install.sh uninstall stop it and remove the LaunchAgent
7
- # service/install.sh status launchd's view of it
8
- # service/install.sh print write the LaunchAgent plist to stdout only
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. The agent runs the
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 launchd label (default local.<dist dir name>.web)
17
- # DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
18
- # DEN_SERVICE_NODE the node the agent runs (default: see pick_node)
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
- PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
39
- DOMAIN="gui/$(id -u)"
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
- write_plist() { # $1 = output file
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" "$LABEL" "$NODE" "$TSX" "$ENGINE" "$DIST" "$SVC_PATH" "$HOME" "$LOGS" <<'PY'
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, node, tsx, engine, dist, path, home, logs = sys.argv[1:]
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": [node, "--import", f"{tsx}/dist/loader.mjs",
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": f"{logs}/web.log",
101
- "StandardErrorPath": f"{logs}/web.log",
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" "$(dirname "$PLIST")"
115
- write_plist "$PLIST.new"
116
- plutil -lint "$PLIST.new" >/dev/null
117
- launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
118
- # bootout returns before launchd has let go of the label; a bootstrap
119
- # straight after fails ("Bootstrap failed: 5") and leaves NO service.
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
- launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
141
- rm -f "$PLIST"
142
- echo "✓ $LABEL removed"
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
- # The node the agent was installed with may be gone (a removed nvm
149
- # version): launchd then restarts it forever with no useful message.
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: [bin, "--resume", input.resumeToken, ...c.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 never writes a name back into the runtime.
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.