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.
- package/dist/assets/{DataModel-B0LDnnSL.js → DataModel-DZK9QZpV.js} +1 -1
- package/dist/assets/{History-C685Kytt.js → History-BDyJUGtR.js} +1 -1
- package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CmK6l0uf.js} +1 -1
- package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-D8TIx-Bx.js} +1 -1
- package/dist/assets/{Library-BU9np05E.js → Library-DTxpOjx8.js} +1 -1
- package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-BPJyrevq.js} +1 -1
- package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-B_rvIYDs.js} +1 -1
- package/dist/assets/{Memory-DAP1t8bA.js → Memory-B-9GlHqe.js} +1 -1
- package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-DhMMp27n.js} +1 -1
- package/dist/assets/{Plugins-Q1KoLEzn.js → Plugins-DKfAlmMY.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-ptlItdCK.js} +1 -1
- package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-CSbNSl2W.js} +1 -1
- package/dist/assets/Scheduler--rn1QBYc.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-BJof-GWH.js} +1 -1
- package/dist/assets/{Settings-D5Lhj7ga.js → Settings-Dsx6XZP5.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CGl4B89-.js} +1 -1
- package/dist/assets/{Skills-BQDqp-EN.js → Skills-D522Zfol.js} +1 -1
- package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt-DKj-q39J.js} +1 -1
- package/dist/assets/{TagLibrary-BjyrnaRE.js → TagLibrary-BH7CG7-6.js} +1 -1
- package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-BbcJL9az.js} +1 -1
- package/dist/assets/{Toggle-DzoROibf.js → Toggle-DAslvnnv.js} +1 -1
- package/dist/assets/{index-CCl4tz-u.js → index-Yf8e7VCB.js} +8 -8
- package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-pzp0TgvN.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +3 -1
- package/scripts/README.md +3 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
- package/src/main/build-info.json +4 -4
- package/src/main/crashDiagnostics.cjs +9 -0
- package/src/main/health.cjs +25 -1
- package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
- package/src/main/lib/loopDelay.cjs +44 -0
- package/src/main/lib/usageCircuit.cjs +40 -12
- package/src/main/scheduler.cjs +49 -3
- package/src/main/usage.cjs +9 -1
- package/src/preload/api.d.ts +17 -2
- 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-
|
|
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.
|
|
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
|
+
});
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.95.0",
|
|
3
|
+
"gitSha": "42f7779080772b1b8432b6287aff06c52b9f0f90",
|
|
4
|
+
"gitShortSha": "42f77790",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
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
|
|
package/src/main/health.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
{
|
|
109
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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[]
|
|
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
|
-
{
|
|
130
|
-
{
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
{
|
|
150
|
-
{
|
|
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 };
|