@rubytech/create-maxy-code 0.1.90 → 0.1.92
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/index.js +31 -0
- package/dist/port-resolution.js +1 -1
- package/package.json +1 -1
- package/payload/platform/package-lock.json +18 -4
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +4 -3
- 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 +159 -219
- 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 +9 -0
- 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 +262 -67
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +10 -0
- 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 +8 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +11 -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.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +14 -6
- 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 +2 -0
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +6 -3
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- 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/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/make-brochure/SKILL.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
- package/payload/server/public/assets/admin-BhEvxMrC.js +217 -0
- package/payload/server/public/assets/{data-3n20lir5.js → data-Cbo0tTS3.js} +1 -1
- package/payload/server/public/assets/{graph-ChwMpxn_.js → graph-Beucu3_J.js} +1 -1
- package/payload/server/public/assets/graph-labels-D15-eFQK.js +1 -0
- package/payload/server/public/assets/{page-DNjUtT5q.js → page-Bu8kO4jO.js} +2 -2
- package/payload/server/public/assets/{page-D3bFmqF4.js → page-C1hj89Gf.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server-init.cjs +35 -3
- package/payload/server/server.js +33 -0
- package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
- package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
|
@@ -26,8 +26,9 @@ import { stream } from 'hono/streaming';
|
|
|
26
26
|
import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
|
|
27
27
|
import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
|
|
28
28
|
import { readJsonlSession } from './jsonl-enumerator.js';
|
|
29
|
-
import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
|
|
29
|
+
import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId } from './jsonl-path.js';
|
|
30
30
|
import { basename, join } from 'node:path';
|
|
31
|
+
import { validateUserTitle } from './user-title-store.js';
|
|
31
32
|
const ROLES = ['admin', 'public'];
|
|
32
33
|
const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
|
|
33
34
|
// Task 179 — resume idempotency. Two POSTs 9ms apart with the same
|
|
@@ -169,7 +170,8 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
|
|
|
169
170
|
if (session.specialist !== AUTO_ARCHIVE_SPECIALIST)
|
|
170
171
|
return;
|
|
171
172
|
let archived = false;
|
|
172
|
-
|
|
173
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, session.sessionId);
|
|
174
|
+
function endTurnSeen() {
|
|
173
175
|
try {
|
|
174
176
|
const content = readFileSync(jsonlPath, 'utf8');
|
|
175
177
|
return content.includes(END_TURN_SIGNATURE);
|
|
@@ -178,19 +180,18 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
|
|
|
178
180
|
return false;
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
|
-
function tryArchive(
|
|
183
|
+
function tryArchive() {
|
|
182
184
|
if (archived)
|
|
183
185
|
return;
|
|
184
186
|
if (!existsSync(jsonlPath))
|
|
185
187
|
return;
|
|
186
|
-
if (!endTurnSeen(
|
|
188
|
+
if (!endTurnSeen())
|
|
187
189
|
return;
|
|
188
190
|
archived = true;
|
|
189
191
|
deps.logger(`auto-archive sessionId=${session.sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
|
|
190
192
|
void stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, session.sessionId);
|
|
191
193
|
}
|
|
192
|
-
|
|
193
|
-
tryArchive(session.jsonlPath);
|
|
194
|
+
tryArchive();
|
|
194
195
|
const unsubscribe = deps.watcher.subscribe((event) => {
|
|
195
196
|
if (archived)
|
|
196
197
|
return;
|
|
@@ -198,20 +199,18 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
|
|
|
198
199
|
return;
|
|
199
200
|
if (event.source !== 'jsonl')
|
|
200
201
|
return;
|
|
201
|
-
|
|
202
|
-
if (!path)
|
|
203
|
-
return;
|
|
204
|
-
tryArchive(path);
|
|
202
|
+
tryArchive();
|
|
205
203
|
});
|
|
206
204
|
session.pty.onExit(() => unsubscribe());
|
|
207
205
|
}
|
|
208
206
|
function attachPublicAudit(deps, session) {
|
|
209
207
|
if (session.role !== 'public')
|
|
210
208
|
return;
|
|
211
|
-
if (session.jsonlPath === null)
|
|
212
|
-
return;
|
|
213
209
|
const claudeSessionId = session.sessionId;
|
|
214
|
-
deps.
|
|
210
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, claudeSessionId);
|
|
211
|
+
if (!existsSync(jsonlPath))
|
|
212
|
+
return;
|
|
213
|
+
deps.publicToolAudit.attach(claudeSessionId, jsonlPath);
|
|
215
214
|
if (session.exited) {
|
|
216
215
|
deps.publicToolAudit.detach(claudeSessionId);
|
|
217
216
|
return;
|
|
@@ -235,22 +234,29 @@ export function buildHttpApp(deps) {
|
|
|
235
234
|
listener(sessionId);
|
|
236
235
|
}
|
|
237
236
|
app.__notifyUrlCapturedForTests = notifyUrlCaptured;
|
|
238
|
-
//
|
|
239
|
-
//
|
|
237
|
+
// Task 253 — same fan-out shape for rename. The POST /:sessionId/rename
|
|
238
|
+
// route writes the user-title store and then calls notifyRename; each
|
|
239
|
+
// /events handler then re-emits a `row-updated` frame so every browser
|
|
240
|
+
// tab sees the new label without re-fetching /list.
|
|
241
|
+
const renameListeners = new Set();
|
|
242
|
+
function notifyRename(sessionId) {
|
|
243
|
+
for (const listener of renameListeners)
|
|
244
|
+
listener(sessionId);
|
|
245
|
+
}
|
|
246
|
+
app.__notifyRenameForTests = notifyRename;
|
|
247
|
+
// Watcher subscriber: when the JSONL CREATE event fires for a live
|
|
248
|
+
// session, attach the public-audit watcher. The path is derived
|
|
249
|
+
// (Task 258 — StoredSession no longer carries jsonlPath).
|
|
240
250
|
deps.watcher.subscribe((event) => {
|
|
241
251
|
if (event.source !== 'jsonl')
|
|
242
252
|
return;
|
|
243
253
|
if (event.kind !== 'create' && event.kind !== 'modify')
|
|
244
254
|
return;
|
|
245
|
-
if (!event.row
|
|
255
|
+
if (!event.row)
|
|
246
256
|
return;
|
|
247
257
|
const live = deps.store.get(event.sessionId);
|
|
248
258
|
if (!live)
|
|
249
259
|
return;
|
|
250
|
-
if (live.jsonlPath !== null)
|
|
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
260
|
attachPublicAudit(deps, live);
|
|
255
261
|
});
|
|
256
262
|
// Task 183 — dispatch jsonl modify/create events into the recorder
|
|
@@ -258,7 +264,8 @@ export function buildHttpApp(deps) {
|
|
|
258
264
|
// database-operator spawns are registered), so operator turns pass
|
|
259
265
|
// through without producing recorder-* lines. The tail's append-only
|
|
260
266
|
// cursor ensures repeated modify events for the same JSONL only read
|
|
261
|
-
// newly-appended bytes.
|
|
267
|
+
// newly-appended bytes. Path is derived from sessionId + spawnCwd
|
|
268
|
+
// (Task 258).
|
|
262
269
|
if (deps.jsonlTail) {
|
|
263
270
|
const tail = deps.jsonlTail;
|
|
264
271
|
deps.watcher.subscribe((event) => {
|
|
@@ -266,22 +273,48 @@ export function buildHttpApp(deps) {
|
|
|
266
273
|
return;
|
|
267
274
|
if (event.kind !== 'create' && event.kind !== 'modify')
|
|
268
275
|
return;
|
|
269
|
-
const path = event.
|
|
270
|
-
if (!path)
|
|
276
|
+
const path = jsonlPathForSessionId(deps.spawnCwd, event.sessionId);
|
|
277
|
+
if (!existsSync(path))
|
|
271
278
|
return;
|
|
272
279
|
tail.onJsonlModify(event.sessionId, path);
|
|
273
280
|
});
|
|
274
281
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
/** Task 253 — resolve (displayName, titleSource) for a live PTY.
|
|
283
|
+
* Three cases:
|
|
284
|
+
* - JSONL exists: read the enumerator's merged result (the enumerator
|
|
285
|
+
* consults the user-title store; one resolution site for both layers).
|
|
286
|
+
* - JSONL missing but user-title-store has an entry: pre-jsonl rename —
|
|
287
|
+
* operator renamed a brand-new row before the first turn flushed.
|
|
288
|
+
* - Neither: null + null. */
|
|
289
|
+
function liveTitleAndSource(s) {
|
|
290
|
+
const path = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
|
|
291
|
+
if (existsSync(path)) {
|
|
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 };
|
|
280
295
|
}
|
|
281
|
-
|
|
296
|
+
const userTitle = deps.userTitleStore.get(s.sessionId)?.title;
|
|
297
|
+
if (userTitle)
|
|
298
|
+
return { displayName: userTitle, titleSource: 'user' };
|
|
299
|
+
return { displayName: null, titleSource: null };
|
|
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 }));
|
|
282
313
|
}
|
|
283
314
|
function toLivePayload(s) {
|
|
284
|
-
const
|
|
315
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
|
|
316
|
+
const hasJsonl = existsSync(jsonlPath);
|
|
317
|
+
const title = liveTitleAndSource(s);
|
|
285
318
|
return {
|
|
286
319
|
sessionId: s.sessionId,
|
|
287
320
|
pid: s.pid,
|
|
@@ -290,13 +323,15 @@ export function buildHttpApp(deps) {
|
|
|
290
323
|
senderId: s.senderId,
|
|
291
324
|
role: s.role,
|
|
292
325
|
channel: s.channel,
|
|
293
|
-
jsonlPath:
|
|
326
|
+
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
294
327
|
status: 'alive',
|
|
295
|
-
|
|
328
|
+
archived: false,
|
|
329
|
+
displayName: title.displayName,
|
|
330
|
+
titleSource: title.titleSource,
|
|
296
331
|
lastMessageAt: null,
|
|
297
332
|
turnCount: 0,
|
|
298
333
|
capped: false,
|
|
299
|
-
sizeBytes: hasJsonl
|
|
334
|
+
sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
|
|
300
335
|
hidden: s.hidden,
|
|
301
336
|
specialist: s.specialist,
|
|
302
337
|
model: null,
|
|
@@ -304,16 +339,20 @@ export function buildHttpApp(deps) {
|
|
|
304
339
|
}
|
|
305
340
|
function toArchivedPayload(row, senderId) {
|
|
306
341
|
let displayName = null;
|
|
342
|
+
let titleSource = null;
|
|
307
343
|
let lastMessageAt = null;
|
|
308
344
|
let turnCount = 0;
|
|
309
345
|
let capped = false;
|
|
310
346
|
let sizeBytes = null;
|
|
311
347
|
let model = null;
|
|
312
348
|
let startedAt = new Date(row.updatedAt || 0).toISOString();
|
|
313
|
-
|
|
314
|
-
|
|
349
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
350
|
+
const hasJsonl = existsSync(jsonlPath);
|
|
351
|
+
if (hasJsonl) {
|
|
352
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
315
353
|
if (enriched) {
|
|
316
354
|
displayName = enriched.displayName;
|
|
355
|
+
titleSource = enriched.titleSource;
|
|
317
356
|
lastMessageAt = enriched.lastMessageAt;
|
|
318
357
|
turnCount = enriched.turnCount;
|
|
319
358
|
capped = enriched.capped;
|
|
@@ -322,6 +361,15 @@ export function buildHttpApp(deps) {
|
|
|
322
361
|
model = enriched.model;
|
|
323
362
|
}
|
|
324
363
|
}
|
|
364
|
+
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
|
+
const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
|
|
368
|
+
if (userTitle) {
|
|
369
|
+
displayName = userTitle;
|
|
370
|
+
titleSource = 'user';
|
|
371
|
+
}
|
|
372
|
+
}
|
|
325
373
|
return {
|
|
326
374
|
sessionId: row.sessionId,
|
|
327
375
|
pid: null,
|
|
@@ -330,9 +378,11 @@ export function buildHttpApp(deps) {
|
|
|
330
378
|
senderId,
|
|
331
379
|
role: null,
|
|
332
380
|
channel: null,
|
|
333
|
-
jsonlPath:
|
|
381
|
+
jsonlPath: hasJsonl ? jsonlPath : null,
|
|
334
382
|
status: 'ended',
|
|
383
|
+
archived: isArchivedJsonlPath(row.sessionId),
|
|
335
384
|
displayName,
|
|
385
|
+
titleSource,
|
|
336
386
|
lastMessageAt,
|
|
337
387
|
turnCount,
|
|
338
388
|
capped,
|
|
@@ -449,7 +499,8 @@ export function buildHttpApp(deps) {
|
|
|
449
499
|
// `row-<created|updated|archived|removed>`.
|
|
450
500
|
//
|
|
451
501
|
// Payload shape per event (`data: <json>`):
|
|
452
|
-
// { sessionId, live, status, agentLabel, cwd, firstPrompt,
|
|
502
|
+
// { sessionId, live, status, agentLabel, cwd, firstPrompt, titleSource,
|
|
503
|
+
// updatedAt, url, archived }
|
|
453
504
|
// Task 141: `sessionId` carries whichever phase is current (bridge
|
|
454
505
|
// suffix preferred, JSONL basename fallback) — same single-id contract
|
|
455
506
|
// as `/meta`.
|
|
@@ -474,10 +525,22 @@ export function buildHttpApp(deps) {
|
|
|
474
525
|
function rowToPayload(row) {
|
|
475
526
|
const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
|
|
476
527
|
let firstPrompt = null;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
528
|
+
let titleSource = null;
|
|
529
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
|
|
530
|
+
if (existsSync(jsonlPath)) {
|
|
531
|
+
const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
|
|
532
|
+
if (enriched) {
|
|
480
533
|
firstPrompt = enriched.displayName;
|
|
534
|
+
titleSource = enriched.titleSource;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
// Pre-jsonl / post-purge: only the user-title store can answer.
|
|
539
|
+
const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
|
|
540
|
+
if (userTitle) {
|
|
541
|
+
firstPrompt = userTitle;
|
|
542
|
+
titleSource = 'user';
|
|
543
|
+
}
|
|
481
544
|
}
|
|
482
545
|
return {
|
|
483
546
|
sessionId: row.sessionId,
|
|
@@ -486,8 +549,12 @@ export function buildHttpApp(deps) {
|
|
|
486
549
|
agentLabel,
|
|
487
550
|
cwd: row.cwd,
|
|
488
551
|
firstPrompt,
|
|
552
|
+
titleSource,
|
|
489
553
|
updatedAt: row.updatedAt,
|
|
490
554
|
url: deps.store.get(row.sessionId)?.url ?? null,
|
|
555
|
+
// Task 259 — UI uses this to render Unarchive (vs Resume + Archive)
|
|
556
|
+
// on rows whose JSONL has been moved to projects/<slug>/archive/.
|
|
557
|
+
archived: isArchivedJsonlPath(row.sessionId),
|
|
491
558
|
};
|
|
492
559
|
}
|
|
493
560
|
timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
|
|
@@ -581,6 +648,24 @@ export function buildHttpApp(deps) {
|
|
|
581
648
|
void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
582
649
|
};
|
|
583
650
|
urlUpdateListeners.add(urlListener);
|
|
651
|
+
// Task 253 — rename re-emit. The POST /:sessionId/rename handler
|
|
652
|
+
// mutates the user-title store and then calls notifyRename(); each
|
|
653
|
+
// /events handler re-emits the row so every browser tab sees the new
|
|
654
|
+
// label without re-fetching /list. The row lookup goes through the
|
|
655
|
+
// watcher (canonical row index); rowToPayload then re-reads the user
|
|
656
|
+
// title from `deps.userTitleStore`, picking up the freshly-set value.
|
|
657
|
+
const renameListener = (sessionId) => {
|
|
658
|
+
if (!active)
|
|
659
|
+
return;
|
|
660
|
+
const row = deps.watcher.getBySessionId(sessionId);
|
|
661
|
+
if (!row)
|
|
662
|
+
return;
|
|
663
|
+
const payload = rowToPayload(row);
|
|
664
|
+
subscribersCount += 1;
|
|
665
|
+
deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} reason=rename subscribers=1`);
|
|
666
|
+
void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
667
|
+
};
|
|
668
|
+
renameListeners.add(renameListener);
|
|
584
669
|
// Keep-alive comment every 25 s. Proxies aggressively close idle
|
|
585
670
|
// text/event-stream connections at 30–60 s; a comment line is
|
|
586
671
|
// ignored by EventSource consumers but resets the proxy clock.
|
|
@@ -594,6 +679,7 @@ export function buildHttpApp(deps) {
|
|
|
594
679
|
clearInterval(keepAlive);
|
|
595
680
|
unsubscribe();
|
|
596
681
|
urlUpdateListeners.delete(urlListener);
|
|
682
|
+
renameListeners.delete(renameListener);
|
|
597
683
|
const durationMs = Date.now() - connectStart;
|
|
598
684
|
deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
|
|
599
685
|
});
|
|
@@ -627,7 +713,9 @@ export function buildHttpApp(deps) {
|
|
|
627
713
|
continue;
|
|
628
714
|
if (liveSessionIds.has(row.sessionId))
|
|
629
715
|
continue;
|
|
630
|
-
|
|
716
|
+
// Task 258 — confirm the jsonl is actually on disk; row state can lag
|
|
717
|
+
// behind a delete during boot-seed timing edges.
|
|
718
|
+
if (!existsSync(jsonlPathForSessionId(deps.spawnCwd, row.sessionId)))
|
|
631
719
|
continue;
|
|
632
720
|
dehydrated.push(toArchivedPayload(row, senderId));
|
|
633
721
|
}
|
|
@@ -701,6 +789,54 @@ export function buildHttpApp(deps) {
|
|
|
701
789
|
: undefined;
|
|
702
790
|
const sourceRow = resolveRow(deps.watcher, sessionId);
|
|
703
791
|
const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
|
|
792
|
+
// Task 258 — restore the archived jsonl to its canonical top-level path
|
|
793
|
+
// before claude --resume runs. Claude appends to `<projectsDir>/<sid>.jsonl`;
|
|
794
|
+
// if the file lives in `archive/`, claude doesn't find it and starts a fresh
|
|
795
|
+
// empty transcript at the top-level path, silently splitting the conversation.
|
|
796
|
+
const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
797
|
+
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
798
|
+
const canonicalExists = existsSync(canonicalJsonl);
|
|
799
|
+
const archivedExists = existsSync(archivedJsonl);
|
|
800
|
+
if (archivedExists && !canonicalExists) {
|
|
801
|
+
try {
|
|
802
|
+
renameSync(archivedJsonl, canonicalJsonl);
|
|
803
|
+
deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
807
|
+
deps.logger(`resume-archive-restore-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
808
|
+
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
809
|
+
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
else if (archivedExists && canonicalExists) {
|
|
813
|
+
// Both files exist — pre-Task-258 bug already produced two parallel
|
|
814
|
+
// jsonls for one sessionId. Keep the larger as canonical (more history);
|
|
815
|
+
// park the smaller at `archive/<sid>.<ts>.jsonl`. No data loss.
|
|
816
|
+
try {
|
|
817
|
+
const canonicalSize = statSync(canonicalJsonl).size;
|
|
818
|
+
const archivedSize = statSync(archivedJsonl).size;
|
|
819
|
+
const keepArchived = archivedSize > canonicalSize;
|
|
820
|
+
const ts = Date.now();
|
|
821
|
+
if (keepArchived) {
|
|
822
|
+
const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
|
|
823
|
+
renameSync(canonicalJsonl, stash);
|
|
824
|
+
renameSync(archivedJsonl, canonicalJsonl);
|
|
825
|
+
deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-archived canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
|
|
829
|
+
renameSync(archivedJsonl, stash);
|
|
830
|
+
deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
835
|
+
deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
836
|
+
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
837
|
+
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
704
840
|
const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
|
|
705
841
|
senderId,
|
|
706
842
|
userId,
|
|
@@ -729,8 +865,13 @@ export function buildHttpApp(deps) {
|
|
|
729
865
|
if (specialist) {
|
|
730
866
|
deps.logger(`resume-specialist-propagated source=${sessionId} specialist=${specialist}`);
|
|
731
867
|
}
|
|
732
|
-
|
|
733
|
-
|
|
868
|
+
// Task 258 — the archive-restore-before-spawn flow means the operator's
|
|
869
|
+
// prior canonical sessionId IS the jsonl basename claude appends to.
|
|
870
|
+
// The pty-spawner's StoredSession.sessionId is set to that same prior
|
|
871
|
+
// canonical id (resumeClaudeSessionId), so the wire payload echoes it
|
|
872
|
+
// directly. No mint divergence to log: there's only one sessionId.
|
|
873
|
+
deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
|
|
874
|
+
const payload = { ...toLivePayload(result.session), sessionId };
|
|
734
875
|
if (idempotencyKey) {
|
|
735
876
|
resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
|
|
736
877
|
}
|
|
@@ -758,7 +899,8 @@ export function buildHttpApp(deps) {
|
|
|
758
899
|
let capped = false;
|
|
759
900
|
let sizeBytes = base.sizeBytes;
|
|
760
901
|
let model = null;
|
|
761
|
-
|
|
902
|
+
const liveJsonlPath = jsonlPathForSessionId(deps.spawnCwd, live.sessionId);
|
|
903
|
+
if (existsSync(liveJsonlPath)) {
|
|
762
904
|
const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
|
|
763
905
|
if (enriched) {
|
|
764
906
|
lastMessageAt = enriched.lastMessageAt;
|
|
@@ -802,15 +944,26 @@ export function buildHttpApp(deps) {
|
|
|
802
944
|
timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
|
|
803
945
|
return c.json({ error: 'session-not-found' }, 404);
|
|
804
946
|
}
|
|
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
947
|
const slug = projectSlugForCwd(deps.spawnCwd);
|
|
811
948
|
const slugDir = join(claudeStateRoot(), 'projects', slug);
|
|
812
949
|
const archiveDir = join(slugDir, 'archive');
|
|
813
950
|
const fileName = `${row.sessionId}.jsonl`;
|
|
951
|
+
// Task 258 — refuse archive whenever any alive PTY would append to the
|
|
952
|
+
// canonical jsonl for this row. After the derived-state refactor the
|
|
953
|
+
// path is computed from sessionId + spawnCwd: every StoredSession has
|
|
954
|
+
// exactly one effective path, and the live writer is the StoredSession
|
|
955
|
+
// whose path equals the candidate.
|
|
956
|
+
const candidateJsonl = join(slugDir, fileName);
|
|
957
|
+
const aliveOnPath = deps.store.all().find((s) => {
|
|
958
|
+
if (s.exited)
|
|
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)}`);
|
|
964
|
+
timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
|
|
965
|
+
return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
|
|
966
|
+
}
|
|
814
967
|
const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
|
|
815
968
|
const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
|
|
816
969
|
if (!existsSync(fromJsonl)) {
|
|
@@ -832,6 +985,49 @@ export function buildHttpApp(deps) {
|
|
|
832
985
|
timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
|
|
833
986
|
return c.json({ ok: true, mode }, 200);
|
|
834
987
|
});
|
|
988
|
+
// Task 253 — POST /:sessionId/rename { title }
|
|
989
|
+
// - 200 + body on accept (carries the persisted title)
|
|
990
|
+
// - 400 on validator reject (empty / too-long / control-chars)
|
|
991
|
+
// - 404 when no row matches any of the four ids
|
|
992
|
+
// After accepting, the user-title store persists and notifyRename()
|
|
993
|
+
// fans a row-updated frame to every connected /events client so the
|
|
994
|
+
// sidebar refreshes without a /list re-fetch.
|
|
995
|
+
app.post('/:sessionId/rename', async (c) => {
|
|
996
|
+
const start = Date.now();
|
|
997
|
+
const id = c.req.param('sessionId');
|
|
998
|
+
if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
|
|
999
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1000
|
+
return c.json({ error: 'invalid-session-id' }, 400);
|
|
1001
|
+
}
|
|
1002
|
+
let body;
|
|
1003
|
+
try {
|
|
1004
|
+
body = await c.req.json();
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1008
|
+
return c.json({ error: 'invalid-json-body' }, 400);
|
|
1009
|
+
}
|
|
1010
|
+
const row = resolveRow(deps.watcher, id);
|
|
1011
|
+
const sessionId = row?.sessionId ?? (deps.store.get(id)?.sessionId);
|
|
1012
|
+
if (!sessionId) {
|
|
1013
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
|
|
1014
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
1015
|
+
}
|
|
1016
|
+
const result = validateUserTitle(body.title);
|
|
1017
|
+
if (!result.ok) {
|
|
1018
|
+
deps.logger(`rename-rejected sessionId=${sessionId.slice(0, 8)} reason=${result.reason}`);
|
|
1019
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
|
|
1020
|
+
return c.json({ error: 'invalid-title', reason: result.reason }, 400);
|
|
1021
|
+
}
|
|
1022
|
+
const entry = deps.userTitleStore.set(sessionId, result.title);
|
|
1023
|
+
// Title content is never logged — operator names may contain customer
|
|
1024
|
+
// data. Byte count is enough for forensic recovery of bulk-rename surges.
|
|
1025
|
+
const titleBytes = Buffer.byteLength(entry.title, 'utf-8');
|
|
1026
|
+
deps.logger(`rename sessionId=${sessionId.slice(0, 8)} title-bytes=${titleBytes} source=manager-route`);
|
|
1027
|
+
notifyRename(sessionId);
|
|
1028
|
+
timed(deps.logger, 'POST', `/${id}/rename`, 200, Date.now() - start);
|
|
1029
|
+
return c.json({ ok: true, sessionId, titleSource: 'user', renamedAt: entry.renamedAt }, 200);
|
|
1030
|
+
});
|
|
835
1031
|
// SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
|
|
836
1032
|
app.post('/:sessionId/stop', async (c) => {
|
|
837
1033
|
const start = Date.now();
|
|
@@ -865,21 +1061,22 @@ export function buildHttpApp(deps) {
|
|
|
865
1061
|
timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
|
|
866
1062
|
return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
|
|
867
1063
|
}
|
|
868
|
-
|
|
1064
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1065
|
+
if (!row || !existsSync(jsonlPath)) {
|
|
869
1066
|
timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
|
|
870
1067
|
return c.json({ error: 'session-not-found' }, 404);
|
|
871
1068
|
}
|
|
872
1069
|
const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
|
|
873
1070
|
let bytes = 0;
|
|
874
1071
|
try {
|
|
875
|
-
bytes = existsSync(
|
|
1072
|
+
bytes = existsSync(jsonlPath) ? statSync(jsonlPath).size : 0;
|
|
876
1073
|
}
|
|
877
1074
|
catch {
|
|
878
1075
|
// size is informational
|
|
879
1076
|
}
|
|
880
1077
|
try {
|
|
881
|
-
if (existsSync(
|
|
882
|
-
rmSync(
|
|
1078
|
+
if (existsSync(jsonlPath))
|
|
1079
|
+
rmSync(jsonlPath);
|
|
883
1080
|
if (existsSync(subdir))
|
|
884
1081
|
rmSync(subdir, { recursive: true, force: true });
|
|
885
1082
|
}
|
|
@@ -889,6 +1086,11 @@ export function buildHttpApp(deps) {
|
|
|
889
1086
|
timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
|
|
890
1087
|
return c.json({ error: 'delete-failed', detail: msg }, 500);
|
|
891
1088
|
}
|
|
1089
|
+
// Task 253 — drop the user-title entry for the purged session so the
|
|
1090
|
+
// session-titles.json doesn't accrete orphans. Best-effort; the store
|
|
1091
|
+
// logs its own failure and reads still serve from the in-memory map
|
|
1092
|
+
// until next boot.
|
|
1093
|
+
deps.userTitleStore.remove(sessionId);
|
|
892
1094
|
deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
|
|
893
1095
|
timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
|
|
894
1096
|
return c.body(null, 204);
|
|
@@ -954,26 +1156,19 @@ export function buildHttpApp(deps) {
|
|
|
954
1156
|
const row = resolveRow(deps.watcher, id);
|
|
955
1157
|
const sessionId = row?.sessionId ?? id;
|
|
956
1158
|
const s = deps.store.get(sessionId);
|
|
957
|
-
// Task
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
// the existing fallback shape at /meta, /stop, and DELETE /:sessionId.
|
|
962
|
-
// 404 only when neither the runtime store nor the watcher row can offer a
|
|
963
|
-
// path to a transcript.
|
|
964
|
-
if (!s && !row?.jsonlPath) {
|
|
1159
|
+
// Task 258 — /log reads the transcript at the derived canonical path.
|
|
1160
|
+
// 404 when there's no row AND no live session at this id; 202 pending
|
|
1161
|
+
// when a live PTY hasn't flushed its first turn yet (no jsonl on disk).
|
|
1162
|
+
if (!s && !row) {
|
|
965
1163
|
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
966
1164
|
return c.json({ error: 'session-not-found' }, 404);
|
|
967
1165
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
// proves the file landed, so the read can proceed against disk.
|
|
971
|
-
if (s && s.jsonlPath === null && !row?.jsonlPath) {
|
|
1166
|
+
const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
1167
|
+
if (s && !existsSync(jsonlPath)) {
|
|
972
1168
|
timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
|
|
973
1169
|
return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
|
|
974
1170
|
}
|
|
975
|
-
|
|
976
|
-
if (!jsonlPath) {
|
|
1171
|
+
if (!existsSync(jsonlPath)) {
|
|
977
1172
|
timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
|
|
978
1173
|
return c.json({ error: 'session-not-found' }, 404);
|
|
979
1174
|
}
|