@shawnstack/quickforge 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +22 -16
  2. package/bin/quickforge.mjs +83 -8
  3. package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +2 -1
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +326 -34
  27. package/server/project-config.mjs +85 -55
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +49 -19
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +66 -12
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +578 -133
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +31 -1
  52. package/server/utils/response.mjs +9 -2
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-BQJ8qi1U.css +0 -3
  65. package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
  66. package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
  67. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  68. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  69. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  70. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  71. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,1053 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { Agent } from '@mariozechner/pi-agent-core'
4
+ import { streamSimple } from '@mariozechner/pi-ai'
5
+ import { toolHandlers, loadSkillToolContext } from './tools/index.mjs'
6
+ import { createSkillTools, workspaceTools } from './tools/definitions.mjs'
7
+ import { projectContextFromId, readProjectConfig } from './project-config.mjs'
8
+ import { readStore, atomicUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
9
+ import { logger } from './utils/logger.mjs'
10
+ import { buildSystemPrompt, generateAiTitle } from './session-utils.mjs'
11
+ import { restoreReasoningContentInPayload } from './reasoning-cache.mjs'
12
+ import {
13
+ compactConversation,
14
+ parseCompactArgs,
15
+ saveCompactBackup,
16
+ } from './conversation-compaction.mjs'
17
+ import {
18
+ handleInternalCommand,
19
+ parseInternalCommandInvocation,
20
+ resolveCustomCommandInvocation,
21
+ } from './custom-commands.mjs'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Tool definitions (server-side, no REST roundtrip)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function wrapToolDefinition(definition, context, toolPermissions) {
28
+ const handler = toolHandlers[definition.name]
29
+ if (!handler) throw new Error(`Missing handler for tool: ${definition.name}`)
30
+ return {
31
+ ...definition,
32
+ execute: async (_toolCallId, params) => {
33
+ if (toolPermissions) {
34
+ const permissionError = toolPermissions(definition.name)
35
+ if (permissionError) throw new Error(permissionError)
36
+ }
37
+
38
+ const result = await handler(params || {}, context)
39
+ return {
40
+ content: [{ type: 'text', text: result.content }],
41
+ details: result.details,
42
+ }
43
+ },
44
+ }
45
+ }
46
+
47
+ async function createServerTools(projectId, projectContext, skillsContext, includeWorkspaceTools, toolPermissions) {
48
+ const skillTools = await createSkillTools({
49
+ globalSkillNames: skillsContext.globalSkillNames,
50
+ projectSkillNames: skillsContext.projectSkillNames,
51
+ workspaceRoot: projectContext?.workspaceRoot,
52
+ })
53
+ const skillToolContext = await loadSkillToolContext({
54
+ globalSkillNames: skillsContext.globalSkillNames,
55
+ projectSkillNames: skillsContext.projectSkillNames,
56
+ workspaceRoot: projectContext?.workspaceRoot,
57
+ })
58
+ const toolContext = { ...projectContext, ...skillToolContext }
59
+ const tools = skillTools.map((definition) => wrapToolDefinition(definition, toolContext, toolPermissions))
60
+
61
+ if (includeWorkspaceTools && projectId && projectContext) {
62
+ tools.push(...workspaceTools.map((definition) => wrapToolDefinition(definition, toolContext, toolPermissions)))
63
+ }
64
+
65
+ return tools
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Agent Manager
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const agentSessions = new Map()
73
+
74
+ /** @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 */
75
+
76
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
77
+ const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command'])
78
+
79
+ function createCommandToolPermissions(session) {
80
+ return (toolName) => {
81
+ const permissions = session.activeCommandPermissions
82
+ if (!permissions || !commandRestrictedTools.has(toolName)) return null
83
+ if (toolName === 'run_command' && permissions.allowCommands === false) {
84
+ return `Custom command /${session.activeCommandName} does not allow running shell commands.`
85
+ }
86
+ if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
87
+ return `Custom command /${session.activeCommandName} does not allow editing files.`
88
+ }
89
+ return null
90
+ }
91
+ }
92
+
93
+ function assistantTextMessage(text, model) {
94
+ return {
95
+ role: 'assistant',
96
+ content: [{ type: 'text', text }],
97
+ api: model?.api || 'unknown',
98
+ provider: model?.provider || 'unknown',
99
+ model: model?.id || model?.name || 'unknown',
100
+ usage: {
101
+ input: 0,
102
+ output: 0,
103
+ cacheRead: 0,
104
+ cacheWrite: 0,
105
+ totalTokens: 0,
106
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
107
+ },
108
+ stopReason: 'stop',
109
+ timestamp: Date.now(),
110
+ }
111
+ }
112
+
113
+ function userTextMessage(text) {
114
+ return {
115
+ role: 'user',
116
+ content: [{ type: 'text', text }],
117
+ timestamp: Date.now(),
118
+ }
119
+ }
120
+
121
+ function compactedSessionTitle(title) {
122
+ const base = typeof title === 'string' && title.trim() ? title.trim() : 'New chat'
123
+ if (base === 'New chat') return 'Compacted chat'
124
+ return `Compacted: ${base}`
125
+ }
126
+
127
+ function estimateTokenReduction(originalChars, finalChars) {
128
+ if (!originalChars || originalChars <= 0) return 0
129
+ return Math.max(0, Math.min(99, Math.round(((originalChars - finalChars) / originalChars) * 100)))
130
+ }
131
+
132
+ function emitSessionEvent(session, event) {
133
+ session.eventBus.emit('agent_event', event)
134
+ agentEvents.emit('agent_event', { sessionId: session.sessionId, ...event })
135
+ }
136
+
137
+ function updateSessionMessages(session, messages) {
138
+ session.agent.state.messages = messages
139
+ const compacted = compactedContextMessages(messages)
140
+ if (compacted.length < messages.length) {
141
+ session.agent.state.messages = compacted
142
+ }
143
+ }
144
+
145
+ function finishManualSessionRun(session, status, errorMessage) {
146
+ session.status = status
147
+ session.finishedAt = new Date().toISOString()
148
+ session.agent.state.isStreaming = false
149
+ session.agent.state.streamingMessage = undefined
150
+ session.agent.state.errorMessage = errorMessage
151
+ }
152
+
153
+ async function compactSession(session, initialUserMessage, compactOptions) {
154
+ if (session.agent.state.isStreaming) {
155
+ session.agent.state.messages = [
156
+ ...session.agent.state.messages,
157
+ initialUserMessage,
158
+ assistantTextMessage('Cannot compact while a generation is still running. Stop it or wait until it finishes, then run /compact again.', session.model),
159
+ ]
160
+ await persistSession(session)
161
+ const messages = session.agent.state.messages
162
+ emitSessionEvent(session, { type: 'message_end', messages })
163
+ emitSessionEvent(session, { type: 'agent_end', messages })
164
+ return { sessionId: session.sessionId, status: session.status }
165
+ }
166
+
167
+ const sourceStatus = session.status
168
+ const sourceStartedAt = session.startedAt
169
+ const sourceFinishedAt = session.finishedAt
170
+ const sourceErrorMessage = session.agent.state.errorMessage
171
+
172
+ resetIdleTimer(session)
173
+ session.status = 'running'
174
+ session.startedAt = session.startedAt ?? new Date().toISOString()
175
+ session.finishedAt = null
176
+ session.agent.state.isStreaming = true
177
+ session.agent.state.errorMessage = undefined
178
+ emitSessionEvent(session, { type: 'agent_start' })
179
+
180
+ try {
181
+ const originalMessages = session.agent.state.messages.slice()
182
+ const options = parseCompactArgs(compactOptions?.args || '')
183
+
184
+ if (options.unsupported?.length) {
185
+ session.agent.state.messages = [
186
+ ...originalMessages,
187
+ initialUserMessage,
188
+ assistantTextMessage(`Unsupported /compact option(s): ${options.unsupported.join(', ')}\n\nSupported usage: /compact or /compact keep=0`, session.model),
189
+ ]
190
+ finishManualSessionRun(session, 'idle')
191
+ await persistSession(session)
192
+ const messages = session.agent.state.messages
193
+ emitSessionEvent(session, { type: 'message_end', messages })
194
+ emitSessionEvent(session, { type: 'agent_end', messages })
195
+ return { sessionId: session.sessionId, status: session.status }
196
+ }
197
+
198
+ const result = await compactConversation({
199
+ messages: originalMessages,
200
+ model: session.model,
201
+ thinkingLevel: session.thinkingLevel,
202
+ getApiKey: session.getApiKey,
203
+ keepTurns: options.keepTurns,
204
+ })
205
+
206
+ if (result.skipped) {
207
+ session.agent.state.messages = [
208
+ ...originalMessages,
209
+ initialUserMessage,
210
+ assistantTextMessage('Not enough earlier history to compact. Continue chatting and run /compact again later.', session.model),
211
+ ]
212
+ finishManualSessionRun(session, 'idle')
213
+ await persistSession(session)
214
+ const messages = session.agent.state.messages
215
+ emitSessionEvent(session, { type: 'message_end', messages })
216
+ emitSessionEvent(session, { type: 'agent_end', messages })
217
+ return { sessionId: session.sessionId, status: session.status }
218
+ }
219
+
220
+ await saveCompactBackup(session.sessionId, originalMessages)
221
+
222
+ const reduction = estimateTokenReduction(result.originalApproxChars, result.finalApproxChars)
223
+ const summaryMessage = userTextMessage([
224
+ 'The previous conversation has been compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
225
+ '',
226
+ '<compact_summary>',
227
+ result.summary,
228
+ '</compact_summary>',
229
+ ].join('\n'))
230
+ const notice = assistantTextMessage([
231
+ `已基于当前对话创建压缩后的新对话:原 ${result.originalCount} 条消息 → ${result.recentTail.length + 2} 条消息。`,
232
+ `当前原对话已完整保留,保留最近 ${result.keepTurns} 个用户回合原文,估算新对话上下文减少约 ${reduction}%。`,
233
+ '压缩前历史已保存到本地备份。',
234
+ ].join('\n'), session.model)
235
+
236
+ const compactedMessages = [summaryMessage, notice, ...result.recentTail]
237
+ const titleSourceMessages = [summaryMessage, ...result.recentTail]
238
+ const aiTitle = await generateAiTitle(titleSourceMessages, session.model, session.thinkingLevel, session.getApiKey)
239
+ const compactedTitle = aiTitle && aiTitle !== 'New chat'
240
+ ? aiTitle
241
+ : compactedSessionTitle(session.title)
242
+ const compactedSessionId = randomUUID()
243
+ const compactedSession = await createAgent(compactedSessionId, {
244
+ scope: session.scope,
245
+ projectId: session.projectId,
246
+ yoloMode: session.yoloMode,
247
+ model: session.model,
248
+ thinkingLevel: session.thinkingLevel,
249
+ messages: compactedMessages,
250
+ title: compactedTitle,
251
+ createdAt: new Date().toISOString(),
252
+ })
253
+ updateSessionMessages(compactedSession, compactedMessages)
254
+ await persistSession(compactedSession)
255
+
256
+ session.status = sourceStatus
257
+ session.startedAt = sourceStartedAt
258
+ session.finishedAt = sourceFinishedAt
259
+ session.agent.state.isStreaming = false
260
+ session.agent.state.streamingMessage = undefined
261
+ session.agent.state.errorMessage = sourceErrorMessage
262
+ await persistSession(session)
263
+
264
+ const messages = session.agent.state.messages
265
+ emitSessionEvent(session, { type: 'agent_end', messages })
266
+ emitSessionEvent(session, {
267
+ type: 'session_forked',
268
+ sourceSessionId: session.sessionId,
269
+ targetSessionId: compactedSessionId,
270
+ title: compactedSession.title,
271
+ createdAt: compactedSession.createdAt,
272
+ scope: compactedSession.scope,
273
+ projectId: compactedSession.projectId,
274
+ messages: compactedSession.agent.state.messages,
275
+ })
276
+ emitSessionEvent(compactedSession, { type: 'message_end', messages: compactedSession.agent.state.messages })
277
+ emitSessionEvent(compactedSession, { type: 'agent_end', messages: compactedSession.agent.state.messages })
278
+ return { sessionId: session.sessionId, status: session.status, compactedSessionId }
279
+ } catch (err) {
280
+ const errorMessage = err?.message || 'Conversation compaction failed'
281
+ session.agent.state.messages = [
282
+ ...session.agent.state.messages,
283
+ initialUserMessage,
284
+ assistantTextMessage(`Conversation compaction failed: ${errorMessage}`, session.model),
285
+ ]
286
+ finishManualSessionRun(session, 'error', errorMessage)
287
+ await persistSession(session)
288
+ const messages = session.agent.state.messages
289
+ emitSessionEvent(session, { type: 'error', error: errorMessage })
290
+ emitSessionEvent(session, { type: 'agent_end', messages, errorMessage })
291
+ return { sessionId: session.sessionId, status: session.status }
292
+ }
293
+ }
294
+
295
+ async function clearSession(session) {
296
+ if (session.agent.state.isStreaming) {
297
+ session.agent.state.messages = [
298
+ ...session.agent.state.messages,
299
+ assistantTextMessage('Cannot clear while a generation is still running. Stop it or wait until it finishes, then run /clear again.', session.model),
300
+ ]
301
+ await persistSession(session)
302
+ const messages = session.agent.state.messages
303
+ emitSessionEvent(session, { type: 'message_end', messages })
304
+ emitSessionEvent(session, { type: 'agent_end', messages })
305
+ return { sessionId: session.sessionId, status: session.status }
306
+ }
307
+
308
+ updateSessionMessages(session, [])
309
+ session.status = 'idle'
310
+ session.startedAt = null
311
+ session.finishedAt = new Date().toISOString()
312
+ session.title = 'New chat'
313
+ session.titleGenerated = false
314
+ session.agent.state.isStreaming = false
315
+ session.agent.state.streamingMessage = undefined
316
+ session.agent.state.errorMessage = undefined
317
+
318
+ await persistSession(session)
319
+ const messages = session.agent.state.messages
320
+ emitSessionEvent(session, { type: 'message_end', messages })
321
+ emitSessionEvent(session, { type: 'agent_end', messages })
322
+ emitSessionEvent(session, { type: 'title_updated', title: session.title })
323
+ return { sessionId: session.sessionId, status: session.status, cleared: true }
324
+ }
325
+
326
+ async function resolveCommandState(session, userMessage) {
327
+ const internalResponse = await handleInternalCommand(
328
+ parseInternalCommandInvocation(userMessage),
329
+ session.projectContext?.workspaceRoot,
330
+ )
331
+ if (typeof internalResponse === 'string') return { textResponse: internalResponse }
332
+ if (internalResponse?.clear) return { clear: internalResponse }
333
+ if (internalResponse?.compact) return { compact: internalResponse }
334
+
335
+ if (!session.projectContext?.workspaceRoot) return { userMessage }
336
+
337
+ const invocation = await resolveCustomCommandInvocation(userMessage, session.projectContext.workspaceRoot)
338
+ if (!invocation) return { userMessage }
339
+
340
+ return {
341
+ userMessage,
342
+ commandPrompt: invocation.systemPrompt,
343
+ permissions: invocation.permissions,
344
+ commandName: invocation.command.name,
345
+ }
346
+ }
347
+
348
+ function applyActiveCommandPrompt(messages, commandPrompt) {
349
+ if (!commandPrompt) return messages
350
+
351
+ for (let index = messages.length - 1; index >= 0; index--) {
352
+ const message = messages[index]
353
+ if (message?.role !== 'user' && message?.role !== 'user-with-attachments') continue
354
+
355
+ const transformed = messages.slice()
356
+ transformed[index] = {
357
+ ...message,
358
+ role: 'user',
359
+ content: commandPrompt,
360
+ }
361
+ return transformed
362
+ }
363
+
364
+ return messages
365
+ }
366
+
367
+ function compactSummaryIndex(messages) {
368
+ for (let index = messages.length - 1; index >= 0; index--) {
369
+ const message = messages[index]
370
+ const content = message?.content
371
+ const text = typeof content === 'string'
372
+ ? content
373
+ : Array.isArray(content)
374
+ ? content.filter((block) => block?.type === 'text').map((block) => block.text ?? '').join('\n')
375
+ : ''
376
+ if (message?.role === 'user' && text.includes('<compact_summary>')) return index
377
+ }
378
+ return -1
379
+ }
380
+
381
+ function compactedContextMessages(messages) {
382
+ const index = compactSummaryIndex(messages)
383
+ return index >= 0 ? messages.slice(index) : messages
384
+ }
385
+
386
+ function transformAgentContext(messages, commandPrompt) {
387
+ return applyActiveCommandPrompt(compactedContextMessages(messages), commandPrompt)
388
+ }
389
+
390
+ export const agentEvents = new EventEmitter()
391
+ agentEvents.setMaxListeners(100)
392
+
393
+ function resetIdleTimer(session) {
394
+ if (session.idleTimer) clearTimeout(session.idleTimer)
395
+ session.idleTimer = setTimeout(() => {
396
+ logger.info(`Session ${session.sessionId} idle timeout (${IDLE_TIMEOUT_MS / 1000}s), destroying...`)
397
+ destroyAgent(session.sessionId).catch((err) =>
398
+ console.error(`Failed to destroy idle agent ${session.sessionId}:`, err),
399
+ )
400
+ }, IDLE_TIMEOUT_MS)
401
+ }
402
+
403
+ /**
404
+ * Reset the idle timer for a session (e.g. on SSE activity).
405
+ * Returns true if the session was found.
406
+ */
407
+ export function touchSession(sessionId) {
408
+ const session = agentSessions.get(sessionId)
409
+ if (session) {
410
+ resetIdleTimer(session)
411
+ return true
412
+ }
413
+ return false
414
+ }
415
+
416
+ /**
417
+ * Create or retrieve an Agent for a session.
418
+ * If the session already has a running agent, return it.
419
+ * Otherwise, create a new Agent and optionally restore from storage.
420
+ */
421
+ export async function createAgent(sessionId, config = {}) {
422
+ const existing = agentSessions.get(sessionId)
423
+ if (existing) {
424
+ resetIdleTimer(existing)
425
+ return existing
426
+ }
427
+
428
+ const {
429
+ scope = 'global',
430
+ projectId = null,
431
+ yoloMode = false,
432
+ model = null,
433
+ thinkingLevel = 'off',
434
+ messages = [],
435
+ systemPrompt = null,
436
+ title = 'New chat',
437
+ createdAt = new Date().toISOString(),
438
+ } = config
439
+
440
+ // Resolve project context for tool calls
441
+ let projectContext = null
442
+ if (projectId) {
443
+ try {
444
+ projectContext = await projectContextFromId(projectId)
445
+ } catch {
446
+ // project not found — run without tools
447
+ }
448
+ }
449
+
450
+ // Build system prompt
451
+ const projectConfig = await readProjectConfig()
452
+ const configuredProject = projectId
453
+ ? projectConfig.projects.find((project) => project.id === projectId)
454
+ : null
455
+ const skillsContext = {
456
+ globalSkillNames: projectConfig.globalSkills,
457
+ projectSkillNames: configuredProject?.skills,
458
+ }
459
+ const resolvedSystemPrompt = systemPrompt ?? (await buildSystemPrompt(projectId))
460
+
461
+ // Resolve model
462
+ let resolvedModel = model
463
+ if (!resolvedModel) {
464
+ // Try to load from storage
465
+ try {
466
+ const settings = await readStore('settings')
467
+ const raw = settings?.['active-model']
468
+ if (raw) resolvedModel = typeof raw === 'string' ? JSON.parse(raw) : raw
469
+ } catch {
470
+ // ignore
471
+ }
472
+ }
473
+
474
+ // Build skills tools for enabled skills, plus workspace tools when YOLO + project are available.
475
+ const toolPermissions = (toolName) => {
476
+ const session = agentSessions.get(sessionId)
477
+ return session ? createCommandToolPermissions(session)(toolName) : null
478
+ }
479
+ const tools = await createServerTools(projectId, projectContext, skillsContext, yoloMode, toolPermissions)
480
+
481
+ // Resolve API key
482
+ const getApiKey = async (provider) => {
483
+ try {
484
+ const keys = await readStore('provider-keys')
485
+ return keys?.[provider] || undefined
486
+ } catch {
487
+ return undefined
488
+ }
489
+ }
490
+
491
+ const agent = new Agent({
492
+ initialState: {
493
+ systemPrompt: resolvedSystemPrompt,
494
+ model: resolvedModel,
495
+ thinkingLevel,
496
+ messages,
497
+ tools,
498
+ },
499
+ streamFn: streamSimple,
500
+ getApiKey,
501
+ sessionId,
502
+ onPayload: (payload) => {
503
+ restoreReasoningContentInPayload(payload, agent.state.messages, agent.state.model)
504
+ },
505
+ transformContext: (messages) => transformAgentContext(messages, session?.activeCommandPrompt),
506
+ beforeToolCall: async (context) => {
507
+ const toolName = context.toolCall?.name
508
+ const isSkillTool = toolName === 'activate_skill' || toolName === 'read_skill_resource'
509
+ if (isSkillTool) return undefined
510
+ if (!projectContext) {
511
+ return { block: true, reason: 'No active project. Select a project to use tools.' }
512
+ }
513
+ if (!yoloMode) {
514
+ return { block: true, reason: 'YOLO mode is disabled. Enable it to use tools.' }
515
+ }
516
+ return undefined
517
+ },
518
+ })
519
+
520
+ const eventBus = new EventEmitter()
521
+ eventBus.setMaxListeners(100)
522
+
523
+ const session = {
524
+ sessionId,
525
+ agent,
526
+ projectContext,
527
+ projectId,
528
+ yoloMode,
529
+ model: resolvedModel,
530
+ thinkingLevel,
531
+ scope,
532
+ title,
533
+ createdAt,
534
+ status: 'idle',
535
+ startedAt: null,
536
+ finishedAt: null,
537
+ activeCommandName: null,
538
+ activeCommandPermissions: null,
539
+ activeCommandPrompt: null,
540
+ eventBus,
541
+ idleTimer: null,
542
+ titleGenerated: false,
543
+ getApiKey,
544
+ /** Track active SSE connections. Only one SSE stream allowed per session to prevent
545
+ * connection-pool exhaustion when two browser tabs load the same session. */
546
+ sseConnected: false,
547
+ }
548
+
549
+ // Subscribe to agent lifecycle events and forward to eventBus
550
+ agent.subscribe((event) => {
551
+ // Forward all events to the session event bus and the global bus.
552
+ eventBus.emit('agent_event', event)
553
+ agentEvents.emit('agent_event', { sessionId, ...event })
554
+
555
+ // Track status
556
+ if (event.type === 'agent_start') {
557
+ session.status = 'running'
558
+ session.startedAt = session.startedAt ?? new Date().toISOString()
559
+ session.finishedAt = null
560
+ // Persist running state immediately so a browser refresh still shows the green dot
561
+ persistSession(session).catch((err) =>
562
+ console.error(`Failed to persist session on start ${sessionId}:`, err),
563
+ )
564
+ }
565
+
566
+ if (event.type === 'agent_end') {
567
+ session.status = session.agent.state.errorMessage ? 'error' : 'idle'
568
+ session.finishedAt = new Date().toISOString()
569
+
570
+ // Persist after run ends
571
+ persistSession(session).catch((err) =>
572
+ console.error(`Failed to persist session ${sessionId}:`, err),
573
+ )
574
+ }
575
+
576
+ if (event.type === 'message_end') {
577
+ // Do a lightweight persist on message_end for crash recovery
578
+ persistSession(session).catch((err) =>
579
+ console.error(`Failed to persist session ${sessionId}:`, err),
580
+ )
581
+ }
582
+ })
583
+
584
+ agentSessions.set(sessionId, session)
585
+ resetIdleTimer(session)
586
+ logger.info(`Created session ${sessionId} (scope: ${scope}, project: ${projectId || 'none'}, yolo: ${yoloMode})`)
587
+ return session
588
+ }
589
+
590
+ /**
591
+ * Persist session data to storage.
592
+ */
593
+ async function persistSession(session) {
594
+ const { sessionId, agent, scope, projectId, title, createdAt, status, startedAt, finishedAt, model, thinkingLevel, yoloMode } = session
595
+ const messages = compactedContextMessages(agent.state.messages)
596
+
597
+ if (messages.length === 0) {
598
+ try {
599
+ await deleteSessionValue(sessionId)
600
+ await atomicUpdate('sessions-metadata', (data) => {
601
+ delete data[sessionId]
602
+ return data
603
+ })
604
+ } catch (err) {
605
+ console.error(`Failed to remove empty session ${sessionId}:`, err)
606
+ }
607
+ return
608
+ }
609
+
610
+ const now = new Date().toISOString()
611
+ const sessionData = {
612
+ id: sessionId,
613
+ title,
614
+ model,
615
+ thinkingLevel,
616
+ yoloMode,
617
+ messages,
618
+ createdAt: createdAt || now,
619
+ lastModified: now,
620
+ scope,
621
+ projectId: scope === 'project' ? projectId : undefined,
622
+ taskStatus: status,
623
+ taskStartedAt: startedAt,
624
+ taskFinishedAt: finishedAt,
625
+ }
626
+
627
+ // Calculate usage
628
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }
629
+ for (const msg of messages) {
630
+ if (msg.role === 'assistant' && msg.usage) {
631
+ usage.input += msg.usage.input ?? 0
632
+ usage.output += msg.usage.output ?? 0
633
+ usage.cacheRead += msg.usage.cacheRead ?? 0
634
+ usage.cacheWrite += msg.usage.cacheWrite ?? 0
635
+ usage.totalTokens += msg.usage.totalTokens ?? 0
636
+ }
637
+ }
638
+
639
+ // Generate preview from last assistant message
640
+ let preview = ''
641
+ for (let i = messages.length - 1; i >= 0; i--) {
642
+ if (messages[i].role === 'assistant') {
643
+ const content = messages[i].content
644
+ if (Array.isArray(content)) {
645
+ preview = content
646
+ .filter((b) => b.type === 'text')
647
+ .map((b) => b.text ?? '')
648
+ .join(' ')
649
+ .slice(0, 200)
650
+ } else if (typeof content === 'string') {
651
+ preview = content.slice(0, 200)
652
+ }
653
+ break
654
+ }
655
+ }
656
+
657
+ const metadata = {
658
+ id: sessionId,
659
+ title,
660
+ createdAt: createdAt || now,
661
+ lastModified: now,
662
+ messageCount: messages.length,
663
+ usage,
664
+ thinkingLevel,
665
+ preview,
666
+ scope,
667
+ projectId: scope === 'project' ? projectId : undefined,
668
+ taskStatus: status,
669
+ taskStartedAt: startedAt,
670
+ taskFinishedAt: finishedAt,
671
+ }
672
+
673
+ // Write to storage atomically (read-modify-write within queue)
674
+ try {
675
+ await writeSessionValue(sessionId, sessionData)
676
+ await atomicUpdate('sessions-metadata', (data) => {
677
+ data[sessionId] = metadata
678
+ return data
679
+ })
680
+ } catch (err) {
681
+ console.error(`Failed to persist session ${sessionId}:`, err)
682
+ }
683
+ }
684
+
685
+ export async function replaceSessionMessages(sessionId, messages) {
686
+ const session = agentSessions.get(sessionId)
687
+ if (!session) return null
688
+ if (session.agent.state.isStreaming) {
689
+ throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes before rolling back.'), { statusCode: 409 })
690
+ }
691
+ updateSessionMessages(session, Array.isArray(messages) ? messages : [])
692
+ session.status = 'idle'
693
+ session.finishedAt = new Date().toISOString()
694
+ await persistSession(session)
695
+ const nextMessages = session.agent.state.messages
696
+ emitSessionEvent(session, { type: 'message_end', messages: nextMessages })
697
+ emitSessionEvent(session, { type: 'agent_end', messages: nextMessages })
698
+ return getSessionState(sessionId)
699
+ }
700
+
701
+ /**
702
+ * Send a user message to the agent and start the agent loop.
703
+ * Returns immediately; events are streamed via the event bus.
704
+ */
705
+ export async function runPrompt(sessionId, message) {
706
+ let session = agentSessions.get(sessionId)
707
+ if (!session) {
708
+ session = await restoreAgent(sessionId)
709
+ }
710
+ if (!session) {
711
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
712
+ }
713
+
714
+ resetIdleTimer(session)
715
+
716
+ // Build user message
717
+ const initialUserMessage = typeof message === 'string'
718
+ ? { role: 'user', content: message, timestamp: new Date().toISOString() }
719
+ : message
720
+ const commandState = await resolveCommandState(session, initialUserMessage)
721
+ const userMessage = commandState.userMessage ?? initialUserMessage
722
+
723
+ if (commandState.textResponse) {
724
+ session.agent.state.messages = [
725
+ ...session.agent.state.messages,
726
+ initialUserMessage,
727
+ assistantTextMessage(commandState.textResponse, session.model),
728
+ ]
729
+ await persistSession(session)
730
+ const messages = session.agent.state.messages
731
+ session.eventBus.emit('agent_event', { type: 'message_end', messages })
732
+ session.eventBus.emit('agent_event', { type: 'agent_end', messages })
733
+ agentEvents.emit('agent_event', { sessionId, type: 'message_end', messages })
734
+ agentEvents.emit('agent_event', { sessionId, type: 'agent_end', messages })
735
+ return { sessionId, status: session.status }
736
+ }
737
+
738
+ if (commandState.clear) {
739
+ return clearSession(session)
740
+ }
741
+
742
+ if (commandState.compact) {
743
+ return compactSession(session, initialUserMessage, commandState.compact)
744
+ }
745
+
746
+ // AI title generation on first user message (fire-and-forget, before agent runs)
747
+ if (!session.titleGenerated && session.title === 'New chat') {
748
+ session.titleGenerated = true
749
+ generateAiTitle([userMessage], session.model, session.thinkingLevel, session.getApiKey).then(async (aiTitle) => {
750
+ if (aiTitle && aiTitle !== 'New chat') {
751
+ session.title = aiTitle
752
+ await persistSession(session)
753
+ session.eventBus.emit('agent_event', { type: 'title_updated', title: aiTitle })
754
+ agentEvents.emit('agent_event', { sessionId, type: 'title_updated', title: aiTitle })
755
+ }
756
+ }).catch((err) => {
757
+ logger.warn(`Title generation failed for session ${sessionId}:`, err.message || err)
758
+ })
759
+ }
760
+
761
+ session.activeCommandName = commandState.commandName ?? null
762
+ session.activeCommandPermissions = commandState.permissions ?? null
763
+ session.activeCommandPrompt = commandState.commandPrompt ?? null
764
+
765
+ // Fire and forget — events come through eventBus
766
+ session.agent.prompt(userMessage).catch((err) => {
767
+ logger.error(`Agent prompt error for session ${sessionId}:`, err)
768
+ const event = {
769
+ type: 'error',
770
+ error: err.message || 'Unknown error',
771
+ }
772
+ session.eventBus.emit('agent_event', event)
773
+ agentEvents.emit('agent_event', { sessionId, ...event })
774
+ }).finally(() => {
775
+ session.activeCommandName = null
776
+ session.activeCommandPermissions = null
777
+ session.activeCommandPrompt = null
778
+ })
779
+
780
+ return { sessionId, status: session.status }
781
+ }
782
+
783
+ /**
784
+ * Abort the current agent run.
785
+ */
786
+ export async function abortRun(sessionId) {
787
+ const session = agentSessions.get(sessionId)
788
+ if (!session) {
789
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
790
+ }
791
+
792
+ session.agent.abort()
793
+
794
+ if (session.status === 'running') {
795
+ session.status = 'aborted'
796
+ session.finishedAt = new Date().toISOString()
797
+ persistSession(session).catch((err) =>
798
+ console.error(`Failed to persist aborted session ${sessionId}:`, err),
799
+ )
800
+ const event = {
801
+ type: 'agent_end',
802
+ messages: session.agent.state.messages,
803
+ }
804
+ session.eventBus.emit('agent_event', event)
805
+ agentEvents.emit('agent_event', { sessionId, ...event })
806
+ }
807
+
808
+ return { sessionId, aborted: true }
809
+ }
810
+
811
+ /**
812
+ * Queue a steering message to inject after the current assistant turn.
813
+ */
814
+ export function steerAgent(sessionId, message) {
815
+ const session = agentSessions.get(sessionId)
816
+ if (!session) {
817
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
818
+ }
819
+
820
+ const agentMessage = typeof message === 'string'
821
+ ? { role: 'user', content: message, timestamp: Date.now() }
822
+ : message
823
+
824
+ session.agent.steer(agentMessage)
825
+ return { sessionId, steered: true }
826
+ }
827
+
828
+ /**
829
+ * Queue a follow-up message to process after the agent would otherwise stop.
830
+ */
831
+ export function followUpAgent(sessionId, message) {
832
+ const session = agentSessions.get(sessionId)
833
+ if (!session) {
834
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
835
+ }
836
+
837
+ const agentMessage = typeof message === 'string'
838
+ ? { role: 'user', content: message, timestamp: Date.now() }
839
+ : message
840
+
841
+ session.agent.followUp(agentMessage)
842
+ return { sessionId, followUp: true }
843
+ }
844
+
845
+ /**
846
+ * Get the current state of a session (for page refresh recovery).
847
+ */
848
+ export function getSessionState(sessionId) {
849
+ const session = agentSessions.get(sessionId)
850
+ if (!session) return null
851
+
852
+ return {
853
+ sessionId: session.sessionId,
854
+ scope: session.scope,
855
+ projectId: session.projectId,
856
+ yoloMode: session.yoloMode,
857
+ systemPrompt: session.agent.state.systemPrompt,
858
+ model: session.model,
859
+ thinkingLevel: session.thinkingLevel,
860
+ title: session.title,
861
+ createdAt: session.createdAt,
862
+ status: session.status,
863
+ startedAt: session.startedAt,
864
+ finishedAt: session.finishedAt,
865
+ messages: session.agent.state.messages,
866
+ isStreaming: session.agent.state.isStreaming,
867
+ errorMessage: session.agent.state.errorMessage,
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Try to claim the SSE slot for a session. Returns true if acquired, false if
873
+ * another tab already holds the SSE connection for this session.
874
+ */
875
+ export function tryAcquireSse(sessionId) {
876
+ const session = agentSessions.get(sessionId)
877
+ if (!session || session.sseConnected) return false
878
+ session.sseConnected = true
879
+ return true
880
+ }
881
+
882
+ /**
883
+ * Check whether a session already has an active SSE connection, without
884
+ * acquiring it. For use by lightweight HEAD probes.
885
+ */
886
+ export function isSseConnected(sessionId) {
887
+ const session = agentSessions.get(sessionId)
888
+ return session ? session.sseConnected : false
889
+ }
890
+
891
+ /**
892
+ * Release the SSE slot for a session.
893
+ */
894
+ export function releaseSse(sessionId) {
895
+ const session = agentSessions.get(sessionId)
896
+ if (session) session.sseConnected = false
897
+ }
898
+
899
+ /**
900
+ * Get the event bus for a session (for SSE connections).
901
+ */
902
+ export function getSessionEventBus(sessionId) {
903
+ const session = agentSessions.get(sessionId)
904
+ return session?.eventBus ?? null
905
+ }
906
+
907
+ /**
908
+ * Destroy an agent session.
909
+ */
910
+ export async function destroyAgent(sessionId) {
911
+ const session = agentSessions.get(sessionId)
912
+ if (!session) return
913
+
914
+ logger.info(`Destroying session ${sessionId} (status: ${session.status})`)
915
+
916
+ if (session.idleTimer) clearTimeout(session.idleTimer)
917
+
918
+ try {
919
+ session.agent.abort()
920
+ } catch {
921
+ // ignore
922
+ }
923
+
924
+ // Final persist (empty sessions are cleaned up by persistSession)
925
+ try {
926
+ await persistSession(session)
927
+ } catch {
928
+ // ignore
929
+ }
930
+
931
+ session.eventBus.removeAllListeners()
932
+ agentSessions.delete(sessionId)
933
+ }
934
+
935
+ /**
936
+ * Try to restore an agent session from persisted storage.
937
+ * Returns the restored session, or null if not found.
938
+ */
939
+ export async function restoreAgent(sessionId) {
940
+ const existing = agentSessions.get(sessionId)
941
+ if (existing) return existing
942
+
943
+ try {
944
+ const sessionData = await readSessionValue(sessionId)
945
+ if (!sessionData) {
946
+ logger.warn(`Cannot restore session ${sessionId}: no stored data found`)
947
+ return null
948
+ }
949
+
950
+ logger.info(`Restoring session ${sessionId} from storage (scope: ${sessionData.scope}, messages: ${sessionData.messages?.length ?? 0})`)
951
+
952
+ return await createAgent(sessionId, {
953
+ scope: sessionData.scope || 'global',
954
+ projectId: sessionData.projectId || null,
955
+ yoloMode: sessionData.yoloMode || false,
956
+ model: sessionData.model,
957
+ thinkingLevel: sessionData.thinkingLevel || 'off',
958
+ messages: sessionData.messages || [],
959
+ title: sessionData.title || 'New chat',
960
+ createdAt: sessionData.createdAt,
961
+ })
962
+ } catch (err) {
963
+ logger.error(`Failed to restore agent ${sessionId}:`, err)
964
+ return null
965
+ }
966
+ }
967
+
968
+ /**
969
+ * List all active sessions.
970
+ */
971
+ export function listSessions() {
972
+ const result = []
973
+ for (const [id, session] of agentSessions) {
974
+ result.push({
975
+ sessionId: id,
976
+ scope: session.scope,
977
+ status: session.status,
978
+ title: session.title,
979
+ })
980
+ }
981
+ return result
982
+ }
983
+
984
+ /**
985
+ * Update the model for an existing session.
986
+ * Syncs the model to both the session record (for persistence) and the agent state (for API calls).
987
+ * Does NOT force persistence — normal lifecycle events (message_end, agent_end) will persist
988
+ * the updated model.
989
+ */
990
+ export function updateSessionModel(sessionId, model) {
991
+ const session = agentSessions.get(sessionId)
992
+ if (!session) {
993
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
994
+ }
995
+ if (!model) {
996
+ throw Object.assign(new Error('Missing model'), { statusCode: 400 })
997
+ }
998
+
999
+ session.model = model
1000
+ session.agent.state.model = model
1001
+
1002
+ return { sessionId, model }
1003
+ }
1004
+
1005
+ /**
1006
+ * Update the thinking level for an existing session.
1007
+ */
1008
+ export function updateSessionThinkingLevel(sessionId, thinkingLevel) {
1009
+ const session = agentSessions.get(sessionId)
1010
+ if (!session) {
1011
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1012
+ }
1013
+ if (!thinkingLevel) {
1014
+ throw Object.assign(new Error('Missing thinkingLevel'), { statusCode: 400 })
1015
+ }
1016
+
1017
+ session.thinkingLevel = thinkingLevel
1018
+ session.agent.state.thinkingLevel = thinkingLevel
1019
+
1020
+ return { sessionId, thinkingLevel }
1021
+ }
1022
+
1023
+ /**
1024
+ * Reset stale `taskStatus: 'running'` entries in persisted session metadata.
1025
+ * Called on server startup — any sessions marked as running are clearly stale
1026
+ * since the server just started fresh.
1027
+ */
1028
+ export async function resetStaleTaskStatuses() {
1029
+ try {
1030
+ const metadataStore = await readStore('sessions-metadata')
1031
+ let changed = false
1032
+ for (const [id, meta] of Object.entries(metadataStore)) {
1033
+ if (meta && meta.taskStatus === 'running') {
1034
+ metadataStore[id] = { ...meta, taskStatus: 'idle', taskFinishedAt: meta.taskFinishedAt ?? new Date().toISOString() }
1035
+ changed = true
1036
+ }
1037
+ }
1038
+ if (changed) {
1039
+ await atomicUpdate('sessions-metadata', () => metadataStore)
1040
+ logger.info('Reset stale task statuses in persisted metadata')
1041
+ }
1042
+ } catch (err) {
1043
+ logger.error('Failed to reset stale task statuses:', err)
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Clean up all agents on shutdown.
1049
+ */
1050
+ export async function shutdown() {
1051
+ const ids = [...agentSessions.keys()]
1052
+ await Promise.all(ids.map((id) => destroyAgent(id)))
1053
+ }