@shawnstack/quickforge 1.4.1 → 1.5.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/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-BIwd5Nzg.js +1 -0
  4. package/dist/assets/ChatPanelHost-De-DMjx5.js +242 -0
  5. package/dist/assets/PluginsPage-kRzB5k8J.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-ZnjohaPS.js +2 -0
  7. package/dist/assets/SharedConversationPage-EQdZgWCM.js +1 -0
  8. package/dist/assets/TerminalDock-P2pJH_tx.js +2 -0
  9. package/dist/assets/WorkspaceInspector-CkLAqYQ6.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BwzZ8Tgv.js +1 -0
  11. package/dist/assets/diff-line-counts-CeZC7b0z.js +10 -0
  12. package/dist/assets/icons-DJqt-rnw.js +1 -0
  13. package/dist/assets/index-CcGy4TXo.js +1354 -0
  14. package/dist/assets/index-DuTUuAMk.css +3 -0
  15. package/dist/assets/{monaco-evITXh-m.js → monaco-CNEfYIy1.js} +1 -1
  16. package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-CZCcjpSR.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 +200 -34
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/auto-compaction.mjs +82 -39
  26. package/server/channels/process-channel.mjs +278 -0
  27. package/server/channels/providers/wechat.mjs +271 -0
  28. package/server/channels/registry.mjs +58 -0
  29. package/server/custom-commands.mjs +13 -1
  30. package/server/frontmatter.mjs +167 -0
  31. package/server/index.mjs +52 -3
  32. package/server/project-config.mjs +43 -6
  33. package/server/routes/agent-profiles.mjs +6 -2
  34. package/server/routes/agent.mjs +12 -1
  35. package/server/routes/channels.mjs +145 -0
  36. package/server/routes/models.mjs +68 -0
  37. package/server/routes/project.mjs +2 -2
  38. package/server/routes/scheduled-tasks.mjs +6 -5
  39. package/server/routes/storage.mjs +4 -2
  40. package/server/routes/system.mjs +27 -0
  41. package/server/routes/tools.mjs +17 -6
  42. package/server/routes/workspace.mjs +142 -20
  43. package/server/session-utils.mjs +10 -2
  44. package/server/storage.mjs +29 -2
  45. package/server/system-prompt.mjs +1 -0
  46. package/server/tools/definitions.mjs +18 -0
  47. package/server/tools/index.mjs +86 -0
  48. package/server/utils/package-update.mjs +156 -0
  49. package/server/utils/workspace.mjs +1 -1
  50. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
  51. package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
  52. package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
  53. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
  54. package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
  55. package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
  56. package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
  57. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
  58. package/dist/assets/icons-BWtivFsx.js +0 -1
  59. package/dist/assets/index-CxOHP41X.css +0 -3
  60. package/dist/assets/index-Dcf73EL8.js +0 -895
@@ -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)
@@ -13,10 +13,44 @@ export function setDefaultWorkspaceRoot(root) {
13
13
  defaultWorkspaceRoot = path.resolve(root)
14
14
  }
15
15
 
16
+ export function getDefaultWorkspaceRoot() {
17
+ return defaultWorkspaceRoot
18
+ }
19
+
20
+ // Synthetic workspace context for global conversations (no projectId).
21
+ // Gives global chats the same file-tool capabilities as project chats, rooted
22
+ // at the default workspace directory (~/.quickforge/workspace by default). The
23
+ // synthetic `project` object (id 'default') lets workspace/git/terminal REST
24
+ // endpoints and subagents keep working without a real registered project.
25
+ export function defaultGlobalWorkspaceContext() {
26
+ return {
27
+ project: {
28
+ id: 'default',
29
+ name: 'workspace',
30
+ path: defaultWorkspaceRoot,
31
+ lastOpenedAt: '',
32
+ sortOrder: 0,
33
+ skills: [],
34
+ commandDir: '',
35
+ },
36
+ workspaceRoot: defaultWorkspaceRoot,
37
+ }
38
+ }
39
+
16
40
  function projectNameFromPath(dir) {
17
41
  return path.basename(dir) || dir
18
42
  }
19
43
 
44
+ // Compare two project paths for equality in a cross-platform way.
45
+ // On Windows (and other case-insensitive filesystems) drive-letter casing and
46
+ // path separators can differ while pointing at the same directory. Normalize
47
+ // both sides to a resolved lowercase form so the same directory always matches
48
+ // an existing project instead of being re-registered with a new id.
49
+ export function sameProjectPath(a, b) {
50
+ if (!a || !b) return false
51
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase()
52
+ }
53
+
20
54
  function defaultProjectConfig() {
21
55
  return {
22
56
  activeProjectId: null,
@@ -222,7 +256,7 @@ export async function setActiveProjectPath(inputPath) {
222
256
  let project
223
257
 
224
258
  const updated = await atomicProjectConfigUpdate((config) => {
225
- project = config.projects.find((item) => path.resolve(item.path) === resolved)
259
+ project = config.projects.find((item) => sameProjectPath(item.path, resolved))
226
260
  if (!project) {
227
261
  project = {
228
262
  id: randomUUID(),
@@ -264,16 +298,19 @@ export async function initializeActiveProject() {
264
298
  }
265
299
  }
266
300
 
267
- // No project configured — leave workspace unset, user will be prompted to add one.
301
+ // No project configured — fall back to the default workspace root so global
302
+ // conversations still have a working directory and the filesystem browser works.
303
+ setWorkspaceRoot(defaultWorkspaceRoot)
268
304
  }
269
305
 
270
306
  export async function projectContextFromId(projectId) {
271
307
  const config = await readProjectConfig()
272
308
  const project = config.projects.find((item) => item.id === projectId)
273
309
  if (!project) {
274
- const error = new Error('Unknown project')
275
- error.statusCode = 404
276
- throw error
310
+ // Unknown or removed project (e.g. a global conversation's synthetic id)
311
+ // fall back to the default workspace so workspace/git REST endpoints keep
312
+ // working for global conversations.
313
+ return defaultGlobalWorkspaceContext()
277
314
  }
278
315
 
279
316
  await assertDirectory(project.path)
@@ -375,7 +412,7 @@ export async function buildInstructionsPayload(projectId) {
375
412
  name: project.name,
376
413
  root: project.path,
377
414
  }
378
- : null,
415
+ : (defaultWorkspaceRoot ? { name: 'workspace', root: defaultWorkspaceRoot } : null),
379
416
  global: globalInstructions,
380
417
  project: projectInstructions,
381
418
  globalSources: globalInstructionSources,
@@ -3,6 +3,7 @@ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
3
3
  import { readStore } from '../storage.mjs'
4
4
  import { logger } from '../utils/logger.mjs'
5
5
  import {
6
+ agentProfileSnapshot,
6
7
  createCustomAgentProfile,
7
8
  deleteCustomAgentProfile,
8
9
  getAgentProfile,
@@ -120,7 +121,8 @@ export async function handleAgentProfilesApi(req, res, url) {
120
121
  const parts = url.pathname.split('/').filter(Boolean)
121
122
 
122
123
  if (req.method === 'GET' && url.pathname === '/api/agent-profiles') {
123
- sendJson(res, 200, { agents: await listAgentProfiles({ includeDisabled: true }) })
124
+ const agents = await listAgentProfiles({ includeDisabled: true })
125
+ sendJson(res, 200, { agents: agents.map(agentProfileSnapshot) })
124
126
  return
125
127
  }
126
128
 
@@ -147,13 +149,14 @@ export async function handleAgentProfilesApi(req, res, url) {
147
149
  if (req.method === 'GET') {
148
150
  const agent = await getAgentProfile(id)
149
151
  if (!agent) throw requestError('Agent not found', 404)
150
- sendJson(res, 200, { agent })
152
+ sendJson(res, 200, { agent: agentProfileSnapshot(agent) })
151
153
  return
152
154
  }
153
155
 
154
156
  if (req.method === 'PATCH' || req.method === 'PUT') {
155
157
  const current = await getAgentProfile(id)
156
158
  if (current?.builtin) throw requestError('Built-in agents cannot be modified', 403)
159
+ if (current?.readonly) throw requestError('File-based agents cannot be modified from the API', 403)
157
160
  const body = await readJsonBody(req)
158
161
  sendJson(res, 200, { agent: await updateCustomAgentProfile(id, body || {}) })
159
162
  return
@@ -162,6 +165,7 @@ export async function handleAgentProfilesApi(req, res, url) {
162
165
  if (req.method === 'DELETE') {
163
166
  const current = await getAgentProfile(id)
164
167
  if (current?.builtin) throw requestError('Built-in agents cannot be deleted', 403)
168
+ if (current?.readonly) throw requestError('File-based agents cannot be deleted from the API', 403)
165
169
  await deleteCustomAgentProfile(id)
166
170
  sendJson(res, 200, { ok: true })
167
171
  return
@@ -16,6 +16,7 @@ import {
16
16
  touchSession,
17
17
  listSessions,
18
18
  refreshAllSessionTools,
19
+ updateSessionAccessMode,
19
20
  updateSessionYoloMode,
20
21
  updateSessionModel,
21
22
  updateSessionThinkingLevel,
@@ -140,6 +141,8 @@ export async function handleAgentApi(req, res, url) {
140
141
  status: session.status,
141
142
  scope: session.scope,
142
143
  title: session.title,
144
+ accessMode: session.accessMode,
145
+ yoloMode: session.yoloMode,
143
146
  })
144
147
  return
145
148
  }
@@ -151,7 +154,15 @@ export async function handleAgentApi(req, res, url) {
151
154
  return
152
155
  }
153
156
 
154
- // POST /api/agents/:sessionId/yolo-mode — update session YOLO mode
157
+ // POST /api/agents/:sessionId/access-mode — update session Agent access mode
158
+ if (req.method === 'POST' && subPath === 'access-mode') {
159
+ const body = await readJsonBody(req)
160
+ const result = await updateSessionAccessMode(sessionId, body?.accessMode)
161
+ sendJson(res, 200, result)
162
+ return
163
+ }
164
+
165
+ // POST /api/agents/:sessionId/yolo-mode — legacy compatibility for old clients
155
166
  if (req.method === 'POST' && subPath === 'yolo-mode') {
156
167
  const body = await readJsonBody(req)
157
168
  const result = await updateSessionYoloMode(sessionId, body?.yoloMode === true)
@@ -0,0 +1,145 @@
1
+ import { sendJson, decodeSegment, readJsonBody } from '../utils/response.mjs'
2
+ import {
3
+ channelEvents,
4
+ getChannelStatus,
5
+ listChannels,
6
+ restartChannel,
7
+ runChannelAction,
8
+ startChannel,
9
+ stopChannel,
10
+ } from '../channels/registry.mjs'
11
+
12
+ function assertLocal(context) {
13
+ if (!context.isLocalRequest) {
14
+ const error = new Error('Channel management is only allowed from this computer')
15
+ error.statusCode = 403
16
+ throw error
17
+ }
18
+ }
19
+
20
+ function assertActionHeader(req) {
21
+ if (req.headers['x-quickforge-action'] !== 'channel-action') {
22
+ const error = new Error('Forbidden action')
23
+ error.statusCode = 403
24
+ throw error
25
+ }
26
+ }
27
+
28
+ async function readActionOptions(req) {
29
+ if (!['POST', 'PUT', 'PATCH'].includes(req.method || '')) return {}
30
+ const contentType = String(req.headers['content-type'] || '')
31
+ if (!contentType.toLowerCase().includes('application/json')) return {}
32
+ return await readJsonBody(req, 64 * 1024) || {}
33
+ }
34
+
35
+ export async function handleChannelsApi(req, res, url, context = {}) {
36
+ const pathname = url.pathname
37
+ const parts = pathname.split('/').filter(Boolean)
38
+
39
+ if (req.method === 'GET' && pathname === '/api/channels') {
40
+ sendJson(res, 200, { channels: listChannels() })
41
+ return
42
+ }
43
+
44
+ if (req.method === 'GET' && pathname === '/api/channels/events') {
45
+ handleChannelEvents(req, res)
46
+ return
47
+ }
48
+
49
+ if (parts.length < 3 || parts[0] !== 'api' || parts[1] !== 'channels') {
50
+ const error = new Error('Not found')
51
+ error.statusCode = 404
52
+ throw error
53
+ }
54
+
55
+ const channelId = decodeSegment(parts[2])
56
+ const subPath = parts.slice(3).map(decodeSegment)
57
+
58
+ if (req.method === 'GET' && subPath.length === 0) {
59
+ sendJson(res, 200, getChannelStatus(channelId))
60
+ return
61
+ }
62
+
63
+ if (req.method === 'GET' && subPath[0] === 'status') {
64
+ sendJson(res, 200, getChannelStatus(channelId))
65
+ return
66
+ }
67
+
68
+ assertLocal(context)
69
+
70
+ if (req.method === 'POST' && subPath[0] === 'start') {
71
+ assertActionHeader(req)
72
+ sendJson(res, 202, await startChannel(channelId, await readActionOptions(req)))
73
+ return
74
+ }
75
+
76
+ if (req.method === 'POST' && subPath[0] === 'stop') {
77
+ assertActionHeader(req)
78
+ sendJson(res, 202, await stopChannel(channelId))
79
+ return
80
+ }
81
+
82
+ if (req.method === 'POST' && subPath[0] === 'restart') {
83
+ assertActionHeader(req)
84
+ sendJson(res, 202, await restartChannel(channelId, await readActionOptions(req)))
85
+ return
86
+ }
87
+
88
+ if (req.method === 'POST' && subPath[0] === 'actions' && subPath[1]) {
89
+ assertActionHeader(req)
90
+ sendJson(res, 202, await runChannelAction(channelId, subPath[1], await readActionOptions(req)))
91
+ return
92
+ }
93
+
94
+ const error = new Error('Not found')
95
+ error.statusCode = 404
96
+ throw error
97
+ }
98
+
99
+ function handleChannelEvents(req, res) {
100
+ res.writeHead(200, {
101
+ 'content-type': 'text/event-stream',
102
+ 'cache-control': 'no-cache, no-transform',
103
+ connection: 'keep-alive',
104
+ 'x-accel-buffering': 'no',
105
+ })
106
+
107
+ writeSseEvent(res, 'snapshot', { type: 'snapshot', channels: listChannels(), timestamp: new Date().toISOString() })
108
+
109
+ const keepAlive = setInterval(() => {
110
+ try {
111
+ res.write(': ping\n\n')
112
+ } catch {
113
+ cleanup()
114
+ }
115
+ }, 15000)
116
+
117
+ const onChannelEvent = (event) => {
118
+ try {
119
+ writeSseEvent(res, event.type || 'channel_event', event)
120
+ } catch {
121
+ cleanup()
122
+ }
123
+ }
124
+
125
+ const cleanup = () => {
126
+ clearInterval(keepAlive)
127
+ channelEvents.removeListener('channel_event', onChannelEvent)
128
+ if (!res.writableEnded) res.end()
129
+ }
130
+
131
+ channelEvents.on('channel_event', onChannelEvent)
132
+ req.on('close', cleanup)
133
+ req.on('error', cleanup)
134
+ res.on('error', cleanup)
135
+ }
136
+
137
+ function writeSseEvent(res, event, data) {
138
+ const payload = typeof data === 'string' ? data : JSON.stringify(data)
139
+ const lines = payload.split('\n')
140
+ res.write(`event: ${event}\n`)
141
+ for (const line of lines) {
142
+ res.write(`data: ${line}\n`)
143
+ }
144
+ res.write('\n')
145
+ }
@@ -0,0 +1,68 @@
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
3
+ import { readStore } from '../storage.mjs'
4
+ import { logger } from '../utils/logger.mjs'
5
+
6
+ function requestError(message, statusCode = 400) {
7
+ const error = new Error(message)
8
+ error.statusCode = statusCode
9
+ return error
10
+ }
11
+
12
+ async function getApiKey(provider) {
13
+ try {
14
+ const keys = await readStore('provider-keys')
15
+ return keys?.[provider] || undefined
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+
21
+ // Send a minimal one-token request to verify the endpoint is reachable and the
22
+ // API key is valid. Returns { ok: true } on success; throws on failure.
23
+ async function probeModelConnection(model, apiKeyOverride) {
24
+ const apiKey = apiKeyOverride || (await getApiKey(model?.provider))
25
+ const stream = streamSimple(
26
+ model,
27
+ {
28
+ systemPrompt: 'You are a connectivity test. Reply with a single word.',
29
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
30
+ tools: [],
31
+ },
32
+ {
33
+ apiKey,
34
+ maxTokens: 16,
35
+ temperature: 0,
36
+ // Keep reasoning off so thinking-capable models don't require extra tokens.
37
+ reasoning: undefined,
38
+ maxRetryDelayMs: 30000,
39
+ },
40
+ )
41
+ await stream.result()
42
+ return { ok: true }
43
+ }
44
+
45
+ export async function handleModelsApi(req, res, url) {
46
+ if (req.method === 'POST' && url.pathname === '/api/models/test-connection') {
47
+ const body = await readJsonBody(req)
48
+ const model = body?.model
49
+ const apiKeyOverride =
50
+ typeof body?.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined
51
+
52
+ if (!model || !model.id || !model.baseUrl) {
53
+ throw requestError('model and baseUrl are required')
54
+ }
55
+
56
+ try {
57
+ const result = await probeModelConnection(model, apiKeyOverride)
58
+ sendJson(res, 200, result)
59
+ } catch (error) {
60
+ logger.warn('Model connection test failed:', error?.message || error)
61
+ // Return 200 with { ok:false } so the client can parse success/failure uniformly.
62
+ sendJson(res, 200, { ok: false, error: error?.message || String(error) })
63
+ }
64
+ return
65
+ }
66
+
67
+ throw requestError('Not found', 404)
68
+ }
@@ -1,5 +1,5 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
- import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
2
+ import { getActiveProject, setActiveProjectPath, readProjectConfig, getDefaultWorkspaceRoot } from '../project-config.mjs'
3
3
  import { listProjectCommands, createCommandFile } from '../custom-commands.mjs'
4
4
  import { atomicProjectConfigUpdate } from '../storage.mjs'
5
5
  import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.mjs'
@@ -11,7 +11,7 @@ export async function handleProjectApi(req, res, url) {
11
11
 
12
12
  if (req.method === 'GET' && url.pathname === '/api/project') {
13
13
  const sorted = [...config.projects].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
14
- sendJson(res, 200, { project: getActiveProject(config), projects: sorted, workspaceRoot: getWorkspaceRoot() })
14
+ sendJson(res, 200, { project: getActiveProject(config), projects: sorted, workspaceRoot: getWorkspaceRoot(), defaultWorkspaceRoot: getDefaultWorkspaceRoot() })
15
15
  return
16
16
  }
17
17