@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.
Files changed (60) hide show
  1. package/README.md +12 -12
  2. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +1 -0
  3. package/dist/assets/ChatPanelHost-FqPQwwMO.js +217 -0
  4. package/dist/assets/PluginsPage-BCu1Ept0.js +1 -0
  5. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +2 -0
  6. package/dist/assets/SharedConversationPage-55vX9sqe.js +1 -0
  7. package/dist/assets/TerminalDock-DLN_pLkJ.js +2 -0
  8. package/dist/assets/WorkspaceInspector-DoemHHnY.js +3 -0
  9. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +6 -0
  10. package/dist/assets/{icons-BVM5--R9.js → icons-BWtivFsx.js} +1 -1
  11. package/dist/assets/index-CxOHP41X.css +3 -0
  12. package/dist/assets/index-Dcf73EL8.js +895 -0
  13. package/dist/assets/logger-B65Akg8A.js +1 -0
  14. package/dist/assets/monaco-evITXh-m.js +11 -0
  15. package/dist/assets/pi-ai-Cx633yhb.js +134 -0
  16. package/dist/assets/pi-web-ui-CBet4bMl.js +2770 -0
  17. package/dist/assets/plugin-api-YfYj_Bd7.js +1 -0
  18. package/dist/assets/{react-vendor-DAoL5p8_.js → react-vendor-Mthyt1p4.js} +1 -1
  19. package/dist/assets/rolldown-runtime-DWdDZTNf.js +1 -0
  20. package/dist/assets/xterm-5XDrJ343.js +36 -0
  21. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  22. package/dist/index.html +8 -5
  23. package/package.json +1 -1
  24. package/server/agent-manager.mjs +189 -31
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +63 -72
  27. package/server/context-usage.mjs +108 -0
  28. package/server/custom-commands.mjs +145 -28
  29. package/server/index.mjs +13 -0
  30. package/server/mcp/registry.mjs +40 -0
  31. package/server/routes/agent.mjs +20 -1
  32. package/server/routes/mcp.mjs +7 -1
  33. package/server/routes/project.mjs +32 -2
  34. package/server/routes/shared-conversation.mjs +1 -1
  35. package/server/storage.mjs +32 -19
  36. package/server/subagents.mjs +8 -6
  37. package/server/system-prompt.mjs +2 -2
  38. package/server/tools/definitions.mjs +1 -1
  39. package/server/utils/logger.mjs +0 -2
  40. package/dist/assets/anthropic-DYkQmon0.js +0 -39
  41. package/dist/assets/azure-openai-responses-B1_ZuuCX.js +0 -1
  42. package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
  43. package/dist/assets/google-Bx1PGUtS.js +0 -1
  44. package/dist/assets/google-shared-Cqjw1plk.js +0 -11
  45. package/dist/assets/google-vertex-1iRQw75f.js +0 -1
  46. package/dist/assets/hash-kZ2KD_no.js +0 -1
  47. package/dist/assets/headers-5EYI0_pl.js +0 -1
  48. package/dist/assets/index-CQq-kPng.js +0 -3837
  49. package/dist/assets/index-D0c0FMPa.css +0 -3
  50. package/dist/assets/mistral-B1j5S2k5.js +0 -44
  51. package/dist/assets/openai-Bf1npfRy.js +0 -16
  52. package/dist/assets/openai-codex-responses-BJKEqst-.js +0 -7
  53. package/dist/assets/openai-completions-B_cU49Pc.js +0 -5
  54. package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
  55. package/dist/assets/openai-responses-DgGY16ph.js +0 -1
  56. package/dist/assets/openai-responses-shared-J1-i-goZ.js +0 -12
  57. package/dist/assets/openrouter-BVaMghZV.js +0 -1
  58. package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
  59. package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
  60. 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 (!workspaceRoot) {
348
- return 'Custom commands require an active project chat.'
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 project custom commands found.',
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/`, or `.ai/commands/`, for example `.ai/commands/review.md`.',
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
- 'Project custom commands:',
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
- async function createCommandTemplate(workspaceRoot, name) {
401
- const commandName = normalizeCommandName(name)
402
- if (!commandName) {
403
- return `Invalid command name: ${name}\n\nUse lowercase letters, numbers, and hyphens.`
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
- const dir = commandDirectory(workspaceRoot)
407
- const file = path.join(dir, `${commandName}.md`)
408
- try {
409
- await fs.mkdir(dir, { recursive: true })
410
- await fs.writeFile(file, commandTemplate(commandName), { encoding: 'utf8', flag: 'wx' })
411
- } catch (error) {
412
- if (error?.code === 'EEXIST') {
413
- return `Custom command already exists: ${commandsRelativeDir}/${commandName}.md`
414
- }
415
- throw error
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
- `Created custom command: ${commandsRelativeDir}/${commandName}.md`,
420
- '',
421
- `Run it with: /${commandName} your arguments`,
422
- ].join('\n')
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) {
@@ -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()
@@ -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)
@@ -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 = project?.path ? await listProjectCommands(project.path, project.commandDir) : []
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
  }
@@ -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
- export async function ensureStorage() {
709
- await fs.mkdir(configDir, { recursive: true })
710
- await fs.mkdir(storageDir, { recursive: true })
711
- await fs.mkdir(cacheDir, { recursive: true })
712
- await fs.mkdir(logsDir, { recursive: true })
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
- await Promise.all([
725
- ensureJsonFile(quickForgeConfigFile, defaultConfig()),
726
- ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
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) {