@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.
- package/README.md +22 -16
- package/bin/quickforge.mjs +83 -8
- package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +2 -1
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +326 -34
- package/server/project-config.mjs +85 -55
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +49 -19
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +66 -12
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +578 -133
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +31 -1
- package/server/utils/response.mjs +9 -2
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-BQJ8qi1U.css +0 -3
- package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
- package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /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
|
+
}
|