@cjhyy/code-shell-core 0.8.1 → 0.8.3
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/engine/engine.js +17 -10
- package/dist/engine/run-tooling.js +4 -0
- package/dist/engine/turn-loop.d.ts +3 -0
- package/dist/engine/turn-loop.js +80 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/llm/providers/openai.js +49 -2
- package/dist/protocol/chat-session-manager.js +9 -0
- package/dist/run/EngineRunner.js +5 -4
- package/dist/session/session-manager.d.ts +12 -13
- package/dist/session/session-manager.js +214 -28
- package/dist/session/transcript.d.ts +20 -4
- package/dist/session/transcript.js +97 -36
- package/dist/settings/schema.d.ts +99 -0
- package/dist/settings/schema.js +13 -0
- package/dist/tool-system/builtin/tool-search.js +44 -2
- package/dist/tool-system/context.d.ts +14 -1
- package/dist/tool-system/external-tool-exposure.js +50 -26
- package/dist/tool-system/session-tool-host.d.ts +1 -1
- package/dist/tool-system/session-tool-host.js +31 -3
- package/dist/types.d.ts +7 -3
- package/package.json +1 -1
|
@@ -142,6 +142,12 @@ function adoptCompatibilityGoalMutation(state) {
|
|
|
142
142
|
state.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
// Quick Chat sessions are intentionally process-local. SessionManager
|
|
146
|
+
// instances are created per Engine, so this registry lives at module scope to
|
|
147
|
+
// let a fork created by one Engine be resumed by another without touching
|
|
148
|
+
// disk. The storage root remains part of the key to preserve identity/data-root
|
|
149
|
+
// isolation.
|
|
150
|
+
const processLocalSessionBundles = new Map();
|
|
145
151
|
const FORK_COPY_EVENT_TYPES = new Set([
|
|
146
152
|
"message",
|
|
147
153
|
"tool_use",
|
|
@@ -292,6 +298,26 @@ export class SessionManager {
|
|
|
292
298
|
mkdirSync(this.sessionsDir, { recursive: true });
|
|
293
299
|
this.cleanupStaleForkStaging();
|
|
294
300
|
}
|
|
301
|
+
processLocalKey(sessionId) {
|
|
302
|
+
return `${this.sessionsDir}\0${sessionId}`;
|
|
303
|
+
}
|
|
304
|
+
processLocalBundle(sessionId) {
|
|
305
|
+
return processLocalSessionBundles.get(this.processLocalKey(sessionId));
|
|
306
|
+
}
|
|
307
|
+
storeProcessLocalBundle(bundle) {
|
|
308
|
+
processLocalSessionBundles.set(this.processLocalKey(bundle.state.sessionId), {
|
|
309
|
+
state: structuredClone(bundle.state),
|
|
310
|
+
transcript: bundle.transcript,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Forget a Quick Chat/side-chat bundle immediately; nothing remains on disk. */
|
|
314
|
+
forgetEphemeralSession(sessionId) {
|
|
315
|
+
assertSafeSessionId(sessionId);
|
|
316
|
+
const bundle = this.processLocalBundle(sessionId);
|
|
317
|
+
if (!bundle || !isEphemeralSessionState(bundle.state))
|
|
318
|
+
return false;
|
|
319
|
+
return processLocalSessionBundles.delete(this.processLocalKey(sessionId));
|
|
320
|
+
}
|
|
295
321
|
cleanupStaleForkStaging() {
|
|
296
322
|
let removed = 0;
|
|
297
323
|
let entries;
|
|
@@ -336,11 +362,8 @@ export class SessionManager {
|
|
|
336
362
|
return next;
|
|
337
363
|
}
|
|
338
364
|
/**
|
|
339
|
-
* Create a
|
|
340
|
-
*
|
|
341
|
-
* "tui-main" and expect us to honor it). Otherwise generate one with
|
|
342
|
-
* nanoid. Either way the on-disk directory is materialized and the
|
|
343
|
-
* state.json + transcript.jsonl files are written before return.
|
|
365
|
+
* Create a session. `qchat-` sessions stay process-local; ordinary sessions
|
|
366
|
+
* materialize state.json + transcript.jsonl before return.
|
|
344
367
|
*/
|
|
345
368
|
create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
|
|
346
369
|
// External callers may pass any string; nanoid output is trusted. Either
|
|
@@ -349,16 +372,6 @@ export class SessionManager {
|
|
|
349
372
|
if (explicitSessionId !== undefined)
|
|
350
373
|
assertSafeSessionId(explicitSessionId);
|
|
351
374
|
const sessionId = explicitSessionId ?? nanoid(16);
|
|
352
|
-
const sessionDir = join(this.sessionsDir, sessionId);
|
|
353
|
-
try {
|
|
354
|
-
mkdirSync(sessionDir);
|
|
355
|
-
}
|
|
356
|
-
catch (err) {
|
|
357
|
-
if (err.code === "EEXIST") {
|
|
358
|
-
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
359
|
-
}
|
|
360
|
-
throw err;
|
|
361
|
-
}
|
|
362
375
|
const state = {
|
|
363
376
|
sessionId,
|
|
364
377
|
kind,
|
|
@@ -383,6 +396,33 @@ export class SessionManager {
|
|
|
383
396
|
...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
|
|
384
397
|
...(origin ? { origin } : {}),
|
|
385
398
|
};
|
|
399
|
+
if (isEphemeralSessionState(state)) {
|
|
400
|
+
if (this.processLocalBundle(sessionId)) {
|
|
401
|
+
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
402
|
+
}
|
|
403
|
+
const transcript = Transcript.inMemory(sessionId);
|
|
404
|
+
transcript.append("session_meta", {
|
|
405
|
+
sessionId,
|
|
406
|
+
cwd,
|
|
407
|
+
model,
|
|
408
|
+
provider,
|
|
409
|
+
startedAt: state.startedAt,
|
|
410
|
+
kind,
|
|
411
|
+
});
|
|
412
|
+
const bundle = { state, transcript };
|
|
413
|
+
this.storeProcessLocalBundle(bundle);
|
|
414
|
+
return bundle;
|
|
415
|
+
}
|
|
416
|
+
const sessionDir = join(this.sessionsDir, sessionId);
|
|
417
|
+
try {
|
|
418
|
+
mkdirSync(sessionDir);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err.code === "EEXIST") {
|
|
422
|
+
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
423
|
+
}
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
386
426
|
// Atomic write (tmp+rename) like saveState, so a crash during this one-time
|
|
387
427
|
// create can't leave a torn state.json that resume() then fails to parse.
|
|
388
428
|
const stateTarget = join(sessionDir, "state.json");
|
|
@@ -400,11 +440,7 @@ export class SessionManager {
|
|
|
400
440
|
});
|
|
401
441
|
return { state, transcript };
|
|
402
442
|
}
|
|
403
|
-
/**
|
|
404
|
-
* Whether a session directory exists on disk. Used by ChatSession-driven
|
|
405
|
-
* cold starts to decide between resume vs create-with-explicit-sid
|
|
406
|
-
* without catching SessionError.
|
|
407
|
-
*/
|
|
443
|
+
/** Whether a persisted or process-local session exists. */
|
|
408
444
|
exists(sessionId) {
|
|
409
445
|
// exists() is a probe — callers use it to decide between resume and
|
|
410
446
|
// create-with-explicit-sid. Treat an invalid id as "not present"
|
|
@@ -415,6 +451,12 @@ export class SessionManager {
|
|
|
415
451
|
catch {
|
|
416
452
|
return false;
|
|
417
453
|
}
|
|
454
|
+
if (this.processLocalBundle(sessionId))
|
|
455
|
+
return true;
|
|
456
|
+
// Never resurrect a legacy Quick Chat directory after the process-local
|
|
457
|
+
// record has expired.
|
|
458
|
+
if (sessionId.startsWith("qchat-"))
|
|
459
|
+
return false;
|
|
418
460
|
return existsSync(join(this.sessionsDir, sessionId));
|
|
419
461
|
}
|
|
420
462
|
/**
|
|
@@ -430,6 +472,11 @@ export class SessionManager {
|
|
|
430
472
|
catch {
|
|
431
473
|
return undefined;
|
|
432
474
|
}
|
|
475
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
476
|
+
if (processLocal)
|
|
477
|
+
return sessionMainRoot(processLocal.state);
|
|
478
|
+
if (sessionId.startsWith("qchat-"))
|
|
479
|
+
return undefined;
|
|
433
480
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
434
481
|
if (!existsSync(stateFile))
|
|
435
482
|
return undefined;
|
|
@@ -449,6 +496,11 @@ export class SessionManager {
|
|
|
449
496
|
catch {
|
|
450
497
|
return undefined;
|
|
451
498
|
}
|
|
499
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
500
|
+
if (processLocal)
|
|
501
|
+
return normalizedSessionKind(processLocal.state.kind);
|
|
502
|
+
if (sessionId.startsWith("qchat-"))
|
|
503
|
+
return undefined;
|
|
452
504
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
453
505
|
if (!existsSync(stateFile))
|
|
454
506
|
return undefined;
|
|
@@ -464,6 +516,13 @@ export class SessionManager {
|
|
|
464
516
|
readSessionWorkspaceProfile(sessionId) {
|
|
465
517
|
try {
|
|
466
518
|
assertSafeSessionId(sessionId);
|
|
519
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
520
|
+
if (processLocal) {
|
|
521
|
+
const profile = processLocal.state.workspaceProfile;
|
|
522
|
+
return typeof profile === "string" && profile ? profile : undefined;
|
|
523
|
+
}
|
|
524
|
+
if (sessionId.startsWith("qchat-"))
|
|
525
|
+
return undefined;
|
|
467
526
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
468
527
|
if (!existsSync(stateFile))
|
|
469
528
|
return undefined;
|
|
@@ -534,7 +593,7 @@ export class SessionManager {
|
|
|
534
593
|
readCwd(sessionId) {
|
|
535
594
|
return this.readSessionMainRoot(sessionId);
|
|
536
595
|
}
|
|
537
|
-
/**
|
|
596
|
+
/** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
|
|
538
597
|
readParentSessionId(sessionId) {
|
|
539
598
|
try {
|
|
540
599
|
assertSafeSessionId(sessionId);
|
|
@@ -542,6 +601,13 @@ export class SessionManager {
|
|
|
542
601
|
catch {
|
|
543
602
|
return undefined;
|
|
544
603
|
}
|
|
604
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
605
|
+
if (processLocal) {
|
|
606
|
+
const parent = processLocal.state.parentSessionId;
|
|
607
|
+
return parent === null || typeof parent === "string" ? parent : undefined;
|
|
608
|
+
}
|
|
609
|
+
if (sessionId.startsWith("qchat-"))
|
|
610
|
+
return undefined;
|
|
545
611
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
546
612
|
if (!existsSync(stateFile))
|
|
547
613
|
return undefined;
|
|
@@ -556,7 +622,7 @@ export class SessionManager {
|
|
|
556
622
|
}
|
|
557
623
|
}
|
|
558
624
|
/**
|
|
559
|
-
*
|
|
625
|
+
* Workspace pointer reader. Legacy sessions written before
|
|
560
626
|
* `workspace` existed are treated as main-workspace sessions rooted at
|
|
561
627
|
* `state.cwd`; the read is intentionally non-mutating.
|
|
562
628
|
*/
|
|
@@ -567,6 +633,16 @@ export class SessionManager {
|
|
|
567
633
|
catch {
|
|
568
634
|
return undefined;
|
|
569
635
|
}
|
|
636
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
637
|
+
if (processLocal) {
|
|
638
|
+
const state = processLocal.state;
|
|
639
|
+
if (isSessionWorkspace(state.workspace))
|
|
640
|
+
return structuredClone(state.workspace);
|
|
641
|
+
const mainRoot = sessionMainRoot(state);
|
|
642
|
+
return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
|
|
643
|
+
}
|
|
644
|
+
if (sessionId.startsWith("qchat-"))
|
|
645
|
+
return undefined;
|
|
570
646
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
571
647
|
if (!existsSync(stateFile))
|
|
572
648
|
return undefined;
|
|
@@ -601,6 +677,11 @@ export class SessionManager {
|
|
|
601
677
|
catch {
|
|
602
678
|
return undefined;
|
|
603
679
|
}
|
|
680
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
681
|
+
if (processLocal)
|
|
682
|
+
return processLocal.state.archivedAt;
|
|
683
|
+
if (sessionId.startsWith("qchat-"))
|
|
684
|
+
return undefined;
|
|
604
685
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
605
686
|
if (!existsSync(stateFile))
|
|
606
687
|
return undefined;
|
|
@@ -618,6 +699,19 @@ export class SessionManager {
|
|
|
618
699
|
}
|
|
619
700
|
recordWorkspaceHandoff(sessionId, from, to) {
|
|
620
701
|
assertSafeSessionId(sessionId);
|
|
702
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
703
|
+
if (processLocal) {
|
|
704
|
+
processLocal.transcript.append("session_meta", {
|
|
705
|
+
sessionId,
|
|
706
|
+
cwd: to.root,
|
|
707
|
+
workspace: to,
|
|
708
|
+
handoffFrom: from?.root,
|
|
709
|
+
handoffAt: Date.now(),
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (sessionId.startsWith("qchat-"))
|
|
714
|
+
return;
|
|
621
715
|
const transcriptFile = join(this.sessionsDir, sessionId, "transcript.jsonl");
|
|
622
716
|
if (!existsSync(transcriptFile))
|
|
623
717
|
return;
|
|
@@ -645,15 +739,13 @@ export class SessionManager {
|
|
|
645
739
|
*/
|
|
646
740
|
async resolveSessionWorkspaceForResume(sessionId) {
|
|
647
741
|
assertSafeSessionId(sessionId);
|
|
648
|
-
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
649
|
-
if (!existsSync(stateFile)) {
|
|
650
|
-
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
651
|
-
}
|
|
652
742
|
let state;
|
|
653
743
|
try {
|
|
654
|
-
state =
|
|
744
|
+
state = this.readPersistedState(sessionId);
|
|
655
745
|
}
|
|
656
746
|
catch (err) {
|
|
747
|
+
if (err instanceof SessionError)
|
|
748
|
+
throw err;
|
|
657
749
|
throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
658
750
|
}
|
|
659
751
|
const mainRoot = sessionMainRoot(state);
|
|
@@ -731,6 +823,26 @@ export class SessionManager {
|
|
|
731
823
|
catch {
|
|
732
824
|
return undefined;
|
|
733
825
|
}
|
|
826
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
827
|
+
if (processLocal) {
|
|
828
|
+
try {
|
|
829
|
+
const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
830
|
+
const lifecycle = state.goalLifecycle;
|
|
831
|
+
if (!lifecycle || lifecycle.phase === "terminal")
|
|
832
|
+
return undefined;
|
|
833
|
+
const goal = goalConfigFromLifecycle(lifecycle);
|
|
834
|
+
return {
|
|
835
|
+
...goal,
|
|
836
|
+
goalId: goal.goalId ?? deriveLegacyGoalId(sessionId, goal),
|
|
837
|
+
revision: goal.revision ?? 1,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (sessionId.startsWith("qchat-"))
|
|
845
|
+
return undefined;
|
|
734
846
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
735
847
|
if (!existsSync(stateFile))
|
|
736
848
|
return undefined;
|
|
@@ -873,6 +985,18 @@ export class SessionManager {
|
|
|
873
985
|
}
|
|
874
986
|
resume(sessionId) {
|
|
875
987
|
assertSafeSessionId(sessionId);
|
|
988
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
989
|
+
if (processLocal) {
|
|
990
|
+
const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
991
|
+
state.kind = normalizedSessionKind(state.kind);
|
|
992
|
+
state.status = "active";
|
|
993
|
+
delete state.lastCompletionKind;
|
|
994
|
+
Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
|
|
995
|
+
return { state, transcript: processLocal.transcript };
|
|
996
|
+
}
|
|
997
|
+
if (sessionId.startsWith("qchat-")) {
|
|
998
|
+
throw new SessionError(`Session not found: ${sessionId}`);
|
|
999
|
+
}
|
|
876
1000
|
const sessionDir = join(this.sessionsDir, sessionId);
|
|
877
1001
|
if (!existsSync(sessionDir)) {
|
|
878
1002
|
throw new SessionError(`Session not found: ${sessionId}`);
|
|
@@ -1166,6 +1290,34 @@ export class SessionManager {
|
|
|
1166
1290
|
(currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
|
|
1167
1291
|
return { ok: false, reason: "generation_conflict" };
|
|
1168
1292
|
}
|
|
1293
|
+
const processLocal = this.processLocalBundle(state.sessionId);
|
|
1294
|
+
if (processLocal) {
|
|
1295
|
+
const persisted = structuredClone(processLocal.state);
|
|
1296
|
+
const incomingKind = normalizedSessionKind(state.kind);
|
|
1297
|
+
const persistedKind = normalizedSessionKind(persisted.kind);
|
|
1298
|
+
if (incomingKind !== persistedKind)
|
|
1299
|
+
return { ok: false, reason: "kind_conflict" };
|
|
1300
|
+
state.kind = persistedKind;
|
|
1301
|
+
const persistedRevision = persisted.stateRevision;
|
|
1302
|
+
const incomingRevision = state.stateRevision;
|
|
1303
|
+
const revisionsMatch = (persistedRevision === undefined && incomingRevision === undefined) ||
|
|
1304
|
+
(typeof persistedRevision === "number" && incomingRevision === persistedRevision);
|
|
1305
|
+
if (!revisionsMatch)
|
|
1306
|
+
return { ok: false, reason: "revision_conflict" };
|
|
1307
|
+
if (persisted.title !== undefined && !("title" in state))
|
|
1308
|
+
state.title = persisted.title;
|
|
1309
|
+
state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
|
|
1310
|
+
const next = hydrateGoalLifecycle(structuredClone(stateForPersistence(state)));
|
|
1311
|
+
this.rebaseLiveState(processLocal.state, next);
|
|
1312
|
+
if (state !== processLocal.state)
|
|
1313
|
+
this.rebaseLiveState(state, next);
|
|
1314
|
+
return { ok: true };
|
|
1315
|
+
}
|
|
1316
|
+
// A closed process-local session must stay gone even if a late writer
|
|
1317
|
+
// races the close fence.
|
|
1318
|
+
if (isEphemeralSessionState(state)) {
|
|
1319
|
+
return { ok: false, reason: "revision_conflict" };
|
|
1320
|
+
}
|
|
1169
1321
|
const sessionDir = join(this.sessionsDir, state.sessionId);
|
|
1170
1322
|
mkdirSync(sessionDir, { recursive: true });
|
|
1171
1323
|
const target = join(sessionDir, "state.json");
|
|
@@ -1272,6 +1424,12 @@ export class SessionManager {
|
|
|
1272
1424
|
}
|
|
1273
1425
|
}
|
|
1274
1426
|
readPersistedState(sessionId) {
|
|
1427
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
1428
|
+
if (processLocal)
|
|
1429
|
+
return hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
1430
|
+
if (sessionId.startsWith("qchat-")) {
|
|
1431
|
+
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
1432
|
+
}
|
|
1275
1433
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
1276
1434
|
if (!existsSync(stateFile)) {
|
|
1277
1435
|
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
@@ -1322,6 +1480,13 @@ export class SessionManager {
|
|
|
1322
1480
|
/** Freeze and validate an inclusive source range before any model call. */
|
|
1323
1481
|
selectContextPackage(sourceSessionId, range) {
|
|
1324
1482
|
assertSafeSessionId(sourceSessionId);
|
|
1483
|
+
const processLocal = this.processLocalBundle(sourceSessionId);
|
|
1484
|
+
if (processLocal) {
|
|
1485
|
+
return Transcript.selectContextRange(processLocal.transcript.getEvents(), range);
|
|
1486
|
+
}
|
|
1487
|
+
if (sourceSessionId.startsWith("qchat-")) {
|
|
1488
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1489
|
+
}
|
|
1325
1490
|
const transcriptFile = join(this.sessionsDir, sourceSessionId, "transcript.jsonl");
|
|
1326
1491
|
const stateFile = join(this.sessionsDir, sourceSessionId, "state.json");
|
|
1327
1492
|
if (!existsSync(stateFile))
|
|
@@ -1377,6 +1542,13 @@ export class SessionManager {
|
|
|
1377
1542
|
return { bundle, lineage, copiedEventCount: 0 };
|
|
1378
1543
|
}
|
|
1379
1544
|
readForkSnapshot(sourceSessionId, throughEventId, snapshotMode) {
|
|
1545
|
+
const processLocal = this.processLocalBundle(sourceSessionId);
|
|
1546
|
+
if (processLocal) {
|
|
1547
|
+
return this.freezeForkSnapshot(sourceSessionId, structuredClone(processLocal.state), processLocal.transcript.getEvents(), throughEventId, snapshotMode);
|
|
1548
|
+
}
|
|
1549
|
+
if (sourceSessionId.startsWith("qchat-")) {
|
|
1550
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1551
|
+
}
|
|
1380
1552
|
const sessionDir = join(this.sessionsDir, sourceSessionId);
|
|
1381
1553
|
const stateFile = join(sessionDir, "state.json");
|
|
1382
1554
|
const transcriptFile = join(sessionDir, "transcript.jsonl");
|
|
@@ -1393,7 +1565,10 @@ export class SessionManager {
|
|
|
1393
1565
|
if (parsed.malformedLineCount > 0) {
|
|
1394
1566
|
throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
|
|
1395
1567
|
}
|
|
1396
|
-
|
|
1568
|
+
return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
|
|
1569
|
+
}
|
|
1570
|
+
freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
|
|
1571
|
+
const sourceEvents = structuredClone([...events]);
|
|
1397
1572
|
let frozen = sourceEvents;
|
|
1398
1573
|
const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
|
|
1399
1574
|
if (snapshotMode === "completed" && effectiveCursor === undefined) {
|
|
@@ -1436,6 +1611,17 @@ export class SessionManager {
|
|
|
1436
1611
|
}
|
|
1437
1612
|
publishSessionAtomically(targetSessionId, state, events) {
|
|
1438
1613
|
const targetDir = join(this.sessionsDir, targetSessionId);
|
|
1614
|
+
if (isEphemeralSessionState(state)) {
|
|
1615
|
+
if (this.processLocalBundle(targetSessionId) || existsSync(targetDir)) {
|
|
1616
|
+
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
1617
|
+
}
|
|
1618
|
+
const bundle = {
|
|
1619
|
+
state: hydrateGoalLifecycle(structuredClone(state)),
|
|
1620
|
+
transcript: Transcript.fromMemoryEvents(targetSessionId, events),
|
|
1621
|
+
};
|
|
1622
|
+
this.storeProcessLocalBundle(bundle);
|
|
1623
|
+
return bundle;
|
|
1624
|
+
}
|
|
1439
1625
|
if (existsSync(targetDir))
|
|
1440
1626
|
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
1441
1627
|
const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
|
|
@@ -48,10 +48,18 @@ export declare class Transcript {
|
|
|
48
48
|
private filePath;
|
|
49
49
|
private currentTurn;
|
|
50
50
|
private readonly writer;
|
|
51
|
+
private readonly persistent;
|
|
51
52
|
private dirty;
|
|
52
53
|
private lastFlushFailure;
|
|
53
54
|
getFilePath(): string;
|
|
54
|
-
constructor(filePath: string, writer?: TranscriptWriter
|
|
55
|
+
constructor(filePath: string, writer?: TranscriptWriter, options?: {
|
|
56
|
+
persistent?: boolean;
|
|
57
|
+
});
|
|
58
|
+
/** A process-local transcript that never creates or appends a file. */
|
|
59
|
+
static inMemory(label: string): Transcript;
|
|
60
|
+
/** Rehydrate a process-local fork without serializing its copied history. */
|
|
61
|
+
static fromMemoryEvents(label: string, events: readonly TranscriptEvent[]): Transcript;
|
|
62
|
+
isPersistent(): boolean;
|
|
55
63
|
append(type: TranscriptEventType, data: Record<string, unknown>): TranscriptEvent;
|
|
56
64
|
/**
|
|
57
65
|
* Append a chat message to the transcript.
|
|
@@ -116,9 +124,16 @@ export declare class Transcript {
|
|
|
116
124
|
private errorErrno;
|
|
117
125
|
private errorMessage;
|
|
118
126
|
/**
|
|
119
|
-
*
|
|
120
|
-
* -
|
|
121
|
-
* -
|
|
127
|
+
* Normalize tool_result pairing in this detached in-memory snapshot:
|
|
128
|
+
* - orphaned results are removed;
|
|
129
|
+
* - duplicate results collapse to one, preferring a real result over the
|
|
130
|
+
* legacy synthetic interrupted placeholder;
|
|
131
|
+
* - missing results remain missing. The run-resume boundary patches those
|
|
132
|
+
* in its request-local Message[] after it has established ownership.
|
|
133
|
+
*
|
|
134
|
+
* This method must never append to the JSONL file. loadFromFile is used by
|
|
135
|
+
* read-only/background consumers while another run may be waiting for tool
|
|
136
|
+
* approval; persisting a synthetic result there races the real executor.
|
|
122
137
|
*/
|
|
123
138
|
repairToolResultPairs(): void;
|
|
124
139
|
static readEvents(filePath: string): ParsedEvents;
|
|
@@ -133,5 +148,6 @@ export declare class Transcript {
|
|
|
133
148
|
*/
|
|
134
149
|
static selectContextRange(events: readonly TranscriptEvent[], range: ContextEventRange): SelectedContextRange;
|
|
135
150
|
static loadFromFile(filePath: string): Transcript;
|
|
151
|
+
private loadEvents;
|
|
136
152
|
}
|
|
137
153
|
export {};
|
|
@@ -13,24 +13,84 @@ const CONTEXT_EVENT_TYPES = new Set([
|
|
|
13
13
|
"summary",
|
|
14
14
|
"context_transfer",
|
|
15
15
|
]);
|
|
16
|
+
const INTERRUPTED_TOOL_RESULT_ERROR = "[Tool result missing due to interrupted session]";
|
|
17
|
+
function isSyntheticInterruptedToolResult(event) {
|
|
18
|
+
return (event.type === "tool_result" &&
|
|
19
|
+
event.data.toolName === "unknown" &&
|
|
20
|
+
event.data.error === INTERRUPTED_TOOL_RESULT_ERROR);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Choose at most one result for every declared tool call. A real late result
|
|
24
|
+
* wins over the legacy synthetic "interrupted" placeholder that an older
|
|
25
|
+
* reader could persist while the tool was merely waiting for approval.
|
|
26
|
+
*/
|
|
27
|
+
function preferredToolResults(events) {
|
|
28
|
+
const toolUseIds = new Set();
|
|
29
|
+
for (const event of events) {
|
|
30
|
+
if (event.type === "tool_use" && typeof event.data.toolCallId === "string") {
|
|
31
|
+
toolUseIds.add(event.data.toolCallId);
|
|
32
|
+
}
|
|
33
|
+
if (event.type === "message" &&
|
|
34
|
+
event.data.role === "assistant" &&
|
|
35
|
+
Array.isArray(event.data.content)) {
|
|
36
|
+
for (const block of event.data.content) {
|
|
37
|
+
if (block.type === "tool_use" && typeof block.id === "string") {
|
|
38
|
+
toolUseIds.add(block.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const preferred = new Map();
|
|
44
|
+
for (const event of events) {
|
|
45
|
+
if (event.type !== "tool_result")
|
|
46
|
+
continue;
|
|
47
|
+
const toolCallId = event.data.toolCallId;
|
|
48
|
+
if (typeof toolCallId !== "string" || !toolUseIds.has(toolCallId))
|
|
49
|
+
continue;
|
|
50
|
+
const current = preferred.get(toolCallId);
|
|
51
|
+
if (!current ||
|
|
52
|
+
isSyntheticInterruptedToolResult(current) ||
|
|
53
|
+
!isSyntheticInterruptedToolResult(event)) {
|
|
54
|
+
preferred.set(toolCallId, event);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return preferred;
|
|
58
|
+
}
|
|
16
59
|
export class Transcript {
|
|
17
60
|
events = [];
|
|
18
61
|
filePath;
|
|
19
62
|
currentTurn = 0;
|
|
20
63
|
writer;
|
|
64
|
+
persistent;
|
|
21
65
|
dirty = false;
|
|
22
66
|
lastFlushFailure;
|
|
23
67
|
getFilePath() {
|
|
24
68
|
return this.filePath;
|
|
25
69
|
}
|
|
26
|
-
constructor(filePath, writer = appendFileSync) {
|
|
70
|
+
constructor(filePath, writer = appendFileSync, options = {}) {
|
|
27
71
|
this.filePath = filePath;
|
|
28
72
|
this.writer = writer;
|
|
73
|
+
this.persistent = options.persistent !== false;
|
|
74
|
+
if (!this.persistent)
|
|
75
|
+
return;
|
|
29
76
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
30
77
|
if (!existsSync(filePath)) {
|
|
31
78
|
writeFileSync(filePath, "", "utf-8");
|
|
32
79
|
}
|
|
33
80
|
}
|
|
81
|
+
/** A process-local transcript that never creates or appends a file. */
|
|
82
|
+
static inMemory(label) {
|
|
83
|
+
return new Transcript(`<memory:${label}>`, () => undefined, { persistent: false });
|
|
84
|
+
}
|
|
85
|
+
/** Rehydrate a process-local fork without serializing its copied history. */
|
|
86
|
+
static fromMemoryEvents(label, events) {
|
|
87
|
+
const transcript = Transcript.inMemory(label);
|
|
88
|
+
transcript.loadEvents(events);
|
|
89
|
+
return transcript;
|
|
90
|
+
}
|
|
91
|
+
isPersistent() {
|
|
92
|
+
return this.persistent;
|
|
93
|
+
}
|
|
34
94
|
append(type, data) {
|
|
35
95
|
const event = {
|
|
36
96
|
id: nanoid(12),
|
|
@@ -161,6 +221,7 @@ export class Transcript {
|
|
|
161
221
|
*/
|
|
162
222
|
toMessages() {
|
|
163
223
|
const messages = [];
|
|
224
|
+
const selectedToolResults = preferredToolResults(this.events);
|
|
164
225
|
for (const event of this.events) {
|
|
165
226
|
switch (event.type) {
|
|
166
227
|
case "message": {
|
|
@@ -174,6 +235,11 @@ export class Transcript {
|
|
|
174
235
|
break;
|
|
175
236
|
}
|
|
176
237
|
case "tool_result": {
|
|
238
|
+
const eventToolCallId = event.data.toolCallId;
|
|
239
|
+
if (typeof eventToolCallId !== "string" ||
|
|
240
|
+
selectedToolResults.get(eventToolCallId) !== event) {
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
177
243
|
const { toolCallId, result, error, contentBlocks } = event.data;
|
|
178
244
|
// Find if there's already a user message with tool_results to append to
|
|
179
245
|
const lastMsg = messages[messages.length - 1];
|
|
@@ -243,6 +309,8 @@ export class Transcript {
|
|
|
243
309
|
event.data.clientMessageId === clientMessageId);
|
|
244
310
|
}
|
|
245
311
|
flush(event) {
|
|
312
|
+
if (!this.persistent)
|
|
313
|
+
return true;
|
|
246
314
|
const line = JSON.stringify(event) + "\n";
|
|
247
315
|
try {
|
|
248
316
|
this.writer(this.filePath, line, "utf-8");
|
|
@@ -290,39 +358,24 @@ export class Transcript {
|
|
|
290
358
|
return error instanceof Error ? error.message : String(error);
|
|
291
359
|
}
|
|
292
360
|
/**
|
|
293
|
-
*
|
|
294
|
-
* -
|
|
295
|
-
* -
|
|
361
|
+
* Normalize tool_result pairing in this detached in-memory snapshot:
|
|
362
|
+
* - orphaned results are removed;
|
|
363
|
+
* - duplicate results collapse to one, preferring a real result over the
|
|
364
|
+
* legacy synthetic interrupted placeholder;
|
|
365
|
+
* - missing results remain missing. The run-resume boundary patches those
|
|
366
|
+
* in its request-local Message[] after it has established ownership.
|
|
367
|
+
*
|
|
368
|
+
* This method must never append to the JSONL file. loadFromFile is used by
|
|
369
|
+
* read-only/background consumers while another run may be waiting for tool
|
|
370
|
+
* approval; persisting a synthetic result there races the real executor.
|
|
296
371
|
*/
|
|
297
372
|
repairToolResultPairs() {
|
|
298
|
-
const
|
|
299
|
-
const toolResultIds = new Set();
|
|
300
|
-
for (const event of this.events) {
|
|
301
|
-
if (event.type === "tool_use") {
|
|
302
|
-
toolUseIds.add(event.data.toolCallId);
|
|
303
|
-
}
|
|
304
|
-
else if (event.type === "tool_result") {
|
|
305
|
-
toolResultIds.add(event.data.toolCallId);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
// Find tool_use events without matching tool_result
|
|
309
|
-
for (const id of toolUseIds) {
|
|
310
|
-
if (!toolResultIds.has(id)) {
|
|
311
|
-
// Synthesize an error result
|
|
312
|
-
this.append("tool_result", {
|
|
313
|
-
toolCallId: id,
|
|
314
|
-
toolName: "unknown",
|
|
315
|
-
error: "[Tool result missing due to interrupted session]",
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
// Remove orphaned tool_results (result without matching use)
|
|
373
|
+
const selectedToolResults = preferredToolResults(this.events);
|
|
320
374
|
this.events = this.events.filter((event) => {
|
|
321
|
-
if (event.type
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return true;
|
|
375
|
+
if (event.type !== "tool_result")
|
|
376
|
+
return true;
|
|
377
|
+
const toolCallId = event.data.toolCallId;
|
|
378
|
+
return typeof toolCallId === "string" && selectedToolResults.get(toolCallId) === event;
|
|
326
379
|
});
|
|
327
380
|
}
|
|
328
381
|
static readEvents(filePath) {
|
|
@@ -436,22 +489,30 @@ export class Transcript {
|
|
|
436
489
|
return transcript;
|
|
437
490
|
const content = readFileSync(filePath, "utf-8");
|
|
438
491
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
492
|
+
const events = [];
|
|
439
493
|
for (const line of lines) {
|
|
440
494
|
try {
|
|
441
|
-
|
|
442
|
-
transcript.events.push(event);
|
|
443
|
-
if (event.type === "turn_boundary") {
|
|
444
|
-
transcript.currentTurn = event.data.turnNumber ?? transcript.currentTurn + 1;
|
|
445
|
-
}
|
|
495
|
+
events.push(JSON.parse(line));
|
|
446
496
|
}
|
|
447
497
|
catch {
|
|
448
498
|
// Skip malformed lines
|
|
449
499
|
}
|
|
450
500
|
}
|
|
501
|
+
transcript.loadEvents(events);
|
|
451
502
|
// Repair pairing on load
|
|
452
503
|
transcript.repairToolResultPairs();
|
|
453
504
|
return transcript;
|
|
454
505
|
}
|
|
506
|
+
loadEvents(events) {
|
|
507
|
+
this.events = structuredClone([...events]);
|
|
508
|
+
this.currentTurn = 0;
|
|
509
|
+
for (const event of this.events) {
|
|
510
|
+
if (event.type === "turn_boundary") {
|
|
511
|
+
this.currentTurn =
|
|
512
|
+
typeof event.data.turnNumber === "number" ? event.data.turnNumber : this.currentTurn + 1;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
455
516
|
}
|
|
456
517
|
function isEngineResultReceipt(value) {
|
|
457
518
|
if (!value || typeof value !== "object" || Array.isArray(value))
|