opencode-context-indicator 1.8.1 → 1.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/CHANGELOG.md +71 -1
- package/README.md +50 -45
- package/dist/gui-experimental.cjs +1 -1
- package/index.js +8 -1
- package/lib/cache.js +19 -3
- package/lib/commands.js +66 -45
- package/lib/contributors.js +6 -2
- package/lib/estimate.js +2 -1
- package/lib/state.js +261 -52
- package/lib/tui-data.d.ts +13 -0
- package/lib/tui-data.js +87 -1
- package/lib/tui-panel.ts +14 -27
- package/lib/v1.js +5 -0
- package/lib/v2.js +33 -5
- package/package.json +1 -1
package/lib/state.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
sumCategories,
|
|
39
39
|
} from "./estimate.js";
|
|
40
40
|
import {
|
|
41
|
-
|
|
41
|
+
breakdownForModel,
|
|
42
42
|
MAX_TRACKED_SESSIONS,
|
|
43
43
|
sessionAgentCache,
|
|
44
44
|
sessionParentCache,
|
|
@@ -424,10 +424,107 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
|
|
|
424
424
|
// affect the log / toast / event paths.
|
|
425
425
|
// Tombstone window: state.json entries untouched for longer than this are
|
|
426
426
|
// pruned during the next write (deleted / abandoned sessions used to linger
|
|
427
|
-
// until the LRU cap evicted them).
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
const PRUNE_AFTER_MS =
|
|
427
|
+
// until the LRU cap evicted them). Hygiene, not correctness: consumers are
|
|
428
|
+
// family-scoped, and a resurrected session re-measures on its first request.
|
|
429
|
+
// 7 days: a tombstone only matters while a sibling/parent poll can still see it.
|
|
430
|
+
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
431
|
+
|
|
432
|
+
// Session IDs whose persisted entry was removed by `session.deleted` (or is
|
|
433
|
+
// queued for removal because the state lock was busy). The host never reuses an
|
|
434
|
+
// ID, so once a session is gone every later write for it must be DROPPED - a
|
|
435
|
+
// write would recreate the entry, and after `clearChildSubagentTracking()` there
|
|
436
|
+
// is no parent cache left either, so the child came back as a phantom ROOT row
|
|
437
|
+
// that lived up to the 7-day tombstone (worse than the old phantom child).
|
|
438
|
+
// Bounded FIFO like the other per-instance maps.
|
|
439
|
+
const PRUNED_MAX = 512;
|
|
440
|
+
const prunedSessions = new Set();
|
|
441
|
+
// DELETE REQUESTS waiting for a state-file write: the lock was busy when the
|
|
442
|
+
// event arrived. Stored as the deleted ROOT id, not as a flat list of entries -
|
|
443
|
+
// the subtree is unknown without the file, and the request is replayed under the
|
|
444
|
+
// next write's lock (where the snapshot is available) so a busy lock cannot leave
|
|
445
|
+
// descendants alive. Bounded FIFO as well.
|
|
446
|
+
const pendingPruneRoots = new Set();
|
|
447
|
+
// Diagnostics: one log line per session that a late write was blocked for. A
|
|
448
|
+
// silent drop is impossible to debug in the field, but a line per idle/step
|
|
449
|
+
// would flood the log.
|
|
450
|
+
const barrierLogged = new Set();
|
|
451
|
+
|
|
452
|
+
function rememberPruned(id) {
|
|
453
|
+
if (typeof id !== "string" || !id) return;
|
|
454
|
+
prunedSessions.add(id);
|
|
455
|
+
if (prunedSessions.size > PRUNED_MAX) {
|
|
456
|
+
const oldest = prunedSessions.values().next().value;
|
|
457
|
+
if (oldest !== undefined) prunedSessions.delete(oldest);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function queuePrune(rootID) {
|
|
462
|
+
if (typeof rootID !== "string" || !rootID) return;
|
|
463
|
+
pendingPruneRoots.add(rootID);
|
|
464
|
+
if (pendingPruneRoots.size > PRUNED_MAX) {
|
|
465
|
+
const oldest = pendingPruneRoots.values().next().value;
|
|
466
|
+
if (oldest !== undefined) pendingPruneRoots.delete(oldest);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function logBarrierOnce(id) {
|
|
471
|
+
if (barrierLogged.has(id)) return;
|
|
472
|
+
barrierLogged.add(id);
|
|
473
|
+
if (barrierLogged.size > PRUNED_MAX) {
|
|
474
|
+
const oldest = barrierLogged.values().next().value;
|
|
475
|
+
if (oldest !== undefined) barrierLogged.delete(oldest);
|
|
476
|
+
}
|
|
477
|
+
logErr(`writeStateFile dropped for deleted session=${oneLine(id)}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// `rootID` plus every persisted entry below it in the parentID chain. The index
|
|
481
|
+
// is built once (O(n)) instead of rescanning the whole snapshot per visited node:
|
|
482
|
+
// the file is user-writable and unbounded on read, and this runs on the event
|
|
483
|
+
// loop. `doomed` doubles as the cycle guard.
|
|
484
|
+
function subtreeIDs(all, rootID) {
|
|
485
|
+
const childrenByParent = new Map();
|
|
486
|
+
for (const [id, entry] of Object.entries(all)) {
|
|
487
|
+
const parent = entry?.parentID;
|
|
488
|
+
if (typeof parent !== "string") continue;
|
|
489
|
+
const kids = childrenByParent.get(parent);
|
|
490
|
+
if (kids) kids.push(id);
|
|
491
|
+
else childrenByParent.set(parent, [id]);
|
|
492
|
+
}
|
|
493
|
+
const doomed = new Set([rootID]);
|
|
494
|
+
const stack = [rootID];
|
|
495
|
+
while (stack.length > 0) {
|
|
496
|
+
const current = stack.pop();
|
|
497
|
+
for (const child of childrenByParent.get(current) || []) {
|
|
498
|
+
if (doomed.has(child)) continue;
|
|
499
|
+
doomed.add(child);
|
|
500
|
+
stack.push(child);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return doomed;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Atomic commit of a state snapshot: random temp suffix + exclusive create
|
|
507
|
+
// (CWE-377), rename over a regular file only, temp cleanup on failure. Shared by
|
|
508
|
+
// the writer and the deleter so the two can never drift apart.
|
|
509
|
+
function commitState(all) {
|
|
510
|
+
const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
511
|
+
writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
|
|
512
|
+
let renamed = false;
|
|
513
|
+
try {
|
|
514
|
+
ensureRegularFile(STATE_FILE); // never rename over a planted symlink
|
|
515
|
+
renamed = renameWithRetry(tmp, STATE_FILE);
|
|
516
|
+
} finally {
|
|
517
|
+
// A rename that exhausted its retries leaves the temp file behind; drop it
|
|
518
|
+
// so repeated failures cannot litter %TEMP% (best-effort: on a SUCCESSFUL
|
|
519
|
+
// rename the temp is already gone, existsSync filters that).
|
|
520
|
+
try {
|
|
521
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
522
|
+
} catch {
|
|
523
|
+
/* best-effort cleanup */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return renamed;
|
|
527
|
+
}
|
|
431
528
|
|
|
432
529
|
function writeStateFile(
|
|
433
530
|
sessionID,
|
|
@@ -447,6 +544,16 @@ function writeStateFile(
|
|
|
447
544
|
logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
|
|
448
545
|
return false;
|
|
449
546
|
}
|
|
547
|
+
// The session was deleted: never recreate its entry. Without this barrier a
|
|
548
|
+
// late idle/step write (or a live child whose parent was just deleted)
|
|
549
|
+
// re-adds the row, and with the parent cache already cleared it lands with
|
|
550
|
+
// `parentID: null` - a phantom ROOT session in the table, the picker and the
|
|
551
|
+
// Desktop tab until the tombstone. Logged once per ID: the check runs on
|
|
552
|
+
// every write for that session and a silent drop would be undebuggable.
|
|
553
|
+
if (prunedSessions.has(sessionID)) {
|
|
554
|
+
logBarrierOnce(sessionID);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
450
557
|
const lock = acquireLock(STATE_LOCK_FILE);
|
|
451
558
|
// {ok:false} => busy: skip (value stays in memory, lands next cycle), as
|
|
452
559
|
// before. null => unexpected error: fail-open, write WITHOUT the lock and do
|
|
@@ -458,7 +565,6 @@ function writeStateFile(
|
|
|
458
565
|
return false;
|
|
459
566
|
}
|
|
460
567
|
try {
|
|
461
|
-
const bd = breakdownCache.get(sessionID) || null;
|
|
462
568
|
const usableInfo = getUsableContext(providerID, modelID, undefined, opts?.directory);
|
|
463
569
|
// Persist the EFFECTIVE window (min(input, context) when both are known,
|
|
464
570
|
// else input || context: the same window the ceiling was derived from),
|
|
@@ -503,7 +609,7 @@ function writeStateFile(
|
|
|
503
609
|
}
|
|
504
610
|
// Tombstone prune: entries untouched for PRUNE_AFTER_MS drop out of the
|
|
505
611
|
// shared file (deleted / abandoned sessions used to linger until the LRU
|
|
506
|
-
// cap). Edge case: resuming a
|
|
612
|
+
// cap). Edge case: resuming a 7+-day-stale session prunes its own old
|
|
507
613
|
// entry once, losing the inherited usable/agent/parentID for the first
|
|
508
614
|
// write — it self-heals on the next event. Unparsable updatedAt -> keep.
|
|
509
615
|
{
|
|
@@ -516,27 +622,6 @@ function writeStateFile(
|
|
|
516
622
|
const prevEntry = Object.prototype.hasOwnProperty.call(all, sessionID)
|
|
517
623
|
? all[sessionID]
|
|
518
624
|
: null;
|
|
519
|
-
const pc = prevEntry?.categories || null;
|
|
520
|
-
|
|
521
|
-
// Which breakdown is allowed to OWN the persisted categories?
|
|
522
|
-
//
|
|
523
|
-
// This file is shared by every plugin instance, and the instance that
|
|
524
|
-
// answers the Desktop RPC (the window's) never serves a model request, so
|
|
525
|
-
// its `breakdownCache` entry is always the client-context fallback rebuilt
|
|
526
|
-
// from the persisted message list. That fallback cannot see the assembled
|
|
527
|
-
// system prompt or the tool schemas, folds their whole mass into `other`,
|
|
528
|
-
// and after a compaction it can even read an empty conversation. Letting
|
|
529
|
-
// it overwrite the hook's measurement — the two writes landed 11 ms apart
|
|
530
|
-
// in the field — produced "system 0 / tool schemas 0 / other inflated" and
|
|
531
|
-
// could blank user/assistant. So: a hook-family source (see
|
|
532
|
-
// isHookSource) always wins; the fallback only fills an entry that no hook
|
|
533
|
-
// has ever measured.
|
|
534
|
-
//
|
|
535
|
-
// Legacy entries (written before `source` was persisted) carry no tag, but
|
|
536
|
-
// only a hook can produce a non-null system / tool-schema estimate.
|
|
537
|
-
const prevHookMeasured = isHookMeasuredEntry(prevEntry);
|
|
538
|
-
const bdFromHook = bd != null && isHookSource(bd.source);
|
|
539
|
-
const useBd = bd != null && (bdFromHook || !prevHookMeasured);
|
|
540
625
|
|
|
541
626
|
// Resolve the model identity for THIS write. An unmeasured write from a
|
|
542
627
|
// mirror/idle instance may not know the model (its caches were cleared);
|
|
@@ -554,10 +639,52 @@ function writeStateFile(
|
|
|
554
639
|
// Same model AND provider? Only then is a previously persisted denominator
|
|
555
640
|
// meaningful — a session that switched model (or provider, even with the
|
|
556
641
|
// same modelID) must not inherit the old window.
|
|
642
|
+
//
|
|
643
|
+
// An EMPTY provider on either side is "not known yet", not a change: an
|
|
644
|
+
// idle/mirror write carries no provider (its caches were cleared), and a
|
|
645
|
+
// valid split written by another instance under `providerID: ""` must
|
|
646
|
+
// survive the next write that knows the provider. Treating that as a
|
|
647
|
+
// switch would zero the categories and drop the denominator.
|
|
648
|
+
const providerCompatible =
|
|
649
|
+
prevEntry == null ||
|
|
650
|
+
!prevEntry.providerID ||
|
|
651
|
+
!effectiveProvider ||
|
|
652
|
+
prevEntry.providerID === effectiveProvider;
|
|
557
653
|
const sameModelAsPrev =
|
|
558
654
|
prevEntry != null &&
|
|
559
655
|
prevEntry.model === effectiveModel &&
|
|
560
|
-
|
|
656
|
+
providerCompatible;
|
|
657
|
+
|
|
658
|
+
// A breakdown measured for a DIFFERENT model describes the payload the
|
|
659
|
+
// previous model received: same tokens, but the window and the tool/system
|
|
660
|
+
// share move. Persisting it under the new model shows the old split until
|
|
661
|
+
// the next request re-measures (the reported "categories lag after a model
|
|
662
|
+
// switch"). breakdownForModel drops exactly that case.
|
|
663
|
+
const bd = breakdownForModel(sessionID, effectiveModel);
|
|
664
|
+
// Persisted categories are model-scoped too: after a switch the old split
|
|
665
|
+
// must not seed the new entry (a hook measurement of the OLD model also
|
|
666
|
+
// must not block the fallback from filling the new one).
|
|
667
|
+
const pc = sameModelAsPrev ? prevEntry?.categories || null : null;
|
|
668
|
+
|
|
669
|
+
// Which breakdown is allowed to OWN the persisted categories?
|
|
670
|
+
//
|
|
671
|
+
// This file is shared by every plugin instance, and the instance that
|
|
672
|
+
// answers the Desktop RPC (the window's) never serves a model request, so
|
|
673
|
+
// its `breakdownCache` entry is always the client-context fallback rebuilt
|
|
674
|
+
// from the persisted message list. That fallback cannot see the assembled
|
|
675
|
+
// system prompt or the tool schemas, folds their whole mass into `other`,
|
|
676
|
+
// and after a compaction it can even read an empty conversation. Letting
|
|
677
|
+
// it overwrite the hook's measurement — the two writes landed 11 ms apart
|
|
678
|
+
// in the field — produced "system 0 / tool schemas 0 / other inflated" and
|
|
679
|
+
// could blank user/assistant. So: a hook-family source (see
|
|
680
|
+
// isHookSource) always wins; the fallback only fills an entry that no hook
|
|
681
|
+
// has ever measured.
|
|
682
|
+
//
|
|
683
|
+
// Legacy entries (written before `source` was persisted) carry no tag, but
|
|
684
|
+
// only a hook can produce a non-null system / tool-schema estimate.
|
|
685
|
+
const prevHookMeasured = sameModelAsPrev && isHookMeasuredEntry(prevEntry);
|
|
686
|
+
const bdFromHook = bd != null && isHookSource(bd.source);
|
|
687
|
+
const useBd = bd != null && (bdFromHook || !prevHookMeasured);
|
|
561
688
|
|
|
562
689
|
// A write with no token figure at all is NOT a measurement: it happens on
|
|
563
690
|
// a mirror instance (the hook/step served another instance, so this
|
|
@@ -608,25 +735,34 @@ function writeStateFile(
|
|
|
608
735
|
// Untrusted names -> sanitised + bounded. A present breakdown OWNS the
|
|
609
736
|
// split (an empty one means "no tool results now" — do NOT revive a stale
|
|
610
737
|
// peer value); only fall back to the persisted map when this instance has
|
|
611
|
-
// no live breakdown at all
|
|
738
|
+
// no live breakdown at all AND the model is unchanged (the split describes
|
|
739
|
+
// the previous model's request payload).
|
|
612
740
|
const toolOutByTool = useBd
|
|
613
741
|
? safeToolOutByTool(bd.toolOutByTool)
|
|
614
|
-
:
|
|
742
|
+
: sameModelAsPrev
|
|
743
|
+
? safeToolOutByTool(prevEntry?.toolOutByTool)
|
|
744
|
+
: null;
|
|
615
745
|
|
|
616
746
|
// Redundant tool output (waste detector). Persisted WITHOUT targets
|
|
617
747
|
// (keepTarget=false): state.json is a local temp file, but keeping it
|
|
618
748
|
// path-free matches the redacted-export policy. A present breakdown owns
|
|
619
|
-
// the value; otherwise inherit the persisted one
|
|
749
|
+
// the value; otherwise inherit the persisted one (model-gated, like the
|
|
750
|
+
// categories it summarises).
|
|
620
751
|
const reclaim = useBd
|
|
621
752
|
? safeReclaim(bd.reclaim, false)
|
|
622
|
-
:
|
|
753
|
+
: sameModelAsPrev
|
|
754
|
+
? safeReclaim(prevEntry?.reclaim, false)
|
|
755
|
+
: null;
|
|
623
756
|
|
|
624
757
|
// Heaviest single items still in the request (top-N). A present breakdown
|
|
625
|
-
// OWNS the list; otherwise inherit the persisted one
|
|
626
|
-
//
|
|
758
|
+
// OWNS the list; otherwise inherit the persisted one (model-gated: the
|
|
759
|
+
// system prompt / tool-schema share moves between models). Persisted
|
|
760
|
+
// WITHOUT targets (path-free policy, same as reclaim).
|
|
627
761
|
const top = useBd
|
|
628
762
|
? safeTop(bd.top, false)
|
|
629
|
-
:
|
|
763
|
+
: sameModelAsPrev
|
|
764
|
+
? safeTop(prevEntry?.top, false)
|
|
765
|
+
: [];
|
|
630
766
|
|
|
631
767
|
// Provider-reported prompt cache (EXACT; a subset of input — never added
|
|
632
768
|
// to the category sum). Inherit when this write carries no fresh figure
|
|
@@ -788,24 +924,23 @@ function writeStateFile(
|
|
|
788
924
|
.slice(MAX_TRACKED_SESSIONS)
|
|
789
925
|
.forEach((k) => delete all[k]);
|
|
790
926
|
}
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (existsSync(tmp)) unlinkSync(tmp);
|
|
804
|
-
} catch {
|
|
805
|
-
/* best-effort cleanup */
|
|
927
|
+
// Replay queued deletes inside THIS write's lock: the snapshot is only
|
|
928
|
+
// readable under it, so a busy lock at delete time cannot leave descendants
|
|
929
|
+
// alive. The requests are kept until the commit SUCCEEDS - clearing the
|
|
930
|
+
// queue before a rename that never happened would drop the delete silently
|
|
931
|
+
// and leave the row to the 7-day tombstone.
|
|
932
|
+
const replaying = pendingPruneRoots.size > 0;
|
|
933
|
+
if (replaying) {
|
|
934
|
+
for (const root of pendingPruneRoots) {
|
|
935
|
+
for (const id of subtreeIDs(all, root)) {
|
|
936
|
+
delete all[id];
|
|
937
|
+
rememberPruned(id);
|
|
938
|
+
}
|
|
806
939
|
}
|
|
807
940
|
}
|
|
808
|
-
|
|
941
|
+
const committed = commitState(all);
|
|
942
|
+
if (committed && replaying) pendingPruneRoots.clear();
|
|
943
|
+
return committed;
|
|
809
944
|
} finally {
|
|
810
945
|
// Release ONLY a lock we actually own (lock === null => fail-open, skip).
|
|
811
946
|
if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
|
|
@@ -902,10 +1037,84 @@ function pushFinalSummary(claimKey, summaryLines) {
|
|
|
902
1037
|
}
|
|
903
1038
|
}
|
|
904
1039
|
|
|
1040
|
+
// Delete one session's entry AND its whole subtree from the shared state file.
|
|
1041
|
+
//
|
|
1042
|
+
// `session.deleted` is authoritative, so waiting for the tombstone (7 days) or
|
|
1043
|
+
// the LRU cap keeps a dead session visible in `/context-breakdown`, the Desktop
|
|
1044
|
+
// tab and the `/cx` picker - and a phantom row has no step event left to be
|
|
1045
|
+
// filtered out by. The walk follows the persisted parentID chain (the same
|
|
1046
|
+
// source discovery uses), so a deleted parent takes its descendants with it and
|
|
1047
|
+
// a deleted child takes its own sub-subagents.
|
|
1048
|
+
//
|
|
1049
|
+
// Every removed ID also enters `prunedSessions`: the host never reuses an ID, so
|
|
1050
|
+
// a later write for it is dropped instead of recreating the row (a live child
|
|
1051
|
+
// whose parent was deleted would otherwise come back as a phantom ROOT once
|
|
1052
|
+
// clearChildSubagentTracking() had dropped its parent cache).
|
|
1053
|
+
//
|
|
1054
|
+
// Same locking/atomic-write contract as writeStateFile, except busy: the delete
|
|
1055
|
+
// is QUEUED for the next write (which already holds the lock) rather than lost -
|
|
1056
|
+
// the tombstone would not remove it for 7 days.
|
|
1057
|
+
function pruneSessionState(sessionID) {
|
|
1058
|
+
try {
|
|
1059
|
+
if (!sessionID || !isSafeSessionKey(sessionID)) return false;
|
|
1060
|
+
// In-memory descendants first: a child that has not written its first entry
|
|
1061
|
+
// yet is invisible to the disk walk below, but it WILL write later - and by
|
|
1062
|
+
// then `session.deleted` has already wiped its parent attribution, so it
|
|
1063
|
+
// would land as a phantom root. Remember them regardless of the lock.
|
|
1064
|
+
const kidsByParent = new Map();
|
|
1065
|
+
for (const [childID, parentID] of sessionParentCache) {
|
|
1066
|
+
if (typeof parentID !== "string") continue;
|
|
1067
|
+
const kids = kidsByParent.get(parentID);
|
|
1068
|
+
if (kids) kids.push(childID);
|
|
1069
|
+
else kidsByParent.set(parentID, [childID]);
|
|
1070
|
+
}
|
|
1071
|
+
const inMemory = new Set([sessionID]);
|
|
1072
|
+
const pending = [sessionID];
|
|
1073
|
+
while (pending.length > 0) {
|
|
1074
|
+
const current = pending.pop();
|
|
1075
|
+
for (const child of kidsByParent.get(current) || []) {
|
|
1076
|
+
if (inMemory.has(child)) continue;
|
|
1077
|
+
inMemory.add(child);
|
|
1078
|
+
pending.push(child);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
for (const id of inMemory) rememberPruned(id);
|
|
1082
|
+
const lock = acquireLock(STATE_LOCK_FILE);
|
|
1083
|
+
if (lock && !lock.ok) {
|
|
1084
|
+
logErr(`pruneSessionState queued (state lock busy) session=${oneLine(sessionID)}`);
|
|
1085
|
+
queuePrune(sessionID); // replayed under the next write's lock, with the subtree
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
try {
|
|
1089
|
+
const all = readStateSnapshot();
|
|
1090
|
+
const doomed = subtreeIDs(all, sessionID);
|
|
1091
|
+
let removed = 0;
|
|
1092
|
+
for (const id of doomed) {
|
|
1093
|
+
// Remembered even when nothing was persisted: the write barrier has to
|
|
1094
|
+
// cover a session whose entry was already gone (another instance pruned
|
|
1095
|
+
// it) and a child that has not written its first entry yet.
|
|
1096
|
+
rememberPruned(id);
|
|
1097
|
+
if (Object.prototype.hasOwnProperty.call(all, id)) {
|
|
1098
|
+
delete all[id];
|
|
1099
|
+
removed++;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (removed === 0) return false; // nothing persisted (already gone)
|
|
1103
|
+
return commitState(all);
|
|
1104
|
+
} finally {
|
|
1105
|
+
if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
|
|
1106
|
+
}
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
logErr("pruneSessionState failed", err);
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
905
1113
|
export {
|
|
906
1114
|
logErr,
|
|
907
1115
|
writeLog,
|
|
908
1116
|
writeStateFile,
|
|
1117
|
+
pruneSessionState,
|
|
909
1118
|
debugEvent,
|
|
910
1119
|
readStateSnapshot,
|
|
911
1120
|
pushFinalSummary,
|
package/lib/tui-data.d.ts
CHANGED
|
@@ -73,6 +73,19 @@ export declare function fmt(n: number): string
|
|
|
73
73
|
export declare function num(v: unknown): number
|
|
74
74
|
export declare function fit(s: string, w: number): string
|
|
75
75
|
export declare function updatedAt(iso: string | undefined): string
|
|
76
|
+
export declare function updatedCell(
|
|
77
|
+
iso: string | undefined,
|
|
78
|
+
stale: boolean | undefined,
|
|
79
|
+
width: number,
|
|
80
|
+
): string
|
|
81
|
+
export declare function panelLayout(panelWidth: number | undefined): {
|
|
82
|
+
roleW: number
|
|
83
|
+
modelW: number
|
|
84
|
+
ctxW: number
|
|
85
|
+
costW: number
|
|
86
|
+
updW: number
|
|
87
|
+
gutter: string
|
|
88
|
+
}
|
|
76
89
|
export declare function toolOutEntries(
|
|
77
90
|
map: Record<string, number> | undefined,
|
|
78
91
|
limit?: number,
|
package/lib/tui-data.js
CHANGED
|
@@ -122,16 +122,102 @@ export function fit(s, w) {
|
|
|
122
122
|
return s.padEnd(w)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Compose the `updated` cell. The stale marker is appended AFTER fitting: fit()
|
|
126
|
+
// cuts the tail and replaces it with "~", so a marker folded into the fitted
|
|
127
|
+
// string would disappear at the first narrow width - while the legend line below
|
|
128
|
+
// the table would still advertise it. The time cell keeps at least 5 columns.
|
|
129
|
+
export function updatedCell(iso, stale, width) {
|
|
130
|
+
const marker = stale === true ? " *" : ""
|
|
131
|
+
const timeW = Math.max(5, width - marker.length)
|
|
132
|
+
return fit(updatedAt(iso), timeW) + marker
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Deterministic clock for the updated column: HH:MM:SS in LOCAL time, built
|
|
136
|
+
// from the Date getters instead of toLocaleTimeString(). toLocaleTimeString()
|
|
137
|
+
// follows the host locale (AM/PM, dots, seconds dropped) and can exceed the
|
|
138
|
+
// column width - the sidebar and the `/cx` row then lose their alignment on
|
|
139
|
+
// non-en-US machines.
|
|
125
140
|
export function updatedAt(iso) {
|
|
126
141
|
if (!iso) return "?"
|
|
127
142
|
try {
|
|
128
143
|
const d = new Date(iso)
|
|
129
|
-
|
|
144
|
+
if (Number.isNaN(d.getTime())) return "?"
|
|
145
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
146
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
130
147
|
} catch {
|
|
131
148
|
return "?"
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
|
|
152
|
+
// Panel column layout (monospace), derived from the host-provided panel width so
|
|
153
|
+
// cells never collide on narrow terminals. Pure math (no JSX, no host imports),
|
|
154
|
+
// so the unit harness can assert the row never grows past the panel.
|
|
155
|
+
//
|
|
156
|
+
// Columns: role | model | ctx | cost | updated, four 2-column gutters.
|
|
157
|
+
//
|
|
158
|
+
// Width budget (row = role + model + ctx + cost + updated + 4 gutters):
|
|
159
|
+
// - the `updated` cell takes 10 columns (HH:MM:SS + " *") only when that
|
|
160
|
+
// leaves the model cell at its 8-column floor, otherwise 8 (the marker then
|
|
161
|
+
// elides the seconds) - growing it must never push the row past the panel;
|
|
162
|
+
// - the model cell absorbs whatever is left above its floor;
|
|
163
|
+
// - if both floors bind (narrow panels), the ROLE cell gives back the
|
|
164
|
+
// difference down to its own floor: without that clawback the row landed on
|
|
165
|
+
// an exact fit at w=60..62, and an exact fit is what clipped the last cell
|
|
166
|
+
// (the stale marker) in the first place;
|
|
167
|
+
// - PANEL_SAFE_COLUMNS is therefore preserved for every width >= 57; at 56
|
|
168
|
+
// the role floor stops the clawback and the row is an exact fit.
|
|
169
|
+
// Below 56 columns the fixed columns (ctx 12 + cost 8 + updated 8 + four
|
|
170
|
+
// gutters) plus the role/model floors no longer fit; the host clips the right
|
|
171
|
+
// edge there.
|
|
172
|
+
const PANEL_GUTTER = 2
|
|
173
|
+
const PANEL_CTX_WIDTH = 12
|
|
174
|
+
const PANEL_COST_WIDTH = 8
|
|
175
|
+
// One spare column: the host adds its own padding/border around a panel and an
|
|
176
|
+
// exact fit let the last cell land on the right edge (clipped on some widths).
|
|
177
|
+
const PANEL_SAFE_COLUMNS = 1
|
|
178
|
+
// Model cell floor: a model name shorter than this is unreadable.
|
|
179
|
+
const PANEL_MIN_MODEL_W = 8
|
|
180
|
+
// Role cell floor: `subagent-12` is the longest role label we print.
|
|
181
|
+
const PANEL_MIN_ROLE_W = 12
|
|
182
|
+
// `HH:MM:SS` is 8 columns; the stale marker needs 2 more.
|
|
183
|
+
const PANEL_TIME_W = 8
|
|
184
|
+
const PANEL_TIME_STALE_W = 10
|
|
185
|
+
|
|
186
|
+
export function panelLayout(panelWidth) {
|
|
187
|
+
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
188
|
+
const ctxW = PANEL_CTX_WIDTH
|
|
189
|
+
const costW = PANEL_COST_WIDTH
|
|
190
|
+
const gutters = 4 * PANEL_GUTTER
|
|
191
|
+
let roleW = Math.max(PANEL_MIN_ROLE_W, Math.min(20, Math.floor(w * 0.28)))
|
|
192
|
+
const usable = w - PANEL_SAFE_COLUMNS - roleW - ctxW - costW - gutters
|
|
193
|
+
// Prefer the full `HH:MM:SS` + " *" cell. When that does not leave the model
|
|
194
|
+
// cell its floor, fall back to the 8-column time cell: the marker then elides
|
|
195
|
+
// the SECONDS instead of the row overrunning the panel (which clipped the very
|
|
196
|
+
// marker the legend explains).
|
|
197
|
+
const updW =
|
|
198
|
+
usable - PANEL_TIME_STALE_W >= PANEL_MIN_MODEL_W
|
|
199
|
+
? PANEL_TIME_STALE_W
|
|
200
|
+
: PANEL_TIME_W
|
|
201
|
+
const modelW = Math.max(PANEL_MIN_MODEL_W, usable - updW)
|
|
202
|
+
// Both floors binding (narrow panel) made the row an EXACT fit: the model
|
|
203
|
+
// floor ate PANEL_SAFE_COLUMNS and the last cell again landed on the panel
|
|
204
|
+
// edge. Take the difference back from the role cell instead - it is prose and
|
|
205
|
+
// elides gracefully, and the safe column survives.
|
|
206
|
+
const total = roleW + modelW + ctxW + costW + updW + gutters
|
|
207
|
+
const over = total - (w - PANEL_SAFE_COLUMNS)
|
|
208
|
+
if (over > 0) {
|
|
209
|
+
roleW = Math.max(PANEL_MIN_ROLE_W, roleW - over)
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
roleW,
|
|
213
|
+
modelW,
|
|
214
|
+
ctxW,
|
|
215
|
+
costW,
|
|
216
|
+
updW,
|
|
217
|
+
gutter: " ".repeat(PANEL_GUTTER),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
135
221
|
// ---------------------------------------------------------------------------
|
|
136
222
|
// Composer pill (`prompt.footer.status` slot).
|
|
137
223
|
// ---------------------------------------------------------------------------
|
package/lib/tui-panel.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdirSync, writeFileSync } from "node:fs"
|
|
|
10
10
|
import { tmpdir } from "node:os"
|
|
11
11
|
import { basename, join } from "node:path"
|
|
12
12
|
import type { StateEntry } from "./tui-data.js"
|
|
13
|
-
import { collectFamily, fit, fmt, num, toolOutEntries,
|
|
13
|
+
import { collectFamily, fit, fmt, num, panelLayout, toolOutEntries, updatedCell } from "./tui-data.js"
|
|
14
14
|
import { token } from "./tui-theme.js"
|
|
15
15
|
import { c as comp, h } from "./tui-h.ts"
|
|
16
16
|
import {
|
|
@@ -121,29 +121,9 @@ function listSignature(list: unknown[]): string {
|
|
|
121
121
|
return `${n}:${id}:${created}:${size}`
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// Panel column layout (
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
const GUTTER = 2
|
|
128
|
-
const CTX_WIDTH = 12
|
|
129
|
-
const COST_WIDTH = 8
|
|
130
|
-
|
|
131
|
-
function panelLayout(panelWidth: number): {
|
|
132
|
-
roleW: number
|
|
133
|
-
modelW: number
|
|
134
|
-
ctxW: number
|
|
135
|
-
costW: number
|
|
136
|
-
updW: number
|
|
137
|
-
gutter: string
|
|
138
|
-
} {
|
|
139
|
-
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
140
|
-
const ctxW = CTX_WIDTH
|
|
141
|
-
const costW = COST_WIDTH
|
|
142
|
-
const updW = Math.max(5, Math.min(8, w - ctxW - 4 * GUTTER - 24))
|
|
143
|
-
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.28)))
|
|
144
|
-
const modelW = Math.max(8, w - roleW - ctxW - costW - updW - 4 * GUTTER)
|
|
145
|
-
return { roleW, modelW, ctxW, costW, updW, gutter: " ".repeat(GUTTER) }
|
|
146
|
-
}
|
|
124
|
+
// Panel column layout lives in lib/tui-data.js (pure math) so the unit harness
|
|
125
|
+
// can regression-test that the row never grows past the panel width (T42 in
|
|
126
|
+
// test/prepublish.mjs).
|
|
147
127
|
|
|
148
128
|
// Dollar formatting for the cost column: compact, always `$`-prefixed, `-`
|
|
149
129
|
// when the host data layer has no cost for the session.
|
|
@@ -617,8 +597,7 @@ export function BreakdownPanel(props: {
|
|
|
617
597
|
L.gutter +
|
|
618
598
|
fit(money(costOf(e.sessionID)), L.costW) +
|
|
619
599
|
L.gutter +
|
|
620
|
-
|
|
621
|
-
(e.compactStale === true ? " (stale)" : "")
|
|
600
|
+
updatedCell(e.updatedAt, e.compactStale === true, L.updW)
|
|
622
601
|
)
|
|
623
602
|
}
|
|
624
603
|
|
|
@@ -633,7 +612,7 @@ export function BreakdownPanel(props: {
|
|
|
633
612
|
all && typeof all === "object"
|
|
634
613
|
? Object.values(all).filter((v) => num(v) > 0).length
|
|
635
614
|
: 0
|
|
636
|
-
const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
|
|
615
|
+
const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)} tokens`)
|
|
637
616
|
if (total > entries.length) parts.push(`+${total - entries.length} more`)
|
|
638
617
|
return parts.join(" \u00b7 ")
|
|
639
618
|
}
|
|
@@ -701,6 +680,14 @@ export function BreakdownPanel(props: {
|
|
|
701
680
|
.rows.map((e, i) =>
|
|
702
681
|
mutedText(() => row(e, i === 0)),
|
|
703
682
|
),
|
|
683
|
+
comp(Show, {
|
|
684
|
+
get when() {
|
|
685
|
+
return data().rows.some((e) => e.compactStale === true)
|
|
686
|
+
},
|
|
687
|
+
get children() {
|
|
688
|
+
return mutedText(() => " * stale - compacted")
|
|
689
|
+
},
|
|
690
|
+
}),
|
|
704
691
|
comp(Show, {
|
|
705
692
|
get when() {
|
|
706
693
|
return (data().root?.usable ?? 0) > 0
|
package/lib/v1.js
CHANGED
|
@@ -671,6 +671,11 @@ function makeV1(client) {
|
|
|
671
671
|
) {
|
|
672
672
|
// session.idle / session.compacted -> properties.sessionID;
|
|
673
673
|
// session.deleted -> properties.info.id
|
|
674
|
+
//
|
|
675
|
+
// V1 is feature-frozen: unlike the V2 handler, a V1 delete event does NOT
|
|
676
|
+
// purge the state.json entry (it ages out via the 7-day tombstone), so a
|
|
677
|
+
// V1 host can still show a phantom row. Kept intentionally small; V2 is
|
|
678
|
+
// the supported path.
|
|
674
679
|
const sid =
|
|
675
680
|
event.properties?.sessionID ?? event.properties?.info?.id;
|
|
676
681
|
if (!sid) return;
|