@shawnstack/quickforge 1.3.30 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +10 -10
  2. package/dist/assets/AgentProfilesPage-C79teCgh.js +1 -0
  3. package/dist/assets/ChatPanelHost-BjdIshtX.js +195 -0
  4. package/dist/assets/PluginsPage-Dt7Iiddo.js +1 -0
  5. package/dist/assets/ScheduledTasksPage-C047y3p3.js +2 -0
  6. package/dist/assets/SharedConversationPage-8X8kfztQ.js +1 -0
  7. package/dist/assets/TerminalDock-CEuJNf0m.js +2 -0
  8. package/dist/assets/WorkspaceInspector-BIa5gLVs.js +3 -0
  9. package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +6 -0
  10. package/dist/assets/{icons-BVM5--R9.js → icons-Dsc5yL3l.js} +1 -1
  11. package/dist/assets/{index-D0c0FMPa.css → index-CPAWYhzz.css} +1 -1
  12. package/dist/assets/index-YTL26wyJ.js +814 -0
  13. package/dist/assets/logger-B65Akg8A.js +1 -0
  14. package/dist/assets/monaco-DG4TcBMc.js +11 -0
  15. package/dist/assets/pi-ai-Cx633yhb.js +134 -0
  16. package/dist/assets/pi-web-ui-CBet4bMl.js +2770 -0
  17. package/dist/assets/plugin-api-YfYj_Bd7.js +1 -0
  18. package/dist/assets/{react-vendor-DAoL5p8_.js → react-vendor-CiCXOLb5.js} +1 -1
  19. package/dist/assets/rolldown-runtime-DWdDZTNf.js +1 -0
  20. package/dist/assets/xterm-5XDrJ343.js +36 -0
  21. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  22. package/dist/index.html +8 -5
  23. package/package.json +1 -1
  24. package/server/agent-manager.mjs +104 -18
  25. package/server/auto-compaction.mjs +36 -1
  26. package/server/index.mjs +13 -0
  27. package/server/routes/agent.mjs +19 -0
  28. package/server/storage.mjs +32 -20
  29. package/server/utils/logger.mjs +0 -2
  30. package/dist/assets/anthropic-DYkQmon0.js +0 -39
  31. package/dist/assets/azure-openai-responses-B1_ZuuCX.js +0 -1
  32. package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
  33. package/dist/assets/google-Bx1PGUtS.js +0 -1
  34. package/dist/assets/google-shared-Cqjw1plk.js +0 -11
  35. package/dist/assets/google-vertex-1iRQw75f.js +0 -1
  36. package/dist/assets/hash-kZ2KD_no.js +0 -1
  37. package/dist/assets/headers-5EYI0_pl.js +0 -1
  38. package/dist/assets/index-CQq-kPng.js +0 -3837
  39. package/dist/assets/mistral-B1j5S2k5.js +0 -44
  40. package/dist/assets/openai-Bf1npfRy.js +0 -16
  41. package/dist/assets/openai-codex-responses-BJKEqst-.js +0 -7
  42. package/dist/assets/openai-completions-B_cU49Pc.js +0 -5
  43. package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
  44. package/dist/assets/openai-responses-DgGY16ph.js +0 -1
  45. package/dist/assets/openai-responses-shared-J1-i-goZ.js +0 -12
  46. package/dist/assets/openrouter-BVaMghZV.js +0 -1
  47. package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
  48. package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
  49. package/dist/assets/transform-messages-Dhj_4OTw.js +0 -1
@@ -0,0 +1 @@
1
+ .xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}
package/dist/index.html CHANGED
@@ -11,13 +11,16 @@
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-CQq-kPng.js"></script>
15
- <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CkqCuyE9.js">
14
+ <script type="module" crossorigin src="/assets/index-YTL26wyJ.js"></script>
15
+ <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
16
+ <link rel="modulepreload" crossorigin href="/assets/pi-ai-Cx633yhb.js">
16
17
  <link rel="modulepreload" crossorigin href="/assets/lit-vendor-Dr3cpBGF.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/pi-web-ui-CBet4bMl.js">
17
19
  <link rel="modulepreload" crossorigin href="/assets/css-utils-rkE68RDy.js">
18
- <link rel="modulepreload" crossorigin href="/assets/icons-BVM5--R9.js">
19
- <link rel="modulepreload" crossorigin href="/assets/react-vendor-DAoL5p8_.js">
20
- <link rel="stylesheet" crossorigin href="/assets/index-D0c0FMPa.css">
20
+ <link rel="modulepreload" crossorigin href="/assets/icons-Dsc5yL3l.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/react-vendor-CiCXOLb5.js">
22
+ <link rel="modulepreload" crossorigin href="/assets/logger-B65Akg8A.js">
23
+ <link rel="stylesheet" crossorigin href="/assets/index-CPAWYhzz.css">
21
24
  </head>
22
25
  <body>
23
26
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.3.30",
3
+ "version": "1.4.0",
4
4
  "description": "AI chat application with YOLO-mode local workspace tools. React + Vite + Tailwind CSS frontend, local Node.js storage server.",
5
5
  "keywords": [
6
6
  "ai",
@@ -196,19 +196,14 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
196
196
  source,
197
197
  })
198
198
 
199
- // Notify the frontend via both the session-level and global event buses.
200
- // The global SSE handler (/api/agents/events) only listens to `agentEvents`,
201
- // so events emitted only on session.eventBus never reach the client.
202
- const approvalEvent = {
199
+ emitSessionEvent(session, {
203
200
  type: 'tool_approval_required',
204
201
  sessionId: session.sessionId,
205
202
  toolCallId,
206
203
  toolName,
207
204
  args,
208
205
  source,
209
- }
210
- session.eventBus.emit('agent_event', approvalEvent)
211
- agentEvents.emit('agent_event', approvalEvent)
206
+ })
212
207
  })
213
208
  }
214
209
 
@@ -312,11 +307,18 @@ function estimateTokenReduction(originalChars, finalChars) {
312
307
  return Math.max(0, Math.min(99, Math.round(((originalChars - finalChars) / originalChars) * 100)))
313
308
  }
314
309
 
310
+ function nextSessionStateVersion(session) {
311
+ const current = Number.isFinite(session?.stateVersion) ? session.stateVersion : 0
312
+ session.stateVersion = current + 1
313
+ return session.stateVersion
314
+ }
315
+
315
316
  function emitSessionEvent(session, event) {
317
+ const stateVersion = nextSessionStateVersion(session)
316
318
  const enrichedEvent = (event?.type === 'message_end' || event?.type === 'agent_end' || event?.type === 'messages_replaced' || event?.type === 'auto_compact_completed')
317
319
  && event.contextUsage === undefined
318
- ? { ...event, contextUsage: getSessionContextUsage(session) }
319
- : event
320
+ ? { ...event, contextUsage: getSessionContextUsage(session), stateVersion }
321
+ : { ...event, stateVersion }
320
322
  session.eventBus.emit('agent_event', enrichedEvent)
321
323
  agentEvents.emit('agent_event', { sessionId: session.sessionId, ...enrichedEvent })
322
324
  }
@@ -1138,6 +1140,7 @@ export async function createAgent(sessionId, config = {}) {
1138
1140
  activeCommandPrompt: null,
1139
1141
  eventBus,
1140
1142
  idleTimer: null,
1143
+ persistTimer: null,
1141
1144
  titleGenerated: false,
1142
1145
  toolTimings: new Map(),
1143
1146
  getApiKey,
@@ -1145,6 +1148,7 @@ export async function createAgent(sessionId, config = {}) {
1145
1148
  agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
1146
1149
  lastTransformedContextMessages: null,
1147
1150
  autoCompacting: false,
1151
+ stateVersion: 0,
1148
1152
  lastAutoCompactAt: null,
1149
1153
  lastAutoCompactRejected: null,
1150
1154
  /** Track active SSE connections. Only one SSE stream allowed per session to prevent
@@ -1159,8 +1163,20 @@ export async function createAgent(sessionId, config = {}) {
1159
1163
  // complete session history. Replace with the authoritative full state
1160
1164
  // before forwarding to clients.
1161
1165
  const timedEvent = addToolTimingToEvent(session, event)
1162
- const forwardEvent = timedEvent.type === 'agent_end' && timedEvent.messages
1163
- ? { ...timedEvent, messages: agent.state.messages }
1166
+ const eventEndStatus = event.type === 'agent_end'
1167
+ ? session.agent.signal?.aborted
1168
+ ? 'aborted'
1169
+ : session.agent.state.errorMessage
1170
+ ? 'error'
1171
+ : 'idle'
1172
+ : undefined
1173
+ const forwardEvent = timedEvent.type === 'agent_end'
1174
+ ? {
1175
+ ...timedEvent,
1176
+ ...(timedEvent.messages ? { messages: agent.state.messages } : {}),
1177
+ status: eventEndStatus,
1178
+ ...(session.agent.state.errorMessage && timedEvent.errorMessage === undefined ? { errorMessage: session.agent.state.errorMessage } : {}),
1179
+ }
1164
1180
  : timedEvent
1165
1181
 
1166
1182
  // Forward all events to the session event bus and the global bus.
@@ -1178,22 +1194,21 @@ export async function createAgent(sessionId, config = {}) {
1178
1194
  }
1179
1195
 
1180
1196
  if (event.type === 'agent_end') {
1181
- session.status = session.agent.state.errorMessage ? 'error' : 'idle'
1197
+ session.status = eventEndStatus || (session.agent.state.errorMessage ? 'error' : 'idle')
1182
1198
  session.finishedAt = new Date().toISOString()
1183
1199
  session.toolTimings?.clear()
1184
1200
  resetIdleTimer(session)
1185
1201
 
1186
- // Persist after run ends
1187
- persistSession(session).catch((err) =>
1202
+ // Persist after run ends. Flush any debounced write so the final state is durable.
1203
+ flushSessionPersist(session).catch((err) =>
1188
1204
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
1189
1205
  )
1190
1206
  }
1191
1207
 
1192
1208
  if (event.type === 'message_end') {
1193
- // Do a lightweight persist on message_end for crash recovery
1194
- persistSession(session).catch((err) =>
1195
- logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
1196
- )
1209
+ // Debounced persist for crash recovery; coalesces the many message_end
1210
+ // events within a single run into infrequent full-session writes.
1211
+ scheduleSessionPersist(session)
1197
1212
  }
1198
1213
  })
1199
1214
 
@@ -1337,6 +1352,44 @@ async function persistSession(session) {
1337
1352
  }
1338
1353
 
1339
1354
  export async function persistSessionState(session) {
1355
+ await flushSessionPersist(session)
1356
+ }
1357
+
1358
+ /**
1359
+ * Coalesce fire-and-forget session persists during a run.
1360
+ *
1361
+ * persistSession() serializes the ENTIRE session (all messages) on every call,
1362
+ * and the agent event loop calls it on agent_start / each message_end / agent_end.
1363
+ * Within a single run these events fire many times (one per assistant turn +
1364
+ * tool result), so writing on each one makes cumulative disk I/O O(n^2) as a
1365
+ * conversation grows. These message_end call sites are fire-and-forget
1366
+ * (crash-recovery only), so we debounce them into at most one write per
1367
+ * PERSIST_DEBOUNCE_MS. Run boundaries (agent_end) and explicit persists cancel
1368
+ * the pending timer and write the current state immediately, so the final
1369
+ * state is always durable.
1370
+ */
1371
+ const PERSIST_DEBOUNCE_MS = 400
1372
+
1373
+ function scheduleSessionPersist(session) {
1374
+ if (session.persistTimer) return
1375
+ session.persistTimer = setTimeout(() => {
1376
+ session.persistTimer = null
1377
+ persistSession(session).catch((err) =>
1378
+ logger.error(`Failed to persist session ${session.sessionId}:`, err, { sessionId: session.sessionId }),
1379
+ )
1380
+ }, PERSIST_DEBOUNCE_MS).unref?.()
1381
+ }
1382
+
1383
+ /**
1384
+ * Cancel any pending debounced write and persist the current state immediately.
1385
+ * Used at run boundaries (agent_end) and by explicit persistSessionState() so
1386
+ * the final state is always durable regardless of a pending timer.
1387
+ */
1388
+ async function flushSessionPersist(session) {
1389
+ if (session.persistTimer) {
1390
+ clearTimeout(session.persistTimer)
1391
+ session.persistTimer = null
1392
+ }
1340
1393
  await persistSession(session)
1341
1394
  }
1342
1395
 
@@ -1562,6 +1615,7 @@ export async function abortRun(sessionId) {
1562
1615
  )
1563
1616
  const event = {
1564
1617
  type: 'agent_end',
1618
+ status: 'aborted',
1565
1619
  messages: session.agent.state.messages,
1566
1620
  }
1567
1621
  emitSessionEvent(session, event)
@@ -1631,6 +1685,7 @@ export function getSessionState(sessionId) {
1631
1685
  title: session.title,
1632
1686
  createdAt: session.createdAt,
1633
1687
  lastModified: session.lastModified,
1688
+ stateVersion: session.stateVersion || 0,
1634
1689
  status: session.status,
1635
1690
  startedAt: session.startedAt,
1636
1691
  finishedAt: session.finishedAt,
@@ -1643,6 +1698,33 @@ export function getSessionState(sessionId) {
1643
1698
  }
1644
1699
  }
1645
1700
 
1701
+ /**
1702
+ * Get a lightweight status snapshot for SSE-first state recovery.
1703
+ */
1704
+ export function getSessionStatus(sessionId) {
1705
+ const session = agentSessions.get(sessionId)
1706
+ if (!session) return null
1707
+
1708
+ const messages = session.agent.state.messages || []
1709
+ const lastMessage = messages[messages.length - 1]
1710
+ return {
1711
+ sessionId: session.sessionId,
1712
+ scope: session.scope,
1713
+ projectId: session.projectId,
1714
+ title: session.title,
1715
+ createdAt: session.createdAt,
1716
+ lastModified: session.lastModified,
1717
+ stateVersion: session.stateVersion || 0,
1718
+ status: session.status,
1719
+ startedAt: session.startedAt,
1720
+ finishedAt: session.finishedAt,
1721
+ isStreaming: session.agent.state.isStreaming,
1722
+ errorMessage: session.agent.state.errorMessage,
1723
+ messageCount: messages.length,
1724
+ lastMessageTimestamp: lastMessage?.timestamp ?? null,
1725
+ }
1726
+ }
1727
+
1646
1728
  /**
1647
1729
  * Try to claim the SSE slot for a session. Returns true if acquired, false if
1648
1730
  * another tab already holds the SSE connection for this session.
@@ -1689,6 +1771,10 @@ export async function destroyAgent(sessionId) {
1689
1771
  logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
1690
1772
 
1691
1773
  if (session.idleTimer) clearTimeout(session.idleTimer)
1774
+ if (session.persistTimer) {
1775
+ clearTimeout(session.persistTimer)
1776
+ session.persistTimer = null
1777
+ }
1692
1778
  session.toolTimings?.clear()
1693
1779
 
1694
1780
  try {
@@ -222,15 +222,50 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
222
222
  if (sourceMessages.length === 0) {
223
223
  return { inputTokens: 0, estimatedInputTokens: 0, knownInputTokens: 0, reservedOutputTokens: 0, totalTokens: 0, contextWindow, percent: 0 }
224
224
  }
225
+
226
+ // Cache by input identity. estimateContextUsage() scans every message with a
227
+ // tokenizer regex (O(n)) and JSON-stringifies the full tools array, but its
228
+ // inputs (messages, model, systemPrompt, tools, contextCompaction) are stable
229
+ // within a run and only change on discrete events (message_end, tool result,
230
+ // compaction). Reference equality makes the cache check essentially free, so
231
+ // the repeated calls from emitSessionEvent() on message_end/agent_end/etc.
232
+ // only recompute when something actually changed.
233
+ const lastMessage = sourceMessages[sourceMessages.length - 1]
234
+ const cacheKey = {
235
+ messages,
236
+ messagesLength: sourceMessages.length,
237
+ lastMessage,
238
+ model: session.model,
239
+ systemPrompt: session.agent.state.systemPrompt,
240
+ tools: session.agent.state.tools,
241
+ contextCompaction: session.contextCompaction,
242
+ }
243
+ const cached = session._contextUsageCache
244
+ if (
245
+ cached &&
246
+ cached.key.messages === cacheKey.messages &&
247
+ cached.key.messagesLength === cacheKey.messagesLength &&
248
+ cached.key.lastMessage === cacheKey.lastMessage &&
249
+ cached.key.model === cacheKey.model &&
250
+ cached.key.systemPrompt === cacheKey.systemPrompt &&
251
+ cached.key.tools === cacheKey.tools &&
252
+ cached.key.contextCompaction === cacheKey.contextCompaction
253
+ ) {
254
+ return cached.value
255
+ }
256
+
225
257
  const loopMessages = buildAutoCompactLoopMessages(session, sourceMessages)
226
258
  const knownInputTokens = latestKnownInputTokens(sourceMessages, latestCompactTimestampMs(session))
227
- return estimateContextUsage({
259
+ const value = estimateContextUsage({
228
260
  systemPrompt: session.agent.state.systemPrompt,
229
261
  messages: loopMessages,
230
262
  tools: session.agent.state.tools,
231
263
  model: session.model,
232
264
  knownInputTokens,
233
265
  })
266
+
267
+ session._contextUsageCache = { key: cacheKey, value }
268
+ return value
234
269
  }
235
270
 
236
271
  export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
package/server/index.mjs CHANGED
@@ -537,6 +537,19 @@ await initializeActiveProject()
537
537
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
538
538
  startScheduledTaskRunner()
539
539
 
540
+ server.on('error', (error) => {
541
+ // Handle listen errors (most commonly EADDRINUSE). Without this, Node would
542
+ // throw an uncaught exception and crash with only a raw stack trace in the log.
543
+ if (error.code === 'EADDRINUSE') {
544
+ logger.error(`Port ${port} is already in use. QuickForge could not start.`)
545
+ logger.error('Hint: stop the running instance with "quickforge stop" or "quickforge restart", or use a different port with QUICKFORGE_PORT=<port>.')
546
+ } else {
547
+ logger.error(`QuickForge failed to listen on ${host}:${port}: ${error.message}`)
548
+ }
549
+ flushLogger()
550
+ process.exit(1)
551
+ })
552
+
540
553
  server.listen(port, host, () => {
541
554
  logger.info(`QuickForge local API: http://${host}:${port}`)
542
555
  if (shareLanEnabled) {
@@ -6,6 +6,7 @@ import {
6
6
  steerAgent,
7
7
  followUpAgent,
8
8
  getSessionState,
9
+ getSessionStatus,
9
10
  getSessionEventBus,
10
11
  tryAcquireSse,
11
12
  releaseSse,
@@ -112,6 +113,24 @@ export async function handleAgentApi(req, res, url) {
112
113
  return
113
114
  }
114
115
 
116
+ // GET /api/agents/:sessionId/status - get lightweight session status
117
+ if (req.method === 'GET' && subPath === 'status') {
118
+ let status = getSessionStatus(sessionId)
119
+ if (!status) {
120
+ // Try to restore from persistent storage before giving up.
121
+ // This recovers sessions that were evicted by idle timeout.
122
+ await restoreAgent(sessionId)
123
+ status = getSessionStatus(sessionId)
124
+ }
125
+ if (!status) {
126
+ const error = new Error('Session not found')
127
+ error.statusCode = 404
128
+ throw error
129
+ }
130
+ sendJson(res, 200, status)
131
+ return
132
+ }
133
+
115
134
  // POST /api/agents/:sessionId — create/ensure agent
116
135
  if (req.method === 'POST' && parts.length === 3) {
117
136
  const body = await readJsonBody(req)
@@ -705,26 +705,38 @@ export async function atomicProjectConfigUpdate(updateFn) {
705
705
  })
706
706
  }
707
707
 
708
- export async function ensureStorage() {
709
- await fs.mkdir(configDir, { recursive: true })
710
- await fs.mkdir(storageDir, { recursive: true })
711
- await fs.mkdir(cacheDir, { recursive: true })
712
- await fs.mkdir(logsDir, { recursive: true })
713
- await Promise.all([
714
- fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
715
- fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
716
- fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
717
- fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
718
- fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
719
- cleanOldLogs(),
720
- ])
721
-
722
- await migrateUnifiedConfig()
723
-
724
- await Promise.all([
725
- ensureJsonFile(quickForgeConfigFile, defaultConfig()),
726
- ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
727
- ])
708
+ // Cached storage-initialization promise. ensureStorage() is idempotent (mkdir
709
+ // recursive, one-shot migration gated by a marker file, ensureJsonFile), so once
710
+ // it succeeds we can skip the redundant syscalls (~20 per call) every later call
711
+ // would perform. Reset on failure so the next call can retry.
712
+ let storageInitPromise = null
713
+
714
+ export function ensureStorage() {
715
+ if (storageInitPromise) return storageInitPromise
716
+ storageInitPromise = (async () => {
717
+ await fs.mkdir(configDir, { recursive: true })
718
+ await fs.mkdir(storageDir, { recursive: true })
719
+ await fs.mkdir(cacheDir, { recursive: true })
720
+ await fs.mkdir(logsDir, { recursive: true })
721
+ await Promise.all([
722
+ fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
723
+ fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
724
+ fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
725
+ fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
726
+ fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
727
+ cleanOldLogs(),
728
+ ])
729
+
730
+ await migrateUnifiedConfig()
731
+
732
+ await Promise.all([
733
+ ensureJsonFile(quickForgeConfigFile, defaultConfig()),
734
+ ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
735
+ ])
736
+ })()
737
+ // Reset on failure so the next call can retry instead of caching a rejection.
738
+ storageInitPromise.catch(() => { storageInitPromise = null })
739
+ return storageInitPromise
728
740
  }
729
741
 
730
742
  export async function readStore(storeName) {
@@ -136,9 +136,7 @@ function writeLog(level, context, ...args) {
136
136
  const jsonLine = JSON.stringify(jsonObj) + '\n'
137
137
 
138
138
  try {
139
- const s = getStream()
140
139
  pendingLines.push(jsonLine)
141
- s.write(jsonLine) // also try immediate write
142
140
  scheduleFlush()
143
141
  } catch {
144
142
  // ignore write errors