@shawnstack/quickforge 1.3.29 → 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 (51) hide show
  1. package/README.md +12 -12
  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-CPAWYhzz.css +3 -0
  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 +109 -20
  25. package/server/approval-store.mjs +1 -1
  26. package/server/auto-compaction.mjs +36 -1
  27. package/server/index.mjs +13 -0
  28. package/server/routes/agent.mjs +19 -0
  29. package/server/storage.mjs +32 -20
  30. package/server/utils/logger.mjs +0 -2
  31. package/dist/assets/anthropic-Bi2whCo9.js +0 -39
  32. package/dist/assets/azure-openai-responses-BIluwauz.js +0 -1
  33. package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
  34. package/dist/assets/google-DzMAdtX7.js +0 -1
  35. package/dist/assets/google-shared-Cqjw1plk.js +0 -11
  36. package/dist/assets/google-vertex-BPMvmXyu.js +0 -1
  37. package/dist/assets/hash-kZ2KD_no.js +0 -1
  38. package/dist/assets/headers-5EYI0_pl.js +0 -1
  39. package/dist/assets/index-CcvNhwdQ.css +0 -3
  40. package/dist/assets/index-CnT_4xVs.js +0 -3837
  41. package/dist/assets/mistral-C3NYr8yr.js +0 -44
  42. package/dist/assets/openai-Bf1npfRy.js +0 -16
  43. package/dist/assets/openai-codex-responses-jn0IUTnv.js +0 -7
  44. package/dist/assets/openai-completions-d4aAZ4cH.js +0 -5
  45. package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
  46. package/dist/assets/openai-responses-D5wQx0VD.js +0 -1
  47. package/dist/assets/openai-responses-shared-DkIGPnog.js +0 -12
  48. package/dist/assets/openrouter-DslZMI-g.js +0 -1
  49. package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
  50. package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
  51. 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-CnT_4xVs.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-CcvNhwdQ.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.29",
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
  }
@@ -556,7 +558,7 @@ async function resolveCommandState(session, userMessage) {
556
558
  return {
557
559
  userMessage,
558
560
  commandPrompt: formatPlanCommandPrompt(internalResponse.args),
559
- permissions: { allowEdit: false, allowCommands: false },
561
+ permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
560
562
  commandName: 'plan',
561
563
  }
562
564
  }
@@ -597,6 +599,7 @@ Rules for this turn:
597
599
  - Do not run shell commands.
598
600
  - Do not use write_file, edit_file, run_command, or any other state-changing tool.
599
601
  - You may use read-only tools such as read_file and grep_files if needed to inspect the project.
602
+ - You may delegate bounded read-only research to subagents, but subagents must also obey this /plan turn: no file modifications and no shell commands.
600
603
  - Output the plan and then stop. Do not start implementation.
601
604
 
602
605
  Plan should include:
@@ -681,7 +684,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
681
684
  true,
682
685
  (toolName) => {
683
686
  if (!definition.allowedTools.includes(toolName)) return `Subagent ${definition.name} is not allowed to use ${toolName}.`
684
- return null
687
+ return commandToolPermissionError(parentSession, toolName)
685
688
  },
686
689
  {
687
690
  allowedToolNames: definition.allowedTools,
@@ -764,6 +767,8 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
764
767
  if (!definition.allowedTools.includes(toolName)) {
765
768
  return { block: true, reason: `Subagent ${definition.name} is not allowed to use ${toolName}.` }
766
769
  }
770
+ const commandPermissionError = commandToolPermissionError(parentSession, toolName)
771
+ if (commandPermissionError) return { block: true, reason: commandPermissionError }
767
772
  if (!parentSession.yoloMode) {
768
773
  if (safeReadTools.has(toolName)) return undefined
769
774
  return createApprovalPromise(parentSession, context.toolCall?.id, toolName, context.args, {
@@ -1135,6 +1140,7 @@ export async function createAgent(sessionId, config = {}) {
1135
1140
  activeCommandPrompt: null,
1136
1141
  eventBus,
1137
1142
  idleTimer: null,
1143
+ persistTimer: null,
1138
1144
  titleGenerated: false,
1139
1145
  toolTimings: new Map(),
1140
1146
  getApiKey,
@@ -1142,6 +1148,7 @@ export async function createAgent(sessionId, config = {}) {
1142
1148
  agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
1143
1149
  lastTransformedContextMessages: null,
1144
1150
  autoCompacting: false,
1151
+ stateVersion: 0,
1145
1152
  lastAutoCompactAt: null,
1146
1153
  lastAutoCompactRejected: null,
1147
1154
  /** Track active SSE connections. Only one SSE stream allowed per session to prevent
@@ -1156,8 +1163,20 @@ export async function createAgent(sessionId, config = {}) {
1156
1163
  // complete session history. Replace with the authoritative full state
1157
1164
  // before forwarding to clients.
1158
1165
  const timedEvent = addToolTimingToEvent(session, event)
1159
- const forwardEvent = timedEvent.type === 'agent_end' && timedEvent.messages
1160
- ? { ...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
+ }
1161
1180
  : timedEvent
1162
1181
 
1163
1182
  // Forward all events to the session event bus and the global bus.
@@ -1175,22 +1194,21 @@ export async function createAgent(sessionId, config = {}) {
1175
1194
  }
1176
1195
 
1177
1196
  if (event.type === 'agent_end') {
1178
- session.status = session.agent.state.errorMessage ? 'error' : 'idle'
1197
+ session.status = eventEndStatus || (session.agent.state.errorMessage ? 'error' : 'idle')
1179
1198
  session.finishedAt = new Date().toISOString()
1180
1199
  session.toolTimings?.clear()
1181
1200
  resetIdleTimer(session)
1182
1201
 
1183
- // Persist after run ends
1184
- persistSession(session).catch((err) =>
1202
+ // Persist after run ends. Flush any debounced write so the final state is durable.
1203
+ flushSessionPersist(session).catch((err) =>
1185
1204
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
1186
1205
  )
1187
1206
  }
1188
1207
 
1189
1208
  if (event.type === 'message_end') {
1190
- // Do a lightweight persist on message_end for crash recovery
1191
- persistSession(session).catch((err) =>
1192
- logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
1193
- )
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)
1194
1212
  }
1195
1213
  })
1196
1214
 
@@ -1334,6 +1352,44 @@ async function persistSession(session) {
1334
1352
  }
1335
1353
 
1336
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
+ }
1337
1393
  await persistSession(session)
1338
1394
  }
1339
1395
 
@@ -1559,6 +1615,7 @@ export async function abortRun(sessionId) {
1559
1615
  )
1560
1616
  const event = {
1561
1617
  type: 'agent_end',
1618
+ status: 'aborted',
1562
1619
  messages: session.agent.state.messages,
1563
1620
  }
1564
1621
  emitSessionEvent(session, event)
@@ -1628,6 +1685,7 @@ export function getSessionState(sessionId) {
1628
1685
  title: session.title,
1629
1686
  createdAt: session.createdAt,
1630
1687
  lastModified: session.lastModified,
1688
+ stateVersion: session.stateVersion || 0,
1631
1689
  status: session.status,
1632
1690
  startedAt: session.startedAt,
1633
1691
  finishedAt: session.finishedAt,
@@ -1640,6 +1698,33 @@ export function getSessionState(sessionId) {
1640
1698
  }
1641
1699
  }
1642
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
+
1643
1728
  /**
1644
1729
  * Try to claim the SSE slot for a session. Returns true if acquired, false if
1645
1730
  * another tab already holds the SSE connection for this session.
@@ -1686,6 +1771,10 @@ export async function destroyAgent(sessionId) {
1686
1771
  logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
1687
1772
 
1688
1773
  if (session.idleTimer) clearTimeout(session.idleTimer)
1774
+ if (session.persistTimer) {
1775
+ clearTimeout(session.persistTimer)
1776
+ session.persistTimer = null
1777
+ }
1689
1778
  session.toolTimings?.clear()
1690
1779
 
1691
1780
  try {
@@ -49,7 +49,7 @@ export function commandToolPermissionError(session, toolName) {
49
49
  if (toolName === 'run_command' && permissions.allowCommands === false) {
50
50
  return `Command /${session.activeCommandName} does not allow running shell commands.`
51
51
  }
52
- if (toolName === 'run_subagent' && (permissions.allowSubagents === false || permissions.allowCommands === false)) {
52
+ if (toolName === 'run_subagent' && permissions.allowSubagents === false) {
53
53
  return `Command /${session.activeCommandName} does not allow running subagents.`
54
54
  }
55
55
  if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
@@ -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