@shawnstack/quickforge 1.3.30 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/dist/assets/AgentProfilesPage-CNK5PxA3.js +1 -0
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +217 -0
- package/dist/assets/PluginsPage-BCu1Ept0.js +1 -0
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +2 -0
- package/dist/assets/SharedConversationPage-55vX9sqe.js +1 -0
- package/dist/assets/TerminalDock-DLN_pLkJ.js +2 -0
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +6 -0
- package/dist/assets/{icons-BVM5--R9.js → icons-BWtivFsx.js} +1 -1
- package/dist/assets/index-CxOHP41X.css +3 -0
- package/dist/assets/index-Dcf73EL8.js +895 -0
- package/dist/assets/logger-B65Akg8A.js +1 -0
- package/dist/assets/monaco-evITXh-m.js +11 -0
- package/dist/assets/pi-ai-Cx633yhb.js +134 -0
- package/dist/assets/pi-web-ui-CBet4bMl.js +2770 -0
- package/dist/assets/plugin-api-YfYj_Bd7.js +1 -0
- package/dist/assets/{react-vendor-DAoL5p8_.js → react-vendor-Mthyt1p4.js} +1 -1
- package/dist/assets/rolldown-runtime-DWdDZTNf.js +1 -0
- package/dist/assets/xterm-5XDrJ343.js +36 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/index.html +8 -5
- package/package.json +1 -1
- package/server/agent-manager.mjs +189 -31
- package/server/approval-store.mjs +13 -1
- package/server/auto-compaction.mjs +63 -72
- package/server/context-usage.mjs +108 -0
- package/server/custom-commands.mjs +145 -28
- package/server/index.mjs +13 -0
- package/server/mcp/registry.mjs +40 -0
- package/server/routes/agent.mjs +20 -1
- package/server/routes/mcp.mjs +7 -1
- package/server/routes/project.mjs +32 -2
- package/server/routes/shared-conversation.mjs +1 -1
- package/server/storage.mjs +32 -19
- package/server/subagents.mjs +8 -6
- package/server/system-prompt.mjs +2 -2
- package/server/tools/definitions.mjs +1 -1
- package/server/utils/logger.mjs +0 -2
- package/dist/assets/anthropic-DYkQmon0.js +0 -39
- package/dist/assets/azure-openai-responses-B1_ZuuCX.js +0 -1
- package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
- package/dist/assets/google-Bx1PGUtS.js +0 -1
- package/dist/assets/google-shared-Cqjw1plk.js +0 -11
- package/dist/assets/google-vertex-1iRQw75f.js +0 -1
- package/dist/assets/hash-kZ2KD_no.js +0 -1
- package/dist/assets/headers-5EYI0_pl.js +0 -1
- package/dist/assets/index-CQq-kPng.js +0 -3837
- package/dist/assets/index-D0c0FMPa.css +0 -3
- package/dist/assets/mistral-B1j5S2k5.js +0 -44
- package/dist/assets/openai-Bf1npfRy.js +0 -16
- package/dist/assets/openai-codex-responses-BJKEqst-.js +0 -7
- package/dist/assets/openai-completions-B_cU49Pc.js +0 -5
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
- package/dist/assets/openai-responses-DgGY16ph.js +0 -1
- package/dist/assets/openai-responses-shared-J1-i-goZ.js +0 -12
- package/dist/assets/openrouter-BVaMghZV.js +0 -1
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
- package/dist/assets/transform-messages-Dhj_4OTw.js +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
estimateContextTokens,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
shouldCompact,
|
|
5
|
+
} from '@earendil-works/pi-agent-core'
|
|
6
|
+
|
|
7
|
+
function safeJson(value) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(value)
|
|
10
|
+
} catch {
|
|
11
|
+
return ''
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeMessageForTokenEstimate(message) {
|
|
16
|
+
if (!message || typeof message !== 'object') return message
|
|
17
|
+
if (message.role !== 'user-with-attachments') return message
|
|
18
|
+
|
|
19
|
+
const content = typeof message.content === 'string'
|
|
20
|
+
? [{ type: 'text', text: message.content }]
|
|
21
|
+
: Array.isArray(message.content)
|
|
22
|
+
? [...message.content]
|
|
23
|
+
: []
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(message.attachments)) {
|
|
26
|
+
for (const attachment of message.attachments) {
|
|
27
|
+
if (attachment?.type === 'image' && attachment.content) {
|
|
28
|
+
content.push({ type: 'image', data: attachment.content, mimeType: attachment.mimeType })
|
|
29
|
+
} else if (attachment?.type === 'document' && attachment.extractedText) {
|
|
30
|
+
content.push({ type: 'text', text: `\n\n[Document: ${attachment.fileName || 'Untitled'}]\n${attachment.extractedText}` })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ...message, role: 'user', content }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeMessagesForTokenEstimate(messages) {
|
|
39
|
+
return (Array.isArray(messages) ? messages : []).map(normalizeMessageForTokenEstimate)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textTokens(text) {
|
|
43
|
+
if (!text) return 0
|
|
44
|
+
return estimateTokens({ role: 'user', content: String(text), timestamp: 0 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function localMessagesTokens(messages) {
|
|
48
|
+
return normalizeMessagesForTokenEstimate(messages).reduce((total, message) => total + estimateTokens(message), 0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
52
|
+
const contextWindow = Number(model?.contextWindow) || 0
|
|
53
|
+
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
54
|
+
const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
|
|
55
|
+
const coreEstimate = estimateContextTokens(normalizedMessages)
|
|
56
|
+
const systemPromptTokens = textTokens(systemPrompt)
|
|
57
|
+
const toolsTokens = textTokens(safeJson(tools))
|
|
58
|
+
const messagesTokens = localMessagesTokens(normalizedMessages)
|
|
59
|
+
const estimatedInputTokens = systemPromptTokens + messagesTokens + toolsTokens
|
|
60
|
+
const providerBasedContextTokens = Math.max(0, Number(coreEstimate.usageTokens) || 0) > 0
|
|
61
|
+
? Math.max(0, Number(coreEstimate.tokens) || 0)
|
|
62
|
+
: 0
|
|
63
|
+
const inputTokens = providerBasedContextTokens > 0
|
|
64
|
+
? Math.max(estimatedInputTokens, providerBasedContextTokens)
|
|
65
|
+
: estimatedInputTokens
|
|
66
|
+
const totalTokens = inputTokens + reservedOutputTokens
|
|
67
|
+
const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
|
|
68
|
+
const inputTokenSource = providerBasedContextTokens > 0
|
|
69
|
+
? providerBasedContextTokens >= estimatedInputTokens ? 'provider' : 'mixed'
|
|
70
|
+
: 'estimated'
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
inputTokens,
|
|
74
|
+
estimatedInputTokens,
|
|
75
|
+
knownInputTokens: providerBasedContextTokens,
|
|
76
|
+
providerContextTokens: providerBasedContextTokens,
|
|
77
|
+
inputTokenSource,
|
|
78
|
+
reservedOutputTokens,
|
|
79
|
+
totalTokens,
|
|
80
|
+
contextWindow,
|
|
81
|
+
percent,
|
|
82
|
+
breakdown: {
|
|
83
|
+
systemPromptTokens,
|
|
84
|
+
messagesTokens,
|
|
85
|
+
toolsTokens,
|
|
86
|
+
reservedOutputTokens,
|
|
87
|
+
providerUsageTokens: Math.max(0, Number(coreEstimate.usageTokens) || 0),
|
|
88
|
+
trailingTokens: Math.max(0, Number(coreEstimate.trailingTokens) || 0),
|
|
89
|
+
lastUsageIndex: coreEstimate.lastUsageIndex,
|
|
90
|
+
localEstimatedContextTokens: estimatedInputTokens,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function shouldCompactContextByPercent(usage, thresholdPercent) {
|
|
96
|
+
const contextWindow = Number(usage?.contextWindow) || 0
|
|
97
|
+
const totalTokens = Math.max(0, Number(usage?.totalTokens) || 0)
|
|
98
|
+
const threshold = Math.min(100, Math.max(0, Number(thresholdPercent) || 0))
|
|
99
|
+
if (!contextWindow) return false
|
|
100
|
+
|
|
101
|
+
const thresholdTokens = Math.ceil(contextWindow * threshold / 100)
|
|
102
|
+
const reserveTokens = Math.min(contextWindow, Math.max(0, contextWindow - thresholdTokens + 1))
|
|
103
|
+
return shouldCompact(totalTokens, contextWindow, {
|
|
104
|
+
enabled: true,
|
|
105
|
+
reserveTokens,
|
|
106
|
+
keepRecentTokens: 0,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -1,11 +1,59 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { getEnabledPluginCommandSources } from './plugins/registry.mjs'
|
|
4
|
+
import { userCommandsDir } from './storage.mjs'
|
|
4
5
|
|
|
5
6
|
const commandsRelativeDirs = ['.claude/commands', '.opencode/commands', '.ai/commands']
|
|
6
7
|
const commandsRelativeDir = '.ai/commands'
|
|
7
8
|
const commandNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Centralized metadata for all built-in slash commands.
|
|
12
|
+
* This is the single source of truth used by /help output.
|
|
13
|
+
* Front-end i18n descriptions (i18n.ts) should be kept in sync with the
|
|
14
|
+
* `description` values here.
|
|
15
|
+
*/
|
|
16
|
+
const builtinCommandCatalog = [
|
|
17
|
+
{
|
|
18
|
+
name: 'plan',
|
|
19
|
+
description: 'Create a plan first; this turn cannot edit files or run commands.',
|
|
20
|
+
argumentHint: '[task]',
|
|
21
|
+
permissionNote: 'read-only',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'review',
|
|
25
|
+
description: 'Review pending code changes before commit; this turn cannot edit files.',
|
|
26
|
+
argumentHint: '[scope]',
|
|
27
|
+
permissionNote: 'no edits',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'compact',
|
|
31
|
+
description: 'Create a new chat with this conversation compacted to reduce context usage.',
|
|
32
|
+
argumentHint: '',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'clear',
|
|
36
|
+
description: 'Clear the current chat history and context without calling the model.',
|
|
37
|
+
argumentHint: '',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'commands',
|
|
41
|
+
description: 'List custom commands (project, user-level, and plugin).',
|
|
42
|
+
argumentHint: '',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'command new',
|
|
46
|
+
description: 'Create a project custom command template.',
|
|
47
|
+
argumentHint: '<name>',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'help',
|
|
51
|
+
aliases: ['?'],
|
|
52
|
+
description: 'Show available commands and their usage.',
|
|
53
|
+
argumentHint: '',
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
9
57
|
function normalizeCommandName(value) {
|
|
10
58
|
const name = String(value || '').trim().toLowerCase()
|
|
11
59
|
return commandNamePattern.test(name) ? name : null
|
|
@@ -103,7 +151,7 @@ function firstOptionalBoolean(...values) {
|
|
|
103
151
|
return undefined
|
|
104
152
|
}
|
|
105
153
|
|
|
106
|
-
function commandFromFile(file, text, options = {}) {
|
|
154
|
+
export function commandFromFile(file, text, options = {}) {
|
|
107
155
|
const parsed = parseFrontmatter(text)
|
|
108
156
|
if (!parsed.body) return null
|
|
109
157
|
|
|
@@ -229,13 +277,27 @@ async function listPluginCommands(workspaceRoot) {
|
|
|
229
277
|
return commands
|
|
230
278
|
}
|
|
231
279
|
|
|
280
|
+
async function listUserCommands() {
|
|
281
|
+
return listCommandsFromDirectory(userCommandsDir, {
|
|
282
|
+
source: 'user',
|
|
283
|
+
relativeRoot: '~/.quickforge/commands',
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
232
287
|
export async function listProjectCommands(workspaceRoot, commandDir) {
|
|
233
288
|
const byName = new Map()
|
|
234
289
|
|
|
290
|
+
// 1. Plugin commands (lowest priority)
|
|
235
291
|
for (const command of (await listPluginCommands(workspaceRoot)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
236
292
|
byName.set(command.name, command)
|
|
237
293
|
}
|
|
238
294
|
|
|
295
|
+
// 2. User-level commands (~/.quickforge/commands/) — override plugins, overridden by project
|
|
296
|
+
for (const command of (await listUserCommands()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
297
|
+
byName.set(command.name, command)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 3. Project directories: .claude → .opencode → .ai → configured (highest priority)
|
|
239
301
|
for (const dir of commandDirectories(workspaceRoot, commandDir)) {
|
|
240
302
|
const commands = await listCommandsFromDirectory(dir)
|
|
241
303
|
for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -297,6 +359,7 @@ function escapeXml(value) {
|
|
|
297
359
|
|
|
298
360
|
export function parseInternalCommandInvocation(message) {
|
|
299
361
|
const text = textFromUserMessage(message).trim()
|
|
362
|
+
if (/^\/(?:help|\?)(?:\s+.*)?$/i.test(text)) return { type: 'help' }
|
|
300
363
|
if (/^\/commands(?:\s+.*)?$/i.test(text)) return { type: 'list' }
|
|
301
364
|
if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
|
|
302
365
|
if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
|
|
@@ -344,14 +407,19 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
344
407
|
return 'Usage: /clear'
|
|
345
408
|
}
|
|
346
409
|
|
|
347
|
-
if (
|
|
348
|
-
return
|
|
410
|
+
if (invocation.type === 'help') {
|
|
411
|
+
return formatHelpText(await listProjectCommands(workspaceRoot, commandDir))
|
|
349
412
|
}
|
|
350
413
|
|
|
414
|
+
// /commands and /help work without a project (user-level commands are global)
|
|
351
415
|
if (invocation.type === 'list') {
|
|
352
416
|
return formatCommandList(await listProjectCommands(workspaceRoot, commandDir))
|
|
353
417
|
}
|
|
354
418
|
|
|
419
|
+
if (!workspaceRoot) {
|
|
420
|
+
return 'Custom commands require an active project chat.'
|
|
421
|
+
}
|
|
422
|
+
|
|
355
423
|
if (invocation.type === 'new') {
|
|
356
424
|
return createCommandTemplate(workspaceRoot, invocation.name)
|
|
357
425
|
}
|
|
@@ -367,17 +435,17 @@ function formatPermission(value) {
|
|
|
367
435
|
return value === false ? 'false' : 'true'
|
|
368
436
|
}
|
|
369
437
|
|
|
370
|
-
function formatCommandList(commands) {
|
|
438
|
+
export function formatCommandList(commands) {
|
|
371
439
|
if (commands.length === 0) {
|
|
372
440
|
return [
|
|
373
|
-
'No
|
|
441
|
+
'No custom commands found.',
|
|
374
442
|
'',
|
|
375
443
|
'Create one with:',
|
|
376
444
|
'```text',
|
|
377
445
|
'/command new review',
|
|
378
446
|
'```',
|
|
379
447
|
'',
|
|
380
|
-
'Or add Markdown files under `.claude/commands/`, `.opencode/commands/`,
|
|
448
|
+
'Or add Markdown files under `~/.quickforge/commands/` (user-level), or `.claude/commands/`, `.opencode/commands/`, `.ai/commands/` (project-level), for example `.ai/commands/review.md`.',
|
|
381
449
|
].join('\n')
|
|
382
450
|
}
|
|
383
451
|
|
|
@@ -389,37 +457,59 @@ function formatCommandList(commands) {
|
|
|
389
457
|
})
|
|
390
458
|
|
|
391
459
|
return [
|
|
392
|
-
'
|
|
460
|
+
'Custom commands:',
|
|
393
461
|
'',
|
|
394
462
|
...rows,
|
|
395
463
|
'',
|
|
396
|
-
'Command files live in `.claude/commands/*.md`, `.opencode/commands/*.md`, `.ai/commands/*.md`, or configured directories. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
|
|
464
|
+
'Command files live in `~/.quickforge/commands/*.md` (user-level), `.claude/commands/*.md`, `.opencode/commands/*.md`, `.ai/commands/*.md`, or configured directories. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
|
|
397
465
|
].join('\n')
|
|
398
466
|
}
|
|
399
467
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
468
|
+
function formatBuiltinCommandRows() {
|
|
469
|
+
return builtinCommandCatalog.map((cmd) => {
|
|
470
|
+
const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : ''
|
|
471
|
+
const aliases = cmd.aliases?.length
|
|
472
|
+
? ` (alias: ${cmd.aliases.map((alias) => `/${alias}`).join(', ')})`
|
|
473
|
+
: ''
|
|
474
|
+
const perm = cmd.permissionNote ? ` \[${cmd.permissionNote}\]` : ''
|
|
475
|
+
return `- \`/${cmd.name}${hint}\`${aliases} — ${cmd.description}${perm}`
|
|
476
|
+
})
|
|
477
|
+
}
|
|
405
478
|
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
479
|
+
export function formatHelpText(customCommands = []) {
|
|
480
|
+
const sections = [
|
|
481
|
+
'QuickForge command reference',
|
|
482
|
+
'',
|
|
483
|
+
'Built-in commands:',
|
|
484
|
+
'',
|
|
485
|
+
...formatBuiltinCommandRows(),
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
if (customCommands.length > 0) {
|
|
489
|
+
sections.push('', formatCommandList(customCommands))
|
|
490
|
+
} else {
|
|
491
|
+
sections.push(
|
|
492
|
+
'',
|
|
493
|
+
'No custom commands found. Add Markdown files under `~/.quickforge/commands/` (user-level) or `.claude/commands/`, `.opencode/commands/`, `.ai/commands/` (project-level).',
|
|
494
|
+
)
|
|
416
495
|
}
|
|
417
496
|
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
497
|
+
return sections.join('\n')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function createCommandTemplate(workspaceRoot, name) {
|
|
501
|
+
const result = await createCommandFile(workspaceRoot, name)
|
|
502
|
+
if (result.ok) {
|
|
503
|
+
return [
|
|
504
|
+
`Created custom command: ${result.relativePath}`,
|
|
505
|
+
'',
|
|
506
|
+
`Run it with: /${result.name} your arguments`,
|
|
507
|
+
].join('\n')
|
|
508
|
+
}
|
|
509
|
+
if (result.reason === 'exists') {
|
|
510
|
+
return `Custom command already exists: ${commandsRelativeDir}/${result.name}.md`
|
|
511
|
+
}
|
|
512
|
+
return `Invalid command name: ${name}\n\nUse lowercase letters, numbers, and hyphens.`
|
|
423
513
|
}
|
|
424
514
|
|
|
425
515
|
function commandTemplate(name) {
|
|
@@ -436,3 +526,30 @@ allow_commands: false
|
|
|
436
526
|
$ARGUMENTS
|
|
437
527
|
`
|
|
438
528
|
}
|
|
529
|
+
|
|
530
|
+
export async function createCommandFile(workspaceRoot, name) {
|
|
531
|
+
const commandName = normalizeCommandName(name)
|
|
532
|
+
if (!commandName) {
|
|
533
|
+
return { ok: false, reason: 'invalid' }
|
|
534
|
+
}
|
|
535
|
+
const dir = commandDirectory(workspaceRoot)
|
|
536
|
+
if (!dir) {
|
|
537
|
+
return { ok: false, reason: 'no-project' }
|
|
538
|
+
}
|
|
539
|
+
const file = path.join(dir, `${commandName}.md`)
|
|
540
|
+
try {
|
|
541
|
+
await fs.mkdir(dir, { recursive: true })
|
|
542
|
+
await fs.writeFile(file, commandTemplate(commandName), { encoding: 'utf8', flag: 'wx' })
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error?.code === 'EEXIST') {
|
|
545
|
+
return { ok: false, reason: 'exists', name: commandName, filePath: file }
|
|
546
|
+
}
|
|
547
|
+
throw error
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
name: commandName,
|
|
552
|
+
filePath: file,
|
|
553
|
+
relativePath: `${commandsRelativeDir}/${commandName}.md`,
|
|
554
|
+
}
|
|
555
|
+
}
|
package/server/index.mjs
CHANGED
|
@@ -537,6 +537,19 @@ await initializeActiveProject()
|
|
|
537
537
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
538
538
|
startScheduledTaskRunner()
|
|
539
539
|
|
|
540
|
+
server.on('error', (error) => {
|
|
541
|
+
// Handle listen errors (most commonly EADDRINUSE). Without this, Node would
|
|
542
|
+
// throw an uncaught exception and crash with only a raw stack trace in the log.
|
|
543
|
+
if (error.code === 'EADDRINUSE') {
|
|
544
|
+
logger.error(`Port ${port} is already in use. QuickForge could not start.`)
|
|
545
|
+
logger.error('Hint: stop the running instance with "quickforge stop" or "quickforge restart", or use a different port with QUICKFORGE_PORT=<port>.')
|
|
546
|
+
} else {
|
|
547
|
+
logger.error(`QuickForge failed to listen on ${host}:${port}: ${error.message}`)
|
|
548
|
+
}
|
|
549
|
+
flushLogger()
|
|
550
|
+
process.exit(1)
|
|
551
|
+
})
|
|
552
|
+
|
|
540
553
|
server.listen(port, host, () => {
|
|
541
554
|
logger.info(`QuickForge local API: http://${host}:${port}`)
|
|
542
555
|
if (shareLanEnabled) {
|
package/server/mcp/registry.mjs
CHANGED
|
@@ -231,6 +231,46 @@ export async function refreshMcpConnections() {
|
|
|
231
231
|
return refreshPromise
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
export async function reconnectMcpServer(name) {
|
|
235
|
+
const normalizedName = String(name || '').trim()
|
|
236
|
+
// close any existing connection first (bypasses retry backoff)
|
|
237
|
+
const existing = connections.get(normalizedName)
|
|
238
|
+
if (existing) {
|
|
239
|
+
connections.delete(normalizedName)
|
|
240
|
+
await closeConnection(existing)
|
|
241
|
+
}
|
|
242
|
+
const servers = await readMcpServers()
|
|
243
|
+
const config = servers.find((server) => server.name === normalizedName)
|
|
244
|
+
if (!config) {
|
|
245
|
+
const error = new Error(`MCP server not found: ${normalizedName}`)
|
|
246
|
+
error.statusCode = 404
|
|
247
|
+
throw error
|
|
248
|
+
}
|
|
249
|
+
if (!config.enabled) {
|
|
250
|
+
const error = new Error(`MCP server is disabled: ${normalizedName}`)
|
|
251
|
+
error.statusCode = 400
|
|
252
|
+
throw error
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const connection = await connectServer(config)
|
|
256
|
+
connections.set(config.name, connection)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
logger.error(`Failed to reconnect MCP server ${config.name}:`, error)
|
|
259
|
+
connections.set(config.name, {
|
|
260
|
+
config,
|
|
261
|
+
client: null,
|
|
262
|
+
transport: null,
|
|
263
|
+
status: 'error',
|
|
264
|
+
error: error?.message || 'Failed to connect MCP server',
|
|
265
|
+
tools: [],
|
|
266
|
+
connectedAt: null,
|
|
267
|
+
stderr: '',
|
|
268
|
+
lastAttemptAt: Date.now(),
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
return connections
|
|
272
|
+
}
|
|
273
|
+
|
|
234
274
|
export async function getMcpStatus() {
|
|
235
275
|
await refreshMcpConnections()
|
|
236
276
|
const servers = await readMcpServers()
|
package/server/routes/agent.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
steerAgent,
|
|
7
7
|
followUpAgent,
|
|
8
8
|
getSessionState,
|
|
9
|
+
getSessionStatus,
|
|
9
10
|
getSessionEventBus,
|
|
10
11
|
tryAcquireSse,
|
|
11
12
|
releaseSse,
|
|
@@ -82,7 +83,7 @@ export async function handleAgentApi(req, res, url) {
|
|
|
82
83
|
error.statusCode = 400
|
|
83
84
|
throw error
|
|
84
85
|
}
|
|
85
|
-
const result = await runPrompt(sessionId, message, body?.selectedCapabilities)
|
|
86
|
+
const result = await runPrompt(sessionId, message, body?.selectedCapabilities, body?.command)
|
|
86
87
|
sendJson(res, 200, result)
|
|
87
88
|
return
|
|
88
89
|
}
|
|
@@ -112,6 +113,24 @@ export async function handleAgentApi(req, res, url) {
|
|
|
112
113
|
return
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// GET /api/agents/:sessionId/status - get lightweight session status
|
|
117
|
+
if (req.method === 'GET' && subPath === 'status') {
|
|
118
|
+
let status = getSessionStatus(sessionId)
|
|
119
|
+
if (!status) {
|
|
120
|
+
// Try to restore from persistent storage before giving up.
|
|
121
|
+
// This recovers sessions that were evicted by idle timeout.
|
|
122
|
+
await restoreAgent(sessionId)
|
|
123
|
+
status = getSessionStatus(sessionId)
|
|
124
|
+
}
|
|
125
|
+
if (!status) {
|
|
126
|
+
const error = new Error('Session not found')
|
|
127
|
+
error.statusCode = 404
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
sendJson(res, 200, status)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
115
134
|
// POST /api/agents/:sessionId — create/ensure agent
|
|
116
135
|
if (req.method === 'POST' && parts.length === 3) {
|
|
117
136
|
const body = await readJsonBody(req)
|
package/server/routes/mcp.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sendJson, readJsonBody } from '../utils/response.mjs'
|
|
2
2
|
import { refreshAllSessionTools } from '../agent-manager.mjs'
|
|
3
3
|
import { deleteMcpServer, normalizeMcpServers, readMcpServers, setMcpServerEnabled, upsertMcpServer, writeMcpServers } from '../mcp/config.mjs'
|
|
4
|
-
import { getMcpStatus, refreshMcpConnections } from '../mcp/registry.mjs'
|
|
4
|
+
import { getMcpStatus, reconnectMcpServer, refreshMcpConnections } from '../mcp/registry.mjs'
|
|
5
5
|
|
|
6
6
|
async function refreshMcpAndAgentTools() {
|
|
7
7
|
await refreshMcpConnections()
|
|
@@ -54,6 +54,12 @@ export async function handleMcpApi(req, res, url) {
|
|
|
54
54
|
return
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
if (req.method === 'POST' && parts[0] === 'api' && parts[1] === 'mcp' && parts[2] === 'reconnect' && parts[3]) {
|
|
58
|
+
await reconnectMcpServer(decodeURIComponent(parts[3]))
|
|
59
|
+
sendJson(res, 200, await refreshMcpAndAgentTools())
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
if (req.method === 'DELETE' && parts[0] === 'api' && parts[1] === 'mcp' && parts[2] === 'servers' && parts[3]) {
|
|
58
64
|
await deleteMcpServer(decodeURIComponent(parts[3]))
|
|
59
65
|
sendJson(res, 200, await refreshMcpAndAgentTools())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
2
|
import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
|
|
3
|
-
import { listProjectCommands } from '../custom-commands.mjs'
|
|
3
|
+
import { listProjectCommands, createCommandFile } from '../custom-commands.mjs'
|
|
4
4
|
import { atomicProjectConfigUpdate } from '../storage.mjs'
|
|
5
5
|
import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.mjs'
|
|
6
6
|
import { selectDirectoryDialog, openPathInFileManager } from '../utils/platform.mjs'
|
|
@@ -20,7 +20,7 @@ export async function handleProjectApi(req, res, url) {
|
|
|
20
20
|
const project = projectId
|
|
21
21
|
? config.projects.find((item) => item.id === projectId)
|
|
22
22
|
: getActiveProject(config)
|
|
23
|
-
const commands =
|
|
23
|
+
const commands = await listProjectCommands(project?.path, project?.commandDir)
|
|
24
24
|
sendJson(res, 200, {
|
|
25
25
|
commands: commands.map((command) => ({
|
|
26
26
|
name: command.name,
|
|
@@ -29,6 +29,7 @@ export async function handleProjectApi(req, res, url) {
|
|
|
29
29
|
allowEdit: command.allowEdit,
|
|
30
30
|
allowCommands: command.allowCommands,
|
|
31
31
|
relativePath: command.relativePath,
|
|
32
|
+
filePath: command.filePath,
|
|
32
33
|
source: command.source,
|
|
33
34
|
pluginName: command.pluginName,
|
|
34
35
|
})),
|
|
@@ -107,6 +108,35 @@ export async function handleProjectApi(req, res, url) {
|
|
|
107
108
|
return
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
if (req.method === 'POST' && url.pathname === '/api/project/open-path') {
|
|
112
|
+
const body = await readJsonBody(req)
|
|
113
|
+
const active = body?.projectId
|
|
114
|
+
? config.projects.find((project) => project.id === body.projectId) ?? getActiveProject(config)
|
|
115
|
+
: getActiveProject(config)
|
|
116
|
+
const target = String(body?.path || '')
|
|
117
|
+
const resolved = target && path.isAbsolute(target)
|
|
118
|
+
? path.resolve(target)
|
|
119
|
+
: path.resolve(active?.path || '', target)
|
|
120
|
+
await openPathInFileManager(resolved)
|
|
121
|
+
sendJson(res, 200, { ok: true })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (req.method === 'POST' && url.pathname === '/api/project/command') {
|
|
126
|
+
const body = await readJsonBody(req)
|
|
127
|
+
const active = body?.projectId
|
|
128
|
+
? config.projects.find((project) => project.id === body.projectId) ?? getActiveProject(config)
|
|
129
|
+
: getActiveProject(config)
|
|
130
|
+
if (!active?.path) {
|
|
131
|
+
const error = new Error('No active project')
|
|
132
|
+
error.statusCode = 400
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
const result = await createCommandFile(active.path, body?.name)
|
|
136
|
+
sendJson(res, 200, result)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
110
140
|
if (req.method === 'DELETE' && url.pathname.startsWith('/api/project/')) {
|
|
111
141
|
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
112
142
|
const updated = await atomicProjectConfigUpdate((cfg) => {
|
|
@@ -363,7 +363,7 @@ export async function handleSharedConversationApi(req, res, url) {
|
|
|
363
363
|
assertOperate(record)
|
|
364
364
|
const body = await readJsonBody(req)
|
|
365
365
|
await restoreAgent(record.sessionId)
|
|
366
|
-
const result = await runPrompt(record.sessionId, messageFromBody(body, record, req))
|
|
366
|
+
const result = await runPrompt(record.sessionId, messageFromBody(body, record, req), [], body?.command)
|
|
367
367
|
sendJson(res, 200, result)
|
|
368
368
|
return
|
|
369
369
|
}
|
package/server/storage.mjs
CHANGED
|
@@ -94,6 +94,7 @@ export const configDir = path.join(dataDir, 'config')
|
|
|
94
94
|
export const storageDir = path.join(dataDir, 'storage')
|
|
95
95
|
export const cacheDir = path.join(dataDir, 'cache')
|
|
96
96
|
export const logsDir = path.join(dataDir, 'logs')
|
|
97
|
+
export const userCommandsDir = path.join(dataDir, 'commands')
|
|
97
98
|
|
|
98
99
|
const quickForgeConfigFile = path.join(configDir, 'config.json')
|
|
99
100
|
const configMigrationMarkerFile = path.join(configDir, '.layout-migrated')
|
|
@@ -705,26 +706,38 @@ export async function atomicProjectConfigUpdate(updateFn) {
|
|
|
705
706
|
})
|
|
706
707
|
}
|
|
707
708
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
await Promise.all([
|
|
714
|
-
fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
|
|
715
|
-
fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
|
|
716
|
-
fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
|
|
717
|
-
fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
|
|
718
|
-
fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
|
|
719
|
-
cleanOldLogs(),
|
|
720
|
-
])
|
|
721
|
-
|
|
722
|
-
await migrateUnifiedConfig()
|
|
709
|
+
// Cached storage-initialization promise. ensureStorage() is idempotent (mkdir
|
|
710
|
+
// recursive, one-shot migration gated by a marker file, ensureJsonFile), so once
|
|
711
|
+
// it succeeds we can skip the redundant syscalls (~20 per call) every later call
|
|
712
|
+
// would perform. Reset on failure so the next call can retry.
|
|
713
|
+
let storageInitPromise = null
|
|
723
714
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
715
|
+
export function ensureStorage() {
|
|
716
|
+
if (storageInitPromise) return storageInitPromise
|
|
717
|
+
storageInitPromise = (async () => {
|
|
718
|
+
await fs.mkdir(configDir, { recursive: true })
|
|
719
|
+
await fs.mkdir(storageDir, { recursive: true })
|
|
720
|
+
await fs.mkdir(cacheDir, { recursive: true })
|
|
721
|
+
await fs.mkdir(logsDir, { recursive: true })
|
|
722
|
+
await Promise.all([
|
|
723
|
+
fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
|
|
724
|
+
fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
|
|
725
|
+
fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
|
|
726
|
+
fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
|
|
727
|
+
fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
|
|
728
|
+
cleanOldLogs(),
|
|
729
|
+
])
|
|
730
|
+
|
|
731
|
+
await migrateUnifiedConfig()
|
|
732
|
+
|
|
733
|
+
await Promise.all([
|
|
734
|
+
ensureJsonFile(quickForgeConfigFile, defaultConfig()),
|
|
735
|
+
ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
|
|
736
|
+
])
|
|
737
|
+
})()
|
|
738
|
+
// Reset on failure so the next call can retry instead of caching a rejection.
|
|
739
|
+
storageInitPromise.catch(() => { storageInitPromise = null })
|
|
740
|
+
return storageInitPromise
|
|
728
741
|
}
|
|
729
742
|
|
|
730
743
|
export async function readStore(storeName) {
|