opencode-context-indicator 1.8.2 → 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 CHANGED
@@ -4,6 +4,33 @@ Notable user-facing changes per release. Dates are commit days (UTC).
4
4
  The package also ships a dual V1/V2 plugin — see the README for the surface
5
5
  matrix; everything below concerns the V2 path unless stated otherwise.
6
6
 
7
+ ## 1.8.3 — 2026-11-03
8
+
9
+ - **A deleted session disappears immediately.** `session.deleted` now removes the
10
+ session's `state.json` entry - and every entry under it in the `parentID` chain -
11
+ instead of leaving it to the 7-day tombstone. The row used to stay in
12
+ `/context-breakdown`, the Desktop tab and the `/cx` picker for up to a week, and
13
+ a phantom row has no step event left for the phantom filter to catch. Removed
14
+ IDs are remembered for the lifetime of the instance (the host never reuses an
15
+ ID), so a late write cannot recreate the row: without that barrier a child that
16
+ was still running when its parent was deleted came back as a phantom ROOT,
17
+ because deleting the parent had already wiped its attribution cache. The same
18
+ barrier covers a child that has no persisted entry yet. A state lock that is
19
+ busy at delete time queues the removal for the next write instead of dropping
20
+ it. The write path follows the same lock/atomic-rename contract as the state
21
+ writer; a missing entry is a no-op and an unsafe key is rejected.
22
+ - **The Desktop subagent list is heaviest-first.** A family can hold a dozen
23
+ children and the RPC order is the `parentID` walk, which buried the session
24
+ actually eating the window; the list is now sorted by `ctx` (the polled array is
25
+ copied first, never reordered in place).
26
+ - **`.gitattributes` normalises line endings.** Text files are stored with LF and
27
+ the image assets are marked binary, so a checkout on Windows and on the Linux CI
28
+ produces the same bytes instead of a CRLF/LF diff on every edit.
29
+ - Unit harness extended to T43 (deleted-session purge: subtree removal, unrelated
30
+ entries kept, missing/unsafe keys are no-ops, late writes for a pruned or
31
+ not-yet-persisted child are dropped, a busy lock queues the whole subtree for
32
+ the next write), 116 checks in total.
33
+
7
34
  ## 1.8.2 — 2026-11-03
8
35
 
9
36
  - **Model-rendered commands no longer echo the instruction.** `/context`,
package/README.md CHANGED
@@ -358,12 +358,16 @@ notice if needed.
358
358
  unambiguous payload boundary instead of a leading "reproduce exactly"
359
359
  phrasing. Residual risk: a model may still add light commentary or include the
360
360
  tags themselves.
361
- * **Deleted sessions age out over 7 days.** A `state.json` entry is pruned on
362
- the next write once it has been untouched for 7 days (tombstone prune), so
363
- deleted sessions no longer linger until the `MAX_TRACKED_SESSIONS` LRU cap.
364
- Until the prune fires, a deleted session that had already served a step (has
365
- tokens) is not filtered as a phantom and may momentarily appear in the
366
- table.
361
+ * **Deleted sessions are dropped immediately.** A `session.deleted` event removes
362
+ the session's `state.json` entry - and every entry under it in the `parentID`
363
+ chain - instead of waiting for the 7-day tombstone prune, so the row leaves
364
+ `/context-breakdown`, the `/cx` picker and the Desktop tab at once. The removed
365
+ IDs are remembered for the lifetime of the instance (the host never reuses an
366
+ ID), so a late idle/step write, or a child that was still running when its
367
+ parent was deleted, cannot recreate the row - without that barrier it came back
368
+ as a phantom ROOT session. A state lock that is busy at delete time queues the
369
+ removal for the next write instead of losing it. The 7-day tombstone remains as
370
+ the backstop for sessions that vanish without an event.
367
371
 
368
372
  ### TUI session panel (V2, terminal only)
369
373
 
@@ -766,7 +770,7 @@ caller.
766
770
 
767
771
  ```bash
768
772
  npm run build:gui # build dist/gui-experimental.cjs (required by the gate)
769
- npm run test:prepublish # fast gate: tarball + JSX-free TUI + resolve + T1-T41 unit checks + GUI bundle + GUI runtime smoke
773
+ npm run test:prepublish # fast gate: tarball + JSX-free TUI + resolve + T1-T43 unit checks + GUI bundle + GUI runtime smoke
770
774
  npm run test:types # tsc for the TUI entry AND the Desktop GUI part
771
775
  npm run test:e2e # full E2E: plugin load + one "Say OK" round-trip + npm-layout liveness
772
776
  npm run test:e2e:quick # E2E load-only, no LLM call
@@ -790,7 +794,7 @@ STEP 80 s + PROBE 45 s, ~4 min total.
790
794
  | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically); `docs/` may contain images only; `dist/` may contain only the built GUI bundle | no test/node_modules/tsconfig in the tarball, and no published export pointing at nothing |
791
795
  | (b) JSX-free TUI | no `.tsx`/`.jsx` in the TUI surface (root + `lib/`), every TUI source parses with esbuild `loader:"ts"` (JSX syntax fails), no `@jsx*` pragma, imports stay inside the host-bridgeable allowlist, relative imports carry a file extension | OpenCode skips its Solid transform under `node_modules` (#33884): one JSX file freezes the npm-installed sidebar |
792
796
  | (c) resolve chain | walks the real TUI import graph (`resolve-bridge.cjs`): every bare specifier is host-bridged, every relative one resolves to a shipped file, and **no host-bridged module is a production dependency** (a second Solid/OpenTUI instance freezes the surfaces) | an accidental runtime dependency, or a bad local import, silently breaks the sidebar |
793
- | (d) unit harness T1-T42 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff, subagent-rollup and Desktop-RPC checks (111 total; T30 schema-vs-builder drift, T31 registration + handler, T32-T36 fallback-vs-hook category priority across the report, the shared state file and the live log (T36: a counter-less hook entry borrows the snapshot or prints `?`), T37 the opencode 2.2.5 `min(input, context)` window, T38 the configured `compaction.buffer` (JSONC strip, project/global precedence, percent/token conversion, env/argument priority, per-project isolation), T39 the TUI composer capsule thresholds, T40 the data-first command payload + locale-free clock + labelled contributors, T41 model-scoped breakdowns after an in-session model switch, T42 the panel row width budget (the column sum never exceeds the panel; the stale-marker columns come out of the row, not on top of it), T25c/T25d compaction-hook arming, T34 cross-instance summary merge) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime, and a GUI/server wire mismatch |
797
+ | (d) unit harness T1-T43 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff, subagent-rollup and Desktop-RPC checks (116 total; T30 schema-vs-builder drift, T31 registration + handler, T32-T36 fallback-vs-hook category priority across the report, the shared state file and the live log (T36: a counter-less hook entry borrows the snapshot or prints `?`), T37 the opencode 2.2.5 `min(input, context)` window, T38 the configured `compaction.buffer` (JSONC strip, project/global precedence, percent/token conversion, env/argument priority, per-project isolation), T39 the TUI composer capsule thresholds, T40 the data-first command payload + locale-free clock + labelled contributors, T41 model-scoped breakdowns after an in-session model switch, T42 the panel row width budget (the column sum never exceeds the panel; the stale-marker columns come out of the row, not on top of it), T43 deleted-session purge of `state.json` (subtree removed, peers kept, unsafe keys rejected, late writes for a pruned or not-yet-persisted child dropped), T25c/T25d compaction-hook arming, T34 cross-instance summary merge) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime, and a GUI/server wire mismatch |
794
798
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.ts`, `lib/*`, `gui/*`, `README.md`, `CHANGELOG.md`, `package.json`, the GUI build config) or the test tree | compliance |
795
799
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in the same published surface and test tree (`docs/` is image-only and is not text-scanned) | English-only source rule |
796
800
  | (g) Desktop GUI bundle | `dist/gui-experimental.cjs` exists and is a non-trivial single-file CJS bundle: no ESM `import`, no dynamic `import()`, no Node built-in, and every app-provided module (`solid-js`, `solid-js/web`, `@opencode/plugin/gui`, `@opencode/plugin/rpc`) still a `require()` - no bundled second copy, no React/OpenTUI/bare-`opencode` import; the artifact must also be newer than every source file it inlines | a stale or hand-edited bundle, or a build that inlines the app instance, breaks the Desktop panel at load time |
@@ -1 +1 @@
1
- Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js"),r=require("@opencode/plugin/rpc");var i={sessionID:{type:`string`},parentID:{type:`string`},agent:{type:`string`},modelID:{type:`string`},providerID:{type:`string`},ctx:{type:`number`},input:{type:`number`},usable:{type:`number`},reserve:{type:`number`},user:{type:`number`},assistant:{type:`number`},reasoning:{type:`number`},toolArgs:{type:`number`},toolOut:{type:`number`},system:{type:`number`},toolSchemas:{type:`number`},other:{type:`number`},source:{type:`string`},updatedAt:{type:`string`},stale:{type:`boolean`}},a=Object.keys(i),o=r.Rpc.define({id:`context-indicator`,methods:{family:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{rows:{type:`array`,items:{type:`object`,properties:i,required:a,additionalProperties:!1}}},required:[`rows`],additionalProperties:!1}}},events:{}});function s(e){return Number.isFinite(e)?e>=1e3?`${(e/1e3).toFixed(1)}k`:String(Math.round(e)):`0`}var c=(0,e.template)(`<div>Server part unavailable: <!> (retrying)`),l=(0,e.template)(`<div>`),u=(0,e.template)(`<div>Loading context...`),d=(0,e.template)(`<div>No measurement yet. Send a prompt in this session and the numbers appear here.`),f=(0,e.template)(`<div><span></span><span>`),p=(0,e.template)(`<span>stale after compaction`),m=(0,e.template)(`<span>reserve `),h=(0,e.template)(`<div>Subagents (<!>)`),g=(0,e.template)(`<div><span></span><span></span><span><span style=display:block></span></span><span>`),_=1500,v=[[`system`,`System`],[`toolSchemas`,`Tool schemas`],[`user`,`User`],[`assistant`,`Assistant`],[`reasoning`,`Reasoning`],[`toolArgs`,`Tool args`],[`toolOut`,`Tool output`],[`other`,`Other`]];function y(e){if(!e)return`?`;try{let t=new Date(e);if(Number.isNaN(t.getTime()))return`?`;let n=e=>String(e).padStart(2,`0`);return`${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`}catch{return`?`}}function b(e,t){return!t||t<=0?``:`${(e/t*100).toFixed(0)}%`}var x={panel:{display:`flex`,"flex-direction":`column`,gap:`10px`,padding:`12px`,"font-size":`12px`,"line-height":`1.45`},hint:{opacity:`0.65`,"font-size":`12px`},header:{display:`flex`,"align-items":`baseline`,"justify-content":`space-between`,gap:`8px`},model:{"font-weight":`600`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},total:{"font-variant-numeric":`tabular-nums`,"white-space":`nowrap`},section:{display:`flex`,"flex-direction":`column`,gap:`6px`},row:{display:`grid`,"grid-template-columns":`1fr auto 64px auto`,"align-items":`center`,gap:`6px`},label:{opacity:`0.8`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},value:{"font-variant-numeric":`tabular-nums`},track:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.15`,overflow:`hidden`},fill:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.9`},share:{opacity:`0.65`,"text-align":`right`,"font-variant-numeric":`tabular-nums`},meta:{display:`flex`,gap:`8px`,opacity:`0.6`,"font-size":`11px`},sub:{display:`flex`,"justify-content":`space-between`,gap:`8px`,"font-size":`11px`},subName:{overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`,opacity:`0.85`},title:{"font-weight":`600`,opacity:`0.85`}};function S(r){let i=(0,t.usePlugin)(),[a,S]=(0,n.createSignal)([]),[C,w]=(0,n.createSignal)(``),[T,E]=(0,n.createSignal)(!1);(0,n.createEffect)(()=>{let e=r.session.id,t=!1,a=!1;E(!1),S([]),w(``);let s=async()=>{if(!(a||t)){t=!0;try{let t=await i.client.rpc(o).family({sessionID:e});if(a)return;S(Array.isArray(t?.rows)?t.rows:[]),w(``)}catch(e){if(a)return;w(e instanceof Error?e.message:String(e))}finally{t=!1,a||E(!0)}}};s();let c=setInterval(()=>void s(),_);(0,n.onCleanup)(()=>{a=!0,clearInterval(c)})});let D=()=>a()[0],O=()=>a().slice(1),k=e=>{let t=D()?.ctx||0;return t<=0||e<=0?`0%`:`${Math.min(100,Math.max(2,Math.round(e/t*100)))}%`};return(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return T()},get fallback(){return(()=>{var t=u();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t})()},get children(){return[(0,e.createComponent)(n.Show,{get when(){return C()},get children(){var t=c(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,C,n),(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}}),(0,e.createComponent)(n.Show,{get when(){return D()},get fallback(){return(0,e.createComponent)(n.Show,{get when(){return!C()},get children(){var t=d();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}})},children:t=>[(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t().modelID||`unknown model`),(0,e.insert)(i,()=>s(t().ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t().usable>0);return()=>n()?` / ${s(t().usable)} (${b(t().ctx,t().usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.header,s=x.model,c=t().modelID,l=x.total;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})(),(()=>{var r=l();return(0,e.insert)(r,(0,e.createComponent)(n.For,{each:v,children:n=>(()=>{var r=g(),i=r.firstChild,a=i.nextSibling,o=a.nextSibling,c=o.firstChild,l=o.nextSibling;return(0,e.insert)(i,()=>n[1]),(0,e.insert)(a,()=>s(t()[n[0]])),(0,e.insert)(l,()=>b(t()[n[0]],t().ctx)),(0,e.effect)(s=>{var u=x.row,d=x.label,f=x.value,p=x.track,m={...x.fill,width:k(t()[n[0]])},h=x.share;return s.e=(0,e.style)(r,u,s.e),s.t=(0,e.style)(i,d,s.t),s.a=(0,e.style)(a,f,s.a),s.o=(0,e.style)(o,p,s.o),s.i=(0,e.style)(c,m,s.i),s.n=(0,e.style)(l,h,s.n),s},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0}),r})()})),(0,e.effect)(t=>(0,e.style)(r,x.section,t)),r})(),(()=>{var r=f(),i=r.firstChild,a=i.nextSibling;return(0,e.insert)(i,()=>y(t().updatedAt)),(0,e.insert)(a,()=>t().source||`none`),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().stale},get children(){return p()}}),null),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().reserve>0},get children(){var n=m();return n.firstChild,(0,e.insert)(n,()=>s(t().reserve),null),n}}),null),(0,e.effect)(t=>(0,e.style)(r,x.meta,t)),r})(),(0,e.createComponent)(n.Show,{get when(){return O().length>0},get children(){return[(()=>{var t=h(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,()=>O().length,n),(0,e.effect)(n=>(0,e.style)(t,x.title,n)),t})(),(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.For,{get each(){return O()},children:t=>(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.agent||`sub`),(0,e.insert)(i,()=>s(t.ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t.usable>0);return()=>n()?` (${b(t.ctx,t.usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.sub,s=x.subName,c=t.modelID,l=x.value;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})()})),(0,e.effect)(n=>(0,e.style)(t,x.section,n)),t})()]}})]})]}})),(0,e.effect)(n=>(0,e.style)(t,x.panel,n)),t})()}var C=`context`,w=`window-analytics`,T=`Context`,E=5e3,D=65,O=85,k={id:C,title:T},A=t.Plugin.define({setup(r){let i=`${r.id}:${C}`,a=e=>{let t=e??r.sessions.current();t&&r.layout.open(i,t,{tab:`select`})};r.add(t.Panel,()=>({id:C,region:`side`,wide:!0,list:e=>e.open.includes(C)?[k]:[],render:t=>(0,e.createComponent)(S,{get session(){return t.session}}),mobile:{title:T,order:1,kind:`tab`,icon:w}})),r.add(t.MenuItem,()=>({id:C,menu:`session.panel`,title:T,icon:w,description:`Context-window usage`,run:()=>a()})),r.add(t.Command,()=>({id:`open`,title:`Open context panel`,description:`Context-window usage for this session`,keywords:`context tokens window panel`,section:`session`,slash:{name:`cx`},run:()=>a()}));let[s,c]=(0,n.createSignal)(void 0),l=async()=>{let e=r.sessions.current();if(!e)c(void 0);else try{let t=(await r.client.rpc(o).family({sessionID:e.id}))?.rows?.[0];if(!t||t.usable<=0){c(void 0);return}let n=t.ctx/t.usable*100;c({sessionID:e.id,label:`${n.toFixed(0)}% ctx`,tone:n>=O?`danger`:n>=D?`warning`:void 0})}catch{c(void 0)}};l();let u=setInterval(()=>void l(),E);(0,n.onCleanup)(()=>clearInterval(u)),r.add(t.ComposerPill,()=>({id:`usage`,show:({session:e})=>{let t=s();if(t&&t.sessionID===e.id)return{label:t.label,icon:w,tone:t.tone,title:`Context window usage`,run:()=>a(e)}}}))}});exports.default=A;
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js"),r=require("@opencode/plugin/rpc");var i={sessionID:{type:`string`},parentID:{type:`string`},agent:{type:`string`},modelID:{type:`string`},providerID:{type:`string`},ctx:{type:`number`},input:{type:`number`},usable:{type:`number`},reserve:{type:`number`},user:{type:`number`},assistant:{type:`number`},reasoning:{type:`number`},toolArgs:{type:`number`},toolOut:{type:`number`},system:{type:`number`},toolSchemas:{type:`number`},other:{type:`number`},source:{type:`string`},updatedAt:{type:`string`},stale:{type:`boolean`}},a=Object.keys(i),o=r.Rpc.define({id:`context-indicator`,methods:{family:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{rows:{type:`array`,items:{type:`object`,properties:i,required:a,additionalProperties:!1}}},required:[`rows`],additionalProperties:!1}}},events:{}});function s(e){return Number.isFinite(e)?e>=1e3?`${(e/1e3).toFixed(1)}k`:String(Math.round(e)):`0`}var c=(0,e.template)(`<div>Server part unavailable: <!> (retrying)`),l=(0,e.template)(`<div>`),u=(0,e.template)(`<div>Loading context...`),d=(0,e.template)(`<div>No measurement yet. Send a prompt in this session and the numbers appear here.`),f=(0,e.template)(`<div><span></span><span>`),p=(0,e.template)(`<span>stale after compaction`),m=(0,e.template)(`<span>reserve `),h=(0,e.template)(`<div>Subagents (<!>)`),g=(0,e.template)(`<div><span></span><span></span><span><span style=display:block></span></span><span>`),_=1500,v=[[`system`,`System`],[`toolSchemas`,`Tool schemas`],[`user`,`User`],[`assistant`,`Assistant`],[`reasoning`,`Reasoning`],[`toolArgs`,`Tool args`],[`toolOut`,`Tool output`],[`other`,`Other`]];function y(e){if(!e)return`?`;try{let t=new Date(e);if(Number.isNaN(t.getTime()))return`?`;let n=e=>String(e).padStart(2,`0`);return`${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`}catch{return`?`}}function b(e,t){return!t||t<=0?``:`${(e/t*100).toFixed(0)}%`}var x={panel:{display:`flex`,"flex-direction":`column`,gap:`10px`,padding:`12px`,"font-size":`12px`,"line-height":`1.45`},hint:{opacity:`0.65`,"font-size":`12px`},header:{display:`flex`,"align-items":`baseline`,"justify-content":`space-between`,gap:`8px`},model:{"font-weight":`600`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},total:{"font-variant-numeric":`tabular-nums`,"white-space":`nowrap`},section:{display:`flex`,"flex-direction":`column`,gap:`6px`},row:{display:`grid`,"grid-template-columns":`1fr auto 64px auto`,"align-items":`center`,gap:`6px`},label:{opacity:`0.8`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},value:{"font-variant-numeric":`tabular-nums`},track:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.15`,overflow:`hidden`},fill:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.9`},share:{opacity:`0.65`,"text-align":`right`,"font-variant-numeric":`tabular-nums`},meta:{display:`flex`,gap:`8px`,opacity:`0.6`,"font-size":`11px`},sub:{display:`flex`,"justify-content":`space-between`,gap:`8px`,"font-size":`11px`},subName:{overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`,opacity:`0.85`},title:{"font-weight":`600`,opacity:`0.85`}};function S(r){let i=(0,t.usePlugin)(),[a,S]=(0,n.createSignal)([]),[C,w]=(0,n.createSignal)(``),[T,E]=(0,n.createSignal)(!1);(0,n.createEffect)(()=>{let e=r.session.id,t=!1,a=!1;E(!1),S([]),w(``);let s=async()=>{if(!(a||t)){t=!0;try{let t=await i.client.rpc(o).family({sessionID:e});if(a)return;S(Array.isArray(t?.rows)?t.rows:[]),w(``)}catch(e){if(a)return;w(e instanceof Error?e.message:String(e))}finally{t=!1,a||E(!0)}}};s();let c=setInterval(()=>void s(),_);(0,n.onCleanup)(()=>{a=!0,clearInterval(c)})});let D=()=>a()[0],O=()=>a().slice(1).sort((e,t)=>(t.ctx||0)-(e.ctx||0)),k=e=>{let t=D()?.ctx||0;return t<=0||e<=0?`0%`:`${Math.min(100,Math.max(2,Math.round(e/t*100)))}%`};return(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return T()},get fallback(){return(()=>{var t=u();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t})()},get children(){return[(0,e.createComponent)(n.Show,{get when(){return C()},get children(){var t=c(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,C,n),(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}}),(0,e.createComponent)(n.Show,{get when(){return D()},get fallback(){return(0,e.createComponent)(n.Show,{get when(){return!C()},get children(){var t=d();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}})},children:t=>[(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t().modelID||`unknown model`),(0,e.insert)(i,()=>s(t().ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t().usable>0);return()=>n()?` / ${s(t().usable)} (${b(t().ctx,t().usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.header,s=x.model,c=t().modelID,l=x.total;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})(),(()=>{var r=l();return(0,e.insert)(r,(0,e.createComponent)(n.For,{each:v,children:n=>(()=>{var r=g(),i=r.firstChild,a=i.nextSibling,o=a.nextSibling,c=o.firstChild,l=o.nextSibling;return(0,e.insert)(i,()=>n[1]),(0,e.insert)(a,()=>s(t()[n[0]])),(0,e.insert)(l,()=>b(t()[n[0]],t().ctx)),(0,e.effect)(s=>{var u=x.row,d=x.label,f=x.value,p=x.track,m={...x.fill,width:k(t()[n[0]])},h=x.share;return s.e=(0,e.style)(r,u,s.e),s.t=(0,e.style)(i,d,s.t),s.a=(0,e.style)(a,f,s.a),s.o=(0,e.style)(o,p,s.o),s.i=(0,e.style)(c,m,s.i),s.n=(0,e.style)(l,h,s.n),s},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0}),r})()})),(0,e.effect)(t=>(0,e.style)(r,x.section,t)),r})(),(()=>{var r=f(),i=r.firstChild,a=i.nextSibling;return(0,e.insert)(i,()=>y(t().updatedAt)),(0,e.insert)(a,()=>t().source||`none`),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().stale},get children(){return p()}}),null),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().reserve>0},get children(){var n=m();return n.firstChild,(0,e.insert)(n,()=>s(t().reserve),null),n}}),null),(0,e.effect)(t=>(0,e.style)(r,x.meta,t)),r})(),(0,e.createComponent)(n.Show,{get when(){return O().length>0},get children(){return[(()=>{var t=h(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,()=>O().length,n),(0,e.effect)(n=>(0,e.style)(t,x.title,n)),t})(),(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.For,{get each(){return O()},children:t=>(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.agent||`sub`),(0,e.insert)(i,()=>s(t.ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t.usable>0);return()=>n()?` (${b(t.ctx,t.usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.sub,s=x.subName,c=t.modelID,l=x.value;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})()})),(0,e.effect)(n=>(0,e.style)(t,x.section,n)),t})()]}})]})]}})),(0,e.effect)(n=>(0,e.style)(t,x.panel,n)),t})()}var C=`context`,w=`window-analytics`,T=`Context`,E=5e3,D=65,O=85,k={id:C,title:T},A=t.Plugin.define({setup(r){let i=`${r.id}:${C}`,a=e=>{let t=e??r.sessions.current();t&&r.layout.open(i,t,{tab:`select`})};r.add(t.Panel,()=>({id:C,region:`side`,wide:!0,list:e=>e.open.includes(C)?[k]:[],render:t=>(0,e.createComponent)(S,{get session(){return t.session}}),mobile:{title:T,order:1,kind:`tab`,icon:w}})),r.add(t.MenuItem,()=>({id:C,menu:`session.panel`,title:T,icon:w,description:`Context-window usage`,run:()=>a()})),r.add(t.Command,()=>({id:`open`,title:`Open context panel`,description:`Context-window usage for this session`,keywords:`context tokens window panel`,section:`session`,slash:{name:`cx`},run:()=>a()}));let[s,c]=(0,n.createSignal)(void 0),l=async()=>{let e=r.sessions.current();if(!e)c(void 0);else try{let t=(await r.client.rpc(o).family({sessionID:e.id}))?.rows?.[0];if(!t||t.usable<=0){c(void 0);return}let n=t.ctx/t.usable*100;c({sessionID:e.id,label:`${n.toFixed(0)}% ctx`,tone:n>=O?`danger`:n>=D?`warning`:void 0})}catch{c(void 0)}};l();let u=setInterval(()=>void l(),E);(0,n.onCleanup)(()=>clearInterval(u)),r.add(t.ComposerPill,()=>({id:`usage`,show:({session:e})=>{let t=s();if(t&&t.sessionID===e.id)return{label:t.label,icon:w,tone:t.tone,title:`Context window usage`,run:()=>a(e)}}}))}});exports.default=A;
package/index.js CHANGED
@@ -184,6 +184,7 @@ import { breakdownCache, breakdownForModel, lastKnownCacheRead, lastKnownCacheWr
184
184
  import { registerDesktopRpc, toDesktopRow } from "./lib/desktop.js";
185
185
  import {
186
186
  logErr,
187
+ pruneSessionState,
187
188
  readStateSnapshot,
188
189
  writeStateFile,
189
190
  } from "./lib/state.js";
@@ -201,6 +202,7 @@ export {
201
202
  sessionAgentCache,
202
203
  sessionParentCache,
203
204
  writeStateFile,
205
+ pruneSessionState,
204
206
  hydrateModelLimitsFromState,
205
207
  getUsableContext,
206
208
  getModelLimit,
package/lib/state.js CHANGED
@@ -429,6 +429,103 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
429
429
  // 7 days: a tombstone only matters while a sibling/parent poll can still see it.
430
430
  const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
431
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
+ }
528
+
432
529
  function writeStateFile(
433
530
  sessionID,
434
531
  modelID,
@@ -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
@@ -817,24 +924,23 @@ function writeStateFile(
817
924
  .slice(MAX_TRACKED_SESSIONS)
818
925
  .forEach((k) => delete all[k]);
819
926
  }
820
- // Random temp suffix + exclusive create (CWE-377), then atomic rename.
821
- const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
822
- writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
823
- let renamed = false;
824
- try {
825
- ensureRegularFile(STATE_FILE); // never rename over a planted symlink
826
- renamed = renameWithRetry(tmp, STATE_FILE);
827
- } finally {
828
- // A rename that exhausted its retries leaves the temp file behind; drop
829
- // it so repeated failures cannot litter %TEMP% (best-effort: on a
830
- // SUCCESSFUL rename the temp is already gone, existsSync filters that).
831
- try {
832
- if (existsSync(tmp)) unlinkSync(tmp);
833
- } catch {
834
- /* 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
+ }
835
939
  }
836
940
  }
837
- return renamed;
941
+ const committed = commitState(all);
942
+ if (committed && replaying) pendingPruneRoots.clear();
943
+ return committed;
838
944
  } finally {
839
945
  // Release ONLY a lock we actually own (lock === null => fail-open, skip).
840
946
  if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
@@ -931,10 +1037,84 @@ function pushFinalSummary(claimKey, summaryLines) {
931
1037
  }
932
1038
  }
933
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
+
934
1113
  export {
935
1114
  logErr,
936
1115
  writeLog,
937
1116
  writeStateFile,
1117
+ pruneSessionState,
938
1118
  debugEvent,
939
1119
  readStateSnapshot,
940
1120
  pushFinalSummary,
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;
package/lib/v2.js CHANGED
@@ -66,6 +66,7 @@ import { locationDirectory } from "./config.js";
66
66
  import {
67
67
  debugEvent,
68
68
  logErr,
69
+ pruneSessionState,
69
70
  pushFinalSummary,
70
71
  readStateSnapshot,
71
72
  writeLog,
@@ -995,10 +996,11 @@ async function handleV2Event(ctx, event) {
995
996
  if (event.type === "session.deleted") {
996
997
  const sid = event.data?.sessionID;
997
998
  if (!sid) return;
998
- // Discovery reads the persisted parentID chain, so a deleted session drops
999
- // out of the table as soon as its state.json entry is gone (or, while the
1000
- // stale entry lingers, via the phantom filter — a deleted session has no
1001
- // step). No dedicated in-memory index to clean up.
999
+ // Discovery reads the persisted parentID chain, so the entry has to go too:
1000
+ // clearing the in-memory caches alone leaves the row in state.json until the
1001
+ // tombstone (7 days) or the LRU cap drops it, and there is no step event left
1002
+ // to filter it out as a phantom.
1003
+ pruneSessionState(sid);
1002
1004
  clearSessionTracking(sid);
1003
1005
  // Whatever this session was, its own descendants must go too: a deleted
1004
1006
  // parent takes the whole subtree, and a deleted child takes its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, a TUI sidebar, and an experimental Desktop panel. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",