@shawnstack/quickforge 1.7.10 → 1.7.12

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 (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -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'
@@ -68,6 +68,15 @@ import {
68
68
  commandToolPermissionError,
69
69
  createCommandToolPermissions,
70
70
  } from './approval-store.mjs'
71
+ import {
72
+ ASK_TIMEOUT_MS,
73
+ pendingAsks,
74
+ getPendingAskForSession,
75
+ normalizeAskQuestions,
76
+ formatAskResult,
77
+ } from './ask-store.mjs'
78
+
79
+ export { getPendingAskForSession, normalizeAskQuestions } from './ask-store.mjs'
71
80
 
72
81
  // ---------------------------------------------------------------------------
73
82
  // Tool definitions (server-side, no REST roundtrip)
@@ -88,8 +97,25 @@ function wrapSubagentToolDefinition(definition, parentSessionId) {
88
97
  }
89
98
  }
90
99
 
100
+ function wrapAskUserToolDefinition(definition, parentSessionId) {
101
+ return {
102
+ ...definition,
103
+ execute: async (toolCallId, params) => {
104
+ const session = agentSessions.get(parentSessionId)
105
+ if (!session) {
106
+ return {
107
+ content: [{ type: 'text', text: 'No active session for ask_user.' }],
108
+ details: { askId: null, skipped: true },
109
+ }
110
+ }
111
+ return createAskUserPromise(session, toolCallId, params || {})
112
+ },
113
+ }
114
+ }
115
+
91
116
  function wrapWorkspaceToolDefinition(definition, context, toolPermissions, options = {}) {
92
117
  if (definition.name === 'run_subagent') return wrapSubagentToolDefinition(definition, options.parentSessionId)
118
+ if (definition.name === 'ask_user') return wrapAskUserToolDefinition(definition, options.parentSessionId)
93
119
  return wrapToolDefinition(definition, context, toolPermissions)
94
120
  }
95
121
 
@@ -304,8 +330,83 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
304
330
  })
305
331
  }
306
332
 
307
- function createAcpApprovalPromise(session, request) {
308
- return new Promise((resolve, reject) => {
333
+ /**
334
+ * Create a Promise that resolves when the user answers (or skips) an ask_user
335
+ * tool call. The tool's execute blocks on this promise, pausing the agent loop
336
+ * until the user responds.
337
+ */
338
+ function createAskUserPromise(session, toolCallId, params) {
339
+ const questions = normalizeAskQuestions(params)
340
+ if (!questions.length) {
341
+ return Promise.resolve({
342
+ content: [{ type: 'text', text: formatAskResult(questions, null, true, 'no-questions') }],
343
+ details: { askId: null, skipped: true },
344
+ })
345
+ }
346
+ const askId = randomUUID()
347
+ return new Promise((resolve) => {
348
+ let settled = false
349
+ const requestedAt = Date.now()
350
+ const expiresAt = requestedAt + ASK_TIMEOUT_MS
351
+
352
+ const timeout = setTimeout(() => {
353
+ if (settled) return
354
+ finish({ skipped: true, reason: 'timeout' })
355
+ }, ASK_TIMEOUT_MS)
356
+
357
+ let onAbort = null
358
+
359
+ const finish = (payload) => {
360
+ if (settled) return
361
+ settled = true
362
+ clearTimeout(timeout)
363
+ if (onAbort && session.agent.signal) session.agent.signal.removeEventListener('abort', onAbort)
364
+ pendingAsks.delete(askId)
365
+ const answers = payload.skipped ? null : payload.answers
366
+ resolve({
367
+ content: [{ type: 'text', text: formatAskResult(questions, answers, payload.skipped, payload.reason) }],
368
+ details: { askId, questions, answers, skipped: !!payload.skipped, ...(payload.reason ? { skipReason: payload.reason } : {}) },
369
+ })
370
+ emitSessionEvent(session, {
371
+ type: 'ask_user_answered',
372
+ sessionId: session.sessionId,
373
+ askId,
374
+ toolCallId,
375
+ skipped: !!payload.skipped,
376
+ answers: answers || [],
377
+ })
378
+ }
379
+
380
+ const signal = session.agent.signal
381
+ if (signal) {
382
+ if (signal.aborted) {
383
+ finish({ skipped: true, reason: 'aborted' })
384
+ return
385
+ }
386
+ onAbort = () => finish({ skipped: true, reason: 'aborted' })
387
+ signal.addEventListener('abort', onAbort, { once: true })
388
+ }
389
+
390
+ pendingAsks.set(askId, {
391
+ finish: (payload) => finish(payload),
392
+ sessionId: session.sessionId,
393
+ toolCallId,
394
+ questions,
395
+ requestedAt,
396
+ expiresAt,
397
+ })
398
+
399
+ emitSessionEvent(session, {
400
+ type: 'ask_user_required',
401
+ sessionId: session.sessionId,
402
+ askId,
403
+ toolCallId,
404
+ questions,
405
+ })
406
+ })
407
+ }
408
+
409
+ function createAcpApprovalPromise(session, request) { return new Promise((resolve, reject) => {
309
410
  let settled = false
310
411
  const requestedAt = Date.now()
311
412
  const expiresAt = requestedAt + APPROVAL_TIMEOUT_MS
@@ -1880,6 +1981,8 @@ export async function createAgent(sessionId, config = {}) {
1880
1981
  if (commandPermissionError) return { block: true, reason: commandPermissionError }
1881
1982
  const isSkillTool = toolName === 'activate_skill' || toolName === 'read_skill_resource'
1882
1983
  if (isSkillTool) return undefined
1984
+ // ask_user only waits for the user's answer; it changes nothing and needs no approval.
1985
+ if (toolName === 'ask_user') return undefined
1883
1986
  if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
1884
1987
  if (toolName === 'manage_global_memory') return undefined
1885
1988
  if (toolName === 'run_subagent') {
@@ -1967,6 +2070,10 @@ export async function createAgent(sessionId, config = {}) {
1967
2070
  persistedMessageCount: Number.isInteger(config.persistedMessageCount) && config.persistedMessageCount >= 0 ? config.persistedMessageCount : null,
1968
2071
  persistedTailDigest: typeof config.persistedTailDigest === 'string' ? config.persistedTailDigest : null,
1969
2072
  persistConflictCount: 0,
2073
+ // Set when an authoritative persist is skipped after CAS conflicts; cleared
2074
+ // on the next successful persist. Surfaced via getSessionState/getSessionStatus
2075
+ // so the UI can warn the user instead of silently losing messages.
2076
+ persistDegraded: null,
1970
2077
  lastAutoCompactAt: null,
1971
2078
  lastAutoCompactRejected: null,
1972
2079
  memoryEnabled: initialMemoryEnabled,
@@ -2153,6 +2260,26 @@ function canonicalStateJson(value) {
2153
2260
  return JSON.stringify(value)
2154
2261
  }
2155
2262
 
2263
+ /**
2264
+ * Mark the session as persist-degraded (an authoritative persist was skipped
2265
+ * after CAS conflicts) and notify connected clients so the UI can surface a
2266
+ * non-blocking warning instead of silently losing messages.
2267
+ */
2268
+ function markPersistDegraded(session, attempts) {
2269
+ session.persistDegraded = { at: new Date().toISOString(), attempts }
2270
+ emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: true })
2271
+ }
2272
+
2273
+ /**
2274
+ * Clear the degraded marker after a successful persist and notify clients so
2275
+ * the UI warning disappears.
2276
+ */
2277
+ function clearPersistDegraded(session) {
2278
+ if (!session.persistDegraded) return
2279
+ session.persistDegraded = null
2280
+ emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: false })
2281
+ }
2282
+
2156
2283
  /**
2157
2284
  * Persist a full session record in one authoritative SQLite transaction with
2158
2285
  * revision CAS. When the CAS fails because a concurrent metadata-only change
@@ -2201,6 +2328,7 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
2201
2328
  session.persistedMessageCount = Array.isArray(sessionData.messages) ? sessionData.messages.length : 0
2202
2329
  }
2203
2330
  session.persistedTailDigest = sessionMessagesTailDigest(sessionData.messages)
2331
+ clearPersistDegraded(session)
2204
2332
  return { ...saved, metadata: mergedMetadata }
2205
2333
  } catch (error) {
2206
2334
  if (error?.errorCode !== 'SESSION_STATE_CONFLICT') throw error
@@ -2222,11 +2350,13 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
2222
2350
  }
2223
2351
  session.persistConflictCount += 1
2224
2352
  logger.warn(`Session ${sessionId} persist conflict: storage changed agent-owned fields; skipping overwrite`, { sessionId })
2353
+ markPersistDegraded(session, attempt)
2225
2354
  return null
2226
2355
  }
2227
2356
  }
2228
2357
  session.persistConflictCount += 1
2229
2358
  logger.warn(`Session ${sessionId} persist still conflicting after ${maxAttempts} merge retries`, { sessionId, reason: lastConflict?.message })
2359
+ markPersistDegraded(session, maxAttempts)
2230
2360
  return null
2231
2361
  }
2232
2362
 
@@ -2239,14 +2369,7 @@ async function persistSessionUnlocked(session) {
2239
2369
 
2240
2370
  if (messages.length === 0) {
2241
2371
  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
- }
2372
+ deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
2250
2373
  session.persistedStorageRevision = null
2251
2374
  session.persistedStateVersion = null
2252
2375
  session.persistedStateJson = null
@@ -2353,34 +2476,14 @@ async function persistSessionUnlocked(session) {
2353
2476
  idleRetention: session.idleRetention || undefined,
2354
2477
  }
2355
2478
 
2356
- // Write body + metadata as one authoritative SQLite transaction after cutover.
2479
+ // Write body + metadata as one authoritative SQLite transaction.
2357
2480
  let persistedMetadata
2358
2481
  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
- }
2482
+ const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
2483
+ if (!saved) return null
2484
+ session.persistedStorageRevision = saved.revision
2485
+ session.persistedStateVersion = saved.stateVersion
2486
+ persistedMetadata = saved.metadata
2384
2487
  } catch (err) {
2385
2488
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
2386
2489
  return null
@@ -2410,11 +2513,10 @@ const SLOW_PERSIST_LOG_MS = 1_000
2410
2513
 
2411
2514
  async function persistSession(session) {
2412
2515
  const startedAt = performance.now()
2413
- // Serialize per session once SQLite state is authoritative (per-row revision
2414
- // CAS guarantees correctness); cross-session persists then run independently
2415
- // instead of piling onto one global chain that also gates destroyAgent.
2416
- // JSON-mirror mode keeps the global lock for bucket-level atomicity.
2417
- const lockKey = isSessionStateAuthoritative() ? `session:${session.sessionId}` : ''
2516
+ // Serialize per session (per-row revision CAS guarantees correctness);
2517
+ // cross-session persists run independently instead of piling onto one
2518
+ // global chain that also gates destroyAgent.
2519
+ const lockKey = `session:${session.sessionId}`
2418
2520
  const result = await withSessionPersistenceLock(() => persistSessionUnlocked(session), lockKey)
2419
2521
  const durationMs = performance.now() - startedAt
2420
2522
  if (durationMs >= SLOW_PERSIST_LOG_MS) {
@@ -2973,9 +3075,11 @@ export function getSessionState(sessionId) {
2973
3075
  contextUsage: getSessionContextUsage(session),
2974
3076
  pendingToolApproval: getPendingApprovalForSession(session.sessionId),
2975
3077
  pendingAutoCompactApproval: getPendingAutoCompactApprovalForSession(session.sessionId),
3078
+ pendingAsk: getPendingAskForSession(session.sessionId),
2976
3079
  acpSession: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.state.acpSession : undefined,
2977
3080
  isStreaming: session.abortPending ? false : session.agent.state.isStreaming,
2978
3081
  errorMessage: session.agent.state.errorMessage,
3082
+ persistDegraded: session.persistDegraded ? true : undefined,
2979
3083
  }
2980
3084
  }
2981
3085
 
@@ -3008,6 +3112,7 @@ export function getSessionStatus(sessionId) {
3008
3112
  errorMessage: session.agent.state.errorMessage,
3009
3113
  messageCount: messages.length,
3010
3114
  lastMessageTimestamp: lastMessage?.timestamp ?? null,
3115
+ persistDegraded: session.persistDegraded ? true : undefined,
3011
3116
  }
3012
3117
  }
3013
3118
 
@@ -3039,6 +3144,21 @@ export function releaseSse(sessionId) {
3039
3144
  if (session) session.sseConnected = false
3040
3145
  }
3041
3146
 
3147
+ /**
3148
+ * Count sessions with a live SSE connection — the background-migration idle
3149
+ * signal's "no active stream" input
3150
+ * (docs/architecture/session-storage-background-migration-design.zh-CN.md
3151
+ * §3.3). Derived from the per-session sseConnected flags the SSE route
3152
+ * already maintains, so no extra bookkeeping is required.
3153
+ */
3154
+ export function countActiveSseStreams() {
3155
+ let count = 0
3156
+ for (const session of agentSessions.values()) {
3157
+ if (session.sseConnected) count += 1
3158
+ }
3159
+ return count
3160
+ }
3161
+
3042
3162
  /**
3043
3163
  * Get the event bus for a session (for SSE connections).
3044
3164
  */
@@ -3128,8 +3248,8 @@ async function restoreAgentUnlocked(sessionId) {
3128
3248
  // Read the authoritative storage record once: the body (split marker),
3129
3249
  // revision for CAS, and the stored message rows (count + tail digest) used
3130
3250
  // for split-representation conflict detection after restore.
3131
- const record = isSessionStateAuthoritative() ? readSessionStateRecord(sessionId) : null
3132
- const storedMessages = isSessionStateAuthoritative() && record?.state?.messageStorage === 'split'
3251
+ const record = readSessionStateRecord(sessionId)
3252
+ const storedMessages = record?.state?.messageStorage === 'split'
3133
3253
  ? storedMessagesState(sessionId)
3134
3254
  : null
3135
3255
 
@@ -3156,9 +3276,9 @@ async function restoreAgentUnlocked(sessionId) {
3156
3276
  openCodeUsage: sessionData.openCodeUsage || null,
3157
3277
  idleRetention: sessionData.idleRetention || null,
3158
3278
  stateVersion: sessionData.stateVersion,
3159
- persistedStateVersion: isSessionStateAuthoritative() ? sessionData.stateVersion : null,
3160
- persistedStorageRevision: isSessionStateAuthoritative() ? record?.revision ?? null : null,
3161
- persistedStateJson: isSessionStateAuthoritative() ? canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})) : null,
3279
+ persistedStateVersion: sessionData.stateVersion,
3280
+ persistedStorageRevision: record?.revision ?? null,
3281
+ persistedStateJson: canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})),
3162
3282
  persistedMessageStorage: record?.state?.messageStorage === 'split' ? 'split' : null,
3163
3283
  persistedMessageCount: storedMessages?.count ?? (Array.isArray(sessionData.messages) ? sessionData.messages.length : 0),
3164
3284
  persistedTailDigest: storedMessages?.tailDigest || sessionMessagesTailDigest(sessionData.messages),
@@ -3212,6 +3332,30 @@ export function rejectAutoCompact(sessionId, approvalId) {
3212
3332
  return { rejected: true, approvalId }
3213
3333
  }
3214
3334
 
3335
+ /**
3336
+ * Resolve a pending ask_user call with the user's answers (or a skip).
3337
+ * `answers` is an array aligned with the ask's questions:
3338
+ * `[{ choices: string[], custom?: string }]`.
3339
+ */
3340
+ export function answerAsk(sessionId, askId, { answers, skipped = false } = {}) {
3341
+ const ask = pendingAsks.get(askId)
3342
+ if (!ask || ask.sessionId !== sessionId) {
3343
+ throw Object.assign(new Error('No pending ask for this session'), { statusCode: 404 })
3344
+ }
3345
+ const normalizedAnswers = (Array.isArray(answers) ? answers : []).slice(0, ask.questions.length).map((answer) => ({
3346
+ choices: (Array.isArray(answer?.choices) ? answer.choices : [])
3347
+ .filter((choice) => typeof choice === 'string')
3348
+ .map((choice) => choice.slice(0, 500))
3349
+ .slice(0, 8),
3350
+ ...(typeof answer?.custom === 'string' && answer.custom.trim()
3351
+ ? { custom: answer.custom.slice(0, 4000) }
3352
+ : {}),
3353
+ }))
3354
+ if (skipped) ask.finish({ skipped: true })
3355
+ else ask.finish({ answers: normalizedAnswers })
3356
+ return { answered: true, askId, skipped: !!skipped }
3357
+ }
3358
+
3215
3359
  export function abortToolCall(sessionId, toolCallId) {
3216
3360
  const session = agentSessions.get(sessionId)
3217
3361
  if (!session) {
@@ -31,6 +31,7 @@ export const planAllowedTools = new Set([
31
31
  'activate_skill',
32
32
  'read_skill_resource',
33
33
  'run_subagent',
34
+ 'ask_user',
34
35
  ])
35
36
 
36
37
  export const safeReadTools = new Set([
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Ask-user store — shared pending-question state.
3
+ *
4
+ * The Promise-based ask flow (createAskUserPromise) lives in agent-manager.mjs
5
+ * because it depends on the agent event buses; this module only holds the
6
+ * pending queue, mirroring approval-store.mjs.
7
+ */
8
+
9
+ // Waiting for a user decision is not a security gate — allow generous time
10
+ // before resolving as skipped so the agent can continue on its own.
11
+ export const ASK_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
12
+
13
+ /** askId → { finish, sessionId, toolCallId, questions, requestedAt, expiresAt } */
14
+ export const pendingAsks = new Map()
15
+
16
+ export function getPendingAskForSession(sessionId) {
17
+ for (const [askId, ask] of pendingAsks) {
18
+ if (ask.sessionId === sessionId) {
19
+ return {
20
+ askId,
21
+ toolCallId: ask.toolCallId,
22
+ questions: ask.questions,
23
+ requestedAt: ask.requestedAt,
24
+ expiresAt: ask.expiresAt,
25
+ }
26
+ }
27
+ }
28
+ return null
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Pure helpers (no dependencies — unit-tested directly)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Normalize ask_user params into a bounded questions list (robust against model shorthand). */
36
+ export function normalizeAskQuestions(params) {
37
+ const rawQuestions = Array.isArray(params?.questions)
38
+ ? params.questions
39
+ : (typeof params?.question === 'string' ? [{ ...params }] : [])
40
+ return rawQuestions
41
+ .filter((q) => q && typeof q.question === 'string' && q.question.trim())
42
+ .slice(0, 4)
43
+ .map((q) => ({
44
+ question: String(q.question),
45
+ multiSelect: q.multiSelect === true,
46
+ allowCustom: q.allowCustom === false ? false : true,
47
+ options: (Array.isArray(q.options) ? q.options : [])
48
+ .filter((option) => option && typeof option.label === 'string' && option.label.trim())
49
+ .slice(0, 4)
50
+ .map((option) => ({
51
+ label: String(option.label),
52
+ ...(typeof option.description === 'string' && option.description ? { description: option.description } : {}),
53
+ })),
54
+ }))
55
+ }
56
+
57
+ /** Render the tool-result text returned to the model. */
58
+ export function formatAskResult(questions, answers, skipped, reason) {
59
+ if (skipped) {
60
+ const note = reason === 'timeout'
61
+ ? '(等待超时)'
62
+ : reason === 'aborted'
63
+ ? '(运行被停止)'
64
+ : reason === 'no-questions'
65
+ ? '(问题列表无效)'
66
+ : '(用户跳过)'
67
+ return `用户没有回答这些问题${note}。请按你的默认方案继续,不要再重复追问。`
68
+ }
69
+ const lines = questions.map((q, index) => {
70
+ const answer = answers?.[index]
71
+ if (!answer) return `${index + 1}. ${q.question} → 用户未回答`
72
+ const choiceText = Array.isArray(answer.choices) && answer.choices.length ? answer.choices.join('、') : ''
73
+ const customText = typeof answer.custom === 'string' && answer.custom.trim() ? answer.custom.trim() : ''
74
+ const text = [choiceText, customText].filter(Boolean).join(' 补充:')
75
+ return `${index + 1}. ${q.question} → ${text || '用户未回答'}`
76
+ })
77
+ return `用户的回答:\n${lines.join('\n')}`
78
+ }
@@ -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