@shawnstack/quickforge 1.3.23 → 1.3.25

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 (54) hide show
  1. package/README.md +15 -15
  2. package/dist/assets/anthropic-B1_Yrokl.js +39 -0
  3. package/dist/assets/azure-openai-responses-UMiOBCBd.js +1 -0
  4. package/dist/assets/google-BLE_Gcd1.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-6_sIZLVc.js +1 -0
  7. package/dist/assets/{icons-WD3UkVNM.js → icons-Bs7OG8yi.js} +1 -1
  8. package/dist/assets/{index-CjTN0qaQ.js → index-C3bc5C3k.js} +576 -561
  9. package/dist/assets/index-C7oT9Rdw.css +3 -0
  10. package/dist/assets/{mistral-Ber29mja.js → mistral-DmZEmRkv.js} +1 -1
  11. package/dist/assets/openai-codex-responses-i_SmQGzQ.js +7 -0
  12. package/dist/assets/openai-completions-BmmZFDDY.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-C8tPdeE9.js +1 -0
  15. package/dist/assets/{openai-responses-shared-a_PAPxTO.js → openai-responses-shared-DchtjQNp.js} +1 -1
  16. package/dist/assets/openrouter-CcTv1G_v.js +1 -0
  17. package/dist/assets/react-vendor-Cu-7p9CI.js +61 -0
  18. package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
  19. package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
  20. package/dist/index.html +4 -4
  21. package/package.json +6 -3
  22. package/server/agent-manager.mjs +144 -151
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +8 -0
  26. package/server/index.mjs +1 -1
  27. package/server/message-converters.mjs +79 -0
  28. package/server/project-config.mjs +7 -9
  29. package/server/routes/agent-profiles.mjs +1 -1
  30. package/server/routes/agent.mjs +15 -1
  31. package/server/routes/filesystem.mjs +18 -2
  32. package/server/routes/project.mjs +33 -1
  33. package/server/routes/scheduled-tasks.mjs +1 -1
  34. package/server/routes/storage.mjs +66 -31
  35. package/server/routes/terminal.mjs +28 -3
  36. package/server/routes/workspace.mjs +43 -1
  37. package/server/session-utils.mjs +1 -1
  38. package/server/storage.mjs +78 -2
  39. package/server/terminal/terminal-manager.mjs +12 -0
  40. package/server/tool-wiring.mjs +87 -0
  41. package/server/utils/workspace.mjs +20 -1
  42. package/dist/assets/anthropic-CDKnv1FQ.js +0 -39
  43. package/dist/assets/azure-openai-responses-BnUwVl-8.js +0 -1
  44. package/dist/assets/google-DOEyCDZy.js +0 -1
  45. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  46. package/dist/assets/google-vertex-BPPf3car.js +0 -1
  47. package/dist/assets/index-eeLjaV06.css +0 -3
  48. package/dist/assets/openai-codex-responses-D8gq8a3l.js +0 -7
  49. package/dist/assets/openai-completions-CATWPFBp.js +0 -5
  50. package/dist/assets/openai-responses-DxcB6Ksu.js +0 -1
  51. package/dist/assets/react-vendor-BcQaTQ90.js +0 -9
  52. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  53. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  54. /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
@@ -0,0 +1 @@
1
+ function e(e){return e.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,``)}export{e as t};
@@ -0,0 +1 @@
1
+ function e(e,t,n){return{temperature:t?.temperature,maxTokens:t?.maxTokens,signal:t?.signal,apiKey:n||t?.apiKey,transport:t?.transport,cacheRetention:t?.cacheRetention,sessionId:t?.sessionId,headers:t?.headers,onPayload:t?.onPayload,onResponse:t?.onResponse,timeoutMs:t?.timeoutMs,maxRetries:t?.maxRetries,maxRetryDelayMs:t?.maxRetryDelayMs,metadata:t?.metadata}}function t(e){return e===`xhigh`?`high`:e}function n(e,n,r,i){let a={minimal:1024,low:2048,medium:8192,high:16384,...i}[t(r)],o=e===void 0?n:Math.min(e+a,n);return o<=a&&(a=Math.max(0,o-1024)),{maxTokens:o,thinkingBudget:a}}var r=`(image omitted: model does not support images)`,i=`(tool image omitted: model does not support images)`;function a(e,t){let n=[],r=!1;for(let i of e){if(i.type===`image`){r||n.push({type:`text`,text:t}),r=!0;continue}n.push(i),r=i.text===t}return n}function o(e,t){return t.input.includes(`image`)?e:e.map(e=>e.role===`user`&&Array.isArray(e.content)?{...e,content:a(e.content,r)}:e.role===`toolResult`?{...e,content:a(e.content,i)}:e)}function s(e,t,n){let r=new Map,i=o(e,t).map(e=>{if(e.role===`user`)return e;if(e.role===`toolResult`){let t=r.get(e.toolCallId);return t&&t!==e.toolCallId?{...e,toolCallId:t}:e}if(e.role===`assistant`){let i=e,a=i.provider===t.provider&&i.api===t.api&&i.model===t.id,o=i.content.flatMap(e=>{if(e.type===`thinking`)return e.redacted?a?e:[]:a&&e.thinkingSignature?e:!e.thinking||e.thinking.trim()===``?[]:a?e:{type:`text`,text:e.thinking};if(e.type===`text`)return a?e:{type:`text`,text:e.text};if(e.type===`toolCall`){let o=e,s=o;if(!a&&o.thoughtSignature&&(s={...o},delete s.thoughtSignature),!a&&n){let e=n(o.id,t,i);e!==o.id&&(r.set(o.id,e),s={...s,id:e})}return s}return e});return{...i,content:o}}return e}),a=[],s=[],c=new Set,l=()=>{if(s.length>0){for(let e of s)c.has(e.id)||a.push({role:`toolResult`,toolCallId:e.id,toolName:e.name,content:[{type:`text`,text:`No result provided`}],isError:!0,timestamp:Date.now()});s=[],c=new Set}};for(let e=0;e<i.length;e++){let t=i[e];if(t.role===`assistant`){l();let e=t;if(e.stopReason===`error`||e.stopReason===`aborted`)continue;let n=e.content.filter(e=>e.type===`toolCall`);n.length>0&&(s=n,c=new Set),a.push(t)}else t.role===`toolResult`?(c.add(t.toolCallId),a.push(t)):(t.role===`user`&&l(),a.push(t))}return l(),a}export{n,e as r,s as t};
package/dist/index.html CHANGED
@@ -11,13 +11,13 @@
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-CjTN0qaQ.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-C3bc5C3k.js"></script>
15
15
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CkqCuyE9.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/lit-vendor-Dr3cpBGF.js">
17
17
  <link rel="modulepreload" crossorigin href="/assets/css-utils-rkE68RDy.js">
18
- <link rel="modulepreload" crossorigin href="/assets/icons-WD3UkVNM.js">
19
- <link rel="modulepreload" crossorigin href="/assets/react-vendor-BcQaTQ90.js">
20
- <link rel="stylesheet" crossorigin href="/assets/index-eeLjaV06.css">
18
+ <link rel="modulepreload" crossorigin href="/assets/icons-Bs7OG8yi.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/react-vendor-Cu-7p9CI.js">
20
+ <link rel="stylesheet" crossorigin href="/assets/index-C7oT9Rdw.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.3.23",
3
+ "version": "1.3.25",
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",
@@ -42,8 +42,11 @@
42
42
  "package.json"
43
43
  ],
44
44
  "dependencies": {
45
- "@mariozechner/pi-agent-core": "^0.73.1",
46
- "@mariozechner/pi-ai": "^0.73.1",
45
+ "@dnd-kit/core": "^6.3.1",
46
+ "@dnd-kit/sortable": "^10.0.0",
47
+ "@dnd-kit/utilities": "^3.2.2",
48
+ "@earendil-works/pi-agent-core": "^0.75.3",
49
+ "@earendil-works/pi-ai": "^0.75.3",
47
50
  "@modelcontextprotocol/sdk": "^1.29.0",
48
51
  "ws": "^8.20.1"
49
52
  },
@@ -1,17 +1,17 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { Agent } from '@mariozechner/pi-agent-core'
3
+ import { Agent } from '@earendil-works/pi-agent-core'
4
4
  import { streamSimpleWithAiHttpLogging } from './ai-http-logger.mjs'
5
- import { toolHandlers, loadSkillToolContext, abortRunningCommand } from './tools/index.mjs'
5
+ import { loadSkillToolContext, abortRunningCommand } from './tools/index.mjs'
6
6
  import { createSkillTools, workspaceTools } from './tools/definitions.mjs'
7
- import { callMcpTool, createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
7
+ import { createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
8
8
  import {
9
9
  composeSubagentSystemPrompt,
10
10
  formatSubagentTask,
11
11
  } from './subagents.mjs'
12
12
  import { agentProfileSnapshot, getAgentProfile } from './agent-profiles.mjs'
13
13
  import { projectContextFromId, readProjectConfig } from './project-config.mjs'
14
- import { readStore, atomicUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
14
+ import { readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
15
15
  import { logger } from './utils/logger.mjs'
16
16
  import { buildSystemPrompt, generateAiTitle, generateTitle } from './session-utils.mjs'
17
17
  import { restoreReasoningContentInPayload } from './reasoning-cache.mjs'
@@ -30,70 +30,22 @@ import {
30
30
  parseInternalCommandInvocation,
31
31
  resolveCustomCommandInvocation,
32
32
  } from './custom-commands.mjs'
33
+ import { omitDetailsForLlm, serverConvertToLlm, messageText, lastAssistantText } from './message-converters.mjs'
34
+ import { isPlainObject, mergeQuickForgeTiming, wrapToolDefinition, wrapMcpToolDefinition, sessionSkillsContext } from './tool-wiring.mjs'
35
+ import {
36
+ APPROVAL_TIMEOUT_MS,
37
+ commandRestrictedTools,
38
+ safeReadTools,
39
+ pendingApprovals,
40
+ pendingAutoCompactApprovals,
41
+ commandToolPermissionError,
42
+ createCommandToolPermissions,
43
+ } from './approval-store.mjs'
33
44
 
34
45
  // ---------------------------------------------------------------------------
35
46
  // Tool definitions (server-side, no REST roundtrip)
36
47
  // ---------------------------------------------------------------------------
37
48
 
38
- function isPlainObject(value) {
39
- return Boolean(value && typeof value === 'object' && !Array.isArray(value))
40
- }
41
-
42
- function mergeQuickForgeTiming(details, timing) {
43
- if (!isPlainObject(details)) return { quickforgeTiming: timing }
44
- return { ...details, quickforgeTiming: timing }
45
- }
46
-
47
- function wrapToolDefinition(definition, context, toolPermissions) {
48
- const handler = toolHandlers[definition.name]
49
- if (!handler) throw new Error(`Missing handler for tool: ${definition.name}`)
50
- return {
51
- ...definition,
52
- execute: async (_toolCallId, params, signal, onUpdate) => {
53
- if (toolPermissions) {
54
- const permissionError = toolPermissions(definition.name)
55
- if (permissionError) throw new Error(permissionError)
56
- }
57
-
58
- const startedAt = Date.now()
59
- const startedAtPerf = performance.now()
60
- const result = await handler(params || {}, context, { signal, onUpdate, toolCallId: _toolCallId })
61
- const finishedAt = Date.now()
62
- const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
63
- const details = mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs })
64
- return {
65
- content: [{ type: 'text', text: result.content }],
66
- details: isPlainObject(details) ? { ...details, toolCallId: _toolCallId } : details,
67
- }
68
- },
69
- }
70
- }
71
-
72
- function wrapMcpToolDefinition(definition, toolPermissions) {
73
- return {
74
- ...definition,
75
- execute: async (_toolCallId, params) => {
76
- if (toolPermissions) {
77
- const permissionError = toolPermissions(definition.name)
78
- if (permissionError) throw new Error(permissionError)
79
- }
80
-
81
- const startedAt = Date.now()
82
- const startedAtPerf = performance.now()
83
- const result = await callMcpTool(definition.name, params || {})
84
- const finishedAt = Date.now()
85
- const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
86
- if (result.isError) {
87
- throw new Error(result.content || `MCP tool failed: ${definition.name}`)
88
- }
89
- return {
90
- content: [{ type: 'text', text: result.content }],
91
- details: mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs }),
92
- }
93
- },
94
- }
95
- }
96
-
97
49
  function wrapSubagentToolDefinition(definition, parentSessionId) {
98
50
  return {
99
51
  ...definition,
@@ -154,13 +106,6 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
154
106
  return tools
155
107
  }
156
108
 
157
- function sessionSkillsContext(session) {
158
- return {
159
- globalSkillNames: session.globalSkillNames,
160
- projectSkillNames: session.projectSkillNames,
161
- }
162
- }
163
-
164
109
  async function rebuildSessionTools(session) {
165
110
  session.agent.state.tools = await createServerTools(
166
111
  session.projectId,
@@ -181,26 +126,8 @@ const agentSessions = new Map()
181
126
  /** @typedef {{ agent: Agent, projectContext: object|null, projectId: string|null, yoloMode: boolean, model: object, thinkingLevel: string, scope: string, title: string, createdAt: string, status: string, startedAt: string|null, finishedAt: string|null, listeners: Set<function>, idleTimer: NodeJS.Timeout|null, eventBus: EventEmitter }} AgentSession */
182
127
 
183
128
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
184
- const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
185
129
  const SUBAGENT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
186
- const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command'])
187
- const safeReadTools = new Set(['read_file', 'grep_files'])
188
- const pendingApprovals = new Map() // toolCallId → { resolve, reject, sessionId, toolName, args, source, timeout }
189
- const pendingAutoCompactApprovals = new Map() // approvalId → { resolve, reject, sessionId, timeout }
190
-
191
- function createCommandToolPermissions(session) {
192
- return (toolName) => {
193
- const permissions = session.activeCommandPermissions
194
- if (!permissions || !commandRestrictedTools.has(toolName)) return null
195
- if (toolName === 'run_command' && permissions.allowCommands === false) {
196
- return `Custom command /${session.activeCommandName} does not allow running shell commands.`
197
- }
198
- if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
199
- return `Custom command /${session.activeCommandName} does not allow editing files.`
200
- }
201
- return null
202
- }
203
- }
130
+ const SUBAGENT_TRACE_THROTTLE_MS = 150
204
131
 
205
132
  /**
206
133
  * Create a Promise that only resolves when the user accepts or rejects the tool call.
@@ -219,8 +146,14 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
219
146
  resolve({ block: true, reason: `Approval timeout for ${toolName}` })
220
147
  }, APPROVAL_TIMEOUT_MS)
221
148
 
149
+ let onAbort = null
150
+
222
151
  const cleanup = () => {
223
152
  clearTimeout(timeout)
153
+ if (onAbort) {
154
+ session.agent.signal?.removeEventListener('abort', onAbort)
155
+ onAbort = null
156
+ }
224
157
  if (settled) return
225
158
  settled = true
226
159
  pendingApprovals.delete(toolCallId)
@@ -234,7 +167,7 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
234
167
  reject(new Error('Run aborted'))
235
168
  return
236
169
  }
237
- const onAbort = () => {
170
+ onAbort = () => {
238
171
  cleanup()
239
172
  reject(new Error('Run aborted'))
240
173
  }
@@ -284,8 +217,14 @@ function createAutoCompactApprovalPromise(session, details = {}) {
284
217
  resolve(false)
285
218
  }, APPROVAL_TIMEOUT_MS)
286
219
 
220
+ let onAbort = null
221
+
287
222
  const cleanup = () => {
288
223
  clearTimeout(timeout)
224
+ if (onAbort) {
225
+ session.agent.signal?.removeEventListener('abort', onAbort)
226
+ onAbort = null
227
+ }
289
228
  if (settled) return
290
229
  settled = true
291
230
  pendingAutoCompactApprovals.delete(approvalId)
@@ -298,7 +237,7 @@ function createAutoCompactApprovalPromise(session, details = {}) {
298
237
  reject(new Error('Run aborted'))
299
238
  return
300
239
  }
301
- const onAbort = () => {
240
+ onAbort = () => {
302
241
  cleanup()
303
242
  reject(new Error('Run aborted'))
304
243
  }
@@ -606,6 +545,14 @@ async function resolveCommandState(session, userMessage) {
606
545
  if (typeof internalResponse === 'string') return { textResponse: internalResponse }
607
546
  if (internalResponse?.clear) return { clear: internalResponse }
608
547
  if (internalResponse?.compact) return { compact: internalResponse }
548
+ if (internalResponse?.plan) {
549
+ return {
550
+ userMessage,
551
+ commandPrompt: formatPlanCommandPrompt(internalResponse.args),
552
+ permissions: { allowEdit: false, allowCommands: false },
553
+ commandName: 'plan',
554
+ }
555
+ }
609
556
 
610
557
  if (!session.projectContext?.workspaceRoot) return { userMessage }
611
558
 
@@ -624,65 +571,32 @@ async function resolveCommandState(session, userMessage) {
624
571
  }
625
572
  }
626
573
 
627
- function omitDetailsForLlm(message) {
628
- if (!message || typeof message !== 'object' || message.details === undefined) return message
629
- const copy = { ...message }
630
- delete copy.details
631
- return copy
632
- }
574
+ function formatPlanCommandPrompt(task) {
575
+ const taskText = String(task || '').trim()
576
+ return `<plan_command_invocation name="plan">
577
+ This /plan command applies only to the current user request. Generate an implementation plan before execution.
633
578
 
634
- /**
635
- * Convert AgentMessage[] to LLM-compatible Message[].
636
- * Handles "user-with-attachments" "user" with multi-modal content blocks.
637
- * Without this the default pi-agent-core convertToLlm silently drops
638
- * user-with-attachments messages, so the LLM never sees attachments.
639
- */
640
- function serverConvertToLlm(messages) {
641
- return messages
642
- .filter(m => m.role !== 'artifact')
643
- .map(m => {
644
- if (m.role === 'user-with-attachments') {
645
- const textContent = typeof m.content === 'string'
646
- ? [{ type: 'text', text: m.content }]
647
- : [...m.content]
648
- if (Array.isArray(m.attachments)) {
649
- for (const att of m.attachments) {
650
- if (att.type === 'image' && att.content) {
651
- textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
652
- } else if (att.type === 'document' && att.extractedText) {
653
- textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
654
- }
655
- }
656
- }
657
- return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
658
- }
659
- if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
660
- return null
661
- })
662
- .filter(Boolean)
663
- }
579
+ Rules for this turn:
580
+ - Do not modify files.
581
+ - Do not create files.
582
+ - Do not run shell commands.
583
+ - Do not use write_file, edit_file, run_command, or any other state-changing tool.
584
+ - You may use read-only tools such as read_file and grep_files if needed to inspect the project.
585
+ - Output the plan and then stop. Do not start implementation.
664
586
 
665
- function messageText(message) {
666
- const content = message?.content
667
- if (typeof content === 'string') return content
668
- if (Array.isArray(content)) {
669
- return content
670
- .filter((block) => block?.type === 'text')
671
- .map((block) => block.text ?? '')
672
- .join('\n')
673
- .trim()
674
- }
675
- return ''
676
- }
587
+ Plan should include:
588
+ 1. Task understanding
589
+ 2. Relevant files or areas to inspect/change
590
+ 3. Step-by-step implementation plan
591
+ 4. Risks or assumptions
592
+ 5. Validation commands/checks to run after implementation
593
+ 6. Whether documentation/wiki updates are needed
677
594
 
678
- function lastAssistantText(messages) {
679
- for (let index = messages.length - 1; index >= 0; index--) {
680
- const message = messages[index]
681
- if (message?.role !== 'assistant') continue
682
- const text = messageText(message)
683
- if (text) return text
684
- }
685
- return ''
595
+ End by telling the user they can reply “允许”, “按计划执行”, or an equivalent approval phrase to continue in a normal follow-up turn.
596
+
597
+ User task:
598
+ ${taskText}
599
+ </plan_command_invocation>`
686
600
  }
687
601
 
688
602
  async function runSubagent(parentSession, params, parentSignal, onUpdate) {
@@ -714,6 +628,9 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
714
628
  let latestMessages = []
715
629
  let latestPendingToolCalls = []
716
630
  let toolsForClient = []
631
+ let lastTraceAt = 0
632
+ let tracePending = false
633
+ let traceTimer = null
717
634
 
718
635
  const tools = await createServerTools(
719
636
  parentSession.projectId,
@@ -733,6 +650,12 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
733
650
  toolsForClient = tools.map(({ execute, prepareArguments, ...tool }) => tool)
734
651
 
735
652
  const emitSubagentTrace = () => {
653
+ if (traceTimer) {
654
+ clearTimeout(traceTimer)
655
+ traceTimer = null
656
+ }
657
+ tracePending = false
658
+ lastTraceAt = Date.now()
736
659
  onUpdate?.({
737
660
  content: [],
738
661
  details: {
@@ -751,6 +674,19 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
751
674
  })
752
675
  }
753
676
 
677
+ const emitSubagentTraceThrottled = () => {
678
+ const elapsed = Date.now() - lastTraceAt
679
+ if (elapsed >= SUBAGENT_TRACE_THROTTLE_MS) {
680
+ emitSubagentTrace()
681
+ return
682
+ }
683
+ tracePending = true
684
+ if (traceTimer) return
685
+ traceTimer = setTimeout(() => {
686
+ if (tracePending) emitSubagentTrace()
687
+ }, SUBAGENT_TRACE_THROTTLE_MS - elapsed)
688
+ }
689
+
754
690
  const systemPrompt = composeSubagentSystemPrompt({
755
691
  definition,
756
692
  parentSystemPrompt: parentSession.agent.state.systemPrompt,
@@ -807,7 +743,11 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
807
743
  latestMessages = [...latestMessages, event.message]
808
744
  }
809
745
  }
810
- emitSubagentTrace()
746
+ if (event.type === 'tool_execution_start' || event.type === 'tool_execution_end' || event.type === 'message_end') {
747
+ emitSubagentTrace()
748
+ } else {
749
+ emitSubagentTraceThrottled()
750
+ }
811
751
  })
812
752
 
813
753
  let timedOut = false
@@ -825,6 +765,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
825
765
  } finally {
826
766
  clearTimeout(timeout)
827
767
  parentSignal?.removeEventListener?.('abort', onParentAbort)
768
+ emitSubagentTrace()
828
769
  }
829
770
 
830
771
  const content = lastAssistantText(subagent.state.messages) || `Subagent ${definition.name} completed without a text response.`
@@ -1050,9 +991,11 @@ export async function createAgent(sessionId, config = {}) {
1050
991
  beforeToolCall: async (context) => {
1051
992
  const toolName = context.toolCall?.name
1052
993
  const toolCallId = context.toolCall?.id
994
+ const currentSession = agentSessions.get(sessionId)
995
+ const commandPermissionError = commandToolPermissionError(currentSession, toolName)
996
+ if (commandPermissionError) return { block: true, reason: commandPermissionError }
1053
997
  const isSkillTool = toolName === 'activate_skill' || toolName === 'read_skill_resource'
1054
998
  if (isSkillTool) return undefined
1055
- const currentSession = agentSessions.get(sessionId)
1056
999
  if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
1057
1000
  if (toolName === 'run_subagent') return undefined
1058
1001
  if (isMcpToolName(toolName)) {
@@ -1138,6 +1081,7 @@ export async function createAgent(sessionId, config = {}) {
1138
1081
  if (event.type === 'agent_end') {
1139
1082
  session.status = session.agent.state.errorMessage ? 'error' : 'idle'
1140
1083
  session.finishedAt = new Date().toISOString()
1084
+ session.toolTimings?.clear()
1141
1085
  resetIdleTimer(session)
1142
1086
 
1143
1087
  // Persist after run ends
@@ -1194,7 +1138,7 @@ async function persistSession(session) {
1194
1138
  if (messages.length === 0) {
1195
1139
  try {
1196
1140
  await deleteSessionValue(sessionId)
1197
- await atomicUpdate('sessions-metadata', (data) => {
1141
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
1198
1142
  delete data[sessionId]
1199
1143
  return data
1200
1144
  })
@@ -1281,7 +1225,7 @@ async function persistSession(session) {
1281
1225
  // Write to storage atomically (read-modify-write within queue)
1282
1226
  try {
1283
1227
  await writeSessionValue(sessionId, sessionData)
1284
- await atomicUpdate('sessions-metadata', (data) => {
1228
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
1285
1229
  data[sessionId] = {
1286
1230
  ...metadata,
1287
1231
  pinnedAt: data[sessionId]?.pinnedAt,
@@ -1383,6 +1327,10 @@ export async function runPrompt(sessionId, message) {
1383
1327
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1384
1328
  }
1385
1329
 
1330
+ if (session.agent.state.isStreaming) {
1331
+ throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
1332
+ }
1333
+
1386
1334
  resetIdleTimer(session)
1387
1335
 
1388
1336
  // Build user message
@@ -1454,6 +1402,50 @@ export async function runPrompt(sessionId, message) {
1454
1402
  return { sessionId, status: session.status }
1455
1403
  }
1456
1404
 
1405
+ /**
1406
+ * Continue generation from the current last message (must be a user or
1407
+ * tool-result message). Used by the retry button to regenerate a response
1408
+ * in-place without appending a new user message.
1409
+ *
1410
+ * Trims messages to keep up to and including the last user message,
1411
+ * removing the assistant response that follows it.
1412
+ */
1413
+ export async function continueSession(sessionId) {
1414
+ const session = agentSessions.get(sessionId)
1415
+ if (!session) {
1416
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1417
+ }
1418
+ if (session.agent.state.isStreaming) {
1419
+ throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
1420
+ }
1421
+
1422
+ const messages = Array.isArray(session.agent.state.messages) ? session.agent.state.messages : []
1423
+
1424
+ // Find the last user message and trim everything after it (the assistant response)
1425
+ let lastUserIndex = -1
1426
+ for (let i = messages.length - 1; i >= 0; i--) {
1427
+ if (messages[i].role === 'user' || messages[i].role === 'user-with-attachments') {
1428
+ lastUserIndex = i
1429
+ break
1430
+ }
1431
+ }
1432
+ if (lastUserIndex < 0) {
1433
+ throw Object.assign(new Error('Cannot continue: no user message found.'), { statusCode: 400 })
1434
+ }
1435
+
1436
+ const trimmedMessages = messages.slice(0, lastUserIndex + 1)
1437
+ updateSessionMessages(session, trimmedMessages)
1438
+ resetSessionCompaction(session)
1439
+
1440
+ resetIdleTimer(session)
1441
+ session.agent.continue().catch((err) => {
1442
+ logger.error(`Agent continue error for session ${sessionId}:`, err, { sessionId })
1443
+ emitSessionEvent(session, { type: 'error', error: err.message || 'Unknown error' })
1444
+ })
1445
+
1446
+ return { sessionId, status: 'running' }
1447
+ }
1448
+
1457
1449
  /**
1458
1450
  * Abort the current agent run.
1459
1451
  */
@@ -1614,6 +1606,7 @@ export async function destroyAgent(sessionId) {
1614
1606
  logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
1615
1607
 
1616
1608
  if (session.idleTimer) clearTimeout(session.idleTimer)
1609
+ session.toolTimings?.clear()
1617
1610
 
1618
1611
  try {
1619
1612
  session.agent.abort()
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { AsyncLocalStorage } from 'node:async_hooks'
4
4
  import { randomUUID } from 'node:crypto'
5
- import { streamSimple } from '@mariozechner/pi-ai'
5
+ import { streamSimple } from '@earendil-works/pi-ai'
6
6
  import { logsDir } from './storage.mjs'
7
7
 
8
8
  const PATCH_MARKER = Symbol.for('quickforge.aiHttpLogger.fetchPatched')
@@ -16,16 +16,31 @@ function currentLogFile() {
16
16
  return path.join(logsDir, `ai-http-${date}.jsonl`)
17
17
  }
18
18
 
19
- function writeAiHttpRecord(record) {
20
- if (!aiHttpLogEnabled) return
19
+ let logsDirEnsured = false
20
+
21
+ async function ensureLogsDir() {
22
+ if (logsDirEnsured) return
21
23
  try {
22
- fs.mkdirSync(logsDir, { recursive: true })
23
- fs.appendFile(currentLogFile(), `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`, () => {})
24
+ const { promises: fsp } = await import('node:fs')
25
+ await fsp.mkdir(logsDir, { recursive: true })
26
+ logsDirEnsured = true
24
27
  } catch {
25
28
  // Keep AI calls working even when diagnostic logging fails.
26
29
  }
27
30
  }
28
31
 
32
+ function writeAiHttpRecord(record) {
33
+ if (!aiHttpLogEnabled) return
34
+ // Schedule async write — never blocks the event loop
35
+ void ensureLogsDir().then(() => {
36
+ try {
37
+ fs.appendFile(currentLogFile(), `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`, () => {})
38
+ } catch {
39
+ // Keep AI calls working even when diagnostic logging fails.
40
+ }
41
+ })
42
+ }
43
+
29
44
  function headersToRecord(headers) {
30
45
  const result = {}
31
46
  if (!headers) return result
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tool approval store — shared state and permission helpers.
3
+ *
4
+ * Manages the pending approval queues and command-tool permission checks.
5
+ * The Promise-based approval functions (createApprovalPromise,
6
+ * createAutoCompactApprovalPromise) remain in agent-manager.mjs because
7
+ * they depend on the agent event buses.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Tool categories
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const commandRestrictedTools = new Set([
21
+ 'write_file',
22
+ 'edit_file',
23
+ 'run_command',
24
+ 'run_subagent',
25
+ ])
26
+
27
+ export const safeReadTools = new Set([
28
+ 'read_file',
29
+ 'grep_files',
30
+ ])
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Pending approval queues
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** toolCallId → { resolve, reject, sessionId, toolName, args, source, timeout } */
37
+ export const pendingApprovals = new Map()
38
+
39
+ /** approvalId → { resolve, reject, sessionId, timeout } */
40
+ export const pendingAutoCompactApprovals = new Map()
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Permission helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export function commandToolPermissionError(session, toolName) {
47
+ const permissions = session?.activeCommandPermissions
48
+ if (!permissions || !commandRestrictedTools.has(toolName)) return null
49
+ if (toolName === 'run_command' && permissions.allowCommands === false) {
50
+ return `Command /${session.activeCommandName} does not allow running shell commands.`
51
+ }
52
+ if (toolName === 'run_subagent' && permissions.allowCommands === false) {
53
+ return `Command /${session.activeCommandName} does not allow running subagents.`
54
+ }
55
+ if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
56
+ return `Command /${session.activeCommandName} does not allow editing files.`
57
+ }
58
+ return null
59
+ }
60
+
61
+ export function createCommandToolPermissions(session) {
62
+ return (toolName) => commandToolPermissionError(session, toolName)
63
+ }
@@ -251,6 +251,9 @@ export function parseInternalCommandInvocation(message) {
251
251
  if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
252
252
  if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
253
253
 
254
+ const planMatch = text.match(/^\/plan(?:\s+([\s\S]*))?$/i)
255
+ if (planMatch) return { type: 'plan', args: (planMatch[1] || '').trim() }
256
+
254
257
  const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
255
258
  if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
256
259
 
@@ -270,6 +273,11 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
270
273
  return { compact: true, args: invocation.args || '' }
271
274
  }
272
275
 
276
+ if (invocation.type === 'plan') {
277
+ if (!invocation.args) return 'Usage: /plan <task>'
278
+ return { plan: true, args: invocation.args }
279
+ }
280
+
273
281
  if (invocation.type === 'clear') {
274
282
  return { clear: true }
275
283
  }
package/server/index.mjs CHANGED
@@ -234,7 +234,7 @@ async function handleApi(req, res, url) {
234
234
  }
235
235
 
236
236
  // Project workspace inspector routes
237
- if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file') {
237
+ if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
238
238
  await handleWorkspaceApi(req, res, url)
239
239
  return
240
240
  }