@shawnstack/quickforge 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +12 -12
  2. package/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
  4. package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
  5. package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
  7. package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
  8. package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
  9. package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
  11. package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
  12. package/dist/assets/icons-47L5YLKz.js +1 -0
  13. package/dist/assets/index-CqfScETb.js +1200 -0
  14. package/dist/assets/index-DzkBgHZf.css +3 -0
  15. package/dist/assets/{monaco-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-CiCXOLb5.js → react-vendor-DunfCFfp.js} +1 -1
  17. package/dist/favicon.svg +16 -1
  18. package/dist/index.html +5 -5
  19. package/dist/manifest.webmanifest +30 -30
  20. package/package.json +3 -2
  21. package/server/acp/server.mjs +921 -0
  22. package/server/agent-manager.mjs +283 -45
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +111 -112
  27. package/server/channels/process-channel.mjs +278 -0
  28. package/server/channels/providers/wechat.mjs +271 -0
  29. package/server/channels/registry.mjs +58 -0
  30. package/server/context-usage.mjs +108 -0
  31. package/server/custom-commands.mjs +157 -28
  32. package/server/frontmatter.mjs +167 -0
  33. package/server/index.mjs +52 -3
  34. package/server/mcp/registry.mjs +40 -0
  35. package/server/project-config.mjs +43 -6
  36. package/server/routes/agent-profiles.mjs +6 -2
  37. package/server/routes/agent.mjs +13 -2
  38. package/server/routes/channels.mjs +145 -0
  39. package/server/routes/mcp.mjs +7 -1
  40. package/server/routes/models.mjs +68 -0
  41. package/server/routes/project.mjs +34 -4
  42. package/server/routes/scheduled-tasks.mjs +6 -5
  43. package/server/routes/shared-conversation.mjs +1 -1
  44. package/server/routes/storage.mjs +4 -2
  45. package/server/routes/system.mjs +27 -0
  46. package/server/routes/tools.mjs +17 -6
  47. package/server/routes/workspace.mjs +138 -0
  48. package/server/session-utils.mjs +10 -2
  49. package/server/storage.mjs +30 -2
  50. package/server/subagents.mjs +8 -6
  51. package/server/system-prompt.mjs +3 -2
  52. package/server/tools/definitions.mjs +19 -1
  53. package/server/tools/index.mjs +83 -0
  54. package/server/utils/package-update.mjs +156 -0
  55. package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
  56. package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
  57. package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
  58. package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
  59. package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
  60. package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
  61. package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
  62. package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
  63. package/dist/assets/icons-Dsc5yL3l.js +0 -1
  64. package/dist/assets/index-CPAWYhzz.css +0 -3
  65. package/dist/assets/index-YTL26wyJ.js +0 -814
@@ -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,64 @@
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: 'summary',
31
+ description: 'Create a new chat with this conversation summarized to reduce context usage.',
32
+ argumentHint: '',
33
+ },
34
+ {
35
+ name: 'compact',
36
+ description: 'Compact this conversation context in place using the same rolling summary as auto-compaction.',
37
+ argumentHint: '',
38
+ },
39
+ {
40
+ name: 'clear',
41
+ description: 'Clear the current chat history and context without calling the model.',
42
+ argumentHint: '',
43
+ },
44
+ {
45
+ name: 'commands',
46
+ description: 'List custom commands (project, user-level, and plugin).',
47
+ argumentHint: '',
48
+ },
49
+ {
50
+ name: 'command new',
51
+ description: 'Create a project custom command template.',
52
+ argumentHint: '<name>',
53
+ },
54
+ {
55
+ name: 'help',
56
+ aliases: ['?'],
57
+ description: 'Show available commands and their usage.',
58
+ argumentHint: '',
59
+ },
60
+ ]
61
+
9
62
  function normalizeCommandName(value) {
10
63
  const name = String(value || '').trim().toLowerCase()
11
64
  return commandNamePattern.test(name) ? name : null
@@ -103,7 +156,7 @@ function firstOptionalBoolean(...values) {
103
156
  return undefined
104
157
  }
105
158
 
106
- function commandFromFile(file, text, options = {}) {
159
+ export function commandFromFile(file, text, options = {}) {
107
160
  const parsed = parseFrontmatter(text)
108
161
  if (!parsed.body) return null
109
162
 
@@ -229,13 +282,27 @@ async function listPluginCommands(workspaceRoot) {
229
282
  return commands
230
283
  }
231
284
 
285
+ async function listUserCommands() {
286
+ return listCommandsFromDirectory(userCommandsDir, {
287
+ source: 'user',
288
+ relativeRoot: '~/.quickforge/commands',
289
+ })
290
+ }
291
+
232
292
  export async function listProjectCommands(workspaceRoot, commandDir) {
233
293
  const byName = new Map()
234
294
 
295
+ // 1. Plugin commands (lowest priority)
235
296
  for (const command of (await listPluginCommands(workspaceRoot)).sort((a, b) => a.name.localeCompare(b.name))) {
236
297
  byName.set(command.name, command)
237
298
  }
238
299
 
300
+ // 2. User-level commands (~/.quickforge/commands/) — override plugins, overridden by project
301
+ for (const command of (await listUserCommands()).sort((a, b) => a.name.localeCompare(b.name))) {
302
+ byName.set(command.name, command)
303
+ }
304
+
305
+ // 3. Project directories: .claude → .opencode → .ai → configured (highest priority)
239
306
  for (const dir of commandDirectories(workspaceRoot, commandDir)) {
240
307
  const commands = await listCommandsFromDirectory(dir)
241
308
  for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
@@ -297,6 +364,7 @@ function escapeXml(value) {
297
364
 
298
365
  export function parseInternalCommandInvocation(message) {
299
366
  const text = textFromUserMessage(message).trim()
367
+ if (/^\/(?:help|\?)(?:\s+.*)?$/i.test(text)) return { type: 'help' }
300
368
  if (/^\/commands(?:\s+.*)?$/i.test(text)) return { type: 'list' }
301
369
  if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
302
370
  if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
@@ -310,6 +378,9 @@ export function parseInternalCommandInvocation(message) {
310
378
  const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
311
379
  if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
312
380
 
381
+ const summaryMatch = text.match(/^\/summary(?:\s+([\s\S]*))?$/i)
382
+ if (summaryMatch) return { type: 'summary', args: (summaryMatch[1] || '').trim() }
383
+
313
384
  const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
314
385
  if (createMatch) {
315
386
  const name = normalizeCommandName(createMatch[1])
@@ -326,6 +397,10 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
326
397
  return { compact: true, args: invocation.args || '' }
327
398
  }
328
399
 
400
+ if (invocation.type === 'summary') {
401
+ return { summary: true, args: invocation.args || '' }
402
+ }
403
+
329
404
  if (invocation.type === 'plan') {
330
405
  if (!invocation.args) return 'Usage: /plan <task>'
331
406
  return { plan: true, args: invocation.args }
@@ -344,14 +419,19 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
344
419
  return 'Usage: /clear'
345
420
  }
346
421
 
347
- if (!workspaceRoot) {
348
- return 'Custom commands require an active project chat.'
422
+ if (invocation.type === 'help') {
423
+ return formatHelpText(await listProjectCommands(workspaceRoot, commandDir))
349
424
  }
350
425
 
426
+ // /commands and /help work without a project (user-level commands are global)
351
427
  if (invocation.type === 'list') {
352
428
  return formatCommandList(await listProjectCommands(workspaceRoot, commandDir))
353
429
  }
354
430
 
431
+ if (!workspaceRoot) {
432
+ return 'Custom commands require an active project chat.'
433
+ }
434
+
355
435
  if (invocation.type === 'new') {
356
436
  return createCommandTemplate(workspaceRoot, invocation.name)
357
437
  }
@@ -367,17 +447,17 @@ function formatPermission(value) {
367
447
  return value === false ? 'false' : 'true'
368
448
  }
369
449
 
370
- function formatCommandList(commands) {
450
+ export function formatCommandList(commands) {
371
451
  if (commands.length === 0) {
372
452
  return [
373
- 'No project custom commands found.',
453
+ 'No custom commands found.',
374
454
  '',
375
455
  'Create one with:',
376
456
  '```text',
377
457
  '/command new review',
378
458
  '```',
379
459
  '',
380
- 'Or add Markdown files under `.claude/commands/`, `.opencode/commands/`, or `.ai/commands/`, for example `.ai/commands/review.md`.',
460
+ '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
461
  ].join('\n')
382
462
  }
383
463
 
@@ -389,37 +469,59 @@ function formatCommandList(commands) {
389
469
  })
390
470
 
391
471
  return [
392
- 'Project custom commands:',
472
+ 'Custom commands:',
393
473
  '',
394
474
  ...rows,
395
475
  '',
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.',
476
+ '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
477
  ].join('\n')
398
478
  }
399
479
 
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
- }
480
+ function formatBuiltinCommandRows() {
481
+ return builtinCommandCatalog.map((cmd) => {
482
+ const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : ''
483
+ const aliases = cmd.aliases?.length
484
+ ? ` (alias: ${cmd.aliases.map((alias) => `/${alias}`).join(', ')})`
485
+ : ''
486
+ const perm = cmd.permissionNote ? ` \[${cmd.permissionNote}\]` : ''
487
+ return `- \`/${cmd.name}${hint}\`${aliases} — ${cmd.description}${perm}`
488
+ })
489
+ }
405
490
 
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
491
+ export function formatHelpText(customCommands = []) {
492
+ const sections = [
493
+ 'QuickForge command reference',
494
+ '',
495
+ 'Built-in commands:',
496
+ '',
497
+ ...formatBuiltinCommandRows(),
498
+ ]
499
+
500
+ if (customCommands.length > 0) {
501
+ sections.push('', formatCommandList(customCommands))
502
+ } else {
503
+ sections.push(
504
+ '',
505
+ 'No custom commands found. Add Markdown files under `~/.quickforge/commands/` (user-level) or `.claude/commands/`, `.opencode/commands/`, `.ai/commands/` (project-level).',
506
+ )
416
507
  }
417
508
 
418
- return [
419
- `Created custom command: ${commandsRelativeDir}/${commandName}.md`,
420
- '',
421
- `Run it with: /${commandName} your arguments`,
422
- ].join('\n')
509
+ return sections.join('\n')
510
+ }
511
+
512
+ async function createCommandTemplate(workspaceRoot, name) {
513
+ const result = await createCommandFile(workspaceRoot, name)
514
+ if (result.ok) {
515
+ return [
516
+ `Created custom command: ${result.relativePath}`,
517
+ '',
518
+ `Run it with: /${result.name} your arguments`,
519
+ ].join('\n')
520
+ }
521
+ if (result.reason === 'exists') {
522
+ return `Custom command already exists: ${commandsRelativeDir}/${result.name}.md`
523
+ }
524
+ return `Invalid command name: ${name}\n\nUse lowercase letters, numbers, and hyphens.`
423
525
  }
424
526
 
425
527
  function commandTemplate(name) {
@@ -436,3 +538,30 @@ allow_commands: false
436
538
  $ARGUMENTS
437
539
  `
438
540
  }
541
+
542
+ export async function createCommandFile(workspaceRoot, name) {
543
+ const commandName = normalizeCommandName(name)
544
+ if (!commandName) {
545
+ return { ok: false, reason: 'invalid' }
546
+ }
547
+ const dir = commandDirectory(workspaceRoot)
548
+ if (!dir) {
549
+ return { ok: false, reason: 'no-project' }
550
+ }
551
+ const file = path.join(dir, `${commandName}.md`)
552
+ try {
553
+ await fs.mkdir(dir, { recursive: true })
554
+ await fs.writeFile(file, commandTemplate(commandName), { encoding: 'utf8', flag: 'wx' })
555
+ } catch (error) {
556
+ if (error?.code === 'EEXIST') {
557
+ return { ok: false, reason: 'exists', name: commandName, filePath: file }
558
+ }
559
+ throw error
560
+ }
561
+ return {
562
+ ok: true,
563
+ name: commandName,
564
+ filePath: file,
565
+ relativePath: `${commandsRelativeDir}/${commandName}.md`,
566
+ }
567
+ }
@@ -0,0 +1,167 @@
1
+ function leadingIndent(line) {
2
+ const match = String(line || '').match(/^\s*/)
3
+ return match ? match[0].length : 0
4
+ }
5
+
6
+ function stripInlineComment(value) {
7
+ const trimmed = String(value ?? '').trim()
8
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
9
+ const index = trimmed.indexOf(' #')
10
+ return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
11
+ }
12
+
13
+ export function parseYamlScalar(value, options = {}) {
14
+ const trimmed = stripInlineComment(value)
15
+ if (!trimmed) return ''
16
+ if (options.booleans !== false) {
17
+ const normalized = trimmed.toLowerCase()
18
+ if (normalized === 'true') return true
19
+ if (normalized === 'false') return false
20
+ }
21
+
22
+ if (
23
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
24
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
25
+ ) {
26
+ return trimmed
27
+ .slice(1, -1)
28
+ .replace(/\\"/g, '"')
29
+ .replace(/\\'/g, "'")
30
+ }
31
+
32
+ return trimmed
33
+ }
34
+
35
+ function collectIndentedBlock(lines, startIndex, parentIndent) {
36
+ const block = []
37
+ let index = startIndex
38
+ while (index < lines.length) {
39
+ const line = lines[index]
40
+ if (!line.trim()) {
41
+ block.push(line)
42
+ index++
43
+ continue
44
+ }
45
+ if (leadingIndent(line) <= parentIndent) break
46
+ block.push(line)
47
+ index++
48
+ }
49
+ return { block, nextIndex: index }
50
+ }
51
+
52
+ function parseBlockScalar(lines, style) {
53
+ const nonEmpty = lines.filter((line) => line.trim())
54
+ const minIndent = nonEmpty.length
55
+ ? Math.min(...nonEmpty.map((line) => leadingIndent(line)))
56
+ : 0
57
+ const unindented = lines.map((line) => line.slice(Math.min(minIndent, line.length)))
58
+ return style === '>'
59
+ ? unindented.join(' ').replace(/\s+/g, ' ').trim()
60
+ : unindented.join('\n').trim()
61
+ }
62
+
63
+ export function parseSimpleYamlMap(text, options = {}) {
64
+ const result = {}
65
+ const lines = String(text || '').split(/\r?\n/)
66
+ let index = 0
67
+
68
+ while (index < lines.length) {
69
+ const line = lines[index]
70
+ const trimmed = line.trim()
71
+ if (!trimmed || trimmed.startsWith('#') || leadingIndent(line) > 0) {
72
+ index++
73
+ continue
74
+ }
75
+
76
+ const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
77
+ if (!match) {
78
+ index++
79
+ continue
80
+ }
81
+
82
+ const [, key, rawValue = ''] = match
83
+ const value = rawValue.trim()
84
+
85
+ if (value === '|' || value === '>') {
86
+ const { block, nextIndex } = collectIndentedBlock(lines, index + 1, 0)
87
+ result[key] = parseBlockScalar(block, value)
88
+ index = nextIndex
89
+ continue
90
+ }
91
+
92
+ if (value) {
93
+ result[key] = parseYamlScalar(value, options)
94
+ index++
95
+ continue
96
+ }
97
+
98
+ const nested = {}
99
+ let nestedIndex = index + 1
100
+ while (nestedIndex < lines.length) {
101
+ const nestedLine = lines[nestedIndex]
102
+ const nestedTrimmed = nestedLine.trim()
103
+ if (!nestedTrimmed || nestedTrimmed.startsWith('#')) {
104
+ nestedIndex++
105
+ continue
106
+ }
107
+
108
+ const indent = leadingIndent(nestedLine)
109
+ if (indent <= 0) break
110
+
111
+ const nestedMatch = nestedLine.slice(indent).match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
112
+ if (!nestedMatch) {
113
+ nestedIndex++
114
+ continue
115
+ }
116
+
117
+ const [, nestedKey, nestedRawValue = ''] = nestedMatch
118
+ nested[nestedKey] = parseYamlScalar(nestedRawValue.trim(), options)
119
+ nestedIndex++
120
+ }
121
+
122
+ result[key] = Object.keys(nested).length ? nested : ''
123
+ index = Object.keys(nested).length ? nestedIndex : index + 1
124
+ }
125
+
126
+ return result
127
+ }
128
+
129
+ export function parseFrontmatter(text, options = {}) {
130
+ const normalized = String(text || '').replace(/^\uFEFF/, '')
131
+ const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
132
+ if (!match) {
133
+ return options.requireFrontmatter ? null : { metadata: {}, frontmatter: '', body: normalized.trim() }
134
+ }
135
+ return {
136
+ metadata: parseSimpleYamlMap(match[1], options),
137
+ frontmatter: match[1],
138
+ body: match[2].trim(),
139
+ }
140
+ }
141
+
142
+ export function firstString(...values) {
143
+ for (const value of values) {
144
+ if (typeof value === 'string' && value.trim()) return value.trim()
145
+ }
146
+ return undefined
147
+ }
148
+
149
+ export function firstOptionalBoolean(...values) {
150
+ for (const value of values) {
151
+ if (value === true || value === false) return value
152
+ if (typeof value === 'string') {
153
+ const normalized = value.trim().toLowerCase()
154
+ if (normalized === 'true') return true
155
+ if (normalized === 'false') return false
156
+ }
157
+ }
158
+ return undefined
159
+ }
160
+
161
+ export function splitDelimitedList(value) {
162
+ if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean)
163
+ return String(value || '')
164
+ .split(',')
165
+ .map((item) => item.trim())
166
+ .filter(Boolean)
167
+ }
package/server/index.mjs CHANGED
@@ -27,13 +27,17 @@ import { handleMcpApi } from './routes/mcp.mjs'
27
27
  import { handlePluginsApi } from './routes/plugins.mjs'
28
28
  import { handleWorkspaceApi, handleGitApi } from './routes/workspace.mjs'
29
29
  import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
30
+ import { handleChannelsApi } from './routes/channels.mjs'
31
+ import { handleModelsApi } from './routes/models.mjs'
30
32
  import { serveStatic } from './routes/static.mjs'
31
33
  import { logger, flushLogger } from './utils/logger.mjs'
34
+ import { getPackageInfo, checkForUpdates, installLatestVersion } from './utils/package-update.mjs'
32
35
  import { installAiHttpLogger } from './ai-http-logger.mjs'
33
36
  import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
34
37
  import { parseCookies } from './share-store.mjs'
35
38
  import { lanAccessCookieName, verifyLanAccessToken } from './lan-access-store.mjs'
36
39
  import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
40
+ import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
37
41
  import { shutdownMcpConnections } from './mcp/registry.mjs'
38
42
  import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
39
43
 
@@ -54,8 +58,9 @@ if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_R
54
58
  const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
55
59
  const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
56
60
  let restartInProgress = false
61
+ let updateInProgress = false
57
62
 
58
- setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || projectRoot)
63
+ setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
59
64
  installAiHttpLogger()
60
65
 
61
66
  function getRestartSupport() {
@@ -139,6 +144,7 @@ async function performRestart() {
139
144
  stopVite()
140
145
  await shutdownAgentManager()
141
146
  await shutdownMcpConnections()
147
+ await shutdownChannels()
142
148
  shutdownTerminalSessions()
143
149
  await closeHttpServer()
144
150
  process.exit(0)
@@ -169,6 +175,29 @@ async function requestRestart() {
169
175
  return { ok: true, restarting: true, bootId }
170
176
  }
171
177
 
178
+ async function updateQuickForge() {
179
+ if (updateInProgress) {
180
+ const error = new Error('Update already in progress')
181
+ error.statusCode = 423
182
+ throw error
183
+ }
184
+
185
+ updateInProgress = true
186
+ try {
187
+ const update = await checkForUpdates(projectRoot)
188
+ if (!update.updateAvailable) {
189
+ return { ...update, ok: true, updated: false }
190
+ }
191
+
192
+ logger.info(`Updating QuickForge from ${update.currentVersion} to ${update.latestVersion}.`)
193
+ await installLatestVersion(update.name, { cwd: projectRoot })
194
+ logger.info('QuickForge update completed.')
195
+ return { ...update, ok: true, updated: true }
196
+ } finally {
197
+ updateInProgress = false
198
+ }
199
+ }
200
+
172
201
  // --- Route dispatching ---
173
202
  async function handleApi(req, res, url) {
174
203
  const pathname = url.pathname
@@ -216,6 +245,12 @@ async function handleApi(req, res, url) {
216
245
  return
217
246
  }
218
247
 
248
+ // Custom model management (connection test)
249
+ if (pathname === '/api/models/test-connection') {
250
+ await handleModelsApi(req, res, url)
251
+ return
252
+ }
253
+
219
254
  // Skills
220
255
  if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
221
256
  await handleSkillsApi(req, res, url)
@@ -228,6 +263,14 @@ async function handleApi(req, res, url) {
228
263
  return
229
264
  }
230
265
 
266
+ // Channels
267
+ if (pathname === '/api/channels' || pathname.startsWith('/api/channels/')) {
268
+ await handleChannelsApi(req, res, url, {
269
+ isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
270
+ })
271
+ return
272
+ }
273
+
231
274
  // Plugins
232
275
  if (pathname === '/api/plugins' || pathname.startsWith('/api/plugins/')) {
233
276
  await handlePluginsApi(req, res, url)
@@ -241,7 +284,7 @@ async function handleApi(req, res, url) {
241
284
  }
242
285
 
243
286
  // Project workspace inspector routes
244
- if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
287
+ if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname.startsWith('/api/workspace/preview/')) {
245
288
  await handleWorkspaceApi(req, res, url)
246
289
  return
247
290
  }
@@ -288,10 +331,14 @@ async function handleApi(req, res, url) {
288
331
  }
289
332
 
290
333
  // System routes
291
- if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell') {
334
+ if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell' || pathname === '/api/system/about' || pathname === '/api/system/update/check' || pathname === '/api/system/update') {
292
335
  await handleSystemApi(req, res, url, {
293
336
  getSystemStatus,
294
337
  requestRestart,
338
+ getPackageInfo: () => getPackageInfo(projectRoot),
339
+ checkForUpdates: () => checkForUpdates(projectRoot),
340
+ updateQuickForge,
341
+ isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
295
342
  getTerminalShellSetting: readTerminalShellSetting,
296
343
  updateTerminalShellSetting,
297
344
  getTerminalShellConfig: readTerminalShellConfig,
@@ -532,6 +579,7 @@ server.on('upgrade', (req, socket, head) => {
532
579
  })
533
580
 
534
581
  await ensureStorage()
582
+ initializeChannels({ projectRoot })
535
583
  await resetStaleTaskStatuses()
536
584
  await initializeActiveProject()
537
585
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
@@ -577,6 +625,7 @@ async function gracefulShutdown(signal) {
577
625
  stopVite()
578
626
  await shutdownAgentManager()
579
627
  await shutdownMcpConnections()
628
+ await shutdownChannels()
580
629
  shutdownTerminalSessions()
581
630
  flushLogger()
582
631
  process.exit(0)