@shawnstack/quickforge 1.6.12 → 1.7.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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/AgentProfilesPage-DQ8URegq.js +1 -0
  3. package/dist/assets/ChatPanelHost-vi1tDqlJ.js +291 -0
  4. package/dist/assets/{PluginsPage-qXTjDrpd.js → PluginsPage-B8bHMwfv.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-AaUz5el2.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CnFDmGvW.js → SettingsWorkspacePage-BOP9CtoI.js} +112 -105
  7. package/dist/assets/{SharedConversationPage-CkQ-XwYS.js → SharedConversationPage-9A4L9UcQ.js} +1 -1
  8. package/dist/assets/TerminalDock-DQOXpaDe.js +2 -0
  9. package/dist/assets/WorkspaceInspector-DaM7TJHb.js +13 -0
  10. package/dist/assets/icons-ko_i0WpN.js +1 -0
  11. package/dist/assets/index-B2eauKpo.css +3 -0
  12. package/dist/assets/index-B8Awq5FV.js +63 -0
  13. package/dist/assets/{mcp-servers-dialog-CsRQxx7A.js → mcp-servers-dialog-XE4K8PCO.js} +2 -2
  14. package/dist/assets/{monaco-CdOyGlWD.js → monaco-C13M09wD.js} +1 -1
  15. package/dist/assets/{react-vendor-VqdnQHHS.js → react-vendor-2RKYr-A4.js} +1 -1
  16. package/dist/assets/{skills-dialog-CmVLi_h6.js → skills-dialog-MpoxntiV.js} +1 -1
  17. package/dist/index.html +6 -6
  18. package/dist/licenses/material-icon-theme.txt +8 -0
  19. package/package.json +1 -1
  20. package/server/agent-manager.mjs +182 -39
  21. package/server/auto-archive.mjs +137 -0
  22. package/server/auto-compaction.mjs +15 -18
  23. package/server/conversation-compaction.mjs +69 -3
  24. package/server/custom-commands.mjs +17 -0
  25. package/server/image-generation.mjs +130 -0
  26. package/server/index.mjs +12 -1
  27. package/server/provider-config.mjs +78 -0
  28. package/server/routes/agent.mjs +9 -0
  29. package/server/routes/session-assets.mjs +49 -0
  30. package/server/routes/shared-conversation.mjs +10 -0
  31. package/server/routes/storage.mjs +4 -0
  32. package/server/routes/tools.mjs +1 -1
  33. package/server/routes/workspace.mjs +5 -2
  34. package/server/session-assets.mjs +134 -0
  35. package/server/session-persistence-lock.mjs +9 -0
  36. package/server/storage.mjs +24 -1
  37. package/server/tools/definitions.mjs +10 -0
  38. package/server/tools/index.mjs +2 -0
  39. package/dist/assets/AgentProfilesPage-CooGLweI.js +0 -1
  40. package/dist/assets/ChatPanelHost-TEbv0lnL.js +0 -260
  41. package/dist/assets/ScheduledTasksPage-BBor8Yb2.js +0 -2
  42. package/dist/assets/TerminalDock-CvqBBMPg.js +0 -2
  43. package/dist/assets/WorkspaceInspector-6JGc6VGb.js +0 -13
  44. package/dist/assets/icons-C7j5jdKo.js +0 -1
  45. package/dist/assets/index-DxNzlPmc.js +0 -63
  46. package/dist/assets/index-HVxfqXfB.css +0 -3
@@ -6,6 +6,16 @@ import { cacheDir } from './storage.mjs'
6
6
  export const DEFAULT_COMPACT_KEEP_TURNS = 0
7
7
  const MAX_COMPACT_KEEP_TURNS = 20
8
8
  const MIN_SUMMARY_SOURCE_CHARS = 1600
9
+ const COMPACT_SUMMARY_OPEN_TAG = '<compact_summary>'
10
+ const COMPACT_SUMMARY_CLOSE_TAG = '</compact_summary>'
11
+ const COMPACTION_DETAILS_KEY = 'quickforgeCompaction'
12
+ const LEGACY_COMPACT_SUMMARY_INTROS = [
13
+ 'The previous conversation has been compacted.',
14
+ 'The previous conversation has been automatically compacted.',
15
+ 'Existing rolling compact summary from earlier conversation history:',
16
+ ]
17
+ const LEGACY_COMPACTION_NOTICE_PREFIX = '已基于当前对话创建压缩后的新对话:'
18
+ const LEGACY_COMPACTION_NOTICE_SUFFIX = '压缩前历史已保存到本地备份。'
9
19
 
10
20
  export const COMPACT_SYSTEM_PROMPT = `你是 QuickForge 的“历史对话压缩器”。你的任务是把一段较长的 AI 助手对话压缩成后续模型继续工作所需的最小充分上下文。
11
21
 
@@ -72,8 +82,60 @@ function normalizeKeepTurns(value) {
72
82
  return Math.min(MAX_COMPACT_KEEP_TURNS, Math.max(0, Math.floor(parsed)))
73
83
  }
74
84
 
85
+ function messageContentText(message) {
86
+ const content = message?.content
87
+ if (typeof content === 'string') return content
88
+ if (!Array.isArray(content)) return ''
89
+ return content
90
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
91
+ .map((block) => block.text)
92
+ .join('\n')
93
+ }
94
+
95
+ function compactionMessageKind(message) {
96
+ const kind = message?.details?.[COMPACTION_DETAILS_KEY]?.kind
97
+ if (kind === 'summary' || kind === 'notice') return kind
98
+
99
+ const text = messageContentText(message).trim()
100
+ if (
101
+ message?.role === 'user'
102
+ && LEGACY_COMPACT_SUMMARY_INTROS.some((intro) => text.startsWith(intro))
103
+ && text.includes(COMPACT_SUMMARY_OPEN_TAG)
104
+ && text.lastIndexOf(COMPACT_SUMMARY_CLOSE_TAG) > text.indexOf(COMPACT_SUMMARY_OPEN_TAG)
105
+ ) return 'summary'
106
+
107
+ if (
108
+ message?.role === 'assistant'
109
+ && text.startsWith(LEGACY_COMPACTION_NOTICE_PREFIX)
110
+ && text.includes(LEGACY_COMPACTION_NOTICE_SUFFIX)
111
+ ) return 'notice'
112
+
113
+ return null
114
+ }
115
+
116
+ export function compactionMessageDetails(kind) {
117
+ return { [COMPACTION_DETAILS_KEY]: { version: 1, kind } }
118
+ }
119
+
120
+ export function isCompactSummaryMessage(message) {
121
+ return compactionMessageKind(message) === 'summary'
122
+ }
123
+
124
+ export function isCompactionNoticeMessage(message) {
125
+ return compactionMessageKind(message) === 'notice'
126
+ }
127
+
128
+ export function extractCompactSummaryText(message) {
129
+ const text = messageContentText(message).trim()
130
+ const openIndex = text.indexOf(COMPACT_SUMMARY_OPEN_TAG)
131
+ const closeIndex = text.lastIndexOf(COMPACT_SUMMARY_CLOSE_TAG)
132
+ if (openIndex < 0 || closeIndex <= openIndex) return text
133
+ return text.slice(openIndex + COMPACT_SUMMARY_OPEN_TAG.length, closeIndex).trim()
134
+ }
135
+
75
136
  function isUserMessage(message) {
76
- return message?.role === 'user' || message?.role === 'user-with-attachments'
137
+ return (message?.role === 'user' || message?.role === 'user-with-attachments')
138
+ && !isCompactSummaryMessage(message)
77
139
  }
78
140
 
79
141
  function truncateMiddle(value, maxLength) {
@@ -200,7 +262,7 @@ export function splitMessagesForCompaction(messages, options = {}) {
200
262
  if (keepTurns <= 0) {
201
263
  return {
202
264
  keepTurns,
203
- compactRange: sourceMessages.slice(),
265
+ compactRange: sourceMessages.filter((message) => !isCompactionNoticeMessage(message)),
204
266
  recentTail: [],
205
267
  tailStart: sourceMessages.length,
206
268
  }
@@ -219,9 +281,13 @@ export function splitMessagesForCompaction(messages, options = {}) {
219
281
 
220
282
  if (seenUserTurns < keepTurns) tailStart = 0
221
283
 
284
+ const compactRange = sourceMessages
285
+ .slice(0, tailStart)
286
+ .filter((message) => !isCompactionNoticeMessage(message))
287
+
222
288
  return {
223
289
  keepTurns,
224
- compactRange: sourceMessages.slice(0, tailStart),
290
+ compactRange,
225
291
  recentTail: sourceMessages.slice(tailStart),
226
292
  tailStart,
227
293
  }
@@ -15,6 +15,12 @@ const commandNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
15
15
  * `description` values here.
16
16
  */
17
17
  const builtinCommandCatalog = [
18
+ {
19
+ name: 'init',
20
+ description: 'Generate or update a concise AGENTS.md contributor guide for the current repository.',
21
+ argumentHint: '',
22
+ permissionNote: 'may edit files and run commands',
23
+ },
18
24
  {
19
25
  name: 'plan',
20
26
  description: 'Create a plan first; this turn cannot edit files or run commands.',
@@ -367,6 +373,8 @@ export function parseInternalCommandInvocation(message) {
367
373
  const text = textFromUserMessage(message).trim()
368
374
  if (/^\/(?:help|\?)(?:\s+.*)?$/i.test(text)) return { type: 'help' }
369
375
  if (/^\/commands(?:\s+.*)?$/i.test(text)) return { type: 'list' }
376
+ if (/^\/init\s*$/i.test(text)) return { type: 'init' }
377
+ if (/^\/init(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-init-args' }
370
378
  if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
371
379
  if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
372
380
 
@@ -407,6 +415,15 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
407
415
  return { plan: true, args: invocation.args }
408
416
  }
409
417
 
418
+ if (invocation.type === 'init') {
419
+ if (!workspaceRoot) return 'Initialization requires an active project chat.'
420
+ return { init: true }
421
+ }
422
+
423
+ if (invocation.type === 'invalid-init-args') {
424
+ return 'Usage: /init'
425
+ }
426
+
410
427
  if (invocation.type === 'review') {
411
428
  if (!workspaceRoot) return 'Review requires an active project chat.'
412
429
  return { review: true, args: invocation.args || '' }
@@ -0,0 +1,130 @@
1
+ import { builtinImagesModels } from '@earendil-works/pi-ai/providers/all'
2
+ import { resolveOpenRouterConfig } from './provider-config.mjs'
3
+ import { deleteSessionAsset, writeSessionAsset } from './session-assets.mjs'
4
+
5
+ export const DEFAULT_IMAGE_MODEL = 'google/gemini-2.5-flash-image'
6
+ export const MAX_GENERATED_IMAGES = 4
7
+ export const MAX_GENERATED_IMAGE_BYTES = 50 * 1024 * 1024
8
+
9
+ function requestError(message, statusCode = 400) {
10
+ const error = new Error(message)
11
+ error.statusCode = statusCode
12
+ return error
13
+ }
14
+
15
+ function normalizeBucket(value) {
16
+ if (value?.scope === 'project') return { scope: 'project', projectId: value.projectId }
17
+ return { scope: 'global' }
18
+ }
19
+
20
+ function resolveImagesModels(runtime) {
21
+ const candidate = runtime.imagesModels
22
+ if (candidate && typeof candidate.getModel === 'function') return candidate
23
+ if (typeof candidate === 'function') return candidate()
24
+ return builtinImagesModels()
25
+ }
26
+
27
+ function imageByteLength(data) {
28
+ const normalized = typeof data === 'string' ? data.replace(/\s+/g, '') : ''
29
+ if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
30
+ throw requestError('Image provider returned invalid base64 data')
31
+ }
32
+ const buffer = Buffer.from(normalized, 'base64')
33
+ if (buffer.toString('base64') !== normalized) {
34
+ throw requestError('Image provider returned invalid base64 data')
35
+ }
36
+ return buffer.byteLength
37
+ }
38
+
39
+ function resultText(output) {
40
+ return output
41
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string' && item.text.trim())
42
+ .map((item) => item.text.trim())
43
+ .join('\n\n')
44
+ }
45
+
46
+ function contentSummary(count, text) {
47
+ const generated = `Generated ${count} image${count === 1 ? '' : 's'}.`
48
+ return text ? `${generated}\n\n${text}` : generated
49
+ }
50
+
51
+ export async function generateSessionImages(params, context = {}, runtime = {}) {
52
+ const prompt = typeof params?.prompt === 'string' ? params.prompt.trim() : ''
53
+ if (!prompt) throw requestError('prompt is required')
54
+
55
+ const sessionId = typeof context.sessionId === 'string' ? context.sessionId : params?.sessionId
56
+ if (!sessionId) throw requestError('sessionId is required')
57
+ const bucket = normalizeBucket(context.bucket || context.sessionBucket || params?.bucket || {
58
+ scope: context.scope,
59
+ projectId: context.projectId,
60
+ })
61
+ const requestedModel = typeof params?.model === 'string' && params.model.trim()
62
+ ? params.model.trim()
63
+ : DEFAULT_IMAGE_MODEL
64
+
65
+ const imagesModels = resolveImagesModels(runtime)
66
+ const model = imagesModels.getModel('openrouter', requestedModel)
67
+ if (!model || model.api !== 'openrouter-images') {
68
+ throw requestError(`Unsupported image model: ${requestedModel}`)
69
+ }
70
+
71
+ const resolveConfig = runtime.resolveOpenRouterConfig || resolveOpenRouterConfig
72
+ const config = await resolveConfig(runtime)
73
+ const requestModel = config.baseUrl ? { ...model, baseUrl: config.baseUrl } : model
74
+ const response = await imagesModels.generateImages(
75
+ requestModel,
76
+ { input: [{ type: 'text', text: prompt }] },
77
+ {
78
+ apiKey: config.apiKey,
79
+ ...(config.headers ? { headers: config.headers } : {}),
80
+ ...(runtime.signal || context.signal ? { signal: runtime.signal || context.signal } : {}),
81
+ },
82
+ )
83
+
84
+ if (response?.stopReason !== 'stop') {
85
+ throw new Error(response?.errorMessage || `Image generation stopped: ${response?.stopReason || 'unknown'}`)
86
+ }
87
+
88
+ const output = Array.isArray(response.output) ? response.output : []
89
+ const images = output.filter((item) => item?.type === 'image')
90
+ if (!images.length) throw new Error('Image generation returned no images')
91
+ if (images.length > MAX_GENERATED_IMAGES) {
92
+ throw requestError(`Image generation returned more than ${MAX_GENERATED_IMAGES} images`, 413)
93
+ }
94
+
95
+ let totalBytes = 0
96
+ for (const image of images) {
97
+ totalBytes += imageByteLength(image.data)
98
+ if (totalBytes > MAX_GENERATED_IMAGE_BYTES) {
99
+ throw requestError(`Generated images exceed the ${MAX_GENERATED_IMAGE_BYTES} byte limit`, 413)
100
+ }
101
+ }
102
+
103
+ const writeAsset = runtime.writeSessionAsset || writeSessionAsset
104
+ const deleteAsset = runtime.deleteSessionAsset || deleteSessionAsset
105
+ const assets = []
106
+ try {
107
+ for (const image of images) {
108
+ assets.push(await writeAsset(bucket, sessionId, image))
109
+ }
110
+ } catch (error) {
111
+ await Promise.allSettled(assets.map((asset) => deleteAsset(bucket, sessionId, asset.assetId)))
112
+ throw error
113
+ }
114
+
115
+ const text = resultText(output)
116
+ return {
117
+ content: contentSummary(assets.length, text),
118
+ details: {
119
+ type: 'generated_image_result',
120
+ sessionId,
121
+ prompt,
122
+ model: requestedModel,
123
+ assets,
124
+ text,
125
+ usage: response.usage,
126
+ },
127
+ }
128
+ }
129
+
130
+ export const generateImages = generateSessionImages
package/server/index.mjs CHANGED
@@ -19,10 +19,12 @@ import { handleSkillsApi } from './routes/skills.mjs'
19
19
  import { handleAgentApi } from './routes/agent.mjs'
20
20
  import { handleAgentProfilesApi } from './routes/agent-profiles.mjs'
21
21
  import { handleScheduledTasksApi, startScheduledTaskRunner, stopScheduledTaskRunner } from './routes/scheduled-tasks.mjs'
22
+ import { startAutoArchiveRunner, stopAutoArchiveRunner } from './auto-archive.mjs'
22
23
  import { handleBackupApi } from './routes/backup.mjs'
23
24
  import { handleSystemApi } from './routes/system.mjs'
24
25
  import { handleSharesApi } from './routes/shares.mjs'
25
26
  import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
27
+ import { handleSessionAssetsApi } from './routes/session-assets.mjs'
26
28
  import { handleLanAccessApi, renderLanUnlockPage } from './routes/lan-access.mjs'
27
29
  import { handleMcpApi } from './routes/mcp.mjs'
28
30
  import { handlePluginsApi } from './routes/plugins.mjs'
@@ -146,6 +148,7 @@ async function performRestart() {
146
148
  logger.info(`Restart supervisor started (PID ${supervisorPid}).`)
147
149
 
148
150
  stopScheduledTaskRunner()
151
+ stopAutoArchiveRunner()
149
152
  stopVite()
150
153
  await shutdownAgentManager()
151
154
  await shutdownMcpConnections()
@@ -220,6 +223,7 @@ function spawnUpdateSupervisor(update) {
220
223
  async function shutdownForUpdate() {
221
224
  logger.info('Shutting down QuickForge for external updater.')
222
225
  stopScheduledTaskRunner()
226
+ stopAutoArchiveRunner()
223
227
  stopVite()
224
228
  await shutdownAgentManager()
225
229
  await shutdownMcpConnections()
@@ -288,6 +292,11 @@ async function handleApi(req, res, url) {
288
292
  return
289
293
  }
290
294
 
295
+ if (pathname.startsWith('/api/session-assets/')) {
296
+ await handleSessionAssetsApi(req, res, url)
297
+ return
298
+ }
299
+
291
300
  if (pathname === '/api/lan-access/status' || pathname === '/api/lan-access/settings' || pathname === '/api/lan-access/unlock' || pathname === '/api/lan-access/logout' || pathname === '/api/lan-access/revoke-all') {
292
301
  await handleLanAccessApi(req, res, url, {
293
302
  port,
@@ -364,7 +373,7 @@ async function handleApi(req, res, url) {
364
373
  }
365
374
 
366
375
  // Project workspace inspector routes
367
- if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname.startsWith('/api/workspace/preview/')) {
376
+ if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname === '/api/workspace/open-external' || pathname.startsWith('/api/workspace/preview/')) {
368
377
  await handleWorkspaceApi(req, res, url)
369
378
  return
370
379
  }
@@ -665,6 +674,7 @@ await resetStaleTaskStatuses()
665
674
  await initializeActiveProject()
666
675
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
667
676
  startScheduledTaskRunner()
677
+ startAutoArchiveRunner()
668
678
 
669
679
  server.on('error', (error) => {
670
680
  // Handle listen errors (most commonly EADDRINUSE). Without this, Node would
@@ -703,6 +713,7 @@ server.listen(port, host, () => {
703
713
  async function gracefulShutdown(signal) {
704
714
  logger.info(`Received ${signal}, shutting down gracefully...`)
705
715
  stopScheduledTaskRunner()
716
+ stopAutoArchiveRunner()
706
717
  stopVite()
707
718
  await shutdownAgentManager()
708
719
  await shutdownMcpConnections()
@@ -0,0 +1,78 @@
1
+ import { readStore } from './storage.mjs'
2
+
3
+ const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
4
+
5
+ function requestError(message, statusCode = 400) {
6
+ const error = new Error(message)
7
+ error.statusCode = statusCode
8
+ return error
9
+ }
10
+
11
+ function providersFromStore(store) {
12
+ return Array.isArray(store)
13
+ ? store
14
+ : Object.entries(store || {}).map(([id, provider]) => (
15
+ provider && typeof provider === 'object' ? { id, ...provider } : provider
16
+ ))
17
+ }
18
+
19
+ function isOpenRouterProvider(provider) {
20
+ if (!provider || typeof provider !== 'object') return false
21
+ const id = String(provider.id || '').trim().toLowerCase()
22
+ const name = String(provider.name || '').trim().toLowerCase()
23
+ if (id === 'openrouter' || name === 'openrouter') return true
24
+
25
+ try {
26
+ return new URL(String(provider.baseUrl || '')).hostname.toLowerCase() === 'openrouter.ai'
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ function normalizeHeaders(provider) {
33
+ const source = provider?.headers || provider?.models?.find((model) => model?.headers)?.headers
34
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return undefined
35
+ const headers = {}
36
+ for (const [key, value] of Object.entries(source)) {
37
+ if (typeof value === 'string' && key.trim()) headers[key.trim()] = value
38
+ }
39
+ return Object.keys(headers).length ? headers : undefined
40
+ }
41
+
42
+ function findProviderKey(keys, providerName) {
43
+ if (!keys || typeof keys !== 'object' || Array.isArray(keys)) return undefined
44
+ if (typeof keys[providerName] === 'string' && keys[providerName].trim()) return keys[providerName].trim()
45
+
46
+ const wanted = String(providerName || '').trim().toLowerCase()
47
+ for (const [key, value] of Object.entries(keys)) {
48
+ const normalized = key.trim().toLowerCase()
49
+ if ((normalized === wanted || normalized === 'openrouter') && typeof value === 'string' && value.trim()) {
50
+ return value.trim()
51
+ }
52
+ }
53
+ return undefined
54
+ }
55
+
56
+ export async function resolveOpenRouterConfig(runtime = {}) {
57
+ const read = runtime.readStore || readStore
58
+ const [providerStore, providerKeys] = await Promise.all([
59
+ read('custom-providers'),
60
+ read('provider-keys'),
61
+ ])
62
+ const provider = providersFromStore(providerStore).find(isOpenRouterProvider)
63
+ if (!provider) throw requestError('OpenRouter provider is not configured')
64
+
65
+ const providerName = String(provider.name || provider.id || 'openrouter').trim()
66
+ const apiKey = findProviderKey(providerKeys, providerName)
67
+ if (!apiKey) throw requestError('OpenRouter API key is not configured')
68
+
69
+ const baseUrl = typeof provider.baseUrl === 'string' && provider.baseUrl.trim()
70
+ ? provider.baseUrl.trim().replace(/\/$/, '')
71
+ : DEFAULT_OPENROUTER_BASE_URL
72
+
73
+ return {
74
+ apiKey,
75
+ baseUrl,
76
+ headers: normalizeHeaders(provider),
77
+ }
78
+ }
@@ -16,6 +16,7 @@ import {
16
16
  touchSession,
17
17
  listSessions,
18
18
  updateSessionAccessMode,
19
+ updateSessionTitle,
19
20
  updateSessionYoloMode,
20
21
  updateSessionModel,
21
22
  updateSessionThinkingLevel,
@@ -153,6 +154,14 @@ export async function handleAgentApi(req, res, url) {
153
154
  return
154
155
  }
155
156
 
157
+ // POST /api/agents/:sessionId/title — update the authoritative session title
158
+ if (req.method === 'POST' && subPath === 'title') {
159
+ const body = await readJsonBody(req)
160
+ const result = await updateSessionTitle(sessionId, body?.title)
161
+ sendJson(res, 200, result)
162
+ return
163
+ }
164
+
156
165
  // POST /api/agents/:sessionId/access-mode — update session Agent access mode
157
166
  if (req.method === 'POST' && subPath === 'access-mode') {
158
167
  const body = await readJsonBody(req)
@@ -0,0 +1,49 @@
1
+ import { findSessionBucket } from '../storage.mjs'
2
+ import { readSessionAsset } from '../session-assets.mjs'
3
+ import { decodeSegment } from '../utils/response.mjs'
4
+
5
+ function notFound(message = 'Image asset not found') {
6
+ const error = new Error(message)
7
+ error.statusCode = 404
8
+ return error
9
+ }
10
+
11
+ export async function sendSessionAsset(res, bucket, sessionId, assetId) {
12
+ let asset
13
+ try {
14
+ asset = await readSessionAsset(bucket, sessionId, assetId)
15
+ } catch (error) {
16
+ if (error?.code === 'ENOENT') throw notFound()
17
+ throw error
18
+ }
19
+
20
+ res.writeHead(200, {
21
+ 'content-type': asset.mimeType,
22
+ 'content-length': String(asset.size),
23
+ 'cache-control': 'private, max-age=31536000, immutable',
24
+ 'content-disposition': 'inline',
25
+ 'x-content-type-options': 'nosniff',
26
+ })
27
+ res.end(asset.data)
28
+ }
29
+
30
+ export async function handleSessionAssetsApi(req, res, url) {
31
+ if (req.method !== 'GET') {
32
+ const error = new Error('Session asset endpoints require GET')
33
+ error.statusCode = 405
34
+ throw error
35
+ }
36
+
37
+ const parts = url.pathname.split('/').filter(Boolean)
38
+ const sessionId = decodeSegment(parts[2])
39
+ const assetId = decodeSegment(parts[3])
40
+ if (!sessionId || !assetId || parts.length !== 4) {
41
+ const error = new Error('Missing session image asset path')
42
+ error.statusCode = 400
43
+ throw error
44
+ }
45
+
46
+ const bucket = await findSessionBucket(sessionId)
47
+ if (!bucket) throw notFound('Session not found')
48
+ await sendSessionAsset(res, bucket, sessionId, assetId)
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
2
  import { readSessionValue, readStore } from '../storage.mjs'
3
+ import { sendSessionAsset } from './session-assets.mjs'
3
4
  import { abortRun, restoreAgent, runPrompt, getSessionState, getSessionEventBus, updateSessionModel, updateSessionThinkingLevel } from '../agent-manager.mjs'
4
5
  import {
5
6
  assertShareActive,
@@ -307,6 +308,7 @@ export async function handleSharedConversationApi(req, res, url) {
307
308
  const parts = url.pathname.split('/').filter(Boolean)
308
309
  const shareId = decodeSegment(parts[2])
309
310
  const action = parts[3]
311
+ const actionId = parts[4]
310
312
 
311
313
  if (!shareId) {
312
314
  const error = new Error('Missing share id')
@@ -343,6 +345,14 @@ export async function handleSharedConversationApi(req, res, url) {
343
345
  const record = await requireShareAuth(req, shareId)
344
346
  if (record.permission === 'operate' && !record.passwordHash) throw passwordRequiredError()
345
347
 
348
+ if (req.method === 'GET' && action === 'assets' && actionId) {
349
+ const bucket = record.scope === 'project'
350
+ ? { scope: 'project', projectId: record.projectId }
351
+ : { scope: 'global' }
352
+ await sendSessionAsset(res, bucket, record.sessionId, decodeSegment(actionId))
353
+ return
354
+ }
355
+
346
356
  if (req.method === 'GET' && action === 'session') {
347
357
  sendJson(res, 200, await sharedSessionPayload(record))
348
358
  return
@@ -1,5 +1,6 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
2
  import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
3
+ import { AUTO_ARCHIVE_SETTINGS_KEY, archiveInactiveSessions, normalizeAutoArchiveSettings } from '../auto-archive.mjs'
3
4
  import { directorySize } from '../utils/workspace.mjs'
4
5
 
5
6
  const metadataIndexCache = new Map()
@@ -194,6 +195,9 @@ export async function handleStorageApi(req, res, url) {
194
195
  data[key] = body?.value
195
196
  return data
196
197
  })
198
+ if (store === 'settings' && key === AUTO_ARCHIVE_SETTINGS_KEY && normalizeAutoArchiveSettings(body?.value).enabled) {
199
+ await archiveInactiveSessions()
200
+ }
197
201
  sendJson(res, 200, { ok: true })
198
202
  return
199
203
  }
@@ -8,7 +8,7 @@ import { callPluginTool, createPluginToolDefinitions, isPluginToolName } from '.
8
8
  import { safeReadTools } from '../approval-store.mjs'
9
9
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
10
10
 
11
- const directRouteDisabledTools = new Set(['run_subagent', 'manage_global_memory'])
11
+ const directRouteDisabledTools = new Set(['run_subagent', 'manage_global_memory', 'generate_image'])
12
12
 
13
13
  /**
14
14
  * GET /api/tools — returns canonical tool definitions (no project context needed).
@@ -304,9 +304,12 @@ async function countWorkspaceLines(context, relativePath) {
304
304
  }
305
305
  }
306
306
 
307
- async function listGitStatus(context) {
307
+ export async function listGitStatus(context) {
308
308
  if (!(await isGitRepository(context.workspaceRoot))) return { isGitRepository: false, files: [] }
309
- const result = await git(['status', '--porcelain=v1', '-z'], context.workspaceRoot)
309
+ const result = await git(
310
+ ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
311
+ context.workspaceRoot,
312
+ )
310
313
  const files = parseGitStatus(result.stdout)
311
314
  const numstat = await collectNumstat(context)
312
315
  for (const file of files) {