@rubytech/create-maxy-code 0.1.92 → 0.1.94
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/plugins/docs/references/platform.md +4 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +61 -2
- 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 +337 -32
- 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 +2 -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 +316 -211
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +62 -13
- 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 +9 -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 +14 -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 +283 -92
- 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 +4 -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 +23 -22
- 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/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/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-BhEvxMrC.js → admin-ux6TKHS1.js} +1 -1
- 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-C1hj89Gf.js → page-BeYR_GI2.js} +1 -1
- package/payload/server/public/assets/{page-Bu8kO4jO.js → page-DVNp-Dfy.js} +1 -1
- 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/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-Cbo0tTS3.js +0 -1
- package/payload/server/public/assets/graph-Beucu3_J.js +0 -1
- package/payload/server/public/assets/graph-labels-D15-eFQK.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,9 +24,10 @@
|
|
|
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, jsonlPathForSessionId } from './jsonl-path.js';
|
|
30
|
+
import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
|
|
30
31
|
import { basename, join } from 'node:path';
|
|
31
32
|
import { validateUserTitle } from './user-title-store.js';
|
|
32
33
|
const ROLES = ['admin', 'public'];
|
|
@@ -131,8 +132,15 @@ function parseSpecialistDomains(raw) {
|
|
|
131
132
|
return out.length > 0 ? out : undefined;
|
|
132
133
|
}
|
|
133
134
|
// `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
|
|
134
|
-
// bridgeSuffix,
|
|
135
|
-
|
|
135
|
+
// bridgeSuffix, pid (numeric), or the resume-conflict stash composite
|
|
136
|
+
// id `<uuid>.<unix-ms>` minted at http-server.ts:1006/1013 and surfaced
|
|
137
|
+
// by Task 260's archive enumerator. Dot is admitted; length raised to
|
|
138
|
+
// fit UUID (36) + `.` + 13-digit ms; the negative lookahead rejects
|
|
139
|
+
// all-dot strings so path-traversal shapes (`.`, `..`, `...`) cannot
|
|
140
|
+
// satisfy the gate. No `/` — Hono's route param already excludes it,
|
|
141
|
+
// and admitting it here would broaden the filesystem surface the
|
|
142
|
+
// downstream `jsonlPathForSessionId` composition trusts.
|
|
143
|
+
export const SESSION_ID_PATTERN = /^(?!\.+$)[A-Za-z0-9._-]{1,160}$/;
|
|
136
144
|
const LIST_TAIL_BYTES = 64 * 1024;
|
|
137
145
|
function timed(logger, method, path, status, ms) {
|
|
138
146
|
logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
|
|
@@ -166,11 +174,11 @@ function resolveRow(watcher, id) {
|
|
|
166
174
|
// auto-archive` lines cover the lifecycle.
|
|
167
175
|
const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
|
|
168
176
|
const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
|
|
169
|
-
function attachSpecialistEndTurnAutoArchive(deps,
|
|
170
|
-
if (
|
|
177
|
+
function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
|
|
178
|
+
if (specialist !== AUTO_ARCHIVE_SPECIALIST)
|
|
171
179
|
return;
|
|
172
180
|
let archived = false;
|
|
173
|
-
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd,
|
|
181
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
174
182
|
function endTurnSeen() {
|
|
175
183
|
try {
|
|
176
184
|
const content = readFileSync(jsonlPath, 'utf8');
|
|
@@ -188,35 +196,38 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
|
|
|
188
196
|
if (!endTurnSeen())
|
|
189
197
|
return;
|
|
190
198
|
archived = true;
|
|
191
|
-
deps.logger(`auto-archive sessionId=${
|
|
192
|
-
void stopSession({
|
|
199
|
+
deps.logger(`auto-archive sessionId=${sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
|
|
200
|
+
void stopSession({
|
|
201
|
+
killGraceMs: deps.killGraceMs,
|
|
202
|
+
logger: deps.logger,
|
|
203
|
+
runSystemctl: deps.runSystemctl,
|
|
204
|
+
}, sessionId);
|
|
193
205
|
}
|
|
194
206
|
tryArchive();
|
|
195
207
|
const unsubscribe = deps.watcher.subscribe((event) => {
|
|
196
208
|
if (archived)
|
|
197
209
|
return;
|
|
198
|
-
if (event.sessionId !==
|
|
210
|
+
if (event.sessionId !== sessionId)
|
|
199
211
|
return;
|
|
200
212
|
if (event.source !== 'jsonl')
|
|
201
213
|
return;
|
|
202
214
|
tryArchive();
|
|
203
215
|
});
|
|
204
|
-
|
|
216
|
+
onPtyExit(sessionId, () => unsubscribe());
|
|
205
217
|
}
|
|
206
|
-
function attachPublicAudit(deps,
|
|
207
|
-
if (
|
|
218
|
+
function attachPublicAudit(deps, sessionId, role) {
|
|
219
|
+
if (role !== 'public')
|
|
208
220
|
return;
|
|
209
|
-
const
|
|
210
|
-
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, claudeSessionId);
|
|
221
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
211
222
|
if (!existsSync(jsonlPath))
|
|
212
223
|
return;
|
|
213
|
-
deps.publicToolAudit.attach(
|
|
214
|
-
if (
|
|
215
|
-
deps.publicToolAudit.detach(
|
|
224
|
+
deps.publicToolAudit.attach(sessionId, jsonlPath);
|
|
225
|
+
if (!isLive(sessionId)) {
|
|
226
|
+
deps.publicToolAudit.detach(sessionId);
|
|
216
227
|
return;
|
|
217
228
|
}
|
|
218
|
-
|
|
219
|
-
deps.publicToolAudit.detach(
|
|
229
|
+
onPtyExit(sessionId, () => {
|
|
230
|
+
deps.publicToolAudit.detach(sessionId);
|
|
220
231
|
});
|
|
221
232
|
}
|
|
222
233
|
export function buildHttpApp(deps) {
|
|
@@ -245,8 +256,10 @@ export function buildHttpApp(deps) {
|
|
|
245
256
|
}
|
|
246
257
|
app.__notifyRenameForTests = notifyRename;
|
|
247
258
|
// Watcher subscriber: when the JSONL CREATE event fires for a live
|
|
248
|
-
// session, attach the public-audit watcher. The
|
|
249
|
-
// (Task
|
|
259
|
+
// session, attach the public-audit watcher. The watcher row carries
|
|
260
|
+
// the role from the sidecar (Task 260 — disk is the source of truth),
|
|
261
|
+
// so the audit attachment fires deterministically against rows whose
|
|
262
|
+
// PTY is still alive in this manager process.
|
|
250
263
|
deps.watcher.subscribe((event) => {
|
|
251
264
|
if (event.source !== 'jsonl')
|
|
252
265
|
return;
|
|
@@ -254,10 +267,9 @@ export function buildHttpApp(deps) {
|
|
|
254
267
|
return;
|
|
255
268
|
if (!event.row)
|
|
256
269
|
return;
|
|
257
|
-
|
|
258
|
-
if (!live)
|
|
270
|
+
if (!isLive(event.sessionId))
|
|
259
271
|
return;
|
|
260
|
-
attachPublicAudit(deps,
|
|
272
|
+
attachPublicAudit(deps, event.sessionId, event.row.role);
|
|
261
273
|
});
|
|
262
274
|
// Task 183 — dispatch jsonl modify/create events into the recorder
|
|
263
275
|
// tail. The tail filters by registered sessionId (only specialist
|
|
@@ -279,75 +291,71 @@ export function buildHttpApp(deps) {
|
|
|
279
291
|
tail.onJsonlModify(event.sessionId, path);
|
|
280
292
|
});
|
|
281
293
|
}
|
|
282
|
-
/** Task
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
293
|
-
if (enriched)
|
|
294
|
-
return { displayName: enriched.displayName, titleSource: enriched.titleSource };
|
|
294
|
+
/** Task 260 — /spawn and /resume need to return a wire payload
|
|
295
|
+
* immediately, but at that moment the watcher row may not yet
|
|
296
|
+
* include the sidecar fields (the JSONL CREATE event hasn't fired
|
|
297
|
+
* in fresh-spawn tests with synthetic watchers). Build the payload
|
|
298
|
+
* from the row if present, falling back to a synthetic shape that
|
|
299
|
+
* reads the sidecar directly. */
|
|
300
|
+
function buildSpawnReturnPayload(sessionId, pid, startedAt, capturedUrl) {
|
|
301
|
+
const row = deps.watcher.getBySessionId(sessionId);
|
|
302
|
+
if (row && row.senderId !== null) {
|
|
303
|
+
return toPayload(row);
|
|
295
304
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
/** Task 258/259 — derived: a session is "archived" when its canonical
|
|
302
|
-
* top-level jsonl is absent but the archive/ variant exists. After the
|
|
303
|
-
* Task 258 refactor the watcher's row index doesn't surface archive/
|
|
304
|
-
* rows; the field stays on the wire so the Sidebar/SessionMetadataPane
|
|
305
|
-
* Unarchive affordance remains type-stable. A future task that
|
|
306
|
-
* enumerates archive/ jsonls into the row index will populate this
|
|
307
|
-
* honestly for archived rows. */
|
|
308
|
-
function isArchivedJsonlPath(sessionId) {
|
|
309
|
-
const canonical = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
310
|
-
if (existsSync(canonical))
|
|
311
|
-
return false;
|
|
312
|
-
return existsSync(jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true }));
|
|
313
|
-
}
|
|
314
|
-
function toLivePayload(s) {
|
|
315
|
-
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
|
|
305
|
+
// Read the sidecar directly — the spawn just wrote it.
|
|
306
|
+
const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
307
|
+
const { sidecar } = readSidecar(sidecarPath, deps.logger);
|
|
308
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
316
309
|
const hasJsonl = existsSync(jsonlPath);
|
|
317
|
-
const title = liveTitleAndSource(s);
|
|
318
310
|
return {
|
|
319
|
-
sessionId
|
|
320
|
-
pid
|
|
321
|
-
url:
|
|
322
|
-
startedAt:
|
|
323
|
-
senderId:
|
|
324
|
-
role:
|
|
325
|
-
channel:
|
|
311
|
+
sessionId,
|
|
312
|
+
pid,
|
|
313
|
+
url: capturedUrl ?? sidecar?.url ?? null,
|
|
314
|
+
startedAt: sidecar?.startedAt ?? startedAt,
|
|
315
|
+
senderId: sidecar?.senderId ?? '',
|
|
316
|
+
role: sidecar?.role ?? null,
|
|
317
|
+
channel: sidecar?.channel ?? null,
|
|
326
318
|
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
327
319
|
status: 'alive',
|
|
328
320
|
archived: false,
|
|
329
|
-
displayName:
|
|
330
|
-
titleSource:
|
|
321
|
+
displayName: null,
|
|
322
|
+
titleSource: null,
|
|
331
323
|
lastMessageAt: null,
|
|
332
324
|
turnCount: 0,
|
|
333
325
|
capped: false,
|
|
334
326
|
sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
|
|
335
|
-
hidden:
|
|
336
|
-
specialist:
|
|
327
|
+
hidden: sidecar?.hidden ?? false,
|
|
328
|
+
specialist: sidecar?.specialist ?? null,
|
|
337
329
|
model: null,
|
|
338
330
|
};
|
|
339
331
|
}
|
|
340
|
-
|
|
332
|
+
/** Task 260 — single payload builder. Both live and archived rows
|
|
333
|
+
* derive every wire field from the watcher row (which itself derived
|
|
334
|
+
* from the sidecar + JSONL + PID file). The split between
|
|
335
|
+
* `toLivePayload`/`toArchivedPayload` is gone; the row's `state`
|
|
336
|
+
* and `pid` decide `status` and the `archived` flag.
|
|
337
|
+
*
|
|
338
|
+
* The `model` field continues to derive from the JSONL tail (last
|
|
339
|
+
* assistant turn) rather than the sidecar's spawn-request model —
|
|
340
|
+
* see [[learnings_writer_reader_charset_drift]] family: the running
|
|
341
|
+
* model is what the wire's `model` has historically meant, and the
|
|
342
|
+
* sidecar's `model` is kept as a self-diagnostic record. */
|
|
343
|
+
function toPayload(row) {
|
|
344
|
+
const isLiveState = row.state === 'live';
|
|
345
|
+
const archivedJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
346
|
+
const topJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
347
|
+
const jsonlPath = isLiveState
|
|
348
|
+
? topJsonlPath
|
|
349
|
+
: existsSync(archivedJsonlPath) ? archivedJsonlPath : topJsonlPath;
|
|
350
|
+
const hasJsonl = existsSync(jsonlPath);
|
|
341
351
|
let displayName = null;
|
|
342
352
|
let titleSource = null;
|
|
343
353
|
let lastMessageAt = null;
|
|
344
354
|
let turnCount = 0;
|
|
345
355
|
let capped = false;
|
|
346
|
-
let sizeBytes = null;
|
|
356
|
+
let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
|
|
347
357
|
let model = null;
|
|
348
|
-
let
|
|
349
|
-
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
350
|
-
const hasJsonl = existsSync(jsonlPath);
|
|
358
|
+
let enrichedStartedAt = null;
|
|
351
359
|
if (hasJsonl) {
|
|
352
360
|
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
353
361
|
if (enriched) {
|
|
@@ -357,38 +365,49 @@ export function buildHttpApp(deps) {
|
|
|
357
365
|
turnCount = enriched.turnCount;
|
|
358
366
|
capped = enriched.capped;
|
|
359
367
|
sizeBytes = enriched.sizeBytes;
|
|
360
|
-
|
|
368
|
+
enrichedStartedAt = enriched.startedAt;
|
|
361
369
|
model = enriched.model;
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
else {
|
|
365
|
-
// JSONL gone but user override may still exist (operator renamed the row
|
|
366
|
-
// before the cascade deleted the file). Surface the user title.
|
|
367
373
|
const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
|
|
368
374
|
if (userTitle) {
|
|
369
375
|
displayName = userTitle;
|
|
370
376
|
titleSource = 'user';
|
|
371
377
|
}
|
|
372
378
|
}
|
|
379
|
+
// startedAt resolution order:
|
|
380
|
+
// 1. JSONL enrichment (the most authoritative — claude's own record)
|
|
381
|
+
// 2. Sidecar (/spawn time)
|
|
382
|
+
// 3. Row mtime fallback (legacy archived rows without sidecar)
|
|
383
|
+
const startedAt = enrichedStartedAt
|
|
384
|
+
?? row.startedAt
|
|
385
|
+
?? new Date(row.updatedAt || 0).toISOString();
|
|
386
|
+
// Agent label: PID-file `agent` for live rows; cachedAgentSetting for
|
|
387
|
+
// archived rows (JSONL head). Sidecar's `sidecarSpecialist` is the
|
|
388
|
+
// spawn-time request — fall back to it when both above are null so
|
|
389
|
+
// post-Task-260 rows surface their specialist even if the JSONL
|
|
390
|
+
// doesn't include an agent-setting record.
|
|
391
|
+
const specialist = row.agent ?? row.cachedAgentSetting ?? row.sidecarSpecialist;
|
|
373
392
|
return {
|
|
374
393
|
sessionId: row.sessionId,
|
|
375
|
-
pid:
|
|
376
|
-
url:
|
|
394
|
+
pid: row.pid,
|
|
395
|
+
url: row.url,
|
|
377
396
|
startedAt,
|
|
378
|
-
senderId,
|
|
379
|
-
role:
|
|
380
|
-
channel:
|
|
397
|
+
senderId: row.senderId ?? '',
|
|
398
|
+
role: row.role,
|
|
399
|
+
channel: row.channel,
|
|
381
400
|
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
382
|
-
status: 'ended',
|
|
383
|
-
archived:
|
|
401
|
+
status: isLiveState ? 'alive' : 'ended',
|
|
402
|
+
archived: row.state === 'archived',
|
|
384
403
|
displayName,
|
|
385
404
|
titleSource,
|
|
386
405
|
lastMessageAt,
|
|
387
406
|
turnCount,
|
|
388
407
|
capped,
|
|
389
408
|
sizeBytes,
|
|
390
|
-
hidden:
|
|
391
|
-
specialist
|
|
409
|
+
hidden: row.hidden,
|
|
410
|
+
specialist,
|
|
392
411
|
model,
|
|
393
412
|
};
|
|
394
413
|
}
|
|
@@ -462,7 +481,16 @@ export function buildHttpApp(deps) {
|
|
|
462
481
|
: body.tunnelUrl === null
|
|
463
482
|
? null
|
|
464
483
|
: undefined;
|
|
465
|
-
const result = await spawnClaudeSession({
|
|
484
|
+
const result = await spawnClaudeSession({
|
|
485
|
+
...deps,
|
|
486
|
+
watcher: deps.watcher,
|
|
487
|
+
onSessionReady: (tracker) => {
|
|
488
|
+
attachPublicAudit(deps, tracker.sessionId, role);
|
|
489
|
+
attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
|
|
490
|
+
},
|
|
491
|
+
onUrlCaptured: notifyUrlCaptured,
|
|
492
|
+
tunnelUrlOverride,
|
|
493
|
+
}, {
|
|
466
494
|
senderId,
|
|
467
495
|
userId,
|
|
468
496
|
role: role,
|
|
@@ -491,7 +519,8 @@ export function buildHttpApp(deps) {
|
|
|
491
519
|
return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
|
|
492
520
|
}
|
|
493
521
|
timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
|
|
494
|
-
|
|
522
|
+
const payload = buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url);
|
|
523
|
+
return c.json(payload, 200);
|
|
495
524
|
});
|
|
496
525
|
// SSE feed of row deltas. One channel per client; manager fans out from
|
|
497
526
|
// a single watcher subscription. On connect: replay current rows as
|
|
@@ -551,10 +580,17 @@ export function buildHttpApp(deps) {
|
|
|
551
580
|
firstPrompt,
|
|
552
581
|
titleSource,
|
|
553
582
|
updatedAt: row.updatedAt,
|
|
554
|
-
url
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
|
|
583
|
+
// Task 260 — url is on the row from the sidecar; url-capture
|
|
584
|
+
// mutates the sidecar in place and the watcher re-reads on the
|
|
585
|
+
// next event. Earlier this dipped into deps.store for the live-
|
|
586
|
+
// row case and returned null for everything else.
|
|
587
|
+
url: row.url,
|
|
588
|
+
// Task 260 — archive state is on the row directly (the watcher
|
|
589
|
+
// enumerates both top and archive subdirs and folds a top→archive
|
|
590
|
+
// move into a single state change). Earlier this was an extra
|
|
591
|
+
// existsSync against the archive path because archived rows
|
|
592
|
+
// weren't first-class members of the index.
|
|
593
|
+
archived: row.state === 'archived',
|
|
558
594
|
};
|
|
559
595
|
}
|
|
560
596
|
timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
|
|
@@ -630,12 +666,13 @@ export function buildHttpApp(deps) {
|
|
|
630
666
|
deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
|
|
631
667
|
void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
632
668
|
});
|
|
633
|
-
// Task 189 — when `url-capture` fires for a session, the
|
|
634
|
-
// calls `notifyUrlCaptured(sessionId)` after
|
|
635
|
-
//
|
|
636
|
-
// Open-in-new-tab arrow appears in step. The row lookup goes
|
|
637
|
-
// the watcher (canonical row index); rowToPayload then
|
|
638
|
-
//
|
|
669
|
+
// Task 189 / 260 — when `url-capture` fires for a session, the
|
|
670
|
+
// pty-spawner calls `notifyUrlCaptured(sessionId)` after writing
|
|
671
|
+
// the URL to the sidecar. Re-emit the row so the client's hidden
|
|
672
|
+
// Open-in-new-tab arrow appears in step. The row lookup goes
|
|
673
|
+
// through the watcher (canonical row index); rowToPayload then
|
|
674
|
+
// reads `url` from the row (which the watcher just refreshed
|
|
675
|
+
// from the sidecar).
|
|
639
676
|
const urlListener = (sessionId) => {
|
|
640
677
|
if (!active)
|
|
641
678
|
return;
|
|
@@ -698,32 +735,41 @@ export function buildHttpApp(deps) {
|
|
|
698
735
|
timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
|
|
699
736
|
return c.json({ error: 'senderId-required' }, 400);
|
|
700
737
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
738
|
+
// Task 260 — one source. Filter the watcher's row index by sidecar
|
|
739
|
+
// senderId, then partition into live vs archived for the log line
|
|
740
|
+
// (the merged list returns to the caller in startedAt-desc order).
|
|
741
|
+
// Rows without a sidecar carry `senderId: null` and never match
|
|
742
|
+
// unless includeHidden=1 is passed (the legacy pre-Task-260 archives
|
|
743
|
+
// case). Hidden rows are dropped unless includeHidden=1.
|
|
744
|
+
const payloads = [];
|
|
745
|
+
let liveCount = 0;
|
|
746
|
+
let archivedCount = 0;
|
|
747
|
+
let hiddenSkipped = 0;
|
|
711
748
|
for (const row of deps.watcher.all()) {
|
|
712
|
-
if (row.
|
|
749
|
+
if (row.senderId !== senderId && !(includeHidden && row.senderId === null))
|
|
713
750
|
continue;
|
|
714
|
-
if (
|
|
751
|
+
if (row.hidden && !includeHidden) {
|
|
752
|
+
hiddenSkipped += 1;
|
|
715
753
|
continue;
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
|
|
754
|
+
}
|
|
755
|
+
// Confirm the JSONL is actually on disk; row state can lag behind
|
|
756
|
+
// a delete during boot-seed timing edges. Live rows whose PID
|
|
757
|
+
// file exists pre-first-turn legitimately have no JSONL — keep
|
|
758
|
+
// them in the result.
|
|
759
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
760
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
761
|
+
if (row.state === 'archived' && !existsSync(archivedJsonl) && !existsSync(topJsonl))
|
|
719
762
|
continue;
|
|
720
|
-
|
|
763
|
+
payloads.push(toPayload(row));
|
|
764
|
+
if (row.state === 'live')
|
|
765
|
+
liveCount += 1;
|
|
766
|
+
else
|
|
767
|
+
archivedCount += 1;
|
|
721
768
|
}
|
|
722
|
-
deps.logger(`list-source live=${
|
|
723
|
-
|
|
724
|
-
merged.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
769
|
+
deps.logger(`[list-source] live=${liveCount} archived=${archivedCount} hidden-skipped=${hiddenSkipped}`);
|
|
770
|
+
payloads.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
725
771
|
timed(deps.logger, 'GET', '/list', 200, Date.now() - start);
|
|
726
|
-
return c.json(
|
|
772
|
+
return c.json(payloads, 200);
|
|
727
773
|
});
|
|
728
774
|
app.post('/resume', async (c) => {
|
|
729
775
|
const start = Date.now();
|
|
@@ -800,6 +846,19 @@ export function buildHttpApp(deps) {
|
|
|
800
846
|
if (archivedExists && !canonicalExists) {
|
|
801
847
|
try {
|
|
802
848
|
renameSync(archivedJsonl, canonicalJsonl);
|
|
849
|
+
// Task 260 — sidecar travels with the JSONL. Move the archived
|
|
850
|
+
// sidecar to the top-level path so the watcher picks it up on
|
|
851
|
+
// the next reconcile.
|
|
852
|
+
const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
853
|
+
const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
854
|
+
if (existsSync(archivedSidecar)) {
|
|
855
|
+
try {
|
|
856
|
+
renameSync(archivedSidecar, canonicalSidecar);
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
deps.logger(`resume-sidecar-restore-failed sessionId=${sessionId.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
803
862
|
deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
|
|
804
863
|
}
|
|
805
864
|
catch (err) {
|
|
@@ -837,7 +896,16 @@ export function buildHttpApp(deps) {
|
|
|
837
896
|
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
838
897
|
}
|
|
839
898
|
}
|
|
840
|
-
const result = await spawnClaudeSession({
|
|
899
|
+
const result = await spawnClaudeSession({
|
|
900
|
+
...deps,
|
|
901
|
+
watcher: deps.watcher,
|
|
902
|
+
onSessionReady: (tracker) => {
|
|
903
|
+
attachPublicAudit(deps, tracker.sessionId, role);
|
|
904
|
+
attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
|
|
905
|
+
},
|
|
906
|
+
onUrlCaptured: notifyUrlCaptured,
|
|
907
|
+
tunnelUrlOverride,
|
|
908
|
+
}, {
|
|
841
909
|
senderId,
|
|
842
910
|
userId,
|
|
843
911
|
role: role,
|
|
@@ -867,11 +935,13 @@ export function buildHttpApp(deps) {
|
|
|
867
935
|
}
|
|
868
936
|
// Task 258 — the archive-restore-before-spawn flow means the operator's
|
|
869
937
|
// prior canonical sessionId IS the jsonl basename claude appends to.
|
|
870
|
-
// The
|
|
871
|
-
//
|
|
872
|
-
// directly. No mint divergence to log: there's only one sessionId.
|
|
938
|
+
// The PtyTracker's sessionId is set to that same prior canonical id
|
|
939
|
+
// (resumeClaudeSessionId), so the wire payload echoes it directly.
|
|
873
940
|
deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
|
|
874
|
-
const payload = {
|
|
941
|
+
const payload = {
|
|
942
|
+
...buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url),
|
|
943
|
+
sessionId,
|
|
944
|
+
};
|
|
875
945
|
if (idempotencyKey) {
|
|
876
946
|
resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
|
|
877
947
|
}
|
|
@@ -890,30 +960,27 @@ export function buildHttpApp(deps) {
|
|
|
890
960
|
timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
|
|
891
961
|
return c.json({ error: 'session-not-found' }, 404);
|
|
892
962
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
963
|
+
// Task 260 — single row source. /meta uses the deeper enumerator
|
|
964
|
+
// limit (jsonlReadMaxBytes) for richer turn/sizeBytes data than
|
|
965
|
+
// /list provides; otherwise the payload is identical to /list.
|
|
966
|
+
let payload = toPayload(row);
|
|
967
|
+
const candidatePath = row.state === 'archived'
|
|
968
|
+
? jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
|
|
969
|
+
: jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
970
|
+
if (existsSync(candidatePath)) {
|
|
971
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, deps.jsonlReadMaxBytes, deps.logger, deps.userTitleStore);
|
|
972
|
+
if (enriched) {
|
|
973
|
+
payload = {
|
|
974
|
+
...payload,
|
|
975
|
+
lastMessageAt: enriched.lastMessageAt,
|
|
976
|
+
turnCount: enriched.turnCount,
|
|
977
|
+
capped: enriched.capped,
|
|
978
|
+
sizeBytes: enriched.sizeBytes,
|
|
979
|
+
model: enriched.model,
|
|
980
|
+
displayName: enriched.displayName,
|
|
981
|
+
titleSource: enriched.titleSource,
|
|
982
|
+
};
|
|
912
983
|
}
|
|
913
|
-
payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes, model };
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
payload = toArchivedPayload(row, '');
|
|
917
984
|
}
|
|
918
985
|
deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
|
|
919
986
|
timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
|
|
@@ -948,19 +1015,14 @@ export function buildHttpApp(deps) {
|
|
|
948
1015
|
const slugDir = join(claudeStateRoot(), 'projects', slug);
|
|
949
1016
|
const archiveDir = join(slugDir, 'archive');
|
|
950
1017
|
const fileName = `${row.sessionId}.jsonl`;
|
|
951
|
-
// Task
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
return false;
|
|
960
|
-
return jsonlPathForSessionId(deps.spawnCwd, s.sessionId) === candidateJsonl;
|
|
961
|
-
});
|
|
962
|
-
if (aliveOnPath) {
|
|
963
|
-
deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive-jsonl-write store-key=${aliveOnPath.sessionId.slice(0, 8)}`);
|
|
1018
|
+
// Task 260 — refuse archive whenever an alive PTY exists for this
|
|
1019
|
+
// row in the manager's tracker map. Earlier this walked every
|
|
1020
|
+
// store entry to find one whose derived path equaled the candidate
|
|
1021
|
+
// — that loop was redundant because the JSONL basename IS the
|
|
1022
|
+
// sessionId, so the only relevant check is "is this sessionId
|
|
1023
|
+
// tracker alive?". `isLive` answers in O(1).
|
|
1024
|
+
if (mode === 'archive' && isLive(row.sessionId)) {
|
|
1025
|
+
deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive`);
|
|
964
1026
|
timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
|
|
965
1027
|
return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
|
|
966
1028
|
}
|
|
@@ -981,7 +1043,29 @@ export function buildHttpApp(deps) {
|
|
|
981
1043
|
timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
|
|
982
1044
|
return c.json({ error: 'archive-failed', detail: msg }, 500);
|
|
983
1045
|
}
|
|
984
|
-
|
|
1046
|
+
// Task 260 — the sidecar travels with the JSONL. Best-effort: an
|
|
1047
|
+
// orphan sidecar at the source path is self-healing (the next
|
|
1048
|
+
// bootSeed indexes the row with null fields and the operator
|
|
1049
|
+
// re-archives), but the orphan is itself a regression signal the
|
|
1050
|
+
// log line surfaces.
|
|
1051
|
+
let sidecarMoved = false;
|
|
1052
|
+
try {
|
|
1053
|
+
const fromSidecar = mode === 'archive'
|
|
1054
|
+
? sidecarPathForSessionId(deps.spawnCwd, row.sessionId)
|
|
1055
|
+
: sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
|
|
1056
|
+
const toSidecar = mode === 'archive'
|
|
1057
|
+
? sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
|
|
1058
|
+
: sidecarPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
1059
|
+
if (existsSync(fromSidecar)) {
|
|
1060
|
+
renameSync(fromSidecar, toSidecar);
|
|
1061
|
+
sidecarMoved = true;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1066
|
+
deps.logger(`archive-sidecar-move-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
|
|
1067
|
+
}
|
|
1068
|
+
deps.logger(`[archive] sessionId=${row.sessionId.slice(0, 8)} mode=${mode} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))} sidecar-moved=${sidecarMoved}`);
|
|
985
1069
|
timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
|
|
986
1070
|
return c.json({ ok: true, mode }, 200);
|
|
987
1071
|
});
|
|
@@ -1008,7 +1092,11 @@ export function buildHttpApp(deps) {
|
|
|
1008
1092
|
return c.json({ error: 'invalid-json-body' }, 400);
|
|
1009
1093
|
}
|
|
1010
1094
|
const row = resolveRow(deps.watcher, id);
|
|
1011
|
-
|
|
1095
|
+
// Task 260 — rename resolves via the watcher row index. The live
|
|
1096
|
+
// tracker is no longer a secondary lookup surface — the watcher's
|
|
1097
|
+
// PID-file event indexes every live session, so a sessionId that
|
|
1098
|
+
// names no row also names no live PTY.
|
|
1099
|
+
const sessionId = row?.sessionId;
|
|
1012
1100
|
if (!sessionId) {
|
|
1013
1101
|
timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
|
|
1014
1102
|
return c.json({ error: 'session-not-found' }, 404);
|
|
@@ -1037,12 +1125,28 @@ export function buildHttpApp(deps) {
|
|
|
1037
1125
|
return c.json({ error: 'invalid-session-id' }, 400);
|
|
1038
1126
|
}
|
|
1039
1127
|
const row = resolveRow(deps.watcher, id);
|
|
1040
|
-
if (!row && !
|
|
1128
|
+
if (!row && !isLive(id)) {
|
|
1041
1129
|
timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
|
|
1042
1130
|
return c.json({ error: 'session-not-found' }, 404);
|
|
1043
1131
|
}
|
|
1044
1132
|
const sessionId = row?.sessionId ?? id;
|
|
1045
|
-
await stopSession({
|
|
1133
|
+
const outcome = await stopSession({
|
|
1134
|
+
killGraceMs: deps.killGraceMs,
|
|
1135
|
+
logger: deps.logger,
|
|
1136
|
+
runSystemctl: deps.runSystemctl,
|
|
1137
|
+
}, sessionId);
|
|
1138
|
+
// Task 250 — surface scope-stop failures as 500 so the operator sees
|
|
1139
|
+
// the End-click fail when systemd cannot stop the unit. ok and
|
|
1140
|
+
// already-gone collapse to 204 (idempotent); no-store-entry is the
|
|
1141
|
+
// archived-row path that auto-archive triggers (no PTY to stop).
|
|
1142
|
+
if (outcome.scopeOutcome === 'error') {
|
|
1143
|
+
timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
|
|
1144
|
+
return c.json({
|
|
1145
|
+
error: 'scope-stop-failed',
|
|
1146
|
+
exitCode: outcome.exitCode,
|
|
1147
|
+
detail: 'systemctl --user stop returned a non-zero exit code for the session\'s scope unit',
|
|
1148
|
+
}, 500);
|
|
1149
|
+
}
|
|
1046
1150
|
timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
|
|
1047
1151
|
return c.body(null, 204);
|
|
1048
1152
|
});
|
|
@@ -1056,27 +1160,40 @@ export function buildHttpApp(deps) {
|
|
|
1056
1160
|
}
|
|
1057
1161
|
const row = resolveRow(deps.watcher, id);
|
|
1058
1162
|
const sessionId = row?.sessionId ?? id;
|
|
1059
|
-
if (
|
|
1163
|
+
if (isLive(sessionId)) {
|
|
1060
1164
|
deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
|
|
1061
1165
|
timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
|
|
1062
1166
|
return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
|
|
1063
1167
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1168
|
+
// Task 260 — purge from BOTH locations: a row may have its JSONL
|
|
1169
|
+
// at the top level (live or just-ended) or under archive/ (archived).
|
|
1170
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1171
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1172
|
+
const topSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
|
|
1173
|
+
const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1174
|
+
const hasTop = existsSync(topJsonl);
|
|
1175
|
+
const hasArchived = existsSync(archivedJsonl);
|
|
1176
|
+
if (!row || (!hasTop && !hasArchived)) {
|
|
1066
1177
|
timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
|
|
1067
1178
|
return c.json({ error: 'session-not-found' }, 404);
|
|
1068
1179
|
}
|
|
1069
1180
|
const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
|
|
1070
1181
|
let bytes = 0;
|
|
1071
1182
|
try {
|
|
1072
|
-
bytes =
|
|
1183
|
+
bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
|
|
1073
1184
|
}
|
|
1074
1185
|
catch {
|
|
1075
1186
|
// size is informational
|
|
1076
1187
|
}
|
|
1077
1188
|
try {
|
|
1078
|
-
if (
|
|
1079
|
-
rmSync(
|
|
1189
|
+
if (hasTop)
|
|
1190
|
+
rmSync(topJsonl);
|
|
1191
|
+
if (hasArchived)
|
|
1192
|
+
rmSync(archivedJsonl);
|
|
1193
|
+
if (existsSync(topSidecar))
|
|
1194
|
+
rmSync(topSidecar);
|
|
1195
|
+
if (existsSync(archivedSidecar))
|
|
1196
|
+
rmSync(archivedSidecar);
|
|
1080
1197
|
if (existsSync(subdir))
|
|
1081
1198
|
rmSync(subdir, { recursive: true, force: true });
|
|
1082
1199
|
}
|
|
@@ -1100,15 +1217,6 @@ export function buildHttpApp(deps) {
|
|
|
1100
1217
|
const id = c.req.param('sessionId');
|
|
1101
1218
|
const row = resolveRow(deps.watcher, id);
|
|
1102
1219
|
const sessionId = row?.sessionId ?? id;
|
|
1103
|
-
const s = deps.store.get(sessionId);
|
|
1104
|
-
if (!s) {
|
|
1105
|
-
timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
|
|
1106
|
-
return c.json({ error: 'session-not-found' }, 404);
|
|
1107
|
-
}
|
|
1108
|
-
if (s.exited) {
|
|
1109
|
-
timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
|
|
1110
|
-
return c.json({ error: 'session-exited' }, 410);
|
|
1111
|
-
}
|
|
1112
1220
|
let body;
|
|
1113
1221
|
try {
|
|
1114
1222
|
body = await c.req.json();
|
|
@@ -1121,50 +1229,42 @@ export function buildHttpApp(deps) {
|
|
|
1121
1229
|
timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
|
|
1122
1230
|
return c.json({ error: 'text-required' }, 400);
|
|
1123
1231
|
}
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
// payload as a single paste followed by an explicit submit keystroke.
|
|
1131
|
-
// Single-line text keeps the previous typed-keystroke path byte-for-byte
|
|
1132
|
-
// so the Sidebar timestamp stimulus is unchanged.
|
|
1133
|
-
const multiline = body.text.includes('\n');
|
|
1134
|
-
const submitMode = multiline ? 'bracketed-paste' : 'typed';
|
|
1135
|
-
try {
|
|
1136
|
-
if (multiline) {
|
|
1137
|
-
s.pty.write(`\x1b[200~${body.text}\x1b[201~\r`);
|
|
1232
|
+
const channel = row?.channel ?? null;
|
|
1233
|
+
const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
|
|
1234
|
+
if (!result.ok) {
|
|
1235
|
+
if (result.error === 'not-found') {
|
|
1236
|
+
timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
|
|
1237
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
1138
1238
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1239
|
+
if (result.error === 'exited') {
|
|
1240
|
+
timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
|
|
1241
|
+
return c.json({ error: 'session-exited' }, 410);
|
|
1141
1242
|
}
|
|
1142
|
-
}
|
|
1143
|
-
catch (err) {
|
|
1144
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1145
|
-
deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
|
|
1146
1243
|
timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
|
|
1147
|
-
return c.json({ error: 'pty-write-failed', detail:
|
|
1244
|
+
return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
|
|
1148
1245
|
}
|
|
1149
|
-
deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel} multiline=${multiline} submit-mode=${submitMode}`);
|
|
1150
1246
|
timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
|
|
1151
|
-
return c.json({ ok: true, bytes }, 200);
|
|
1247
|
+
return c.json({ ok: true, bytes: result.bytes }, 200);
|
|
1152
1248
|
});
|
|
1153
1249
|
app.get('/:sessionId/log', (c) => {
|
|
1154
1250
|
const start = Date.now();
|
|
1155
1251
|
const id = c.req.param('sessionId');
|
|
1156
1252
|
const row = resolveRow(deps.watcher, id);
|
|
1157
1253
|
const sessionId = row?.sessionId ?? id;
|
|
1158
|
-
const
|
|
1159
|
-
// Task
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1254
|
+
const live = isLive(sessionId);
|
|
1255
|
+
// Task 260 — /log reads from whichever location holds the JSONL
|
|
1256
|
+
// right now: top level for live + recently-ended rows, archive
|
|
1257
|
+
// subdir for archived rows. 404 when there's no row AND no live
|
|
1258
|
+
// PTY for this id; 202 pending when a live PTY hasn't flushed
|
|
1259
|
+
// its first turn yet (no JSONL on disk in either location).
|
|
1260
|
+
if (!live && !row) {
|
|
1163
1261
|
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
1164
1262
|
return c.json({ error: 'session-not-found' }, 404);
|
|
1165
1263
|
}
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1264
|
+
const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1265
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
1266
|
+
const jsonlPath = existsSync(topJsonl) ? topJsonl : archivedJsonl;
|
|
1267
|
+
if (live && !existsSync(topJsonl) && !existsSync(archivedJsonl)) {
|
|
1168
1268
|
timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
|
|
1169
1269
|
return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
|
|
1170
1270
|
}
|
|
@@ -1238,7 +1338,12 @@ export function buildHttpApp(deps) {
|
|
|
1238
1338
|
}
|
|
1239
1339
|
position = initialSize;
|
|
1240
1340
|
}
|
|
1241
|
-
|
|
1341
|
+
// Task 260 — prefer the row's PID (watcher-derived) but fall back
|
|
1342
|
+
// to the live tracker if the row hasn't been populated yet (the
|
|
1343
|
+
// pre-first-turn window during which the watcher's PID-file event
|
|
1344
|
+
// has not fired against the row builder).
|
|
1345
|
+
const followPid = row?.pid ?? getPtyTrackerForTests(sessionId)?.pid ?? null;
|
|
1346
|
+
deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${followPid ?? 'null'}`);
|
|
1242
1347
|
let active = true;
|
|
1243
1348
|
out.onAbort(() => {
|
|
1244
1349
|
active = false;
|
|
@@ -1270,7 +1375,7 @@ export function buildHttpApp(deps) {
|
|
|
1270
1375
|
deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
|
|
1271
1376
|
});
|
|
1272
1377
|
});
|
|
1273
|
-
app.get('/healthz', (c) => c.json({ ok: true, sessions:
|
|
1378
|
+
app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
|
|
1274
1379
|
return app;
|
|
1275
1380
|
}
|
|
1276
1381
|
//# sourceMappingURL=http-server.js.map
|