@shawnstack/quickforge 1.3.23 → 1.3.25

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 (54) hide show
  1. package/README.md +15 -15
  2. package/dist/assets/anthropic-B1_Yrokl.js +39 -0
  3. package/dist/assets/azure-openai-responses-UMiOBCBd.js +1 -0
  4. package/dist/assets/google-BLE_Gcd1.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-6_sIZLVc.js +1 -0
  7. package/dist/assets/{icons-WD3UkVNM.js → icons-Bs7OG8yi.js} +1 -1
  8. package/dist/assets/{index-CjTN0qaQ.js → index-C3bc5C3k.js} +576 -561
  9. package/dist/assets/index-C7oT9Rdw.css +3 -0
  10. package/dist/assets/{mistral-Ber29mja.js → mistral-DmZEmRkv.js} +1 -1
  11. package/dist/assets/openai-codex-responses-i_SmQGzQ.js +7 -0
  12. package/dist/assets/openai-completions-BmmZFDDY.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-C8tPdeE9.js +1 -0
  15. package/dist/assets/{openai-responses-shared-a_PAPxTO.js → openai-responses-shared-DchtjQNp.js} +1 -1
  16. package/dist/assets/openrouter-CcTv1G_v.js +1 -0
  17. package/dist/assets/react-vendor-Cu-7p9CI.js +61 -0
  18. package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
  19. package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
  20. package/dist/index.html +4 -4
  21. package/package.json +6 -3
  22. package/server/agent-manager.mjs +144 -151
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +8 -0
  26. package/server/index.mjs +1 -1
  27. package/server/message-converters.mjs +79 -0
  28. package/server/project-config.mjs +7 -9
  29. package/server/routes/agent-profiles.mjs +1 -1
  30. package/server/routes/agent.mjs +15 -1
  31. package/server/routes/filesystem.mjs +18 -2
  32. package/server/routes/project.mjs +33 -1
  33. package/server/routes/scheduled-tasks.mjs +1 -1
  34. package/server/routes/storage.mjs +66 -31
  35. package/server/routes/terminal.mjs +28 -3
  36. package/server/routes/workspace.mjs +43 -1
  37. package/server/session-utils.mjs +1 -1
  38. package/server/storage.mjs +78 -2
  39. package/server/terminal/terminal-manager.mjs +12 -0
  40. package/server/tool-wiring.mjs +87 -0
  41. package/server/utils/workspace.mjs +20 -1
  42. package/dist/assets/anthropic-CDKnv1FQ.js +0 -39
  43. package/dist/assets/azure-openai-responses-BnUwVl-8.js +0 -1
  44. package/dist/assets/google-DOEyCDZy.js +0 -1
  45. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  46. package/dist/assets/google-vertex-BPPf3car.js +0 -1
  47. package/dist/assets/index-eeLjaV06.css +0 -3
  48. package/dist/assets/openai-codex-responses-D8gq8a3l.js +0 -7
  49. package/dist/assets/openai-completions-CATWPFBp.js +0 -5
  50. package/dist/assets/openai-responses-DxcB6Ksu.js +0 -1
  51. package/dist/assets/react-vendor-BcQaTQ90.js +0 -9
  52. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  53. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  54. /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * LLM message format converters.
3
+ *
4
+ * Pure functions that transform AgentMessage[] to LLM-compatible Message[]
5
+ * and extract text content from messages. No module-level state.
6
+ */
7
+
8
+ /**
9
+ * Strip the `details` property from a message object.
10
+ * Returns a shallow copy so the original message is not mutated.
11
+ */
12
+ export function omitDetailsForLlm(message) {
13
+ if (!message || typeof message !== 'object' || message.details === undefined) return message
14
+ const copy = { ...message }
15
+ delete copy.details
16
+ return copy
17
+ }
18
+
19
+ /**
20
+ * Convert AgentMessage[] to LLM-compatible Message[].
21
+ * Handles "user-with-attachments" → "user" with multi-modal content blocks.
22
+ * Without this the default pi-agent-core convertToLlm silently drops
23
+ * user-with-attachments messages, so the LLM never sees attachments.
24
+ */
25
+ export function serverConvertToLlm(messages) {
26
+ return messages
27
+ .filter(m => m.role !== 'artifact')
28
+ .map(m => {
29
+ if (m.role === 'user-with-attachments') {
30
+ const textContent = typeof m.content === 'string'
31
+ ? [{ type: 'text', text: m.content }]
32
+ : [...m.content]
33
+ if (Array.isArray(m.attachments)) {
34
+ for (const att of m.attachments) {
35
+ if (att.type === 'image' && att.content) {
36
+ textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
37
+ } else if (att.type === 'document' && att.extractedText) {
38
+ textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
39
+ }
40
+ }
41
+ }
42
+ return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
43
+ }
44
+ if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
45
+ return null
46
+ })
47
+ .filter(Boolean)
48
+ }
49
+
50
+ /**
51
+ * Extract plain text content from a message object.
52
+ * Handles string content and ContentBlock[] arrays.
53
+ */
54
+ export function messageText(message) {
55
+ const content = message?.content
56
+ if (typeof content === 'string') return content
57
+ if (Array.isArray(content)) {
58
+ return content
59
+ .filter((block) => block?.type === 'text')
60
+ .map((block) => block.text ?? '')
61
+ .join('\n')
62
+ .trim()
63
+ }
64
+ return ''
65
+ }
66
+
67
+ /**
68
+ * Find the last assistant message with non-empty text content.
69
+ * Returns the text string, or '' if no assistant text is found.
70
+ */
71
+ export function lastAssistantText(messages) {
72
+ for (let index = messages.length - 1; index >= 0; index--) {
73
+ const message = messages[index]
74
+ if (message?.role !== 'assistant') continue
75
+ const text = messageText(message)
76
+ if (text) return text
77
+ }
78
+ return ''
79
+ }
@@ -39,7 +39,6 @@ export async function readProjectConfig() {
39
39
  }
40
40
 
41
41
  const TERMINAL_SHELL_PROFILE_CANDIDATES = [
42
- { id: 'auto', name: 'Auto', command: 'auto', platforms: ['win32', 'darwin', 'linux', 'freebsd', 'openbsd'] },
43
42
  { id: 'cmd', name: 'Command Prompt', command: 'cmd.exe', platforms: ['win32'] },
44
43
  { id: 'powershell', name: 'Windows PowerShell', command: 'powershell.exe', platforms: ['win32'] },
45
44
  { id: 'pwsh', name: 'PowerShell 7+', command: 'pwsh.exe', platforms: ['win32', 'darwin', 'linux', 'freebsd', 'openbsd'] },
@@ -56,7 +55,7 @@ function isWindows() {
56
55
  }
57
56
 
58
57
  function commandExists(command) {
59
- if (!command || command === 'auto') return true
58
+ if (!command) return true
60
59
  if (command.includes('/') || command.includes('\\')) return existsSync(command)
61
60
  const probe = isWindows() ? 'where' : 'command'
62
61
  const args = isWindows() ? [command] : ['-v', command]
@@ -66,13 +65,11 @@ function commandExists(command) {
66
65
 
67
66
  function terminalShellProfileCandidatesForPlatform(platform = os.platform()) {
68
67
  const profiles = TERMINAL_SHELL_PROFILE_CANDIDATES
69
- .filter((profile) => profile.platforms.includes(platform) || profile.id === 'auto')
70
- .filter((profile) => profile.id === 'auto' || commandExists(profile.command))
68
+ .filter((profile) => profile.platforms.includes(platform))
69
+ .filter((profile) => commandExists(profile.command))
71
70
  .map(({ platforms, ...profile }) => ({ ...profile, builtin: true, detected: true }))
72
71
 
73
- return profiles.length > 1
74
- ? profiles
75
- : [TERMINAL_SHELL_PROFILE_CANDIDATES[0]].map(({ platforms, ...profile }) => ({ ...profile, builtin: true, detected: true }))
72
+ return profiles
76
73
  }
77
74
 
78
75
  function nameFromTerminalShellCommand(command) {
@@ -232,10 +229,11 @@ export async function setActiveProjectPath(inputPath) {
232
229
  name: projectNameFromPath(resolved),
233
230
  path: resolved,
234
231
  lastOpenedAt: now,
232
+ sortOrder: config.projects.length,
235
233
  skills: [],
236
234
  commandDir: '',
237
235
  }
238
- config.projects.unshift(project)
236
+ config.projects.push(project)
239
237
  } else {
240
238
  project.name = projectNameFromPath(resolved)
241
239
  project.path = resolved
@@ -243,7 +241,7 @@ export async function setActiveProjectPath(inputPath) {
243
241
  }
244
242
 
245
243
  config.activeProjectId = project.id
246
- config.projects = [project, ...config.projects.filter((item) => item.id !== project.id)].slice(0, 20)
244
+ if (config.projects.length > 20) config.projects = config.projects.slice(-20)
247
245
  return config
248
246
  })
249
247
 
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
2
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
3
3
  import { readStore } from '../storage.mjs'
4
4
  import { logger } from '../utils/logger.mjs'
@@ -25,6 +25,7 @@ import {
25
25
  abortToolCall,
26
26
  replaceSessionMessages,
27
27
  rollbackSessionMessages,
28
+ continueSession,
28
29
  agentEvents,
29
30
  } from '../agent-manager.mjs'
30
31
 
@@ -96,7 +97,13 @@ export async function handleAgentApi(req, res, url) {
96
97
 
97
98
  // GET /api/agents/:sessionId/state — get session state
98
99
  if (req.method === 'GET' && subPath === 'state') {
99
- const state = getSessionState(sessionId)
100
+ let state = getSessionState(sessionId)
101
+ if (!state) {
102
+ // Try to restore from persistent storage before giving up.
103
+ // This recovers sessions that were evicted by idle timeout.
104
+ await restoreAgent(sessionId)
105
+ state = getSessionState(sessionId)
106
+ }
100
107
  if (!state) {
101
108
  const error = new Error('Session not found')
102
109
  error.statusCode = 404
@@ -184,6 +191,13 @@ export async function handleAgentApi(req, res, url) {
184
191
  return
185
192
  }
186
193
 
194
+ // POST /api/agents/:sessionId/continue — continue generation from last message (retry)
195
+ if (req.method === 'POST' && subPath === 'continue') {
196
+ const result = await continueSession(sessionId)
197
+ sendJson(res, 200, result)
198
+ return
199
+ }
200
+
187
201
  // POST /api/agents/:sessionId/steer — queue steering message
188
202
  if (req.method === 'POST' && subPath === 'steer') {
189
203
  const body = await readJsonBody(req)
@@ -50,9 +50,21 @@ async function getFilesystemRoots() {
50
50
  return roots
51
51
  }
52
52
 
53
- async function listFilesystemDirectories(inputPath) {
53
+ async function listFilesystemDirectories(inputPath, allowedRoots) {
54
54
  const requestedPath = String(inputPath || os.homedir())
55
55
  const resolved = path.resolve(requestedPath)
56
+
57
+ // Only allow browsing within or at known filesystem roots
58
+ const isAllowed = allowedRoots.some((root) => {
59
+ const rel = path.relative(root, resolved)
60
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
61
+ })
62
+ if (!isAllowed) {
63
+ const error = new Error('Access denied: path is outside allowed roots')
64
+ error.statusCode = 403
65
+ throw error
66
+ }
67
+
56
68
  await assertDirectory(resolved)
57
69
 
58
70
  const entries = await fs.readdir(resolved, { withFileTypes: true }).catch((error) => {
@@ -77,7 +89,11 @@ export async function handleFilesystemApi(req, res, url) {
77
89
  }
78
90
 
79
91
  if (req.method === 'GET' && url.pathname === '/api/filesystem/directories') {
80
- sendJson(res, 200, await listFilesystemDirectories(url.searchParams.get('path')))
92
+ const roots = await getFilesystemRoots()
93
+ const allowedRootPaths = roots.map((r) => path.resolve(r.path))
94
+ // Always allow browsing from home directory as a fallback
95
+ allowedRootPaths.push(os.homedir())
96
+ sendJson(res, 200, await listFilesystemDirectories(url.searchParams.get('path'), allowedRootPaths))
81
97
  return
82
98
  }
83
99
 
@@ -10,7 +10,8 @@ export async function handleProjectApi(req, res, url) {
10
10
  const config = await readProjectConfig()
11
11
 
12
12
  if (req.method === 'GET' && url.pathname === '/api/project') {
13
- sendJson(res, 200, { project: getActiveProject(config), projects: config.projects, workspaceRoot: getWorkspaceRoot() })
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
15
  return
15
16
  }
16
17
 
@@ -125,6 +126,37 @@ export async function handleProjectApi(req, res, url) {
125
126
  return
126
127
  }
127
128
 
129
+ if (req.method === 'PUT' && url.pathname === '/api/project/reorder') {
130
+ const body = await readJsonBody(req)
131
+ if (!Array.isArray(body?.orderedIds)) {
132
+ const error = new Error('orderedIds must be an array')
133
+ error.statusCode = 400
134
+ throw error
135
+ }
136
+ const orderedIds = body.orderedIds
137
+ const updated = await atomicProjectConfigUpdate((cfg) => {
138
+ const idToProject = new Map(cfg.projects.map((p) => [p.id, p]))
139
+ const reordered = []
140
+ for (const id of orderedIds) {
141
+ const p = idToProject.get(id)
142
+ if (p) {
143
+ p.sortOrder = reordered.length
144
+ reordered.push(p)
145
+ idToProject.delete(id)
146
+ }
147
+ }
148
+ // append any remaining projects not in orderedIds (shouldn't happen normally)
149
+ for (const p of idToProject.values()) {
150
+ p.sortOrder = reordered.length
151
+ reordered.push(p)
152
+ }
153
+ cfg.projects = reordered
154
+ return cfg
155
+ })
156
+ sendJson(res, 200, { projects: updated.projects, workspaceRoot: getWorkspaceRoot() })
157
+ return
158
+ }
159
+
128
160
  const error = new Error('Not found')
129
161
  error.statusCode = 404
130
162
  throw error
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
2
  import { readJsonBody, sendJson, decodeSegment } from '../utils/response.mjs'
3
3
  import { readStore, atomicUpdate } from '../storage.mjs'
4
4
  import { createAgent, getSessionEventBus, agentEvents, persistSessionState } from '../agent-manager.mjs'
@@ -1,8 +1,72 @@
1
1
  import path from 'node:path'
2
2
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
3
- import { readStore, writeStore, atomicUpdate, getComparable, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
3
+ import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
4
4
  import { directorySize } from '../utils/workspace.mjs'
5
5
 
6
+ const metadataIndexCache = new Map()
7
+ const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
8
+
9
+ function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
10
+ return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
11
+ }
12
+
13
+ function sortIndexedValues(values, store, indexName, direction) {
14
+ values.sort((a, b) => {
15
+ if (store === 'sessions-metadata' && indexName === 'lastModified') {
16
+ const leftPinned = getComparable(a, 'pinnedAt')
17
+ const rightPinned = getComparable(b, 'pinnedAt')
18
+ if (leftPinned !== rightPinned) {
19
+ if (leftPinned === undefined || leftPinned === null) return 1
20
+ if (rightPinned === undefined || rightPinned === null) return -1
21
+ return -String(leftPinned).localeCompare(String(rightPinned))
22
+ }
23
+ }
24
+
25
+ const left = getComparable(a, indexName)
26
+ const right = getComparable(b, indexName)
27
+ if (left === right) return 0
28
+ if (left === undefined || left === null) return direction === 'desc' ? 1 : -1
29
+ if (right === undefined || right === null) return direction === 'desc' ? -1 : 1
30
+ const result = String(left).localeCompare(String(right))
31
+ return direction === 'desc' ? -result : result
32
+ })
33
+ return values
34
+ }
35
+
36
+ async function readIndexedValues(store, indexName, direction, scope, projectId) {
37
+ if (store !== 'sessions-metadata') {
38
+ let data
39
+ if (scope && store === 'sessions') {
40
+ data = await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
41
+ } else {
42
+ data = await readStore(store)
43
+ }
44
+ return sortIndexedValues(Object.values(data), store, indexName, direction)
45
+ }
46
+
47
+ const revision = getStoreRevision(store)
48
+ const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
49
+ const cached = metadataIndexCache.get(key)
50
+ if (cached && cached.revision === revision) return cached.values
51
+
52
+ const data = scope
53
+ ? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
54
+ : await readStore(store)
55
+ const values = sortIndexedValues(
56
+ Object.values(data).filter((value) => value?.messageCount !== 0),
57
+ store,
58
+ indexName,
59
+ direction,
60
+ )
61
+
62
+ metadataIndexCache.set(key, { revision, values })
63
+ if (metadataIndexCache.size > MAX_METADATA_INDEX_CACHE_ENTRIES) {
64
+ const firstKey = metadataIndexCache.keys().next().value
65
+ if (firstKey) metadataIndexCache.delete(firstKey)
66
+ }
67
+ return values
68
+ }
69
+
6
70
  export async function handleStorageApi(req, res, url) {
7
71
  const parts = url.pathname.split('/').filter(Boolean)
8
72
 
@@ -44,36 +108,7 @@ export async function handleStorageApi(req, res, url) {
44
108
 
45
109
  await ensureStorage()
46
110
 
47
- let data
48
- if (scope && (store === 'sessions' || store === 'sessions-metadata')) {
49
- data = await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
50
- } else {
51
- data = await readStore(store)
52
- }
53
-
54
- let values = Object.values(data)
55
- if (store === 'sessions-metadata') {
56
- values = values.filter((value) => value?.messageCount !== 0)
57
- }
58
- values.sort((a, b) => {
59
- if (store === 'sessions-metadata' && indexName === 'lastModified') {
60
- const leftPinned = getComparable(a, 'pinnedAt')
61
- const rightPinned = getComparable(b, 'pinnedAt')
62
- if (leftPinned !== rightPinned) {
63
- if (leftPinned === undefined || leftPinned === null) return 1
64
- if (rightPinned === undefined || rightPinned === null) return -1
65
- return -String(leftPinned).localeCompare(String(rightPinned))
66
- }
67
- }
68
-
69
- const left = getComparable(a, indexName)
70
- const right = getComparable(b, indexName)
71
- if (left === right) return 0
72
- if (left === undefined || left === null) return direction === 'desc' ? 1 : -1
73
- if (right === undefined || right === null) return direction === 'desc' ? -1 : 1
74
- const result = String(left).localeCompare(String(right))
75
- return direction === 'desc' ? -result : result
76
- })
111
+ const values = await readIndexedValues(store, indexName, direction, scope, projectId)
77
112
 
78
113
  const total = values.length
79
114
  const limit = limitParam ? parseInt(limitParam, 10) : undefined
@@ -10,6 +10,7 @@ import {
10
10
  listTerminalSessions,
11
11
  platformInfo,
12
12
  terminalCapabilities,
13
+ writeTerminalInput,
13
14
  } from '../terminal/terminal-manager.mjs'
14
15
 
15
16
  const wsServer = new WebSocketServer({ noServer: true })
@@ -49,6 +50,11 @@ function sessionIdFromPath(pathname) {
49
50
  return match ? decodeSegment(match[1]) : null
50
51
  }
51
52
 
53
+ function inputSessionIdFromPath(pathname) {
54
+ const match = pathname.match(/^\/api\/terminal\/sessions\/([^/]+)\/input$/)
55
+ return match ? decodeSegment(match[1]) : null
56
+ }
57
+
52
58
  export async function handleTerminalApi(req, res, url, options = {}) {
53
59
  await assertLocalTerminalRequest(req, options.isLocalRequest)
54
60
 
@@ -82,13 +88,32 @@ export async function handleTerminalApi(req, res, url, options = {}) {
82
88
  return
83
89
  }
84
90
 
85
- const sessionId = sessionIdFromPath(url.pathname)
86
- if (req.method === 'DELETE' && sessionId) {
87
- destroyTerminalSession(sessionId)
91
+ const inputSessionId = inputSessionIdFromPath(url.pathname)
92
+ if ((req.method === 'POST' || req.method === 'PUT') && inputSessionId) {
93
+ const body = await readJsonBody(req, 256 * 1024) || {}
94
+ if (typeof body.data !== 'string') throw error('Terminal input data is required', 400)
95
+ writeTerminalInput(inputSessionId, body.data)
88
96
  sendJson(res, 200, { ok: true })
89
97
  return
90
98
  }
91
99
 
100
+ const sessionId = sessionIdFromPath(url.pathname)
101
+ if (sessionId) {
102
+ if (req.method === 'POST' || req.method === 'PUT') {
103
+ const body = await readJsonBody(req, 256 * 1024) || {}
104
+ if (typeof body.data !== 'string') throw error('Terminal input data is required', 400)
105
+ writeTerminalInput(sessionId, body.data)
106
+ sendJson(res, 200, { ok: true })
107
+ return
108
+ }
109
+
110
+ if (req.method === 'DELETE') {
111
+ destroyTerminalSession(sessionId)
112
+ sendJson(res, 200, { ok: true })
113
+ return
114
+ }
115
+ }
116
+
92
117
  throw error('Not found', 404)
93
118
  }
94
119
 
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
- import { sendJson } from '../utils/response.mjs'
4
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
5
5
  import { projectContextFromId } from '../project-config.mjs'
6
6
  import {
7
7
  assertSafeWorkspacePath,
@@ -246,6 +246,44 @@ async function handleWorkspaceFile(req, res, url) {
246
246
  })
247
247
  }
248
248
 
249
+ async function handleWorkspaceResolvePath(req, res) {
250
+ const body = await readJsonBody(req, 16 * 1024)
251
+ const projectId = typeof body?.projectId === 'string' ? body.projectId : ''
252
+ const inputPath = typeof body?.path === 'string' ? body.path.trim() : ''
253
+
254
+ if (!projectId) {
255
+ const error = new Error('projectId is required')
256
+ error.statusCode = 400
257
+ throw error
258
+ }
259
+ if (!inputPath) {
260
+ const error = new Error('path is required')
261
+ error.statusCode = 400
262
+ throw error
263
+ }
264
+ if (!path.isAbsolute(inputPath)) {
265
+ const error = new Error('Only absolute paths are supported')
266
+ error.statusCode = 400
267
+ throw error
268
+ }
269
+
270
+ const context = await projectContextFromId(projectId)
271
+ const file = resolveWorkspacePath(inputPath, context)
272
+ await assertSafeWorkspacePath(file, context)
273
+ const stat = await fs.stat(file)
274
+ if (!stat.isFile()) {
275
+ const error = new Error('Path is not a file')
276
+ error.statusCode = 400
277
+ throw error
278
+ }
279
+
280
+ sendJson(res, 200, {
281
+ relativePath: toWorkspaceRelative(file, context),
282
+ exists: true,
283
+ isDirectory: false,
284
+ })
285
+ }
286
+
249
287
  async function handleGitStatus(req, res, url) {
250
288
  const context = await projectContextFromUrl(url)
251
289
  sendJson(res, 200, await listGitStatus(context))
@@ -306,6 +344,10 @@ export async function handleWorkspaceApi(req, res, url) {
306
344
  await handleWorkspaceFile(req, res, url)
307
345
  return
308
346
  }
347
+ if (req.method === 'POST' && url.pathname === '/api/workspace/resolve-path') {
348
+ await handleWorkspaceResolvePath(req, res)
349
+ return
350
+ }
309
351
 
310
352
  const error = new Error('Not found')
311
353
  error.statusCode = 404
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
2
  import { buildInstructionsPayload } from './project-config.mjs'
3
3
  import { composeSystemPrompt } from './system-prompt.mjs'
4
4
  import { listSubagentProfiles } from './agent-profiles.mjs'
@@ -62,6 +62,17 @@ export const stores = new Set([
62
62
  const sessionBucketIndex = new Map()
63
63
  let bucketIndexBuilt = false
64
64
 
65
+ // Monotonic in-process revisions for cache invalidation in route-level indexes.
66
+ const storeRevisions = new Map()
67
+
68
+ function bumpStoreRevision(storeName) {
69
+ storeRevisions.set(storeName, (storeRevisions.get(storeName) || 0) + 1)
70
+ }
71
+
72
+ export function getStoreRevision(storeName) {
73
+ return storeRevisions.get(storeName) || 0
74
+ }
75
+
65
76
  const configStores = new Set(['settings', 'provider-keys', 'custom-providers'])
66
77
  const sessionStores = new Set(['sessions', 'sessions-metadata'])
67
78
 
@@ -370,6 +381,34 @@ async function readAllSessionValues() {
370
381
  return result
371
382
  }
372
383
 
384
+ function sessionMetadataQueueName(bucket) {
385
+ return bucket.scope === 'project' ? `sessions-metadata:${bucket.projectId}` : 'sessions-metadata:global'
386
+ }
387
+
388
+ function sameSessionBucket(left, right) {
389
+ if (!left || !right) return false
390
+ return left.scope === right.scope && (left.projectId || undefined) === (right.projectId || undefined)
391
+ }
392
+
393
+ function updateSessionMetadataBucketIndex(bucket, previousData, nextData) {
394
+ const ids = new Set([
395
+ ...Object.keys(previousData || {}),
396
+ ...Object.keys(nextData || {}),
397
+ ])
398
+
399
+ for (const sessionId of ids) {
400
+ const meta = nextData?.[sessionId]
401
+ if (meta && typeof meta === 'object') {
402
+ sessionBucketIndex.set(sessionId, sessionBucket(meta))
403
+ continue
404
+ }
405
+
406
+ if (sameSessionBucket(sessionBucketIndex.get(sessionId), bucket)) {
407
+ sessionBucketIndex.delete(sessionId)
408
+ }
409
+ }
410
+ }
411
+
373
412
  async function writeSessionValueFile(sessionId, value) {
374
413
  await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
375
414
  // Keep in-memory index current
@@ -497,6 +536,15 @@ async function writeSessionStore(storeName, data) {
497
536
  filesToWrite.add(sessionStoreFile(storeName, bucket))
498
537
  }
499
538
 
539
+ const previousByFile = new Map()
540
+ if (storeName === 'sessions-metadata') {
541
+ await Promise.all(
542
+ [...filesToWrite].map(async (file) => {
543
+ previousByFile.set(file, await readJsonFile(file, {}))
544
+ }),
545
+ )
546
+ }
547
+
500
548
  await Promise.all(
501
549
  [...filesToWrite].map(async (file) => {
502
550
  const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
@@ -506,9 +554,14 @@ async function writeSessionStore(storeName, data) {
506
554
 
507
555
  // Keep in-memory bucket index current for metadata writes
508
556
  if (storeName === 'sessions-metadata') {
509
- for (const [sessionId, meta] of Object.entries(data || {})) {
510
- if (meta && typeof meta === 'object') sessionBucketIndex.set(sessionId, sessionBucket(meta))
557
+ for (const file of filesToWrite) {
558
+ const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
559
+ const bucket = bucketEntry?.bucket ?? (file === sessionStoreFile(storeName, { scope: 'global' })
560
+ ? { scope: 'global' }
561
+ : { scope: 'project', projectId: path.basename(path.dirname(file)) })
562
+ updateSessionMetadataBucketIndex(bucket, previousByFile.get(file) ?? {}, bucketEntry?.data ?? {})
511
563
  }
564
+ bumpStoreRevision(storeName)
512
565
  }
513
566
  }
514
567
 
@@ -607,6 +660,29 @@ export async function atomicUpdate(storeName, updateFn) {
607
660
  })
608
661
  }
609
662
 
663
+ /**
664
+ * Atomically read-modify-write the scoped sessions metadata file within its serialized write queue.
665
+ *
666
+ * @param {string} scope
667
+ * @param {string|null|undefined} projectId
668
+ * @param {(data: object) => object} updateFn — receives current scoped metadata, returns updated metadata
669
+ * @returns {Promise<object>} the updated scoped metadata
670
+ */
671
+ export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
672
+ const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
673
+ const file = sessionStoreFile('sessions-metadata', bucket)
674
+ return enqueueWrite(sessionMetadataQueueName(bucket), async () => {
675
+ await ensureStorage()
676
+ const data = await readJsonFile(file, {})
677
+ const previousData = { ...data }
678
+ const updated = updateFn(data)
679
+ await writeJsonAtomic(file, updated)
680
+ updateSessionMetadataBucketIndex(bucket, previousData, updated)
681
+ bumpStoreRevision('sessions-metadata')
682
+ return updated
683
+ })
684
+ }
685
+
610
686
  /**
611
687
  * Atomically read-modify-write the project config within the config queue.
612
688
  */
@@ -242,6 +242,18 @@ export function attachTerminalClient(sessionId, client) {
242
242
  }
243
243
  }
244
244
 
245
+ export function writeTerminalInput(sessionId, data) {
246
+ const session = sessions.get(sessionId)
247
+ if (!session) throw createError('Terminal session not found', 404)
248
+ if (session.exited) throw createError('Terminal session has exited', 410)
249
+ if (typeof data !== 'string') throw createError('Terminal input must be a string', 400)
250
+
251
+ session.touchedAt = Date.now()
252
+ session.updatedAt = new Date().toISOString()
253
+ session.pty.write(data)
254
+ return serializeSession(session)
255
+ }
256
+
245
257
  export function destroyTerminalSession(sessionId) {
246
258
  const session = sessions.get(sessionId)
247
259
  if (!session) return false