`);function Os(e,t){Ue(t,!0);let n=Wi(t,`active`,3,!1),r=F(()=>ao(t.session)),i=F(()=>!!K(r)),a=L(!1),o=L(``),s=L(!1);vn(()=>{if(!K(s))return;let e=setTimeout(()=>{R(s,!1)},150);return()=>clearTimeout(e)}),vn(()=>{K(r),t.session.status,R(o,``)});async function c(){if(!K(i)||K(a))return;let e=K(r);R(a,!0),R(s,!0),R(o,``);try{await navigator.clipboard.writeText(e),K(r)===e&&R(o,`Copied.`)}catch{K(r)===e&&R(o,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{R(a,!1)}}function l(e){!n()||!K(i)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),c())}var u={copy:c},d=Nr();xr(`keydown`,Qt,l);var f=B(d),p=e=>{var t=Ds(),n=z(t),r=V(n,!0),i=H(n,2);let l;P(t),U(()=>{X(r,K(o)),i.disabled=K(a),l=ci(i,1,`svelte-543j00`,null,l,{flashed:K(s)})}),q(`click`,i,c),Y(e,t)};return Z(f,e=>{K(i)&&e(p)}),Y(e,d),We(u)}Sr([`click`]);var ks=J(`
`,1),As=J(`
LATEST GUIDANCE
`,1),js=J(`
CONTEXT
`,1),Ms=J(`
`),Ns=J(`
`),Ps=J(`
`),Fs=J(`
`,1),Is=J(`
`),Ls=J(`
Command unavailable from this observation. Open Codex to inspect the
request.
`),Rs=J(`
`,1),zs=J(`
`),Bs=J(` `,1),Vs=J(`
`),Hs=J(`
Session connection or refresh unavailable. Context and telemetry may be
stale.
`),Us=J(`
Some quota profile checks are unavailable. Only sessions with freshly
verified quota and settings can be updated; previous outcome notices remain
visible.
↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK
`,1),fc=J(`
SESSION TOTALS
`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(a,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(o)});var s=H(a,2),c=e=>{Y(e,Mr(`+`))};Z(s,e=>{t().runningLimited&&e(c)}),P(r);var l=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(l,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return[`MODEL BUSY`,`TURN FAILED`].includes(e.status)}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``}
+ Codexometer.
`),dc=J(`
↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK
`,1),fc=J(`
SESSION TOTALS
`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.contextKind===`TURN ERROR`}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``}
${o??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>K(t).status===`APPROVAL NEEDED`||K(t).status===`INPUT NEEDED`||y(K(t)),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)});var r=H(n,2);Xr(r,17,()=>K(o).filter(e=>e.pending&&K(a).some(t=>t.id===e.session)),Kr,(e,t)=>{var n=Gs(),r=V(n);U((e,a,o)=>{Q(n,`title`,i().id&&i().id!==K(t).session&&K(u)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),X(r,`QUOTA THRESHOLD ${a??``} // ${o??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session)+`?review=profile`,()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory||K(t).session]),q(`click`,n,e=>d(e,K(t).session)),Y(e,n)});var l=H(r,2);Xr(l,17,()=>K(C),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,i)=>{Q(n,`href`,e),X(r,`TURN COMPLETE ${i??``} //
${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)}),Xr(H(l,2),17,()=>K(s),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,t,i)=>{Q(n,`href`,e),X(r,`TRIGGER SET ${t??``} // ${i??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session),()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory]),q(`click`,n,e=>c(e,K(t).session)),Y(e,n)}),P(t),Y(e,t)},de=F(()=>(K(x).length||K(C).length||K(o).some(e=>e.pending)||K(s).length)&&!K(v));Z(le,e=>{K(de)&&e(ue)});var fe=H(le,2),j=e=>{var t=Nr(),a=B(t),s=e=>{var t=Qs(),a=z(t),s=z(a);let c;var d=z(s);let f;var h=H(d);P(s);var g=H(s,2);P(a);var _=H(a,2),b=e=>{var t=zs(),n=V(t,!0);U(()=>X(n,K(p).directory)),Y(e,t)};Z(_,e=>{K(p).name&&K(p).directory&&e(b)});var x=H(_,2),S=V(x),C=H(x,2);let T;var E=z(C),D=z(E);n(D,()=>K(p),()=>!0,()=>!0),P(E);var ee=H(E,2),te=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{{let t=F(()=>new URLSearchParams(Yi.querystring).get(`schedule`)===`edit`),n=F(()=>K(p).fileChanges||[]);xs(e,{get session(){return K(p).id},oncopy:()=>K(r)?.copy(),get openScheduler(){return K(t)},get observedCommand(){return K(p).command},get observedFileChanges(){return K(n)},get suspended(){return K(m)},onProtectedChange:e=>{R(u,e,!0)}})}}),Y(e,t)},O=e=>{var t=Js();Gr(H(z(t),2),()=>K(p).id,e=>{Po(e,{get session(){return K(p).id},localOnly:!0,oncopy:()=>K(r)?.copy()})}),P(t),Y(e,t)};Z(ee,e=>{$.data?.control?e(te):e(O,-1)}),P(C);var ne=H(C,2);Xr(ne,17,()=>K(o).filter(e=>e.session===K(p).id),e=>e.session,(e,t)=>{var n=Zs(),r=z(n),i=e=>{var n=Ys(),r=V(n,!0);U(()=>X(r,K(t).notice)),Y(e,n)};Z(r,e=>{K(t).notice&&e(i)});var a=H(r,2),o=e=>{xs(e,{get session(){return K(p).id},review:`profile`})},s=e=>{var t=Xs();U(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(p).id)+`?review=profile`]),Y(e,t)};Z(a,e=>{K(t).pending&&K(m)?e(o):K(t).pending&&e(s,1)}),P(n),U(()=>Q(n,`id`,`quota-profile-`+K(p).id)),Y(e,n)});var k=H(ne,2),re=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{Ii(Os(e,{get session(){return K(p)},active:!0}),e=>R(r,e,!0),()=>K(r))}),Y(e,t)};Z(k,e=>{K(m)||e(re)}),Gr(H(k,2),()=>K(p).id,e=>{Es(e,{get session(){return K(p)},get stale(){return K(v)}})}),P(t),U((e,t,n,r)=>{c=ci(s,1,`svelte-mm4wfe`,null,c,{failed:e}),f=ci(d,1,`lamp lit svelte-mm4wfe`,null,f,{failed:t,working:K(p).status===`WORKING`&&!K(v)}),X(h,`${n??``} // ${(K(p).name||K(p).directory)??``}`),X(S,`${r??``} TOKENS // ${K(p).id??``} // CONTEXT SOURCE // ${(K(p).source||`LOCAL`)??``}`),T=ui(C,``,T,{display:K(m)?`none`:void 0})},[()=>y(K(p))&&!K(v)&&!K(m),()=>y(K(p))&&!K(v),()=>K(v)?`STALE`:K(m)?`QUOTA THRESHOLD`:l(K(p)),()=>pa(K(p).tokens)]),q(`click`,g,()=>{w(i().id),fa(i().id,2)}),Y(e,t)},c=e=>{Y(e,$s())};Z(a,e=>{K(p)?e(s):e(c,-1)}),Y(e,t)},pe=e=>{var t=dc(),r=B(t),i=H(z(r),2),s=z(i),c=H(s,2);P(i),P(r);var u=H(r,2);Xr(u,17,()=>K(a),e=>e.id,(e,t)=>{let r=F(()=>ua(K(t).id));var i=lc();let a;var s=z(i),c=z(s),u=e=>{var n=ec(),r=z(n),i=V(r);P(n),U(e=>{Q(r,`aria-pressed`,K(h)===K(t).id),X(i,`${e??``} // ${K(t).name??``}`)},[()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,r,()=>w(K(t).id)),Y(e,n)};Z(c,e=>{K(t).name&&e(u)});var f=H(c,2),p=z(f);let m;var g=z(p);let _;var b=H(g,1,!0);P(p),P(f);var x=H(f,2),S=e=>{var n=tc(),r=V(n,!0);U(()=>{Q(n,`aria-pressed`,K(h)===K(t).id),X(r,K(t).directory||K(t).id)}),q(`click`,n,()=>w(K(t).id)),Y(e,n)};Z(x,e=>{K(t).name||e(S)});var C=H(x,2),E=z(C);xe(),P(C);var D=H(C,2),te=e=>{var n=zs(),r=V(n,!0);U(()=>X(r,K(t).directory)),Y(e,n)};Z(D,e=>{K(t).name&&K(t).directory&&e(te)});var O=H(D,2),ne=V(O),k=H(O,2),re=V(k),ie=H(k,2),ae=z(ie),oe=H(ae,2),se=V(oe,!0),A=H(oe,2);P(ie);var ce=H(ie,2),le=H(ce,2),ue=e=>{var n=nc(),r=V(n,!0);U(e=>X(r,e),[()=>K(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:y(K(t))?`OPEN DETAIL FOR THE TURN ERROR`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:K(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`]),Y(e,n)},de=F(()=>!K(v)&&([`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(K(t).status)||y(K(t))));Z(le,e=>{K(de)&&e(ue)}),P(s);var fe=H(s,2),j=e=>{var r=sc(),i=z(r),a=z(i),s=V(a,!0),c=H(a,2),l=e=>{var n=rc(),r=V(n,!0);U(e=>{Q(n,`href`,e),X(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id)]),Y(e,n)};Z(c,e=>{!K(v)&&K(t).status===`APPROVAL NEEDED`&&e(l)}),P(i);var u=H(i,2);n(u,()=>K(t),()=>!1);var f=H(u,2);Xr(f,17,()=>K(o).filter(e=>e.session===K(t).id),Kr,(e,n)=>{var r=oc(),i=B(r),a=e=>{var t=ic(),r=V(t,!0);U(()=>X(r,K(n).notice)),Y(e,t)};Z(i,e=>{K(n).notice&&e(a)});var o=H(i,2),s=e=>{var n=ac();U(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(t).id)+`?review=profile`]),q(`click`,n,e=>d(e,K(t).id)),Y(e,n)};Z(o,e=>{K(n).pending&&e(s)}),Y(e,r)});var p=H(f,2);{let e=F(()=>K(h)===K(t).id);Os(p,{get session(){return K(t)},get active(){return K(e)}})}P(r),U(e=>X(s,e),[()=>ee(K(t))]),Y(e,r)};Z(fe,e=>{K(r)>0&&e(j)});var pe=H(fe,2),me=e=>{var n=cc(),r=H(z(n),2);{let e=F(()=>(K(t).samples||[]).map(e=>e.tokens));io(r,{get values(){return K(e)},capacity:120})}var i=V(H(r,2));P(n),U(()=>X(i,`LAST ${(K(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),Y(e,n)};Z(pe,e=>{K(r)<2&&e(me)}),P(i),U((e,n,o,s,c,l,u)=>{a=ci(i,1,`session-row`,null,a,{selected:K(h)===K(t).id,wide:K(r)===2,split:K(r)===1}),Q(i,`aria-label`,`Session `+(K(t).name||K(t).directory||K(t).id)),m=ci(p,1,`status-detail svelte-mm4wfe`,null,m,{failed:e}),Q(p,`href`,n),_=ci(g,1,`lamp lit svelte-mm4wfe`,null,_,{failed:o,working:K(t).status===`WORKING`&&!K(v)}),X(b,s),X(E,`${c??``} `),X(ne,`${K(t).agents??``} LINKED AGENTS`),X(re,`ACTIVE // ${l??``}`),ae.disabled=K(r)===0,Q(oe,`aria-expanded`,K(r)>0),X(se,K(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(ce,`href`,u)},[()=>y(K(t))&&!K(v),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>y(K(t))&&!K(v),()=>K(v)?`STALE`:l(K(t)),()=>pa(K(t).tokens),()=>ma(K(t).activity),()=>`#/sessions/`+encodeURIComponent(K(t).id)]),q(`click`,p,()=>w(K(t).id)),q(`click`,ae,()=>T(K(t).id,-1)),q(`click`,oe,()=>{w(K(t).id),fa(K(t).id,+!K(r))}),q(`click`,A,()=>T(K(t).id,1)),q(`click`,ce,()=>w(K(t).id)),Y(e,i)});var f=H(u,2),p=e=>{Y(e,uc())};Z(f,e=>{K(a).length||e(p)}),q(`click`,s,()=>da(1)),q(`click`,c,()=>da(0)),Y(e,t)};Z(fe,e=>{i().id?e(j):e(pe,-1)}),U(e=>{X(ne,`OBSERVED ${e??``}`),re=ci(k,1,`session-totals`,null,re,{stale:K(v)}),X(ae,`${K(v)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens
observed since this server started for currently listed sessions; linked
diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html
index efe56cb0..381d8ec1 100644
--- a/internal/web/dist/index.html
+++ b/internal/web/dist/index.html
@@ -5,7 +5,7 @@
Codexometer // Experimental web
-
+
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 2f6a76da..23d818b8 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -336,20 +336,24 @@ func TestDisplayProjectionAndAccountIsolation(t *testing.T) {
func TestWorkingContextProjection(t *testing.T) {
s := newStore()
+ now := time.Now()
row := codex.LiveUsageSession{ID: "root", Working: true, Context: codex.SessionContext{
Kind: codex.SessionContextActivity, Text: "Explanation", Activity: codex.SessionActivity{
- Prose: "Explanation", Command: "go test", CommandStatus: "running", RunningCommands: 2,
+ Prose: "Explanation", Command: "go test", CommandStatus: "running", RunningCommands: 2, CommandStartedAt: now.Add(-42 * time.Second),
},
}}
- s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now())
+ s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, now)
got := s.state.Sessions[0]
if got.Text != "Explanation" || got.WorkingCommand != "go test" || got.CommandStatus != "running" || got.RunningCommands != 2 || got.Command != "" {
t.Fatal(got)
}
+ if got.CommandTiming != "42s" {
+ t.Fatal("recorded timer not projected", got)
+ }
for _, kind := range []codex.SessionContextKind{codex.SessionContextReply, codex.SessionContextApproval, codex.SessionContextQuestion} {
row.Context.Kind = kind
s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now())
- if s.state.Sessions[0].WorkingCommand != "" {
+ if s.state.Sessions[0].WorkingCommand != "" || s.state.Sessions[0].CommandTiming != "" {
t.Fatal("activity leaked into higher-priority context")
}
}
diff --git a/internal/web/session_failure_test.go b/internal/web/session_failure_test.go
index 2fe1d103..73bc9d6a 100644
--- a/internal/web/session_failure_test.go
+++ b/internal/web/session_failure_test.go
@@ -14,4 +14,10 @@ func TestSessionFailurePresentation(t *testing.T) {
if sessionStatus(row) != "TURN FAILED" {
t.Fatal("generic failure mislabeled")
}
+ for code, want := range map[string]string{"modelNotFound": "MODEL UNAVAILABLE", "policyCapacityUnavailable": "CAPACITY RESTRICTED"} {
+ row.Context.FailureCode = code
+ if sessionStatus(row) != want || contextKind(row.Context.Kind) != "TURN ERROR" {
+ t.Fatal("new error lost shared failure classification", row)
+ }
+ }
}
diff --git a/internal/web/state.go b/internal/web/state.go
index 3b33891d..49181c7f 100644
--- a/internal/web/state.go
+++ b/internal/web/state.go
@@ -40,6 +40,7 @@ type session struct {
WorkingCommand string `json:"workingCommand,omitempty"`
ApprovalContext string `json:"approvalContext,omitempty"`
CommandStatus string `json:"commandStatus,omitempty"`
+ CommandTiming string `json:"commandTiming,omitempty"`
RunningCommands int `json:"runningCommands,omitempty"`
RunningLimited bool `json:"runningLimited,omitempty"`
Source string `json:"source"`
@@ -338,6 +339,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) {
Status: sessionStatus(row), ContextKind: contextKind(row.Context.Kind), Text: text,
Command: row.Context.CommandDetails.Command, Source: row.Context.Source, Activity: row.LastActivity,
WorkingCommand: activity.Command, CommandStatus: activity.CommandStatus,
+ CommandTiming: activity.CommandTiming(now),
ApprovalContext: approvalContext,
FileChanges: codex.FileDiffLines(row.Context.FileChanges),
CurrentTask: codex.SanitizeSessionContext(row.Context.CurrentTask), LatestGuidance: codex.SanitizeSessionContext(row.Context.LatestGuidance), Streaming: row.Context.Streaming,
diff --git a/intro-post.md b/intro-post.md
index 1108c405..4e7f1f9e 100644
--- a/intro-post.md
+++ b/intro-post.md
@@ -115,7 +115,7 @@ Use `--reset-warning-hours 24` to warn one day ahead, or `--reset-warning-hours
Expiry warnings are reminders to review, not instructions to reset immediately. Confirmation warns that unused allowance does not carry over or stack and that the weekly schedule changes. Missing or incomplete expiry information is explicitly flagged: no warning is not proof that nothing expires soon, and undisclosed credits may expire sooner.
-Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch.
+Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, **MODEL UNAVAILABLE** for a missing model, **CAPACITY RESTRICTED** for policy-restricted capacity, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch. Working-command headings can also show elapsed/completed time when the shared server supplies valid timestamps.
## An experimental browser companion
diff --git a/web/src/Sessions.svelte b/web/src/Sessions.svelte
index d1f5d639..6a59914c 100644
--- a/web/src/Sessions.svelte
+++ b/web/src/Sessions.svelte
@@ -66,7 +66,7 @@
);
let stale = $derived(!live.connected || !!live.data?.sessionsError);
function isFailure(session: Session) {
- return ['MODEL BUSY', 'TURN FAILED'].includes(session.status);
+ return session.status !== 'STALE' && session.contextKind === 'TURN ERROR';
}
// Parent rows already include linked-agent usage. Sum each listed row once;
// do not add agent counts or activity samples to its observed token counter.
@@ -183,7 +183,11 @@
if (isFailure(session))
return session.status === 'MODEL BUSY'
? 'CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.'
- : 'CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.';
+ : session.status === 'MODEL UNAVAILABLE'
+ ? 'CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.'
+ : session.status === 'CAPACITY RESTRICTED'
+ ? 'CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.'
+ : 'CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.';
if (
live.data?.control &&
['APPROVAL NEEDED', 'INPUT NEEDED'].includes(session.status)
@@ -242,6 +246,8 @@
COMMAND // {stale
? 'UNKNOWN'
: (session.commandStatus || 'unknown').toUpperCase()}
+ {#if session.commandTiming}
+ // {session.commandTiming}{/if}
{#if !stale && (session.runningCommands || 0) > (session.commandStatus === 'running' ? 1 : 0)}
// +{(session.runningCommands || 0) -
(session.commandStatus === 'running' ? 1 : 0)} RUNNING
diff --git a/web/src/state.svelte.ts b/web/src/state.svelte.ts
index c73eb01b..f87603c5 100644
--- a/web/src/state.svelte.ts
+++ b/web/src/state.svelte.ts
@@ -21,6 +21,7 @@ export interface Session {
latestGuidance?: string;
streaming?: boolean;
commandStatus?: string;
+ commandTiming?: string;
runningCommands?: number;
runningLimited?: boolean;
source: string;
diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts
index dfcf74fb..f9e6b997 100644
--- a/web/tests/browser.spec.ts
+++ b/web/tests/browser.spec.ts
@@ -290,6 +290,7 @@ test('working detail preserves prose alongside command state', async ({
activity: '',
samples: [],
workingCommand: 'go test ./...',
+ commandTiming: '',
commandStatus: 'running',
runningCommands: 2,
};
@@ -356,6 +357,7 @@ test('working detail preserves prose alongside command state', async ({
.toBe('go vet ./...');
session.text = 'Checking the build ';
session.commandStatus = 'completed';
+ session.commandTiming = '7s';
session.runningCommands = 0;
await page.evaluate(
(detail) =>
@@ -363,7 +365,10 @@ test('working detail preserves prose alongside command state', async ({
snapshot,
);
await expect(
- page.getByRole('heading', { name: 'COMMAND // COMPLETED', exact: true }),
+ page.getByRole('heading', {
+ name: 'COMMAND // COMPLETED // 7s',
+ exact: true,
+ }),
).toBeVisible();
await expect(page.getByText(session.text, { exact: true })).toBeVisible();
session.status = 'TURN COMPLETE';
@@ -3524,6 +3529,8 @@ test('session failures have a separate count, ordered pills, error detail and li
'INPUT NEEDED',
'APPROVAL NEEDED',
'TURN FAILED',
+ 'MODEL UNAVAILABLE',
+ 'CAPACITY RESTRICTED',
'CHECK SESSION',
].map((status, index) => ({
id: 'failure-test-' + index,
@@ -3532,7 +3539,12 @@ test('session failures have a separate count, ordered pills, error detail and li
tokens: 10,
agents: 0,
status,
- contextKind: ['MODEL BUSY', 'TURN FAILED'].includes(status)
+ contextKind: [
+ 'MODEL BUSY',
+ 'TURN FAILED',
+ 'MODEL UNAVAILABLE',
+ 'CAPACITY RESTRICTED',
+ ].includes(status)
? 'TURN ERROR'
: 'LAST REPLY',
text:
@@ -3551,16 +3563,26 @@ test('session failures have a separate count, ordered pills, error detail and li
const cell = page
.locator('.session-totals > div')
.filter({ has: page.locator('dt').filter({ hasText: /^ERRORS$/ }) });
- await expect(cell.locator('dd')).toHaveText('2');
+ await expect(cell.locator('dd')).toHaveText('4');
const pills = page
.getByRole('navigation', { name: 'Sessions needing attention' })
.getByRole('link');
- await expect(pills).toHaveCount(5);
+ await expect(pills).toHaveCount(7);
await expect(pills.nth(0)).toContainText('APPROVAL NEEDED');
await expect(pills.nth(1)).toContainText('INPUT NEEDED');
await expect(pills.nth(2)).toContainText('MODEL BUSY');
await expect(pills.nth(3)).toContainText('TURN FAILED');
- await expect(pills.nth(4)).toContainText('TURN COMPLETE');
+ await expect(pills.nth(4)).toContainText('MODEL UNAVAILABLE');
+ await expect(pills.nth(5)).toContainText('CAPACITY RESTRICTED');
+ await expect(pills.nth(6)).toContainText('TURN COMPLETE');
+ await pills.nth(4).click();
+ await expect(page.locator('.detail-context')).toContainText(
+ 'provider could not find the selected model',
+ );
+ await pills.nth(5).click();
+ await expect(page.locator('.detail-context')).toContainText(
+ 'policy-restricted route',
+ );
await pills.nth(2).click();
await expect(page.locator('.detail-context')).toContainText(
'Selected model is at capacity.',
@@ -3581,7 +3603,7 @@ test('session failures have a separate count, ordered pills, error detail and li
),
snapshot,
);
- await expect(cell.locator('dd')).toHaveText('1');
+ await expect(cell.locator('dd')).toHaveText('3');
await expect(pills.filter({ hasText: 'MODEL BUSY' })).toHaveCount(0);
await expect(page.locator('.detail-context')).not.toContainText(
'Selected model is at capacity.',
From 27157535c2e81d41069b43e18238df3bc97a6984 Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 18:58:21 +0000
Subject: [PATCH 2/7] FEAT: add opt-in privacy-conscious operational error
logging
---
.gitignore | 1 +
README.md | 43 ++++
internal/codex/benchmark.go | 24 +-
internal/codex/client.go | 16 +-
internal/codex/daemon_status_unix.go | 26 +-
internal/codex/live_usage.go | 12 +-
internal/codex/usage_coverage.go | 30 ++-
internal/codex/usage_coverage_logging_test.go | 104 ++++++++
internal/diagnostics/log.go | 227 ++++++++++++++++++
internal/diagnostics/log_test.go | 181 ++++++++++++++
intro-post.md | 2 +
main.go | 26 ++
main_logging_test.go | 53 ++++
13 files changed, 734 insertions(+), 11 deletions(-)
create mode 100644 internal/codex/usage_coverage_logging_test.go
create mode 100644 internal/diagnostics/log.go
create mode 100644 internal/diagnostics/log_test.go
create mode 100644 main_logging_test.go
diff --git a/.gitignore b/.gitignore
index e56ec82e..6e0ef479 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/codexometer
+/error.log
/dist/
/web/node_modules/
/web/test-results/
diff --git a/README.md b/README.md
index eb010628..45bea6fb 100644
--- a/README.md
+++ b/README.md
@@ -2713,6 +2713,8 @@ open directly.
```text
--codex PATH path to the Codex CLI (default: codex)
--check-auth verify the current Codex login and exit
+--error-log PATH opt into bounded JSON operational diagnostics (off by default)
+--verbose add diagnostic context to --error-log; never raw payloads
--demo preview simulated quota, Sessions, Usage, and benchmark data
--inline render inline instead of using the alternate screen
--web experimental read-only browser interface (loopback only)
@@ -3408,6 +3410,47 @@ establishing a new baseline.
## Troubleshooting
+### Capture operational errors
+
+Logging is off by default. Enable it before reproducing an issue:
+
+```sh
+codexometer --error-log ./error.log
+codexometer --error-log ./error.log --verbose
+# The same switches work in browser mode:
+codexometer --web --error-log ./error.log --verbose
+```
+
+The standard Go structured logger writes JSON lines directly to the requested
+file, never over the terminal UI. Errors include timestamp, source, operation,
+safe error type/category and RPC code when available. Configuration-access
+failures include the exact attempted path, OS error (such as permission denied)
+and a short session identifier. Verbose mode additionally identifies whether
+coverage used effective daemon state or local configuration, missing optional
+files (normal), successful config checks and application start/stop.
+
+For `INCOMPLETE COVERAGE // SESSION …: background usage configuration could not
+be read`, find the corresponding `local-config` entry: `msg` identifies `stat`,
+`open` or `read`, and `path` / `error` identify the failing file and OS cause.
+Malformed TOML and oversized files receive separate operation labels without
+dumping their contents. Logging does not change the coverage guard or repair
+permissions, and cannot reconstruct errors that happened before it was enabled.
+
+The file appends until it would exceed **2 MiB**, then discards its older entries
+and continues with recent diagnostics; no backup files are created. Identical
+events are rate-limited to once a minute, with the suppressed-repeat count on
+the next occurrence. Use a separate path per running instance. The parent
+directory must exist; an explicitly requested unavailable log, symlink or
+non-regular target fails at startup. File permissions are restricted to `0600`
+where supported; on Windows, access also depends on the parent directory's ACL.
+Later log write/close errors are reported when the interface exits.
+
+Prompts, replies, config contents, credentials, pairing links, request/response
+bodies and arbitrary upstream error messages are not logged. Such errors retain
+their type/category and code, not their potentially sensitive text. Paths can
+still reveal usernames/project names: review logs before sharing them. `--verbose`
+requires `--error-log` and does not enable network access or browser writes.
+
### `Codex CLI not found`
Confirm that `codex --version` works in the same shell. Otherwise use
diff --git a/internal/codex/benchmark.go b/internal/codex/benchmark.go
index 4fb51d72..9a64fccd 100644
--- a/internal/codex/benchmark.go
+++ b/internal/codex/benchmark.go
@@ -16,6 +16,7 @@ import (
"sync"
"time"
+ "github.com/merefield/codexometer/internal/diagnostics"
"go.starlark.net/starlark"
)
@@ -667,7 +668,21 @@ func (s *appServerSession) call(
method string,
params any,
onNotification func(string, json.RawMessage),
-) (json.RawMessage, error) {
+) (_ json.RawMessage, resultErr error) {
+ defer func() {
+ if resultErr == nil {
+ return
+ }
+ fields := diagnostics.Fields{}
+ if values, ok := params.(map[string]any); ok {
+ fields.Session, _ = values["threadId"].(string)
+ }
+ var rpc *benchmarkMethodError
+ if errors.As(resultErr, &rpc) {
+ fields.Code = rpc.code
+ }
+ diagnostics.Error("benchmark-rpc", method, resultErr, fields)
+ }()
s.nextID++
id := s.nextID
request := map[string]any{"method": method, "id": id}
@@ -1410,7 +1425,12 @@ func fatalBenchmarkError(ctx context.Context, err error) error {
return err
}
-func (s *appServerSession) readUntilNotification(ctx context.Context, accept func(string, json.RawMessage) bool) (json.RawMessage, error) {
+func (s *appServerSession) readUntilNotification(ctx context.Context, accept func(string, json.RawMessage) bool) (_ json.RawMessage, resultErr error) {
+ defer func() {
+ if resultErr != nil {
+ diagnostics.Error("benchmark-rpc", "read event stream", resultErr, diagnostics.Fields{})
+ }
+ }()
for {
if err := ctx.Err(); err != nil {
return nil, err
diff --git a/internal/codex/client.go b/internal/codex/client.go
index 4bc94cb4..13223af7 100644
--- a/internal/codex/client.go
+++ b/internal/codex/client.go
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/merefield/codexometer/internal/diagnostics"
)
const requestTimeout = 15 * time.Second
@@ -96,7 +98,18 @@ func (c Client) ConsumeResetCredit(ctx context.Context, key, account, creditID s
return attempt.outcome, err
}
-func (c Client) fetch(ctx context.Context, reset *resetAttempt, history *AccountUsage) (Snapshot, error) {
+func (c Client) fetch(ctx context.Context, reset *resetAttempt, history *AccountUsage) (_ Snapshot, resultErr error) {
+ defer func() {
+ if resultErr != nil {
+ operation := "quota refresh"
+ if reset != nil {
+ operation = "quota reset"
+ } else if history != nil {
+ operation = "usage refresh"
+ }
+ diagnostics.Error("app-server", operation, resultErr, diagnostics.Fields{})
+ }
+ }()
binary := c.Binary
if binary == "" {
binary = "codex"
@@ -282,6 +295,7 @@ func responseFor(decoder *json.Decoder, id int) (json.RawMessage, error) {
continue
}
if response.Error != nil {
+ diagnostics.Error("app-server-rpc", "request rejected", nil, diagnostics.Fields{Code: response.Error.Code})
return nil, fmt.Errorf("%s (RPC %d)", response.Error.Message, response.Error.Code)
}
return response.Result, nil
diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go
index a59a76ea..4995600b 100644
--- a/internal/codex/daemon_status_unix.go
+++ b/internal/codex/daemon_status_unix.go
@@ -9,11 +9,13 @@ import (
"fmt"
"net"
"net/http"
+ "os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/websocket"
+ "github.com/merefield/codexometer/internal/diagnostics"
)
const (
@@ -72,6 +74,11 @@ func newSessionStatusProvider(codexHome string) sessionStatusProvider {
func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (sessionDaemonSnapshot, bool) {
if err := p.ensureConnected(ctx); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ diagnostics.Debug("daemon", "not available; using local fallback", diagnostics.Fields{Path: p.socketPath})
+ } else {
+ diagnostics.Error("daemon", "connect", err, diagnostics.Fields{Path: p.socketPath})
+ }
return sessionDaemonSnapshot{}, false
}
if snapshot, ok := p.cachedStatusSnapshot(threadIDs, time.Now()); ok {
@@ -344,7 +351,24 @@ func (p *daemonStatusProvider) request(ctx context.Context, method string, param
return p.requestOn(ctx, connection, method, params, target)
}
-func (p *daemonStatusProvider) requestOn(ctx context.Context, connection *websocket.Conn, method string, params any, target any) error {
+func (p *daemonStatusProvider) requestOn(ctx context.Context, connection *websocket.Conn, method string, params any, target any) (resultErr error) {
+ defer func() {
+ if resultErr == nil {
+ return
+ }
+ fields := diagnostics.Fields{}
+ if values, ok := params.(map[string]any); ok {
+ fields.Session, _ = values["threadId"].(string)
+ }
+ var rpc *daemonResponseError
+ if errors.As(resultErr, &rpc) {
+ var wire struct{ Code int }
+ if json.Unmarshal([]byte(rpc.payload), &wire) == nil {
+ fields.Code = wire.Code
+ }
+ }
+ diagnostics.Error("daemon-rpc", method, resultErr, fields)
+ }()
if err := ctx.Err(); err != nil {
return err
}
diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go
index 651e57f8..4b2738b4 100644
--- a/internal/codex/live_usage.go
+++ b/internal/codex/live_usage.go
@@ -14,6 +14,8 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/merefield/codexometer/internal/diagnostics"
)
const (
@@ -376,7 +378,12 @@ func (r *LiveUsageReader) FetchTokenUsageFresh(ctx context.Context) (LiveUsageSn
return r.fetchTokenUsage(ctx, true)
}
-func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscovery bool) (LiveUsageSnapshot, error) {
+func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscovery bool) (_ LiveUsageSnapshot, resultErr error) {
+ defer func() {
+ if resultErr != nil {
+ diagnostics.Error("local-rollouts", "session telemetry refresh", resultErr, diagnostics.Fields{})
+ }
+ }()
r.mu.Lock()
defer r.mu.Unlock()
if err := contextErr(ctx); err != nil {
@@ -429,6 +436,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
err = r.restoreCompressedCursor(ctx, path, info, cursor)
}
if err != nil {
+ diagnostics.Error("local-rollouts", "restore compressed rollout", err, diagnostics.Fields{Path: path, Session: cursor.threadID})
if firstReadErr == nil {
firstReadErr = err
}
@@ -443,6 +451,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
if errors.Is(err, os.ErrNotExist) {
if info, compressedErr := os.Stat(path + ".zst"); compressedErr == nil {
if compressedErr = r.addCompressedFile(ctx, path+".zst", info); compressedErr != nil {
+ diagnostics.Error("local-rollouts", "read compressed rollout", compressedErr, diagnostics.Fields{Path: path + ".zst", Session: cursor.threadID})
r.discoveryCoverageIssue = "compressed rollout history could not be verified"
if firstReadErr == nil {
firstReadErr = compressedErr
@@ -456,6 +465,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
if firstReadErr == nil {
firstReadErr = err
}
+ diagnostics.Error("local-rollouts", "read rollout", err, diagnostics.Fields{Path: path, Session: cursor.threadID})
continue
}
readableFiles++
diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go
index b32604b7..9b655016 100644
--- a/internal/codex/usage_coverage.go
+++ b/internal/codex/usage_coverage.go
@@ -9,6 +9,7 @@ import (
"sort"
"time"
+ "github.com/merefield/codexometer/internal/diagnostics"
"github.com/pelletier/go-toml/v2"
)
@@ -61,7 +62,10 @@ func (r *LiveUsageReader) usageCoverage(ctx context.Context, sessions []LiveUsag
continue
}
if issue == "" && !verified {
- issue = r.localUsageCoverage(ctx, session.WorkingDirectory)
+ diagnostics.Debug("usage coverage", "using local configuration fallback", diagnostics.Fields{Session: session.ID})
+ issue = r.localUsageCoverage(ctx, session.WorkingDirectory, session.ID)
+ } else if verified {
+ diagnostics.Debug("usage coverage", "using daemon feature result", diagnostics.Fields{Session: session.ID})
}
if issue != "" {
id := []rune(SanitizeSessionContext(session.ID))
@@ -78,7 +82,7 @@ func (r *LiveUsageReader) usageCoverage(ctx context.Context, sessions []LiveUsag
return issues[0]
}
-func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) string {
+func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd, session string) string {
paths := []string{filepath.Join(filepath.Dir(r.SessionsRoot), "config.toml")}
if filepath.IsAbs(cwd) {
for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
@@ -92,7 +96,7 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) st
if err := ctx.Err(); err != nil {
return "background usage configuration check interrupted"
}
- enabled, issue := r.localAsyncCompaction(path)
+ enabled, issue := r.localAsyncCompaction(path, session)
if issue != "" {
return issue
}
@@ -103,12 +107,15 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) st
return ""
}
-func (r *LiveUsageReader) localAsyncCompaction(path string) (bool, string) {
+func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, string) {
+ fields := diagnostics.Fields{Path: path, Session: session}
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
+ diagnostics.Debug("local-config", "configuration not present (normal)", fields)
return false, ""
}
if err != nil {
+ diagnostics.Error("local-config", "stat", err, fields)
return false, "background usage configuration could not be read"
}
if r.localFeatureChecks == nil {
@@ -119,16 +126,27 @@ func (r *LiveUsageReader) localAsyncCompaction(path string) (bool, string) {
}
file, err := os.Open(path)
if err != nil {
+ diagnostics.Error("local-config", "open", err, fields)
return false, "background usage configuration could not be read"
}
data, err := io.ReadAll(io.LimitReader(file, 1<<20+1))
- file.Close()
+ if closeErr := file.Close(); closeErr != nil {
+ diagnostics.Error("local-config", "close", closeErr, fields)
+ }
check := localFeatureCheck{info: info}
var config map[string]any
- if err != nil || len(data) > 1<<20 || toml.Unmarshal(data, &config) != nil {
+ if err != nil {
+ diagnostics.Error("local-config", "read", err, fields)
+ check.issue = "background usage configuration could not be verified"
+ } else if len(data) > 1<<20 {
+ diagnostics.Error("local-config", "configuration exceeds 1 MiB limit", nil, fields)
+ check.issue = "background usage configuration could not be verified"
+ } else if parseErr := toml.Unmarshal(data, &config); parseErr != nil {
+ diagnostics.Error("local-config", "parse TOML", parseErr, fields)
check.issue = "background usage configuration could not be verified"
} else {
check.enabled = hasAsyncCompaction(config)
+ diagnostics.Debug("local-config", "configuration checked", fields)
}
// Only active-session configuration is relevant; retain a bounded cache.
if len(r.localFeatureChecks) >= 256 {
diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go
new file mode 100644
index 00000000..86c6114b
--- /dev/null
+++ b/internal/codex/usage_coverage_logging_test.go
@@ -0,0 +1,104 @@
+package codex
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/merefield/codexometer/internal/diagnostics"
+)
+
+func TestUsageCoverageLogsFilesystemCauseWithoutChangingTheGuard(t *testing.T) {
+ home := t.TempDir()
+ logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml")
+ close, err := diagnostics.Install(logPath, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ // A directory is unreadable as TOML on every target OS: either open or read
+ // reports a real filesystem error without depending on root/ACL privileges.
+ if err := os.Mkdir(config, 0700); err != nil {
+ t.Fatal(err)
+ }
+ r := &LiveUsageReader{}
+ _, issue := r.localAsyncCompaction(config, "session-123456789")
+ if !strings.Contains(issue, "could not be") {
+ t.Fatal("filesystem failure stopped blocking learning", issue)
+ }
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{`"source":"local-config"`, `"session":"session-1234`, `"error_type":`, `config.toml`} {
+ if !strings.Contains(string(data), want) {
+ t.Fatalf("missing %s in %s", want, data)
+ }
+ }
+ if strings.Contains(string(data), "details omitted") {
+ t.Fatal("OS error was hidden", string(data))
+ }
+}
+
+func TestUsageCoverageLogsExactUnreadableConfigurationFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("creating a symlink requires optional Windows privileges")
+ }
+ home := t.TempDir()
+ logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml")
+ if err := os.Symlink(config, config); err != nil {
+ t.Fatal(err)
+ }
+ close, err := diagnostics.Install(logPath, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ r := &LiveUsageReader{}
+ _, issue := r.localAsyncCompaction(config, "session-one")
+ if issue != "background usage configuration could not be read" {
+ t.Fatal("exact access-failure diagnostic changed", issue)
+ }
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := os.ReadFile(logPath)
+ for _, want := range []string{`"msg":"stat"`, `"path":`, `config.toml`, `"error":`, `"session":"session-one"`} {
+ if !strings.Contains(string(data), want) {
+ t.Fatalf("missing %s in %s", want, data)
+ }
+ }
+ if strings.Contains(string(data), "details omitted") {
+ t.Fatal("actual filesystem cause not captured")
+ }
+}
+
+func TestUsageCoverageNeverLogsConfigurationContents(t *testing.T) {
+ home := t.TempDir()
+ logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml")
+ close, err := diagnostics.Install(logPath, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ if err := os.WriteFile(config, []byte("secret = PRIVATE_CONFIG_CONTENT ["), 0600); err != nil {
+ t.Fatal(err)
+ }
+ r := &LiveUsageReader{}
+ _, issue := r.localAsyncCompaction(config, "s")
+ if issue != "background usage configuration could not be verified" {
+ t.Fatal(issue)
+ }
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := os.ReadFile(logPath)
+ if strings.Contains(string(data), "PRIVATE_CONFIG_CONTENT") || !strings.Contains(string(data), "parse TOML") {
+ t.Fatal("unsafe or missing parse diagnostic", string(data))
+ }
+}
diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go
new file mode 100644
index 00000000..84fdd28a
--- /dev/null
+++ b/internal/diagnostics/log.go
@@ -0,0 +1,227 @@
+// Package diagnostics provides opt-in, process-local operational logging.
+// Never pass prompts, replies, credentials, raw protocol data or upstream error
+// text here. Errors are classified, not printed; explicit filesystem paths are
+// retained to make local access failures actionable.
+package diagnostics
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unicode"
+)
+
+const maxBytes = 2 << 20
+
+type Fields struct {
+ Path, Session string
+ Code int
+}
+
+type repetition struct {
+ at time.Time
+ count int
+}
+
+type logger struct {
+ mu sync.Mutex
+ handler slog.Handler
+ verbose bool
+ closed bool
+ writeErr error
+ seen map[string]repetition
+}
+
+var active atomic.Pointer[logger]
+
+// Install leaves logging completely off for an empty path. The returned close
+// restores the previous logger and reports late write errors after the UI exits.
+func Install(path string, verbose bool) (func() error, error) {
+ if path == "" {
+ if verbose {
+ return nil, errors.New("--verbose requires --error-log PATH")
+ }
+ return func() error { return nil }, nil
+ }
+ output, err := openFile(path)
+ if err != nil {
+ return nil, err
+ }
+ level := slog.LevelError
+ if verbose {
+ level = slog.LevelDebug
+ }
+ l := &logger{verbose: verbose, seen: make(map[string]repetition)}
+ l.handler = slog.NewJSONHandler(output, &slog.HandlerOptions{Level: level})
+ previous := active.Swap(l)
+ var once sync.Once
+ var closeErr error
+ return func() error {
+ once.Do(func() {
+ active.CompareAndSwap(l, previous)
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.closed = true
+ closeErr = errors.Join(l.writeErr, output.Close())
+ })
+ return closeErr
+ }, nil
+}
+
+func Error(source, operation string, err error, fields Fields) {
+ record(slog.LevelError, source, operation, err, fields)
+}
+
+func Debug(source, operation string, fields Fields) {
+ record(slog.LevelDebug, source, operation, nil, fields)
+}
+
+func record(level slog.Level, source, operation string, err error, fields Fields) {
+ l := active.Load()
+ if l == nil {
+ return
+ }
+ if errors.Is(err, context.Canceled) {
+ level = slog.LevelDebug
+ }
+ if level < slog.LevelError && !l.verbose {
+ return
+ }
+ source, operation = bounded(source, 80), bounded(operation, 120)
+ path, session := bounded(fields.Path, 2048), bounded(fields.Session, 12)
+ cause := errorClass(err)
+ key := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d", level, source, operation, path, session, cause, fields.Code)
+ now := time.Now()
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.closed {
+ return
+ }
+ previous, exists := l.seen[key]
+ if exists && now.Sub(previous.at) < time.Minute {
+ previous.count++
+ l.seen[key] = previous
+ return
+ }
+ if len(l.seen) >= 128 {
+ clear(l.seen)
+ }
+ l.seen[key] = repetition{at: now}
+ r := slog.NewRecord(now, level, operation, 0)
+ r.AddAttrs(slog.String("app", "codexometer"), slog.String("source", source))
+ if path != "" {
+ r.AddAttrs(slog.String("path", path))
+ }
+ if session != "" {
+ r.AddAttrs(slog.String("session", session))
+ }
+ if fields.Code != 0 {
+ r.AddAttrs(slog.Int("code", fields.Code))
+ }
+ if err != nil {
+ r.AddAttrs(slog.String("error", cause), slog.String("error_type", fmt.Sprintf("%T", err)))
+ }
+ if previous.count > 0 {
+ r.AddAttrs(slog.Int("suppressed_repeats", previous.count))
+ }
+ if err := l.handler.Handle(context.Background(), r); err != nil && l.writeErr == nil {
+ l.writeErr = err
+ }
+}
+
+// Only trusted OS/context sentinel text is emitted. In particular, fmt-wrapped,
+// JSON/TOML and app-server errors can contain secrets or conversation content.
+func errorClass(err error) string {
+ if err == nil {
+ return ""
+ }
+ var errno syscall.Errno
+ if errors.As(err, &errno) {
+ return errno.Error()
+ }
+ for _, known := range []error{fs.ErrPermission, fs.ErrNotExist, fs.ErrExist, fs.ErrClosed, context.Canceled, context.DeadlineExceeded, io.EOF, io.ErrUnexpectedEOF} {
+ if errors.Is(err, known) {
+ return known.Error()
+ }
+ }
+ return "details omitted; inspect operation and error_type"
+}
+
+func bounded(s string, limit int) string {
+ s = strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
+ return -1
+ }
+ return r
+ }, strings.ToValidUTF8(s, "�"))
+ r := []rune(s)
+ if len(r) > limit {
+ return string(r[:limit]) + "…"
+ }
+ return s
+}
+
+// A single requested file is bounded: once full, older entries are discarded.
+// No backup filenames are overwritten and no extra dependency is needed.
+type boundedFile struct {
+ *os.File
+ size, limit int64
+}
+
+func openFile(path string) (*boundedFile, error) {
+ before, err := os.Lstat(path)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return nil, err
+ }
+ if before != nil && !before.Mode().IsRegular() {
+ return nil, errors.New("error log must be a regular file, not a symlink or device")
+ }
+ // Windows append-only handles cannot truncate. One logger serialises its
+ // writes; seek once on open to append across runs without O_APPEND.
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600)
+ if err != nil {
+ return nil, err
+ }
+ info, statErr := f.Stat()
+ after, pathErr := os.Lstat(path)
+ if statErr != nil || pathErr != nil || !info.Mode().IsRegular() || !os.SameFile(info, after) || after.Mode()&os.ModeSymlink != 0 {
+ f.Close()
+ return nil, errors.New("error log target changed or is not a regular file")
+ }
+ if err := f.Chmod(0600); err != nil {
+ f.Close()
+ return nil, err
+ }
+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
+ f.Close()
+ return nil, err
+ }
+ return &boundedFile{File: f, size: info.Size(), limit: maxBytes}, nil
+}
+
+func (f *boundedFile) Write(p []byte) (int, error) {
+ if int64(len(p)) > f.limit {
+ return 0, errors.New("diagnostic record exceeds log limit")
+ }
+ if f.size+int64(len(p)) > f.limit {
+ if err := f.Truncate(0); err != nil {
+ return 0, err
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return 0, err
+ }
+ f.size = 0
+ }
+ n, err := f.File.Write(p)
+ f.size += int64(n)
+ return n, err
+}
diff --git a/internal/diagnostics/log_test.go b/internal/diagnostics/log_test.go
new file mode 100644
index 00000000..6f093253
--- /dev/null
+++ b/internal/diagnostics/log_test.go
@@ -0,0 +1,181 @@
+package diagnostics
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func records(t *testing.T, path string) []map[string]any {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var result []map[string]any
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+ if line == "" {
+ continue
+ }
+ var record map[string]any
+ if err := json.Unmarshal([]byte(line), &record); err != nil {
+ t.Fatal("invalid JSON log record", err)
+ }
+ result = append(result, record)
+ }
+ return result
+}
+
+func TestLoggingIsOptInAndVerboseRequiresAFile(t *testing.T) {
+ close, err := Install("", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ Error("test", "ignored", errors.New("secret"), Fields{})
+ if close() != nil || active.Load() != nil {
+ t.Fatal("default logging installed a sink")
+ }
+ if _, err = Install("", true); err == nil {
+ t.Fatal("verbose logging without a destination was accepted")
+ }
+}
+
+func TestFileErrorsAreActionableButArbitraryErrorTextIsNotLogged(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ close, err := Install(path, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ Debug("test", "hidden debug", Fields{})
+ Error("local-config", "open", &os.PathError{Op: "open", Path: "/config", Err: syscall.EACCES}, Fields{Path: "/config", Session: "abcdefghijkl-secret"})
+ Error("rpc", "turn/start", errors.New("bearer PRIVATE_TOKEN prompt PRIVATE_PROMPT reply PRIVATE_REPLY"), Fields{Code: -32000})
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ r := records(t, path)
+ if len(r) != 2 || r[0]["level"] != "ERROR" || r[0]["path"] != "/config" || r[0]["error"] != syscall.EACCES.Error() || r[0]["session"] != "abcdefghijkl…" || r[1]["code"] != float64(-32000) {
+ t.Fatal("diagnostic context missing", r)
+ }
+ data, _ := os.ReadFile(path)
+ for _, forbidden := range []string{"PRIVATE_TOKEN", "PRIVATE_PROMPT", "PRIVATE_REPLY", "abcdefghijkl-secret", "hidden debug"} {
+ if strings.Contains(string(data), forbidden) {
+ t.Fatal("unsafe or unrequested logging", forbidden)
+ }
+ }
+ info, _ := os.Stat(path)
+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 {
+ t.Fatal("log permissions too broad", info.Mode())
+ }
+}
+
+func TestVerboseDeduplicatesAndIsConcurrent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ close, err := Install(path, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ Debug("coverage", "local fallback", Fields{Session: "one"})
+ var workers sync.WaitGroup
+ for range 50 {
+ workers.Go(func() { Error("config", "stat", os.ErrPermission, Fields{Path: "/config"}) })
+ }
+ workers.Wait()
+ if got := records(t, path); len(got) != 2 || got[0]["level"] != "DEBUG" {
+ t.Fatal("missing verbose context or repeat spam", got)
+ }
+ l := active.Load()
+ l.mu.Lock()
+ for key, value := range l.seen {
+ value.at = value.at.Add(-2 * time.Minute)
+ l.seen[key] = value
+ }
+ l.mu.Unlock()
+ Error("config", "stat", os.ErrPermission, Fields{Path: "/config"})
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ got := records(t, path)
+ if len(got) != 3 || got[2]["suppressed_repeats"] != float64(49) {
+ t.Fatal("suppressed-repeat summary missing", got)
+ }
+}
+
+func TestLogSizeIsBoundedAndLatestEntrySurvives(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ f, err := openFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ f.limit = 64
+ for range 20 {
+ if _, err := f.Write([]byte("{\"msg\":\"old record\"}\n")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := f.Write([]byte("{\"msg\":\"latest record\"}\n")); err != nil {
+ t.Fatal(err)
+ }
+ info, _ := f.Stat()
+ if info.Size() > f.limit {
+ t.Fatal("log grew beyond its limit", info.Size())
+ }
+ got := records(t, path)
+ if got[len(got)-1]["msg"] != "latest record" {
+ t.Fatal("latest diagnostic was lost", got)
+ }
+}
+
+func TestLogAppendsAcrossRuns(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ for _, operation := range []string{"first run", "second run"} {
+ close, err := Install(path, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ Error("test", operation, os.ErrPermission, Fields{})
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got := records(t, path)
+ if len(got) != 2 || got[0]["msg"] != "first run" || got[1]["msg"] != "second run" {
+ t.Fatal("log was overwritten instead of appended", got)
+ }
+}
+
+func TestInvalidTargetsFailWithoutChangingThem(t *testing.T) {
+ dir := t.TempDir()
+ if _, err := Install(dir, false); err == nil {
+ t.Fatal("directory accepted as log")
+ }
+ if _, err := Install(filepath.Join(dir, "missing", "error.log"), false); err == nil {
+ t.Fatal("inaccessible parent accepted")
+ }
+ if runtime.GOOS == "windows" {
+ return
+ }
+ target, link := filepath.Join(dir, "important"), filepath.Join(dir, "link")
+ if err := os.WriteFile(target, []byte("keep me"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Install(link, false); err == nil {
+ t.Fatal("symlink accepted as log")
+ }
+ data, _ := os.ReadFile(target)
+ if string(data) != "keep me" {
+ t.Fatal("symlink target changed")
+ }
+}
diff --git a/intro-post.md b/intro-post.md
index 4e7f1f9e..7c6ad099 100644
--- a/intro-post.md
+++ b/intro-post.md
@@ -117,6 +117,8 @@ Expiry warnings are reminders to review, not instructions to reset immediately.
Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, **MODEL UNAVAILABLE** for a missing model, **CAPACITY RESTRICTED** for policy-restricted capacity, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch. Working-command headings can also show elapsed/completed time when the shared server supplies valid timestamps.
+Troubleshooting a gremlin? Opt into `--error-log ./error.log`, optionally with `--verbose`, in terminal or web mode. Bounded diagnostics capture actionable filesystem failures and safe operational context—not prompts, replies, credentials or raw API payloads. Paths can reveal project names, so review logs before sharing.
+
## An experimental browser companion
Prefer a browser window? `codexometer --web` now offers an **experimental,
diff --git a/main.go b/main.go
index 87263e4f..60484bdc 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/merefield/codexometer/internal/codex"
+ "github.com/merefield/codexometer/internal/diagnostics"
"github.com/merefield/codexometer/internal/digbench"
"github.com/merefield/codexometer/internal/ui"
"github.com/merefield/codexometer/internal/version"
@@ -386,6 +387,8 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
var quotaSteps quotaStepFlags
var (
codexPath = flags.String("codex", "codex", "path to the Codex CLI")
+ errorLog = flags.String("error-log", "", "write bounded operational diagnostics to this file (off by default)")
+ verbose = flags.Bool("verbose", false, "include diagnostic context in --error-log (never raw payloads)")
refresh = flags.Duration("refresh", time.Minute, "quota refresh interval")
demo = flags.Bool("demo", false, "show the UI with simulated quota data")
inline = flags.Bool("inline", false, "render inline instead of using the alternate screen")
@@ -436,6 +439,22 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
fmt.Fprintf(stderr, "codexometer: --reset-warning-hours must be between 0 and %d\n", ui.MaxResetWarningHours)
return 2
}
+ if *verbose && *errorLog == "" {
+ fmt.Fprintln(stderr, "codexometer: --verbose requires --error-log PATH")
+ return 2
+ }
+ closeLog, err := diagnostics.Install(*errorLog, *verbose)
+ if err != nil {
+ fmt.Fprintln(stderr, "codexometer: cannot open error log:", err)
+ return 1
+ }
+ defer func() {
+ if err := closeLog(); err != nil {
+ fmt.Fprintln(stderr, "codexometer: error log write/close failed:", err)
+ }
+ }()
+ diagnostics.Debug("application", "started", diagnostics.Fields{})
+ defer diagnostics.Debug("application", "stopped", diagnostics.Fields{})
// Capture credentials for the benchmark components, then remove them before
// any path that can launch Codex. This keeps auth checks and quota reads on
// the user's prevailing Codex login instead of leaking or implicitly using a
@@ -454,6 +473,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
snapshot, err := deps.checkAuth(context.Background(), *codexPath)
if err != nil {
fmt.Fprintln(stderr, "Codex auth check failed:", err)
+ diagnostics.Error("application", "auth check", err, diagnostics.Fields{})
return 1
}
fmt.Fprintf(stdout, "Codex auth OK // %d limit meter(s) online\n", len(snapshot.Meters()))
@@ -501,12 +521,15 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
client := codex.Client{Binary: *codexPath, QuotaSteps: quotaSteps}
if liveUsage, err := codex.NewLiveUsageReader(""); err == nil {
client.LiveUsage = liveUsage
+ } else {
+ diagnostics.Error("local-rollouts", "initialise session reader", err, diagnostics.Fields{})
}
var source web.Source = client
if *demo {
source = &demoFetcher{quotaSteps: quotaSteps}
}
if err := deps.startWeb(source, *refresh, *webPort, stdout, *webControl); err != nil {
+ diagnostics.Error("application", "web interface", err, diagnostics.Fields{})
fmt.Fprintln(stderr, "codexometer:", err)
return 1
}
@@ -527,6 +550,8 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
client := codex.Client{Binary: *codexPath, BenchmarkAPIKey: benchmarkAPIKey, DigBenchToken: digBenchToken, DigBenchGames: digBenchGames, QuotaSteps: quotaSteps}
if liveUsage, err := codex.NewLiveUsageReader(""); err == nil {
client.LiveUsage = liveUsage
+ } else {
+ diagnostics.Error("local-rollouts", "initialise session reader", err, diagnostics.Fields{})
}
var fetcher ui.Fetcher = client
if *demo {
@@ -534,6 +559,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int {
}
if err := deps.startUI(fetcher, *refresh, *inline, *resetThreshold, *resetWarningHours); err != nil {
+ diagnostics.Error("application", "terminal interface", err, diagnostics.Fields{})
fmt.Fprintln(stderr, "codexometer:", err)
return 1
}
diff --git a/main_logging_test.go b/main_logging_test.go
new file mode 100644
index 00000000..8015e492
--- /dev/null
+++ b/main_logging_test.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/merefield/codexometer/internal/ui"
+ "github.com/merefield/codexometer/internal/web"
+)
+
+func TestLoggingFlagsAndLaunchFailures(t *testing.T) {
+ for _, browser := range []bool{false, true} {
+ path := filepath.Join(t.TempDir(), "error.log")
+ deps := dependencies{
+ startUI: func(ui.Fetcher, time.Duration, bool, int, int) error { return errors.New("PRIVATE_UPSTREAM_BODY") },
+ startWeb: func(web.Source, time.Duration, int, io.Writer, bool) error {
+ return errors.New("PRIVATE_UPSTREAM_BODY")
+ },
+ }
+ args := []string{"--demo", "--error-log", path, "--verbose"}
+ if browser {
+ args = append(args, "--web")
+ }
+ var out, stderr bytes.Buffer
+ if code := run(args, &out, &stderr, deps); code != 1 {
+ t.Fatal("launch failure hidden", code)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{`"msg":"started"`, `"level":"ERROR"`, `"msg":"stopped"`} {
+ if !strings.Contains(string(data), want) {
+ t.Fatal("launch diagnostics missing", string(data))
+ }
+ }
+ if strings.Contains(string(data), "PRIVATE_UPSTREAM_BODY") {
+ t.Fatal("upstream body leaked to log")
+ }
+ }
+ for _, args := range [][]string{{"--verbose"}, {"--error-log", t.TempDir()}, {"--error-log", filepath.Join(t.TempDir(), "missing", "log")}} {
+ var out, stderr bytes.Buffer
+ if run(args, &out, &stderr, dependencies{}) == 0 || stderr.Len() == 0 {
+ t.Fatal("invalid logging flags did not fail clearly", args)
+ }
+ }
+}
From 107cdd606115193c21fc681493888bb59f03ad2a Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 19:44:47 +0000
Subject: [PATCH 3/7] FIX: preserve empty session hook inventories and address
review notes
---
README.md | 10 ++++++----
internal/codex/session_hooks_unix.go | 5 +++++
internal/codex/session_hooks_unix_test.go | 12 ++++++++++--
internal/ui/monitor_working.go | 3 ++-
4 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 45bea6fb..7603dd98 100644
--- a/README.md
+++ b/README.md
@@ -1813,8 +1813,9 @@ writable mode. Browse-only entries do not execute tools or change configuration.
On Codex 0.166.0, `/hooks` queries the selected loaded thread, including hooks
from its execution environments, and identifies their environment when supplied.
Older servers retain directory-based discovery. A real thread/permission/transport
-error is not silently replaced with a different inventory. Model/reasoning,
-permission and Plan changes remain guarded by the existing idle-state checks;
+error is not silently replaced with a different inventory, and a successful empty
+thread inventory remains empty rather than falling back to directory hooks.
+Model/reasoning, permission and Plan changes remain guarded by the existing idle-state checks;
this compatibility update does not broaden write permissions.
The detail footer uses theme-highlighted text in the bottom border in terminal mode, and
@@ -2969,8 +2970,9 @@ available from the existing reader.
outside scrollable reply text; available commands are separated from their
justification. Missing commands are explicitly labelled, never reconstructed.
- **INPUT NEEDED / APPROVAL NEEDED** represent observed signals; **MODEL BUSY /
- TURN FAILED** represent confirmed turn errors and appear in **ERRORS**, with
- the observed explanation in detail. Retrying errors do not raise failure pills.
+ MODEL UNAVAILABLE / CAPACITY RESTRICTED / TURN FAILED** represent confirmed turn
+ errors and appear in **ERRORS**, with the observed explanation in detail.
+ Retrying errors do not raise failure pills.
**CHECK SESSION**
is labelled inferred inactivity and is not proof that a reply is required.
**TURN COMPLETE** is informational. Disconnection or failed session refresh
diff --git a/internal/codex/session_hooks_unix.go b/internal/codex/session_hooks_unix.go
index e1c1b457..89f528f0 100644
--- a/internal/codex/session_hooks_unix.go
+++ b/internal/codex/session_hooks_unix.go
@@ -27,6 +27,11 @@ func (p *daemonStatusProvider) commandHookRows(ctx context.Context, t commandThr
return nil, err
}
} else {
+ // A successful empty inventory is a valid thread result, not evidence
+ // of an unsupported selector. Never replace it with directory hooks.
+ if len(rows) == 0 {
+ return rows, nil
+ }
for _, raw := range rows {
var entry struct{ Cwd string }
if json.Unmarshal(raw, &entry) != nil || entry.Cwd == "" {
diff --git a/internal/codex/session_hooks_unix_test.go b/internal/codex/session_hooks_unix_test.go
index e3dac115..b6240773 100644
--- a/internal/codex/session_hooks_unix_test.go
+++ b/internal/codex/session_hooks_unix_test.go
@@ -9,7 +9,7 @@ import (
)
func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) {
- for _, mode := range []string{"thread", "ignored-selector", "rejected-selector", "real-thread-error"} {
+ for _, mode := range []string{"thread", "empty-inventory", "empty-thread-hooks", "ignored-selector", "rejected-selector", "real-thread-error"} {
t.Run(mode, func(t *testing.T) {
p, f := newQuotaDaemon(t)
row := func(cwd, key, environment string) map[string]any {
@@ -20,6 +20,10 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) {
f.catalogues = map[string]map[string]any{"hooks/list": row("/work", "directory-hook", "")}
f.threadHooks = row("/work", "environment-hook", "remote-env")
switch mode {
+ case "empty-inventory":
+ f.threadHooks = map[string]any{"data": []any{}}
+ case "empty-thread-hooks":
+ f.threadHooks = map[string]any{"data": []any{map[string]any{"cwd": "/work", "hooks": []any{}}}}
case "ignored-selector":
f.threadHooks = row("/server-default-cwd", "wrong-hook", "")
case "rejected-selector":
@@ -32,6 +36,10 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) {
if err == nil {
t.Fatal("hid a real thread failure behind directory discovery")
}
+ } else if strings.HasPrefix(mode, "empty-") {
+ if err != nil || len(m.Choices) != 0 {
+ t.Fatalf("successful empty thread inventory was replaced with directory hooks: %+v, %v", m, err)
+ }
} else {
want := "directory-hook"
if mode == "thread" {
@@ -47,7 +55,7 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) {
f.mu.Lock()
defer f.mu.Unlock()
wantReads := 2
- if mode == "thread" || mode == "real-thread-error" {
+ if mode == "thread" || mode == "real-thread-error" || strings.HasPrefix(mode, "empty-") {
wantReads = 1
}
if len(f.hookReads) != wantReads || f.hookReads[0]["threadId"] != "one" || f.hookReads[0]["cwds"] != nil {
diff --git a/internal/ui/monitor_working.go b/internal/ui/monitor_working.go
index ecde535a..cb63cb6c 100644
--- a/internal/ui/monitor_working.go
+++ b/internal/ui/monitor_working.go
@@ -1,10 +1,11 @@
package ui
import (
+ "time"
+
"github.com/charmbracelet/x/ansi"
"github.com/merefield/codexometer/internal/codex"
"github.com/merefield/codexometer/internal/i18n"
- "time"
)
func hasWorkingCommand(c codex.SessionContext) bool {
From 2c6de017d8a513b662b35561e437d5e2a1016f4e Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 20:00:10 +0000
Subject: [PATCH 4/7] FIX: ignore non-directory optional Codex configuration
paths
---
README.md | 5 ++
internal/codex/usage_coverage.go | 7 +-
internal/codex/usage_coverage_logging_test.go | 27 ++++++
internal/codex/usage_coverage_test.go | 85 +++++++++++++++++++
4 files changed, 123 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7603dd98..4dec6885 100644
--- a/README.md
+++ b/README.md
@@ -806,6 +806,11 @@ available and no missing response cost is invented.
Without effective daemon feature state, relevant local configuration is checked
read-only. Enabled flags, including flags in potentially selected profiles, are
conservative coverage warnings rather than proof of the current runtime mode.
+Missing optional configs, including paths underneath a regular `.codex` file
+instead of a directory, are treated as absent rather than unreadable. Scanning
+continues to higher ancestors, and a real config added later is checked again.
+Permission/I/O errors, symlink loops and malformed existing configuration still
+produce a coverage warning; Codexometer does not delete or rewrite `.codex` entries.
This fallback cannot establish hidden CLI overrides, remote configuration or
unobserved usage from another machine. Those existing observation limits remain;
Codexometer does not change Codex's compaction configuration.
diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go
index 9b655016..a9774711 100644
--- a/internal/codex/usage_coverage.go
+++ b/internal/codex/usage_coverage.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sort"
+ "syscall"
"time"
"github.com/merefield/codexometer/internal/diagnostics"
@@ -110,7 +111,11 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd, session s
func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, string) {
fields := diagnostics.Fields{Path: path, Session: session}
info, err := os.Stat(path)
- if errors.Is(err, os.ErrNotExist) {
+ // ENOTDIR means the optional file cannot exist: for example, a project's
+ // .codex entry is a regular file rather than a directory. This is absence,
+ // not unreadable configuration. Permission, I/O and symlink-loop failures
+ // still reach the error path below.
+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
diagnostics.Debug("local-config", "configuration not present (normal)", fields)
return false, ""
}
diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go
index 86c6114b..02eac35c 100644
--- a/internal/codex/usage_coverage_logging_test.go
+++ b/internal/codex/usage_coverage_logging_test.go
@@ -102,3 +102,30 @@ func TestUsageCoverageNeverLogsConfigurationContents(t *testing.T) {
t.Fatal("unsafe or missing parse diagnostic", string(data))
}
}
+
+func TestUsageCoverageNonDirectoryOptionalConfigIsDebugNotError(t *testing.T) {
+ home := t.TempDir()
+ marker, logPath := filepath.Join(home, ".codex"), filepath.Join(home, "error.log")
+ if err := os.WriteFile(marker, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ close, err := diagnostics.Install(logPath, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ r := &LiveUsageReader{}
+ if enabled, issue := r.localAsyncCompaction(filepath.Join(marker, "config.toml"), "thread"); enabled || issue != "" {
+ t.Fatal("optional file under non-directory is not absent", enabled, issue)
+ }
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(data), `"level":"ERROR"`) || !strings.Contains(string(data), "configuration not present (normal)") {
+ t.Fatal("non-directory optional config still logged as a failure", string(data))
+ }
+}
diff --git a/internal/codex/usage_coverage_test.go b/internal/codex/usage_coverage_test.go
index 7a29242b..8604f3fa 100644
--- a/internal/codex/usage_coverage_test.go
+++ b/internal/codex/usage_coverage_test.go
@@ -39,6 +39,91 @@ func TestUsageCoverageDetectsFileOnlyConfigurationAndPrefersEffectiveState(t *te
}
}
+func TestUsageCoverageIgnoresNonDirectoryOptionalConfigAndKeepsScanning(t *testing.T) {
+ for _, nested := range []bool{false, true} {
+ t.Run(map[bool]string{false: "working directory", true: "ancestor directory"}[nested], func(t *testing.T) {
+ home := t.TempDir()
+ project := filepath.Join(home, "workspace")
+ cwd := project
+ if nested {
+ cwd = filepath.Join(project, "nested")
+ }
+ if err := os.MkdirAll(cwd, 0700); err != nil {
+ t.Fatal(err)
+ }
+ marker := filepath.Join(project, ".codex")
+ if err := os.WriteFile(marker, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ r := &LiveUsageReader{SessionsRoot: filepath.Join(home, "sessions")}
+ sessions := []LiveUsageSession{{ID: "thread", WorkingDirectory: cwd, Active: true}}
+ if got := r.usageCoverage(context.Background(), sessions, nil); got != "" {
+ t.Fatal("a .codex file was mistaken for inaccessible optional configuration", got)
+ }
+ // A blocked path is absence, not permission to stop scanning: a real
+ // config in a higher ancestor must still qualify coverage.
+ ancestor := filepath.Join(home, ".codex")
+ if err := os.Mkdir(ancestor, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ancestor, "config.toml"), []byte("[features]\nasync_compaction = true\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if got := r.usageCoverage(context.Background(), sessions, nil); !strings.Contains(got, "background compaction") {
+ t.Fatal("non-directory path hid a real ancestor configuration", got)
+ }
+ })
+ }
+}
+
+func TestUsageCoverageDoesNotCacheNonDirectoryOptionalConfig(t *testing.T) {
+ home := t.TempDir()
+ marker := filepath.Join(home, ".codex")
+ if err := os.WriteFile(marker, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ r := &LiveUsageReader{}
+ path := filepath.Join(marker, "config.toml")
+ if enabled, issue := r.localAsyncCompaction(path, "thread"); enabled || issue != "" {
+ t.Fatal("non-directory optional config should be absent", enabled, issue)
+ }
+ if err := os.Remove(marker); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(marker, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("[features]\nasync_compaction = true\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if enabled, issue := r.localAsyncCompaction(path, "thread"); !enabled || issue != "" {
+ t.Fatal("real config added later was hidden by cached absence", enabled, issue)
+ }
+}
+
+func TestUsageCoveragePermissionDeniedConfigStillBlocks(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ if err := os.WriteFile(path, []byte("[features]\nasync_compaction = false\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(path, 0600) })
+ file, err := os.Open(path)
+ if err == nil {
+ file.Close()
+ t.Skip("current user/OS can read despite mode bits; cannot exercise denied read")
+ }
+ if !os.IsPermission(err) {
+ t.Fatal("expected an actual permission failure", err)
+ }
+ r := &LiveUsageReader{}
+ if _, issue := r.localAsyncCompaction(path, "thread"); issue != "background usage configuration could not be read" {
+ t.Fatal("permission failure was incorrectly treated as optional absence", issue)
+ }
+}
+
func TestUsageCoverageKeepsKnownRiskAcrossDisconnectAndIncludesChildren(t *testing.T) {
home := t.TempDir()
r := &LiveUsageReader{SessionsRoot: filepath.Join(home, "sessions"), files: map[string]*rolloutCursor{"child": {threadID: "child", workingDirectory: home, lastModified: time.Now()}}}
From 50e284d406e3ce53699c96ce9313b8a07b34898d Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 20:18:34 +0000
Subject: [PATCH 5/7] FIX: bound existing diagnostic logs during installation
---
internal/diagnostics/log.go | 10 +++++-
internal/diagnostics/log_test.go | 59 ++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go
index 84fdd28a..026ca998 100644
--- a/internal/diagnostics/log.go
+++ b/internal/diagnostics/log.go
@@ -201,11 +201,19 @@ func openFile(path string) (*boundedFile, error) {
f.Close()
return nil, err
}
+ size := info.Size()
+ if size > maxBytes {
+ if err := f.Truncate(0); err != nil {
+ f.Close()
+ return nil, err
+ }
+ size = 0
+ }
if _, err := f.Seek(0, io.SeekEnd); err != nil {
f.Close()
return nil, err
}
- return &boundedFile{File: f, size: info.Size(), limit: maxBytes}, nil
+ return &boundedFile{File: f, size: size, limit: maxBytes}, nil
}
func (f *boundedFile) Write(p []byte) (int, error) {
diff --git a/internal/diagnostics/log_test.go b/internal/diagnostics/log_test.go
index 6f093253..1be25383 100644
--- a/internal/diagnostics/log_test.go
+++ b/internal/diagnostics/log_test.go
@@ -3,6 +3,7 @@ package diagnostics
import (
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"runtime"
@@ -153,6 +154,64 @@ func TestLogAppendsAcrossRuns(t *testing.T) {
}
}
+func TestInstallNormalizesExistingLogWithoutWritingRecords(t *testing.T) {
+ for _, size := range []int{maxBytes - 1, maxBytes, maxBytes + 1} {
+ t.Run(fmt.Sprint(size), func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ original := strings.Repeat("x", size)
+ if err := os.WriteFile(path, []byte(original), 0600); err != nil {
+ t.Fatal(err)
+ }
+ close, err := Install(path, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { close() })
+ // No records are emitted: installation itself must enforce the cap.
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := original
+ if size > maxBytes {
+ want = ""
+ }
+ if string(got) != want {
+ t.Fatalf("installed log size = %d; want %d with original content preserved below the cap", len(got), len(want))
+ }
+ if err := close(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestNormalizedLogTracksSizeAndAppendsAtStart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "error.log")
+ if err := os.WriteFile(path, make([]byte, maxBytes+1), 0600); err != nil {
+ t.Fatal(err)
+ }
+ f, err := openFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if f.size != 0 {
+ t.Fatal("oversized log retained stale tracked size", f.size)
+ }
+ const record = "{\"msg\":\"after normalization\"}\n"
+ if _, err := f.Write([]byte(record)); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != record || f.size != int64(len(record)) {
+ t.Fatal("normalized log did not restart at byte zero", len(got), f.size)
+ }
+}
+
func TestInvalidTargetsFailWithoutChangingThem(t *testing.T) {
dir := t.TempDir()
if _, err := Install(dir, false); err == nil {
From 10316514a9f4b7bb83081f8492170dea4d060875 Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 20:18:34 +0000
Subject: [PATCH 6/7] FIX: retry usage configuration after read or close
failures
---
internal/codex/usage_coverage.go | 22 ++++--
internal/codex/usage_coverage_logging_test.go | 67 +++++++++++++++++++
2 files changed, 83 insertions(+), 6 deletions(-)
diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go
index a9774711..2ea5f48f 100644
--- a/internal/codex/usage_coverage.go
+++ b/internal/codex/usage_coverage.go
@@ -134,16 +134,26 @@ func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, stri
diagnostics.Error("local-config", "open", err, fields)
return false, "background usage configuration could not be read"
}
+ return r.readLocalAsyncCompaction(file, info, fields)
+}
+
+func (r *LiveUsageReader) readLocalAsyncCompaction(file io.ReadCloser, info os.FileInfo, fields diagnostics.Fields) (bool, string) {
data, err := io.ReadAll(io.LimitReader(file, 1<<20+1))
- if closeErr := file.Close(); closeErr != nil {
+ closeErr := file.Close()
+ if closeErr != nil {
diagnostics.Error("local-config", "close", closeErr, fields)
}
- check := localFeatureCheck{info: info}
- var config map[string]any
if err != nil {
diagnostics.Error("local-config", "read", err, fields)
- check.issue = "background usage configuration could not be verified"
- } else if len(data) > 1<<20 {
+ }
+ // Transient filesystem failures must be retried, not cached as a valid
+ // configuration (or as a persistent failure for otherwise unchanged bytes).
+ if err != nil || closeErr != nil {
+ return false, "background usage configuration could not be verified"
+ }
+ check := localFeatureCheck{info: info}
+ var config map[string]any
+ if len(data) > 1<<20 {
diagnostics.Error("local-config", "configuration exceeds 1 MiB limit", nil, fields)
check.issue = "background usage configuration could not be verified"
} else if parseErr := toml.Unmarshal(data, &config); parseErr != nil {
@@ -157,7 +167,7 @@ func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, stri
if len(r.localFeatureChecks) >= 256 {
clear(r.localFeatureChecks)
}
- r.localFeatureChecks[path] = check
+ r.localFeatureChecks[fields.Path] = check
return check.enabled, check.issue
}
diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go
index 02eac35c..44f88d85 100644
--- a/internal/codex/usage_coverage_logging_test.go
+++ b/internal/codex/usage_coverage_logging_test.go
@@ -1,15 +1,82 @@
package codex
import (
+ "io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
+ "testing/iotest"
"github.com/merefield/codexometer/internal/diagnostics"
)
+type coverageReadCloser struct {
+ io.Reader
+ closeErr error
+ closed bool
+}
+
+func (f *coverageReadCloser) Close() error {
+ f.closed = true
+ return f.closeErr
+}
+
+func TestUsageCoverageReadAndCloseFailuresAreLoggedAndRetried(t *testing.T) {
+ for _, failure := range []string{"read", "close", "read and close"} {
+ t.Run(failure, func(t *testing.T) {
+ dir := t.TempDir()
+ path, logPath := filepath.Join(dir, "config.toml"), filepath.Join(dir, "error.log")
+ const config = "[features]\nasync_compaction = true\n"
+ if err := os.WriteFile(path, []byte(config), 0600); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ closeLog, err := diagnostics.Install(logPath, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { closeLog() })
+ file := &coverageReadCloser{Reader: strings.NewReader(config)}
+ if strings.Contains(failure, "read") {
+ file.Reader = iotest.ErrReader(os.ErrPermission)
+ }
+ if strings.Contains(failure, "close") {
+ file.closeErr = os.ErrPermission
+ }
+ r := &LiveUsageReader{localFeatureChecks: make(map[string]localFeatureCheck)}
+ enabled, issue := r.readLocalAsyncCompaction(file, info, diagnostics.Fields{Path: path, Session: "thread"})
+ if enabled || issue != "background usage configuration could not be verified" || !file.closed {
+ t.Fatal("filesystem failure did not block verification and close the file", enabled, issue, file.closed)
+ }
+ if len(r.localFeatureChecks) != 0 {
+ t.Fatal("failed filesystem operation was cached", r.localFeatureChecks)
+ }
+ if err := closeLog(); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, operation := range []string{"read", "close"} {
+ if strings.Contains(failure, operation) && !strings.Contains(string(data), `"msg":"`+operation+`"`) {
+ t.Fatal("filesystem failure was not logged", operation, string(data))
+ }
+ }
+ // Retry the same path and unchanged metadata after the transient
+ // failure. A successful read must now detect and cache the flag.
+ if enabled, issue := r.localAsyncCompaction(path, "thread"); !enabled || issue != "" || !r.localFeatureChecks[path].enabled {
+ t.Fatal("unchanged configuration was not successfully retried", enabled, issue)
+ }
+ })
+ }
+}
+
func TestUsageCoverageLogsFilesystemCauseWithoutChangingTheGuard(t *testing.T) {
home := t.TempDir()
logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml")
From 2f0acff70eee9d10e51f475be0e9a8a7651fd471 Mon Sep 17 00:00:00 2001
From: merefield
Date: Fri, 30 Oct 2026 20:37:21 +0000
Subject: [PATCH 7/7] FIX: distinguish current session failures and stale
command timing
---
internal/ui/monitor_context.go | 2 +-
internal/ui/monitor_context_modes.go | 2 +-
internal/ui/monitor_detail.go | 2 +-
internal/ui/monitor_working.go | 14 +++--
internal/ui/monitor_working_test.go | 56 +++++++++++++++++--
.../{index-Cq0BuLLm.js => index-DdTUC-03.js} | 2 +-
internal/web/dist/index.html | 2 +-
internal/web/session_failure_test.go | 45 ++++++++++++++-
internal/web/state.go | 3 +
web/src/Sessions.svelte | 4 +-
web/src/state.svelte.ts | 1 +
web/tests/browser.spec.ts | 23 ++++++++
12 files changed, 140 insertions(+), 16 deletions(-)
rename internal/web/dist/assets/{index-Cq0BuLLm.js => index-DdTUC-03.js} (97%)
diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go
index a1a9bf95..7eae67fe 100644
--- a/internal/ui/monitor_context.go
+++ b/internal/ui/monitor_context.go
@@ -158,7 +158,7 @@ func (m Model) renderMonitorContextRow(width, height int, metrics string, s moni
lines[textRows-1] = ansi.Truncate(lines[textRows-1], max(inner-1, 0), "") + "…"
}
if hasWorkingCommand(s.preview) {
- lines = workingContextLines(s.preview, inner, contentRows, true)
+ lines = m.workingContextLines(s.preview, inner, contentRows, true)
} else if contentRows > textRows {
lines = append(lines, terminalLabel(s.preview.Source)+" // "+shortSessionID(s.preview.ThreadID)+" // "+contextAge(s.preview))
}
diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go
index a27b3dfc..702dd7c7 100644
--- a/internal/ui/monitor_context_modes.go
+++ b/internal/ui/monitor_context_modes.go
@@ -232,7 +232,7 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors
lines = expandedContextLines(width, withoutTask)
}
if hasWorkingCommand(s.preview) {
- lines = workingContextLines(s.preview, max(width-4, 1), max(textRows, 1), false)
+ lines = m.workingContextLines(s.preview, max(width-4, 1), max(textRows, 1), false)
}
if len(lines) > textRows {
lines = lines[:textRows]
diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go
index 396c9468..d7e873a6 100644
--- a/internal/ui/monitor_detail.go
+++ b/internal/ui/monitor_detail.go
@@ -117,7 +117,7 @@ func (m Model) contextDetailDocument(width int) (document []detailLine) {
section(i18n.Text("COMMENTARY"))
appendText(codex.SanitizeSessionContext(c.Activity.Prose), "body", "")
}
- section(workingCommandTitle(c.Activity))
+ section(m.workingCommandTitle(c.Activity))
prefix := "│ "
if width < 3 {
prefix = ""
diff --git a/internal/ui/monitor_working.go b/internal/ui/monitor_working.go
index cb63cb6c..7fe1cb00 100644
--- a/internal/ui/monitor_working.go
+++ b/internal/ui/monitor_working.go
@@ -12,7 +12,7 @@ func hasWorkingCommand(c codex.SessionContext) bool {
return c.Kind == codex.SessionContextActivity && c.Activity.Command != ""
}
-func workingCommandTitle(a codex.SessionActivity) string {
+func (m Model) workingCommandTitle(a codex.SessionActivity) string {
status := "UNKNOWN"
switch a.CommandStatus {
case "running":
@@ -25,8 +25,12 @@ func workingCommandTitle(a codex.SessionActivity) string {
status = "DECLINED"
}
title := i18n.Text("COMMAND") + " // " + i18n.Text(status)
- if timing := a.CommandTiming(time.Now()); timing != "" {
- title += " // " + timing
+ // Completed duration is historical evidence. Running elapsed time must not
+ // keep advancing when the retained command is only a stale observation.
+ if a.CommandStatus != "running" || m.monitorState == monitorRunning && m.monitorError == "" {
+ if timing := a.CommandTiming(time.Now()); timing != "" {
+ title += " // " + timing
+ }
}
extra := a.RunningCommands
if a.CommandStatus == "running" {
@@ -42,7 +46,7 @@ func workingCommandTitle(a codex.SessionActivity) string {
}
// Reserve space for the command on roomy rows; tiny previews prioritise prose.
-func workingContextLines(c codex.SessionContext, width, rows int, compact bool) []string {
+func (m Model) workingContextLines(c codex.SessionContext, width, rows int, compact bool) []string {
width, rows = max(width, 1), max(rows, 1)
fit := func(text string, n int, cook bool) []string {
lines := wrappedSessionWebText(codex.SanitizeSessionContext(text), width, cook)
@@ -68,7 +72,7 @@ func workingContextLines(c codex.SessionContext, width, rows int, compact bool)
lines = append(lines, "")
}
if rows-len(lines) >= 2 {
- lines = append(lines, ansi.Truncate(workingCommandTitle(a), width, "…"))
+ lines = append(lines, ansi.Truncate(m.workingCommandTitle(a), width, "…"))
lines = append(lines, fit(a.Command, rows-len(lines), false)...)
} else if len(lines) == 0 {
lines = fit(a.Command, rows, false)
diff --git a/internal/ui/monitor_working_test.go b/internal/ui/monitor_working_test.go
index f0847889..270b7299 100644
--- a/internal/ui/monitor_working_test.go
+++ b/internal/ui/monitor_working_test.go
@@ -10,13 +10,14 @@ import (
)
func TestWorkingContextResponsive(t *testing.T) {
+ m := approvalTestModel()
c := codex.SessionContext{Kind: codex.SessionContextActivity, Text: "Checking the build", Activity: codex.SessionActivity{
Prose: "Checking the build", Command: "go test ./...", CommandStatus: "running", RunningCommands: 2,
}}
for _, width := range []int{1, 5, 20, 80} {
for rows := 1; rows <= 12; rows++ {
for _, compact := range []bool{true, false} {
- lines := workingContextLines(c, width, rows, compact)
+ lines := m.workingContextLines(c, width, rows, compact)
if len(lines) > rows {
t.Fatalf("height overflow: %v", lines)
}
@@ -36,7 +37,6 @@ func TestWorkingContextResponsive(t *testing.T) {
}
}
}
- m := approvalTestModel()
m.monitorSessionData[0].preview = c
for _, width := range []int{1, 20, 80} {
doc := m.contextDetailDocument(width)
@@ -62,15 +62,16 @@ func TestWorkingContextResponsive(t *testing.T) {
}
func TestWorkingCommandRecordedDurationRemainsResponsive(t *testing.T) {
+ m := contextTestModel()
start := time.Now().Add(-time.Minute)
a := codex.SessionActivity{Command: "go test", CommandStatus: "completed", CommandStartedAt: start, CommandCompletedAt: start.Add(7 * time.Second)}
- if got := workingCommandTitle(a); got != "COMMAND // COMPLETED // 7s" {
+ if got := m.workingCommandTitle(a); got != "COMMAND // COMPLETED // 7s" {
t.Fatal("recorded duration missing", got)
}
c := codex.SessionContext{Kind: codex.SessionContextActivity, Activity: a}
for _, width := range []int{1, 8, 20, 80} {
for _, compact := range []bool{false, true} {
- lines := workingContextLines(c, width, 4, compact)
+ lines := m.workingContextLines(c, width, 4, compact)
for _, line := range lines {
if ansi.StringWidth(line) > width {
t.Fatal("duration caused layout overflow", line)
@@ -82,3 +83,50 @@ func TestWorkingCommandRecordedDurationRemainsResponsive(t *testing.T) {
}
}
}
+
+func TestWorkingCommandTimingRespectsTelemetryFreshnessInEveryView(t *testing.T) {
+ for _, tc := range []struct {
+ name, observationError, commandStatus string
+ state monitorState
+ wantTiming bool
+ }{
+ {"live", "", "running", monitorRunning, true},
+ {"failed observation", "telemetry unavailable", "running", monitorRunning, false},
+ {"starting", "", "running", monitorStarting, false},
+ {"resetting", "", "running", monitorResetting, false},
+ {"idle", "", "running", monitorIdle, false},
+ {"completed with stale telemetry", "telemetry unavailable", "completed", monitorRunning, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ m := contextTestModel()
+ m.monitorState, m.monitorError = tc.state, tc.observationError
+ start := time.Now().Add(-time.Minute)
+ s := m.monitorSessionData[0]
+ s.preview = codex.SessionContext{Kind: codex.SessionContextActivity, Text: "Checking", Activity: codex.SessionActivity{
+ Prose: "Checking", Command: "go test", CommandStatus: tc.commandStatus,
+ CommandStartedAt: start, CommandCompletedAt: start.Add(7 * time.Second),
+ }}
+ m.monitorSessionData[0] = s
+ colors := paletteFor(m.theme)
+ m.setRowContext(s.id, contextSplit)
+ compact := m.renderMonitorContextRow(240, 15, "", s, colors)
+ m.setRowContext(s.id, contextWide)
+ wide := m.renderExpandedContext(120, 15, s, colors)
+ m.setRowContext(s.id, contextFull)
+ full := strings.Join(m.contextDetailLines(120), "\n")
+ for view, text := range map[string]string{"compact": compact, "wide": wide, "full": full} {
+ text = ansi.Strip(text)
+ heading := "COMMAND // " + strings.ToUpper(tc.commandStatus)
+ if !strings.Contains(text, heading) {
+ t.Fatalf("%s lost retained command context: %s", view, text)
+ }
+ if strings.Contains(text, heading+" //") != tc.wantTiming {
+ t.Fatalf("%s: timing freshness mismatch (want timing=%v): %s", view, tc.wantTiming, text)
+ }
+ if tc.commandStatus == "completed" && !strings.Contains(text, heading+" // 7s") {
+ t.Fatalf("%s lost recorded completed duration: %s", view, text)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/web/dist/assets/index-Cq0BuLLm.js b/internal/web/dist/assets/index-DdTUC-03.js
similarity index 97%
rename from internal/web/dist/assets/index-Cq0BuLLm.js
rename to internal/web/dist/assets/index-DdTUC-03.js
index af907cef..7de76c01 100644
--- a/internal/web/dist/assets/index-Cq0BuLLm.js
+++ b/internal/web/dist/assets/index-DdTUC-03.js
@@ -30,7 +30,7 @@
stale.`),Us=J(`
Some quota profile checks are unavailable. Only sessions with freshly
verified quota and settings can be updated; previous outcome notices remain
visible.
↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK
`,1),fc=J(`
SESSION TOTALS
`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.contextKind===`TURN ERROR`}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``}
+ Codexometer.`),dc=J(`
↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK
`,1),fc=J(`
SESSION TOTALS
`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.failed===!0}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``}
${o??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>K(t).status===`APPROVAL NEEDED`||K(t).status===`INPUT NEEDED`||y(K(t)),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)});var r=H(n,2);Xr(r,17,()=>K(o).filter(e=>e.pending&&K(a).some(t=>t.id===e.session)),Kr,(e,t)=>{var n=Gs(),r=V(n);U((e,a,o)=>{Q(n,`title`,i().id&&i().id!==K(t).session&&K(u)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),X(r,`QUOTA THRESHOLD ${a??``} // ${o??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session)+`?review=profile`,()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory||K(t).session]),q(`click`,n,e=>d(e,K(t).session)),Y(e,n)});var l=H(r,2);Xr(l,17,()=>K(C),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,i)=>{Q(n,`href`,e),X(r,`TURN COMPLETE ${i??``} //
${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)}),Xr(H(l,2),17,()=>K(s),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,t,i)=>{Q(n,`href`,e),X(r,`TRIGGER SET ${t??``} // ${i??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session),()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory]),q(`click`,n,e=>c(e,K(t).session)),Y(e,n)}),P(t),Y(e,t)},de=F(()=>(K(x).length||K(C).length||K(o).some(e=>e.pending)||K(s).length)&&!K(v));Z(le,e=>{K(de)&&e(ue)});var fe=H(le,2),j=e=>{var t=Nr(),a=B(t),s=e=>{var t=Qs(),a=z(t),s=z(a);let c;var d=z(s);let f;var h=H(d);P(s);var g=H(s,2);P(a);var _=H(a,2),b=e=>{var t=zs(),n=V(t,!0);U(()=>X(n,K(p).directory)),Y(e,t)};Z(_,e=>{K(p).name&&K(p).directory&&e(b)});var x=H(_,2),S=V(x),C=H(x,2);let T;var E=z(C),D=z(E);n(D,()=>K(p),()=>!0,()=>!0),P(E);var ee=H(E,2),te=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{{let t=F(()=>new URLSearchParams(Yi.querystring).get(`schedule`)===`edit`),n=F(()=>K(p).fileChanges||[]);xs(e,{get session(){return K(p).id},oncopy:()=>K(r)?.copy(),get openScheduler(){return K(t)},get observedCommand(){return K(p).command},get observedFileChanges(){return K(n)},get suspended(){return K(m)},onProtectedChange:e=>{R(u,e,!0)}})}}),Y(e,t)},O=e=>{var t=Js();Gr(H(z(t),2),()=>K(p).id,e=>{Po(e,{get session(){return K(p).id},localOnly:!0,oncopy:()=>K(r)?.copy()})}),P(t),Y(e,t)};Z(ee,e=>{$.data?.control?e(te):e(O,-1)}),P(C);var ne=H(C,2);Xr(ne,17,()=>K(o).filter(e=>e.session===K(p).id),e=>e.session,(e,t)=>{var n=Zs(),r=z(n),i=e=>{var n=Ys(),r=V(n,!0);U(()=>X(r,K(t).notice)),Y(e,n)};Z(r,e=>{K(t).notice&&e(i)});var a=H(r,2),o=e=>{xs(e,{get session(){return K(p).id},review:`profile`})},s=e=>{var t=Xs();U(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(p).id)+`?review=profile`]),Y(e,t)};Z(a,e=>{K(t).pending&&K(m)?e(o):K(t).pending&&e(s,1)}),P(n),U(()=>Q(n,`id`,`quota-profile-`+K(p).id)),Y(e,n)});var k=H(ne,2),re=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{Ii(Os(e,{get session(){return K(p)},active:!0}),e=>R(r,e,!0),()=>K(r))}),Y(e,t)};Z(k,e=>{K(m)||e(re)}),Gr(H(k,2),()=>K(p).id,e=>{Es(e,{get session(){return K(p)},get stale(){return K(v)}})}),P(t),U((e,t,n,r)=>{c=ci(s,1,`svelte-mm4wfe`,null,c,{failed:e}),f=ci(d,1,`lamp lit svelte-mm4wfe`,null,f,{failed:t,working:K(p).status===`WORKING`&&!K(v)}),X(h,`${n??``} // ${(K(p).name||K(p).directory)??``}`),X(S,`${r??``} TOKENS // ${K(p).id??``} // CONTEXT SOURCE // ${(K(p).source||`LOCAL`)??``}`),T=ui(C,``,T,{display:K(m)?`none`:void 0})},[()=>y(K(p))&&!K(v)&&!K(m),()=>y(K(p))&&!K(v),()=>K(v)?`STALE`:K(m)?`QUOTA THRESHOLD`:l(K(p)),()=>pa(K(p).tokens)]),q(`click`,g,()=>{w(i().id),fa(i().id,2)}),Y(e,t)},c=e=>{Y(e,$s())};Z(a,e=>{K(p)?e(s):e(c,-1)}),Y(e,t)},pe=e=>{var t=dc(),r=B(t),i=H(z(r),2),s=z(i),c=H(s,2);P(i),P(r);var u=H(r,2);Xr(u,17,()=>K(a),e=>e.id,(e,t)=>{let r=F(()=>ua(K(t).id));var i=lc();let a;var s=z(i),c=z(s),u=e=>{var n=ec(),r=z(n),i=V(r);P(n),U(e=>{Q(r,`aria-pressed`,K(h)===K(t).id),X(i,`${e??``} // ${K(t).name??``}`)},[()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,r,()=>w(K(t).id)),Y(e,n)};Z(c,e=>{K(t).name&&e(u)});var f=H(c,2),p=z(f);let m;var g=z(p);let _;var b=H(g,1,!0);P(p),P(f);var x=H(f,2),S=e=>{var n=tc(),r=V(n,!0);U(()=>{Q(n,`aria-pressed`,K(h)===K(t).id),X(r,K(t).directory||K(t).id)}),q(`click`,n,()=>w(K(t).id)),Y(e,n)};Z(x,e=>{K(t).name||e(S)});var C=H(x,2),E=z(C);xe(),P(C);var D=H(C,2),te=e=>{var n=zs(),r=V(n,!0);U(()=>X(r,K(t).directory)),Y(e,n)};Z(D,e=>{K(t).name&&K(t).directory&&e(te)});var O=H(D,2),ne=V(O),k=H(O,2),re=V(k),ie=H(k,2),ae=z(ie),oe=H(ae,2),se=V(oe,!0),A=H(oe,2);P(ie);var ce=H(ie,2),le=H(ce,2),ue=e=>{var n=nc(),r=V(n,!0);U(e=>X(r,e),[()=>K(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:y(K(t))?`OPEN DETAIL FOR THE TURN ERROR`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:K(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`]),Y(e,n)},de=F(()=>!K(v)&&([`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(K(t).status)||y(K(t))));Z(le,e=>{K(de)&&e(ue)}),P(s);var fe=H(s,2),j=e=>{var r=sc(),i=z(r),a=z(i),s=V(a,!0),c=H(a,2),l=e=>{var n=rc(),r=V(n,!0);U(e=>{Q(n,`href`,e),X(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id)]),Y(e,n)};Z(c,e=>{!K(v)&&K(t).status===`APPROVAL NEEDED`&&e(l)}),P(i);var u=H(i,2);n(u,()=>K(t),()=>!1);var f=H(u,2);Xr(f,17,()=>K(o).filter(e=>e.session===K(t).id),Kr,(e,n)=>{var r=oc(),i=B(r),a=e=>{var t=ic(),r=V(t,!0);U(()=>X(r,K(n).notice)),Y(e,t)};Z(i,e=>{K(n).notice&&e(a)});var o=H(i,2),s=e=>{var n=ac();U(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(t).id)+`?review=profile`]),q(`click`,n,e=>d(e,K(t).id)),Y(e,n)};Z(o,e=>{K(n).pending&&e(s)}),Y(e,r)});var p=H(f,2);{let e=F(()=>K(h)===K(t).id);Os(p,{get session(){return K(t)},get active(){return K(e)}})}P(r),U(e=>X(s,e),[()=>ee(K(t))]),Y(e,r)};Z(fe,e=>{K(r)>0&&e(j)});var pe=H(fe,2),me=e=>{var n=cc(),r=H(z(n),2);{let e=F(()=>(K(t).samples||[]).map(e=>e.tokens));io(r,{get values(){return K(e)},capacity:120})}var i=V(H(r,2));P(n),U(()=>X(i,`LAST ${(K(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),Y(e,n)};Z(pe,e=>{K(r)<2&&e(me)}),P(i),U((e,n,o,s,c,l,u)=>{a=ci(i,1,`session-row`,null,a,{selected:K(h)===K(t).id,wide:K(r)===2,split:K(r)===1}),Q(i,`aria-label`,`Session `+(K(t).name||K(t).directory||K(t).id)),m=ci(p,1,`status-detail svelte-mm4wfe`,null,m,{failed:e}),Q(p,`href`,n),_=ci(g,1,`lamp lit svelte-mm4wfe`,null,_,{failed:o,working:K(t).status===`WORKING`&&!K(v)}),X(b,s),X(E,`${c??``} `),X(ne,`${K(t).agents??``} LINKED AGENTS`),X(re,`ACTIVE // ${l??``}`),ae.disabled=K(r)===0,Q(oe,`aria-expanded`,K(r)>0),X(se,K(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(ce,`href`,u)},[()=>y(K(t))&&!K(v),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>y(K(t))&&!K(v),()=>K(v)?`STALE`:l(K(t)),()=>pa(K(t).tokens),()=>ma(K(t).activity),()=>`#/sessions/`+encodeURIComponent(K(t).id)]),q(`click`,p,()=>w(K(t).id)),q(`click`,ae,()=>T(K(t).id,-1)),q(`click`,oe,()=>{w(K(t).id),fa(K(t).id,+!K(r))}),q(`click`,A,()=>T(K(t).id,1)),q(`click`,ce,()=>w(K(t).id)),Y(e,i)});var f=H(u,2),p=e=>{Y(e,uc())};Z(f,e=>{K(a).length||e(p)}),q(`click`,s,()=>da(1)),q(`click`,c,()=>da(0)),Y(e,t)};Z(fe,e=>{i().id?e(j):e(pe,-1)}),U(e=>{X(ne,`OBSERVED ${e??``}`),re=ci(k,1,`session-totals`,null,re,{stale:K(v)}),X(ae,`${K(v)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens
observed since this server started for currently listed sessions; linked
diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html
index 381d8ec1..7d6bfc93 100644
--- a/internal/web/dist/index.html
+++ b/internal/web/dist/index.html
@@ -5,7 +5,7 @@
Codexometer // Experimental web
-
+
diff --git a/internal/web/session_failure_test.go b/internal/web/session_failure_test.go
index 73bc9d6a..68a76782 100644
--- a/internal/web/session_failure_test.go
+++ b/internal/web/session_failure_test.go
@@ -1,8 +1,11 @@
package web
import (
- "github.com/merefield/codexometer/internal/codex"
+ "errors"
"testing"
+ "time"
+
+ "github.com/merefield/codexometer/internal/codex"
)
func TestSessionFailurePresentation(t *testing.T) {
@@ -21,3 +24,43 @@ func TestSessionFailurePresentation(t *testing.T) {
}
}
}
+
+func TestSessionFailureProjectionUsesCurrentAttentionNotSavedContext(t *testing.T) {
+ for _, tc := range []struct {
+ name, status string
+ active bool
+ working bool
+ attention codex.SessionAttention
+ kind codex.SessionContextKind
+ failed bool
+ }{
+ {"closed", "INACTIVE", false, false, codex.SessionAttentionNone, codex.SessionContextFailure, false},
+ {"idle", "IDLE", true, false, codex.SessionAttentionNone, codex.SessionContextFailure, false},
+ {"working", "WORKING", true, true, codex.SessionAttentionNone, codex.SessionContextFailure, false},
+ {"current failure", "MODEL BUSY", true, false, codex.SessionAttentionError, codex.SessionContextFailure, true},
+ {"failure without saved context", "TURN FAILED", true, false, codex.SessionAttentionError, codex.SessionContextNone, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s := newStore()
+ row := codex.LiveUsageSession{ID: "thread", Active: tc.active, Working: tc.working, Attention: tc.attention,
+ Context: codex.SessionContext{Kind: tc.kind, FailureCode: "serverOverloaded", Text: "Saved explanation"}}
+ if tc.kind == codex.SessionContextNone {
+ row.Context.FailureCode = ""
+ }
+ now := time.Now()
+ snapshot := codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}
+ s.live(snapshot, nil, now)
+ if got := s.state.Sessions[0]; got.Failed != tc.failed || got.Status != tc.status || got.Text != "Saved explanation" {
+ t.Fatal("current attention and retained context were conflated", got)
+ }
+ s.live(codex.LiveUsageSnapshot{}, errors.New("offline"), now.Add(time.Second))
+ if got := s.state.Sessions[0]; got.Failed || got.Status != "STALE" || got.Text != "Saved explanation" {
+ t.Fatal("stale observation retained current failure classification or lost its text", got)
+ }
+ s.live(snapshot, nil, now.Add(2*time.Second))
+ if got := s.state.Sessions[0]; got.Failed != tc.failed || got.Status != tc.status {
+ t.Fatal("failure classification did not recover after a successful refresh", got)
+ }
+ })
+ }
+}
diff --git a/internal/web/state.go b/internal/web/state.go
index 49181c7f..1bf5af42 100644
--- a/internal/web/state.go
+++ b/internal/web/state.go
@@ -34,6 +34,7 @@ type session struct {
Tokens int64 `json:"tokens"`
Agents int `json:"agents"`
Status string `json:"status"`
+ Failed bool `json:"failed,omitempty"`
ContextKind string `json:"contextKind"`
Text string `json:"text"`
Command string `json:"command"`
@@ -337,6 +338,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) {
ID: row.ID, Name: codex.SanitizeSessionContext(row.Name), Directory: row.WorkingDirectory, Tokens: row.TotalTokens, Agents: row.AgentCount,
StatusLine: statusline.Values(statusline.Data{Model: row.ModelSettings.Model, Effort: row.ModelSettings.ReasoningEffort, Speed: row.ModelSettings.ServiceTier, Directory: row.WorkingDirectory, Name: row.Name, ID: row.ID, State: sessionStatus(row), Source: row.Context.Source, Tokens: row.TotalTokens, Agents: row.AgentCount}),
Status: sessionStatus(row), ContextKind: contextKind(row.Context.Kind), Text: text,
+ Failed: row.Attention == codex.SessionAttentionError,
Command: row.Context.CommandDetails.Command, Source: row.Context.Source, Activity: row.LastActivity,
WorkingCommand: activity.Command, CommandStatus: activity.CommandStatus,
CommandTiming: activity.CommandTiming(now),
@@ -360,6 +362,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) {
// Never keep an apparently authoritative approval/working status on failure.
for i := range s.state.Sessions {
s.state.Sessions[i].Status = "STALE"
+ s.state.Sessions[i].Failed = false
}
}
s.publish()
diff --git a/web/src/Sessions.svelte b/web/src/Sessions.svelte
index 6a59914c..169f3571 100644
--- a/web/src/Sessions.svelte
+++ b/web/src/Sessions.svelte
@@ -66,7 +66,9 @@
);
let stale = $derived(!live.connected || !!live.data?.sessionsError);
function isFailure(session: Session) {
- return session.status !== 'STALE' && session.contextKind === 'TURN ERROR';
+ // Saved error text can outlive current attention (for example, after quit).
+ // Use the shared backend classification, not historical context or labels.
+ return session.status !== 'STALE' && session.failed === true;
}
// Parent rows already include linked-agent usage. Sum each listed row once;
// do not add agent counts or activity samples to its observed token counter.
diff --git a/web/src/state.svelte.ts b/web/src/state.svelte.ts
index f87603c5..00f5cce9 100644
--- a/web/src/state.svelte.ts
+++ b/web/src/state.svelte.ts
@@ -12,6 +12,7 @@ export interface Session {
tokens: number;
agents: number;
status: string;
+ failed?: boolean;
contextKind: string;
text: string;
command: string;
diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts
index f9e6b997..7b0c5222 100644
--- a/web/tests/browser.spec.ts
+++ b/web/tests/browser.spec.ts
@@ -3532,6 +3532,9 @@ test('session failures have a separate count, ordered pills, error detail and li
'MODEL UNAVAILABLE',
'CAPACITY RESTRICTED',
'CHECK SESSION',
+ 'INACTIVE',
+ 'IDLE',
+ 'WORKING',
].map((status, index) => ({
id: 'failure-test-' + index,
name: 'Session ' + index,
@@ -3539,11 +3542,21 @@ test('session failures have a separate count, ordered pills, error detail and li
tokens: 10,
agents: 0,
status,
+ failed: [
+ 'MODEL BUSY',
+ 'TURN FAILED',
+ 'MODEL UNAVAILABLE',
+ 'CAPACITY RESTRICTED',
+ ].includes(status),
contextKind: [
'MODEL BUSY',
'TURN FAILED',
'MODEL UNAVAILABLE',
'CAPACITY RESTRICTED',
+ // Saved error context is still useful, but is not current attention.
+ 'INACTIVE',
+ 'IDLE',
+ 'WORKING',
].includes(status)
? 'TURN ERROR'
: 'LAST REPLY',
@@ -3594,6 +3607,7 @@ test('session failures have a separate count, ordered pills, error detail and li
'TURN ERROR',
);
snapshot.sessions[1].status = 'WORKING';
+ snapshot.sessions[1].failed = false;
snapshot.sessions[1].contextKind = 'LAST ACTIVITY';
snapshot.sessions[1].text = 'Trying a new turn';
await page.evaluate(
@@ -3609,6 +3623,15 @@ test('session failures have a separate count, ordered pills, error detail and li
'Selected model is at capacity.',
);
await expect(page.locator('.detail-heading')).toContainText('WORKING');
+ // A closed session can retain its failed reply without becoming an alert.
+ await page.goto(pairingURL.split('#')[0] + '#/sessions/failure-test-8');
+ await expect(page.locator('.detail-context')).toContainText(
+ 'Observed context',
+ );
+ await expect(page.locator('.detail-context')).not.toContainText(
+ 'CONFIRMED TURN FAILURE',
+ );
+ await expect(page.locator('.detail-heading h2')).not.toHaveClass(/failed/);
});
test('session totals count parent usage once and update for live, stale and empty lists', async ({