@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
@@ -585,11 +585,7 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
585
585
  let sessionId = `scheduled-${task.id}-${Date.now().toString(36)}`
586
586
  let executionAgent = null
587
587
  let agentWarning = null
588
- if (task.agentId) {
589
- executionAgent = await getAgentProfile(task.agentId)
590
- if (!executionAgent) agentWarning = `Configured agent not found: ${task.agentId}`
591
- }
592
- const agentSnapshot = executionAgent ? agentProfileSnapshot(executionAgent) : null
588
+ let agentSnapshot = null
593
589
 
594
590
  let started = false
595
591
  await updateTask(task.id, (current) => {
@@ -630,6 +626,11 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
630
626
 
631
627
  try {
632
628
  const executionProject = await resolveExecutionProject(task)
629
+ if (task.agentId) {
630
+ executionAgent = await getAgentProfile(task.agentId, { projectId: executionProject?.id || null, workspaceRoot: executionProject?.path })
631
+ if (!executionAgent) agentWarning = `Configured agent not found: ${task.agentId}`
632
+ }
633
+ agentSnapshot = executionAgent ? agentProfileSnapshot(executionAgent) : null
633
634
  const settings = await readStore('settings')
634
635
  const yoloMode = settings?.['yolo-mode'] === true || settings?.['yolo-mode'] === 'true'
635
636
 
@@ -5,6 +5,7 @@ import { directorySize } from '../utils/workspace.mjs'
5
5
 
6
6
  const metadataIndexCache = new Map()
7
7
  const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
8
+ const METADATA_INDEX_CACHE_TTL_MS = 1000
8
9
 
9
10
  function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
10
11
  return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
@@ -47,7 +48,8 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
47
48
  const revision = getStoreRevision(store)
48
49
  const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
49
50
  const cached = metadataIndexCache.get(key)
50
- if (cached && cached.revision === revision) return cached.values
51
+ const now = Date.now()
52
+ if (cached && cached.revision === revision && now - cached.cachedAt < METADATA_INDEX_CACHE_TTL_MS) return cached.values
51
53
 
52
54
  const data = scope
53
55
  ? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
@@ -59,7 +61,7 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
59
61
  direction,
60
62
  )
61
63
 
62
- metadataIndexCache.set(key, { revision, values })
64
+ metadataIndexCache.set(key, { revision, values, cachedAt: now })
63
65
  if (metadataIndexCache.size > MAX_METADATA_INDEX_CACHE_ENTRIES) {
64
66
  const firstKey = metadataIndexCache.keys().next().value
65
67
  if (firstKey) metadataIndexCache.delete(firstKey)
@@ -2,6 +2,33 @@ import { sendJson, readJsonBody } from '../utils/response.mjs'
2
2
  import { getLanUrls } from '../utils/network.mjs'
3
3
 
4
4
  export async function handleSystemApi(req, res, url, context) {
5
+ if (req.method === 'GET' && url.pathname === '/api/system/about') {
6
+ sendJson(res, 200, await context.getPackageInfo())
7
+ return
8
+ }
9
+
10
+ if (req.method === 'GET' && url.pathname === '/api/system/update/check') {
11
+ sendJson(res, 200, await context.checkForUpdates())
12
+ return
13
+ }
14
+
15
+ if (req.method === 'POST' && url.pathname === '/api/system/update') {
16
+ if (!context.isLocalRequest) {
17
+ const error = new Error('Update is only allowed from this computer')
18
+ error.statusCode = 403
19
+ throw error
20
+ }
21
+
22
+ if (req.headers['x-quickforge-action'] !== 'update') {
23
+ const error = new Error('Forbidden action')
24
+ error.statusCode = 403
25
+ throw error
26
+ }
27
+
28
+ sendJson(res, 200, await context.updateQuickForge())
29
+ return
30
+ }
31
+
5
32
  if (req.method === 'POST' && url.pathname === '/api/system/restart') {
6
33
  if (req.headers['x-quickforge-action'] !== 'restart') {
7
34
  const error = new Error('Forbidden action')
@@ -4,6 +4,7 @@ import { toolHandlers, loadSkillToolContext } from '../tools/index.mjs'
4
4
  import { createSkillTools, workspaceTools } from '../tools/definitions.mjs'
5
5
  import { createMcpToolDefinitions } from '../mcp/registry.mjs'
6
6
  import { callPluginTool, createPluginToolDefinitions, isPluginToolName } from '../plugins/registry.mjs'
7
+ import { safeReadTools } from '../approval-store.mjs'
7
8
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
8
9
 
9
10
  const directRouteDisabledTools = new Set(['run_subagent'])
@@ -26,13 +27,22 @@ export async function handleGetTools(_req, res) {
26
27
 
27
28
  const workspaceToolNames = new Set(workspaceTools.map((tool) => tool.name))
28
29
 
29
- async function assertYoloEnabledForTool(name) {
30
- if (!workspaceToolNames.has(name)) return
30
+ function normalizeAccessMode(value, fallback = 'default') {
31
+ if (value === 'default' || value === 'full-access') return value
32
+ if (value === true || value === 'true') return 'full-access'
33
+ if (value === false || value === 'false') return 'default'
34
+ if (fallback !== value) return normalizeAccessMode(fallback, 'default')
35
+ return 'default'
36
+ }
37
+
38
+ async function assertAccessModeAllowsDirectTool(name) {
39
+ const protectedTool = workspaceToolNames.has(name) || isPluginToolName(name)
40
+ if (!protectedTool || safeReadTools.has(name)) return
31
41
 
32
42
  const settings = await readStore('settings')
33
- const yoloMode = settings?.['yolo-mode'] === true || settings?.['yolo-mode'] === 'true'
34
- if (!yoloMode) {
35
- const error = new Error('YOLO mode is disabled. Enable it to use this tool.')
43
+ const accessMode = normalizeAccessMode(settings?.['agent-access-mode'], settings?.['yolo-mode'])
44
+ if (accessMode !== 'full-access') {
45
+ const error = new Error('Full access permission is required to execute this tool directly.')
36
46
  error.statusCode = 403
37
47
  throw error
38
48
  }
@@ -74,6 +84,7 @@ export async function handleToolApi(req, res, url) {
74
84
  }
75
85
 
76
86
  if (isPluginToolName(name)) {
87
+ await assertAccessModeAllowsDirectTool(name)
77
88
  const params = await readJsonBody(req)
78
89
  const result = await callPluginTool(name, params || {}, context)
79
90
  sendJson(res, 200, result)
@@ -87,7 +98,7 @@ export async function handleToolApi(req, res, url) {
87
98
  throw error
88
99
  }
89
100
 
90
- await assertYoloEnabledForTool(name)
101
+ await assertAccessModeAllowsDirectTool(name)
91
102
 
92
103
  const params = await readJsonBody(req)
93
104
  const result = await handler(params || {}, context)
@@ -5,15 +5,15 @@ import { sendJson, readJsonBody } from '../utils/response.mjs'
5
5
  import { projectContextFromId } from '../project-config.mjs'
6
6
  import {
7
7
  assertSafeWorkspacePath,
8
- isSensitiveWorkspacePath,
9
8
  resolveWorkspacePath,
10
- shouldSearchFile,
11
9
  toWorkspaceRelative,
12
10
  } from '../utils/workspace.mjs'
13
11
 
14
- const MAX_PREVIEW_BYTES = 1024 * 1024
15
- const MAX_TREE_NODES = 5000
16
- const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-ssr', 'package-dist', 'package-offline', '.vite', 'coverage'])
12
+ const MAX_PREVIEW_BYTES = 50 * 1024 * 1024
13
+ const MAX_STATIC_PREVIEW_BYTES = 50 * 1024 * 1024
14
+ const PREVIEW_ALLOWED_EXTENSIONS = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.json', '.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico', '.txt', '.md'])
15
+ const MAX_TREE_NODES = 50000
16
+ const SKIP_DIRS = new Set(['.git', 'node_modules'])
17
17
 
18
18
  const extensionLanguageMap = new Map([
19
19
  ['ts', 'typescript'], ['tsx', 'typescript'], ['js', 'javascript'], ['jsx', 'javascript'],
@@ -34,12 +34,26 @@ function languageFromPath(filePath) {
34
34
  return extensionLanguageMap.get(extension) || 'plaintext'
35
35
  }
36
36
 
37
- function isBinaryBuffer(buffer) {
38
- const length = Math.min(buffer.length, 8000)
39
- for (let index = 0; index < length; index += 1) {
40
- if (buffer[index] === 0) return true
37
+ function previewContentType(filePath) {
38
+ const ext = path.extname(filePath).toLowerCase()
39
+ const map = {
40
+ '.html': 'text/html; charset=utf-8',
41
+ '.htm': 'text/html; charset=utf-8',
42
+ '.js': 'application/javascript; charset=utf-8',
43
+ '.mjs': 'application/javascript; charset=utf-8',
44
+ '.css': 'text/css; charset=utf-8',
45
+ '.json': 'application/json; charset=utf-8',
46
+ '.svg': 'image/svg+xml',
47
+ '.png': 'image/png',
48
+ '.jpg': 'image/jpeg',
49
+ '.jpeg': 'image/jpeg',
50
+ '.webp': 'image/webp',
51
+ '.gif': 'image/gif',
52
+ '.ico': 'image/x-icon',
53
+ '.txt': 'text/plain; charset=utf-8',
54
+ '.md': 'text/markdown; charset=utf-8',
41
55
  }
42
- return false
56
+ return map[ext] || 'application/octet-stream'
43
57
  }
44
58
 
45
59
  async function projectContextFromUrl(url) {
@@ -144,10 +158,71 @@ function countGitStatus(files) {
144
158
  }, { staged: 0, unstaged: 0, untracked: 0, conflicts: 0, total: 0 })
145
159
  }
146
160
 
161
+ function countTextLines(text) {
162
+ if (text.length === 0) return 0
163
+ return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
164
+ }
165
+
166
+ // numstat 的路径列对 rename 用 "prefix/{old => new}" 或 "old => new" 形式,取新路径
167
+ function numstatNewPath(rawPath) {
168
+ const arrow = rawPath.indexOf(' => ')
169
+ if (arrow < 0) return rawPath
170
+ const head = rawPath.slice(0, arrow)
171
+ const tail = rawPath.slice(arrow + 4)
172
+ const brace = head.lastIndexOf('{')
173
+ if (brace < 0) return tail
174
+ return `${head.slice(0, brace)}${tail.replace(/\}$/, '')}`
175
+ }
176
+
177
+ // 工作区 vs HEAD 的每个文件增删行数(口径与 git diff --numstat 一致)
178
+ async function collectNumstat(context) {
179
+ const map = new Map()
180
+ const result = await git(['diff', 'HEAD', '--numstat', '-z'], context.workspaceRoot, { allowFailure: true })
181
+ if (result.code !== 0) return map
182
+ const records = result.stdout.toString('utf8').split('\0').filter(Boolean)
183
+ for (const record of records) {
184
+ const fields = record.split('\t')
185
+ if (fields.length < 3) continue
186
+ const added = fields[0]
187
+ const removed = fields[1]
188
+ const rawPath = fields.slice(2).join('\t')
189
+ if (added === '-' || removed === '-') continue // 二进制文件
190
+ const additions = Number(added)
191
+ const deletions = Number(removed)
192
+ if (!Number.isFinite(additions) || !Number.isFinite(deletions)) continue
193
+ map.set(numstatNewPath(rawPath), { additions, deletions })
194
+ }
195
+ return map
196
+ }
197
+
198
+ // 未跟踪文件不在 numstat 中,按工作区文件行数估算新增行
199
+ async function countWorkspaceLines(context, relativePath) {
200
+ try {
201
+ const { content } = await readWorkspaceTextFile(context, relativePath)
202
+ return countTextLines(content)
203
+ } catch {
204
+ return undefined
205
+ }
206
+ }
207
+
147
208
  async function listGitStatus(context) {
148
209
  if (!(await isGitRepository(context.workspaceRoot))) return { isGitRepository: false, files: [] }
149
210
  const result = await git(['status', '--porcelain=v1', '-z'], context.workspaceRoot)
150
211
  const files = parseGitStatus(result.stdout)
212
+ const numstat = await collectNumstat(context)
213
+ for (const file of files) {
214
+ const entry = numstat.get(file.path)
215
+ if (entry) {
216
+ file.additions = entry.additions
217
+ file.deletions = entry.deletions
218
+ } else if (file.status === 'untracked' || file.status === 'added') {
219
+ const count = await countWorkspaceLines(context, file.path)
220
+ if (typeof count === 'number') {
221
+ file.additions = count
222
+ file.deletions = 0
223
+ }
224
+ }
225
+ }
151
226
  return {
152
227
  isGitRepository: true,
153
228
  branch: await currentGitBranch(context.workspaceRoot),
@@ -163,7 +238,7 @@ async function readGitFile(workspaceRoot, ref, relativePath) {
163
238
 
164
239
  async function readWorkspaceTextFile(context, relativePath) {
165
240
  const file = resolveWorkspacePath(relativePath, context)
166
- await assertSafeWorkspacePath(file, context)
241
+ await assertSafeWorkspacePath(file, context, { allowSensitive: true })
167
242
  const stat = await fs.stat(file)
168
243
  if (!stat.isFile()) {
169
244
  const error = new Error('Path is not a file')
@@ -176,11 +251,6 @@ async function readWorkspaceTextFile(context, relativePath) {
176
251
  throw error
177
252
  }
178
253
  const buffer = await fs.readFile(file)
179
- if (isBinaryBuffer(buffer)) {
180
- const error = new Error('Binary file cannot be previewed')
181
- error.statusCode = 415
182
- throw error
183
- }
184
254
  return { content: buffer.toString('utf8'), size: stat.size, path: toWorkspaceRelative(file, context) }
185
255
  }
186
256
 
@@ -197,9 +267,9 @@ async function buildTreeForDirectory(dir, context, counter) {
197
267
  const fullPath = path.join(dir, entry.name)
198
268
  const relativePath = toWorkspaceRelative(fullPath, context)
199
269
  if (entry.isDirectory()) {
200
- if (SKIP_DIRS.has(entry.name) || isSensitiveWorkspacePath(fullPath, context)) continue
270
+ if (SKIP_DIRS.has(entry.name)) continue
201
271
  try {
202
- await assertSafeWorkspacePath(fullPath, context)
272
+ await assertSafeWorkspacePath(fullPath, context, { allowSensitive: true })
203
273
  counter.count += 1
204
274
  nodes.push({
205
275
  name: entry.name,
@@ -211,9 +281,8 @@ async function buildTreeForDirectory(dir, context, counter) {
211
281
  // Skip directories that cannot be safely resolved.
212
282
  }
213
283
  } else if (entry.isFile()) {
214
- if (!shouldSearchFile(entry.name) || isSensitiveWorkspacePath(fullPath, context)) continue
215
284
  try {
216
- await assertSafeWorkspacePath(fullPath, context)
285
+ await assertSafeWorkspacePath(fullPath, context, { allowSensitive: true })
217
286
  counter.count += 1
218
287
  nodes.push({ name: entry.name, path: relativePath, type: 'file' })
219
288
  } catch {
@@ -246,6 +315,55 @@ async function handleWorkspaceFile(req, res, url) {
246
315
  })
247
316
  }
248
317
 
318
+ async function handleWorkspacePreview(req, res, url) {
319
+ const prefix = '/api/workspace/preview/'
320
+ const tail = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : ''
321
+ const slashIndex = tail.indexOf('/')
322
+ if (slashIndex <= 0) {
323
+ const error = new Error('projectId and path are required')
324
+ error.statusCode = 400
325
+ throw error
326
+ }
327
+
328
+ const projectId = decodeURIComponent(tail.slice(0, slashIndex))
329
+ const relativePath = decodeURIComponent(tail.slice(slashIndex + 1))
330
+ if (!projectId || !relativePath) {
331
+ const error = new Error('projectId and path are required')
332
+ error.statusCode = 400
333
+ throw error
334
+ }
335
+
336
+ const context = await projectContextFromId(projectId)
337
+ const file = resolveWorkspacePath(relativePath, context)
338
+ await assertSafeWorkspacePath(file, context)
339
+ const extension = path.extname(file).toLowerCase()
340
+ if (!PREVIEW_ALLOWED_EXTENSIONS.has(extension)) {
341
+ const error = new Error('Unsupported preview file type')
342
+ error.statusCode = 415
343
+ throw error
344
+ }
345
+ const stat = await fs.stat(file)
346
+ if (!stat.isFile()) {
347
+ const error = new Error('Path is not a file')
348
+ error.statusCode = 400
349
+ throw error
350
+ }
351
+ if (stat.size > MAX_STATIC_PREVIEW_BYTES) {
352
+ const error = new Error('File is too large to preview')
353
+ error.statusCode = 413
354
+ throw error
355
+ }
356
+
357
+ const contentType = previewContentType(file)
358
+ res.writeHead(200, {
359
+ 'content-type': contentType,
360
+ 'cache-control': 'no-store',
361
+ 'x-content-type-options': 'nosniff',
362
+ })
363
+ const buffer = await fs.readFile(file)
364
+ res.end(buffer)
365
+ }
366
+
249
367
  async function handleWorkspaceResolvePath(req, res) {
250
368
  const body = await readJsonBody(req, 16 * 1024)
251
369
  const projectId = typeof body?.projectId === 'string' ? body.projectId : ''
@@ -344,6 +462,10 @@ export async function handleWorkspaceApi(req, res, url) {
344
462
  await handleWorkspaceFile(req, res, url)
345
463
  return
346
464
  }
465
+ if (req.method === 'GET' && url.pathname.startsWith('/api/workspace/preview/')) {
466
+ await handleWorkspacePreview(req, res, url)
467
+ return
468
+ }
347
469
  if (req.method === 'POST' && url.pathname === '/api/workspace/resolve-path') {
348
470
  await handleWorkspaceResolvePath(req, res)
349
471
  return
@@ -1,5 +1,5 @@
1
1
  import { streamSimple } from '@earendil-works/pi-ai'
2
- import { buildInstructionsPayload } from './project-config.mjs'
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
5
 
@@ -9,9 +9,17 @@ import { listSubagentProfiles } from './agent-profiles.mjs'
9
9
 
10
10
  export async function buildSystemPrompt(projectId) {
11
11
  const instructions = await buildInstructionsPayload(projectId)
12
+ let workspaceRoot = instructions.workspace?.root || null
13
+ if (projectId && !workspaceRoot) {
14
+ try {
15
+ workspaceRoot = (await projectContextFromId(projectId))?.workspaceRoot || null
16
+ } catch {
17
+ // project may have been removed; fall back to global agent profile discovery
18
+ }
19
+ }
12
20
  return composeSystemPrompt({
13
21
  ...instructions,
14
- subagents: await listSubagentProfiles(),
22
+ subagents: await listSubagentProfiles({ projectId, workspaceRoot }),
15
23
  })
16
24
  }
17
25
 
@@ -468,6 +468,26 @@ async function rebuildBucketIndex() {
468
468
  bucketIndexBuilt = true
469
469
  }
470
470
 
471
+ async function findSessionBucketByDataFile(sessionId) {
472
+ assertSafePathSegment(sessionId)
473
+ const buckets = [
474
+ { scope: 'global' },
475
+ ...(await listProjectIds()).map((projectId) => ({ scope: 'project', projectId })),
476
+ ]
477
+
478
+ for (const bucket of buckets) {
479
+ const file = sessionDataFile(sessionId, bucket)
480
+ try {
481
+ const stat = await fs.stat(file)
482
+ if (stat.isFile()) return bucket
483
+ } catch (error) {
484
+ if (error?.code !== 'ENOENT') throw error
485
+ }
486
+ }
487
+
488
+ return null
489
+ }
490
+
471
491
  export async function findSessionBucket(sessionId) {
472
492
  if (!bucketIndexBuilt) {
473
493
  await ensureStorage()
@@ -478,8 +498,12 @@ export async function findSessionBucket(sessionId) {
478
498
 
479
499
  export async function readSessionValue(sessionId) {
480
500
  const bucket = await findSessionBucket(sessionId)
481
- if (!bucket) return null
482
- return readJsonFile(sessionDataFile(sessionId, bucket), null)
501
+ if (bucket) return readJsonFile(sessionDataFile(sessionId, bucket), null)
502
+
503
+ const recoveredBucket = await findSessionBucketByDataFile(sessionId)
504
+ if (!recoveredBucket) return null
505
+ sessionBucketIndex.set(sessionId, recoveredBucket)
506
+ return readJsonFile(sessionDataFile(sessionId, recoveredBucket), null)
483
507
  }
484
508
 
485
509
  export async function writeSessionValue(sessionId, value) {
@@ -725,6 +749,9 @@ export function ensureStorage() {
725
749
  fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
726
750
  fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
727
751
  fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
752
+ // Default workspace directory for global (non-project) conversations, so
753
+ // they share the same file-tool capabilities as project conversations.
754
+ fs.mkdir(path.join(dataDir, 'workspace'), { recursive: true }),
728
755
  cleanOldLogs(),
729
756
  ])
730
757
 
@@ -5,6 +5,7 @@ For project tasks:
5
5
  - Prefer the simplest solution that satisfies the request.
6
6
  - Make surgical changes only. Do not refactor unrelated code.
7
7
  - Match existing style.
8
+ - When content has room for visual explanation, first consider whether an SVG diagram can improve understanding.
8
9
  - For multi-step work, use a brief plan.
9
10
  - Before changing files, gather sufficient context: relevant files, entry points or call chains, existing patterns, tests or validation commands, and docs/wiki impact.
10
11
  - Before taking action, confirm with the user.
@@ -86,6 +86,24 @@ export const workspaceTools = [
86
86
  }),
87
87
  executionMode: 'sequential',
88
88
  },
89
+ {
90
+ name: 'present_files',
91
+ label: 'Present files',
92
+ description: 'Show one or more AI-produced artifact files to the user. Use this after creating or editing user-facing files such as HTML pages, SVG/images, Markdown documents, or other deliverables. HTML files will be previewed directly in the artifact preview panel.',
93
+ parameters: Type.Object({
94
+ files: Type.Array(Type.Union([
95
+ Type.String({ description: 'File path relative to the workspace root.' }),
96
+ Type.Object({
97
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
98
+ title: Type.Optional(Type.String({ description: 'Optional display title.' })),
99
+ description: Type.Optional(Type.String({ description: 'Optional short description shown in artifact lists.' })),
100
+ kind: Type.Optional(Type.String({ description: 'Optional file kind hint, such as html, image, markdown, or code.' })),
101
+ preview: Type.Optional(Type.Boolean({ description: 'Whether this file should be opened as the default preview.' })),
102
+ }),
103
+ ]), { description: 'Artifact files to present.' }),
104
+ defaultPreview: Type.Optional(Type.String({ description: 'File path to open as the default preview when multiple files are presented.' })),
105
+ }),
106
+ },
89
107
  ]
90
108
 
91
109
  function activeSkillSchema(skills) {
@@ -619,6 +619,91 @@ export async function toolReadSkillResource(params, context) {
619
619
  }
620
620
  }
621
621
 
622
+ // --- present_files ---
623
+ function inferPresentedFileKind(relativePath) {
624
+ const lower = String(relativePath || '').toLowerCase()
625
+ if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'html'
626
+ if (/\.(svg|png|jpe?g|webp|gif|bmp|ico)$/.test(lower)) return 'image'
627
+ if (lower.endsWith('.md') || lower.endsWith('.mdx')) return 'markdown'
628
+ if (/\.(ts|tsx|js|jsx|mjs|cjs|css|scss|less|json|jsonc|txt|xml|yml|yaml|toml|ini|py|rb|go|rs|java|c|h|cpp|hpp|cs|php|sh|bash|zsh|ps1)$/.test(lower)) return 'code'
629
+ return 'unknown'
630
+ }
631
+
632
+ function normalizePresentFileEntry(entry) {
633
+ if (typeof entry === 'string') return { path: entry }
634
+ if (entry && typeof entry === 'object' && typeof entry.path === 'string') {
635
+ return {
636
+ path: entry.path,
637
+ title: typeof entry.title === 'string' ? entry.title : undefined,
638
+ description: typeof entry.description === 'string' ? entry.description : undefined,
639
+ kind: typeof entry.kind === 'string' ? entry.kind : undefined,
640
+ preview: typeof entry.preview === 'boolean' ? entry.preview : undefined,
641
+ }
642
+ }
643
+ return undefined
644
+ }
645
+
646
+ export async function toolPresentFiles(params, context) {
647
+ const rawFiles = Array.isArray(params?.files) ? params.files : []
648
+ const defaultPreviewInput = typeof params?.defaultPreview === 'string' ? params.defaultPreview : undefined
649
+ if (rawFiles.length === 0) {
650
+ const error = new Error('files must contain at least one path')
651
+ error.statusCode = 400
652
+ throw error
653
+ }
654
+
655
+ const files = []
656
+ const seen = new Set()
657
+ for (const raw of rawFiles) {
658
+ const entry = normalizePresentFileEntry(raw)
659
+ if (!entry?.path?.trim()) continue
660
+ const file = resolveWorkspacePath(entry.path, context)
661
+ await assertSafeWorkspacePath(file, context)
662
+ const stat = await fs.stat(file)
663
+ if (!stat.isFile()) {
664
+ const error = new Error(`Path is not a file: ${entry.path}`)
665
+ error.statusCode = 400
666
+ throw error
667
+ }
668
+ const relativePath = toWorkspaceRelative(file, context)
669
+ const key = relativePath.toLowerCase()
670
+ if (seen.has(key)) continue
671
+ seen.add(key)
672
+ const kind = entry.kind || inferPresentedFileKind(relativePath)
673
+ const isDefaultPreview = defaultPreviewInput ? relativePath === defaultPreviewInput.replace(/\\/g, '/') : false
674
+ // 所有已知 kind 均自动预览(与前端 isPreviewablePath 语义一致):调用 present_files 即自动打开 tab。
675
+ // 渲染路径由前端按 kind 决定:html/image → browser iframe;markdown/code → 侧栏渲染。
676
+ const autoPreviewable = kind !== 'unknown'
677
+ files.push({
678
+ path: relativePath,
679
+ title: entry.title,
680
+ description: entry.description,
681
+ kind,
682
+ preview: entry.preview ?? (isDefaultPreview || autoPreviewable),
683
+ defaultPreview: isDefaultPreview,
684
+ bytes: stat.size,
685
+ })
686
+ }
687
+
688
+ if (files.length === 0) {
689
+ const error = new Error('No valid files to present')
690
+ error.statusCode = 400
691
+ throw error
692
+ }
693
+
694
+ const previewed = files.filter((file) => file.preview).map((file) => file.path)
695
+ return {
696
+ content: `Presented ${files.length} file(s)${previewed.length ? ` and opened ${previewed.length} preview(s)` : ''}: ${files.map((file) => file.path).join(', ')}`,
697
+ details: {
698
+ type: 'present_files_result',
699
+ files,
700
+ defaultPreview: files.find((file) => file.defaultPreview)?.path,
701
+ previewed,
702
+ project: context?.project,
703
+ },
704
+ }
705
+ }
706
+
622
707
  // --- run_command ---
623
708
  const DEFAULT_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
624
709
  const MIN_RUN_COMMAND_TIMEOUT_MS = 1000
@@ -1009,6 +1094,7 @@ export const toolHandlers = {
1009
1094
  write_file: toolWriteFile,
1010
1095
  edit_file: toolEditFile,
1011
1096
  run_command: toolRunCommand,
1097
+ present_files: toolPresentFiles,
1012
1098
  activate_skill: toolActivateSkill,
1013
1099
  read_skill_resource: toolReadSkillResource,
1014
1100
  }