@shawnstack/quickforge 1.5.1 → 1.5.3

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 (45) hide show
  1. package/README.md +10 -10
  2. package/dist/assets/AgentProfilesPage-BToo_R3Y.js +1 -0
  3. package/dist/assets/ChatPanelHost-BTqhhkWK.js +242 -0
  4. package/dist/assets/{PluginsPage-kRzB5k8J.js → PluginsPage-DwzV2vQ4.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-Cbm6LVk3.js +2 -0
  6. package/dist/assets/{SharedConversationPage-EQdZgWCM.js → SharedConversationPage-CHE9qABz.js} +1 -1
  7. package/dist/assets/TerminalDock-Loi8A4pJ.js +2 -0
  8. package/dist/assets/WorkspaceInspector-Nf5xELW7.js +3 -0
  9. package/dist/assets/{WorkspaceReaderDialog-BwzZ8Tgv.js → WorkspaceReaderDialog-Bai7v3V0.js} +1 -1
  10. package/dist/assets/{diff-line-counts-CeZC7b0z.js → diff-line-counts-CCPYa_e0.js} +1 -1
  11. package/dist/assets/icons-DzxBk7tb.js +1 -0
  12. package/dist/assets/index-Bt_dRvdG.js +1476 -0
  13. package/dist/assets/{index-DuTUuAMk.css → index-BzaZg9Br.css} +1 -1
  14. package/dist/assets/{monaco-CNEfYIy1.js → monaco-dMY7_GLO.js} +1 -1
  15. package/dist/assets/{react-vendor-CZCcjpSR.js → react-vendor-DsAeMFcm.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +1 -0
  19. package/server/agent-manager.mjs +6 -6
  20. package/server/agent-profile-files.mjs +2 -1
  21. package/server/channels/process-channel.mjs +1 -0
  22. package/server/channels/providers/wechat.mjs +1 -0
  23. package/server/custom-commands.mjs +4 -3
  24. package/server/mcp/config.mjs +20 -20
  25. package/server/plugins/registry.mjs +1 -1
  26. package/server/project-config.mjs +2 -2
  27. package/server/routes/agent.mjs +0 -1
  28. package/server/routes/backup.mjs +84 -20
  29. package/server/routes/project.mjs +3 -2
  30. package/server/routes/scheduled-tasks.mjs +13 -121
  31. package/server/routes/static.mjs +1 -1
  32. package/server/routes/storage.mjs +13 -7
  33. package/server/session-utils.mjs +2 -1
  34. package/server/skills.mjs +3 -2
  35. package/server/storage.mjs +182 -49
  36. package/server/utils/logger.mjs +0 -1
  37. package/server/utils/package-update.mjs +2 -2
  38. package/server/utils/scheduled-tasks.mjs +127 -0
  39. package/dist/assets/AgentProfilesPage-BIwd5Nzg.js +0 -1
  40. package/dist/assets/ChatPanelHost-De-DMjx5.js +0 -242
  41. package/dist/assets/ScheduledTasksPage-ZnjohaPS.js +0 -2
  42. package/dist/assets/TerminalDock-P2pJH_tx.js +0 -2
  43. package/dist/assets/WorkspaceInspector-CkLAqYQ6.js +0 -3
  44. package/dist/assets/icons-DJqt-rnw.js +0 -1
  45. package/dist/assets/index-CcGy4TXo.js +0 -1354
@@ -5,14 +5,24 @@ import { createAgent, getSessionEventBus, agentEvents, persistSessionState } fro
5
5
  import { agentProfileSnapshot, getAgentProfile } from '../agent-profiles.mjs'
6
6
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
7
7
  import { logger } from '../utils/logger.mjs'
8
+ import {
9
+ dayMs,
10
+ formatLocalDateTime,
11
+ hourMs,
12
+ minuteMs,
13
+ nextCronRun,
14
+ nextDailyRun,
15
+ nextMonthlyRun,
16
+ nextWeeklyRun,
17
+ normalizeExecutionMode,
18
+ parseExecuteTime,
19
+ timeFromDate,
20
+ } from '../utils/scheduled-tasks.mjs'
8
21
 
9
22
  const STORE = 'scheduled-tasks'
10
23
  const RUN_CHECK_INTERVAL_MS = 30 * 1000
11
24
  const MAX_RUN_HISTORY_PER_TASK = 200
12
25
  const cronRegex = /^(\*|\d{1,2}|\d{1,2}-\d{1,2}|\d{1,2}\/\d{1,2}|\*\/\d{1,2})(\s+(\*|\d{1,2}|\d{1,2}-\d{1,2}|\d{1,2}\/\d{1,2}|\*\/\d{1,2})){4}$/
13
- const minuteMs = 60 * 1000
14
- const hourMs = 60 * minuteMs
15
- const dayMs = 24 * hourMs
16
26
  const editableScheduleTypes = new Set(['once', 'daily', 'weekly', 'monthly'])
17
27
  const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
18
28
 
@@ -24,13 +34,6 @@ function executionModeFor(task) {
24
34
  return task?.executionMode === 'parallel' ? 'parallel' : 'serial'
25
35
  }
26
36
 
27
- function normalizeExecutionMode(value) {
28
- if (value === undefined || value === null || value === '') return 'serial'
29
- const mode = String(value)
30
- if (mode === 'serial' || mode === 'parallel') return mode
31
- throw requestError('executionMode must be serial or parallel')
32
- }
33
-
34
37
  function currentRunIdsFor(task) {
35
38
  const ids = []
36
39
  if (Array.isArray(task?.currentRunIds)) ids.push(...task.currentRunIds.filter(Boolean))
@@ -72,20 +75,6 @@ function createId() {
72
75
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
73
76
  }
74
77
 
75
- function pad(value) {
76
- return String(value).padStart(2, '0')
77
- }
78
-
79
- function formatLocalDateTime(date) {
80
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
81
- }
82
-
83
- function timeFromDate(value) {
84
- const date = value ? new Date(value) : null
85
- if (!date || Number.isNaN(date.getTime())) return undefined
86
- return `${pad(date.getHours())}:${pad(date.getMinutes())}`
87
- }
88
-
89
78
  function requestError(message, statusCode = 400) {
90
79
  const error = new Error(message)
91
80
  error.statusCode = statusCode
@@ -98,67 +87,12 @@ function nonEmptyString(value, fieldName) {
98
87
  return text
99
88
  }
100
89
 
101
- function parseExecuteTime(value) {
102
- const text = String(value ?? '').trim()
103
- const match = text.match(/^(\d{1,2}):(\d{2})$/)
104
- if (!match) throw requestError('executeTime must use HH:mm format')
105
- const hours = Number(match[1])
106
- const minutes = Number(match[2])
107
- if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
108
- throw requestError('executeTime is out of range')
109
- }
110
- return `${pad(hours)}:${pad(minutes)}`
111
- }
112
-
113
- function dateWithTime(base, executeTime) {
114
- const [hours, minutes] = parseExecuteTime(executeTime).split(':').map(Number)
115
- const date = new Date(base)
116
- date.setHours(hours, minutes, 0, 0)
117
- return date
118
- }
119
-
120
90
  function parseDateTime(value, fieldName) {
121
91
  const date = new Date(value)
122
92
  if (Number.isNaN(date.getTime())) throw requestError(`${fieldName} is invalid`)
123
93
  return date
124
94
  }
125
95
 
126
- function nextDailyRun(executeTime, base = new Date()) {
127
- const next = dateWithTime(base, executeTime)
128
- if (next.getTime() <= base.getTime()) next.setDate(next.getDate() + 1)
129
- return next
130
- }
131
-
132
- function nextWeeklyRun(weekDay, executeTime, base = new Date()) {
133
- const targetDay = Number(weekDay)
134
- if (!Number.isInteger(targetDay) || targetDay < 0 || targetDay > 6) {
135
- throw requestError('weekDay must be between 0 and 6')
136
- }
137
- const next = dateWithTime(base, executeTime)
138
- let daysToAdd = (targetDay - next.getDay() + 7) % 7
139
- if (daysToAdd === 0 && next.getTime() <= base.getTime()) daysToAdd = 7
140
- next.setDate(next.getDate() + daysToAdd)
141
- return next
142
- }
143
-
144
- function monthlyCandidate(year, month, monthDay, executeTime) {
145
- const targetDay = Number(monthDay)
146
- if (!Number.isInteger(targetDay) || targetDay < 1 || targetDay > 31) {
147
- throw requestError('monthDay must be between 1 and 31')
148
- }
149
- const [hours, minutes] = parseExecuteTime(executeTime).split(':').map(Number)
150
- const lastDay = new Date(year, month + 1, 0).getDate()
151
- return new Date(year, month, Math.min(targetDay, lastDay), hours, minutes, 0, 0)
152
- }
153
-
154
- function nextMonthlyRun(monthDay, executeTime, base = new Date()) {
155
- let next = monthlyCandidate(base.getFullYear(), base.getMonth(), monthDay, executeTime)
156
- if (next.getTime() <= base.getTime()) {
157
- next = monthlyCandidate(base.getFullYear(), base.getMonth() + 1, monthDay, executeTime)
158
- }
159
- return next
160
- }
161
-
162
96
  function scheduleRuleFor(task) {
163
97
  if (task.scheduleType === 'once') return `单次 ${formatLocalDateTime(new Date(task.executeAt ?? task.nextRunAt))}`
164
98
  if (task.scheduleType === 'daily') return `每天 ${task.executeTime}`
@@ -167,48 +101,6 @@ function scheduleRuleFor(task) {
167
101
  return task.scheduleRule || task.cronExpression || '定时执行'
168
102
  }
169
103
 
170
- function parseCronField(field, min, max) {
171
- if (field === '*') return { any: true, values: [] }
172
- const values = new Set()
173
- for (const part of field.split(',')) {
174
- if (/^\*\/\d+$/.test(part)) {
175
- const step = Number(part.slice(2))
176
- for (let value = min; value <= max; value += step) values.add(value)
177
- } else if (/^\d+-\d+$/.test(part)) {
178
- const [start, end] = part.split('-').map(Number)
179
- for (let value = Math.max(start, min); value <= Math.min(end, max); value += 1) values.add(value)
180
- } else if (/^\d+$/.test(part)) {
181
- const value = Number(part)
182
- if (value >= min && value <= max) values.add(value)
183
- }
184
- }
185
- return { any: false, values: [...values] }
186
- }
187
-
188
- function cronMatches(date, cronExpression) {
189
- const fields = String(cronExpression || '').trim().split(/\s+/)
190
- if (fields.length !== 5) return false
191
- const checks = [
192
- [date.getMinutes(), parseCronField(fields[0], 0, 59)],
193
- [date.getHours(), parseCronField(fields[1], 0, 23)],
194
- [date.getDate(), parseCronField(fields[2], 1, 31)],
195
- [date.getMonth() + 1, parseCronField(fields[3], 1, 12)],
196
- [date.getDay(), parseCronField(fields[4], 0, 6)],
197
- ]
198
- return checks.every(([value, rule]) => rule.any || rule.values.includes(value))
199
- }
200
-
201
- function nextCronRun(cronExpression, base = new Date()) {
202
- const cursor = new Date(base.getTime() + minuteMs)
203
- cursor.setSeconds(0, 0)
204
- const maxChecks = 366 * 24 * 60
205
- for (let index = 0; index < maxChecks; index += 1) {
206
- if (cronMatches(cursor, cronExpression)) return cursor
207
- cursor.setMinutes(cursor.getMinutes() + 1)
208
- }
209
- return null
210
- }
211
-
212
104
  function normalizeAiJson(text) {
213
105
  const raw = String(text || '').trim()
214
106
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
@@ -48,7 +48,7 @@ function shouldFallbackToIndex(url, pathname) {
48
48
  export async function serveStatic(req, res, url) {
49
49
  const distDir = path.join(projectRoot, 'dist')
50
50
  const requested = decodeURIComponent(requestPathname(url))
51
- const normalized = path.normalize(requested).replace(/^([.][.][\/])+/, '').replace(/^[/\\]+/, '')
51
+ const normalized = path.normalize(requested).replace(/^([.][.][/])+/, '').replace(/^[/\\]+/, '')
52
52
  let filePath = path.resolve(distDir, normalized)
53
53
 
54
54
  const relative = path.relative(distDir, filePath)
@@ -1,4 +1,3 @@
1
- import path from 'node:path'
2
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
3
2
  import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
4
3
  import { directorySize } from '../utils/workspace.mjs'
@@ -7,8 +6,8 @@ const metadataIndexCache = new Map()
7
6
  const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
8
7
  const METADATA_INDEX_CACHE_TTL_MS = 1000
9
8
 
10
- function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
11
- return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
9
+ function metadataIndexCacheKey({ scope, projectId, indexName, direction, archived }) {
10
+ return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction, archived: archived || '' })
12
11
  }
13
12
 
14
13
  function sortIndexedValues(values, store, indexName, direction) {
@@ -34,7 +33,7 @@ function sortIndexedValues(values, store, indexName, direction) {
34
33
  return values
35
34
  }
36
35
 
37
- async function readIndexedValues(store, indexName, direction, scope, projectId) {
36
+ async function readIndexedValues(store, indexName, direction, scope, projectId, archived) {
38
37
  if (store !== 'sessions-metadata') {
39
38
  let data
40
39
  if (scope && store === 'sessions') {
@@ -46,7 +45,7 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
46
45
  }
47
46
 
48
47
  const revision = getStoreRevision(store)
49
- const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
48
+ const key = metadataIndexCacheKey({ scope, projectId, indexName, direction, archived })
50
49
  const cached = metadataIndexCache.get(key)
51
50
  const now = Date.now()
52
51
  if (cached && cached.revision === revision && now - cached.cachedAt < METADATA_INDEX_CACHE_TTL_MS) return cached.values
@@ -55,7 +54,13 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
55
54
  ? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
56
55
  : await readStore(store)
57
56
  const values = sortIndexedValues(
58
- Object.values(data).filter((value) => value?.messageCount !== 0),
57
+ Object.values(data)
58
+ .filter((value) => value?.messageCount !== 0)
59
+ .filter((value) => {
60
+ if (archived === 'only') return Boolean(value?.archivedAt)
61
+ if (archived === 'include') return true
62
+ return !value?.archivedAt
63
+ }),
59
64
  store,
60
65
  indexName,
61
66
  direction,
@@ -107,10 +112,11 @@ export async function handleStorageApi(req, res, url) {
107
112
  const projectId = url.searchParams.get('projectId')
108
113
  const limitParam = url.searchParams.get('limit')
109
114
  const offsetParam = url.searchParams.get('offset')
115
+ const archived = url.searchParams.get('archived')
110
116
 
111
117
  await ensureStorage()
112
118
 
113
- const values = await readIndexedValues(store, indexName, direction, scope, projectId)
119
+ const values = await readIndexedValues(store, indexName, direction, scope, projectId, archived)
114
120
 
115
121
  const total = values.length
116
122
  const limit = limitParam ? parseInt(limitParam, 10) : undefined
@@ -2,6 +2,7 @@ import { streamSimple } from '@earendil-works/pi-ai'
2
2
  import { buildInstructionsPayload, projectContextFromId } from './project-config.mjs'
3
3
  import { composeSystemPrompt } from './system-prompt.mjs'
4
4
  import { listSubagentProfiles } from './agent-profiles.mjs'
5
+ import { logger } from './utils/logger.mjs'
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // System prompt
@@ -109,7 +110,7 @@ export async function generateAiTitle(messages, model, thinkingLevel, getApiKey)
109
110
  const title = normalizeAiTitle(titleText)
110
111
  return title || null
111
112
  } catch (error) {
112
- console.warn('Failed to generate AI title:', error.message || error)
113
+ logger.warn('Failed to generate AI title:', error.message || error)
113
114
  return null
114
115
  }
115
116
  }
package/server/skills.mjs CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { dataDir } from './storage.mjs'
5
5
  import { getEnabledPluginSkillSources } from './plugins/registry.mjs'
6
+ import { logger } from './utils/logger.mjs'
6
7
 
7
8
  const userSkillsDir = path.join(dataDir, 'skills')
8
9
  const sharedUserSkillsDir = path.join(os.homedir(), '.agents', 'skills')
@@ -347,7 +348,7 @@ async function loadSkillsFromSources(sources) {
347
348
  if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
348
349
  skillsByName.set(skill.name, skill)
349
350
  } catch (error) {
350
- console.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
351
+ logger.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
351
352
  }
352
353
  }
353
354
  }
@@ -371,7 +372,7 @@ async function loadSkillsFromExplicitSources(sources) {
371
372
  if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
372
373
  skillsByName.set(skill.name, skill)
373
374
  } catch (error) {
374
- console.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
375
+ logger.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
375
376
  }
376
377
  }
377
378
  }
@@ -46,6 +46,7 @@ export async function cleanOldLogs() {
46
46
 
47
47
  export const stores = new Set([
48
48
  'settings',
49
+ 'mcp',
49
50
  'provider-keys',
50
51
  'custom-providers',
51
52
  'plugins',
@@ -74,14 +75,102 @@ export function getStoreRevision(storeName) {
74
75
  return storeRevisions.get(storeName) || 0
75
76
  }
76
77
 
77
- const configStores = new Set(['settings', 'provider-keys', 'custom-providers', 'plugins'])
78
- const sessionStores = new Set(['sessions', 'sessions-metadata'])
78
+ // Each configuration store is persisted to its own file under config/.
79
+ // "Solo" stores own a file whose root object *is* the store data.
80
+ // "Shared" stores share one file (providers.json) keyed by section, so that
81
+ // strongly-coupled provider definitions and their API keys stay in sync under
82
+ // a single write queue.
83
+ const soloConfigStores = {
84
+ settings: 'settings.json',
85
+ mcp: 'mcp-servers.json',
86
+ plugins: 'plugins.json',
87
+ }
88
+
89
+ const sharedConfigGroups = {
90
+ 'providers.json': {
91
+ queue: 'providers',
92
+ sections: {
93
+ 'provider-keys': 'providerKeys',
94
+ 'custom-providers': 'customProviders',
95
+ },
96
+ },
97
+ }
98
+
99
+ // Reverse index: storeName -> { file, queue, sectionKey|null }
100
+ const configStoreLocations = (() => {
101
+ const map = {}
102
+ for (const [store, file] of Object.entries(soloConfigStores)) {
103
+ map[store] = { file, queue: store, sectionKey: null }
104
+ }
105
+ for (const [file, group] of Object.entries(sharedConfigGroups)) {
106
+ for (const [store, sectionKey] of Object.entries(group.sections)) {
107
+ map[store] = { file, queue: group.queue, sectionKey }
108
+ }
109
+ }
110
+ return map
111
+ })()
112
+
113
+ function isConfigStore(storeName) {
114
+ return Boolean(configStoreLocations[storeName])
115
+ }
116
+
117
+ function configStoreFilePath(storeName) {
118
+ return path.join(configDir, configStoreLocations[storeName].file)
119
+ }
120
+
121
+ // Coerce a value into a plain object record (the shape every config store holds).
122
+ function asRecord(value) {
123
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
124
+ }
79
125
 
80
- const configStoreSections = {
81
- settings: ['app', 'settings'],
82
- 'provider-keys': ['credentials', 'providerKeys'],
83
- 'custom-providers': ['providers', 'customProviders'],
84
- plugins: ['extensions', 'plugins'],
126
+ // Where each config store lived in the legacy unified config.json, used only as
127
+ // a read fallback (D6 read-side safety net). Note: `mcp` was lifted out of
128
+ // `settings.mcpServers` — its legacy source is nested, not a direct section.
129
+ const legacyConfigSectionReaders = {
130
+ settings: (config) => asRecord(config.app?.settings),
131
+ mcp: (config) => ({ mcpServers: Array.isArray(config.app?.settings?.mcpServers) ? config.app.settings.mcpServers : [] }),
132
+ plugins: (config) => asRecord(config.extensions?.plugins),
133
+ 'provider-keys': (config) => asRecord(config.credentials?.providerKeys),
134
+ 'custom-providers': (config) => asRecord(config.providers?.customProviders),
135
+ }
136
+
137
+ // Read a single config store from its (possibly shared) file, with a fallback
138
+ // to the legacy unified config.json section when the split file is absent and
139
+ // the split migration has not completed yet (D6 read-side safety net, so an
140
+ // interrupted or partially-completed migration can never surface empty data).
141
+ async function readConfigStore(storeName) {
142
+ const loc = configStoreLocations[storeName]
143
+ const file = configStoreFilePath(storeName)
144
+
145
+ if (existsSync(file)) {
146
+ const content = await readJsonFile(file, {})
147
+ if (loc.sectionKey) return asRecord(content?.[loc.sectionKey])
148
+ return asRecord(content)
149
+ }
150
+
151
+ // Split file missing: only fall back to the legacy section while the split
152
+ // migration is still pending. Once `.split-migrated` exists the split files
153
+ // are authoritative, so a genuinely missing file means an empty store.
154
+ if (!existsSync(splitMigrationMarkerFile)) {
155
+ const reader = legacyConfigSectionReaders[storeName]
156
+ if (reader) return reader(await readConfigFile())
157
+ }
158
+
159
+ return {}
160
+ }
161
+
162
+ // Write a single config store back to its (possibly shared) file. For shared
163
+ // files the sibling sections are preserved. Must run inside the store's queue.
164
+ async function writeConfigStore(storeName, data) {
165
+ const loc = configStoreLocations[storeName]
166
+ const file = configStoreFilePath(storeName)
167
+ if (loc.sectionKey) {
168
+ const content = await readJsonFile(file, {})
169
+ content[loc.sectionKey] = data && typeof data === 'object' && !Array.isArray(data) ? data : {}
170
+ await writeJsonAtomic(file, content)
171
+ return
172
+ }
173
+ await writeJsonAtomic(file, data && typeof data === 'object' && !Array.isArray(data) ? data : {})
85
174
  }
86
175
 
87
176
  export function getDataDir() {
@@ -98,18 +187,16 @@ export const userCommandsDir = path.join(dataDir, 'commands')
98
187
 
99
188
  const quickForgeConfigFile = path.join(configDir, 'config.json')
100
189
  const configMigrationMarkerFile = path.join(configDir, '.layout-migrated')
190
+ const splitMigrationMarkerFile = path.join(configDir, '.split-migrated')
191
+ const projectsConfigFile = path.join(configDir, 'projects.json')
101
192
  const legacyStorageMigrationMarkerFile = path.join(storageDir, '.layout-migrated')
102
193
 
103
194
  export function storeFile(storeName) {
104
195
  assertStore(storeName)
105
- if (configStores.has(storeName)) return quickForgeConfigFile
196
+ if (isConfigStore(storeName)) return configStoreFilePath(storeName)
106
197
  return sessionStoreFile(storeName, { scope: 'global' })
107
198
  }
108
199
 
109
- export function configFile() {
110
- return quickForgeConfigFile
111
- }
112
-
113
200
  function legacyFlatStoreFile(storeName) {
114
201
  return path.join(storageDir, `${storeName}.json`)
115
202
  }
@@ -229,17 +316,6 @@ function normalizeConfig(value) {
229
316
  }
230
317
  }
231
318
 
232
- function configSection(config, storeName) {
233
- const [section, key] = configStoreSections[storeName]
234
- return config?.[section]?.[key] && typeof config[section][key] === 'object' ? config[section][key] : {}
235
- }
236
-
237
- function setConfigSection(config, storeName, data) {
238
- const [section, key] = configStoreSections[storeName]
239
- config[section] = config[section] && typeof config[section] === 'object' ? config[section] : {}
240
- config[section][key] = data && typeof data === 'object' ? data : {}
241
- }
242
-
243
319
  function sessionBucket(value) {
244
320
  if (value?.scope === 'project' && value?.projectId) {
245
321
  return { scope: 'project', projectId: String(value.projectId) }
@@ -323,6 +399,10 @@ async function readConfigFile() {
323
399
  return normalizeConfig(await readJsonFile(quickForgeConfigFile, defaultConfig()))
324
400
  }
325
401
 
402
+ // Used only within the migration chain (migrateUnifiedConfig). It persists the
403
+ // unified (pre-split) layout at layoutVersion 1 as an intermediate step; the
404
+ // subsequent migrateSplitConfig() then demotes config.json to metadata-only
405
+ // (layoutVersion 2). Do not use for normal runtime config writes.
326
406
  async function writeConfigFile(config) {
327
407
  const next = normalizeConfig(config)
328
408
  next.layoutVersion = 1
@@ -652,6 +732,68 @@ async function migrateUnifiedConfig() {
652
732
  await fs.writeFile(configMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
653
733
  }
654
734
 
735
+ // Split the legacy unified config.json into per-store files under config/.
736
+ // Each target file is only written when it does not already exist, so a
737
+ // partially-completed migration can safely resume. config.json is demoted to
738
+ // metadata only after the split succeeds.
739
+ async function migrateSplitConfig() {
740
+ if (existsSync(splitMigrationMarkerFile)) return
741
+
742
+ // Fresh installs have no unified config.json to split — just record marker.
743
+ if (!existsSync(quickForgeConfigFile)) {
744
+ await fs.writeFile(splitMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
745
+ return
746
+ }
747
+
748
+ const config = await readConfigFile()
749
+
750
+ // settings.json — mcpServers is stripped out (moved to its own store below)
751
+ if (!existsSync(path.join(configDir, 'settings.json'))) {
752
+ const oldSettings = config.app?.settings && typeof config.app.settings === 'object'
753
+ ? { ...config.app.settings }
754
+ : {}
755
+ delete oldSettings.mcpServers
756
+ await writeJsonAtomic(path.join(configDir, 'settings.json'), oldSettings)
757
+ }
758
+
759
+ // mcp-servers.json — lifted out of settings.mcpServers
760
+ if (!existsSync(path.join(configDir, 'mcp-servers.json'))) {
761
+ const mcpServers = config.app?.settings?.mcpServers
762
+ await writeJsonAtomic(path.join(configDir, 'mcp-servers.json'), {
763
+ mcpServers: Array.isArray(mcpServers) ? mcpServers : [],
764
+ })
765
+ }
766
+
767
+ // providers.json — customProviders + providerKeys kept together (coupled)
768
+ if (!existsSync(path.join(configDir, 'providers.json'))) {
769
+ const customProviders = config.providers?.customProviders && typeof config.providers.customProviders === 'object'
770
+ ? config.providers.customProviders
771
+ : {}
772
+ const providerKeys = config.credentials?.providerKeys && typeof config.credentials.providerKeys === 'object'
773
+ ? config.credentials.providerKeys
774
+ : {}
775
+ await writeJsonAtomic(path.join(configDir, 'providers.json'), { customProviders, providerKeys })
776
+ }
777
+
778
+ // plugins.json
779
+ if (!existsSync(path.join(configDir, 'plugins.json'))) {
780
+ const plugins = config.extensions?.plugins && typeof config.extensions.plugins === 'object'
781
+ ? config.extensions.plugins
782
+ : {}
783
+ await writeJsonAtomic(path.join(configDir, 'plugins.json'), plugins)
784
+ }
785
+
786
+ // projects.json
787
+ if (!existsSync(projectsConfigFile)) {
788
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(config.projects))
789
+ }
790
+
791
+ // Demote config.json to metadata only.
792
+ await writeJsonAtomic(quickForgeConfigFile, { layoutVersion: 2, migratedAt: new Date().toISOString() })
793
+
794
+ await fs.writeFile(splitMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
795
+ }
796
+
655
797
  const writeQueues = new Map()
656
798
 
657
799
  function enqueueWrite(queueName, operation) {
@@ -674,15 +816,13 @@ function enqueueWrite(queueName, operation) {
674
816
  */
675
817
  export async function atomicUpdate(storeName, updateFn) {
676
818
  assertStore(storeName)
677
- const queueName = configStores.has(storeName) ? 'config' : storeName
819
+ const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
678
820
  return enqueueWrite(queueName, async () => {
679
821
  await ensureStorage()
680
- if (configStores.has(storeName)) {
681
- const config = await readConfigFile()
682
- const data = configSection(config, storeName)
822
+ if (isConfigStore(storeName)) {
823
+ const data = await readConfigStore(storeName)
683
824
  const updated = updateFn(data)
684
- setConfigSection(config, storeName, updated)
685
- await writeConfigFile(config)
825
+ await writeConfigStore(storeName, updated)
686
826
  return updated
687
827
  }
688
828
  const data = await readSessionStore(storeName)
@@ -719,13 +859,11 @@ export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
719
859
  * Atomically read-modify-write the project config within the config queue.
720
860
  */
721
861
  export async function atomicProjectConfigUpdate(updateFn) {
722
- return enqueueWrite('config', async () => {
862
+ return enqueueWrite('projects', async () => {
723
863
  await ensureStorage()
724
- const config = await readConfigFile()
725
- const projectConfig = normalizeProjectConfig(config.projects)
864
+ const projectConfig = normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
726
865
  const updated = updateFn(projectConfig)
727
- config.projects = normalizeProjectConfig(updated)
728
- await writeConfigFile(config)
866
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(updated))
729
867
  return updated
730
868
  })
731
869
  }
@@ -756,9 +894,10 @@ export function ensureStorage() {
756
894
  ])
757
895
 
758
896
  await migrateUnifiedConfig()
897
+ await migrateSplitConfig()
759
898
 
760
899
  await Promise.all([
761
- ensureJsonFile(quickForgeConfigFile, defaultConfig()),
900
+ ensureJsonFile(quickForgeConfigFile, { layoutVersion: 2 }),
762
901
  ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
763
902
  ])
764
903
  })()
@@ -771,9 +910,8 @@ export async function readStore(storeName) {
771
910
  assertStore(storeName)
772
911
  await ensureStorage()
773
912
 
774
- if (configStores.has(storeName)) {
775
- const config = await readConfigFile()
776
- return configSection(config, storeName)
913
+ if (isConfigStore(storeName)) {
914
+ return readConfigStore(storeName)
777
915
  }
778
916
 
779
917
  return readSessionStore(storeName)
@@ -781,15 +919,13 @@ export async function readStore(storeName) {
781
919
 
782
920
  export async function writeStore(storeName, data) {
783
921
  assertStore(storeName)
784
- const queueName = configStores.has(storeName) ? 'config' : storeName
922
+ const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
785
923
 
786
924
  return enqueueWrite(queueName, async () => {
787
925
  await ensureStorage()
788
926
 
789
- if (configStores.has(storeName)) {
790
- const config = await readConfigFile()
791
- setConfigSection(config, storeName, data)
792
- await writeConfigFile(config)
927
+ if (isConfigStore(storeName)) {
928
+ await writeConfigStore(storeName, data)
793
929
  return
794
930
  }
795
931
 
@@ -799,16 +935,13 @@ export async function writeStore(storeName, data) {
799
935
 
800
936
  export async function readProjectConfigData() {
801
937
  await ensureStorage()
802
- const config = await readConfigFile()
803
- return normalizeProjectConfig(config.projects)
938
+ return normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
804
939
  }
805
940
 
806
941
  export async function writeProjectConfigData(projectConfig) {
807
- return enqueueWrite('config', async () => {
942
+ return enqueueWrite('projects', async () => {
808
943
  await ensureStorage()
809
- const config = await readConfigFile()
810
- config.projects = normalizeProjectConfig(projectConfig)
811
- await writeConfigFile(config)
944
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(projectConfig))
812
945
  })
813
946
  }
814
947
 
@@ -4,7 +4,6 @@ import { logsDir } from '../storage.mjs'
4
4
 
5
5
  // --- Level control ---
6
6
  const LEVELS = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 }
7
- const levelNames = Object.keys(LEVELS)
8
7
 
9
8
  const envLevel = (process.env.QUICKFORGE_LOG_LEVEL || '').toUpperCase()
10
9
  const minLevel = LEVELS[envLevel] ?? LEVELS.INFO
@@ -27,7 +27,7 @@ export async function getPackageInfo(projectRoot) {
27
27
  bugsUrl: typeof pkg.bugs === 'string' ? pkg.bugs : pkg.bugs?.url || '',
28
28
  }
29
29
  } catch (error) {
30
- throw new Error(`Unable to read package metadata: ${error.message}`)
30
+ throw new Error(`Unable to read package metadata: ${error.message}`, { cause: error })
31
31
  }
32
32
  }
33
33
 
@@ -109,7 +109,7 @@ export async function fetchLatestVersion(packageName) {
109
109
  if (!latest || typeof latest !== 'string') throw new Error('latest version not found in registry response')
110
110
  return latest
111
111
  } catch (error) {
112
- if (error.name === 'AbortError') throw new Error('request timeout')
112
+ if (error.name === 'AbortError') throw new Error('request timeout', { cause: error })
113
113
  throw error
114
114
  } finally {
115
115
  clearTimeout(timeout)