@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +26 -4
- package/payload/platform/plugins/docs/references/platform.md +25 -3
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +2 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +133 -41
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +34 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +62 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +6 -4
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +8 -5
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +39 -12
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +63 -35
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-spawn.d.ts +18 -7
- package/payload/platform/services/claude-session-manager/dist/systemd-spawn.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-spawn.js +66 -35
- package/payload/platform/services/claude-session-manager/dist/systemd-spawn.js.map +1 -1
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
-
**
|
|
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).
|
|
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
|
|
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
|
-
**
|
|
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).
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
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.
|
|
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
|
-
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
|
|
1489
|
-
|
|
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 registration — closes 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:
|
|
1583
|
+
return c.json({ spawnedPid: pty.pid, sessionId: sessionId ?? null });
|
|
1492
1584
|
});
|
|
1493
1585
|
return app;
|
|
1494
1586
|
}
|