@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -1,3 +1,3 @@
1
- import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-DFASc15l.js";var f=t({SkillsDialog:()=>_,SkillsManagerPanel:()=>g}),p=e(a(),1),m=c();async function h(e){let t=await e.json().catch(()=>null);if(!e.ok)throw Error(t?.error||`HTTP ${e.status}`);return t}function g({active:e=!0,scope:t,project:a,embedded:c=!1,onClose:f,onSaved:g}){let[_,v]=(0,p.useState)([]),[y,b]=(0,p.useState)(()=>new Set),[x,S]=(0,p.useState)(``),[C,w]=(0,p.useState)([]),[T,E]=(0,p.useState)(!1),[D,O]=(0,p.useState)(!1),[k,A]=(0,p.useState)(``),j=t===`project`,[M,N]=(0,p.useState)(null),[P,F]=(0,p.useState)(null),[I,L]=(0,p.useState)(!1),[R,z]=(0,p.useState)(``),B=(0,p.useCallback)(()=>{N(null),F(null),z(``)},[]),V=(0,p.useCallback)(()=>{B(),f?.()},[f,B]);(0,p.useEffect)(()=>{if(!e||j&&!a)return;let t=!1;return(async()=>{E(!0),A(``);try{let e=j?`/api/skills?projectId=${encodeURIComponent(a.id)}`:`/api/skills?scope=global`,n=await h(await fetch(e));if(t)return;v(Array.isArray(n.skills)?n.skills:[]),b(new Set(Array.isArray(n.selectedSkills)?n.selectedSkills:[])),w(Array.isArray(n.searchPaths)?n.searchPaths:[])}catch(e){t||A(e instanceof Error?e.message:d(`failedToLoadSkills`))}finally{t||E(!1)}})(),()=>{t=!0}},[e,a,j]),(0,p.useEffect)(()=>{if(!e||c)return;let t=e=>{e.key===`Escape`&&(M?B():D||V())};return document.addEventListener(`keydown`,t),()=>document.removeEventListener(`keydown`,t)},[e,V,c,D,M,B]);let H=(0,p.useMemo)(()=>{let e=x.trim().toLowerCase();return e?_.filter(t=>[t.name,t.displayName,t.description,t.source,t.compatibility,t.allowedTools,...t.tags??[],...t.triggers??[]].filter(Boolean).join(` `).toLowerCase().includes(e)):_},[x,_]),U=async e=>{N(e),F(null),z(``),L(!0);try{let t=new URLSearchParams({name:e});j&&a?(t.set(`scope`,`project`),t.set(`projectId`,a.id)):t.set(`scope`,`global`),F(await h(await fetch(`/api/skills/content?${t}`)))}catch(e){z(e instanceof Error?e.message:d(`failedToReadSkill`))}finally{L(!1)}};if(!e||j&&!a)return null;let W=e=>{if(D)return;let t=new Set(y);t.has(e)?t.delete(e):t.add(e),b(t),G(t)},G=async e=>{O(!0),A(``);try{let n=await h(await fetch(j?`/api/skills/project`:`/api/skills/global`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(j?{projectId:a.id,selectedSkills:[...e]}:{selectedSkills:[...e]})}));g({scope:t,project:j?n.projects?.find(e=>e.id===a.id)??{...a,skills:n.selectedSkills}:void 0,projects:n.projects,selectedSkills:n.selectedSkills})}catch(e){b(y),A(e instanceof Error?e.message:d(`failedToSaveSkills`))}finally{O(!1)}},K=d(j?`projectSkills`:`globalSkills`),q=j?d(`projectSkillsDescription`,{project:a.name}):d(`globalSkillsDescription`),J=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`div`,{className:`quickforge-settings-divider p-3`,children:(0,m.jsxs)(`div`,{className:`quickforge-settings-inline-field`,children:[(0,m.jsx)(o,{className:`pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/60`}),(0,m.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),placeholder:d(`searchSkills`),className:`quickforge-settings-input`,disabled:T||D})]})}),k?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:k}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsx)(`span`,{className:`quickforge-settings-row-title`,children:d(`availableSkills`)}),(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-info`,children:d(`selectedSkillsCount`,{count:y.size})})]}),(0,m.jsx)(`div`,{className:c?``:`max-h-[46vh] overflow-y-auto`,children:T?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):H.length===0?(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noMatchingSkills`)}):H.map(e=>{let t=y.has(e.name);return(0,m.jsxs)(`div`,{className:l(`quickforge-settings-list-item`,t&&`bg-muted/12`),children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-main`,children:[(0,m.jsx)(`div`,{className:`quickforge-settings-row-title`,children:e.displayName||e.name}),e.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:e.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta`,children:[e.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.source}):null,e.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.compatibility}):null,e.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.allowedTools}):null,e.tags?.slice(0,5).map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]})]}),(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-actions`,onClick:e=>e.stopPropagation(),children:[(0,m.jsxs)(`label`,{className:`quickforge-settings-switch`,"aria-disabled":D?`true`:`false`,children:[(0,m.jsx)(`input`,{type:`checkbox`,checked:t,disabled:D,onChange:()=>W(e.name),"aria-label":t?`Disable ${e.name}`:`Enable ${e.name}`}),(0,m.jsx)(`span`,{"aria-hidden":`true`})]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:()=>void U(e.name),disabled:D||I,title:d(`readSkill`),"aria-label":`${d(`readSkill`)}: ${e.displayName||e.name}`,children:(0,m.jsx)(n,{className:`size-4`})})]})]},e.name)})})]}),Y=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`button`,{type:`button`,className:`quickforge-settings-button quickforge-settings-button-secondary`,onClick:B,disabled:I,children:[(0,m.jsx)(r,{className:`mr-2 size-4`}),d(`backToSkillList`)]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-main min-w-0`,children:(0,m.jsx)(`div`,{className:`quickforge-settings-row-title truncate`,children:P?.displayName||P?.name||M})})]}),(0,m.jsx)(`div`,{className:`p-4`,children:I?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):R?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:R}):P?(0,m.jsxs)(m.Fragment,{children:[P.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description mb-3`,children:P.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta mb-3`,children:[P.version?(0,m.jsxs)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:[`v`,P.version]}):null,P.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.source}):null,P.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.compatibility}):null,P.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.allowedTools}):null,P.license?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.license}):null,P.tags?.map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]}),P.triggers?.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description mb-3`,children:[`Triggers: `,P.triggers.join(`, `)]}):null,P.instructions?(0,m.jsx)(`pre`,{className:`whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 px-4 py-3 font-mono text-sm leading-6 text-foreground/80`,children:P.instructions}):(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noSkillContent`)})]}):null})]});return c?(0,m.jsx)(`div`,{className:`quickforge-settings-stack`,children:(0,m.jsx)(`section`,{className:`quickforge-settings-section`,"aria-label":K,children:M?Y:(0,m.jsxs)(m.Fragment,{children:[C.length?(0,m.jsx)(`div`,{className:`px-5 pt-4`,children:(0,m.jsxs)(`span`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
1
+ import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-BgMvQW1b.js";var f=t({SkillsDialog:()=>_,SkillsManagerPanel:()=>g}),p=e(a(),1),m=c();async function h(e){let t=await e.json().catch(()=>null);if(!e.ok)throw Error(t?.error||`HTTP ${e.status}`);return t}function g({active:e=!0,scope:t,project:a,embedded:c=!1,onClose:f,onSaved:g}){let[_,v]=(0,p.useState)([]),[y,b]=(0,p.useState)(()=>new Set),[x,S]=(0,p.useState)(``),[C,w]=(0,p.useState)([]),[T,E]=(0,p.useState)(!1),[D,O]=(0,p.useState)(!1),[k,A]=(0,p.useState)(``),j=t===`project`,[M,N]=(0,p.useState)(null),[P,F]=(0,p.useState)(null),[I,L]=(0,p.useState)(!1),[R,z]=(0,p.useState)(``),B=(0,p.useCallback)(()=>{N(null),F(null),z(``)},[]),V=(0,p.useCallback)(()=>{B(),f?.()},[f,B]);(0,p.useEffect)(()=>{if(!e||j&&!a)return;let t=!1;return(async()=>{E(!0),A(``);try{let e=j?`/api/skills?projectId=${encodeURIComponent(a.id)}`:`/api/skills?scope=global`,n=await h(await fetch(e));if(t)return;v(Array.isArray(n.skills)?n.skills:[]),b(new Set(Array.isArray(n.selectedSkills)?n.selectedSkills:[])),w(Array.isArray(n.searchPaths)?n.searchPaths:[])}catch(e){t||A(e instanceof Error?e.message:d(`failedToLoadSkills`))}finally{t||E(!1)}})(),()=>{t=!0}},[e,a,j]),(0,p.useEffect)(()=>{if(!e||c)return;let t=e=>{e.key===`Escape`&&(M?B():D||V())};return document.addEventListener(`keydown`,t),()=>document.removeEventListener(`keydown`,t)},[e,V,c,D,M,B]);let H=(0,p.useMemo)(()=>{let e=x.trim().toLowerCase();return e?_.filter(t=>[t.name,t.displayName,t.description,t.source,t.compatibility,t.allowedTools,...t.tags??[],...t.triggers??[]].filter(Boolean).join(` `).toLowerCase().includes(e)):_},[x,_]),U=async e=>{N(e),F(null),z(``),L(!0);try{let t=new URLSearchParams({name:e});j&&a?(t.set(`scope`,`project`),t.set(`projectId`,a.id)):t.set(`scope`,`global`),F(await h(await fetch(`/api/skills/content?${t}`)))}catch(e){z(e instanceof Error?e.message:d(`failedToReadSkill`))}finally{L(!1)}};if(!e||j&&!a)return null;let W=e=>{if(D)return;let t=new Set(y);t.has(e)?t.delete(e):t.add(e),b(t),G(t)},G=async e=>{O(!0),A(``);try{let n=await h(await fetch(j?`/api/skills/project`:`/api/skills/global`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(j?{projectId:a.id,selectedSkills:[...e]}:{selectedSkills:[...e]})}));g({scope:t,project:j?n.projects?.find(e=>e.id===a.id)??{...a,skills:n.selectedSkills}:void 0,projects:n.projects,selectedSkills:n.selectedSkills})}catch(e){b(y),A(e instanceof Error?e.message:d(`failedToSaveSkills`))}finally{O(!1)}},K=d(j?`projectSkills`:`globalSkills`),q=j?d(`projectSkillsDescription`,{project:a.name}):d(`globalSkillsDescription`),J=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`div`,{className:`quickforge-settings-divider p-3`,children:(0,m.jsxs)(`div`,{className:`quickforge-settings-inline-field`,children:[(0,m.jsx)(o,{className:`pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/60`}),(0,m.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),placeholder:d(`searchSkills`),className:`quickforge-settings-input`,disabled:T||D})]})}),k?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:k}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsx)(`span`,{className:`quickforge-settings-row-title`,children:d(`availableSkills`)}),(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-info`,children:d(`selectedSkillsCount`,{count:y.size})})]}),(0,m.jsx)(`div`,{className:c?``:`max-h-[46vh] overflow-y-auto`,children:T?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):H.length===0?(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noMatchingSkills`)}):H.map(e=>{let t=y.has(e.name);return(0,m.jsxs)(`div`,{className:l(`quickforge-settings-list-item`,t&&`bg-muted/12`),children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-main`,children:[(0,m.jsx)(`div`,{className:`quickforge-settings-row-title`,children:e.displayName||e.name}),e.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:e.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta`,children:[e.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.source}):null,e.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.compatibility}):null,e.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.allowedTools}):null,e.tags?.slice(0,5).map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]})]}),(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-actions`,onClick:e=>e.stopPropagation(),children:[(0,m.jsxs)(`label`,{className:`quickforge-settings-switch`,"aria-disabled":D?`true`:`false`,children:[(0,m.jsx)(`input`,{type:`checkbox`,checked:t,disabled:D,onChange:()=>W(e.name),"aria-label":t?`Disable ${e.name}`:`Enable ${e.name}`}),(0,m.jsx)(`span`,{"aria-hidden":`true`})]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:()=>void U(e.name),disabled:D||I,title:d(`readSkill`),"aria-label":`${d(`readSkill`)}: ${e.displayName||e.name}`,children:(0,m.jsx)(n,{className:`size-4`})})]})]},e.name)})})]}),Y=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`button`,{type:`button`,className:`quickforge-settings-button quickforge-settings-button-secondary`,onClick:B,disabled:I,children:[(0,m.jsx)(r,{className:`mr-2 size-4`}),d(`backToSkillList`)]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-main min-w-0`,children:(0,m.jsx)(`div`,{className:`quickforge-settings-row-title truncate`,children:P?.displayName||P?.name||M})})]}),(0,m.jsx)(`div`,{className:`p-4`,children:I?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):R?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:R}):P?(0,m.jsxs)(m.Fragment,{children:[P.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description mb-3`,children:P.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta mb-3`,children:[P.version?(0,m.jsxs)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:[`v`,P.version]}):null,P.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.source}):null,P.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.compatibility}):null,P.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.allowedTools}):null,P.license?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.license}):null,P.tags?.map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]}),P.triggers?.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description mb-3`,children:[`Triggers: `,P.triggers.join(`, `)]}):null,P.instructions?(0,m.jsx)(`pre`,{className:`whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 px-4 py-3 font-mono text-sm leading-6 text-foreground/80`,children:P.instructions}):(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noSkillContent`)})]}):null})]});return c?(0,m.jsx)(`div`,{className:`quickforge-settings-stack`,children:(0,m.jsx)(`section`,{className:`quickforge-settings-section`,"aria-label":K,children:M?Y:(0,m.jsxs)(m.Fragment,{children:[C.length?(0,m.jsx)(`div`,{className:`px-5 pt-4`,children:(0,m.jsxs)(`span`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
2
2
  `)})]})}):null,J]})})}):(0,m.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4`,onClick:e=>{e.target===e.currentTarget&&!D&&V()},children:(0,m.jsxs)(`div`,{className:`flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-background shadow-quickforge`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-main`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-title`,children:[K,(0,m.jsx)(u,{label:q})]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:q}),C.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
3
3
  `)})]}):null]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:V,disabled:D,"aria-label":d(`close`),children:(0,m.jsx)(s,{className:`size-4`})})]}),(0,m.jsx)(`div`,{className:`min-h-0 flex-1 overflow-y-auto`,children:M?Y:J})]})})}function _({open:e,scope:t,project:n,onOpenChange:r,onSaved:i}){return(0,m.jsx)(g,{active:e,scope:t,project:n,onClose:()=>r(!1),onSaved:i})}export{f as n,_ as t};
package/dist/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="QuickForge" />
12
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
13
13
  <title>速构 QuickForge</title>
14
- <script type="module" crossorigin src="/assets/index-DFASc15l.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-BgMvQW1b.js"></script>
15
15
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/pi-ai-Cr2ee4a0.js">
17
17
  <link rel="modulepreload" crossorigin href="/assets/dist-yDHbi5ux.js">
@@ -22,9 +22,9 @@
22
22
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-BpWW_Nvp.js">
23
23
  <link rel="modulepreload" crossorigin href="/assets/logger-8REuLPGK.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/model-display-label-CURwH0xM.js">
25
- <link rel="modulepreload" crossorigin href="/assets/index-DFASc15l.js">
26
- <link rel="modulepreload" crossorigin href="/assets/skills-dialog-DIzZvQqW.js">
27
- <link rel="stylesheet" crossorigin href="/assets/index-D-J_8Smf.css">
25
+ <link rel="modulepreload" crossorigin href="/assets/index-BgMvQW1b.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/skills-dialog-DYp35JM3.js">
27
+ <link rel="stylesheet" crossorigin href="/assets/index-CB1g2D0f.css">
28
28
  </head>
29
29
  <body>
30
30
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "AI chat application with Agent access modes for local workspace tools. React + Vite + Tailwind CSS frontend, local Node.js storage server.",
5
5
  "keywords": [
6
6
  "ai",
@@ -922,16 +922,13 @@ export async function runQuickForgeAcpStdio() {
922
922
  console.log = (...args) => console.error(...args)
923
923
  let sqliteStorage
924
924
  try {
925
- const [{ ensureStorage, readPhysicalSessionMetadataBuckets, registerSessionMetadataCommitHook }, sqliteModule, sessionIndexModule] = await Promise.all([
925
+ const [storageModule, sqliteModule, sessionIndexModule] = await Promise.all([
926
926
  import('../storage.mjs'),
927
927
  import('../sqlite/database.mjs'),
928
928
  import('../session-index-service.mjs'),
929
929
  ])
930
- sqliteStorage = sqliteModule
931
- await ensureStorage()
932
- await sqliteModule.initializeSqliteStorage()
933
- sessionIndexModule.configureSessionIndex({ readBuckets: readPhysicalSessionMetadataBuckets })
934
- registerSessionMetadataCommitHook(sessionIndexModule.syncSessionMetadataCommit)
930
+ await storageModule.ensureStorage()
931
+ sqliteStorage = await sqliteModule.initializeSqliteStorage()
935
932
  await sessionIndexModule.initializeSessionIndex()
936
933
  const quickForgeAgent = await createQuickForgeAcpAgent()
937
934
  const stream = ndJsonStream(
@@ -28,8 +28,8 @@ import {
28
28
  validateModelReference,
29
29
  } from './agent-profile-schema.mjs'
30
30
  import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
31
- import { ensureStorage, readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue, tempAgentsDir } from './storage.mjs'
32
- import { isSessionStateAuthoritative, readSessionStateRecord, saveSessionStatePair, deleteSessionState, storedMessagesState, sessionMessagesTailDigest } from './session-state-service.mjs'
31
+ import { ensureStorage, readStore, atomicUpdate, readSessionValue, tempAgentsDir } from './storage.mjs'
32
+ import { readSessionStateRecord, saveSessionStatePair, deleteSessionState, storedMessagesState, sessionMessagesTailDigest } from './session-state-service.mjs'
33
33
  import { logger } from './utils/logger.mjs'
34
34
  import { publishChannelSessionChanged } from './channels/event-relay.mjs'
35
35
  import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
@@ -1967,6 +1967,10 @@ export async function createAgent(sessionId, config = {}) {
1967
1967
  persistedMessageCount: Number.isInteger(config.persistedMessageCount) && config.persistedMessageCount >= 0 ? config.persistedMessageCount : null,
1968
1968
  persistedTailDigest: typeof config.persistedTailDigest === 'string' ? config.persistedTailDigest : null,
1969
1969
  persistConflictCount: 0,
1970
+ // Set when an authoritative persist is skipped after CAS conflicts; cleared
1971
+ // on the next successful persist. Surfaced via getSessionState/getSessionStatus
1972
+ // so the UI can warn the user instead of silently losing messages.
1973
+ persistDegraded: null,
1970
1974
  lastAutoCompactAt: null,
1971
1975
  lastAutoCompactRejected: null,
1972
1976
  memoryEnabled: initialMemoryEnabled,
@@ -2153,6 +2157,26 @@ function canonicalStateJson(value) {
2153
2157
  return JSON.stringify(value)
2154
2158
  }
2155
2159
 
2160
+ /**
2161
+ * Mark the session as persist-degraded (an authoritative persist was skipped
2162
+ * after CAS conflicts) and notify connected clients so the UI can surface a
2163
+ * non-blocking warning instead of silently losing messages.
2164
+ */
2165
+ function markPersistDegraded(session, attempts) {
2166
+ session.persistDegraded = { at: new Date().toISOString(), attempts }
2167
+ emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: true })
2168
+ }
2169
+
2170
+ /**
2171
+ * Clear the degraded marker after a successful persist and notify clients so
2172
+ * the UI warning disappears.
2173
+ */
2174
+ function clearPersistDegraded(session) {
2175
+ if (!session.persistDegraded) return
2176
+ session.persistDegraded = null
2177
+ emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: false })
2178
+ }
2179
+
2156
2180
  /**
2157
2181
  * Persist a full session record in one authoritative SQLite transaction with
2158
2182
  * revision CAS. When the CAS fails because a concurrent metadata-only change
@@ -2201,6 +2225,7 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
2201
2225
  session.persistedMessageCount = Array.isArray(sessionData.messages) ? sessionData.messages.length : 0
2202
2226
  }
2203
2227
  session.persistedTailDigest = sessionMessagesTailDigest(sessionData.messages)
2228
+ clearPersistDegraded(session)
2204
2229
  return { ...saved, metadata: mergedMetadata }
2205
2230
  } catch (error) {
2206
2231
  if (error?.errorCode !== 'SESSION_STATE_CONFLICT') throw error
@@ -2222,11 +2247,13 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
2222
2247
  }
2223
2248
  session.persistConflictCount += 1
2224
2249
  logger.warn(`Session ${sessionId} persist conflict: storage changed agent-owned fields; skipping overwrite`, { sessionId })
2250
+ markPersistDegraded(session, attempt)
2225
2251
  return null
2226
2252
  }
2227
2253
  }
2228
2254
  session.persistConflictCount += 1
2229
2255
  logger.warn(`Session ${sessionId} persist still conflicting after ${maxAttempts} merge retries`, { sessionId, reason: lastConflict?.message })
2256
+ markPersistDegraded(session, maxAttempts)
2230
2257
  return null
2231
2258
  }
2232
2259
 
@@ -2239,14 +2266,7 @@ async function persistSessionUnlocked(session) {
2239
2266
 
2240
2267
  if (messages.length === 0) {
2241
2268
  try {
2242
- if (isSessionStateAuthoritative()) deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
2243
- else {
2244
- await deleteSessionValue(sessionId)
2245
- await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2246
- delete data[sessionId]
2247
- return data
2248
- })
2249
- }
2269
+ deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
2250
2270
  session.persistedStorageRevision = null
2251
2271
  session.persistedStateVersion = null
2252
2272
  session.persistedStateJson = null
@@ -2353,34 +2373,14 @@ async function persistSessionUnlocked(session) {
2353
2373
  idleRetention: session.idleRetention || undefined,
2354
2374
  }
2355
2375
 
2356
- // Write body + metadata as one authoritative SQLite transaction after cutover.
2376
+ // Write body + metadata as one authoritative SQLite transaction.
2357
2377
  let persistedMetadata
2358
2378
  try {
2359
- if (isSessionStateAuthoritative()) {
2360
- const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
2361
- if (!saved) return null
2362
- session.persistedStorageRevision = saved.revision
2363
- session.persistedStateVersion = saved.stateVersion
2364
- persistedMetadata = saved.metadata
2365
- } else {
2366
- await writeSessionValue(sessionId, sessionData)
2367
- await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2368
- const existingMetadata = data[sessionId]
2369
- const archivedAt = existingMetadata?.archivedAt
2370
- // Merge onto the existing record so storage-only fields the in-memory
2371
- // session does not own (pinnedAt, archivedAt, …) survive the rebuild.
2372
- persistedMetadata = {
2373
- ...existingMetadata,
2374
- ...metadata,
2375
- ...(archivedAt ? { archivedAt } : {}),
2376
- }
2377
- data[sessionId] = persistedMetadata
2378
- return data
2379
- })
2380
- if (persistedMetadata?.archivedAt) {
2381
- await writeSessionValue(sessionId, { ...sessionData, archivedAt: persistedMetadata.archivedAt })
2382
- }
2383
- }
2379
+ const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
2380
+ if (!saved) return null
2381
+ session.persistedStorageRevision = saved.revision
2382
+ session.persistedStateVersion = saved.stateVersion
2383
+ persistedMetadata = saved.metadata
2384
2384
  } catch (err) {
2385
2385
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
2386
2386
  return null
@@ -2403,8 +2403,27 @@ async function persistSessionUnlocked(session) {
2403
2403
  return persistedMetadata || metadata
2404
2404
  }
2405
2405
 
2406
+ // Persist slower than this (queue wait + synchronous SQLite write) is worth a
2407
+ // warning: large sessions write big synchronous transactions that stall the
2408
+ // event loop and make every in-flight request (including /restore) wait.
2409
+ const SLOW_PERSIST_LOG_MS = 1_000
2410
+
2406
2411
  async function persistSession(session) {
2407
- return withSessionPersistenceLock(() => persistSessionUnlocked(session))
2412
+ const startedAt = performance.now()
2413
+ // Serialize per session (per-row revision CAS guarantees correctness);
2414
+ // cross-session persists run independently instead of piling onto one
2415
+ // global chain that also gates destroyAgent.
2416
+ const lockKey = `session:${session.sessionId}`
2417
+ const result = await withSessionPersistenceLock(() => persistSessionUnlocked(session), lockKey)
2418
+ const durationMs = performance.now() - startedAt
2419
+ if (durationMs >= SLOW_PERSIST_LOG_MS) {
2420
+ logger.warn(`Session ${session.sessionId} persist took ${Math.round(durationMs)}ms (queue wait + write)`, {
2421
+ sessionId: session.sessionId,
2422
+ durationMs: Math.round(durationMs),
2423
+ messageCount: session.agent?.state?.messages?.length ?? 0,
2424
+ })
2425
+ }
2426
+ return result
2408
2427
  }
2409
2428
 
2410
2429
  export async function persistSessionState(session) {
@@ -2956,6 +2975,7 @@ export function getSessionState(sessionId) {
2956
2975
  acpSession: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.state.acpSession : undefined,
2957
2976
  isStreaming: session.abortPending ? false : session.agent.state.isStreaming,
2958
2977
  errorMessage: session.agent.state.errorMessage,
2978
+ persistDegraded: session.persistDegraded ? true : undefined,
2959
2979
  }
2960
2980
  }
2961
2981
 
@@ -2988,6 +3008,7 @@ export function getSessionStatus(sessionId) {
2988
3008
  errorMessage: session.agent.state.errorMessage,
2989
3009
  messageCount: messages.length,
2990
3010
  lastMessageTimestamp: lastMessage?.timestamp ?? null,
3011
+ persistDegraded: session.persistDegraded ? true : undefined,
2991
3012
  }
2992
3013
  }
2993
3014
 
@@ -3019,6 +3040,21 @@ export function releaseSse(sessionId) {
3019
3040
  if (session) session.sseConnected = false
3020
3041
  }
3021
3042
 
3043
+ /**
3044
+ * Count sessions with a live SSE connection — the background-migration idle
3045
+ * signal's "no active stream" input
3046
+ * (docs/architecture/session-storage-background-migration-design.zh-CN.md
3047
+ * §3.3). Derived from the per-session sseConnected flags the SSE route
3048
+ * already maintains, so no extra bookkeeping is required.
3049
+ */
3050
+ export function countActiveSseStreams() {
3051
+ let count = 0
3052
+ for (const session of agentSessions.values()) {
3053
+ if (session.sseConnected) count += 1
3054
+ }
3055
+ return count
3056
+ }
3057
+
3022
3058
  /**
3023
3059
  * Get the event bus for a session (for SSE connections).
3024
3060
  */
@@ -3069,14 +3105,33 @@ export async function destroyAgent(sessionId) {
3069
3105
  agentSessions.delete(sessionId)
3070
3106
  }
3071
3107
 
3108
+ // In-flight restores keyed by sessionId. Concurrent route handlers
3109
+ // (POST /restore, GET /state, GET /messages, GET /status, SSE) all fall back
3110
+ // to restoreAgent; without dedupe each raced through createAgent and the last
3111
+ // agentSessions.set overwrote the others, leaking the overwritten sessions
3112
+ // (listeners, idle/persist timers, OpenCode child processes) forever.
3113
+ const pendingRestores = new Map()
3114
+
3072
3115
  /**
3073
3116
  * Try to restore an agent session from persisted storage.
3117
+ * Concurrent calls for the same session share one in-flight restore.
3074
3118
  * Returns the restored session, or null if not found.
3075
3119
  */
3076
- export async function restoreAgent(sessionId) {
3120
+ export function restoreAgent(sessionId) {
3077
3121
  const existing = agentSessions.get(sessionId)
3078
3122
  if (existing) return existing
3079
3123
 
3124
+ const inFlight = pendingRestores.get(sessionId)
3125
+ if (inFlight) return inFlight
3126
+
3127
+ const restorePromise = restoreAgentUnlocked(sessionId).finally(() => {
3128
+ pendingRestores.delete(sessionId)
3129
+ })
3130
+ pendingRestores.set(sessionId, restorePromise)
3131
+ return restorePromise
3132
+ }
3133
+
3134
+ async function restoreAgentUnlocked(sessionId) {
3080
3135
  try {
3081
3136
  const sessionData = await readSessionValue(sessionId)
3082
3137
  if (!sessionData) {
@@ -3089,8 +3144,8 @@ export async function restoreAgent(sessionId) {
3089
3144
  // Read the authoritative storage record once: the body (split marker),
3090
3145
  // revision for CAS, and the stored message rows (count + tail digest) used
3091
3146
  // for split-representation conflict detection after restore.
3092
- const record = isSessionStateAuthoritative() ? readSessionStateRecord(sessionId) : null
3093
- const storedMessages = isSessionStateAuthoritative() && record?.state?.messageStorage === 'split'
3147
+ const record = readSessionStateRecord(sessionId)
3148
+ const storedMessages = record?.state?.messageStorage === 'split'
3094
3149
  ? storedMessagesState(sessionId)
3095
3150
  : null
3096
3151
 
@@ -3117,9 +3172,9 @@ export async function restoreAgent(sessionId) {
3117
3172
  openCodeUsage: sessionData.openCodeUsage || null,
3118
3173
  idleRetention: sessionData.idleRetention || null,
3119
3174
  stateVersion: sessionData.stateVersion,
3120
- persistedStateVersion: isSessionStateAuthoritative() ? sessionData.stateVersion : null,
3121
- persistedStorageRevision: isSessionStateAuthoritative() ? record?.revision ?? null : null,
3122
- persistedStateJson: isSessionStateAuthoritative() ? canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})) : null,
3175
+ persistedStateVersion: sessionData.stateVersion,
3176
+ persistedStorageRevision: record?.revision ?? null,
3177
+ persistedStateJson: canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})),
3123
3178
  persistedMessageStorage: record?.state?.messageStorage === 'split' ? 'split' : null,
3124
3179
  persistedMessageCount: storedMessages?.count ?? (Array.isArray(sessionData.messages) ? sessionData.messages.length : 0),
3125
3180
  persistedTailDigest: storedMessages?.tailDigest || sessionMessagesTailDigest(sessionData.messages),
@@ -3223,6 +3278,33 @@ export async function refreshAllSessionTools() {
3223
3278
  return result
3224
3279
  }
3225
3280
 
3281
+ /**
3282
+ * Refresh the model binding of every active QuickForge session after model
3283
+ * configuration changes (custom providers, maxTokens, ...). OpenCode owns its
3284
+ * model selection and streaming sessions are skipped (runPrompt re-resolves
3285
+ * the binding on the next message). Only sessions whose model actually
3286
+ * changed get a state event.
3287
+ */
3288
+ export async function refreshAllSessionModels() {
3289
+ for (const [sessionId, session] of agentSessions) {
3290
+ if (session.harness !== AGENT_HARNESS_QUICKFORGE) continue
3291
+ if (session.agent?.state?.isStreaming) continue
3292
+ try {
3293
+ const before = JSON.stringify(session.model ?? null)
3294
+ await refreshSessionModelBinding(session)
3295
+ const after = JSON.stringify(session.model ?? null)
3296
+ if (before === after) continue
3297
+ const state = getSessionState(sessionId)
3298
+ emitSessionEvent(session, { type: 'state', ...state })
3299
+ } catch (error) {
3300
+ // The model may have been deleted (model_not_configured); runPrompt will
3301
+ // surface the same error on the next message. Log and keep other
3302
+ // sessions refreshing.
3303
+ logger.error(`Failed to refresh model for session ${sessionId}:`, error, { sessionId })
3304
+ }
3305
+ }
3306
+ }
3307
+
3226
3308
  export async function updateSessionTitle(sessionId, title) {
3227
3309
  let session = agentSessions.get(sessionId)
3228
3310
  if (!session) session = await restoreAgent(sessionId)
@@ -7,10 +7,20 @@ const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, atomicSessionRe
7
7
  export const AUTO_ARCHIVE_SETTINGS_KEY = 'auto-archive-settings'
8
8
  export const AUTO_ARCHIVE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
9
9
  const AUTO_ARCHIVE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
10
+ export const AUTO_ARCHIVE_INITIAL_DELAY_MS = 30_000
10
11
 
11
12
  let autoArchiveTimer = null
13
+ let autoArchiveInitialTimer = null
12
14
  let autoArchiveRunning = false
13
15
 
16
+ // Yield one macrotask turn between heavy archive commits: each archive is a
17
+ // synchronous SQLite transaction (or JSON read-modify-write) that blocks the
18
+ // thread while it runs, and without yields a large batch of back-to-back
19
+ // archives starves every pending HTTP request for the whole batch.
20
+ function yieldToEventLoop() {
21
+ return new Promise((resolve) => setImmediate(resolve))
22
+ }
23
+
14
24
  export function normalizeAutoArchiveSettings(value) {
15
25
  if (!value || typeof value !== 'object') return { enabled: false }
16
26
  return { enabled: value.enabled === true }
@@ -65,8 +75,25 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
65
75
  const metadataStore = await storage.readStore('sessions-metadata')
66
76
  const candidates = []
67
77
 
78
+ // Metadata-first scan: metadata alone decides activeness whenever it carries
79
+ // any timestamp, so the common case never loads the session body (loading and
80
+ // JSON.parsing every 2.9GB-library body is what stalled the event loop for
81
+ // ~minutes on startup). The body is only read when metadata has no usable
82
+ // timestamp at all. The scan is therefore deliberately conservative — a body
83
+ // newer than its (stale) metadata is treated as a candidate — which is safe:
84
+ // a wrongly suspected candidate is rejected by the full-state re-check inside
85
+ // the archive transactions below, and a missed candidate is simply picked up
86
+ // by the next scan. persistSession refreshes metadata timestamps on every
87
+ // save, so active sessions always stay fresh in metadata.
68
88
  for (const [sessionId, metadata] of Object.entries(metadataStore)) {
69
89
  if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running') continue
90
+ const metadataActivityTime = sessionActivityTime(metadata)
91
+ if (metadataActivityTime !== null) {
92
+ if (metadataActivityTime >= cutoff) continue
93
+ candidates.push(sessionId)
94
+ continue
95
+ }
96
+ // Metadata carries no timestamp at all: fall back to the body.
70
97
  const session = await storage.readSessionValue(sessionId)
71
98
  if (!session || session.archivedAt) continue
72
99
  const activityTime = sessionActivityTime(metadata, session)
@@ -75,10 +102,13 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
75
102
  }
76
103
 
77
104
  const archivedSessionIds = []
78
- if (typeof storage.atomicSessionRecordUpdate === 'function' && await storage.isSessionStateAuthoritative()) {
79
- // Authoritative: archive body + metadata in one SQLite transaction (CAS
80
- // retried on concurrent metadata-only changes), so no half-archived state.
81
- for (const sessionId of candidates) {
105
+ // Authoritative: archive body + metadata in one SQLite transaction (CAS
106
+ // retried on concurrent metadata-only changes), so no half-archived state.
107
+ if (typeof storage.atomicSessionRecordUpdate === 'function') {
108
+ for (const [index, sessionId] of candidates.entries()) {
109
+ // Let pending requests run between archive transactions (not before the
110
+ // first candidate — nothing to yield from yet).
111
+ if (index > 0) await yieldToEventLoop()
82
112
  const updated = await storage.atomicSessionRecordUpdate(sessionId, ({ state, metadata }) => {
83
113
  const activityTime = sessionActivityTime(metadata, state)
84
114
  if (state.archivedAt || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) return null
@@ -87,7 +117,10 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
87
117
  if (updated?.state?.archivedAt === archivedAt) archivedSessionIds.push(sessionId)
88
118
  }
89
119
  } else {
90
- for (const sessionId of candidates) {
120
+ for (const [index, sessionId] of candidates.entries()) {
121
+ // Let pending requests run between archive transactions (not before the
122
+ // first candidate — nothing to yield from yet).
123
+ if (index > 0) await yieldToEventLoop()
91
124
  const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
92
125
  const activityTime = sessionActivityTime(session)
93
126
  if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
@@ -134,16 +167,30 @@ export async function archiveInactiveSessions(options = {}) {
134
167
  }
135
168
  }
136
169
 
137
- export function startAutoArchiveRunner() {
170
+ // `options` exists for tests (inject storage/now like archiveInactiveSessions);
171
+ // production callers pass nothing and get defaultStorage + Date.now().
172
+ export function startAutoArchiveRunner(options = {}) {
138
173
  if (autoArchiveTimer) return
139
174
  autoArchiveTimer = setInterval(() => {
140
- archiveInactiveSessions().catch((error) => logger.error('Automatic conversation archive failed:', error))
175
+ archiveInactiveSessions(options).catch((error) => logger.error('Automatic conversation archive failed:', error))
141
176
  }, AUTO_ARCHIVE_CHECK_INTERVAL_MS)
142
177
  autoArchiveTimer.unref?.()
143
- archiveInactiveSessions().catch((error) => logger.error('Initial automatic conversation archive failed:', error))
178
+ // Delay the first pass off the startup path: right after listen() the server
179
+ // is absorbing the browser's initial request burst, and the scan + archive
180
+ // transactions on a large library can block the event loop for a long
181
+ // stretch. The timer is unref'd so it never keeps the process alive.
182
+ autoArchiveInitialTimer = setTimeout(() => {
183
+ autoArchiveInitialTimer = null
184
+ archiveInactiveSessions(options).catch((error) => logger.error('Initial automatic conversation archive failed:', error))
185
+ }, AUTO_ARCHIVE_INITIAL_DELAY_MS)
186
+ autoArchiveInitialTimer.unref?.()
144
187
  }
145
188
 
146
189
  export function stopAutoArchiveRunner() {
190
+ if (autoArchiveInitialTimer) {
191
+ clearTimeout(autoArchiveInitialTimer)
192
+ autoArchiveInitialTimer = null
193
+ }
147
194
  if (!autoArchiveTimer) return
148
195
  clearInterval(autoArchiveTimer)
149
196
  autoArchiveTimer = null
@@ -295,7 +295,6 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
295
295
  estimatedInputTokens: 0,
296
296
  knownInputTokens: 0,
297
297
  inputTokenSource: 'estimated',
298
- reservedOutputTokens: 0,
299
298
  totalTokens: 0,
300
299
  contextWindow,
301
300
  percent: 0,
@@ -306,7 +305,6 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
306
305
  systemPromptTokens: 0,
307
306
  messagesTokens: 0,
308
307
  toolsTokens: 0,
309
- reservedOutputTokens: 0,
310
308
  },
311
309
  }
312
310
  }
@@ -50,7 +50,6 @@ function localMessagesTokens(messages) {
50
50
 
51
51
  export function estimateContextUsage({ systemPrompt, messages, tools, model, minimumProviderUsageIndex = 0 }) {
52
52
  const contextWindow = Number(model?.contextWindow) || 0
53
- const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
54
53
  const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
55
54
  const coreEstimate = estimateContextTokens(normalizedMessages)
56
55
  const systemPromptTokens = textTokens(systemPrompt)
@@ -68,8 +67,11 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
68
67
  const inputTokens = providerBasedContextTokens > 0
69
68
  ? Math.max(estimatedInputTokens, providerBasedContextTokens)
70
69
  : estimatedInputTokens
71
- const totalTokens = inputTokens + reservedOutputTokens
72
- const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
70
+ // 上下文占用按纯输入口径统计:percent = inputTokens / contextWindow。
71
+ // 真实请求的 max_tokens pi-ai `clampMaxTokensToContext` 按窗口收缩,
72
+ // 统计侧不再预留输出 token;totalTokens 字段仅为兼容保留,恒等于 inputTokens。
73
+ const totalTokens = inputTokens
74
+ const percent = contextWindow > 0 ? Math.round((inputTokens / contextWindow) * 1000) / 10 : 0
73
75
  const inputTokenSource = providerBasedContextTokens > 0
74
76
  ? providerBasedContextTokens >= estimatedInputTokens ? 'provider' : 'mixed'
75
77
  : 'estimated'
@@ -80,7 +82,6 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
80
82
  knownInputTokens: providerBasedContextTokens,
81
83
  providerContextTokens: providerBasedContextTokens,
82
84
  inputTokenSource,
83
- reservedOutputTokens,
84
85
  totalTokens,
85
86
  contextWindow,
86
87
  percent,
@@ -88,7 +89,6 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
88
89
  systemPromptTokens,
89
90
  messagesTokens,
90
91
  toolsTokens,
91
- reservedOutputTokens,
92
92
  providerUsageTokens,
93
93
  trailingTokens: providerUsageTokens > 0
94
94
  ? Math.max(0, Number(coreEstimate.trailingTokens) || 0)
@@ -101,13 +101,15 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
101
101
 
102
102
  export function shouldCompactContextByPercent(usage, thresholdPercent) {
103
103
  const contextWindow = Number(usage?.contextWindow) || 0
104
- const totalTokens = Math.max(0, Number(usage?.totalTokens) || 0)
104
+ // 阈值按纯输入占用判断:inputTokens / contextWindow thresholdPercent 即触发,
105
+ // 不包含预留输出 token(真实请求的 max_tokens 由 pi-ai 按窗口收缩)。
106
+ const inputTokens = Math.max(0, Number(usage?.inputTokens) || 0)
105
107
  const threshold = Math.min(100, Math.max(0, Number(thresholdPercent) || 0))
106
108
  if (!contextWindow) return false
107
109
 
108
110
  const thresholdTokens = Math.ceil(contextWindow * threshold / 100)
109
111
  const reserveTokens = Math.min(contextWindow, Math.max(0, contextWindow - thresholdTokens + 1))
110
- return shouldCompact(totalTokens, contextWindow, {
112
+ return shouldCompact(inputTokens, contextWindow, {
111
113
  enabled: true,
112
114
  reserveTokens,
113
115
  keepRecentTokens: 0,