@rubytech/create-maxy-code 0.1.40 → 0.1.41
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/lib/mcp-stderr-tee/package.json +7 -0
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +142 -52
- package/payload/platform/plugins/docs/references/platform.md +18 -4
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/services/claude-session-manager/dist/config.d.ts +17 -0
- package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/config.js +31 -0
- package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +54 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +516 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -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 +364 -204
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +38 -11
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +11 -6
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +37 -44
- 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 +136 -159
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +24 -0
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/reaper.js +114 -0
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +16 -41
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js +29 -68
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +51 -0
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +196 -0
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +7 -0
- package/payload/server/public/assets/{Checkbox-6lLDKzMP.js → Checkbox-blxv2XPY.js} +1 -1
- package/payload/server/public/assets/{admin-DO9W2D9g.js → admin-C3EvHpLN.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BbLtk_k6.js → architectureDiagram-Q4EWVU46-bETsVzGS.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-B9IgcgeJ.js → blockDiagram-DXYQGD6D-5y0GBnfQ.js} +1 -1
- package/payload/server/public/assets/{brand-DQrOw38P.css → brand-DQlLKLDH.css} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BrvPS09f.js → c4Diagram-AHTNJAMY-C1znpVGu.js} +1 -1
- package/payload/server/public/assets/channel-BWqTaXzb.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-DXOr0ZnC.js → chunk-336JU56O-DlCVUGzQ.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BFW4zwrV.js → chunk-426QAEUC-bGYkmA4z.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-D7_xc8xf.js → chunk-4TB4RGXK-6Sb0Undm.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-CdCSpb8j.js → chunk-5FUZZQ4R-CAT_Pq2T.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-BVgnD9QR.js → chunk-5PVQY5BW-j231ESse.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-CYrT1Tom.js → chunk-EDXVE4YY-BAlS4QL7.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-B70d0jN7.js → chunk-ENJZ2VHE-D3Ku_BMP.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-BcLouRbd.js → chunk-ICPOFSXX-AR86A5WB.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-C2ja2fPj.js → chunk-OYMX7WX6-DxGhapkW.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-CCjoTpL6.js → chunk-U2HBQHQK-DAlqsDo_.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-QIVkp-XR.js → chunk-X2U36JSP-lZdkkdyB.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-qD9rebhx.js → chunk-YZCP3GAM-CCjtXOFD.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-C0J_f05H.js → chunk-ZZ45TVLE-ComtdKny.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-B9hC6fOR.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-afl_ydIG.js +1 -0
- package/payload/server/public/assets/clone-Lj83OaZ3.js +1 -0
- package/payload/server/public/assets/{dagre-B3cA7jId.js → dagre-DX8QJeVt.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-C8lVFO5U.js → dagre-KV5264BT-BcRbiZ1q.js} +1 -1
- package/payload/server/public/assets/data-CS_R5VWQ.js +1 -0
- package/payload/server/public/assets/{device-url-actions-qu2-XiHc.js → device-url-actions-Cag9IJ7n.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-BYkcTelK.js → diagram-5BDNPKRD-Bw47VwHO.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-B6f_WR4j.js → diagram-G4DWMVQ6-B-aVfhiD.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-ALaKcN6w.js → diagram-MMDJMWI5-DERz2dKo.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-CkVg3crF.js → diagram-TYMM5635-B7eEX9Ta.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DszlTGIQ.js → erDiagram-SMLLAGMA-G_JJq3zB.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-C9-FqSlk.js → flowDiagram-DWJPFMVM-BeY9QaO0.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-faJU-5Zy.js → ganttDiagram-T4ZO3ILL-IXviGYnx.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Cck8_tiG.js → gitGraphDiagram-UUTBAWPF-GlY6pZQ1.js} +1 -1
- package/payload/server/public/assets/graph-DtGkQBFr.js +1 -0
- package/payload/server/public/assets/graph-labels-Dwu1ON5v.js +1 -0
- package/payload/server/public/assets/{graphlib-CYRxeBXj.js → graphlib-BWgAjZSb.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-CLjEjhJX.js → infoDiagram-42DDH7IO-D4YZM0gD.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-nSIaVXdw.js → ishikawaDiagram-UXIWVN3A-fR71GDOp.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DuKq7d3g.js → journeyDiagram-VCZTEJTY-C1TcY_6m.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Bt5xqHfY.js → kanban-definition-6JOO6SKY-IPZKpnnG.js} +1 -1
- package/payload/server/public/assets/{line-BgV8qCP_.js → line-B2JtEL1B.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-DQzl__pY.js → mermaid-parser.core-BKCLXqUh.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-BAPEOjwh.js → mermaid.core-CmNpCLB-.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CmMBrI79.js → mindmap-definition-QFDTVHPH-BS0E2FPD.js} +1 -1
- package/payload/server/public/assets/{page-BoDdtGsH.js → page-BdI3GCun.js} +1 -1
- package/payload/server/public/assets/{page-D_NCNi5j.js → page-zIjAkU2y.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-BhsR9k6j.js → pieDiagram-DEJITSTG-J61JDyk8.js} +1 -1
- package/payload/server/public/assets/{public-DCcGKIMW.js → public-C5vaQnC_.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DSKV6d_A.js → quadrantDiagram-34T5L4WZ-CLI03OtR.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-BccM48AI.js → requirementDiagram-MS252O5E-C8MCt5NO.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-C0pxiQfn.js → sankeyDiagram-XADWPNL6-DQuugruj.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BVsUIJsW.js → sequenceDiagram-FGHM5R23-CoYitwhO.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-lqYQZ-F1.js → stateDiagram-FHFEXIEX-G68cHHoI.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Dtgpgr_D.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D-LxYOHq.js → timeline-definition-GMOUNBTQ-CelLeJVS.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BNHzvNba.js → vennDiagram-DHZGUBPP-BVgFPkJq.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-BfXR9l5s.js → wardleyDiagram-NUSXRM2D-CzD_8hl2.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CL7mVEbT.js → xychartDiagram-5P7HB3ND-BuTxWdX5.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +94 -4
- package/payload/server/public/assets/channel-Bmc1qlCD.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-RVZorqSk.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BEZem5bl.js +0 -1
- package/payload/server/public/assets/clone-B6eXIhiR.js +0 -1
- package/payload/server/public/assets/data-BaSmBQ7U.js +0 -1
- package/payload/server/public/assets/graph-labels-COhLbJD0.js +0 -1
- package/payload/server/public/assets/graph-qg6fkRyC.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-D2eHThhC.js +0 -1
- /package/payload/server/public/assets/{brand-Bky-hMVw.js → brand-D6mXeyxV.js} +0 -0
|
@@ -6,35 +6,27 @@
|
|
|
6
6
|
// POST /spawn { senderId, role, channel, tunnelUrl? } -> Session
|
|
7
7
|
// GET /list?senderId=... -> Session[]
|
|
8
8
|
// POST /resume { senderId, role, channel, claudeSessionId } -> Session
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// POST /:id/stop -> 204 (kill PTY; idempotent on already-dead row)
|
|
10
|
+
// 404 if no row matches any of the 4 keys
|
|
11
|
+
// DELETE /:id -> 204 (rmSync JSONL + sidecar + subdir)
|
|
12
|
+
// 409 if PTY still alive
|
|
13
|
+
// 404 if no transcript on disk
|
|
14
|
+
// POST /:id/input { text }
|
|
15
|
+
// GET /:id/log -> { jsonlPath, sizeBytes, exists }
|
|
16
|
+
// GET /:id/log?follow=1 -> streamed file content (current + new)
|
|
17
|
+
// GET /:id/meta -> SessionMeta payload
|
|
18
|
+
// PATCH /:id { displayName } -> 204 (writes sidecar)
|
|
19
|
+
// POST /:id/archive { mode }
|
|
16
20
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// operator turn (claude does not write the JSONL until then, verified Pi
|
|
22
|
-
// 2.1.143 and Mac 2.1.128) and promotes the row to the intrinsic
|
|
23
|
-
// claudeSessionId in the background — no second client call needed.
|
|
24
|
-
//
|
|
25
|
-
// Every GET route resolves either the bridge suffix or the intrinsic id to
|
|
26
|
-
// the same row, so a UI that mounted on the bridge suffix at t=0 keeps
|
|
27
|
-
// routing correctly after promotion without flipping the row's visible
|
|
28
|
-
// position. The intrinsic id is the right field to use for routing once
|
|
29
|
-
// known (UI reads `claudeSessionId ?? sessionId`).
|
|
30
|
-
//
|
|
31
|
-
// sidecar `<id>.meta.json` next to the JSONL holds operator-owned fields
|
|
32
|
-
// (displayName). PATCH writes it; /meta reads it via the enumerator helpers.
|
|
21
|
+
// Any of the four ids — intrinsic sessionId, bridgeSessionId, bridgeSuffix,
|
|
22
|
+
// or pid (numeric) — resolves to the same row via the fs-watcher index.
|
|
23
|
+
// Unknown ids return 404; nothing returns a silent 204 against an id the
|
|
24
|
+
// manager does not know.
|
|
33
25
|
import { Hono } from 'hono';
|
|
34
26
|
import { stream } from 'hono/streaming';
|
|
35
27
|
import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
36
|
-
import { spawnClaudeSession,
|
|
37
|
-
import {
|
|
28
|
+
import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
|
|
29
|
+
import { readJsonlSession, readSidecar, sidecarPathFor } from './jsonl-enumerator.js';
|
|
38
30
|
import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
|
|
39
31
|
import { basename, join } from 'node:path';
|
|
40
32
|
const ROLES = ['admin', 'public'];
|
|
@@ -43,12 +35,10 @@ function parseAboutOwner(raw) {
|
|
|
43
35
|
if (!raw || typeof raw !== 'object')
|
|
44
36
|
return undefined;
|
|
45
37
|
const r = raw;
|
|
46
|
-
if (r.ok === true && typeof r.body === 'string')
|
|
38
|
+
if (r.ok === true && typeof r.body === 'string')
|
|
47
39
|
return { ok: true, body: r.body };
|
|
48
|
-
|
|
49
|
-
if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0) {
|
|
40
|
+
if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0)
|
|
50
41
|
return { ok: false, reason: r.reason };
|
|
51
|
-
}
|
|
52
42
|
return undefined;
|
|
53
43
|
}
|
|
54
44
|
function parseDormantPlugins(raw) {
|
|
@@ -121,37 +111,40 @@ function parseSpecialistDomains(raw) {
|
|
|
121
111
|
}
|
|
122
112
|
return out.length > 0 ? out : undefined;
|
|
123
113
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
114
|
+
// `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
|
|
115
|
+
// bridgeSuffix, or pid (numeric). All resolve to a sessionId via watcher.
|
|
126
116
|
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
127
117
|
const LIST_TAIL_BYTES = 64 * 1024;
|
|
128
118
|
const DISPLAY_NAME_MAX = 256;
|
|
129
119
|
function timed(logger, method, path, status, ms) {
|
|
130
120
|
logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
|
|
131
121
|
}
|
|
132
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
122
|
+
/** Resolve any of the 4 keys to the canonical (sessionId, row) pair.
|
|
123
|
+
* Tries: intrinsic sessionId, bridgeSessionId, bridgeSuffix, numeric pid.
|
|
124
|
+
* Returns undefined when nothing matches. */
|
|
125
|
+
function resolveRow(watcher, id) {
|
|
126
|
+
const direct = watcher.getBySessionId(id);
|
|
127
|
+
if (direct)
|
|
128
|
+
return direct;
|
|
129
|
+
const byBridge = watcher.getByBridgeSessionId(id);
|
|
130
|
+
if (byBridge)
|
|
131
|
+
return byBridge;
|
|
132
|
+
const bySuffix = watcher.getByBridgeSuffix(id);
|
|
133
|
+
if (bySuffix)
|
|
134
|
+
return bySuffix;
|
|
135
|
+
if (/^\d+$/.test(id)) {
|
|
136
|
+
const byPid = watcher.getByPid(Number(id));
|
|
137
|
+
if (byPid)
|
|
138
|
+
return byPid;
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
const path = join(claudeStateRoot(), 'projects', slug, `${sessionId}.jsonl`);
|
|
145
|
-
if (existsSync(path))
|
|
146
|
-
return { path, claudeSessionId: sessionId };
|
|
147
|
-
return null;
|
|
140
|
+
return undefined;
|
|
148
141
|
}
|
|
149
142
|
function attachPublicAudit(deps, session) {
|
|
150
143
|
if (session.role !== 'public')
|
|
151
144
|
return;
|
|
152
|
-
if (session.
|
|
145
|
+
if (session.jsonlPath === null)
|
|
153
146
|
return;
|
|
154
|
-
const claudeSessionId = session.
|
|
147
|
+
const claudeSessionId = session.sessionId;
|
|
155
148
|
deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
|
|
156
149
|
if (session.exited) {
|
|
157
150
|
deps.publicToolAudit.detach(claudeSessionId);
|
|
@@ -163,24 +156,41 @@ function attachPublicAudit(deps, session) {
|
|
|
163
156
|
}
|
|
164
157
|
export function buildHttpApp(deps) {
|
|
165
158
|
const app = new Hono();
|
|
159
|
+
// Watcher subscriber: when JSONL CREATE fires for a live session,
|
|
160
|
+
// patch jsonlPath onto the StoredSession and attach the audit watcher.
|
|
161
|
+
deps.watcher.subscribe((event) => {
|
|
162
|
+
if (event.source !== 'jsonl')
|
|
163
|
+
return;
|
|
164
|
+
if (event.kind !== 'create' && event.kind !== 'modify')
|
|
165
|
+
return;
|
|
166
|
+
if (!event.row || !event.row.jsonlPath)
|
|
167
|
+
return;
|
|
168
|
+
const live = deps.store.get(event.sessionId);
|
|
169
|
+
if (!live)
|
|
170
|
+
return;
|
|
171
|
+
if (live.jsonlPath !== null)
|
|
172
|
+
return;
|
|
173
|
+
live.jsonlPath = event.row.jsonlPath;
|
|
174
|
+
deps.logger(`[fs-watcher] live-row-jsonl-attached sessionId=${event.sessionId} path=${event.row.jsonlPath}`);
|
|
175
|
+
attachPublicAudit(deps, live);
|
|
176
|
+
});
|
|
166
177
|
function liveDisplayName(s, sidecarName) {
|
|
167
178
|
if (sidecarName && sidecarName.trim())
|
|
168
179
|
return sidecarName.trim();
|
|
169
|
-
if (s.jsonlPath &&
|
|
170
|
-
const enriched = readJsonlSession(deps.spawnCwd, s.
|
|
180
|
+
if (s.jsonlPath && existsSync(s.jsonlPath)) {
|
|
181
|
+
const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger);
|
|
171
182
|
if (enriched?.aiTitle?.trim())
|
|
172
183
|
return enriched.aiTitle.trim();
|
|
173
184
|
}
|
|
174
|
-
|
|
175
|
-
// chars of the intrinsic id (which the operator may copy into resume).
|
|
176
|
-
return (s.claudeSessionId ?? s.bridgeSuffix).slice(0, 8);
|
|
185
|
+
return s.sessionId.slice(0, 8);
|
|
177
186
|
}
|
|
178
187
|
function toLivePayload(s) {
|
|
179
188
|
const sidecar = s.jsonlPath ? readSidecar(s.jsonlPath) : null;
|
|
180
189
|
const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
|
|
181
190
|
return {
|
|
182
|
-
sessionId: s.
|
|
183
|
-
claudeSessionId: s.
|
|
191
|
+
sessionId: s.sessionId,
|
|
192
|
+
claudeSessionId: s.sessionId,
|
|
193
|
+
bridgeSessionId: s.bridgeSessionId,
|
|
184
194
|
pid: s.pid,
|
|
185
195
|
url: s.url,
|
|
186
196
|
startedAt: s.startedAt,
|
|
@@ -198,6 +208,45 @@ export function buildHttpApp(deps) {
|
|
|
198
208
|
specialist: s.specialist,
|
|
199
209
|
};
|
|
200
210
|
}
|
|
211
|
+
function toArchivedPayload(row, senderId) {
|
|
212
|
+
let displayName = row.sessionId.slice(0, 8);
|
|
213
|
+
let lastMessageAt = null;
|
|
214
|
+
let turnCount = 0;
|
|
215
|
+
let capped = false;
|
|
216
|
+
let sizeBytes = null;
|
|
217
|
+
let startedAt = new Date(row.updatedAt || 0).toISOString();
|
|
218
|
+
if (row.jsonlPath && existsSync(row.jsonlPath)) {
|
|
219
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
|
|
220
|
+
if (enriched) {
|
|
221
|
+
displayName = enriched.displayName;
|
|
222
|
+
lastMessageAt = enriched.lastMessageAt;
|
|
223
|
+
turnCount = enriched.turnCount;
|
|
224
|
+
capped = enriched.capped;
|
|
225
|
+
sizeBytes = enriched.sizeBytes;
|
|
226
|
+
startedAt = enriched.startedAt;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
sessionId: row.sessionId,
|
|
231
|
+
claudeSessionId: row.sessionId,
|
|
232
|
+
bridgeSessionId: row.bridgeSessionId,
|
|
233
|
+
pid: null,
|
|
234
|
+
url: null,
|
|
235
|
+
startedAt,
|
|
236
|
+
senderId,
|
|
237
|
+
role: null,
|
|
238
|
+
channel: null,
|
|
239
|
+
jsonlPath: row.jsonlPath,
|
|
240
|
+
status: 'ended',
|
|
241
|
+
displayName,
|
|
242
|
+
lastMessageAt,
|
|
243
|
+
turnCount,
|
|
244
|
+
capped,
|
|
245
|
+
sizeBytes,
|
|
246
|
+
hidden: false,
|
|
247
|
+
specialist: row.agent,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
201
250
|
app.post('/spawn', async (c) => {
|
|
202
251
|
const start = Date.now();
|
|
203
252
|
let body;
|
|
@@ -216,7 +265,6 @@ export function buildHttpApp(deps) {
|
|
|
216
265
|
const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
|
|
217
266
|
? body.specialist
|
|
218
267
|
: undefined;
|
|
219
|
-
const stdinPayload = typeof body.stdinPayload === 'string' ? body.stdinPayload : undefined;
|
|
220
268
|
const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
|
|
221
269
|
const dormantPluginsShape = Array.isArray(body.dormantPlugins)
|
|
222
270
|
? `array(${body.dormantPlugins.length})`
|
|
@@ -250,16 +298,12 @@ export function buildHttpApp(deps) {
|
|
|
250
298
|
const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
|
|
251
299
|
const activePlugins = parseActivePlugins(body.activePlugins);
|
|
252
300
|
const specialistDomains = parseSpecialistDomains(body.specialistDomains);
|
|
253
|
-
// tunnelUrl override resolved upstream by the UI process from the
|
|
254
|
-
// brand's tunnel.state on disk (the manager has no Neo4j credentials).
|
|
255
|
-
// string → render; null → render `tunnel-url: none`; undefined → fall
|
|
256
|
-
// back to deps.host.tunnelUrl (whatever boot resolved).
|
|
257
301
|
const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
|
|
258
302
|
? body.tunnelUrl
|
|
259
303
|
: body.tunnelUrl === null
|
|
260
304
|
? null
|
|
261
305
|
: undefined;
|
|
262
|
-
const result = await spawnClaudeSession({ ...deps, store: deps.store,
|
|
306
|
+
const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
|
|
263
307
|
senderId,
|
|
264
308
|
role: role,
|
|
265
309
|
channel: channel,
|
|
@@ -272,19 +316,147 @@ export function buildHttpApp(deps) {
|
|
|
272
316
|
specialistDomains,
|
|
273
317
|
hidden,
|
|
274
318
|
specialist,
|
|
275
|
-
stdinPayload,
|
|
276
319
|
});
|
|
277
320
|
if (!result.ok) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
321
|
+
if ('rejected' in result) {
|
|
322
|
+
timed(deps.logger, 'POST', '/spawn', 503, Date.now() - start);
|
|
323
|
+
if (result.rejected.kind === 'specialist-cap-reached') {
|
|
324
|
+
return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
|
|
325
|
+
}
|
|
326
|
+
return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
|
|
327
|
+
}
|
|
282
328
|
timed(deps.logger, 'POST', '/spawn', 500, Date.now() - start);
|
|
283
329
|
return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
|
|
284
330
|
}
|
|
285
331
|
timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
|
|
286
332
|
return c.json(toLivePayload(result.session), 200);
|
|
287
333
|
});
|
|
334
|
+
// SSE feed of row deltas. One channel per client; manager fans out from
|
|
335
|
+
// a single watcher subscription. On connect: replay current rows as
|
|
336
|
+
// `row-created`, then stream subsequent watcher events as
|
|
337
|
+
// `row-<created|updated|archived|removed>`.
|
|
338
|
+
//
|
|
339
|
+
// Payload shape per event (`data: <json>`):
|
|
340
|
+
// { sessionId, live, status, agentLabel, bridgeSessionId, cwd,
|
|
341
|
+
// firstPrompt, updatedAt }
|
|
342
|
+
//
|
|
343
|
+
// `live` mirrors the watcher's `state === 'live'`. `status` is the
|
|
344
|
+
// watcher's `idle | busy | gone`. `agentLabel` is the resolution
|
|
345
|
+
// `agent ?? cachedAgentSetting`; the UI maps null → "admin",
|
|
346
|
+
// non-null → the specialist name. `firstPrompt` is the resolved
|
|
347
|
+
// displayName (sidecar → ai-title → 8-char slice) so the row title is
|
|
348
|
+
// computed once on the server, not by every browser tab.
|
|
349
|
+
app.get('/events', (c) => {
|
|
350
|
+
const start = Date.now();
|
|
351
|
+
const clientIp = c.req.header('x-forwarded-for') ?? 'loopback';
|
|
352
|
+
const replayRows = deps.watcher.all();
|
|
353
|
+
deps.logger(`[admin-events] client-connected ip=${clientIp} seeded-rows=${replayRows.length}`);
|
|
354
|
+
function rowToPayload(row) {
|
|
355
|
+
const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
|
|
356
|
+
let firstPrompt = null;
|
|
357
|
+
if (row.jsonlPath && existsSync(row.jsonlPath)) {
|
|
358
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
|
|
359
|
+
if (enriched)
|
|
360
|
+
firstPrompt = enriched.displayName;
|
|
361
|
+
}
|
|
362
|
+
if (!firstPrompt) {
|
|
363
|
+
const sidecar = row.jsonlPath ? readSidecar(row.jsonlPath) : null;
|
|
364
|
+
firstPrompt = sidecar?.displayName ?? row.sessionId.slice(0, 8);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
sessionId: row.sessionId,
|
|
368
|
+
live: row.state === 'live',
|
|
369
|
+
status: row.status,
|
|
370
|
+
agentLabel,
|
|
371
|
+
bridgeSessionId: row.bridgeSessionId,
|
|
372
|
+
cwd: row.cwd,
|
|
373
|
+
firstPrompt,
|
|
374
|
+
updatedAt: row.updatedAt,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
|
|
378
|
+
return stream(c, async (out) => {
|
|
379
|
+
// SSE response headers — Hono's stream() helper sets text/event-stream
|
|
380
|
+
// automatically, but we explicitly disable proxy buffering so the
|
|
381
|
+
// first replay batch arrives before any keep-alive.
|
|
382
|
+
c.header('Content-Type', 'text/event-stream');
|
|
383
|
+
c.header('Cache-Control', 'no-cache, no-transform');
|
|
384
|
+
c.header('X-Accel-Buffering', 'no');
|
|
385
|
+
c.header('Connection', 'keep-alive');
|
|
386
|
+
const connectStart = Date.now();
|
|
387
|
+
let active = true;
|
|
388
|
+
let subscribersCount = 0;
|
|
389
|
+
// Replay phase. Emit a `row-created` for every row in the index so
|
|
390
|
+
// a freshly-connected tab catches up to the manager's view before
|
|
391
|
+
// any subsequent delta arrives.
|
|
392
|
+
for (const row of replayRows) {
|
|
393
|
+
if (!active)
|
|
394
|
+
break;
|
|
395
|
+
const payload = rowToPayload(row);
|
|
396
|
+
await out.write(`event: row-created\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
397
|
+
}
|
|
398
|
+
// Delta phase. Subscribe to the watcher and map watcher events to
|
|
399
|
+
// the four SSE event kinds. Mapping:
|
|
400
|
+
// create → row-created
|
|
401
|
+
// modify → row-updated (state may have flipped — see archive)
|
|
402
|
+
// delete → row-removed when the row is fully gone, row-archived
|
|
403
|
+
// when the PID file went away but the JSONL survives.
|
|
404
|
+
const unsubscribe = deps.watcher.subscribe((event) => {
|
|
405
|
+
if (!active)
|
|
406
|
+
return;
|
|
407
|
+
// The watcher emits a synthetic `kind=modify source=jsonl` pair
|
|
408
|
+
// immediately after `kind=delete source=pid` when a row demotes
|
|
409
|
+
// from live → archived. Treat that pair as one `row-archived`
|
|
410
|
+
// event so the UI sees a single transition rather than a delete
|
|
411
|
+
// followed by an unrelated update.
|
|
412
|
+
if (event.kind === 'delete' && event.source === 'pid' && event.row) {
|
|
413
|
+
const payload = rowToPayload(event.row);
|
|
414
|
+
subscribersCount += 1;
|
|
415
|
+
deps.logger(`[admin-events] emit event=row-archived sessionId=${event.sessionId} subscribers=1`);
|
|
416
|
+
void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (event.kind === 'delete' && event.source === 'jsonl' && !event.row) {
|
|
420
|
+
subscribersCount += 1;
|
|
421
|
+
deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
|
|
422
|
+
void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// Skip the synthetic `modify jsonl` half of the demote pair —
|
|
426
|
+
// it carries the same row state already emitted as row-archived.
|
|
427
|
+
if (event.kind === 'modify' && event.source === 'jsonl' && event.row && event.row.state === 'archived' && event.row.pidFilePath === null) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!event.row)
|
|
431
|
+
return;
|
|
432
|
+
const sseEvent = event.kind === 'create' ? 'row-created' : 'row-updated';
|
|
433
|
+
const payload = rowToPayload(event.row);
|
|
434
|
+
subscribersCount += 1;
|
|
435
|
+
deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
|
|
436
|
+
void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
437
|
+
});
|
|
438
|
+
// Keep-alive comment every 25 s. Proxies aggressively close idle
|
|
439
|
+
// text/event-stream connections at 30–60 s; a comment line is
|
|
440
|
+
// ignored by EventSource consumers but resets the proxy clock.
|
|
441
|
+
const keepAlive = setInterval(() => {
|
|
442
|
+
if (!active)
|
|
443
|
+
return;
|
|
444
|
+
void out.write(`: keep-alive ${Date.now()}\n\n`);
|
|
445
|
+
}, 25_000);
|
|
446
|
+
out.onAbort(() => {
|
|
447
|
+
active = false;
|
|
448
|
+
clearInterval(keepAlive);
|
|
449
|
+
unsubscribe();
|
|
450
|
+
const durationMs = Date.now() - connectStart;
|
|
451
|
+
deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
|
|
452
|
+
});
|
|
453
|
+
// Park the stream open until the client aborts. Without this the
|
|
454
|
+
// handler returns and Hono closes the response.
|
|
455
|
+
while (active) {
|
|
456
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
});
|
|
288
460
|
app.get('/list', (c) => {
|
|
289
461
|
const start = Date.now();
|
|
290
462
|
const senderId = c.req.query('senderId') ?? '';
|
|
@@ -295,36 +467,22 @@ export function buildHttpApp(deps) {
|
|
|
295
467
|
}
|
|
296
468
|
const liveStored = deps.store.listBySender(senderId, { includeHidden });
|
|
297
469
|
const live = liveStored.map(toLivePayload);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
470
|
+
const liveSessionIds = new Set();
|
|
471
|
+
for (const s of liveStored)
|
|
472
|
+
liveSessionIds.add(s.sessionId);
|
|
473
|
+
// Archived rows come from the watcher's JSONL index (rows whose
|
|
474
|
+
// state === 'archived'). The watcher's boot-seed already paired
|
|
475
|
+
// PID files with JSONLs on disk, so archived rows here are exactly
|
|
476
|
+
// the sessions whose PTY has exited but transcript persists.
|
|
305
477
|
const dehydrated = [];
|
|
306
|
-
for (const
|
|
307
|
-
if (
|
|
478
|
+
for (const row of deps.watcher.all()) {
|
|
479
|
+
if (row.state !== 'archived')
|
|
308
480
|
continue;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
startedAt: j.startedAt,
|
|
315
|
-
senderId,
|
|
316
|
-
role: null,
|
|
317
|
-
channel: null,
|
|
318
|
-
jsonlPath: j.jsonlPath,
|
|
319
|
-
status: 'ended',
|
|
320
|
-
displayName: j.displayName,
|
|
321
|
-
lastMessageAt: j.lastMessageAt,
|
|
322
|
-
turnCount: j.turnCount,
|
|
323
|
-
capped: j.capped,
|
|
324
|
-
sizeBytes: j.sizeBytes,
|
|
325
|
-
hidden: false,
|
|
326
|
-
specialist: null,
|
|
327
|
-
});
|
|
481
|
+
if (liveSessionIds.has(row.sessionId))
|
|
482
|
+
continue;
|
|
483
|
+
if (!row.jsonlPath)
|
|
484
|
+
continue;
|
|
485
|
+
dehydrated.push(toArchivedPayload(row, senderId));
|
|
328
486
|
}
|
|
329
487
|
deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
|
|
330
488
|
const merged = [...live, ...dehydrated];
|
|
@@ -362,7 +520,7 @@ export function buildHttpApp(deps) {
|
|
|
362
520
|
: body.tunnelUrl === null
|
|
363
521
|
? null
|
|
364
522
|
: undefined;
|
|
365
|
-
const result = await spawnClaudeSession({ ...deps, store: deps.store,
|
|
523
|
+
const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
|
|
366
524
|
senderId,
|
|
367
525
|
role: role,
|
|
368
526
|
channel: channel,
|
|
@@ -374,22 +532,34 @@ export function buildHttpApp(deps) {
|
|
|
374
532
|
specialistDomains,
|
|
375
533
|
});
|
|
376
534
|
if (!result.ok) {
|
|
535
|
+
if ('rejected' in result) {
|
|
536
|
+
timed(deps.logger, 'POST', '/resume', 503, Date.now() - start);
|
|
537
|
+
if (result.rejected.kind === 'specialist-cap-reached') {
|
|
538
|
+
return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
|
|
539
|
+
}
|
|
540
|
+
return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
|
|
541
|
+
}
|
|
377
542
|
deps.logger(`resume-failed reason=${result.reason} resumeFrom=${claudeSessionId}`);
|
|
378
543
|
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
379
544
|
return c.json({ error: 'resume-failed', reason: result.reason }, 500);
|
|
380
545
|
}
|
|
381
|
-
deps.logger(`resume pid=${result.session.pid}
|
|
546
|
+
deps.logger(`resume pid=${result.session.pid} sessionId=${result.session.sessionId} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
|
|
382
547
|
timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
|
|
383
548
|
return c.json(toLivePayload(result.session), 200);
|
|
384
549
|
});
|
|
385
550
|
app.get('/:sessionId/meta', (c) => {
|
|
386
551
|
const start = Date.now();
|
|
387
|
-
const
|
|
388
|
-
if (!SESSION_ID_PATTERN.test(
|
|
389
|
-
timed(deps.logger, 'GET', `/${
|
|
552
|
+
const id = c.req.param('sessionId');
|
|
553
|
+
if (!SESSION_ID_PATTERN.test(id)) {
|
|
554
|
+
timed(deps.logger, 'GET', `/${id}/meta`, 400, Date.now() - start);
|
|
390
555
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
391
556
|
}
|
|
392
|
-
const
|
|
557
|
+
const row = resolveRow(deps.watcher, id);
|
|
558
|
+
if (!row) {
|
|
559
|
+
timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
|
|
560
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
561
|
+
}
|
|
562
|
+
const live = deps.store.get(row.sessionId);
|
|
393
563
|
let payload;
|
|
394
564
|
if (live) {
|
|
395
565
|
const base = toLivePayload(live);
|
|
@@ -397,8 +567,8 @@ export function buildHttpApp(deps) {
|
|
|
397
567
|
let turnCount = 0;
|
|
398
568
|
let capped = false;
|
|
399
569
|
let sizeBytes = base.sizeBytes;
|
|
400
|
-
if (live.
|
|
401
|
-
const enriched = readJsonlSession(deps.spawnCwd, live.
|
|
570
|
+
if (live.jsonlPath && existsSync(live.jsonlPath)) {
|
|
571
|
+
const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
|
|
402
572
|
if (enriched) {
|
|
403
573
|
lastMessageAt = enriched.lastMessageAt;
|
|
404
574
|
turnCount = enriched.turnCount;
|
|
@@ -406,50 +576,20 @@ export function buildHttpApp(deps) {
|
|
|
406
576
|
sizeBytes = enriched.sizeBytes;
|
|
407
577
|
}
|
|
408
578
|
}
|
|
409
|
-
payload = {
|
|
410
|
-
...base,
|
|
411
|
-
lastMessageAt,
|
|
412
|
-
turnCount,
|
|
413
|
-
capped,
|
|
414
|
-
sizeBytes,
|
|
415
|
-
};
|
|
579
|
+
payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes };
|
|
416
580
|
}
|
|
417
581
|
else {
|
|
418
|
-
|
|
419
|
-
if (!enriched) {
|
|
420
|
-
timed(deps.logger, 'GET', `/${sessionId}/meta`, 404, Date.now() - start);
|
|
421
|
-
return c.json({ error: 'session-not-found' }, 404);
|
|
422
|
-
}
|
|
423
|
-
payload = {
|
|
424
|
-
sessionId: enriched.claudeSessionId,
|
|
425
|
-
claudeSessionId: enriched.claudeSessionId,
|
|
426
|
-
pid: null,
|
|
427
|
-
url: null,
|
|
428
|
-
startedAt: enriched.startedAt,
|
|
429
|
-
senderId: '',
|
|
430
|
-
role: null,
|
|
431
|
-
channel: null,
|
|
432
|
-
jsonlPath: enriched.jsonlPath,
|
|
433
|
-
status: 'ended',
|
|
434
|
-
displayName: enriched.displayName,
|
|
435
|
-
lastMessageAt: enriched.lastMessageAt,
|
|
436
|
-
turnCount: enriched.turnCount,
|
|
437
|
-
capped: enriched.capped,
|
|
438
|
-
sizeBytes: enriched.sizeBytes,
|
|
439
|
-
hidden: false,
|
|
440
|
-
specialist: null,
|
|
441
|
-
};
|
|
442
|
-
deps.logger(`meta-jsonl-tail-bytes sessionId=${sessionId.slice(0, 8)} read=${Math.min(enriched.sizeBytes, deps.jsonlReadMaxBytes)} capped=${enriched.capped}`);
|
|
582
|
+
payload = toArchivedPayload(row, '');
|
|
443
583
|
}
|
|
444
|
-
deps.logger(`meta sessionId=${sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
|
|
445
|
-
timed(deps.logger, 'GET', `/${
|
|
584
|
+
deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
|
|
585
|
+
timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
|
|
446
586
|
return c.json(payload, 200);
|
|
447
587
|
});
|
|
448
588
|
app.patch('/:sessionId', async (c) => {
|
|
449
589
|
const start = Date.now();
|
|
450
|
-
const
|
|
451
|
-
if (!SESSION_ID_PATTERN.test(
|
|
452
|
-
timed(deps.logger, 'PATCH', `/${
|
|
590
|
+
const id = c.req.param('sessionId');
|
|
591
|
+
if (!SESSION_ID_PATTERN.test(id)) {
|
|
592
|
+
timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
|
|
453
593
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
454
594
|
}
|
|
455
595
|
let body;
|
|
@@ -457,38 +597,38 @@ export function buildHttpApp(deps) {
|
|
|
457
597
|
body = await c.req.json();
|
|
458
598
|
}
|
|
459
599
|
catch {
|
|
460
|
-
timed(deps.logger, 'PATCH', `/${
|
|
600
|
+
timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
|
|
461
601
|
return c.json({ error: 'body-not-json' }, 400);
|
|
462
602
|
}
|
|
463
603
|
if (typeof body.displayName !== 'string' || body.displayName.length === 0 || body.displayName.length > DISPLAY_NAME_MAX) {
|
|
464
|
-
timed(deps.logger, 'PATCH', `/${
|
|
604
|
+
timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
|
|
465
605
|
return c.json({ error: 'invalid-displayName', detail: { maxLen: DISPLAY_NAME_MAX } }, 400);
|
|
466
606
|
}
|
|
467
|
-
const
|
|
468
|
-
if (!
|
|
469
|
-
timed(deps.logger, 'PATCH', `/${
|
|
607
|
+
const row = resolveRow(deps.watcher, id);
|
|
608
|
+
if (!row || !row.jsonlPath) {
|
|
609
|
+
timed(deps.logger, 'PATCH', `/${id}`, 404, Date.now() - start);
|
|
470
610
|
return c.json({ error: 'session-not-found' }, 404);
|
|
471
611
|
}
|
|
472
|
-
const sidecarPath = sidecarPathFor(
|
|
473
|
-
const prior = readSidecar(
|
|
612
|
+
const sidecarPath = sidecarPathFor(row.jsonlPath);
|
|
613
|
+
const prior = readSidecar(row.jsonlPath)?.displayName ?? null;
|
|
474
614
|
try {
|
|
475
615
|
writeFileSync(sidecarPath, JSON.stringify({ displayName: body.displayName }, null, 2) + '\n', 'utf8');
|
|
476
616
|
}
|
|
477
617
|
catch (err) {
|
|
478
618
|
const msg = err instanceof Error ? err.message : String(err);
|
|
479
|
-
deps.logger(`rename-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
480
|
-
timed(deps.logger, 'PATCH', `/${
|
|
619
|
+
deps.logger(`rename-failed sessionId=${row.sessionId.slice(0, 8)} message=${msg}`);
|
|
620
|
+
timed(deps.logger, 'PATCH', `/${id}`, 500, Date.now() - start);
|
|
481
621
|
return c.json({ error: 'sidecar-write-failed', detail: msg }, 500);
|
|
482
622
|
}
|
|
483
|
-
deps.logger(`rename sessionId=${sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
|
|
484
|
-
timed(deps.logger, 'PATCH', `/${
|
|
623
|
+
deps.logger(`rename sessionId=${row.sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
|
|
624
|
+
timed(deps.logger, 'PATCH', `/${id}`, 200, Date.now() - start);
|
|
485
625
|
return c.json({ ok: true, displayName: body.displayName }, 200);
|
|
486
626
|
});
|
|
487
627
|
app.post('/:sessionId/archive', async (c) => {
|
|
488
628
|
const start = Date.now();
|
|
489
|
-
const
|
|
490
|
-
if (!SESSION_ID_PATTERN.test(
|
|
491
|
-
timed(deps.logger, 'POST', `/${
|
|
629
|
+
const id = c.req.param('sessionId');
|
|
630
|
+
if (!SESSION_ID_PATTERN.test(id)) {
|
|
631
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
|
|
492
632
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
493
633
|
}
|
|
494
634
|
let body;
|
|
@@ -496,30 +636,35 @@ export function buildHttpApp(deps) {
|
|
|
496
636
|
body = await c.req.json();
|
|
497
637
|
}
|
|
498
638
|
catch {
|
|
499
|
-
timed(deps.logger, 'POST', `/${
|
|
639
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
|
|
500
640
|
return c.json({ error: 'body-not-json' }, 400);
|
|
501
641
|
}
|
|
502
642
|
const mode = body.mode;
|
|
503
643
|
if (mode !== 'archive' && mode !== 'unarchive') {
|
|
504
|
-
timed(deps.logger, 'POST', `/${
|
|
644
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
|
|
505
645
|
return c.json({ error: 'invalid-mode', detail: { expected: ['archive', 'unarchive'] } }, 400);
|
|
506
646
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
timed(deps.logger, 'POST', `/${
|
|
647
|
+
const row = resolveRow(deps.watcher, id);
|
|
648
|
+
if (!row) {
|
|
649
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
|
|
650
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
651
|
+
}
|
|
652
|
+
if (deps.store.get(row.sessionId)) {
|
|
653
|
+
deps.logger(`archive-rejected sessionId=${row.sessionId.slice(0, 8)} reason=pty-still-alive`);
|
|
654
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
|
|
510
655
|
return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
|
|
511
656
|
}
|
|
512
657
|
const slug = projectSlugForCwd(deps.spawnCwd);
|
|
513
658
|
const slugDir = join(claudeStateRoot(), 'projects', slug);
|
|
514
659
|
const archiveDir = join(slugDir, 'archive');
|
|
515
|
-
const fileName = `${sessionId}.jsonl`;
|
|
516
|
-
const sidecarName = `${sessionId}.meta.json`;
|
|
660
|
+
const fileName = `${row.sessionId}.jsonl`;
|
|
661
|
+
const sidecarName = `${row.sessionId}.meta.json`;
|
|
517
662
|
const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
|
|
518
663
|
const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
|
|
519
664
|
const fromSidecar = mode === 'archive' ? join(slugDir, sidecarName) : join(archiveDir, sidecarName);
|
|
520
665
|
const toSidecar = mode === 'archive' ? join(archiveDir, sidecarName) : join(slugDir, sidecarName);
|
|
521
666
|
if (!existsSync(fromJsonl)) {
|
|
522
|
-
timed(deps.logger, 'POST', `/${
|
|
667
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
|
|
523
668
|
return c.json({ error: 'session-not-found' }, 404);
|
|
524
669
|
}
|
|
525
670
|
try {
|
|
@@ -531,49 +676,63 @@ export function buildHttpApp(deps) {
|
|
|
531
676
|
}
|
|
532
677
|
catch (err) {
|
|
533
678
|
const msg = err instanceof Error ? err.message : String(err);
|
|
534
|
-
deps.logger(`archive-failed sessionId=${sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
|
|
535
|
-
timed(deps.logger, 'POST', `/${
|
|
679
|
+
deps.logger(`archive-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
|
|
680
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
|
|
536
681
|
return c.json({ error: 'archive-failed', detail: msg }, 500);
|
|
537
682
|
}
|
|
538
|
-
deps.logger(`archive sessionId=${sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
|
|
539
|
-
timed(deps.logger, 'POST', `/${
|
|
683
|
+
deps.logger(`archive sessionId=${row.sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
|
|
684
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
|
|
540
685
|
return c.json({ ok: true, mode }, 200);
|
|
541
686
|
});
|
|
542
|
-
|
|
687
|
+
// SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
|
|
688
|
+
app.post('/:sessionId/stop', async (c) => {
|
|
543
689
|
const start = Date.now();
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
timed(deps.logger, 'DELETE', `/${sessionId}`, 400, Date.now() - start);
|
|
690
|
+
const id = c.req.param('sessionId');
|
|
691
|
+
if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
|
|
692
|
+
timed(deps.logger, 'POST', `/${id}/stop`, 400, Date.now() - start);
|
|
548
693
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
549
694
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
timed(deps.logger, '
|
|
553
|
-
return c.
|
|
695
|
+
const row = resolveRow(deps.watcher, id);
|
|
696
|
+
if (!row && !deps.store.get(id)) {
|
|
697
|
+
timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
|
|
698
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
699
|
+
}
|
|
700
|
+
const sessionId = row?.sessionId ?? id;
|
|
701
|
+
await stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
|
|
702
|
+
timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
|
|
703
|
+
return c.body(null, 204);
|
|
704
|
+
});
|
|
705
|
+
// Hard-remove transcript. 409 if PTY is still alive (stop first).
|
|
706
|
+
app.delete('/:sessionId', async (c) => {
|
|
707
|
+
const start = Date.now();
|
|
708
|
+
const id = c.req.param('sessionId');
|
|
709
|
+
if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
|
|
710
|
+
timed(deps.logger, 'DELETE', `/${id}`, 400, Date.now() - start);
|
|
711
|
+
return c.json({ error: 'invalid-session-id' }, 400);
|
|
554
712
|
}
|
|
713
|
+
const row = resolveRow(deps.watcher, id);
|
|
714
|
+
const sessionId = row?.sessionId ?? id;
|
|
555
715
|
if (deps.store.get(sessionId)) {
|
|
556
|
-
deps.logger(`
|
|
557
|
-
timed(deps.logger, 'DELETE', `/${
|
|
558
|
-
return c.json({ error: 'pty-still-alive', detail: '
|
|
716
|
+
deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
|
|
717
|
+
timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
|
|
718
|
+
return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
|
|
559
719
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 404, Date.now() - start);
|
|
720
|
+
if (!row || !row.jsonlPath) {
|
|
721
|
+
timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
|
|
563
722
|
return c.json({ error: 'session-not-found' }, 404);
|
|
564
723
|
}
|
|
565
|
-
const sidecarPath = sidecarPathFor(
|
|
724
|
+
const sidecarPath = sidecarPathFor(row.jsonlPath);
|
|
566
725
|
const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
|
|
567
726
|
let bytes = 0;
|
|
568
727
|
try {
|
|
569
|
-
bytes = existsSync(
|
|
728
|
+
bytes = existsSync(row.jsonlPath) ? statSync(row.jsonlPath).size : 0;
|
|
570
729
|
}
|
|
571
730
|
catch {
|
|
572
|
-
//
|
|
731
|
+
// size is informational
|
|
573
732
|
}
|
|
574
733
|
try {
|
|
575
|
-
if (existsSync(
|
|
576
|
-
rmSync(
|
|
734
|
+
if (existsSync(row.jsonlPath))
|
|
735
|
+
rmSync(row.jsonlPath);
|
|
577
736
|
if (existsSync(sidecarPath))
|
|
578
737
|
rmSync(sidecarPath);
|
|
579
738
|
if (existsSync(subdir))
|
|
@@ -581,24 +740,26 @@ export function buildHttpApp(deps) {
|
|
|
581
740
|
}
|
|
582
741
|
catch (err) {
|
|
583
742
|
const msg = err instanceof Error ? err.message : String(err);
|
|
584
|
-
deps.logger(`
|
|
585
|
-
timed(deps.logger, 'DELETE', `/${
|
|
586
|
-
return c.json({ error: '
|
|
743
|
+
deps.logger(`delete-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
744
|
+
timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
|
|
745
|
+
return c.json({ error: 'delete-failed', detail: msg }, 500);
|
|
587
746
|
}
|
|
588
747
|
deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
|
|
589
|
-
timed(deps.logger, 'DELETE', `/${
|
|
748
|
+
timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
|
|
590
749
|
return c.body(null, 204);
|
|
591
750
|
});
|
|
592
751
|
app.post('/:sessionId/input', async (c) => {
|
|
593
752
|
const start = Date.now();
|
|
594
|
-
const
|
|
753
|
+
const id = c.req.param('sessionId');
|
|
754
|
+
const row = resolveRow(deps.watcher, id);
|
|
755
|
+
const sessionId = row?.sessionId ?? id;
|
|
595
756
|
const s = deps.store.get(sessionId);
|
|
596
757
|
if (!s) {
|
|
597
|
-
timed(deps.logger, 'POST', `/${
|
|
758
|
+
timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
|
|
598
759
|
return c.json({ error: 'session-not-found' }, 404);
|
|
599
760
|
}
|
|
600
761
|
if (s.exited) {
|
|
601
|
-
timed(deps.logger, 'POST', `/${
|
|
762
|
+
timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
|
|
602
763
|
return c.json({ error: 'session-exited' }, 410);
|
|
603
764
|
}
|
|
604
765
|
let body;
|
|
@@ -606,11 +767,11 @@ export function buildHttpApp(deps) {
|
|
|
606
767
|
body = await c.req.json();
|
|
607
768
|
}
|
|
608
769
|
catch {
|
|
609
|
-
timed(deps.logger, 'POST', `/${
|
|
770
|
+
timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
|
|
610
771
|
return c.json({ error: 'body-not-json' }, 400);
|
|
611
772
|
}
|
|
612
773
|
if (typeof body.text !== 'string') {
|
|
613
|
-
timed(deps.logger, 'POST', `/${
|
|
774
|
+
timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
|
|
614
775
|
return c.json({ error: 'text-required' }, 400);
|
|
615
776
|
}
|
|
616
777
|
const bytes = Buffer.byteLength(body.text, 'utf8');
|
|
@@ -620,26 +781,25 @@ export function buildHttpApp(deps) {
|
|
|
620
781
|
catch (err) {
|
|
621
782
|
const msg = err instanceof Error ? err.message : String(err);
|
|
622
783
|
deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
|
|
623
|
-
timed(deps.logger, 'POST', `/${
|
|
784
|
+
timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
|
|
624
785
|
return c.json({ error: 'pty-write-failed', detail: msg }, 500);
|
|
625
786
|
}
|
|
626
787
|
deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel}`);
|
|
627
|
-
timed(deps.logger, 'POST', `/${
|
|
788
|
+
timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
|
|
628
789
|
return c.json({ ok: true, bytes }, 200);
|
|
629
790
|
});
|
|
630
791
|
app.get('/:sessionId/log', (c) => {
|
|
631
792
|
const start = Date.now();
|
|
632
|
-
const
|
|
793
|
+
const id = c.req.param('sessionId');
|
|
794
|
+
const row = resolveRow(deps.watcher, id);
|
|
795
|
+
const sessionId = row?.sessionId ?? id;
|
|
633
796
|
const s = deps.store.get(sessionId);
|
|
634
797
|
if (!s) {
|
|
635
|
-
timed(deps.logger, 'GET', `/${
|
|
798
|
+
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
636
799
|
return c.json({ error: 'session-not-found' }, 404);
|
|
637
800
|
}
|
|
638
801
|
if (s.jsonlPath === null) {
|
|
639
|
-
|
|
640
|
-
// so claude has not written a JSONL yet. Loud-fail with 202 so the
|
|
641
|
-
// UI can re-poll once `claudeSessionId` flips on a subsequent /list.
|
|
642
|
-
timed(deps.logger, 'GET', `/${sessionId}/log`, 202, Date.now() - start);
|
|
802
|
+
timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
|
|
643
803
|
return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
|
|
644
804
|
}
|
|
645
805
|
const jsonlPath = s.jsonlPath;
|
|
@@ -647,10 +807,10 @@ export function buildHttpApp(deps) {
|
|
|
647
807
|
if (!follow) {
|
|
648
808
|
const exists = existsSync(jsonlPath);
|
|
649
809
|
const sizeBytes = exists ? statSync(jsonlPath).size : 0;
|
|
650
|
-
timed(deps.logger, 'GET', `/${
|
|
810
|
+
timed(deps.logger, 'GET', `/${id}/log`, 200, Date.now() - start);
|
|
651
811
|
return c.json({ jsonlPath, exists, sizeBytes }, 200);
|
|
652
812
|
}
|
|
653
|
-
timed(deps.logger, 'GET', `/${
|
|
813
|
+
timed(deps.logger, 'GET', `/${id}/log?follow=1`, 200, Date.now() - start);
|
|
654
814
|
return stream(c, async (out) => {
|
|
655
815
|
let position = 0;
|
|
656
816
|
if (existsSync(jsonlPath)) {
|