@rubytech/create-maxy-code 0.1.206 → 0.1.210

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 (23) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +26 -4
  3. package/payload/platform/plugins/docs/references/platform.md +25 -3
  4. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +2 -1
  5. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  6. package/payload/platform/services/claude-session-manager/dist/http-server.js +133 -41
  7. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +34 -0
  9. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +62 -1
  11. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +6 -4
  13. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  14. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +8 -5
  15. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  16. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +39 -12
  17. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +63 -35
  19. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/systemd-spawn.d.ts +18 -7
  21. package/payload/platform/services/claude-session-manager/dist/systemd-spawn.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/systemd-spawn.js +66 -35
  23. package/payload/platform/services/claude-session-manager/dist/systemd-spawn.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.206",
3
+ "version": "0.1.210",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any claim about what Maxy is, how the platform is built, how plugins/skills/specialists work, how install/deploy runs, or any other product-architecture fact. The body of this skill is the only permissible source for such claims. Cite the `Source:` URL inline.
4
- content-hash: sha256:882390ca0cefd27eb7a40a209ce71d3b9321b2b0ce8ff15e314c14c21f0827b5
4
+ content-hash: sha256:7a63e89172ec61a001e45436e346b7e89e5bae8b72436c74ea6279321e114a55
5
5
  ---
6
6
 
7
7
  # Platform architecture (reference)
@@ -206,14 +206,36 @@ Each session row also carries a small muted timestamp crumb under the name showi
206
206
 
207
207
  **Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.
208
208
 
209
- **Non-PTY scope spawn activation gate (Tasks 552 + 554 + 555).** `/rc-spawn` and rc-daemon spawns go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope is registered with `StandardOutput=journal`, `StandardError=journal`, and `SyslogIdentifier=claude-session-<unitToken>`, so the child's stdout and stderr land in the user journal and are retrievable after the scope is reaped via `journalctl --user -t claude-session-<token>`. The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
209
+ **Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
210
+
211
+ - **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
212
+ - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd at spawn time and releases it at `op=session-ready` once claude's pid file lands. The `trap "" HUP` wrap inside the scope keeps claude resident after the release, so the manager can detach without killing the session. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
213
+
214
+ **`/rc-spawn` lifecycle observability (Task 557).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
215
+
216
+ | Step | Line shape |
217
+ |------|-----------|
218
+ | 1 | `[rc-spawn] op=request unitToken=<t> sessionId=<8|new> name=<…|none> mode=<resume|fresh> jsonl=<path|none>` |
219
+ | 2 | `[rc-spawn] op=argv unitToken=<t> cwd=<dir> argv=<json>` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) |
220
+ | 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
221
+ | 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
222
+ | 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
223
+ | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` (pid file written by claude — the candidate safe fd-release point) |
224
+ | 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
225
+ | 8 | `[rc-spawn] op=fd-release unitToken=<t> pid=<pid> trigger=session-ready master-fd=closed openFds=<n>` then `[rc-spawn] op=survives-fd-release unitToken=<t> pid=<pid> alive=<bool>` (kill -0 — the empirical evidence for whether `trap "" HUP` holds) |
226
+ | 9 | `[fd-census] trigger=rc-spawn openFds=<n> livePtys=<n>` (the leak time-series — `openFds` rising while `livePtys` stays flat across successive spawns is the leak signature; `openFds` returning to baseline after each release is no leak) |
227
+ | 10 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` (fires only if the child exits before fd-release — once the master fd is closed `pty.onExit` no longer fires, so `op=detach` from `op=fd-release` is the terminal manager-side line for surviving children) |
228
+
229
+ `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop is one line: `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[rc-spawn\]|\[fd-census\]'`, then filter by `unitToken` to isolate a single spawn or by `trigger=rc-spawn` to read the cross-spawn fd time-series.
230
+
231
+ **Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
210
232
 
211
233
  - **`boot-crash`** — `cgroup.procs` was observed non-empty at some point and is now empty: claude reached init and crashed. The observed pid is included.
212
- - **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). This is the case Task 555 added to distinguish a clean fast-exit from a genuine systemd-side start failure. The `--remote-control … --resume <sid>` path on a missing transcript is the canonical trigger.
234
+ - **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). Task 555 added this branch to distinguish a clean fast-exit from a genuine systemd-side start failure. Pre-Task-556 the canonical trigger was a `--remote-control … --resume <sid>` spawn that received `/dev/null` on stdin and exited at the no-input branch in ~50 ms; with the script(1) TTY wrap in place, this branch now fires only for genuine fast-exits (a missing transcript, a bad `--resume` id, etc.).
213
235
  - **`systemd-start-failure`** — `cgroup.procs` was never observed populated and systemd reports `Result=failed` (or any non-`success`): systemd itself rejected the unit (bad cwd, missing binary, slice not loaded).
214
236
  - **`activation-timeout`** — the 5 s deadline expired without `ActiveState` ever leaving the activating set: a genuine systemd hang.
215
237
 
216
- Every terminal-branch error envelope embeds the last ~2 KB of the child's stdout/stderr as `journalTail=<json>`, so the admin-UI log line carries the child's actual exit message without an SSH-into-the-box step. The journal entry persists under the SyslogIdentifier after the scope is reaped.
238
+ Every terminal-branch error envelope embeds the last ~2 KB of the capture file as `captureTail=<json>`, so the admin-UI log line carries the child's actual exit message without an SSH-into-the-box step. The capture file itself persists at `~/.<brand>/logs/spawn-<unitToken>.log` after the scope is reaped — `cat`, `tail -f`, or any other file tool reads it; no `journalctl` round-trip required.
217
239
 
218
240
  **PTY lifecycle contract (Tasks 170 + 176 + 260).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. As of Task 260 the tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
219
241
 
@@ -98,14 +98,36 @@ Each session row also carries a small muted timestamp crumb under the name showi
98
98
 
99
99
  **Download JSONL (Task 197).** `GET /<id>/log?download=1` is a one-shot byte-stream of the session's JSONL transcript with attachment-disposition headers, designed for the pane's **Download JSONL** button. Headers: `Content-Type: application/x-ndjson`, `Content-Disposition: attachment; filename="<sessionId>.jsonl"` (the basename is sanitised so any non-`[A-Za-z0-9._-]` character is replaced with underscore), `Cache-Control: no-store`. Four status branches: **200** with the byte-identical file body; **404** `{error: 'session-not-found'}` when the store has no row for the id; **202** `{pending: true, jsonlPath: null}` when the row exists but claude has not flushed the first turn yet; **404** `{error: 'jsonl-missing-on-disk'}` when the row carries a `jsonlPath` but the file has been removed under the manager (post-Purge race). The download branch is declared **before** the follow check, so `?download=1` always wins over `?follow=1` if both are set. The proxy at `app.get('/:sessionId/log')` rebuilds the upstream query from a fixed `follow|download` allowlist; inbound query keys outside that allowlist are dropped. Observability: `[claude-session-manager] log-download sessionId=<sid> bytes=<n> ms=<n>` lands per successful stream completion; the browser console emits `[admin-ui] pane-download-jsonl sessionId=<8> outcome=initiated` on click. `outcome=initiated` rather than `outcome=ok` is intentional — the handler resolves before the browser writes the bytes, so the log line names "the request was kicked off", not "the file landed". If the file does not appear in the operator's downloads folder, check the manager line for the bytes count and the browser's downloads UI for the suppression record. Auth is unchanged from the rest of the `/api/admin/claude-sessions` surface (cookie session via `requireAdminSession`); there is no new key surface.
100
100
 
101
- **Non-PTY scope spawn activation gate (Tasks 552 + 554 + 555).** `/rc-spawn` and rc-daemon spawns go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope is registered with `StandardOutput=journal`, `StandardError=journal`, and `SyslogIdentifier=claude-session-<unitToken>`, so the child's stdout and stderr land in the user journal and are retrievable after the scope is reaped via `journalctl --user -t claude-session-<token>`. The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
101
+ **Two spawn methods coexist (Task 557).** The manager runs two separate on-device spawn surfaces:
102
+
103
+ - **`claude rc` daemon** — spawned at platform boot by `rc-daemon.ts`, **`systemdSpawn`** path. Owns the long-lived composer session that backs claude.ai/code Remote Control. The script(1) TTY wrap (Task 556) inside the scope keeps the daemon resident even though the manager holds no fd. The activation gate below covers this path.
104
+ - **`claude --remote-control` on-device sidebar spawn** — spawned per-click by `/rc-spawn` in `http-server.ts`, **`spawnPty` (node-pty) + `systemd-run --scope`** path. Node-pty inside the scope owns the real `xterm-256color` TTY (120×40); the manager process holds the master fd at spawn time and releases it at `op=session-ready` once claude's pid file lands. The `trap "" HUP` wrap inside the scope keeps claude resident after the release, so the manager can detach without killing the session. Task 552's `systemdSpawn`-with-`stdio:'ignore'` swap broke this surface for five occasions because `--scope` units inherit the launcher's fds, so claude got `/dev/null` on all three streams and exited at the no-input branch in ~50 ms; Task 557 restored node-pty here and left the daemon path unchanged.
105
+
106
+ **`/rc-spawn` lifecycle observability (Task 557).** Every on-device sidebar resume emits a stream of `[rc-spawn]` lines tagged with the same `unitToken=rc-resume-<uuid>` so one spawn's full lifeline can be reconstructed by `grep` alone. The lines, in order:
107
+
108
+ | Step | Line shape |
109
+ |------|-----------|
110
+ | 1 | `[rc-spawn] op=request unitToken=<t> sessionId=<8|new> name=<…|none> mode=<resume|fresh> jsonl=<path|none>` |
111
+ | 2 | `[rc-spawn] op=argv unitToken=<t> cwd=<dir> argv=<json>` (inner claude argv; the `systemd-run --scope` wrap is composed by the spawnPty adapter) |
112
+ | 3 | `[rc-spawn] op=pty-spawned unitToken=<t> pid=<pid> openFds=<n>` (fd baseline) |
113
+ | 4 | `[rc-spawn] op=child-output unitToken=<t> pid=<pid> head=<json>` (first ≤1 KB or 500 ms idle — claude's own words: `Remote Control connecting…` on success, `No deferred tool marker` / `Unknown assignment` on failure) |
114
+ | 5 | `[rc-spawn] op=alive unitToken=<t> pid=<pid> ranMs=<n>` and `op=early-exit` mirror — `early-exit` fires when `pty.onExit` lands before the pid file (the no-TTY death signature) |
115
+ | 6 | `[rc-spawn] op=session-ready unitToken=<t> pid=<pid> pidFile=<path> bridgeId=<…>` (pid file written by claude — the candidate safe fd-release point) |
116
+ | 7 | `[pty-tracker] op=spawn sessionId=<8> pid=<pid> size=<n>` (also fires for spawnClaudeSession; same line shape on the rc-spawn path) |
117
+ | 8 | `[rc-spawn] op=fd-release unitToken=<t> pid=<pid> trigger=session-ready master-fd=closed openFds=<n>` then `[rc-spawn] op=survives-fd-release unitToken=<t> pid=<pid> alive=<bool>` (kill -0 — the empirical evidence for whether `trap "" HUP` holds) |
118
+ | 9 | `[fd-census] trigger=rc-spawn openFds=<n> livePtys=<n>` (the leak time-series — `openFds` rising while `livePtys` stays flat across successive spawns is the leak signature; `openFds` returning to baseline after each release is no leak) |
119
+ | 10 | `[rc-spawn] op=exit unitToken=<t> pid=<pid> ranMs=<n>` paired with `[pty-tracker] op=exit` from `handlePtyNaturalExit` (fires only if the child exits before fd-release — once the master fd is closed `pty.onExit` no longer fires, so `op=detach` from `op=fd-release` is the terminal manager-side line for surviving children) |
120
+
121
+ `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop is one line: `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[rc-spawn\]|\[fd-census\]'`, then filter by `unitToken` to isolate a single spawn or by `trigger=rc-spawn` to read the cross-spawn fd time-series.
122
+
123
+ **Non-PTY scope spawn — activation gate (Tasks 552 + 554 + 555 + 556).** `claude rc` daemon spawns (and historically `/rc-spawn`, until Task 557 restored node-pty for that path) go through `systemdSpawn` (a `child_process.spawn` around `systemd-run --user --scope ...`, no PTY). The scope's inner command is `script -q -f -c "<claude cmd>" "<captureFilePath>"` (Task 556): `script(1)` allocates a real pty master and slave so claude runs on a TTY (without one, `claude --remote-control --resume` and `claude rc --spawn` early-exit because `--scope` units inherit the launcher's `stdio: 'ignore'` fds), and writes its typescript to a per-spawn capture file at `~/.<brand>/logs/spawn-<unitToken>.log`. The capture file holds the child's stdout/stderr and survives scope reaping, so a fast-exiting child's last words are recoverable by reading the file. (The prior Task 555 attempt — `StandardOutput=journal` + `SyslogIdentifier=` scope properties — was a no-op because those are `Exec*=` *service-unit* fields that systemd silently ignores on `--scope` units.) The activation gate polls `systemctl show -p ActiveState -p ControlGroup -p Result -p SubState` every 50 ms until either the scope reaches `ActiveState=active` with a pid in `cgroup.procs` (success — a handle is returned) or it reaches a terminal state. Four terminal outcomes are distinguished:
102
124
 
103
125
  - **`boot-crash`** — `cgroup.procs` was observed non-empty at some point and is now empty: claude reached init and crashed. The observed pid is included.
104
- - **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). This is the case Task 555 added to distinguish a clean fast-exit from a genuine systemd-side start failure. The `--remote-control … --resume <sid>` path on a missing transcript is the canonical trigger.
126
+ - **`child-exited-during-activation`** — `cgroup.procs` was never observed populated and systemd reports `Result=success`: the child exited normally faster than the poll interval (50 ms). Task 555 added this branch to distinguish a clean fast-exit from a genuine systemd-side start failure. Pre-Task-556 the canonical trigger was a `--remote-control … --resume <sid>` spawn that received `/dev/null` on stdin and exited at the no-input branch in ~50 ms; with the script(1) TTY wrap in place, this branch now fires only for genuine fast-exits (a missing transcript, a bad `--resume` id, etc.).
105
127
  - **`systemd-start-failure`** — `cgroup.procs` was never observed populated and systemd reports `Result=failed` (or any non-`success`): systemd itself rejected the unit (bad cwd, missing binary, slice not loaded).
106
128
  - **`activation-timeout`** — the 5 s deadline expired without `ActiveState` ever leaving the activating set: a genuine systemd hang.
107
129
 
108
- Every terminal-branch error envelope embeds the last ~2 KB of the child's stdout/stderr as `journalTail=<json>`, so the admin-UI log line carries the child's actual exit message without an SSH-into-the-box step. The journal entry persists under the SyslogIdentifier after the scope is reaped.
130
+ Every terminal-branch error envelope embeds the last ~2 KB of the capture file as `captureTail=<json>`, so the admin-UI log line carries the child's actual exit message without an SSH-into-the-box step. The capture file itself persists at `~/.<brand>/logs/spawn-<unitToken>.log` after the scope is reaped — `cat`, `tail -f`, or any other file tool reads it; no `journalctl` round-trip required.
109
131
 
110
132
  **PTY lifecycle contract (Tasks 170 + 176 + 260).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. As of Task 260 the tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
111
133
 
@@ -1,6 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { type SpawnDeps } from './pty-spawner.js';
3
- import { type SystemdScopeHandle } from './systemd-spawn.js';
3
+ import type { SystemdScopeHandle } from './systemd-spawn.js';
4
4
  import type { FsWatcher } from './fs-watcher.js';
5
5
  import type { RateLimiter } from './spawn-rate-limiter.js';
6
6
  import type { AuditRegistry } from './public-tool-audit.js';
@@ -26,6 +26,7 @@ export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'>
26
26
  env: Readonly<Record<string, string>>;
27
27
  cwd: string;
28
28
  unitToken: string;
29
+ captureFilePath: string;
29
30
  }) => Promise<SystemdScopeHandle>;
30
31
  }
31
32
  /** Task 189 — Hono instance with the test-only fan-out trigger attached.
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,EAcL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EAAgB,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAY1E,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AA+E9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAI1D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;2DAIuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;QAC7B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;QACrC,GAAG,EAAE,MAAM,CAAA;QACX,SAAS,EAAE,MAAM,CAAA;KAClB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAClC;AA4LD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAmwCpD"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAM3B,OAAO,EAiBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAa5D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AA+E9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAI1D,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;2DAIuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;QAC7B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;QACrC,GAAG,EAAE,MAAM,CAAA;QACX,SAAS,EAAE,MAAM,CAAA;QACjB,eAAe,EAAE,MAAM,CAAA;KACxB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAClC;AA4LD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CA83CpD"}
@@ -24,7 +24,7 @@ import { stream } from 'hono/streaming';
24
24
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import { buildRcChildEnv } from './rc-daemon.js';
27
- import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, } from './pty-spawner.js';
27
+ import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, resolveLiveMemory, openFdCount, registerRcSpawnPty, releaseAndUnregisterRcSpawnPty, } from './pty-spawner.js';
28
28
  import { readSidecar, updateSidecar, appendBridgeId } from './session-sidecar.js';
29
29
  import { readJsonlSession } from './jsonl-enumerator.js';
30
30
  import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId, permissionModeLogPathForSessionId, findExistingJsonlForSessionId, } from './jsonl-path.js';
@@ -1406,11 +1406,25 @@ export function buildHttpApp(deps) {
1406
1406
  // Task 543 — fire-and-forget `claude --remote-control [name] [--session-id <sid>]`
1407
1407
  // spawn for the admin sessions pane. The new PTY registers itself in
1408
1408
  // claude.ai/code as its own Remote Control entry; reconciliation with the
1409
- // rc-daemon is out of scope. The handle is intentionally discarded — the
1410
- // manager does NOT track or supervise this PTY (it is not a managed session
1411
- // row). The PID lands in `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json` via
1412
- // claude's own bootstrap, which is how the sidebar list keeps `live`
1413
- // accurate and how the delete route later finds the PID to SIGTERM.
1409
+ // rc-daemon is out of scope. The PID lands in
1410
+ // `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json` via claude's own bootstrap,
1411
+ // which is how the sidebar list keeps `live` accurate and how the delete
1412
+ // route later finds the PID to SIGTERM.
1413
+ //
1414
+ // Task 557 — restored the node-pty spawn path (Task 552 had swapped it
1415
+ // for `systemdSpawn` with `stdio:'ignore'`, which inherited /dev/null on
1416
+ // all three streams; `claude --remote-control --resume` early-exited at
1417
+ // the no-input branch in ~50 ms). The manager now owns a real PTY for
1418
+ // the rc-spawn child — `xterm-256color` inside `systemd-run --scope` —
1419
+ // and registers it via `registerRcSpawnPty` so the master fd has a
1420
+ // defined owner and release path. Lifecycle observability is the bulk
1421
+ // of the change: every spawn writes `[rc-spawn]` lines tagged with the
1422
+ // `unitToken` for request, argv, pty-spawned, child-output (first
1423
+ // ≤1 KB), alive / early-exit, session-ready, fd-release,
1424
+ // survives-fd-release, and exit, plus an `[fd-census]` snapshot of
1425
+ // `openFdCount()` vs `livePtyCount()` after fd-release. This ends the
1426
+ // 5-occasion blindness — the next failure on this surface is
1427
+ // diagnosable from `server.log` alone.
1414
1428
  app.post('/rc-spawn', async (c) => {
1415
1429
  const start = Date.now();
1416
1430
  let body;
@@ -1429,10 +1443,7 @@ export function buildHttpApp(deps) {
1429
1443
  // Task 547 — when a JSONL already exists for the requested sessionId,
1430
1444
  // resume the existing conversation instead of forking a fresh one
1431
1445
  // under the same id. `--session-id` is claude's fresh-spawn flag;
1432
- // `--resume` is the continue-this-conversation flag. The sidebar's
1433
- // Resume button posts an existing sessionId; without this branch it
1434
- // would mint a new conversation under that id and abandon the prior
1435
- // transcript.
1446
+ // `--resume` is the continue-this-conversation flag.
1436
1447
  const existingJsonlPath = sessionId ? findExistingJsonlForSessionId(deps.claudeConfigDir, sessionId) : null;
1437
1448
  const mode = existingJsonlPath ? 'resume' : 'fresh';
1438
1449
  const argv = ['--remote-control'];
@@ -1449,46 +1460,127 @@ export function buildHttpApp(deps) {
1449
1460
  brandedNeo4jPassword: process.env.NEO4J_PASSWORD ?? '',
1450
1461
  });
1451
1462
  const unitToken = `rc-resume-${randomUUID()}`;
1452
- // Task 552 non-PTY spawn. systemd-run runs detached with stdio:'ignore'
1453
- // so the manager holds zero file descriptors for this child. The route
1454
- // is fire-and-forget; the handle is dropped on return (user systemd
1455
- // owns the scope cgroup's lifetime).
1456
- let handle;
1463
+ const sessionIdForLog = sessionId ? sessionId.slice(0, 8) : 'new';
1464
+ // Step 1 request received, before any spawn work. The argv-resolved
1465
+ // line (step 2) emits next, so a 500 between them isolates resolution
1466
+ // failures from spawn failures.
1467
+ deps.logger(`[rc-spawn] op=request unitToken=${unitToken} sessionId=${sessionIdForLog} name=${name ?? 'none'} mode=${mode} jsonl=${existingJsonlPath ?? 'none'}`);
1468
+ // Step 2 — the exact claude argv (the inner command). The systemd-run
1469
+ // wrap is composed by the spawnPty adapter; logging the inner argv
1470
+ // here is enough for an operator to reproduce by hand. The wrapped
1471
+ // argv is fully reconstructible from this line + the spawn adapter
1472
+ // source (Task 250 / 556).
1473
+ deps.logger(`[rc-spawn] op=argv unitToken=${unitToken} cwd=${deps.spawnCwd} argv=${JSON.stringify([deps.claudeBin, ...argv])}`);
1474
+ let pty;
1457
1475
  try {
1458
- // Filter the env down to strings — the systemdSpawn contract is
1459
- // Record<string,string>, and buildRcChildEnv may carry undefined
1460
- // values through from process.env spreading.
1461
- const cleanEnv = {};
1462
- for (const [k, v] of Object.entries(env)) {
1463
- if (typeof v === 'string')
1464
- cleanEnv[k] = v;
1465
- }
1466
- handle = await deps.systemdSpawn({
1467
- claudeBin: deps.claudeBin,
1468
- claudeArgs: argv,
1469
- env: cleanEnv,
1470
- cwd: deps.spawnCwd,
1471
- unitToken,
1472
- });
1476
+ pty = deps.spawnPty(deps.claudeBin, argv, env, unitToken);
1473
1477
  }
1474
1478
  catch (err) {
1475
1479
  const msg = err instanceof Error ? err.message : String(err);
1476
1480
  deps.logger(`[sessions-rc-resume] spawn-failed sessionId=${sessionId ?? 'new'} name=${name ?? 'none'} err=${msg}`);
1481
+ deps.logger(`[rc-spawn] op=spawn-failed unitToken=${unitToken} err=${JSON.stringify(msg)}`);
1477
1482
  timed(deps.logger, 'POST', '/rc-spawn', 500, Date.now() - start);
1478
1483
  return c.json({ error: msg }, 500);
1479
1484
  }
1480
- // Per spec: log a single deterministic line so a future investigation can
1481
- // correlate these spawns with claude.ai/code Remote Control entries without
1482
- // re-reading the codepath.
1483
- // Task 550bridgeId=pending: the composer's ULID is learned later
1484
- // via the pid-file watcher subscription above; the bind is logged
1485
- // separately as `[sessions-bridge-bind] sessionId=… bridgeId=…`.
1486
- // Task 552 spawnMethod=systemd-scope marker stays constant after
1487
- // this task lands; an investigation can confirm the path without
1488
- // re-reading the source.
1489
- deps.logger(`[sessions-rc-resume] mode=${mode} sessionId=${sessionId ?? 'none'} jsonlPath=${existingJsonlPath ?? 'none'} name=${name ?? 'none'} spawnedPid=${handle.pid} bridgeId=pending spawnMethod=systemd-scope env.CLAUDE_CONFIG_DIR=${deps.claudeConfigDir}`);
1485
+ // Step 3 PTY spawned. fd baseline at this instant; the fd-release
1486
+ // line later in the lifecycle is read relative to this value.
1487
+ deps.logger(`[rc-spawn] op=pty-spawned unitToken=${unitToken} pid=${pty.pid} openFds=${openFdCount()}`);
1488
+ // Tracker registrationcloses the leak gap the Task 552 path opened.
1489
+ // For fresh spawns without a caller-supplied sessionId we register the
1490
+ // tracker under the unitToken so the entry has a stable key; the
1491
+ // operator-visible sessionId only becomes known when claude writes its
1492
+ // pid file. Both keys are equally good for fd-ownership purposes.
1493
+ const trackerKey = sessionId ?? `rc-${unitToken}`;
1494
+ const tracker = registerRcSpawnPty({ logger: deps.logger }, trackerKey, pty, unitToken);
1495
+ // Step 4 — first child output. Accumulate up to 1 KB or until 500 ms
1496
+ // after the first byte arrives, then emit one line. The single log
1497
+ // that ends the 5-occasion blindness: claude's own words land here
1498
+ // — `Remote Control` on success, `No deferred tool marker` /
1499
+ // `Unknown assignment` on failure.
1500
+ let outputBuf = '';
1501
+ let outputEmitted = false;
1502
+ let outputTimer = null;
1503
+ const emitChildOutput = () => {
1504
+ if (outputEmitted)
1505
+ return;
1506
+ outputEmitted = true;
1507
+ if (outputTimer) {
1508
+ clearTimeout(outputTimer);
1509
+ outputTimer = null;
1510
+ }
1511
+ deps.logger(`[rc-spawn] op=child-output unitToken=${unitToken} pid=${pty.pid} head=${JSON.stringify(outputBuf)}`);
1512
+ };
1513
+ pty.onData((data) => {
1514
+ if (outputEmitted)
1515
+ return;
1516
+ const remaining = 1024 - outputBuf.length;
1517
+ outputBuf += remaining >= data.length ? data : data.slice(0, remaining);
1518
+ if (outputBuf.length >= 1024)
1519
+ emitChildOutput();
1520
+ else if (!outputTimer)
1521
+ outputTimer = setTimeout(emitChildOutput, 500);
1522
+ });
1523
+ // Step 5/10 — exit observation. `pidFileSeen` discriminates `early-exit`
1524
+ // (no pid file ever appeared — the no-TTY death signature) from `exit`
1525
+ // (clean termination after a normal run). `handlePtyNaturalExit` (wired
1526
+ // by `registerRcSpawnPty`) handles the tracker side — fd release +
1527
+ // tracker delete + the `kill … reason=process-exited` log.
1528
+ let pidFileSeen = false;
1529
+ pty.onExit((e) => {
1530
+ const ranMs = Date.now() - start;
1531
+ if (!pidFileSeen) {
1532
+ deps.logger(`[rc-spawn] op=early-exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs} exitCode=${e.exitCode} signal=${e.signal ?? 'none'}`);
1533
+ }
1534
+ deps.logger(`[rc-spawn] op=exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs}`);
1535
+ // Flush any buffered child output that never reached 1 KB nor the
1536
+ // 500 ms idle deadline before the child exited.
1537
+ if (!outputEmitted)
1538
+ emitChildOutput();
1539
+ });
1540
+ // Step 6/8/9 — session-ready (pid file landed) → fd-release →
1541
+ // survives-fd-release → fd-census. Runs async; the route returns
1542
+ // immediately after spawn (the sidebar UX is fire-and-forget). The
1543
+ // `waitForPid` timeout cap is the spawner's `pidFileTimeoutMs`; on
1544
+ // timeout we log but do not release the fd (the tracker's onExit
1545
+ // path handles cleanup if claude exits later).
1546
+ void deps.watcher
1547
+ .waitForPid(pty.pid, deps.pidFileTimeoutMs)
1548
+ .then((row) => {
1549
+ pidFileSeen = true;
1550
+ const aliveMs = Date.now() - start;
1551
+ deps.logger(`[rc-spawn] op=alive unitToken=${unitToken} pid=${pty.pid} ranMs=${aliveMs}`);
1552
+ deps.logger(`[rc-spawn] op=session-ready unitToken=${unitToken} pid=${pty.pid} pidFile=${row.pidFilePath ?? 'unknown'} bridgeId=${row.bridgeSessionId ?? 'unknown'}`);
1553
+ // Master-fd release at the session-ready point. Whether claude
1554
+ // survives the release is the empirical question Task 557 was
1555
+ // filed to answer; the `op=survives-fd-release` line below is
1556
+ // the evidence. If `alive=false` lands across the verification
1557
+ // spawns, the release point moves later (per the brief). The
1558
+ // tracker is unregistered here because `pty.onExit` will not
1559
+ // fire for the surviving child after destroy() closes the
1560
+ // master fd.
1561
+ const release = releaseAndUnregisterRcSpawnPty({ logger: deps.logger }, tracker, 'session-ready');
1562
+ deps.logger(`[rc-spawn] op=fd-release unitToken=${unitToken} pid=${pty.pid} trigger=session-ready ${release.suffix} openFds=${release.openFds}`);
1563
+ let alive = false;
1564
+ try {
1565
+ process.kill(pty.pid, 0);
1566
+ alive = true;
1567
+ }
1568
+ catch {
1569
+ alive = false;
1570
+ }
1571
+ deps.logger(`[rc-spawn] op=survives-fd-release unitToken=${unitToken} pid=${pty.pid} alive=${alive}`);
1572
+ deps.logger(`[fd-census] trigger=rc-spawn openFds=${openFdCount()} livePtys=${livePtyCount()}`);
1573
+ })
1574
+ .catch((err) => {
1575
+ deps.logger(`[rc-spawn] op=wait-pid-failed unitToken=${unitToken} pid=${pty.pid} err=${err instanceof Error ? err.message : String(err)}`);
1576
+ });
1577
+ // Task 552 marker preserved for grep continuity; the spawn method is
1578
+ // now node-pty + systemd-scope (Task 557), but the operator-visible
1579
+ // log key stays `spawnMethod=systemd-scope` because every rc-spawn
1580
+ // child still runs inside a `systemd-run --user --scope` unit.
1581
+ deps.logger(`[sessions-rc-resume] mode=${mode} sessionId=${sessionId ?? 'none'} jsonlPath=${existingJsonlPath ?? 'none'} name=${name ?? 'none'} spawnedPid=${pty.pid} bridgeId=pending spawnMethod=systemd-scope env.CLAUDE_CONFIG_DIR=${deps.claudeConfigDir}`);
1490
1582
  timed(deps.logger, 'POST', '/rc-spawn', 200, Date.now() - start);
1491
- return c.json({ spawnedPid: handle.pid, sessionId: sessionId ?? null });
1583
+ return c.json({ spawnedPid: pty.pid, sessionId: sessionId ?? null });
1492
1584
  });
1493
1585
  return app;
1494
1586
  }