claude-code-session-manager 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/assets/{DataModel-B0LDnnSL.js → DataModel-DZK9QZpV.js} +1 -1
  2. package/dist/assets/{History-C685Kytt.js → History-BDyJUGtR.js} +1 -1
  3. package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CmK6l0uf.js} +1 -1
  4. package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-D8TIx-Bx.js} +1 -1
  5. package/dist/assets/{Library-BU9np05E.js → Library-DTxpOjx8.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-BPJyrevq.js} +1 -1
  7. package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-B_rvIYDs.js} +1 -1
  8. package/dist/assets/{Memory-DAP1t8bA.js → Memory-B-9GlHqe.js} +1 -1
  9. package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-DhMMp27n.js} +1 -1
  10. package/dist/assets/{Plugins-Q1KoLEzn.js → Plugins-DKfAlmMY.js} +2 -2
  11. package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-ptlItdCK.js} +1 -1
  12. package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-CSbNSl2W.js} +1 -1
  13. package/dist/assets/Scheduler--rn1QBYc.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-BJof-GWH.js} +1 -1
  15. package/dist/assets/{Settings-D5Lhj7ga.js → Settings-Dsx6XZP5.js} +1 -1
  16. package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CGl4B89-.js} +1 -1
  17. package/dist/assets/{Skills-BQDqp-EN.js → Skills-D522Zfol.js} +1 -1
  18. package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt-DKj-q39J.js} +1 -1
  19. package/dist/assets/{TagLibrary-BjyrnaRE.js → TagLibrary-BH7CG7-6.js} +1 -1
  20. package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-BbcJL9az.js} +1 -1
  21. package/dist/assets/{Toggle-DzoROibf.js → Toggle-DAslvnnv.js} +1 -1
  22. package/dist/assets/{index-CCl4tz-u.js → index-Yf8e7VCB.js} +8 -8
  23. package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-pzp0TgvN.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +3 -1
  26. package/scripts/README.md +3 -0
  27. package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
  28. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
  29. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
  30. package/src/main/build-info.json +4 -4
  31. package/src/main/crashDiagnostics.cjs +9 -0
  32. package/src/main/health.cjs +25 -1
  33. package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
  34. package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
  35. package/src/main/lib/loopDelay.cjs +44 -0
  36. package/src/main/lib/usageCircuit.cjs +40 -12
  37. package/src/main/scheduler.cjs +49 -3
  38. package/src/main/usage.cjs +9 -1
  39. package/src/preload/api.d.ts +17 -2
  40. package/dist/assets/Scheduler-D488ebKm.js +0 -16
@@ -1 +1 @@
1
- import{az as b,ak as n,S as N,aO as O,ax as K}from"./index-CCl4tz-u.js";function T({node:e,targetScope:r,onOverride:s,schema:a,rootLabel:m}){const[f,x]=b.useState(""),[u,o]=b.useState(!0),g=[],d=e.kind==="object"&&Object.keys(e.children).length===0,y=((a==null?void 0:a.rootKeys())??[]).length>0;return n.jsxs("div",{className:"h-full flex flex-col",children:[n.jsxs("div",{className:"shrink-0 border-b border-line bg-bg-elev px-3 py-1.5 flex items-center gap-2 text-xs",children:[n.jsxs("span",{className:"text-fg-faint",children:["merged · overrides → ",n.jsx("span",{className:"text-fg-dim",children:N[r].label})]}),y&&n.jsxs("label",{className:"flex items-center gap-1 text-fg-faint cursor-pointer",children:[n.jsx("input",{type:"checkbox",checked:u,onChange:t=>o(t.target.checked),className:"accent-accent"}),"show unset keys"]}),n.jsx("div",{className:"flex-1"}),n.jsx("input",{value:f,onChange:t=>x(t.target.value),placeholder:"filter keys or descriptions…",className:"bg-bg border border-line rounded px-2 py-0.5 text-xs text-fg w-56"})]}),n.jsx("div",{className:"flex-1 overflow-auto px-3 py-2 font-mono text-xs",children:d&&!u?n.jsx("div",{className:"text-fg-faint italic px-2 py-4",children:"no settings resolved — all scope files empty or missing"}):n.jsx(E,{node:e,path:g,label:m??"(root)",targetScope:r,onOverride:s,filter:f,depth:0,schema:a,showGhosts:u,defaultOpen:!0})})]})}function E(e){return e.node.kind==="object"?n.jsx(P,{...e,node:e.node}):e.node.kind==="array"?n.jsx(J,{...e,node:e.node}):n.jsx(R,{...e,node:e.node})}function A(e,r){return r.trim()?e.toLowerCase().includes(r.toLowerCase()):!0}function w(e,r,s){return!!(!s.trim()||e.some(a=>A(a,s))||r!=null&&r.description&&A(r.description,s))}function P({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u,showGhosts:o,defaultOpen:g}){const[d,h]=b.useState(g??x<2),y=(u==null?void 0:u.at(r))??null,t=new Set(Object.keys(e.children)),i=o?(y==null?void 0:y.knownChildren)??[]:[],c=Array.from(new Set([...t,...i])).sort(),l=b.useMemo(()=>f.trim()?c.filter(p=>{const v=(u==null?void 0:u.at([...r,p]))??null;return w([...r,p],v,f)?!0:t.has(p)?L(e.children[p],[...r,p],f,u):!1}):c,[c.join(","),f,e,r.join("."),u]);if(f.trim()&&l.length===0&&!w(r,y,f))return null;if(r.length===0)return n.jsx("div",{children:l.map(p=>j(p))});return n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:x*14},onClick:()=>h(!d),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:d?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" {",t.size,i.length>t.size?`/${new Set([...t,...i]).size}`:"","}"]}),(y==null?void 0:y.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:y.description,children:["— ",y.description]})]}),d&&l.map(p=>j(p))]});function j(p){if(t.has(p))return n.jsx(E,{node:e.children[p],path:[...r,p],label:p,targetScope:a,onOverride:m,filter:f,depth:x+1,schema:u,showGhosts:o},p);const v=(u==null?void 0:u.at([...r,p]))??null;return v?n.jsx(I,{path:[...r,p],label:p,info:v,targetScope:a,onSet:m,depth:x+1,filter:f},p):null}}function L(e,r,s,a){const m=(a==null?void 0:a.at(r))??null;if(w(r,m,s))return!0;if(e.kind!=="object")return!1;for(const f of Object.keys(e.children))if(L(e.children[f],[...r,f],s,a))return!0;return!1}function J({node:e,path:r,label:s,filter:a,depth:m,schema:f}){const[x,u]=b.useState(!0),o=(f==null?void 0:f.at(r))??null;return w(r,o,a)?n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:m*14},onClick:()=>u(!x),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:x?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" [",e.items.length,"]"]}),n.jsx(S,{tone:"warn",children:"accumulated"}),(o==null?void 0:o.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:o.description,children:["— ",o.description]})]}),x&&e.items.map((g,d)=>n.jsxs("div",{className:"flex items-start gap-2 py-0.5",style:{paddingLeft:(m+1)*14},children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0"}),n.jsxs("span",{className:"text-fg-faint",children:[d,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-xl",children:k(g.value)}),n.jsx(C,{scope:g.fromScope})]},d))]}):null}function R({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u}){const[o,g]=b.useState(!1),d=(u==null?void 0:u.at(r))??null;if(!w(r,d,f))return null;const h=e.winner!==a,y=e.shadowed.length>0;return n.jsxs("div",{children:[n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded",style:{paddingLeft:x*14},children:[n.jsx("span",{className:`w-3 shrink-0 ${y?"text-fg-faint cursor-pointer":""}`,onClick:()=>y&&g(!o),children:y?o?"▾":"▸":""}),n.jsxs("span",{className:"text-fg",children:[s,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-md",children:k(e.value)}),n.jsx(C,{scope:e.winner}),(d==null?void 0:d.deprecated)&&n.jsx(S,{tone:"warn",children:"deprecated"}),(d==null?void 0:d.default)!==void 0&&_(e.value,d.default)&&n.jsx(S,{tone:"dim",children:"= default"}),e.typeMismatch&&n.jsx("span",{className:"text-yellow-500/80",title:"shadowed scopes have a different value type",children:"⚠"}),(d==null?void 0:d.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:d.description,children:["— ",d.description]}),n.jsx("div",{className:"flex-1"}),(d==null?void 0:d.enum)&&n.jsx(F,{values:d.enum}),n.jsx("button",{disabled:!h,onClick:()=>m(r,e.value),className:`opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border rounded transition-opacity ${h?"border-accent/50 text-accent hover:bg-accent hover:text-bg":"border-line text-fg-faint cursor-not-allowed"}`,title:h?`copy into ${N[a].label} draft`:`already winning in ${N[a].label}`,children:"override"})]}),o&&e.shadowed.map((t,i)=>n.jsxs("div",{className:"flex items-center gap-2 py-0.5 text-fg-faint line-through",style:{paddingLeft:(x+1)*14+12},children:[n.jsx("span",{children:"shadowed:"}),n.jsx("span",{className:"truncate max-w-xl",children:k(t.value)}),n.jsx(C,{scope:t.scope,dim:!0})]},i))]})}function I({path:e,label:r,info:s,targetScope:a,onSet:m,depth:f,filter:x}){if(!w(e,s,x))return null;const u=s.default!==void 0?s.default:M(s.type),o=Array.isArray(s.type)?s.type.join("|"):s.type??"?";return n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded opacity-60",style:{paddingLeft:f*14},children:[n.jsx("span",{className:"w-3 shrink-0"}),n.jsx("span",{className:"text-fg-dim",children:r}),n.jsx(S,{tone:"dim",children:o}),s.required&&n.jsx(S,{tone:"warn",children:"required"}),s.default!==void 0&&n.jsxs("span",{className:"text-fg-faint",children:["default: ",k(s.default)]}),s.description&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:s.description,children:["— ",s.description]}),n.jsx("div",{className:"flex-1"}),s.enum&&n.jsx(F,{values:s.enum}),n.jsx("button",{onClick:()=>m(e,u),className:"opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border border-accent/50 rounded text-accent hover:bg-accent hover:text-bg transition-opacity",title:`write to ${N[a].label} draft`,children:"set"})]})}function _(e,r){return e===r?!0:typeof e!=typeof r?!1:e&&r&&typeof e=="object"?JSON.stringify(e)===JSON.stringify(r):!1}function M(e){switch(Array.isArray(e)?e[0]:e){case"boolean":return!1;case"integer":case"number":return 0;case"string":return"";case"array":return[];case"object":return{};case"null":return null;default:return null}}function k(e){if(typeof e=="string")return JSON.stringify(e);if(e===null)return"null";if(typeof e=="object"){const r=JSON.stringify(e);return r.length>100?r.slice(0,100)+"…":r}return String(e)}function C({scope:e,dim:r}){const s=e==="user"?"bg-bg-hi text-fg-dim border-line":e==="project"?"bg-blue-950/30 text-blue-400/80 border-blue-900/40":"bg-purple-950/30 text-purple-400/80 border-purple-900/40";return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s} ${r?"opacity-60":""}`,children:N[e].label})}function S({children:e,tone:r="default"}){const s={default:"bg-bg-hi text-fg-dim border-line",warn:"bg-yellow-950/30 text-yellow-500/80 border-yellow-900/40",dim:"bg-bg-elev text-fg-faint border-line"}[r];return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s}`,children:e})}function F({values:e}){const r=e.map(s=>String(s)).join(" | ");return n.jsxs("span",{className:"text-[10px] text-fg-faint truncate max-w-xs",title:`allowed: ${r}`,children:["∈ ",r.length>30?r.slice(0,30)+"…":r]})}function G(e,r,s){const a=O(o=>o.files),m=O(o=>o.loadJson),f=O(o=>o.watchFile),x=O(o=>o.unwatchFile),u=b.useMemo(()=>{const o={};for(const g of e.scopes){const d=e.resolve(g,r??"",s);d&&(o[g]=d)}return o},[e,r,s]);return b.useEffect(()=>{const o=Object.values(u).filter(Boolean);return o.forEach(g=>{a[g]||m(g),f(g)}),()=>{o.forEach(g=>x(g))}},[JSON.stringify(u)]),u}function z(e){const r=e??{},s={...r.$defs??{},...r.definitions??{}},a=t=>{if(!t)return{};if(!t.$ref)return x(t);const i=/^#\/(\$defs|definitions)\/(.+)$/.exec(t.$ref);if(!i)return x(t);const c=s[i[2]];return c?a({...c,...u(t)}):x(t)},m=new Set(["__proto__","constructor","prototype"]),f=t=>{if(!t)return{};const i={};for(const[c,l]of Object.entries(t))m.has(c)||(i[c]=l);return i},x=t=>{if(!t.allOf||t.allOf.length===0)return t;const i={...t};delete i.allOf;for(const c of t.allOf){const l=a(c);Object.assign(i,{properties:{...f(i.properties),...f(l.properties)},required:B([...i.required??[],...l.required??[]])}),!i.description&&l.description&&(i.description=l.description),i.default===void 0&&l.default!==void 0&&(i.default=l.default),!i.type&&l.type&&(i.type=l.type)}return i},u=t=>{const{$ref:i,...c}=t;return c},o=(t,i)=>{let c=a(t);for(const l of i){if(!c)return null;if(/^\d+$/.test(l)){const v=Array.isArray(c.items)?c.items[Number(l)]:c.items;c=v?a(v):null;continue}const j=c.properties;if(j&&!m.has(l)&&l in j){c=a(j[l]);continue}const p=c.additionalProperties;if(p&&typeof p=="object"){c=a(p);continue}return null}return c},g=(t,i,c)=>{if(!t)return null;const l={};if(t.description&&(l.description=t.description),t.type&&(l.type=t.type),t.default!==void 0&&(l.default=t.default),t.enum&&(l.enum=t.enum),t.const!==void 0&&(l.const=t.const),t.deprecated&&(l.deprecated=!0),t.examples&&(l.examples=t.examples),t.properties&&(l.knownChildren=Object.keys(t.properties)),c&&(i!=null&&i.includes(c))&&(l.required=!0),t.items&&!Array.isArray(t.items)){const j=a(t.items),p=g(j,void 0,null);p&&(l.itemsInfo=p)}return(t.additionalProperties===!0||typeof t.additionalProperties=="object")&&(l.open=!0),l},d=t=>i=>{if(i.length===0)return g(a(t),void 0,null);const c=o(t,i.slice(0,-1));if(!c)return null;const l=i[i.length-1],j=o(c,[l]);return g(j,c.required,l)},h=t=>{const i=a(t);return Object.keys(i.properties??{})},y=t=>({at:d(t),rootKeys:()=>h(t),rootedAt:i=>{const c=o(t,i);return y(c??{})}});return y(r)}function B(e){return Array.from(new Set(e))}let $=null;function V(){return $||($=z(K)),$}export{T as E,V as s,G as u};
1
+ import{az as b,ak as n,S as N,aO as O,ax as K}from"./index-Yf8e7VCB.js";function T({node:e,targetScope:r,onOverride:s,schema:a,rootLabel:m}){const[f,x]=b.useState(""),[u,o]=b.useState(!0),g=[],d=e.kind==="object"&&Object.keys(e.children).length===0,y=((a==null?void 0:a.rootKeys())??[]).length>0;return n.jsxs("div",{className:"h-full flex flex-col",children:[n.jsxs("div",{className:"shrink-0 border-b border-line bg-bg-elev px-3 py-1.5 flex items-center gap-2 text-xs",children:[n.jsxs("span",{className:"text-fg-faint",children:["merged · overrides → ",n.jsx("span",{className:"text-fg-dim",children:N[r].label})]}),y&&n.jsxs("label",{className:"flex items-center gap-1 text-fg-faint cursor-pointer",children:[n.jsx("input",{type:"checkbox",checked:u,onChange:t=>o(t.target.checked),className:"accent-accent"}),"show unset keys"]}),n.jsx("div",{className:"flex-1"}),n.jsx("input",{value:f,onChange:t=>x(t.target.value),placeholder:"filter keys or descriptions…",className:"bg-bg border border-line rounded px-2 py-0.5 text-xs text-fg w-56"})]}),n.jsx("div",{className:"flex-1 overflow-auto px-3 py-2 font-mono text-xs",children:d&&!u?n.jsx("div",{className:"text-fg-faint italic px-2 py-4",children:"no settings resolved — all scope files empty or missing"}):n.jsx(E,{node:e,path:g,label:m??"(root)",targetScope:r,onOverride:s,filter:f,depth:0,schema:a,showGhosts:u,defaultOpen:!0})})]})}function E(e){return e.node.kind==="object"?n.jsx(P,{...e,node:e.node}):e.node.kind==="array"?n.jsx(J,{...e,node:e.node}):n.jsx(R,{...e,node:e.node})}function A(e,r){return r.trim()?e.toLowerCase().includes(r.toLowerCase()):!0}function w(e,r,s){return!!(!s.trim()||e.some(a=>A(a,s))||r!=null&&r.description&&A(r.description,s))}function P({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u,showGhosts:o,defaultOpen:g}){const[d,h]=b.useState(g??x<2),y=(u==null?void 0:u.at(r))??null,t=new Set(Object.keys(e.children)),i=o?(y==null?void 0:y.knownChildren)??[]:[],c=Array.from(new Set([...t,...i])).sort(),l=b.useMemo(()=>f.trim()?c.filter(p=>{const v=(u==null?void 0:u.at([...r,p]))??null;return w([...r,p],v,f)?!0:t.has(p)?L(e.children[p],[...r,p],f,u):!1}):c,[c.join(","),f,e,r.join("."),u]);if(f.trim()&&l.length===0&&!w(r,y,f))return null;if(r.length===0)return n.jsx("div",{children:l.map(p=>j(p))});return n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:x*14},onClick:()=>h(!d),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:d?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" {",t.size,i.length>t.size?`/${new Set([...t,...i]).size}`:"","}"]}),(y==null?void 0:y.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:y.description,children:["— ",y.description]})]}),d&&l.map(p=>j(p))]});function j(p){if(t.has(p))return n.jsx(E,{node:e.children[p],path:[...r,p],label:p,targetScope:a,onOverride:m,filter:f,depth:x+1,schema:u,showGhosts:o},p);const v=(u==null?void 0:u.at([...r,p]))??null;return v?n.jsx(I,{path:[...r,p],label:p,info:v,targetScope:a,onSet:m,depth:x+1,filter:f},p):null}}function L(e,r,s,a){const m=(a==null?void 0:a.at(r))??null;if(w(r,m,s))return!0;if(e.kind!=="object")return!1;for(const f of Object.keys(e.children))if(L(e.children[f],[...r,f],s,a))return!0;return!1}function J({node:e,path:r,label:s,filter:a,depth:m,schema:f}){const[x,u]=b.useState(!0),o=(f==null?void 0:f.at(r))??null;return w(r,o,a)?n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:m*14},onClick:()=>u(!x),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:x?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" [",e.items.length,"]"]}),n.jsx(S,{tone:"warn",children:"accumulated"}),(o==null?void 0:o.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:o.description,children:["— ",o.description]})]}),x&&e.items.map((g,d)=>n.jsxs("div",{className:"flex items-start gap-2 py-0.5",style:{paddingLeft:(m+1)*14},children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0"}),n.jsxs("span",{className:"text-fg-faint",children:[d,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-xl",children:k(g.value)}),n.jsx(C,{scope:g.fromScope})]},d))]}):null}function R({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u}){const[o,g]=b.useState(!1),d=(u==null?void 0:u.at(r))??null;if(!w(r,d,f))return null;const h=e.winner!==a,y=e.shadowed.length>0;return n.jsxs("div",{children:[n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded",style:{paddingLeft:x*14},children:[n.jsx("span",{className:`w-3 shrink-0 ${y?"text-fg-faint cursor-pointer":""}`,onClick:()=>y&&g(!o),children:y?o?"▾":"▸":""}),n.jsxs("span",{className:"text-fg",children:[s,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-md",children:k(e.value)}),n.jsx(C,{scope:e.winner}),(d==null?void 0:d.deprecated)&&n.jsx(S,{tone:"warn",children:"deprecated"}),(d==null?void 0:d.default)!==void 0&&_(e.value,d.default)&&n.jsx(S,{tone:"dim",children:"= default"}),e.typeMismatch&&n.jsx("span",{className:"text-yellow-500/80",title:"shadowed scopes have a different value type",children:"⚠"}),(d==null?void 0:d.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:d.description,children:["— ",d.description]}),n.jsx("div",{className:"flex-1"}),(d==null?void 0:d.enum)&&n.jsx(F,{values:d.enum}),n.jsx("button",{disabled:!h,onClick:()=>m(r,e.value),className:`opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border rounded transition-opacity ${h?"border-accent/50 text-accent hover:bg-accent hover:text-bg":"border-line text-fg-faint cursor-not-allowed"}`,title:h?`copy into ${N[a].label} draft`:`already winning in ${N[a].label}`,children:"override"})]}),o&&e.shadowed.map((t,i)=>n.jsxs("div",{className:"flex items-center gap-2 py-0.5 text-fg-faint line-through",style:{paddingLeft:(x+1)*14+12},children:[n.jsx("span",{children:"shadowed:"}),n.jsx("span",{className:"truncate max-w-xl",children:k(t.value)}),n.jsx(C,{scope:t.scope,dim:!0})]},i))]})}function I({path:e,label:r,info:s,targetScope:a,onSet:m,depth:f,filter:x}){if(!w(e,s,x))return null;const u=s.default!==void 0?s.default:M(s.type),o=Array.isArray(s.type)?s.type.join("|"):s.type??"?";return n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded opacity-60",style:{paddingLeft:f*14},children:[n.jsx("span",{className:"w-3 shrink-0"}),n.jsx("span",{className:"text-fg-dim",children:r}),n.jsx(S,{tone:"dim",children:o}),s.required&&n.jsx(S,{tone:"warn",children:"required"}),s.default!==void 0&&n.jsxs("span",{className:"text-fg-faint",children:["default: ",k(s.default)]}),s.description&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:s.description,children:["— ",s.description]}),n.jsx("div",{className:"flex-1"}),s.enum&&n.jsx(F,{values:s.enum}),n.jsx("button",{onClick:()=>m(e,u),className:"opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border border-accent/50 rounded text-accent hover:bg-accent hover:text-bg transition-opacity",title:`write to ${N[a].label} draft`,children:"set"})]})}function _(e,r){return e===r?!0:typeof e!=typeof r?!1:e&&r&&typeof e=="object"?JSON.stringify(e)===JSON.stringify(r):!1}function M(e){switch(Array.isArray(e)?e[0]:e){case"boolean":return!1;case"integer":case"number":return 0;case"string":return"";case"array":return[];case"object":return{};case"null":return null;default:return null}}function k(e){if(typeof e=="string")return JSON.stringify(e);if(e===null)return"null";if(typeof e=="object"){const r=JSON.stringify(e);return r.length>100?r.slice(0,100)+"…":r}return String(e)}function C({scope:e,dim:r}){const s=e==="user"?"bg-bg-hi text-fg-dim border-line":e==="project"?"bg-blue-950/30 text-blue-400/80 border-blue-900/40":"bg-purple-950/30 text-purple-400/80 border-purple-900/40";return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s} ${r?"opacity-60":""}`,children:N[e].label})}function S({children:e,tone:r="default"}){const s={default:"bg-bg-hi text-fg-dim border-line",warn:"bg-yellow-950/30 text-yellow-500/80 border-yellow-900/40",dim:"bg-bg-elev text-fg-faint border-line"}[r];return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s}`,children:e})}function F({values:e}){const r=e.map(s=>String(s)).join(" | ");return n.jsxs("span",{className:"text-[10px] text-fg-faint truncate max-w-xs",title:`allowed: ${r}`,children:["∈ ",r.length>30?r.slice(0,30)+"…":r]})}function G(e,r,s){const a=O(o=>o.files),m=O(o=>o.loadJson),f=O(o=>o.watchFile),x=O(o=>o.unwatchFile),u=b.useMemo(()=>{const o={};for(const g of e.scopes){const d=e.resolve(g,r??"",s);d&&(o[g]=d)}return o},[e,r,s]);return b.useEffect(()=>{const o=Object.values(u).filter(Boolean);return o.forEach(g=>{a[g]||m(g),f(g)}),()=>{o.forEach(g=>x(g))}},[JSON.stringify(u)]),u}function z(e){const r=e??{},s={...r.$defs??{},...r.definitions??{}},a=t=>{if(!t)return{};if(!t.$ref)return x(t);const i=/^#\/(\$defs|definitions)\/(.+)$/.exec(t.$ref);if(!i)return x(t);const c=s[i[2]];return c?a({...c,...u(t)}):x(t)},m=new Set(["__proto__","constructor","prototype"]),f=t=>{if(!t)return{};const i={};for(const[c,l]of Object.entries(t))m.has(c)||(i[c]=l);return i},x=t=>{if(!t.allOf||t.allOf.length===0)return t;const i={...t};delete i.allOf;for(const c of t.allOf){const l=a(c);Object.assign(i,{properties:{...f(i.properties),...f(l.properties)},required:B([...i.required??[],...l.required??[]])}),!i.description&&l.description&&(i.description=l.description),i.default===void 0&&l.default!==void 0&&(i.default=l.default),!i.type&&l.type&&(i.type=l.type)}return i},u=t=>{const{$ref:i,...c}=t;return c},o=(t,i)=>{let c=a(t);for(const l of i){if(!c)return null;if(/^\d+$/.test(l)){const v=Array.isArray(c.items)?c.items[Number(l)]:c.items;c=v?a(v):null;continue}const j=c.properties;if(j&&!m.has(l)&&l in j){c=a(j[l]);continue}const p=c.additionalProperties;if(p&&typeof p=="object"){c=a(p);continue}return null}return c},g=(t,i,c)=>{if(!t)return null;const l={};if(t.description&&(l.description=t.description),t.type&&(l.type=t.type),t.default!==void 0&&(l.default=t.default),t.enum&&(l.enum=t.enum),t.const!==void 0&&(l.const=t.const),t.deprecated&&(l.deprecated=!0),t.examples&&(l.examples=t.examples),t.properties&&(l.knownChildren=Object.keys(t.properties)),c&&(i!=null&&i.includes(c))&&(l.required=!0),t.items&&!Array.isArray(t.items)){const j=a(t.items),p=g(j,void 0,null);p&&(l.itemsInfo=p)}return(t.additionalProperties===!0||typeof t.additionalProperties=="object")&&(l.open=!0),l},d=t=>i=>{if(i.length===0)return g(a(t),void 0,null);const c=o(t,i.slice(0,-1));if(!c)return null;const l=i[i.length-1],j=o(c,[l]);return g(j,c.required,l)},h=t=>{const i=a(t);return Object.keys(i.properties??{})},y=t=>({at:d(t),rootKeys:()=>h(t),rootedAt:i=>{const c=o(t,i);return y(c??{})}});return y(r)}function B(e){return Array.from(new Set(e))}let $=null;function V(){return $||($=z(K)),$}export{T as E,V as s,G as u};
package/dist/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-CCl4tz-u.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-Yf8e7VCB.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-CKH5Uxik.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.94.0",
3
+ "version": "0.95.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -39,6 +39,8 @@
39
39
  "prepack": "node scripts/write-build-info.cjs",
40
40
  "prepublishOnly": "node scripts/write-build-info.cjs && vite build",
41
41
  "test:unit": "vitest run",
42
+ "bench": "node scripts/bench/main-bench.cjs",
43
+ "bench:renderer": "vitest run --config vitest.bench.config.ts",
42
44
  "test:e2e": "xvfb-run -a playwright test",
43
45
  "smoke:darwin": "playwright test tests/smoke/darwin-boot.spec.ts",
44
46
  "test:e2e:mic": "xvfb-run -a playwright test e2e/mic.spec.mjs",
package/scripts/README.md CHANGED
@@ -9,6 +9,7 @@ scripts/lib was moved into src/main/lib (2026-09); nothing under src/ may requir
9
9
  | script | invoked by | shipped on npm |
10
10
  | --- | --- | --- |
11
11
  | audit-ops-hygiene.cjs | manual; named in `ops-sweep` + `develop` SKILL.md | yes |
12
+ | bench/main-bench.cjs | `npm run bench` (main-process micro-benches, plain Node); renderer half is `npm run bench:renderer` (`tests/bench/`, `vitest.bench.config.ts`, not in `test:unit`) | no |
12
13
  | check-conditional-hooks.cjs | `npm run lint:hooks` (in `lint`) | no |
13
14
  | check-epic-transcripts.cjs | manual read-only diagnostic (`--json`); also surfaced as a non-fatal warning in `npm run health` | no |
14
15
  | check-doc-hierarchy.cjs | `npm run lint:docs` (in `lint`) | no |
@@ -54,6 +55,8 @@ The four `hooks/guard-*.cjs` are adopted by reference, not copied: `src/main/lib
54
55
 
55
56
  `cleanup-nested-queue-stubs.cjs` removes nested queue shards and then dispatches to `cleanup-worktree-ops-stubs.cjs` (its `main()`), which handles the worktree-side stubs.
56
57
 
58
+ Bench numbers are machine-relative — compare runs on the same machine only; there are no pass/fail thresholds.
59
+
57
60
  ## npm shipping
58
61
 
59
62
  `ops-sweep.cjs` and `audit-ops-hygiene.cjs` ship: they need only Node built-ins and `src/main/` (also packed), no dev-only deps. `package.json` `files` lists every shipped script individually; `__tests__/package-files.test.cjs` asserts it.
@@ -92,6 +92,18 @@ test('no false positive: billing utilization at/above utilizationThreshold (when
92
92
  assert.strictEqual(result.reason, 'utilization-at-threshold');
93
93
  });
94
94
 
95
+ test('utilization hold with reset days out is flagged longHold; within 5h is benign', () => {
96
+ const far = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, utilizationWindow: 'weekly_all', nextReset: new Date(NOW + 3 * 86_400_000).toISOString() }, NOW);
97
+ assert.strictEqual(far.stalled, false);
98
+ assert.strictEqual(far.longHold, true);
99
+ assert.strictEqual(far.window, 'weekly_all');
100
+ const unknown = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, nextReset: null }, NOW);
101
+ assert.strictEqual(unknown.longHold, true);
102
+ const near = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, utilizationWindow: 'five_hour', nextReset: new Date(NOW + 2 * 3_600_000).toISOString() }, NOW);
103
+ assert.strictEqual(near.stalled, false);
104
+ assert.strictEqual(near.longHold, undefined);
105
+ });
106
+
95
107
  test('no false positive: no pending jobs at all', () => {
96
108
  const q = baseQueue({ jobs: [] });
97
109
  const result = evaluateTickLiveness(q, null, NOW);
@@ -31,6 +31,7 @@ const {
31
31
  billingResetForPause,
32
32
  computeEffectiveResumeAt,
33
33
  computeResumeDelay,
34
+ computeDegradedBudget,
34
35
  } = require('../scheduler.cjs');
35
36
  const billing = require('../usage.cjs');
36
37
 
@@ -135,3 +136,17 @@ test('computeResumeDelay: a reset far enough out DOES trip the overflow guard',
135
136
  const { tooFar } = computeResumeDelay(thirtyDaysOut, now);
136
137
  assert.strictEqual(tooFar, true);
137
138
  });
139
+
140
+ test('refreshNextReset (via billingResetForPause) seeds lastGoodUsagePayload: a following degraded cycle yields the fetched percent, not 100', async () => {
141
+ const originalFetchUsage = billing.fetchUsage;
142
+ billing.fetchUsage = async () => ({
143
+ kind: 'ok',
144
+ data: { usage: { limits: [{ kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true }] } },
145
+ });
146
+ try {
147
+ await billingResetForPause();
148
+ assert.strictEqual(computeDegradedBudget().utilization, 64);
149
+ } finally {
150
+ billing.fetchUsage = originalFetchUsage;
151
+ }
152
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * scheduler-utilization-hold.test.cjs — the when-available gate holds on the
3
+ * BINDING window and records a machine-readable hold (window/percent/
4
+ * threshold/reset) on the snapshot; under threshold it dispatches.
5
+ * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-utilization-hold.test.cjs
6
+ */
7
+ 'use strict';
8
+
9
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ let tmpHome;
15
+ let originalHome;
16
+ let scheduler;
17
+ let billing;
18
+ let originalFetchUsage;
19
+
20
+ beforeAll(() => {
21
+ originalHome = process.env.HOME;
22
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-utilhold-'));
23
+ process.env.HOME = tmpHome;
24
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
25
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
26
+ scheduler = require('../scheduler.cjs');
27
+ billing = require('../usage.cjs');
28
+ originalFetchUsage = billing.fetchUsage;
29
+ });
30
+
31
+ afterAll(() => {
32
+ billing.fetchUsage = originalFetchUsage;
33
+ process.env.HOME = originalHome;
34
+ fs.rmSync(tmpHome, { recursive: true, force: true });
35
+ });
36
+
37
+ afterEach(() => { billing.fetchUsage = originalFetchUsage; });
38
+
39
+ const RESET = '2026-09-24T17:00:00Z';
40
+ function usageWith(weeklyPct) {
41
+ return {
42
+ kind: 'ok',
43
+ data: {
44
+ usage: {
45
+ five_hour: { utilization: 10, resets_at: '2026-09-21T20:00:00Z' },
46
+ limits: [
47
+ { kind: 'session', percent: 10, scope: null, resets_at: '2026-09-21T20:00:00Z' },
48
+ { kind: 'weekly_all', percent: weeklyPct, scope: null, resets_at: RESET },
49
+ ],
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ // dependsOn never resolves so a tick reaches the picker (stamping
56
+ // lastDispatchAttemptAt) without ever spawning a real job.
57
+ function state() {
58
+ return {
59
+ config: { firePolicy: 'when-available', utilizationThreshold: 90 },
60
+ paused: null,
61
+ jobs: [{ slug: '9002-hold', title: 'x', cwd: tmpHome, status: 'pending', dependsOn: ['never'] }],
62
+ };
63
+ }
64
+
65
+ test('holds (no tick) and records the hold when the binding weekly window is over threshold', async () => {
66
+ billing.fetchUsage = async () => usageWith(91);
67
+ await scheduler.refreshNextReset();
68
+ const logs = [];
69
+ const orig = console.log;
70
+ console.log = (...a) => { logs.push(a.join(' ')); };
71
+ try { await scheduler.maybeLaunchWhenAvailable(state()); } finally { console.log = orig; }
72
+ const payload = scheduler.buildScheduleStatePayload(state());
73
+ expect(payload.utilizationHold).toEqual({ window: 'weekly_all', percent: 91, threshold: 90, resetsAt: RESET });
74
+ expect(payload.utilizationWindow).toBe('weekly_all');
75
+ expect(logs.some((l) => l.includes('utilization-held') && l.includes('weekly_all'))).toBe(true);
76
+ expect(logs.some((l) => l.includes('— ticking'))).toBe(false);
77
+ });
78
+
79
+ test('ticks and clears the hold when the binding window is under threshold (weekly_all 64)', async () => {
80
+ billing.fetchUsage = async () => usageWith(64);
81
+ await scheduler.refreshNextReset();
82
+ const logs = [];
83
+ const orig = console.log;
84
+ console.log = (...a) => { logs.push(a.join(' ')); };
85
+ try { await scheduler.maybeLaunchWhenAvailable(state()); } finally { console.log = orig; }
86
+ await scheduler.tickQueue();
87
+ expect(scheduler.buildScheduleStatePayload(state()).utilizationHold).toBeNull();
88
+ expect(logs.some((l) => l.includes('— ticking'))).toBe(true);
89
+ });
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.94.0",
3
- "gitSha": "b5777198add78466da6d34b83d818bb1352f999a",
4
- "gitShortSha": "b577719",
2
+ "version": "0.95.0",
3
+ "gitSha": "42f7779080772b1b8432b6287aff06c52b9f0f90",
4
+ "gitShortSha": "42f77790",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-21T19:59:46.626Z",
6
+ "builtAt": "2026-09-21T22:01:57.972Z",
7
7
  "dirty": false
8
8
  }
@@ -27,11 +27,13 @@ const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
29
  const { reportCrash } = require('./lib/crashTelemetry.cjs');
30
+ const { createLoopDelayMonitor, stallVerdict } = require('./lib/loopDelay.cjs');
30
31
 
31
32
  let logs = null;
32
33
  let getPowerBlockerId = () => -1;
33
34
  let onSuspendWhileAlive = null;
34
35
  let heartbeatTimer = null;
36
+ let loopMonitor = null;
35
37
  let sentinelPath = null;
36
38
  let startedAtIso = null;
37
39
  let lastSample = null;
@@ -118,6 +120,7 @@ function heartbeat() {
118
120
  persistSentinel(false);
119
121
  const top = [...lastSample.procs].sort((a, b) => b.mb - a.mb).slice(0, 4);
120
122
  const level = lastSample.totalMb >= MEM_WARN_MB ? 'warn' : 'debug';
123
+ const loop = loopMonitor ? loopMonitor.snapshot() : null;
121
124
  logs?.writeLine({
122
125
  scope: 'crash-diag',
123
126
  level,
@@ -127,8 +130,11 @@ function heartbeat() {
127
130
  mainRssMb: lastSample.main.rssMb,
128
131
  powerBlockerHeld: lastSample.powerBlockerHeld,
129
132
  top: top.map((p) => `${p.type}:${p.mb}MB`),
133
+ ...loop,
130
134
  },
131
135
  });
136
+ const stall = loop && stallVerdict(loop);
137
+ if (stall) logs?.writeLine({ scope: 'crash-diag', ...stall, meta: { ...loop } });
132
138
  }
133
139
 
134
140
  // Read the previous run's sentinel. If it was still `open`, the app died
@@ -242,6 +248,8 @@ function init(opts) {
242
248
  persistSentinel(false); // mark this run as open
243
249
  registerCrashHooks();
244
250
 
251
+ loopMonitor = createLoopDelayMonitor();
252
+ loopMonitor.enable();
245
253
  heartbeat(); // immediate baseline sample
246
254
  heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS);
247
255
  if (heartbeatTimer.unref) heartbeatTimer.unref(); // don't keep the loop alive
@@ -250,6 +258,7 @@ function init(opts) {
250
258
  // Call from will-quit so a graceful exit is distinguishable from an OOM-kill.
251
259
  function markCleanShutdown() {
252
260
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
261
+ if (loopMonitor) { loopMonitor.disable(); loopMonitor = null; }
253
262
  persistSentinel(true);
254
263
  }
255
264
 
@@ -39,6 +39,8 @@ const PROJECT_ROOT = path.resolve(__dirname, '../..');
39
39
  // outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
40
40
  // 16.5h before anything asserted on it).
41
41
  const TICK_STALL_MULTIPLIER = 3;
42
+ // A utilization hold whose reset is further out than this is not a routine 5h pause.
43
+ const LONG_HOLD_MS = 5 * 60 * 60_000;
42
44
  const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
43
45
  // A heartbeat line older than this is treated as "the app isn't running /
44
46
  // we can't observe live utilization" rather than "utilization is low" —
@@ -139,7 +141,19 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
139
141
  };
140
142
  }
141
143
  if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
142
- return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
144
+ // A hold on the binding window is benign only if it self-heals soon
145
+ // (reset within 5h). A weekly hold, or an unknown reset horizon, can
146
+ // last days — surface it rather than reading GREEN.
147
+ const resetMs = heartbeat.nextReset ? Date.parse(heartbeat.nextReset) : NaN;
148
+ const longHold = !Number.isFinite(resetMs) || resetMs - now > LONG_HOLD_MS;
149
+ return {
150
+ stalled: false,
151
+ reason: 'utilization-at-threshold',
152
+ utilization: heartbeat.utilization,
153
+ window: heartbeat.utilizationWindow ?? null,
154
+ nextReset: heartbeat.nextReset ?? null,
155
+ ...(longHold ? { longHold: true } : {}),
156
+ };
143
157
  }
144
158
  }
145
159
 
@@ -860,6 +874,16 @@ async function check(opts = {}) {
860
874
  })(),
861
875
  loadGateThreshold: loadGateThreshold(),
862
876
  };
877
+ if (liveness.longHold) {
878
+ status.components.scheduler_queue.longHold = {
879
+ window: liveness.window, utilization: liveness.utilization, nextReset: liveness.nextReset,
880
+ };
881
+ status.issues.push(
882
+ `Queue held on the ${liveness.window ?? 'binding'} usage window at ${liveness.utilization}% (≥ threshold); `
883
+ + (liveness.nextReset ? `resets ${liveness.nextReset}` : 'reset time unknown')
884
+ + ' — hold may last days, not a normal short pause'
885
+ );
886
+ }
863
887
  if (liveness.stalled) {
864
888
  const ageMin = Math.round(liveness.tickAgeMs / 60_000);
865
889
  status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * loopDelay.test.cjs — event-loop delay probe used by crashDiagnostics's heartbeat.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/loopDelay.test.cjs
5
+ */
6
+ 'use strict';
7
+
8
+ import { test, expect } from 'vitest';
9
+
10
+ const { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS } = require('../loopDelay.cjs');
11
+
12
+ // Histogram stub: values in ns, like perf_hooks.
13
+ function stubHistogram(state) {
14
+ return () => ({
15
+ enabled: false,
16
+ enable() { this.enabled = true; },
17
+ disable() { this.enabled = false; },
18
+ percentile(p) { return p === 50 ? state.p50 : state.p99; },
19
+ get max() { return state.max; },
20
+ reset() { state.resets += 1; state.p50 = 0; state.p99 = 0; state.max = 0; },
21
+ });
22
+ }
23
+
24
+ test('normal beat: converts ns to ms (1 decimal) and does not flag a stall', () => {
25
+ const state = { p50: 21_040_000, p99: 34_960_000, max: 40_000_000, resets: 0 };
26
+ const m = createLoopDelayMonitor(stubHistogram(state));
27
+ m.enable();
28
+ const snap = m.snapshot();
29
+ expect(snap).toEqual({ loopDelayP50Ms: 21, loopDelayP99Ms: 35, loopDelayMaxMs: 40 });
30
+ expect(stallVerdict(snap)).toBeNull();
31
+ });
32
+
33
+ test('stall beat: max above threshold yields warn "main loop stall"', () => {
34
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 412_300_000, resets: 0 };
35
+ const snap = createLoopDelayMonitor(stubHistogram(state)).snapshot();
36
+ expect(snap.loopDelayMaxMs).toBe(412.3);
37
+ expect(stallVerdict(snap)).toEqual({ level: 'warn', message: 'main loop stall' });
38
+ expect(stallVerdict({ loopDelayMaxMs: LOOP_STALL_WARN_MS })).toBeNull(); // strictly greater
39
+ });
40
+
41
+ test('reset between beats: a stall does not bleed into the next interval', () => {
42
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 500_000_000, resets: 0 };
43
+ const m = createLoopDelayMonitor(stubHistogram(state));
44
+ expect(stallVerdict(m.snapshot())).not.toBeNull();
45
+ expect(state.resets).toBe(1);
46
+ const second = m.snapshot();
47
+ expect(second.loopDelayMaxMs).toBe(0);
48
+ expect(stallVerdict(second)).toBeNull();
49
+ expect(state.resets).toBe(2);
50
+ });
51
+
52
+ test('passes a 20 ms resolution to the histogram factory and disable() stops it', () => {
53
+ let opts; let h;
54
+ const m = createLoopDelayMonitor((o) => { opts = o; h = stubHistogram({ resets: 0 })(); return h; });
55
+ m.enable();
56
+ expect(opts).toEqual({ resolution: 20 });
57
+ expect(h.enabled).toBe(true);
58
+ m.disable();
59
+ expect(h.enabled).toBe(false);
60
+ });
61
+
62
+ test('real perf_hooks histogram produces finite numbers', () => {
63
+ const m = createLoopDelayMonitor();
64
+ m.enable();
65
+ const snap = m.snapshot();
66
+ m.disable();
67
+ for (const v of Object.values(snap)) expect(Number.isFinite(v)).toBe(true);
68
+ });
@@ -101,18 +101,74 @@ test('isResetFresh: past -> false, future -> true, null -> false', () => {
101
101
  assert.strictEqual(isResetFresh('not-a-date', now), false);
102
102
  });
103
103
 
104
- test('bindingWindow: limits[] with weekly_all is_active wins over five_hour', () => {
104
+ const REAL_LIMITS = [
105
+ { kind: 'session', group: 'session', percent: 6, severity: 'normal', resets_at: '2026-09-22T00:40:00.280514+00:00', scope: null, is_active: false },
106
+ { kind: 'weekly_all', group: 'weekly', percent: 64, severity: 'normal', resets_at: '2026-09-24T17:00:00.280532+00:00', scope: null, is_active: true },
107
+ { kind: 'weekly_scoped', group: 'weekly', percent: 18, severity: 'normal', resets_at: '2026-09-24T17:00:00.280763+00:00', scope: { model: { id: null, display_name: 'Fable' }, surface: null }, is_active: false },
108
+ ];
109
+
110
+ test('bindingWindow: verbatim real payload selects weekly_all 64', () => {
105
111
  const payload = {
106
- five_hour: { utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
112
+ five_hour: { utilization: 6, resets_at: '2026-09-22T00:40:00.280514+00:00' },
113
+ seven_day: { utilization: 64, resets_at: '2026-09-24T17:00:00.280532+00:00' },
114
+ limits: REAL_LIMITS,
115
+ };
116
+ assert.deepStrictEqual(bindingWindow(payload), {
117
+ name: 'weekly_all',
118
+ utilization: 64,
119
+ resets_at: '2026-09-24T17:00:00.280532+00:00',
120
+ });
121
+ });
122
+
123
+ test('bindingWindow: max percent wins over is_active (session 95 beats weekly_all 64)', () => {
124
+ const win = bindingWindow({
107
125
  limits: [
108
- { type: 'five_hour', is_active: false, utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
109
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
126
+ { kind: 'session', percent: 95, resets_at: '2026-09-22T00:40:00Z', scope: null, is_active: false },
127
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
110
128
  ],
111
- };
112
- const win = bindingWindow(payload);
129
+ });
130
+ assert.strictEqual(win.name, 'session');
131
+ assert.strictEqual(win.utilization, 95);
132
+ });
133
+
134
+ test('bindingWindow: is_active breaks a percent tie', () => {
135
+ const win = bindingWindow({
136
+ limits: [
137
+ { kind: 'session', percent: 50, resets_at: null, scope: null, is_active: false },
138
+ { kind: 'weekly_all', percent: 50, resets_at: null, scope: null, is_active: true },
139
+ ],
140
+ });
113
141
  assert.strictEqual(win.name, 'weekly_all');
114
- assert.strictEqual(win.utilization, 81);
115
- assert.strictEqual(win.resets_at, '2026-09-19T00:00:00Z');
142
+ });
143
+
144
+ test('bindingWindow: a high weekly_scoped entry is never selected', () => {
145
+ const win = bindingWindow({
146
+ limits: [
147
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
148
+ { kind: 'weekly_all', percent: 10, resets_at: null, scope: null, is_active: true },
149
+ { kind: 'weekly_scoped', percent: 99, resets_at: null, scope: { model: { display_name: 'Fable' } }, is_active: false },
150
+ ],
151
+ });
152
+ assert.strictEqual(win.name, 'weekly_all');
153
+ assert.strictEqual(win.utilization, 10);
154
+ });
155
+
156
+ test('bindingWindow: percent over 100 is returned raw, not clamped', () => {
157
+ const win = bindingWindow({ limits: [{ kind: 'session', percent: 112, resets_at: null, scope: null }] });
158
+ assert.strictEqual(win.utilization, 112);
159
+ });
160
+
161
+ test('bindingWindow: null resets_at backfills from the matching flat window', () => {
162
+ const win = bindingWindow({
163
+ five_hour: { utilization: 40, resets_at: '2026-09-22T00:40:00Z' },
164
+ seven_day: { utilization: 5, resets_at: '2026-09-24T17:00:00Z' },
165
+ limits: [
166
+ { kind: 'session', percent: 40, resets_at: null, scope: null },
167
+ { kind: 'weekly_all', percent: 5, resets_at: null, scope: null },
168
+ ],
169
+ });
170
+ assert.strictEqual(win.name, 'session');
171
+ assert.strictEqual(win.resets_at, '2026-09-22T00:40:00Z');
116
172
  });
117
173
 
118
174
  test('bindingWindow: limits[] absent falls back to five_hour', () => {
@@ -122,12 +178,12 @@ test('bindingWindow: limits[] absent falls back to five_hour', () => {
122
178
  assert.strictEqual(win.utilization, 42);
123
179
  });
124
180
 
125
- test('bindingWindow: limits[] present but nothing is_active also falls back to five_hour', () => {
181
+ test('bindingWindow: limits[] with only scoped / non-finite entries falls back to five_hour', () => {
126
182
  const payload = {
127
183
  five_hour: { utilization: 12, resets_at: null },
128
184
  limits: [
129
- { type: 'five_hour', is_active: false, utilization: 12, resets_at: null },
130
- { type: 'weekly_all', is_active: false, utilization: 81, resets_at: null },
185
+ { kind: 'weekly_scoped', percent: 81, resets_at: null, scope: { model: { display_name: 'Fable' } } },
186
+ { kind: 'session', resets_at: null, scope: null },
131
187
  ],
132
188
  };
133
189
  const win = bindingWindow(payload);
@@ -135,19 +191,32 @@ test('bindingWindow: limits[] present but nothing is_active also falls back to f
135
191
  assert.strictEqual(win.utilization, 12);
136
192
  });
137
193
 
138
- test('degradedBudget: never yields 0 utilization, even when the last good binding window read 0', () => {
194
+ test('degradedBudget: a last-good binding window that genuinely read 0% carries forward as 0, not 100', () => {
139
195
  const payload = { five_hour: { utilization: 0, resets_at: null } };
140
- const { utilization } = degradedBudget(payload, {});
141
- assert.notStrictEqual(utilization, 0);
142
- assert.strictEqual(utilization, 100); // no real signal -> conservative default
196
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 0);
197
+ });
198
+
199
+ test('degradedBudget: no payload ever received (null/absent) still yields 100, never a blind 0', () => {
200
+ assert.strictEqual(degradedBudget(null, {}).utilization, 100);
201
+ assert.strictEqual(degradedBudget(undefined, {}).utilization, 100);
202
+ });
203
+
204
+ test('degradedBudget: a degraded cycle after a real-shape poll carries forward the real percent (64), not 100', () => {
205
+ const payload = {
206
+ limits: [
207
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
208
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
209
+ ],
210
+ };
211
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 64);
143
212
  });
144
213
 
145
214
  test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
146
215
  const payload = {
147
216
  five_hour: { utilization: 0, resets_at: null },
148
217
  limits: [
149
- { type: 'five_hour', is_active: false, utilization: 0, resets_at: null },
150
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
218
+ { kind: 'session', is_active: false, percent: 0, resets_at: null, scope: null },
219
+ { kind: 'weekly_all', is_active: true, percent: 81, resets_at: '2026-09-19T00:00:00Z', scope: null },
151
220
  ],
152
221
  };
153
222
  const { utilization } = degradedBudget(payload, {});
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Event-loop delay probe for the main-process memory heartbeat. Wraps a
5
+ * perf_hooks IntervalHistogram (injectable for tests): enable() at init,
6
+ * snapshot() once per heartbeat returns the interval just ended in ms and
7
+ * reset()s, disable() rides the heartbeat teardown. No timer of its own.
8
+ */
9
+
10
+ const { monitorEventLoopDelay } = require('node:perf_hooks');
11
+
12
+ const LOOP_DELAY_RESOLUTION_MS = 20;
13
+ // A single beat whose worst loop delay exceeds this is a visible main-thread stall.
14
+ const LOOP_STALL_WARN_MS = 250;
15
+
16
+ const NS_PER_MS = 1e6;
17
+ const toMs = (ns) => Math.round(ns / NS_PER_MS * 10) / 10;
18
+
19
+ function createLoopDelayMonitor(createHistogram = monitorEventLoopDelay) {
20
+ const h = createHistogram({ resolution: LOOP_DELAY_RESOLUTION_MS });
21
+ return {
22
+ enable() { h.enable(); },
23
+ disable() { h.disable(); },
24
+ /** Percentiles for the interval since the last snapshot; resets the histogram. */
25
+ snapshot() {
26
+ const out = {
27
+ loopDelayP50Ms: toMs(h.percentile(50)),
28
+ loopDelayP99Ms: toMs(h.percentile(99)),
29
+ loopDelayMaxMs: toMs(h.max),
30
+ };
31
+ h.reset();
32
+ return out;
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Log level + message for a heartbeat given its loop-delay snapshot. */
38
+ function stallVerdict(snap) {
39
+ return snap.loopDelayMaxMs > LOOP_STALL_WARN_MS
40
+ ? { level: 'warn', message: 'main loop stall' }
41
+ : null;
42
+ }
43
+
44
+ module.exports = { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS, LOOP_DELAY_RESOLUTION_MS };