@rubytech/create-maxy-code 0.1.91 → 0.1.93
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/dist/__tests__/claude-ptys-slice.test.js +25 -0
- package/dist/index.js +10 -1
- package/dist/port-resolution.js +29 -0
- package/package.json +1 -1
- package/payload/platform/package-lock.json +18 -4
- package/payload/platform/plugins/docs/references/platform.md +4 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +468 -223
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +10 -3
- 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 +486 -193
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -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 +25 -0
- 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 +165 -33
- 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 +290 -91
- 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 +6 -2
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +29 -25
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
- 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 +8 -10
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
- package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
- package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
- package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
- package/payload/server/public/assets/admin-ux6TKHS1.js +217 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
- package/payload/server/public/assets/channel-CejaITMF.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
- package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
- package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
- package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
- package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
- package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
- package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
- package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
- package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
- package/payload/server/public/assets/{page-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
- package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
- package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.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 +17 -0
- package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
- package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
- package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
- package/payload/server/public/assets/data-3n20lir5.js +0 -1
- package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
- package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
- /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
import { Hono } from 'hono';
|
|
25
25
|
import { stream } from 'hono/streaming';
|
|
26
26
|
import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
|
|
27
|
-
import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
|
|
27
|
+
import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, PERMISSION_MODES, } from './pty-spawner.js';
|
|
28
|
+
import { readSidecar } from './session-sidecar.js';
|
|
28
29
|
import { readJsonlSession } from './jsonl-enumerator.js';
|
|
29
|
-
import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
|
|
30
|
+
import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
|
|
30
31
|
import { basename, join } from 'node:path';
|
|
32
|
+
import { validateUserTitle } from './user-title-store.js';
|
|
31
33
|
const ROLES = ['admin', 'public'];
|
|
32
34
|
const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
|
|
33
35
|
// Task 179 — resume idempotency. Two POSTs 9ms apart with the same
|
|
@@ -165,11 +167,12 @@ function resolveRow(watcher, id) {
|
|
|
165
167
|
// auto-archive` lines cover the lifecycle.
|
|
166
168
|
const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
|
|
167
169
|
const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
|
|
168
|
-
function attachSpecialistEndTurnAutoArchive(deps,
|
|
169
|
-
if (
|
|
170
|
+
function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
|
|
171
|
+
if (specialist !== AUTO_ARCHIVE_SPECIALIST)
|
|
170
172
|
return;
|
|
171
173
|
let archived = false;
|
|
172
|
-
|
|
174
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
175
|
+
function endTurnSeen() {
|
|
173
176
|
try {
|
|
174
177
|
const content = readFileSync(jsonlPath, 'utf8');
|
|
175
178
|
return content.includes(END_TURN_SIGNATURE);
|
|
@@ -178,46 +181,46 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
|
|
|
178
181
|
return false;
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
|
-
function tryArchive(
|
|
184
|
+
function tryArchive() {
|
|
182
185
|
if (archived)
|
|
183
186
|
return;
|
|
184
187
|
if (!existsSync(jsonlPath))
|
|
185
188
|
return;
|
|
186
|
-
if (!endTurnSeen(
|
|
189
|
+
if (!endTurnSeen())
|
|
187
190
|
return;
|
|
188
191
|
archived = true;
|
|
189
|
-
deps.logger(`auto-archive sessionId=${
|
|
190
|
-
void stopSession({
|
|
192
|
+
deps.logger(`auto-archive sessionId=${sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
|
|
193
|
+
void stopSession({
|
|
194
|
+
killGraceMs: deps.killGraceMs,
|
|
195
|
+
logger: deps.logger,
|
|
196
|
+
runSystemctl: deps.runSystemctl,
|
|
197
|
+
}, sessionId);
|
|
191
198
|
}
|
|
192
|
-
|
|
193
|
-
tryArchive(session.jsonlPath);
|
|
199
|
+
tryArchive();
|
|
194
200
|
const unsubscribe = deps.watcher.subscribe((event) => {
|
|
195
201
|
if (archived)
|
|
196
202
|
return;
|
|
197
|
-
if (event.sessionId !==
|
|
203
|
+
if (event.sessionId !== sessionId)
|
|
198
204
|
return;
|
|
199
205
|
if (event.source !== 'jsonl')
|
|
200
206
|
return;
|
|
201
|
-
|
|
202
|
-
if (!path)
|
|
203
|
-
return;
|
|
204
|
-
tryArchive(path);
|
|
207
|
+
tryArchive();
|
|
205
208
|
});
|
|
206
|
-
|
|
209
|
+
onPtyExit(sessionId, () => unsubscribe());
|
|
207
210
|
}
|
|
208
|
-
function attachPublicAudit(deps,
|
|
209
|
-
if (
|
|
211
|
+
function attachPublicAudit(deps, sessionId, role) {
|
|
212
|
+
if (role !== 'public')
|
|
210
213
|
return;
|
|
211
|
-
|
|
214
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
215
|
+
if (!existsSync(jsonlPath))
|
|
212
216
|
return;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
deps.publicToolAudit.detach(claudeSessionId);
|
|
217
|
+
deps.publicToolAudit.attach(sessionId, jsonlPath);
|
|
218
|
+
if (!isLive(sessionId)) {
|
|
219
|
+
deps.publicToolAudit.detach(sessionId);
|
|
217
220
|
return;
|
|
218
221
|
}
|
|
219
|
-
|
|
220
|
-
deps.publicToolAudit.detach(
|
|
222
|
+
onPtyExit(sessionId, () => {
|
|
223
|
+
deps.publicToolAudit.detach(sessionId);
|
|
221
224
|
});
|
|
222
225
|
}
|
|
223
226
|
export function buildHttpApp(deps) {
|
|
@@ -235,30 +238,39 @@ export function buildHttpApp(deps) {
|
|
|
235
238
|
listener(sessionId);
|
|
236
239
|
}
|
|
237
240
|
app.__notifyUrlCapturedForTests = notifyUrlCaptured;
|
|
238
|
-
//
|
|
239
|
-
//
|
|
241
|
+
// Task 253 — same fan-out shape for rename. The POST /:sessionId/rename
|
|
242
|
+
// route writes the user-title store and then calls notifyRename; each
|
|
243
|
+
// /events handler then re-emits a `row-updated` frame so every browser
|
|
244
|
+
// tab sees the new label without re-fetching /list.
|
|
245
|
+
const renameListeners = new Set();
|
|
246
|
+
function notifyRename(sessionId) {
|
|
247
|
+
for (const listener of renameListeners)
|
|
248
|
+
listener(sessionId);
|
|
249
|
+
}
|
|
250
|
+
app.__notifyRenameForTests = notifyRename;
|
|
251
|
+
// Watcher subscriber: when the JSONL CREATE event fires for a live
|
|
252
|
+
// session, attach the public-audit watcher. The watcher row carries
|
|
253
|
+
// the role from the sidecar (Task 260 — disk is the source of truth),
|
|
254
|
+
// so the audit attachment fires deterministically against rows whose
|
|
255
|
+
// PTY is still alive in this manager process.
|
|
240
256
|
deps.watcher.subscribe((event) => {
|
|
241
257
|
if (event.source !== 'jsonl')
|
|
242
258
|
return;
|
|
243
259
|
if (event.kind !== 'create' && event.kind !== 'modify')
|
|
244
260
|
return;
|
|
245
|
-
if (!event.row
|
|
261
|
+
if (!event.row)
|
|
246
262
|
return;
|
|
247
|
-
|
|
248
|
-
if (!live)
|
|
263
|
+
if (!isLive(event.sessionId))
|
|
249
264
|
return;
|
|
250
|
-
|
|
251
|
-
return;
|
|
252
|
-
live.jsonlPath = event.row.jsonlPath;
|
|
253
|
-
deps.logger(`[fs-watcher] live-row-jsonl-attached sessionId=${event.sessionId} path=${event.row.jsonlPath}`);
|
|
254
|
-
attachPublicAudit(deps, live);
|
|
265
|
+
attachPublicAudit(deps, event.sessionId, event.row.role);
|
|
255
266
|
});
|
|
256
267
|
// Task 183 — dispatch jsonl modify/create events into the recorder
|
|
257
268
|
// tail. The tail filters by registered sessionId (only specialist
|
|
258
269
|
// database-operator spawns are registered), so operator turns pass
|
|
259
270
|
// through without producing recorder-* lines. The tail's append-only
|
|
260
271
|
// cursor ensures repeated modify events for the same JSONL only read
|
|
261
|
-
// newly-appended bytes.
|
|
272
|
+
// newly-appended bytes. Path is derived from sessionId + spawnCwd
|
|
273
|
+
// (Task 258).
|
|
262
274
|
if (deps.jsonlTail) {
|
|
263
275
|
const tail = deps.jsonlTail;
|
|
264
276
|
deps.watcher.subscribe((event) => {
|
|
@@ -266,79 +278,129 @@ export function buildHttpApp(deps) {
|
|
|
266
278
|
return;
|
|
267
279
|
if (event.kind !== 'create' && event.kind !== 'modify')
|
|
268
280
|
return;
|
|
269
|
-
const path = event.
|
|
270
|
-
if (!path)
|
|
281
|
+
const path = jsonlPathForSessionId(deps.spawnCwd, event.sessionId);
|
|
282
|
+
if (!existsSync(path))
|
|
271
283
|
return;
|
|
272
284
|
tail.onJsonlModify(event.sessionId, path);
|
|
273
285
|
});
|
|
274
286
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
287
|
+
/** Task 260 — /spawn and /resume need to return a wire payload
|
|
288
|
+
* immediately, but at that moment the watcher row may not yet
|
|
289
|
+
* include the sidecar fields (the JSONL CREATE event hasn't fired
|
|
290
|
+
* in fresh-spawn tests with synthetic watchers). Build the payload
|
|
291
|
+
* from the row if present, falling back to a synthetic shape that
|
|
292
|
+
* reads the sidecar directly. */
|
|
293
|
+
function buildSpawnReturnPayload(sessionId, pid, startedAt, capturedUrl) {
|
|
294
|
+
const row = deps.watcher.getBySessionId(sessionId);
|
|
295
|
+
if (row && row.senderId !== null) {
|
|
296
|
+
return toPayload(row);
|
|
280
297
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
298
|
+
// Read the sidecar directly — the spawn just wrote it.
|
|
299
|
+
const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
300
|
+
const { sidecar } = readSidecar(sidecarPath, deps.logger);
|
|
301
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
302
|
+
const hasJsonl = existsSync(jsonlPath);
|
|
285
303
|
return {
|
|
286
|
-
sessionId
|
|
287
|
-
pid
|
|
288
|
-
url:
|
|
289
|
-
startedAt:
|
|
290
|
-
senderId:
|
|
291
|
-
role:
|
|
292
|
-
channel:
|
|
293
|
-
jsonlPath:
|
|
304
|
+
sessionId,
|
|
305
|
+
pid,
|
|
306
|
+
url: capturedUrl ?? sidecar?.url ?? null,
|
|
307
|
+
startedAt: sidecar?.startedAt ?? startedAt,
|
|
308
|
+
senderId: sidecar?.senderId ?? '',
|
|
309
|
+
role: sidecar?.role ?? null,
|
|
310
|
+
channel: sidecar?.channel ?? null,
|
|
311
|
+
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
294
312
|
status: 'alive',
|
|
295
|
-
|
|
313
|
+
archived: false,
|
|
314
|
+
displayName: null,
|
|
315
|
+
titleSource: null,
|
|
296
316
|
lastMessageAt: null,
|
|
297
317
|
turnCount: 0,
|
|
298
318
|
capped: false,
|
|
299
|
-
sizeBytes: hasJsonl
|
|
300
|
-
hidden:
|
|
301
|
-
specialist:
|
|
319
|
+
sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
|
|
320
|
+
hidden: sidecar?.hidden ?? false,
|
|
321
|
+
specialist: sidecar?.specialist ?? null,
|
|
302
322
|
model: null,
|
|
303
323
|
};
|
|
304
324
|
}
|
|
305
|
-
|
|
325
|
+
/** Task 260 — single payload builder. Both live and archived rows
|
|
326
|
+
* derive every wire field from the watcher row (which itself derived
|
|
327
|
+
* from the sidecar + JSONL + PID file). The split between
|
|
328
|
+
* `toLivePayload`/`toArchivedPayload` is gone; the row's `state`
|
|
329
|
+
* and `pid` decide `status` and the `archived` flag.
|
|
330
|
+
*
|
|
331
|
+
* The `model` field continues to derive from the JSONL tail (last
|
|
332
|
+
* assistant turn) rather than the sidecar's spawn-request model —
|
|
333
|
+
* see [[learnings_writer_reader_charset_drift]] family: the running
|
|
334
|
+
* model is what the wire's `model` has historically meant, and the
|
|
335
|
+
* sidecar's `model` is kept as a self-diagnostic record. */
|
|
336
|
+
function toPayload(row) {
|
|
337
|
+
const isLiveState = row.state === 'live';
|
|
338
|
+
const archivedJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
339
|
+
const topJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
340
|
+
const jsonlPath = isLiveState
|
|
341
|
+
? topJsonlPath
|
|
342
|
+
: existsSync(archivedJsonlPath) ? archivedJsonlPath : topJsonlPath;
|
|
343
|
+
const hasJsonl = existsSync(jsonlPath);
|
|
306
344
|
let displayName = null;
|
|
345
|
+
let titleSource = null;
|
|
307
346
|
let lastMessageAt = null;
|
|
308
347
|
let turnCount = 0;
|
|
309
348
|
let capped = false;
|
|
310
|
-
let sizeBytes = null;
|
|
349
|
+
let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
|
|
311
350
|
let model = null;
|
|
312
|
-
let
|
|
313
|
-
if (
|
|
314
|
-
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
|
|
351
|
+
let enrichedStartedAt = null;
|
|
352
|
+
if (hasJsonl) {
|
|
353
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
315
354
|
if (enriched) {
|
|
316
355
|
displayName = enriched.displayName;
|
|
356
|
+
titleSource = enriched.titleSource;
|
|
317
357
|
lastMessageAt = enriched.lastMessageAt;
|
|
318
358
|
turnCount = enriched.turnCount;
|
|
319
359
|
capped = enriched.capped;
|
|
320
360
|
sizeBytes = enriched.sizeBytes;
|
|
321
|
-
|
|
361
|
+
enrichedStartedAt = enriched.startedAt;
|
|
322
362
|
model = enriched.model;
|
|
323
363
|
}
|
|
324
364
|
}
|
|
365
|
+
else {
|
|
366
|
+
const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
|
|
367
|
+
if (userTitle) {
|
|
368
|
+
displayName = userTitle;
|
|
369
|
+
titleSource = 'user';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// startedAt resolution order:
|
|
373
|
+
// 1. JSONL enrichment (the most authoritative — claude's own record)
|
|
374
|
+
// 2. Sidecar (/spawn time)
|
|
375
|
+
// 3. Row mtime fallback (legacy archived rows without sidecar)
|
|
376
|
+
const startedAt = enrichedStartedAt
|
|
377
|
+
?? row.startedAt
|
|
378
|
+
?? new Date(row.updatedAt || 0).toISOString();
|
|
379
|
+
// Agent label: PID-file `agent` for live rows; cachedAgentSetting for
|
|
380
|
+
// archived rows (JSONL head). Sidecar's `sidecarSpecialist` is the
|
|
381
|
+
// spawn-time request — fall back to it when both above are null so
|
|
382
|
+
// post-Task-260 rows surface their specialist even if the JSONL
|
|
383
|
+
// doesn't include an agent-setting record.
|
|
384
|
+
const specialist = row.agent ?? row.cachedAgentSetting ?? row.sidecarSpecialist;
|
|
325
385
|
return {
|
|
326
386
|
sessionId: row.sessionId,
|
|
327
|
-
pid:
|
|
328
|
-
url:
|
|
387
|
+
pid: row.pid,
|
|
388
|
+
url: row.url,
|
|
329
389
|
startedAt,
|
|
330
|
-
senderId,
|
|
331
|
-
role:
|
|
332
|
-
channel:
|
|
333
|
-
jsonlPath:
|
|
334
|
-
status: 'ended',
|
|
390
|
+
senderId: row.senderId ?? '',
|
|
391
|
+
role: row.role,
|
|
392
|
+
channel: row.channel,
|
|
393
|
+
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
394
|
+
status: isLiveState ? 'alive' : 'ended',
|
|
395
|
+
archived: row.state === 'archived',
|
|
335
396
|
displayName,
|
|
397
|
+
titleSource,
|
|
336
398
|
lastMessageAt,
|
|
337
399
|
turnCount,
|
|
338
400
|
capped,
|
|
339
401
|
sizeBytes,
|
|
340
|
-
hidden:
|
|
341
|
-
specialist
|
|
402
|
+
hidden: row.hidden,
|
|
403
|
+
specialist,
|
|
342
404
|
model,
|
|
343
405
|
};
|
|
344
406
|
}
|
|
@@ -412,7 +474,16 @@ export function buildHttpApp(deps) {
|
|
|
412
474
|
: body.tunnelUrl === null
|
|
413
475
|
? null
|
|
414
476
|
: undefined;
|
|
415
|
-
const result = await spawnClaudeSession({
|
|
477
|
+
const result = await spawnClaudeSession({
|
|
478
|
+
...deps,
|
|
479
|
+
watcher: deps.watcher,
|
|
480
|
+
onSessionReady: (tracker) => {
|
|
481
|
+
attachPublicAudit(deps, tracker.sessionId, role);
|
|
482
|
+
attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
|
|
483
|
+
},
|
|
484
|
+
onUrlCaptured: notifyUrlCaptured,
|
|
485
|
+
tunnelUrlOverride,
|
|
486
|
+
}, {
|
|
416
487
|
senderId,
|
|
417
488
|
userId,
|
|
418
489
|
role: role,
|
|
@@ -441,7 +512,8 @@ export function buildHttpApp(deps) {
|
|
|
441
512
|
return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
|
|
442
513
|
}
|
|
443
514
|
timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
|
|
444
|
-
|
|
515
|
+
const payload = buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url);
|
|
516
|
+
return c.json(payload, 200);
|
|
445
517
|
});
|
|
446
518
|
// SSE feed of row deltas. One channel per client; manager fans out from
|
|
447
519
|
// a single watcher subscription. On connect: replay current rows as
|
|
@@ -449,7 +521,8 @@ export function buildHttpApp(deps) {
|
|
|
449
521
|
// `row-<created|updated|archived|removed>`.
|
|
450
522
|
//
|
|
451
523
|
// Payload shape per event (`data: <json>`):
|
|
452
|
-
// { sessionId, live, status, agentLabel, cwd, firstPrompt,
|
|
524
|
+
// { sessionId, live, status, agentLabel, cwd, firstPrompt, titleSource,
|
|
525
|
+
// updatedAt, url, archived }
|
|
453
526
|
// Task 141: `sessionId` carries whichever phase is current (bridge
|
|
454
527
|
// suffix preferred, JSONL basename fallback) — same single-id contract
|
|
455
528
|
// as `/meta`.
|
|
@@ -474,10 +547,22 @@ export function buildHttpApp(deps) {
|
|
|
474
547
|
function rowToPayload(row) {
|
|
475
548
|
const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
|
|
476
549
|
let firstPrompt = null;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
550
|
+
let titleSource = null;
|
|
551
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
552
|
+
if (existsSync(jsonlPath)) {
|
|
553
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
554
|
+
if (enriched) {
|
|
480
555
|
firstPrompt = enriched.displayName;
|
|
556
|
+
titleSource = enriched.titleSource;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
// Pre-jsonl / post-purge: only the user-title store can answer.
|
|
561
|
+
const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
|
|
562
|
+
if (userTitle) {
|
|
563
|
+
firstPrompt = userTitle;
|
|
564
|
+
titleSource = 'user';
|
|
565
|
+
}
|
|
481
566
|
}
|
|
482
567
|
return {
|
|
483
568
|
sessionId: row.sessionId,
|
|
@@ -486,8 +571,19 @@ export function buildHttpApp(deps) {
|
|
|
486
571
|
agentLabel,
|
|
487
572
|
cwd: row.cwd,
|
|
488
573
|
firstPrompt,
|
|
574
|
+
titleSource,
|
|
489
575
|
updatedAt: row.updatedAt,
|
|
490
|
-
url
|
|
576
|
+
// Task 260 — url is on the row from the sidecar; url-capture
|
|
577
|
+
// mutates the sidecar in place and the watcher re-reads on the
|
|
578
|
+
// next event. Earlier this dipped into deps.store for the live-
|
|
579
|
+
// row case and returned null for everything else.
|
|
580
|
+
url: row.url,
|
|
581
|
+
// Task 260 — archive state is on the row directly (the watcher
|
|
582
|
+
// enumerates both top and archive subdirs and folds a top→archive
|
|
583
|
+
// move into a single state change). Earlier this was an extra
|
|
584
|
+
// existsSync against the archive path because archived rows
|
|
585
|
+
// weren't first-class members of the index.
|
|
586
|
+
archived: row.state === 'archived',
|
|
491
587
|
};
|
|
492
588
|
}
|
|
493
589
|
timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
|
|
@@ -563,12 +659,13 @@ export function buildHttpApp(deps) {
|
|
|
563
659
|
deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
|
|
564
660
|
void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
565
661
|
});
|
|
566
|
-
// Task 189 — when `url-capture` fires for a session, the
|
|
567
|
-
// calls `notifyUrlCaptured(sessionId)` after
|
|
568
|
-
//
|
|
569
|
-
// Open-in-new-tab arrow appears in step. The row lookup goes
|
|
570
|
-
// the watcher (canonical row index); rowToPayload then
|
|
571
|
-
//
|
|
662
|
+
// Task 189 / 260 — when `url-capture` fires for a session, the
|
|
663
|
+
// pty-spawner calls `notifyUrlCaptured(sessionId)` after writing
|
|
664
|
+
// the URL to the sidecar. Re-emit the row so the client's hidden
|
|
665
|
+
// Open-in-new-tab arrow appears in step. The row lookup goes
|
|
666
|
+
// through the watcher (canonical row index); rowToPayload then
|
|
667
|
+
// reads `url` from the row (which the watcher just refreshed
|
|
668
|
+
// from the sidecar).
|
|
572
669
|
const urlListener = (sessionId) => {
|
|
573
670
|
if (!active)
|
|
574
671
|
return;
|
|
@@ -581,6 +678,24 @@ export function buildHttpApp(deps) {
|
|
|
581
678
|
void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
582
679
|
};
|
|
583
680
|
urlUpdateListeners.add(urlListener);
|
|
681
|
+
// Task 253 — rename re-emit. The POST /:sessionId/rename handler
|
|
682
|
+
// mutates the user-title store and then calls notifyRename(); each
|
|
683
|
+
// /events handler re-emits the row so every browser tab sees the new
|
|
684
|
+
// label without re-fetching /list. The row lookup goes through the
|
|
685
|
+
// watcher (canonical row index); rowToPayload then re-reads the user
|
|
686
|
+
// title from `deps.userTitleStore`, picking up the freshly-set value.
|
|
687
|
+
const renameListener = (sessionId) => {
|
|
688
|
+
if (!active)
|
|
689
|
+
return;
|
|
690
|
+
const row = deps.watcher.getBySessionId(sessionId);
|
|
691
|
+
if (!row)
|
|
692
|
+
return;
|
|
693
|
+
const payload = rowToPayload(row);
|
|
694
|
+
subscribersCount += 1;
|
|
695
|
+
deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} reason=rename subscribers=1`);
|
|
696
|
+
void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
697
|
+
};
|
|
698
|
+
renameListeners.add(renameListener);
|
|
584
699
|
// Keep-alive comment every 25 s. Proxies aggressively close idle
|
|
585
700
|
// text/event-stream connections at 30–60 s; a comment line is
|
|
586
701
|
// ignored by EventSource consumers but resets the proxy clock.
|
|
@@ -594,6 +709,7 @@ export function buildHttpApp(deps) {
|
|
|
594
709
|
clearInterval(keepAlive);
|
|
595
710
|
unsubscribe();
|
|
596
711
|
urlUpdateListeners.delete(urlListener);
|
|
712
|
+
renameListeners.delete(renameListener);
|
|
597
713
|
const durationMs = Date.now() - connectStart;
|
|
598
714
|
deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
|
|
599
715
|
});
|
|
@@ -612,30 +728,41 @@ export function buildHttpApp(deps) {
|
|
|
612
728
|
timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
|
|
613
729
|
return c.json({ error: 'senderId-required' }, 400);
|
|
614
730
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
731
|
+
// Task 260 — one source. Filter the watcher's row index by sidecar
|
|
732
|
+
// senderId, then partition into live vs archived for the log line
|
|
733
|
+
// (the merged list returns to the caller in startedAt-desc order).
|
|
734
|
+
// Rows without a sidecar carry `senderId: null` and never match
|
|
735
|
+
// unless includeHidden=1 is passed (the legacy pre-Task-260 archives
|
|
736
|
+
// case). Hidden rows are dropped unless includeHidden=1.
|
|
737
|
+
const payloads = [];
|
|
738
|
+
let liveCount = 0;
|
|
739
|
+
let archivedCount = 0;
|
|
740
|
+
let hiddenSkipped = 0;
|
|
625
741
|
for (const row of deps.watcher.all()) {
|
|
626
|
-
if (row.
|
|
742
|
+
if (row.senderId !== senderId && !(includeHidden && row.senderId === null))
|
|
627
743
|
continue;
|
|
628
|
-
if (
|
|
744
|
+
if (row.hidden && !includeHidden) {
|
|
745
|
+
hiddenSkipped += 1;
|
|
629
746
|
continue;
|
|
630
|
-
|
|
747
|
+
}
|
|
748
|
+
// Confirm the JSONL is actually on disk; row state can lag behind
|
|
749
|
+
// a delete during boot-seed timing edges. Live rows whose PID
|
|
750
|
+
// file exists pre-first-turn legitimately have no JSONL — keep
|
|
751
|
+
// them in the result.
|
|
752
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
753
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
754
|
+
if (row.state === 'archived' && !existsSync(archivedJsonl) && !existsSync(topJsonl))
|
|
631
755
|
continue;
|
|
632
|
-
|
|
756
|
+
payloads.push(toPayload(row));
|
|
757
|
+
if (row.state === 'live')
|
|
758
|
+
liveCount += 1;
|
|
759
|
+
else
|
|
760
|
+
archivedCount += 1;
|
|
633
761
|
}
|
|
634
|
-
deps.logger(`list-source live=${
|
|
635
|
-
|
|
636
|
-
merged.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
762
|
+
deps.logger(`[list-source] live=${liveCount} archived=${archivedCount} hidden-skipped=${hiddenSkipped}`);
|
|
763
|
+
payloads.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
637
764
|
timed(deps.logger, 'GET', '/list', 200, Date.now() - start);
|
|
638
|
-
return c.json(
|
|
765
|
+
return c.json(payloads, 200);
|
|
639
766
|
});
|
|
640
767
|
app.post('/resume', async (c) => {
|
|
641
768
|
const start = Date.now();
|
|
@@ -701,7 +828,77 @@ export function buildHttpApp(deps) {
|
|
|
701
828
|
: undefined;
|
|
702
829
|
const sourceRow = resolveRow(deps.watcher, sessionId);
|
|
703
830
|
const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
|
|
704
|
-
|
|
831
|
+
// Task 258 — restore the archived jsonl to its canonical top-level path
|
|
832
|
+
// before claude --resume runs. Claude appends to `<projectsDir>/<sid>.jsonl`;
|
|
833
|
+
// if the file lives in `archive/`, claude doesn't find it and starts a fresh
|
|
834
|
+
// empty transcript at the top-level path, silently splitting the conversation.
|
|
835
|
+
const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
836
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
837
|
+
const canonicalExists = existsSync(canonicalJsonl);
|
|
838
|
+
const archivedExists = existsSync(archivedJsonl);
|
|
839
|
+
if (archivedExists && !canonicalExists) {
|
|
840
|
+
try {
|
|
841
|
+
renameSync(archivedJsonl, canonicalJsonl);
|
|
842
|
+
// Task 260 — sidecar travels with the JSONL. Move the archived
|
|
843
|
+
// sidecar to the top-level path so the watcher picks it up on
|
|
844
|
+
// the next reconcile.
|
|
845
|
+
const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
846
|
+
const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
847
|
+
if (existsSync(archivedSidecar)) {
|
|
848
|
+
try {
|
|
849
|
+
renameSync(archivedSidecar, canonicalSidecar);
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
deps.logger(`resume-sidecar-restore-failed sessionId=${sessionId.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
859
|
+
deps.logger(`resume-archive-restore-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
860
|
+
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
861
|
+
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
else if (archivedExists && canonicalExists) {
|
|
865
|
+
// Both files exist — pre-Task-258 bug already produced two parallel
|
|
866
|
+
// jsonls for one sessionId. Keep the larger as canonical (more history);
|
|
867
|
+
// park the smaller at `archive/<sid>.<ts>.jsonl`. No data loss.
|
|
868
|
+
try {
|
|
869
|
+
const canonicalSize = statSync(canonicalJsonl).size;
|
|
870
|
+
const archivedSize = statSync(archivedJsonl).size;
|
|
871
|
+
const keepArchived = archivedSize > canonicalSize;
|
|
872
|
+
const ts = Date.now();
|
|
873
|
+
if (keepArchived) {
|
|
874
|
+
const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
|
|
875
|
+
renameSync(canonicalJsonl, stash);
|
|
876
|
+
renameSync(archivedJsonl, canonicalJsonl);
|
|
877
|
+
deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-archived canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
|
|
881
|
+
renameSync(archivedJsonl, stash);
|
|
882
|
+
deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
887
|
+
deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
888
|
+
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
889
|
+
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const result = await spawnClaudeSession({
|
|
893
|
+
...deps,
|
|
894
|
+
watcher: deps.watcher,
|
|
895
|
+
onSessionReady: (tracker) => {
|
|
896
|
+
attachPublicAudit(deps, tracker.sessionId, role);
|
|
897
|
+
attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
|
|
898
|
+
},
|
|
899
|
+
onUrlCaptured: notifyUrlCaptured,
|
|
900
|
+
tunnelUrlOverride,
|
|
901
|
+
}, {
|
|
705
902
|
senderId,
|
|
706
903
|
userId,
|
|
707
904
|
role: role,
|
|
@@ -729,8 +926,15 @@ export function buildHttpApp(deps) {
|
|
|
729
926
|
if (specialist) {
|
|
730
927
|
deps.logger(`resume-specialist-propagated source=${sessionId} specialist=${specialist}`);
|
|
731
928
|
}
|
|
732
|
-
|
|
733
|
-
|
|
929
|
+
// Task 258 — the archive-restore-before-spawn flow means the operator's
|
|
930
|
+
// prior canonical sessionId IS the jsonl basename claude appends to.
|
|
931
|
+
// The PtyTracker's sessionId is set to that same prior canonical id
|
|
932
|
+
// (resumeClaudeSessionId), so the wire payload echoes it directly.
|
|
933
|
+
deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
|
|
934
|
+
const payload = {
|
|
935
|
+
...buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url),
|
|
936
|
+
sessionId,
|
|
937
|
+
};
|
|
734
938
|
if (idempotencyKey) {
|
|
735
939
|
resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
|
|
736
940
|
}
|
|
@@ -749,29 +953,27 @@ export function buildHttpApp(deps) {
|
|
|
749
953
|
timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
|
|
750
954
|
return c.json({ error: 'session-not-found' }, 404);
|
|
751
955
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
lastMessageAt
|
|
765
|
-
turnCount
|
|
766
|
-
capped
|
|
767
|
-
sizeBytes
|
|
768
|
-
model
|
|
769
|
-
|
|
956
|
+
// Task 260 — single row source. /meta uses the deeper enumerator
|
|
957
|
+
// limit (jsonlReadMaxBytes) for richer turn/sizeBytes data than
|
|
958
|
+
// /list provides; otherwise the payload is identical to /list.
|
|
959
|
+
let payload = toPayload(row);
|
|
960
|
+
const candidatePath = row.state === 'archived'
|
|
961
|
+
? jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
|
|
962
|
+
: jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
963
|
+
if (existsSync(candidatePath)) {
|
|
964
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, deps.jsonlReadMaxBytes, deps.logger, deps.userTitleStore);
|
|
965
|
+
if (enriched) {
|
|
966
|
+
payload = {
|
|
967
|
+
...payload,
|
|
968
|
+
lastMessageAt: enriched.lastMessageAt,
|
|
969
|
+
turnCount: enriched.turnCount,
|
|
970
|
+
capped: enriched.capped,
|
|
971
|
+
sizeBytes: enriched.sizeBytes,
|
|
972
|
+
model: enriched.model,
|
|
973
|
+
displayName: enriched.displayName,
|
|
974
|
+
titleSource: enriched.titleSource,
|
|
975
|
+
};
|
|
770
976
|
}
|
|
771
|
-
payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes, model };
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
payload = toArchivedPayload(row, '');
|
|
775
977
|
}
|
|
776
978
|
deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
|
|
777
979
|
timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
|
|
@@ -802,15 +1004,21 @@ export function buildHttpApp(deps) {
|
|
|
802
1004
|
timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
|
|
803
1005
|
return c.json({ error: 'session-not-found' }, 404);
|
|
804
1006
|
}
|
|
805
|
-
if (deps.store.get(row.sessionId)) {
|
|
806
|
-
deps.logger(`archive-rejected sessionId=${row.sessionId.slice(0, 8)} reason=pty-still-alive`);
|
|
807
|
-
timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
|
|
808
|
-
return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
|
|
809
|
-
}
|
|
810
1007
|
const slug = projectSlugForCwd(deps.spawnCwd);
|
|
811
1008
|
const slugDir = join(claudeStateRoot(), 'projects', slug);
|
|
812
1009
|
const archiveDir = join(slugDir, 'archive');
|
|
813
1010
|
const fileName = `${row.sessionId}.jsonl`;
|
|
1011
|
+
// Task 260 — refuse archive whenever an alive PTY exists for this
|
|
1012
|
+
// row in the manager's tracker map. Earlier this walked every
|
|
1013
|
+
// store entry to find one whose derived path equaled the candidate
|
|
1014
|
+
// — that loop was redundant because the JSONL basename IS the
|
|
1015
|
+
// sessionId, so the only relevant check is "is this sessionId
|
|
1016
|
+
// tracker alive?". `isLive` answers in O(1).
|
|
1017
|
+
if (mode === 'archive' && isLive(row.sessionId)) {
|
|
1018
|
+
deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive`);
|
|
1019
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
|
|
1020
|
+
return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
|
|
1021
|
+
}
|
|
814
1022
|
const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
|
|
815
1023
|
const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
|
|
816
1024
|
if (!existsSync(fromJsonl)) {
|
|
@@ -828,10 +1036,79 @@ export function buildHttpApp(deps) {
|
|
|
828
1036
|
timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
|
|
829
1037
|
return c.json({ error: 'archive-failed', detail: msg }, 500);
|
|
830
1038
|
}
|
|
831
|
-
|
|
1039
|
+
// Task 260 — the sidecar travels with the JSONL. Best-effort: an
|
|
1040
|
+
// orphan sidecar at the source path is self-healing (the next
|
|
1041
|
+
// bootSeed indexes the row with null fields and the operator
|
|
1042
|
+
// re-archives), but the orphan is itself a regression signal the
|
|
1043
|
+
// log line surfaces.
|
|
1044
|
+
let sidecarMoved = false;
|
|
1045
|
+
try {
|
|
1046
|
+
const fromSidecar = mode === 'archive'
|
|
1047
|
+
? sidecarPathForSessionId(deps.spawnCwd, row.sessionId)
|
|
1048
|
+
: sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
1049
|
+
const toSidecar = mode === 'archive'
|
|
1050
|
+
? sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
|
|
1051
|
+
: sidecarPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
1052
|
+
if (existsSync(fromSidecar)) {
|
|
1053
|
+
renameSync(fromSidecar, toSidecar);
|
|
1054
|
+
sidecarMoved = true;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
catch (err) {
|
|
1058
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1059
|
+
deps.logger(`archive-sidecar-move-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
|
|
1060
|
+
}
|
|
1061
|
+
deps.logger(`[archive] sessionId=${row.sessionId.slice(0, 8)} mode=${mode} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))} sidecar-moved=${sidecarMoved}`);
|
|
832
1062
|
timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
|
|
833
1063
|
return c.json({ ok: true, mode }, 200);
|
|
834
1064
|
});
|
|
1065
|
+
// Task 253 — POST /:sessionId/rename { title }
|
|
1066
|
+
// - 200 + body on accept (carries the persisted title)
|
|
1067
|
+
// - 400 on validator reject (empty / too-long / control-chars)
|
|
1068
|
+
// - 404 when no row matches any of the four ids
|
|
1069
|
+
// After accepting, the user-title store persists and notifyRename()
|
|
1070
|
+
// fans a row-updated frame to every connected /events client so the
|
|
1071
|
+
// sidebar refreshes without a /list re-fetch.
|
|
1072
|
+
app.post('/:sessionId/rename', async (c) => {
|
|
1073
|
+
const start = Date.now();
|
|
1074
|
+
const id = c.req.param('sessionId');
|
|
1075
|
+
if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
|
|
1076
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1077
|
+
return c.json({ error: 'invalid-session-id' }, 400);
|
|
1078
|
+
}
|
|
1079
|
+
let body;
|
|
1080
|
+
try {
|
|
1081
|
+
body = await c.req.json();
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1085
|
+
return c.json({ error: 'invalid-json-body' }, 400);
|
|
1086
|
+
}
|
|
1087
|
+
const row = resolveRow(deps.watcher, id);
|
|
1088
|
+
// Task 260 — rename resolves via the watcher row index. The live
|
|
1089
|
+
// tracker is no longer a secondary lookup surface — the watcher's
|
|
1090
|
+
// PID-file event indexes every live session, so a sessionId that
|
|
1091
|
+
// names no row also names no live PTY.
|
|
1092
|
+
const sessionId = row?.sessionId;
|
|
1093
|
+
if (!sessionId) {
|
|
1094
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
|
|
1095
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
1096
|
+
}
|
|
1097
|
+
const result = validateUserTitle(body.title);
|
|
1098
|
+
if (!result.ok) {
|
|
1099
|
+
deps.logger(`rename-rejected sessionId=${sessionId.slice(0, 8)} reason=${result.reason}`);
|
|
1100
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1101
|
+
return c.json({ error: 'invalid-title', reason: result.reason }, 400);
|
|
1102
|
+
}
|
|
1103
|
+
const entry = deps.userTitleStore.set(sessionId, result.title);
|
|
1104
|
+
// Title content is never logged — operator names may contain customer
|
|
1105
|
+
// data. Byte count is enough for forensic recovery of bulk-rename surges.
|
|
1106
|
+
const titleBytes = Buffer.byteLength(entry.title, 'utf-8');
|
|
1107
|
+
deps.logger(`rename sessionId=${sessionId.slice(0, 8)} title-bytes=${titleBytes} source=manager-route`);
|
|
1108
|
+
notifyRename(sessionId);
|
|
1109
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 200, Date.now() - start);
|
|
1110
|
+
return c.json({ ok: true, sessionId, titleSource: 'user', renamedAt: entry.renamedAt }, 200);
|
|
1111
|
+
});
|
|
835
1112
|
// SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
|
|
836
1113
|
app.post('/:sessionId/stop', async (c) => {
|
|
837
1114
|
const start = Date.now();
|
|
@@ -841,12 +1118,28 @@ export function buildHttpApp(deps) {
|
|
|
841
1118
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
842
1119
|
}
|
|
843
1120
|
const row = resolveRow(deps.watcher, id);
|
|
844
|
-
if (!row && !
|
|
1121
|
+
if (!row && !isLive(id)) {
|
|
845
1122
|
timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
|
|
846
1123
|
return c.json({ error: 'session-not-found' }, 404);
|
|
847
1124
|
}
|
|
848
1125
|
const sessionId = row?.sessionId ?? id;
|
|
849
|
-
await stopSession({
|
|
1126
|
+
const outcome = await stopSession({
|
|
1127
|
+
killGraceMs: deps.killGraceMs,
|
|
1128
|
+
logger: deps.logger,
|
|
1129
|
+
runSystemctl: deps.runSystemctl,
|
|
1130
|
+
}, sessionId);
|
|
1131
|
+
// Task 250 — surface scope-stop failures as 500 so the operator sees
|
|
1132
|
+
// the End-click fail when systemd cannot stop the unit. ok and
|
|
1133
|
+
// already-gone collapse to 204 (idempotent); no-store-entry is the
|
|
1134
|
+
// archived-row path that auto-archive triggers (no PTY to stop).
|
|
1135
|
+
if (outcome.scopeOutcome === 'error') {
|
|
1136
|
+
timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
|
|
1137
|
+
return c.json({
|
|
1138
|
+
error: 'scope-stop-failed',
|
|
1139
|
+
exitCode: outcome.exitCode,
|
|
1140
|
+
detail: 'systemctl --user stop returned a non-zero exit code for the session\'s scope unit',
|
|
1141
|
+
}, 500);
|
|
1142
|
+
}
|
|
850
1143
|
timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
|
|
851
1144
|
return c.body(null, 204);
|
|
852
1145
|
});
|
|
@@ -860,26 +1153,40 @@ export function buildHttpApp(deps) {
|
|
|
860
1153
|
}
|
|
861
1154
|
const row = resolveRow(deps.watcher, id);
|
|
862
1155
|
const sessionId = row?.sessionId ?? id;
|
|
863
|
-
if (
|
|
1156
|
+
if (isLive(sessionId)) {
|
|
864
1157
|
deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
|
|
865
1158
|
timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
|
|
866
1159
|
return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
|
|
867
1160
|
}
|
|
868
|
-
|
|
1161
|
+
// Task 260 — purge from BOTH locations: a row may have its JSONL
|
|
1162
|
+
// at the top level (live or just-ended) or under archive/ (archived).
|
|
1163
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1164
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1165
|
+
const topSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
1166
|
+
const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1167
|
+
const hasTop = existsSync(topJsonl);
|
|
1168
|
+
const hasArchived = existsSync(archivedJsonl);
|
|
1169
|
+
if (!row || (!hasTop && !hasArchived)) {
|
|
869
1170
|
timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
|
|
870
1171
|
return c.json({ error: 'session-not-found' }, 404);
|
|
871
1172
|
}
|
|
872
1173
|
const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
|
|
873
1174
|
let bytes = 0;
|
|
874
1175
|
try {
|
|
875
|
-
bytes =
|
|
1176
|
+
bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
|
|
876
1177
|
}
|
|
877
1178
|
catch {
|
|
878
1179
|
// size is informational
|
|
879
1180
|
}
|
|
880
1181
|
try {
|
|
881
|
-
if (
|
|
882
|
-
rmSync(
|
|
1182
|
+
if (hasTop)
|
|
1183
|
+
rmSync(topJsonl);
|
|
1184
|
+
if (hasArchived)
|
|
1185
|
+
rmSync(archivedJsonl);
|
|
1186
|
+
if (existsSync(topSidecar))
|
|
1187
|
+
rmSync(topSidecar);
|
|
1188
|
+
if (existsSync(archivedSidecar))
|
|
1189
|
+
rmSync(archivedSidecar);
|
|
883
1190
|
if (existsSync(subdir))
|
|
884
1191
|
rmSync(subdir, { recursive: true, force: true });
|
|
885
1192
|
}
|
|
@@ -889,6 +1196,11 @@ export function buildHttpApp(deps) {
|
|
|
889
1196
|
timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
|
|
890
1197
|
return c.json({ error: 'delete-failed', detail: msg }, 500);
|
|
891
1198
|
}
|
|
1199
|
+
// Task 253 — drop the user-title entry for the purged session so the
|
|
1200
|
+
// session-titles.json doesn't accrete orphans. Best-effort; the store
|
|
1201
|
+
// logs its own failure and reads still serve from the in-memory map
|
|
1202
|
+
// until next boot.
|
|
1203
|
+
deps.userTitleStore.remove(sessionId);
|
|
892
1204
|
deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
|
|
893
1205
|
timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
|
|
894
1206
|
return c.body(null, 204);
|
|
@@ -898,15 +1210,6 @@ export function buildHttpApp(deps) {
|
|
|
898
1210
|
const id = c.req.param('sessionId');
|
|
899
1211
|
const row = resolveRow(deps.watcher, id);
|
|
900
1212
|
const sessionId = row?.sessionId ?? id;
|
|
901
|
-
const s = deps.store.get(sessionId);
|
|
902
|
-
if (!s) {
|
|
903
|
-
timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
|
|
904
|
-
return c.json({ error: 'session-not-found' }, 404);
|
|
905
|
-
}
|
|
906
|
-
if (s.exited) {
|
|
907
|
-
timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
|
|
908
|
-
return c.json({ error: 'session-exited' }, 410);
|
|
909
|
-
}
|
|
910
1213
|
let body;
|
|
911
1214
|
try {
|
|
912
1215
|
body = await c.req.json();
|
|
@@ -919,61 +1222,46 @@ export function buildHttpApp(deps) {
|
|
|
919
1222
|
timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
|
|
920
1223
|
return c.json({ error: 'text-required' }, 400);
|
|
921
1224
|
}
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
// payload as a single paste followed by an explicit submit keystroke.
|
|
929
|
-
// Single-line text keeps the previous typed-keystroke path byte-for-byte
|
|
930
|
-
// so the Sidebar timestamp stimulus is unchanged.
|
|
931
|
-
const multiline = body.text.includes('\n');
|
|
932
|
-
const submitMode = multiline ? 'bracketed-paste' : 'typed';
|
|
933
|
-
try {
|
|
934
|
-
if (multiline) {
|
|
935
|
-
s.pty.write(`\x1b[200~${body.text}\x1b[201~\r`);
|
|
1225
|
+
const channel = row?.channel ?? null;
|
|
1226
|
+
const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
|
|
1227
|
+
if (!result.ok) {
|
|
1228
|
+
if (result.error === 'not-found') {
|
|
1229
|
+
timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
|
|
1230
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
936
1231
|
}
|
|
937
|
-
|
|
938
|
-
|
|
1232
|
+
if (result.error === 'exited') {
|
|
1233
|
+
timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
|
|
1234
|
+
return c.json({ error: 'session-exited' }, 410);
|
|
939
1235
|
}
|
|
940
|
-
}
|
|
941
|
-
catch (err) {
|
|
942
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
943
|
-
deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
|
|
944
1236
|
timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
|
|
945
|
-
return c.json({ error: 'pty-write-failed', detail:
|
|
1237
|
+
return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
|
|
946
1238
|
}
|
|
947
|
-
deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel} multiline=${multiline} submit-mode=${submitMode}`);
|
|
948
1239
|
timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
|
|
949
|
-
return c.json({ ok: true, bytes }, 200);
|
|
1240
|
+
return c.json({ ok: true, bytes: result.bytes }, 200);
|
|
950
1241
|
});
|
|
951
1242
|
app.get('/:sessionId/log', (c) => {
|
|
952
1243
|
const start = Date.now();
|
|
953
1244
|
const id = c.req.param('sessionId');
|
|
954
1245
|
const row = resolveRow(deps.watcher, id);
|
|
955
1246
|
const sessionId = row?.sessionId ?? id;
|
|
956
|
-
const
|
|
957
|
-
// Task
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
// path to a transcript.
|
|
964
|
-
if (!s && !row?.jsonlPath) {
|
|
1247
|
+
const live = isLive(sessionId);
|
|
1248
|
+
// Task 260 — /log reads from whichever location holds the JSONL
|
|
1249
|
+
// right now: top level for live + recently-ended rows, archive
|
|
1250
|
+
// subdir for archived rows. 404 when there's no row AND no live
|
|
1251
|
+
// PTY for this id; 202 pending when a live PTY hasn't flushed
|
|
1252
|
+
// its first turn yet (no JSONL on disk in either location).
|
|
1253
|
+
if (!live && !row) {
|
|
965
1254
|
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
966
1255
|
return c.json({ error: 'session-not-found' }, 404);
|
|
967
1256
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if (
|
|
1257
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1258
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1259
|
+
const jsonlPath = existsSync(topJsonl) ? topJsonl : archivedJsonl;
|
|
1260
|
+
if (live && !existsSync(topJsonl) && !existsSync(archivedJsonl)) {
|
|
972
1261
|
timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
|
|
973
1262
|
return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
|
|
974
1263
|
}
|
|
975
|
-
|
|
976
|
-
if (!jsonlPath) {
|
|
1264
|
+
if (!existsSync(jsonlPath)) {
|
|
977
1265
|
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
978
1266
|
return c.json({ error: 'session-not-found' }, 404);
|
|
979
1267
|
}
|
|
@@ -1043,7 +1331,12 @@ export function buildHttpApp(deps) {
|
|
|
1043
1331
|
}
|
|
1044
1332
|
position = initialSize;
|
|
1045
1333
|
}
|
|
1046
|
-
|
|
1334
|
+
// Task 260 — prefer the row's PID (watcher-derived) but fall back
|
|
1335
|
+
// to the live tracker if the row hasn't been populated yet (the
|
|
1336
|
+
// pre-first-turn window during which the watcher's PID-file event
|
|
1337
|
+
// has not fired against the row builder).
|
|
1338
|
+
const followPid = row?.pid ?? getPtyTrackerForTests(sessionId)?.pid ?? null;
|
|
1339
|
+
deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${followPid ?? 'null'}`);
|
|
1047
1340
|
let active = true;
|
|
1048
1341
|
out.onAbort(() => {
|
|
1049
1342
|
active = false;
|
|
@@ -1075,7 +1368,7 @@ export function buildHttpApp(deps) {
|
|
|
1075
1368
|
deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
|
|
1076
1369
|
});
|
|
1077
1370
|
});
|
|
1078
|
-
app.get('/healthz', (c) => c.json({ ok: true, sessions:
|
|
1371
|
+
app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
|
|
1079
1372
|
return app;
|
|
1080
1373
|
}
|
|
1081
1374
|
//# sourceMappingURL=http-server.js.map
|