@shawnstack/quickforge 1.6.11 → 1.6.13

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 (43) hide show
  1. package/README.md +292 -452
  2. package/dist/assets/AgentProfilesPage-Be0oZDW5.js +1 -0
  3. package/dist/assets/ChatPanelHost-CxEotTsy.js +291 -0
  4. package/dist/assets/{PluginsPage-B_4nlPca.js → PluginsPage-DZlqGFLn.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DTkj-0O4.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BtgkwIKN.js → SettingsWorkspacePage-BJq11OPD.js} +397 -403
  7. package/dist/assets/{SharedConversationPage-B5FS3eSl.js → SharedConversationPage-BCR8xzii.js} +1 -1
  8. package/dist/assets/TerminalDock-Bs5b-YNp.js +2 -0
  9. package/dist/assets/WorkspaceInspector-BP0XyAlE.js +13 -0
  10. package/dist/assets/icons-DAxUA0e-.js +1 -0
  11. package/dist/assets/index-BZC61KZg.css +3 -0
  12. package/dist/assets/index-DGQbvw7v.js +63 -0
  13. package/dist/assets/{mcp-servers-dialog-BPk4a4nW.js → mcp-servers-dialog-BHljnwYv.js} +2 -2
  14. package/dist/assets/{monaco-CdOyGlWD.js → monaco-DEQoYzYI.js} +1 -1
  15. package/dist/assets/{react-vendor-VqdnQHHS.js → react-vendor-CZsiwuxm.js} +1 -1
  16. package/dist/assets/{skills-dialog-DcxeFWSt.js → skills-dialog-8rn1XU9U.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 +163 -25
  21. package/server/custom-commands.mjs +17 -0
  22. package/server/image-generation.mjs +130 -0
  23. package/server/index.mjs +6 -0
  24. package/server/provider-config.mjs +78 -0
  25. package/server/routes/agent.mjs +9 -0
  26. package/server/routes/project.mjs +3 -18
  27. package/server/routes/session-assets.mjs +49 -0
  28. package/server/routes/shared-conversation.mjs +10 -0
  29. package/server/routes/tools.mjs +1 -1
  30. package/server/routes/workspace.mjs +5 -2
  31. package/server/session-assets.mjs +134 -0
  32. package/server/storage.mjs +13 -1
  33. package/server/tools/definitions.mjs +10 -0
  34. package/server/tools/index.mjs +2 -0
  35. package/server/utils/platform.mjs +20 -3
  36. package/dist/assets/AgentProfilesPage-CvoB0OId.js +0 -1
  37. package/dist/assets/ChatPanelHost-Cyi1yi_P.js +0 -244
  38. package/dist/assets/ScheduledTasksPage-Zbsuo8o6.js +0 -2
  39. package/dist/assets/TerminalDock-CiJCmyqg.js +0 -2
  40. package/dist/assets/WorkspaceInspector-C9HyQIvF.js +0 -13
  41. package/dist/assets/icons-C7j5jdKo.js +0 -1
  42. package/dist/assets/index-BFKildY3.js +0 -63
  43. package/dist/assets/index-CsKSKUn-.css +0 -3
@@ -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
@@ -23,6 +23,7 @@ import { handleBackupApi } from './routes/backup.mjs'
23
23
  import { handleSystemApi } from './routes/system.mjs'
24
24
  import { handleSharesApi } from './routes/shares.mjs'
25
25
  import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
26
+ import { handleSessionAssetsApi } from './routes/session-assets.mjs'
26
27
  import { handleLanAccessApi, renderLanUnlockPage } from './routes/lan-access.mjs'
27
28
  import { handleMcpApi } from './routes/mcp.mjs'
28
29
  import { handlePluginsApi } from './routes/plugins.mjs'
@@ -288,6 +289,11 @@ async function handleApi(req, res, url) {
288
289
  return
289
290
  }
290
291
 
292
+ if (pathname.startsWith('/api/session-assets/')) {
293
+ await handleSessionAssetsApi(req, res, url)
294
+ return
295
+ }
296
+
291
297
  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
298
  await handleLanAccessApi(req, res, url, {
293
299
  port,
@@ -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)
@@ -99,12 +99,7 @@ export async function handleProjectApi(req, res, url) {
99
99
 
100
100
  if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-explorer')) {
101
101
  const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
102
- const selected = config.projects.find((project) => project.id === id)
103
- if (!selected) {
104
- const error = new Error('Unknown project')
105
- error.statusCode = 404
106
- throw error
107
- }
102
+ const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
108
103
  await openPathInFileManager(selected.path)
109
104
  sendJson(res, 200, { ok: true })
110
105
  return
@@ -112,12 +107,7 @@ export async function handleProjectApi(req, res, url) {
112
107
 
113
108
  if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-vscode')) {
114
109
  const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
115
- const selected = config.projects.find((project) => project.id === id)
116
- if (!selected) {
117
- const error = new Error('Unknown project')
118
- error.statusCode = 404
119
- throw error
120
- }
110
+ const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
121
111
  await openPathInVSCode(selected.path)
122
112
  sendJson(res, 200, { ok: true })
123
113
  return
@@ -125,12 +115,7 @@ export async function handleProjectApi(req, res, url) {
125
115
 
126
116
  if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-idea')) {
127
117
  const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
128
- const selected = config.projects.find((project) => project.id === id)
129
- if (!selected) {
130
- const error = new Error('Unknown project')
131
- error.statusCode = 404
132
- throw error
133
- }
118
+ const selected = resolveRequestedProject(config, id, getDefaultWorkspaceRoot())
134
119
  await openPathInIDEA(selected.path)
135
120
  sendJson(res, 200, { ok: true })
136
121
  return
@@ -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
@@ -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) {
@@ -0,0 +1,134 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { storageDir } from './storage.mjs'
5
+
6
+ export const MAX_SESSION_IMAGE_BYTES = 25 * 1024 * 1024
7
+
8
+ const MIME_TYPES = new Map([
9
+ ['image/png', 'png'],
10
+ ['image/jpeg', 'jpg'],
11
+ ['image/webp', 'webp'],
12
+ ['image/gif', 'gif'],
13
+ ])
14
+
15
+ const SAFE_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
16
+ const ASSET_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:png|jpg|webp|gif)$/i
17
+
18
+ function requestError(message, statusCode = 400) {
19
+ const error = new Error(message)
20
+ error.statusCode = statusCode
21
+ return error
22
+ }
23
+
24
+ function assertSafeSegment(value, label) {
25
+ if (typeof value !== 'string' || !SAFE_SEGMENT_RE.test(value)) {
26
+ throw requestError(`Invalid ${label}`)
27
+ }
28
+ return value
29
+ }
30
+
31
+ function normalizeBucket(bucket) {
32
+ if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) {
33
+ throw requestError('Invalid session asset scope')
34
+ }
35
+ if (bucket.scope === 'global') return { scope: 'global' }
36
+ if (bucket.scope === 'project') {
37
+ return {
38
+ scope: 'project',
39
+ projectId: assertSafeSegment(bucket.projectId, 'projectId'),
40
+ }
41
+ }
42
+ throw requestError('Invalid session asset scope')
43
+ }
44
+
45
+ function assetsRoot(bucket) {
46
+ if (bucket.scope === 'project') {
47
+ return path.join(storageDir, 'conversations', 'projects', bucket.projectId, 'assets')
48
+ }
49
+ return path.join(storageDir, 'conversations', 'global', 'assets')
50
+ }
51
+
52
+ function sessionAssetsDir(bucket, sessionId) {
53
+ return path.join(assetsRoot(normalizeBucket(bucket)), assertSafeSegment(sessionId, 'sessionId'))
54
+ }
55
+
56
+ function normalizeMimeType(mimeType) {
57
+ const value = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : ''
58
+ const extension = MIME_TYPES.get(value)
59
+ if (!extension) throw requestError(`Unsupported image MIME type: ${mimeType || ''}`)
60
+ return { mimeType: value, extension }
61
+ }
62
+
63
+ function decodeBase64(value) {
64
+ const normalized = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
65
+ if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
66
+ throw requestError('Invalid base64 image data')
67
+ }
68
+ const buffer = Buffer.from(normalized, 'base64')
69
+ if (buffer.toString('base64') !== normalized) throw requestError('Invalid base64 image data')
70
+ return buffer
71
+ }
72
+
73
+ function normalizeImageData(data) {
74
+ if (Buffer.isBuffer(data)) return data
75
+ if (data instanceof Uint8Array) return Buffer.from(data)
76
+ return decodeBase64(data)
77
+ }
78
+
79
+ function mimeTypeFromAssetId(assetId) {
80
+ const extension = path.extname(assetId).slice(1).toLowerCase()
81
+ for (const [mimeType, candidate] of MIME_TYPES) {
82
+ if (candidate === extension) return mimeType
83
+ }
84
+ throw requestError('Invalid assetId')
85
+ }
86
+
87
+ function assetMetadata(assetId, mimeType, size) {
88
+ return { assetId, mimeType, size }
89
+ }
90
+
91
+ export function validateSessionAssetId(assetId) {
92
+ if (typeof assetId !== 'string' || !ASSET_ID_RE.test(assetId)) {
93
+ throw requestError('Invalid assetId')
94
+ }
95
+ return assetId
96
+ }
97
+
98
+ export async function writeSessionAsset(bucket, sessionId, image) {
99
+ const dir = sessionAssetsDir(bucket, sessionId)
100
+ const { mimeType, extension } = normalizeMimeType(image?.mimeType)
101
+ const data = normalizeImageData(image?.data)
102
+ if (data.byteLength > MAX_SESSION_IMAGE_BYTES) {
103
+ throw requestError(`Image exceeds the ${MAX_SESSION_IMAGE_BYTES} byte limit`, 413)
104
+ }
105
+
106
+ await fs.mkdir(dir, { recursive: true })
107
+ const assetId = `${randomUUID()}.${extension}`
108
+ await fs.writeFile(path.join(dir, assetId), data, { flag: 'wx' })
109
+ return assetMetadata(assetId, mimeType, data.byteLength)
110
+ }
111
+
112
+ export async function readSessionAsset(bucket, sessionId, assetId) {
113
+ const dir = sessionAssetsDir(bucket, sessionId)
114
+ const safeAssetId = validateSessionAssetId(assetId)
115
+ const data = await fs.readFile(path.join(dir, safeAssetId))
116
+ if (data.byteLength > MAX_SESSION_IMAGE_BYTES) {
117
+ throw requestError('Stored image exceeds the allowed size', 413)
118
+ }
119
+ return {
120
+ ...assetMetadata(safeAssetId, mimeTypeFromAssetId(safeAssetId), data.byteLength),
121
+ data,
122
+ }
123
+ }
124
+
125
+ export async function deleteSessionAsset(bucket, sessionId, assetId) {
126
+ const dir = sessionAssetsDir(bucket, sessionId)
127
+ const safeAssetId = validateSessionAssetId(assetId)
128
+ await fs.rm(path.join(dir, safeAssetId), { force: true })
129
+ }
130
+
131
+ export async function deleteSessionAssets(bucket, sessionId) {
132
+ const dir = sessionAssetsDir(bucket, sessionId)
133
+ await fs.rm(dir, { recursive: true, force: true })
134
+ }
@@ -517,7 +517,12 @@ async function writeSessionValues(data) {
517
517
  existingFiles.map(async (file) => {
518
518
  const sessionId = path.basename(file, '.json')
519
519
  if (!nextIds.has(sessionId)) {
520
+ const bucket = sessionBucketIndex.get(sessionId) || await findSessionBucketByDataFile(sessionId)
520
521
  await fs.rm(file, { force: true })
522
+ if (bucket) {
523
+ const { deleteSessionAssets } = await import('./session-assets.mjs')
524
+ await deleteSessionAssets(bucket, sessionId)
525
+ }
521
526
  sessionBucketIndex.delete(sessionId)
522
527
  }
523
528
  }),
@@ -574,7 +579,12 @@ export async function findSessionBucket(sessionId) {
574
579
  await ensureStorage()
575
580
  await rebuildBucketIndex()
576
581
  }
577
- return sessionBucketIndex.get(sessionId) ?? null
582
+ const indexed = sessionBucketIndex.get(sessionId)
583
+ if (indexed) return indexed
584
+
585
+ const recovered = await findSessionBucketByDataFile(sessionId)
586
+ if (recovered) sessionBucketIndex.set(sessionId, recovered)
587
+ return recovered
578
588
  }
579
589
 
580
590
  export async function readSessionValue(sessionId) {
@@ -599,6 +609,8 @@ export async function deleteSessionValue(sessionId) {
599
609
  const bucket = await findSessionBucket(sessionId)
600
610
  if (!bucket) return
601
611
  await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
612
+ const { deleteSessionAssets } = await import('./session-assets.mjs')
613
+ await deleteSessionAssets(bucket, sessionId)
602
614
  sessionBucketIndex.delete(sessionId)
603
615
  })
604
616
  }
@@ -116,6 +116,16 @@ export const workspaceTools = [
116
116
  }),
117
117
  executionMode: 'sequential',
118
118
  },
119
+ {
120
+ name: 'generate_image',
121
+ label: 'Generate image',
122
+ description: 'Generate images with the configured OpenRouter provider and save them as assets owned by the current conversation. Use this when the user explicitly asks for a generated bitmap image. The operation may incur provider charges.',
123
+ parameters: Type.Object({
124
+ prompt: Type.String({ description: 'Detailed image-generation prompt.' }),
125
+ model: Type.Optional(Type.String({ description: 'Optional OpenRouter image model ID. Defaults to google/gemini-2.5-flash-image.' })),
126
+ }),
127
+ executionMode: 'sequential',
128
+ },
119
129
  {
120
130
  name: 'present_files',
121
131
  label: 'Present files',
@@ -15,6 +15,7 @@ import {
15
15
  } from '../skills.mjs'
16
16
  import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
17
17
  import { manageGlobalMemory } from '../global-memory.mjs'
18
+ import { generateSessionImages } from '../image-generation.mjs'
18
19
 
19
20
  const require = createRequire(import.meta.url)
20
21
 
@@ -1100,6 +1101,7 @@ export const toolHandlers = {
1100
1101
  write_file: toolWriteFile,
1101
1102
  edit_file: toolEditFile,
1102
1103
  run_command: toolRunCommand,
1104
+ generate_image: generateSessionImages,
1103
1105
  present_files: toolPresentFiles,
1104
1106
  activate_skill: toolActivateSkill,
1105
1107
  read_skill_resource: toolReadSkillResource,
@@ -131,6 +131,22 @@ async function findExistingFile(candidates) {
131
131
  return undefined
132
132
  }
133
133
 
134
+ export function createExternalAppEnv(source = process.env) {
135
+ const env = { ...source }
136
+ delete env.ELECTRON_RUN_AS_NODE
137
+ delete env.ELECTRON_NO_ATTACH_CONSOLE
138
+ delete env.ATOM_SHELL_INTERNAL_RUN_AS_NODE
139
+ return env
140
+ }
141
+
142
+ export function createVSCodeOpenArgs(targetPath, platform = process.platform) {
143
+ return platform === 'win32' ? ['--reuse-window', targetPath] : [targetPath]
144
+ }
145
+
146
+ export function shouldHideVSCodeLauncherWindow(command, platform = process.platform) {
147
+ return platform === 'win32' && path.win32.basename(command).toLowerCase() === 'cmd.exe'
148
+ }
149
+
134
150
  async function findIntelliJIdeaExecutable() {
135
151
  const roots = [
136
152
  process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs') : undefined,
@@ -206,10 +222,10 @@ export async function openPathInVSCode(targetPath) {
206
222
  const codeExecutable = await findExistingFile(candidates)
207
223
  if (codeExecutable) {
208
224
  command = codeExecutable
209
- args = [resolved]
225
+ args = createVSCodeOpenArgs(resolved)
210
226
  } else {
211
227
  command = 'cmd.exe'
212
- args = ['/d', '/s', '/c', 'start', '""', '/b', 'code', resolved]
228
+ args = ['/d', '/s', '/c', 'start', '""', '/b', 'code', '--reuse-window', resolved]
213
229
  }
214
230
  }
215
231
 
@@ -217,8 +233,9 @@ export async function openPathInVSCode(targetPath) {
217
233
  const child = spawn(command, args, {
218
234
  detached: true,
219
235
  stdio: 'ignore',
220
- windowsHide: true,
236
+ windowsHide: shouldHideVSCodeLauncherWindow(command),
221
237
  shell: false,
238
+ env: createExternalAppEnv(),
222
239
  })
223
240
  child.once('error', (error) => {
224
241
  error.statusCode = 500