@shawnstack/quickforge 1.3.30 → 1.4.1

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 (60) hide show
  1. package/README.md +12 -12
  2. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +1 -0
  3. package/dist/assets/ChatPanelHost-FqPQwwMO.js +217 -0
  4. package/dist/assets/PluginsPage-BCu1Ept0.js +1 -0
  5. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +2 -0
  6. package/dist/assets/SharedConversationPage-55vX9sqe.js +1 -0
  7. package/dist/assets/TerminalDock-DLN_pLkJ.js +2 -0
  8. package/dist/assets/WorkspaceInspector-DoemHHnY.js +3 -0
  9. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +6 -0
  10. package/dist/assets/{icons-BVM5--R9.js → icons-BWtivFsx.js} +1 -1
  11. package/dist/assets/index-CxOHP41X.css +3 -0
  12. package/dist/assets/index-Dcf73EL8.js +895 -0
  13. package/dist/assets/logger-B65Akg8A.js +1 -0
  14. package/dist/assets/monaco-evITXh-m.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-Mthyt1p4.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 +189 -31
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +63 -72
  27. package/server/context-usage.mjs +108 -0
  28. package/server/custom-commands.mjs +145 -28
  29. package/server/index.mjs +13 -0
  30. package/server/mcp/registry.mjs +40 -0
  31. package/server/routes/agent.mjs +20 -1
  32. package/server/routes/mcp.mjs +7 -1
  33. package/server/routes/project.mjs +32 -2
  34. package/server/routes/shared-conversation.mjs +1 -1
  35. package/server/storage.mjs +32 -19
  36. package/server/subagents.mjs +8 -6
  37. package/server/system-prompt.mjs +2 -2
  38. package/server/tools/definitions.mjs +1 -1
  39. package/server/utils/logger.mjs +0 -2
  40. package/dist/assets/anthropic-DYkQmon0.js +0 -39
  41. package/dist/assets/azure-openai-responses-B1_ZuuCX.js +0 -1
  42. package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
  43. package/dist/assets/google-Bx1PGUtS.js +0 -1
  44. package/dist/assets/google-shared-Cqjw1plk.js +0 -11
  45. package/dist/assets/google-vertex-1iRQw75f.js +0 -1
  46. package/dist/assets/hash-kZ2KD_no.js +0 -1
  47. package/dist/assets/headers-5EYI0_pl.js +0 -1
  48. package/dist/assets/index-CQq-kPng.js +0 -3837
  49. package/dist/assets/index-D0c0FMPa.css +0 -3
  50. package/dist/assets/mistral-B1j5S2k5.js +0 -44
  51. package/dist/assets/openai-Bf1npfRy.js +0 -16
  52. package/dist/assets/openai-codex-responses-BJKEqst-.js +0 -7
  53. package/dist/assets/openai-completions-B_cU49Pc.js +0 -5
  54. package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
  55. package/dist/assets/openai-responses-DgGY16ph.js +0 -1
  56. package/dist/assets/openai-responses-shared-J1-i-goZ.js +0 -12
  57. package/dist/assets/openrouter-BVaMghZV.js +0 -1
  58. package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
  59. package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
  60. 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-Dcf73EL8.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-BWtivFsx.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/react-vendor-Mthyt1p4.js">
22
+ <link rel="modulepreload" crossorigin href="/assets/logger-B65Akg8A.js">
23
+ <link rel="stylesheet" crossorigin href="/assets/index-CxOHP41X.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.1",
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",
@@ -35,7 +35,6 @@ import { omitDetailsForLlm, serverConvertToLlm, messageText, lastAssistantText }
35
35
  import { isPlainObject, mergeQuickForgeTiming, wrapToolDefinition, wrapMcpToolDefinition, wrapPluginToolDefinition, sessionSkillsContext } from './tool-wiring.mjs'
36
36
  import {
37
37
  APPROVAL_TIMEOUT_MS,
38
- commandRestrictedTools,
39
38
  safeReadTools,
40
39
  pendingApprovals,
41
40
  pendingAutoCompactApprovals,
@@ -196,19 +195,14 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
196
195
  source,
197
196
  })
198
197
 
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 = {
198
+ emitSessionEvent(session, {
203
199
  type: 'tool_approval_required',
204
200
  sessionId: session.sessionId,
205
201
  toolCallId,
206
202
  toolName,
207
203
  args,
208
204
  source,
209
- }
210
- session.eventBus.emit('agent_event', approvalEvent)
211
- agentEvents.emit('agent_event', approvalEvent)
205
+ })
212
206
  })
213
207
  }
214
208
 
@@ -312,11 +306,18 @@ function estimateTokenReduction(originalChars, finalChars) {
312
306
  return Math.max(0, Math.min(99, Math.round(((originalChars - finalChars) / originalChars) * 100)))
313
307
  }
314
308
 
309
+ function nextSessionStateVersion(session) {
310
+ const current = Number.isFinite(session?.stateVersion) ? session.stateVersion : 0
311
+ session.stateVersion = current + 1
312
+ return session.stateVersion
313
+ }
314
+
315
315
  function emitSessionEvent(session, event) {
316
+ const stateVersion = nextSessionStateVersion(session)
316
317
  const enrichedEvent = (event?.type === 'message_end' || event?.type === 'agent_end' || event?.type === 'messages_replaced' || event?.type === 'auto_compact_completed')
317
318
  && event.contextUsage === undefined
318
- ? { ...event, contextUsage: getSessionContextUsage(session) }
319
- : event
319
+ ? { ...event, contextUsage: getSessionContextUsage(session), stateVersion }
320
+ : { ...event, stateVersion }
320
321
  session.eventBus.emit('agent_event', enrichedEvent)
321
322
  agentEvents.emit('agent_event', { sessionId: session.sessionId, ...enrichedEvent })
322
323
  }
@@ -543,9 +544,59 @@ async function clearSession(session) {
543
544
  return { sessionId: session.sessionId, status: session.status, cleared: true }
544
545
  }
545
546
 
546
- async function resolveCommandState(session, userMessage) {
547
+ const QUICKFORGE_COMMAND_DETAILS_KEY = 'quickforgeCommand'
548
+
549
+ function normalizedPromptCommand(command) {
550
+ return command?.type === 'plan' ? { type: 'plan' } : null
551
+ }
552
+
553
+ function objectDetails(message) {
554
+ const details = message?.details
555
+ return details && typeof details === 'object' && !Array.isArray(details) ? details : {}
556
+ }
557
+
558
+ function promptCommandFromMessage(message) {
559
+ return normalizedPromptCommand(objectDetails(message)[QUICKFORGE_COMMAND_DETAILS_KEY])
560
+ }
561
+
562
+ function messageWithPromptCommand(message, command) {
563
+ const normalized = normalizedPromptCommand(command)
564
+ if (!normalized || !message || typeof message !== 'object') return message
565
+ return {
566
+ ...message,
567
+ details: {
568
+ ...objectDetails(message),
569
+ [QUICKFORGE_COMMAND_DETAILS_KEY]: normalized,
570
+ },
571
+ }
572
+ }
573
+
574
+ function internalInvocationForPromptCommand(userMessage, command) {
575
+ const normalized = normalizedPromptCommand(command)
576
+ if (normalized?.type === 'plan') {
577
+ // Derive the task from the message text. Strip a leading "/plan" so that
578
+ // toggling plan mode while typing "/plan <task>" yields the clean task —
579
+ // matching the slash-command parse path and avoiding a redundant prefix.
580
+ const raw = messageText(userMessage).trim()
581
+ const planPrefix = raw.match(/^\/plan(?:\s+([\s\S]*))?$/i)
582
+ return { type: 'plan', args: planPrefix ? (planPrefix[1] || '').trim() : raw }
583
+ }
584
+ return parseInternalCommandInvocation(userMessage)
585
+ }
586
+
587
+ function planCommandState(userMessage, args) {
588
+ return {
589
+ userMessage: messageWithPromptCommand(userMessage, { type: 'plan' }),
590
+ commandPrompt: formatPlanCommandPrompt(args),
591
+ permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
592
+ commandName: 'plan',
593
+ }
594
+ }
595
+
596
+ async function resolveCommandState(session, userMessage, promptCommand = null) {
597
+ const command = normalizedPromptCommand(promptCommand) || promptCommandFromMessage(userMessage)
547
598
  const internalResponse = await handleInternalCommand(
548
- parseInternalCommandInvocation(userMessage),
599
+ internalInvocationForPromptCommand(userMessage, command),
549
600
  session.projectContext?.workspaceRoot,
550
601
  session.projectContext?.project?.commandDir,
551
602
  )
@@ -553,12 +604,7 @@ async function resolveCommandState(session, userMessage) {
553
604
  if (internalResponse?.clear) return { clear: internalResponse }
554
605
  if (internalResponse?.compact) return { compact: internalResponse }
555
606
  if (internalResponse?.plan) {
556
- return {
557
- userMessage,
558
- commandPrompt: formatPlanCommandPrompt(internalResponse.args),
559
- permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
560
- commandName: 'plan',
561
- }
607
+ return planCommandState(userMessage, internalResponse.args)
562
608
  }
563
609
  if (internalResponse?.review) {
564
610
  return {
@@ -569,7 +615,22 @@ async function resolveCommandState(session, userMessage) {
569
615
  }
570
616
  }
571
617
 
572
- if (!session.projectContext?.workspaceRoot) return { userMessage }
618
+ if (!session.projectContext?.workspaceRoot) {
619
+ // Even without a project, user-level custom commands (~/.quickforge/commands/) are available
620
+ const invocation = await resolveCustomCommandInvocation(
621
+ userMessage,
622
+ null,
623
+ session.projectContext?.project?.commandDir,
624
+ )
625
+ if (!invocation) return { userMessage }
626
+
627
+ return {
628
+ userMessage,
629
+ commandPrompt: invocation.systemPrompt,
630
+ permissions: invocation.permissions,
631
+ commandName: invocation.command.name,
632
+ }
633
+ }
573
634
 
574
635
  const invocation = await resolveCustomCommandInvocation(
575
636
  userMessage,
@@ -1138,6 +1199,7 @@ export async function createAgent(sessionId, config = {}) {
1138
1199
  activeCommandPrompt: null,
1139
1200
  eventBus,
1140
1201
  idleTimer: null,
1202
+ persistTimer: null,
1141
1203
  titleGenerated: false,
1142
1204
  toolTimings: new Map(),
1143
1205
  getApiKey,
@@ -1145,6 +1207,7 @@ export async function createAgent(sessionId, config = {}) {
1145
1207
  agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
1146
1208
  lastTransformedContextMessages: null,
1147
1209
  autoCompacting: false,
1210
+ stateVersion: 0,
1148
1211
  lastAutoCompactAt: null,
1149
1212
  lastAutoCompactRejected: null,
1150
1213
  /** Track active SSE connections. Only one SSE stream allowed per session to prevent
@@ -1159,8 +1222,20 @@ export async function createAgent(sessionId, config = {}) {
1159
1222
  // complete session history. Replace with the authoritative full state
1160
1223
  // before forwarding to clients.
1161
1224
  const timedEvent = addToolTimingToEvent(session, event)
1162
- const forwardEvent = timedEvent.type === 'agent_end' && timedEvent.messages
1163
- ? { ...timedEvent, messages: agent.state.messages }
1225
+ const eventEndStatus = event.type === 'agent_end'
1226
+ ? session.agent.signal?.aborted
1227
+ ? 'aborted'
1228
+ : session.agent.state.errorMessage
1229
+ ? 'error'
1230
+ : 'idle'
1231
+ : undefined
1232
+ const forwardEvent = timedEvent.type === 'agent_end'
1233
+ ? {
1234
+ ...timedEvent,
1235
+ ...(timedEvent.messages ? { messages: agent.state.messages } : {}),
1236
+ status: eventEndStatus,
1237
+ ...(session.agent.state.errorMessage && timedEvent.errorMessage === undefined ? { errorMessage: session.agent.state.errorMessage } : {}),
1238
+ }
1164
1239
  : timedEvent
1165
1240
 
1166
1241
  // Forward all events to the session event bus and the global bus.
@@ -1178,22 +1253,21 @@ export async function createAgent(sessionId, config = {}) {
1178
1253
  }
1179
1254
 
1180
1255
  if (event.type === 'agent_end') {
1181
- session.status = session.agent.state.errorMessage ? 'error' : 'idle'
1256
+ session.status = eventEndStatus || (session.agent.state.errorMessage ? 'error' : 'idle')
1182
1257
  session.finishedAt = new Date().toISOString()
1183
1258
  session.toolTimings?.clear()
1184
1259
  resetIdleTimer(session)
1185
1260
 
1186
- // Persist after run ends
1187
- persistSession(session).catch((err) =>
1261
+ // Persist after run ends. Flush any debounced write so the final state is durable.
1262
+ flushSessionPersist(session).catch((err) =>
1188
1263
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
1189
1264
  )
1190
1265
  }
1191
1266
 
1192
1267
  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
- )
1268
+ // Debounced persist for crash recovery; coalesces the many message_end
1269
+ // events within a single run into infrequent full-session writes.
1270
+ scheduleSessionPersist(session)
1197
1271
  }
1198
1272
  })
1199
1273
 
@@ -1337,6 +1411,44 @@ async function persistSession(session) {
1337
1411
  }
1338
1412
 
1339
1413
  export async function persistSessionState(session) {
1414
+ await flushSessionPersist(session)
1415
+ }
1416
+
1417
+ /**
1418
+ * Coalesce fire-and-forget session persists during a run.
1419
+ *
1420
+ * persistSession() serializes the ENTIRE session (all messages) on every call,
1421
+ * and the agent event loop calls it on agent_start / each message_end / agent_end.
1422
+ * Within a single run these events fire many times (one per assistant turn +
1423
+ * tool result), so writing on each one makes cumulative disk I/O O(n^2) as a
1424
+ * conversation grows. These message_end call sites are fire-and-forget
1425
+ * (crash-recovery only), so we debounce them into at most one write per
1426
+ * PERSIST_DEBOUNCE_MS. Run boundaries (agent_end) and explicit persists cancel
1427
+ * the pending timer and write the current state immediately, so the final
1428
+ * state is always durable.
1429
+ */
1430
+ const PERSIST_DEBOUNCE_MS = 400
1431
+
1432
+ function scheduleSessionPersist(session) {
1433
+ if (session.persistTimer) return
1434
+ session.persistTimer = setTimeout(() => {
1435
+ session.persistTimer = null
1436
+ persistSession(session).catch((err) =>
1437
+ logger.error(`Failed to persist session ${session.sessionId}:`, err, { sessionId: session.sessionId }),
1438
+ )
1439
+ }, PERSIST_DEBOUNCE_MS).unref?.()
1440
+ }
1441
+
1442
+ /**
1443
+ * Cancel any pending debounced write and persist the current state immediately.
1444
+ * Used at run boundaries (agent_end) and by explicit persistSessionState() so
1445
+ * the final state is always durable regardless of a pending timer.
1446
+ */
1447
+ async function flushSessionPersist(session) {
1448
+ if (session.persistTimer) {
1449
+ clearTimeout(session.persistTimer)
1450
+ session.persistTimer = null
1451
+ }
1340
1452
  await persistSession(session)
1341
1453
  }
1342
1454
 
@@ -1399,7 +1511,7 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
1399
1511
  * Send a user message to the agent and start the agent loop.
1400
1512
  * Returns immediately; events are streamed via the event bus.
1401
1513
  */
1402
- export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1514
+ export async function runPrompt(sessionId, message, selectedCapabilities = [], promptCommand = null) {
1403
1515
  let session = agentSessions.get(sessionId)
1404
1516
  if (!session) {
1405
1517
  session = await restoreAgent(sessionId)
@@ -1418,7 +1530,7 @@ export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1418
1530
  const initialUserMessage = typeof message === 'string'
1419
1531
  ? { role: 'user', content: message, timestamp: new Date().toISOString() }
1420
1532
  : message
1421
- const commandState = await resolveCommandState(session, initialUserMessage)
1533
+ const commandState = await resolveCommandState(session, initialUserMessage, promptCommand)
1422
1534
  const userMessage = commandState.userMessage ?? initialUserMessage
1423
1535
 
1424
1536
  if (commandState.textResponse) {
@@ -1516,14 +1628,27 @@ export async function continueSession(sessionId) {
1516
1628
  throw Object.assign(new Error('Cannot continue: no user message found.'), { statusCode: 400 })
1517
1629
  }
1518
1630
 
1519
- const trimmedMessages = messages.slice(0, lastUserIndex + 1)
1631
+ const lastUserMessage = messages[lastUserIndex]
1632
+ const commandState = await resolveCommandState(session, lastUserMessage)
1633
+ const continuedUserMessage = commandState.userMessage ?? lastUserMessage
1634
+ const trimmedMessages = messages.slice(0, lastUserIndex).concat(continuedUserMessage)
1520
1635
  updateSessionMessages(session, trimmedMessages)
1521
1636
  resetSessionCompaction(session)
1522
1637
 
1523
1638
  resetIdleTimer(session)
1639
+ session.activeCommandName = commandState.commandName ?? null
1640
+ session.activeCommandPermissions = commandState.permissions ?? null
1641
+ session.activeCommandPrompt = commandState.commandPrompt ?? null
1642
+ session.activeCapabilityPrompt = null
1643
+
1524
1644
  session.agent.continue().catch((err) => {
1525
1645
  logger.error(`Agent continue error for session ${sessionId}:`, err, { sessionId })
1526
1646
  emitSessionEvent(session, { type: 'error', error: err.message || 'Unknown error' })
1647
+ }).finally(() => {
1648
+ session.activeCommandName = null
1649
+ session.activeCommandPermissions = null
1650
+ session.activeCommandPrompt = null
1651
+ session.activeCapabilityPrompt = null
1527
1652
  })
1528
1653
 
1529
1654
  return { sessionId, status: 'running' }
@@ -1562,6 +1687,7 @@ export async function abortRun(sessionId) {
1562
1687
  )
1563
1688
  const event = {
1564
1689
  type: 'agent_end',
1690
+ status: 'aborted',
1565
1691
  messages: session.agent.state.messages,
1566
1692
  }
1567
1693
  emitSessionEvent(session, event)
@@ -1631,6 +1757,7 @@ export function getSessionState(sessionId) {
1631
1757
  title: session.title,
1632
1758
  createdAt: session.createdAt,
1633
1759
  lastModified: session.lastModified,
1760
+ stateVersion: session.stateVersion || 0,
1634
1761
  status: session.status,
1635
1762
  startedAt: session.startedAt,
1636
1763
  finishedAt: session.finishedAt,
@@ -1643,6 +1770,33 @@ export function getSessionState(sessionId) {
1643
1770
  }
1644
1771
  }
1645
1772
 
1773
+ /**
1774
+ * Get a lightweight status snapshot for SSE-first state recovery.
1775
+ */
1776
+ export function getSessionStatus(sessionId) {
1777
+ const session = agentSessions.get(sessionId)
1778
+ if (!session) return null
1779
+
1780
+ const messages = session.agent.state.messages || []
1781
+ const lastMessage = messages[messages.length - 1]
1782
+ return {
1783
+ sessionId: session.sessionId,
1784
+ scope: session.scope,
1785
+ projectId: session.projectId,
1786
+ title: session.title,
1787
+ createdAt: session.createdAt,
1788
+ lastModified: session.lastModified,
1789
+ stateVersion: session.stateVersion || 0,
1790
+ status: session.status,
1791
+ startedAt: session.startedAt,
1792
+ finishedAt: session.finishedAt,
1793
+ isStreaming: session.agent.state.isStreaming,
1794
+ errorMessage: session.agent.state.errorMessage,
1795
+ messageCount: messages.length,
1796
+ lastMessageTimestamp: lastMessage?.timestamp ?? null,
1797
+ }
1798
+ }
1799
+
1646
1800
  /**
1647
1801
  * Try to claim the SSE slot for a session. Returns true if acquired, false if
1648
1802
  * another tab already holds the SSE connection for this session.
@@ -1689,6 +1843,10 @@ export async function destroyAgent(sessionId) {
1689
1843
  logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
1690
1844
 
1691
1845
  if (session.idleTimer) clearTimeout(session.idleTimer)
1846
+ if (session.persistTimer) {
1847
+ clearTimeout(session.persistTimer)
1848
+ session.persistTimer = null
1849
+ }
1692
1850
  session.toolTimings?.clear()
1693
1851
 
1694
1852
  try {
@@ -24,6 +24,14 @@ export const commandRestrictedTools = new Set([
24
24
  'run_subagent',
25
25
  ])
26
26
 
27
+ export const planAllowedTools = new Set([
28
+ 'read_file',
29
+ 'grep_files',
30
+ 'activate_skill',
31
+ 'read_skill_resource',
32
+ 'run_subagent',
33
+ ])
34
+
27
35
  export const safeReadTools = new Set([
28
36
  'read_file',
29
37
  'grep_files',
@@ -45,7 +53,11 @@ export const pendingAutoCompactApprovals = new Map()
45
53
 
46
54
  export function commandToolPermissionError(session, toolName) {
47
55
  const permissions = session?.activeCommandPermissions
48
- if (!permissions || !commandRestrictedTools.has(toolName)) return null
56
+ if (!permissions) return null
57
+ if (session?.activeCommandName === 'plan' && !planAllowedTools.has(toolName)) {
58
+ return `Command /plan is read-only and cannot use ${toolName}.`
59
+ }
60
+ if (!commandRestrictedTools.has(toolName)) return null
49
61
  if (toolName === 'run_command' && permissions.allowCommands === false) {
50
62
  return `Command /${session.activeCommandName} does not allow running shell commands.`
51
63
  }
@@ -1,5 +1,6 @@
1
1
  import { readStore } from './storage.mjs'
2
2
  import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
3
+ import { estimateContextUsage, shouldCompactContextByPercent } from './context-usage.mjs'
3
4
 
4
5
  export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
5
6
 
@@ -44,14 +45,6 @@ function safeJson(value) {
44
45
  }
45
46
  }
46
47
 
47
- function estimateTextTokens(value) {
48
- const text = String(value || '')
49
- if (!text) return 0
50
- const cjkChars = text.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0
51
- const otherChars = Math.max(0, text.length - cjkChars)
52
- return Math.ceil(cjkChars + otherChars / 3.5)
53
- }
54
-
55
48
  function contentToText(content) {
56
49
  if (typeof content === 'string') return content
57
50
  if (!Array.isArray(content)) return ''
@@ -65,19 +58,6 @@ function contentToText(content) {
65
58
  }).filter(Boolean).join('\n')
66
59
  }
67
60
 
68
- function estimateMessageTokens(message) {
69
- if (!message || typeof message !== 'object') return 0
70
- const parts = [message.role || '', contentToText(message.content)]
71
- if (message.toolName) parts.push(message.toolName)
72
- if (message.toolCallId) parts.push(message.toolCallId)
73
- if (message.attachments !== undefined) parts.push(safeJson(message.attachments))
74
- return estimateTextTokens(parts.join('\n'))
75
- }
76
-
77
- function estimateMessagesTokens(messages) {
78
- return (Array.isArray(messages) ? messages : []).reduce((total, message) => total + estimateMessageTokens(message), 0)
79
- }
80
-
81
61
  function estimateMessagesChars(messages) {
82
62
  return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
83
63
  if (!message || typeof message !== 'object') return total
@@ -85,50 +65,6 @@ function estimateMessagesChars(messages) {
85
65
  }, 0)
86
66
  }
87
67
 
88
- function messageTimestampMs(message) {
89
- const timestamp = message?.timestamp
90
- if (typeof timestamp === 'number') return timestamp
91
- if (typeof timestamp === 'string') {
92
- const parsed = Date.parse(timestamp)
93
- return Number.isNaN(parsed) ? 0 : parsed
94
- }
95
- return 0
96
- }
97
-
98
- function latestCompactTimestampMs(session) {
99
- return messageTimestampMs(session?.contextCompaction?.summaryMessage)
100
- }
101
-
102
- function latestKnownInputTokens(messages, sinceTimestamp = 0) {
103
- let latestTimestamp = -1
104
- let latestInput = 0
105
- for (const message of Array.isArray(messages) ? messages : []) {
106
- if (message?.role !== 'assistant' || !message.usage) continue
107
- const timestamp = messageTimestampMs(message)
108
- if (sinceTimestamp > 0 && timestamp <= sinceTimestamp) continue
109
- if (timestamp < latestTimestamp) continue
110
- const input = Math.max(0, Number(message.usage.input ?? message.usage.totalTokens) || 0)
111
- if (input <= 0) continue
112
- latestTimestamp = timestamp
113
- latestInput = input
114
- }
115
- return latestInput
116
- }
117
-
118
- export function estimateContextUsage({ systemPrompt, messages, tools, model, knownInputTokens = 0 }) {
119
- const contextWindow = Number(model?.contextWindow) || 0
120
- const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
121
- const estimatedInputTokens =
122
- estimateTextTokens(systemPrompt) +
123
- estimateMessagesTokens(messages) +
124
- estimateTextTokens(safeJson(tools))
125
- const knownInput = Math.max(0, Number(knownInputTokens) || 0)
126
- const inputTokens = Math.max(estimatedInputTokens, knownInput)
127
- const totalTokens = inputTokens + reservedOutputTokens
128
- const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
129
- return { inputTokens, estimatedInputTokens, knownInputTokens: knownInput, reservedOutputTokens, totalTokens, contextWindow, percent }
130
- }
131
-
132
68
  function isUserMessage(message) {
133
69
  return message?.role === 'user' || message?.role === 'user-with-attachments'
134
70
  }
@@ -220,17 +156,74 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
220
156
  const sourceMessages = Array.isArray(messages) ? messages : []
221
157
  const contextWindow = Number(session.model?.contextWindow) || 0
222
158
  if (sourceMessages.length === 0) {
223
- return { inputTokens: 0, estimatedInputTokens: 0, knownInputTokens: 0, reservedOutputTokens: 0, totalTokens: 0, contextWindow, percent: 0 }
159
+ return {
160
+ inputTokens: 0,
161
+ estimatedInputTokens: 0,
162
+ knownInputTokens: 0,
163
+ inputTokenSource: 'estimated',
164
+ reservedOutputTokens: 0,
165
+ totalTokens: 0,
166
+ contextWindow,
167
+ percent: 0,
168
+ isCompacted: false,
169
+ originalMessageCount: 0,
170
+ effectiveMessageCount: 0,
171
+ breakdown: {
172
+ systemPromptTokens: 0,
173
+ messagesTokens: 0,
174
+ toolsTokens: 0,
175
+ reservedOutputTokens: 0,
176
+ },
177
+ }
224
178
  }
179
+
180
+ // Cache by input identity. Context usage delegates message token estimation
181
+ // to pi-agent-core and JSON-stringifies the full tools array, but its
182
+ // inputs (messages, model, systemPrompt, tools, contextCompaction) are stable
183
+ // within a run and only change on discrete events (message_end, tool result,
184
+ // compaction). Reference equality makes the cache check essentially free, so
185
+ // the repeated calls from emitSessionEvent() on message_end/agent_end/etc.
186
+ // only recompute when something actually changed.
187
+ const lastMessage = sourceMessages[sourceMessages.length - 1]
188
+ const cacheKey = {
189
+ messages,
190
+ messagesLength: sourceMessages.length,
191
+ lastMessage,
192
+ model: session.model,
193
+ systemPrompt: session.agent.state.systemPrompt,
194
+ tools: session.agent.state.tools,
195
+ contextCompaction: session.contextCompaction,
196
+ }
197
+ const cached = session._contextUsageCache
198
+ if (
199
+ cached &&
200
+ cached.key.messages === cacheKey.messages &&
201
+ cached.key.messagesLength === cacheKey.messagesLength &&
202
+ cached.key.lastMessage === cacheKey.lastMessage &&
203
+ cached.key.model === cacheKey.model &&
204
+ cached.key.systemPrompt === cacheKey.systemPrompt &&
205
+ cached.key.tools === cacheKey.tools &&
206
+ cached.key.contextCompaction === cacheKey.contextCompaction
207
+ ) {
208
+ return cached.value
209
+ }
210
+
225
211
  const loopMessages = buildAutoCompactLoopMessages(session, sourceMessages)
226
- const knownInputTokens = latestKnownInputTokens(sourceMessages, latestCompactTimestampMs(session))
227
- return estimateContextUsage({
212
+ const value = estimateContextUsage({
228
213
  systemPrompt: session.agent.state.systemPrompt,
229
214
  messages: loopMessages,
230
215
  tools: session.agent.state.tools,
231
216
  model: session.model,
232
- knownInputTokens,
233
217
  })
218
+ value.isCompacted = loopMessages !== sourceMessages
219
+ value.originalMessageCount = sourceMessages.length
220
+ value.effectiveMessageCount = loopMessages.length
221
+ if (session.contextCompaction?.summaryMessage) {
222
+ value.compactedUpToIndex = Math.min(sourceMessages.length, Math.max(0, Number(session.contextCompaction.compactedUpToIndex) || 0))
223
+ }
224
+
225
+ session._contextUsageCache = { key: cacheKey, value }
226
+ return value
234
227
  }
235
228
 
236
229
  export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
@@ -240,16 +233,14 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
240
233
  if (signal?.aborted) return { compacted: false, reason: 'aborted' }
241
234
 
242
235
  const loopMessages = buildAutoCompactLoopMessages(session, messages)
243
- const knownInputTokens = latestKnownInputTokens(messages, latestCompactTimestampMs(session))
244
236
  const usage = estimateContextUsage({
245
237
  systemPrompt: session.agent.state.systemPrompt,
246
238
  messages: loopMessages,
247
239
  tools: session.agent.state.tools,
248
240
  model: session.model,
249
- knownInputTokens,
250
241
  })
251
242
  if (!usage.contextWindow) return { compacted: false, usage, reason: 'missing_context_window' }
252
- if (usage.percent < settings.thresholdPercent) return { compacted: false, usage, reason: 'below_threshold' }
243
+ if (!shouldCompactContextByPercent(usage, settings.thresholdPercent)) return { compacted: false, usage, reason: 'below_threshold' }
253
244
  if (shouldSuppressAfterRejection(session, messages, usage)) return { compacted: false, usage, reason: 'user_rejected_recently' }
254
245
 
255
246
  const now = Date.now()