@shawnstack/quickforge 1.3.24 → 1.3.26

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 (57) hide show
  1. package/README.md +24 -18
  2. package/dist/assets/anthropic-BcnDL7hi.js +39 -0
  3. package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
  4. package/dist/assets/google-C2y985rW.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
  7. package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
  8. package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
  9. package/dist/assets/index-ZYbEKGUp.css +3 -0
  10. package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
  11. package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
  12. package/dist/assets/openai-completions-CHDluyXM.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-UtRriBXu.js +1 -0
  15. package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
  16. package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
  17. package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
  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 +4 -3
  22. package/server/agent-manager.mjs +162 -176
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +67 -9
  26. package/server/index.mjs +7 -0
  27. package/server/message-converters.mjs +79 -0
  28. package/server/plugins/loader.mjs +56 -0
  29. package/server/plugins/manifest.mjs +174 -0
  30. package/server/plugins/registry.mjs +304 -0
  31. package/server/project-config.mjs +53 -4
  32. package/server/routes/agent-profiles.mjs +1 -1
  33. package/server/routes/agent.mjs +1 -16
  34. package/server/routes/filesystem.mjs +18 -2
  35. package/server/routes/plugins.mjs +63 -0
  36. package/server/routes/project.mjs +2 -0
  37. package/server/routes/scheduled-tasks.mjs +1 -1
  38. package/server/routes/storage.mjs +66 -31
  39. package/server/routes/tools.mjs +12 -1
  40. package/server/session-utils.mjs +1 -1
  41. package/server/skills.mjs +64 -5
  42. package/server/storage.mjs +91 -8
  43. package/server/system-prompt.mjs +27 -5
  44. package/server/tool-wiring.mjs +113 -0
  45. package/server/utils/workspace.mjs +20 -1
  46. package/dist/assets/anthropic-BrbLtQkg.js +0 -39
  47. package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
  48. package/dist/assets/google-Bv6IeSRf.js +0 -1
  49. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  50. package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
  51. package/dist/assets/index-C4m48ndP.css +0 -3
  52. package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
  53. package/dist/assets/openai-completions-CihVV11E.js +0 -5
  54. package/dist/assets/openai-responses-BigEdUNS.js +0 -1
  55. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  56. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  57. /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
@@ -1,17 +1,18 @@
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
+ import { createPluginToolDefinitions, isPluginToolName } from './plugins/registry.mjs'
8
9
  import {
9
10
  composeSubagentSystemPrompt,
10
11
  formatSubagentTask,
11
12
  } from './subagents.mjs'
12
13
  import { agentProfileSnapshot, getAgentProfile } from './agent-profiles.mjs'
13
14
  import { projectContextFromId, readProjectConfig } from './project-config.mjs'
14
- import { readStore, atomicUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
15
+ import { readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
15
16
  import { logger } from './utils/logger.mjs'
16
17
  import { buildSystemPrompt, generateAiTitle, generateTitle } from './session-utils.mjs'
17
18
  import { restoreReasoningContentInPayload } from './reasoning-cache.mjs'
@@ -30,70 +31,22 @@ import {
30
31
  parseInternalCommandInvocation,
31
32
  resolveCustomCommandInvocation,
32
33
  } from './custom-commands.mjs'
34
+ import { omitDetailsForLlm, serverConvertToLlm, messageText, lastAssistantText } from './message-converters.mjs'
35
+ import { isPlainObject, mergeQuickForgeTiming, wrapToolDefinition, wrapMcpToolDefinition, wrapPluginToolDefinition, sessionSkillsContext } from './tool-wiring.mjs'
36
+ import {
37
+ APPROVAL_TIMEOUT_MS,
38
+ commandRestrictedTools,
39
+ safeReadTools,
40
+ pendingApprovals,
41
+ pendingAutoCompactApprovals,
42
+ commandToolPermissionError,
43
+ createCommandToolPermissions,
44
+ } from './approval-store.mjs'
33
45
 
34
46
  // ---------------------------------------------------------------------------
35
47
  // Tool definitions (server-side, no REST roundtrip)
36
48
  // ---------------------------------------------------------------------------
37
49
 
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
50
  function wrapSubagentToolDefinition(definition, parentSessionId) {
98
51
  return {
99
52
  ...definition,
@@ -119,6 +72,7 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
119
72
  allowedToolNames = null,
120
73
  includeSubagentTool = true,
121
74
  includeMcpTools = true,
75
+ includePluginTools = true,
122
76
  parentSessionId = null,
123
77
  } = options
124
78
  const allowedTools = allowedToolNames ? new Set(allowedToolNames) : null
@@ -151,14 +105,12 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
151
105
  tools.push(...mcpTools.filter(isAllowed).map((definition) => wrapMcpToolDefinition(definition, toolPermissions)))
152
106
  }
153
107
 
154
- return tools
155
- }
156
-
157
- function sessionSkillsContext(session) {
158
- return {
159
- globalSkillNames: session.globalSkillNames,
160
- projectSkillNames: session.projectSkillNames,
108
+ if (includePluginTools) {
109
+ const pluginTools = await createPluginToolDefinitions(projectContext)
110
+ tools.push(...pluginTools.filter(isAllowed).map((definition) => wrapPluginToolDefinition(definition, toolContext, toolPermissions)))
161
111
  }
112
+
113
+ return tools
162
114
  }
163
115
 
164
116
  async function rebuildSessionTools(session) {
@@ -181,31 +133,8 @@ const agentSessions = new Map()
181
133
  /** @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
134
 
183
135
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
184
- const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
185
136
  const SUBAGENT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
186
- const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command', 'run_subagent'])
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 commandToolPermissionError(session, toolName) {
192
- const permissions = session?.activeCommandPermissions
193
- if (!permissions || !commandRestrictedTools.has(toolName)) return null
194
- if (toolName === 'run_command' && permissions.allowCommands === false) {
195
- return `Command /${session.activeCommandName} does not allow running shell commands.`
196
- }
197
- if (toolName === 'run_subagent' && permissions.allowCommands === false) {
198
- return `Command /${session.activeCommandName} does not allow running subagents.`
199
- }
200
- if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
201
- return `Command /${session.activeCommandName} does not allow editing files.`
202
- }
203
- return null
204
- }
205
-
206
- function createCommandToolPermissions(session) {
207
- return (toolName) => commandToolPermissionError(session, toolName)
208
- }
137
+ const SUBAGENT_TRACE_THROTTLE_MS = 150
209
138
 
210
139
  /**
211
140
  * Create a Promise that only resolves when the user accepts or rejects the tool call.
@@ -224,8 +153,14 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
224
153
  resolve({ block: true, reason: `Approval timeout for ${toolName}` })
225
154
  }, APPROVAL_TIMEOUT_MS)
226
155
 
156
+ let onAbort = null
157
+
227
158
  const cleanup = () => {
228
159
  clearTimeout(timeout)
160
+ if (onAbort) {
161
+ session.agent.signal?.removeEventListener('abort', onAbort)
162
+ onAbort = null
163
+ }
229
164
  if (settled) return
230
165
  settled = true
231
166
  pendingApprovals.delete(toolCallId)
@@ -239,7 +174,7 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
239
174
  reject(new Error('Run aborted'))
240
175
  return
241
176
  }
242
- const onAbort = () => {
177
+ onAbort = () => {
243
178
  cleanup()
244
179
  reject(new Error('Run aborted'))
245
180
  }
@@ -289,8 +224,14 @@ function createAutoCompactApprovalPromise(session, details = {}) {
289
224
  resolve(false)
290
225
  }, APPROVAL_TIMEOUT_MS)
291
226
 
227
+ let onAbort = null
228
+
292
229
  const cleanup = () => {
293
230
  clearTimeout(timeout)
231
+ if (onAbort) {
232
+ session.agent.signal?.removeEventListener('abort', onAbort)
233
+ onAbort = null
234
+ }
294
235
  if (settled) return
295
236
  settled = true
296
237
  pendingAutoCompactApprovals.delete(approvalId)
@@ -303,7 +244,7 @@ function createAutoCompactApprovalPromise(session, details = {}) {
303
244
  reject(new Error('Run aborted'))
304
245
  return
305
246
  }
306
- const onAbort = () => {
247
+ onAbort = () => {
307
248
  cleanup()
308
249
  reject(new Error('Run aborted'))
309
250
  }
@@ -619,6 +560,14 @@ async function resolveCommandState(session, userMessage) {
619
560
  commandName: 'plan',
620
561
  }
621
562
  }
563
+ if (internalResponse?.review) {
564
+ return {
565
+ userMessage,
566
+ commandPrompt: formatReviewCommandPrompt(internalResponse.args),
567
+ permissions: { allowEdit: false, allowCommands: true, allowSubagents: false },
568
+ commandName: 'review',
569
+ }
570
+ }
622
571
 
623
572
  if (!session.projectContext?.workspaceRoot) return { userMessage }
624
573
 
@@ -665,65 +614,31 @@ ${taskText}
665
614
  </plan_command_invocation>`
666
615
  }
667
616
 
668
- function omitDetailsForLlm(message) {
669
- if (!message || typeof message !== 'object' || message.details === undefined) return message
670
- const copy = { ...message }
671
- delete copy.details
672
- return copy
673
- }
617
+ function formatReviewCommandPrompt(scope) {
618
+ const scopeText = String(scope || '').trim() || '(none; review the repository changes that appear relevant for a pre-commit check)'
619
+ return `<review_command_invocation name="review">
620
+ This /review command applies only to the current user request. Perform a pre-commit self-review of the code that is about to be committed.
674
621
 
675
- /**
676
- * Convert AgentMessage[] to LLM-compatible Message[].
677
- * Handles "user-with-attachments" "user" with multi-modal content blocks.
678
- * Without this the default pi-agent-core convertToLlm silently drops
679
- * user-with-attachments messages, so the LLM never sees attachments.
680
- */
681
- function serverConvertToLlm(messages) {
682
- return messages
683
- .filter(m => m.role !== 'artifact')
684
- .map(m => {
685
- if (m.role === 'user-with-attachments') {
686
- const textContent = typeof m.content === 'string'
687
- ? [{ type: 'text', text: m.content }]
688
- : [...m.content]
689
- if (Array.isArray(m.attachments)) {
690
- for (const att of m.attachments) {
691
- if (att.type === 'image' && att.content) {
692
- textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
693
- } else if (att.type === 'document' && att.extractedText) {
694
- textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
695
- }
696
- }
697
- }
698
- return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
699
- }
700
- if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
701
- return null
702
- })
703
- .filter(Boolean)
704
- }
622
+ Rules for this turn:
623
+ - Do not modify files.
624
+ - Do not create files.
625
+ - Do not stage, unstage, commit, tag, push, publish, or otherwise change repository state.
626
+ - Do not use write_file or edit_file.
627
+ - You may use read-only tools and shell commands to inspect the workspace and run validation checks.
628
+ - Do not use subagents; perform the review directly in this turn.
629
+ - Prefer safe inspection commands such as git status, git diff, git diff --cached, and targeted lint/build/test commands.
630
+ - Treat command output as evidence; distinguish confirmed issues from risks or suggestions.
705
631
 
706
- function messageText(message) {
707
- const content = message?.content
708
- if (typeof content === 'string') return content
709
- if (Array.isArray(content)) {
710
- return content
711
- .filter((block) => block?.type === 'text')
712
- .map((block) => block.text ?? '')
713
- .join('\n')
714
- .trim()
715
- }
716
- return ''
717
- }
632
+ Review checklist:
633
+ 1. Identify the changes under review, prioritizing staged changes when present and otherwise unstaged working tree changes.
634
+ 2. Look for correctness bugs, regressions, edge cases, missing error handling, security or privacy risks, and unintended side effects.
635
+ 3. Check whether tests, lint/build validation, or documentation/wiki updates are needed.
636
+ 4. Call out any risky commands that should not be run automatically.
637
+ 5. Output a concise review with severity, file/area, evidence, and recommended next steps. If no blocking issues are found, say so clearly.
718
638
 
719
- function lastAssistantText(messages) {
720
- for (let index = messages.length - 1; index >= 0; index--) {
721
- const message = messages[index]
722
- if (message?.role !== 'assistant') continue
723
- const text = messageText(message)
724
- if (text) return text
725
- }
726
- return ''
639
+ User review scope or focus:
640
+ ${scopeText}
641
+ </review_command_invocation>`
727
642
  }
728
643
 
729
644
  async function runSubagent(parentSession, params, parentSignal, onUpdate) {
@@ -755,6 +670,9 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
755
670
  let latestMessages = []
756
671
  let latestPendingToolCalls = []
757
672
  let toolsForClient = []
673
+ let lastTraceAt = 0
674
+ let tracePending = false
675
+ let traceTimer = null
758
676
 
759
677
  const tools = await createServerTools(
760
678
  parentSession.projectId,
@@ -774,6 +692,12 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
774
692
  toolsForClient = tools.map(({ execute, prepareArguments, ...tool }) => tool)
775
693
 
776
694
  const emitSubagentTrace = () => {
695
+ if (traceTimer) {
696
+ clearTimeout(traceTimer)
697
+ traceTimer = null
698
+ }
699
+ tracePending = false
700
+ lastTraceAt = Date.now()
777
701
  onUpdate?.({
778
702
  content: [],
779
703
  details: {
@@ -792,6 +716,19 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
792
716
  })
793
717
  }
794
718
 
719
+ const emitSubagentTraceThrottled = () => {
720
+ const elapsed = Date.now() - lastTraceAt
721
+ if (elapsed >= SUBAGENT_TRACE_THROTTLE_MS) {
722
+ emitSubagentTrace()
723
+ return
724
+ }
725
+ tracePending = true
726
+ if (traceTimer) return
727
+ traceTimer = setTimeout(() => {
728
+ if (tracePending) emitSubagentTrace()
729
+ }, SUBAGENT_TRACE_THROTTLE_MS - elapsed)
730
+ }
731
+
795
732
  const systemPrompt = composeSubagentSystemPrompt({
796
733
  definition,
797
734
  parentSystemPrompt: parentSession.agent.state.systemPrompt,
@@ -848,7 +785,11 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
848
785
  latestMessages = [...latestMessages, event.message]
849
786
  }
850
787
  }
851
- emitSubagentTrace()
788
+ if (event.type === 'tool_execution_start' || event.type === 'tool_execution_end' || event.type === 'message_end') {
789
+ emitSubagentTrace()
790
+ } else {
791
+ emitSubagentTraceThrottled()
792
+ }
852
793
  })
853
794
 
854
795
  let timedOut = false
@@ -866,6 +807,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
866
807
  } finally {
867
808
  clearTimeout(timeout)
868
809
  parentSignal?.removeEventListener?.('abort', onParentAbort)
810
+ emitSubagentTrace()
869
811
  }
870
812
 
871
813
  const content = lastAssistantText(subagent.state.messages) || `Subagent ${definition.name} completed without a text response.`
@@ -905,6 +847,57 @@ function applyActiveCommandPrompt(messages, commandPrompt) {
905
847
  return messages
906
848
  }
907
849
 
850
+ function textFromMessageContent(content) {
851
+ if (typeof content === 'string') return content
852
+ if (Array.isArray(content)) {
853
+ return content.filter((block) => block?.type === 'text').map((block) => block.text ?? '').join('\n')
854
+ }
855
+ return ''
856
+ }
857
+
858
+ function selectedCapabilityPrompt(capabilities) {
859
+ if (!Array.isArray(capabilities) || capabilities.length === 0) return null
860
+ const normalized = capabilities
861
+ .filter((capability) => capability && typeof capability === 'object')
862
+ .map((capability) => ({
863
+ type: String(capability.type || '').slice(0, 32),
864
+ pluginName: String(capability.pluginName || '').slice(0, 120),
865
+ name: String(capability.name || '').slice(0, 120),
866
+ label: String(capability.label || capability.name || '').slice(0, 160),
867
+ description: String(capability.description || '').slice(0, 400),
868
+ }))
869
+ .filter((capability) => capability.type && capability.pluginName && capability.name)
870
+ .slice(0, 4)
871
+ if (normalized.length === 0) return null
872
+
873
+ const lines = normalized.map((capability) => {
874
+ const toolHint = capability.type === 'tool' ? ` Tool name: plugin__${capability.pluginName}__${capability.name}.` : ''
875
+ const description = capability.description ? ` Description: ${capability.description}` : ''
876
+ return `- ${capability.label} (${capability.type}, plugin: ${capability.pluginName}, name: ${capability.name}).${toolHint}${description}`
877
+ }).join('\n')
878
+
879
+ return `The user selected the following QuickForge plugin capability mentions for this turn. Treat them as an explicit preference for routing and context. Use the selected capability when relevant, but do not force it if it is unrelated to the actual request.\n\n${lines}`
880
+ }
881
+
882
+ function applyActiveCapabilityPrompt(messages, capabilityPrompt) {
883
+ if (!capabilityPrompt) return messages
884
+
885
+ for (let index = messages.length - 1; index >= 0; index--) {
886
+ const message = messages[index]
887
+ if (message?.role !== 'user' && message?.role !== 'user-with-attachments') continue
888
+
889
+ const visibleText = textFromMessageContent(message.content)
890
+ const transformed = messages.slice()
891
+ transformed[index] = {
892
+ ...message,
893
+ content: `${capabilityPrompt}\n\nUser request:\n${visibleText}`,
894
+ }
895
+ return transformed
896
+ }
897
+
898
+ return messages
899
+ }
900
+
908
901
  function compactSummaryIndex(messages) {
909
902
  for (let index = messages.length - 1; index >= 0; index--) {
910
903
  const message = messages[index]
@@ -943,7 +936,10 @@ async function transformSessionContext(session, messages, signal) {
943
936
  }
944
937
  const transformedMessages = buildAutoCompactLoopMessages(session, messages)
945
938
  session.lastTransformedContextMessages = transformedMessages
946
- return applyActiveCommandPrompt(compactedContextMessages(transformedMessages), session?.activeCommandPrompt)
939
+ return applyActiveCapabilityPrompt(
940
+ applyActiveCommandPrompt(compactedContextMessages(transformedMessages), session?.activeCommandPrompt),
941
+ session?.activeCapabilityPrompt,
942
+ )
947
943
  }
948
944
 
949
945
  export const agentEvents = new EventEmitter()
@@ -1098,7 +1094,7 @@ export async function createAgent(sessionId, config = {}) {
1098
1094
  if (isSkillTool) return undefined
1099
1095
  if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
1100
1096
  if (toolName === 'run_subagent') return undefined
1101
- if (isMcpToolName(toolName)) {
1097
+ if (isMcpToolName(toolName) || isPluginToolName(toolName)) {
1102
1098
  if (!currentSession?.yoloMode) return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1103
1099
  return undefined
1104
1100
  }
@@ -1181,6 +1177,7 @@ export async function createAgent(sessionId, config = {}) {
1181
1177
  if (event.type === 'agent_end') {
1182
1178
  session.status = session.agent.state.errorMessage ? 'error' : 'idle'
1183
1179
  session.finishedAt = new Date().toISOString()
1180
+ session.toolTimings?.clear()
1184
1181
  resetIdleTimer(session)
1185
1182
 
1186
1183
  // Persist after run ends
@@ -1237,7 +1234,7 @@ async function persistSession(session) {
1237
1234
  if (messages.length === 0) {
1238
1235
  try {
1239
1236
  await deleteSessionValue(sessionId)
1240
- await atomicUpdate('sessions-metadata', (data) => {
1237
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
1241
1238
  delete data[sessionId]
1242
1239
  return data
1243
1240
  })
@@ -1324,7 +1321,7 @@ async function persistSession(session) {
1324
1321
  // Write to storage atomically (read-modify-write within queue)
1325
1322
  try {
1326
1323
  await writeSessionValue(sessionId, sessionData)
1327
- await atomicUpdate('sessions-metadata', (data) => {
1324
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
1328
1325
  data[sessionId] = {
1329
1326
  ...metadata,
1330
1327
  pinnedAt: data[sessionId]?.pinnedAt,
@@ -1395,29 +1392,11 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
1395
1392
  return { session: getSessionState(sessionId), rollbackIndex }
1396
1393
  }
1397
1394
 
1398
- export async function replaceSessionMessages(sessionId, messages) {
1399
- const session = agentSessions.get(sessionId)
1400
- if (!session) return null
1401
- if (session.agent.state.isStreaming) {
1402
- throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes before rolling back.'), { statusCode: 409 })
1403
- }
1404
- updateSessionMessages(session, Array.isArray(messages) ? messages : [])
1405
- resetSessionCompaction(session)
1406
- session.status = 'idle'
1407
- session.finishedAt = new Date().toISOString()
1408
- await persistSession(session)
1409
- const nextMessages = session.agent.state.messages
1410
- const contextUsage = getSessionContextUsage(session)
1411
- emitSessionEvent(session, { type: 'message_end', messages: nextMessages, contextUsage })
1412
- emitSessionEvent(session, { type: 'agent_end', messages: nextMessages, contextUsage })
1413
- return getSessionState(sessionId)
1414
- }
1415
-
1416
1395
  /**
1417
1396
  * Send a user message to the agent and start the agent loop.
1418
1397
  * Returns immediately; events are streamed via the event bus.
1419
1398
  */
1420
- export async function runPrompt(sessionId, message) {
1399
+ export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1421
1400
  let session = agentSessions.get(sessionId)
1422
1401
  if (!session) {
1423
1402
  session = await restoreAgent(sessionId)
@@ -1426,6 +1405,10 @@ export async function runPrompt(sessionId, message) {
1426
1405
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1427
1406
  }
1428
1407
 
1408
+ if (session.agent.state.isStreaming) {
1409
+ throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
1410
+ }
1411
+
1429
1412
  resetIdleTimer(session)
1430
1413
 
1431
1414
  // Build user message
@@ -1479,6 +1462,7 @@ export async function runPrompt(sessionId, message) {
1479
1462
  session.activeCommandName = commandState.commandName ?? null
1480
1463
  session.activeCommandPermissions = commandState.permissions ?? null
1481
1464
  session.activeCommandPrompt = commandState.commandPrompt ?? null
1465
+ session.activeCapabilityPrompt = selectedCapabilityPrompt(selectedCapabilities)
1482
1466
 
1483
1467
  // Fire and forget — events come through eventBus
1484
1468
  session.agent.prompt(userMessage).catch((err) => {
@@ -1492,6 +1476,7 @@ export async function runPrompt(sessionId, message) {
1492
1476
  session.activeCommandName = null
1493
1477
  session.activeCommandPermissions = null
1494
1478
  session.activeCommandPrompt = null
1479
+ session.activeCapabilityPrompt = null
1495
1480
  })
1496
1481
 
1497
1482
  return { sessionId, status: session.status }
@@ -1701,6 +1686,7 @@ export async function destroyAgent(sessionId) {
1701
1686
  logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
1702
1687
 
1703
1688
  if (session.idleTimer) clearTimeout(session.idleTimer)
1689
+ session.toolTimings?.clear()
1704
1690
 
1705
1691
  try {
1706
1692
  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.allowSubagents === false || 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
+ }