@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.
- package/README.md +15 -15
- package/dist/assets/anthropic-B1_Yrokl.js +39 -0
- package/dist/assets/azure-openai-responses-UMiOBCBd.js +1 -0
- package/dist/assets/google-BLE_Gcd1.js +1 -0
- package/dist/assets/google-shared-Cqjw1plk.js +11 -0
- package/dist/assets/google-vertex-6_sIZLVc.js +1 -0
- package/dist/assets/{icons-WD3UkVNM.js → icons-Bs7OG8yi.js} +1 -1
- package/dist/assets/{index-CjTN0qaQ.js → index-C3bc5C3k.js} +576 -561
- package/dist/assets/index-C7oT9Rdw.css +3 -0
- package/dist/assets/{mistral-Ber29mja.js → mistral-DmZEmRkv.js} +1 -1
- package/dist/assets/openai-codex-responses-i_SmQGzQ.js +7 -0
- package/dist/assets/openai-completions-BmmZFDDY.js +5 -0
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
- package/dist/assets/openai-responses-C8tPdeE9.js +1 -0
- package/dist/assets/{openai-responses-shared-a_PAPxTO.js → openai-responses-shared-DchtjQNp.js} +1 -1
- package/dist/assets/openrouter-CcTv1G_v.js +1 -0
- package/dist/assets/react-vendor-Cu-7p9CI.js +61 -0
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
- package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
- package/dist/index.html +4 -4
- package/package.json +6 -3
- package/server/agent-manager.mjs +144 -151
- package/server/ai-http-logger.mjs +20 -5
- package/server/approval-store.mjs +63 -0
- package/server/custom-commands.mjs +8 -0
- package/server/index.mjs +1 -1
- package/server/message-converters.mjs +79 -0
- package/server/project-config.mjs +7 -9
- package/server/routes/agent-profiles.mjs +1 -1
- package/server/routes/agent.mjs +15 -1
- package/server/routes/filesystem.mjs +18 -2
- package/server/routes/project.mjs +33 -1
- package/server/routes/scheduled-tasks.mjs +1 -1
- package/server/routes/storage.mjs +66 -31
- package/server/routes/terminal.mjs +28 -3
- package/server/routes/workspace.mjs +43 -1
- package/server/session-utils.mjs +1 -1
- package/server/storage.mjs +78 -2
- package/server/terminal/terminal-manager.mjs +12 -0
- package/server/tool-wiring.mjs +87 -0
- package/server/utils/workspace.mjs +20 -1
- package/dist/assets/anthropic-CDKnv1FQ.js +0 -39
- package/dist/assets/azure-openai-responses-BnUwVl-8.js +0 -1
- package/dist/assets/google-DOEyCDZy.js +0 -1
- package/dist/assets/google-shared-CLc4ziON.js +0 -11
- package/dist/assets/google-vertex-BPPf3car.js +0 -1
- package/dist/assets/index-eeLjaV06.css +0 -3
- package/dist/assets/openai-codex-responses-D8gq8a3l.js +0 -7
- package/dist/assets/openai-completions-CATWPFBp.js +0 -5
- package/dist/assets/openai-responses-DxcB6Ksu.js +0 -1
- package/dist/assets/react-vendor-BcQaTQ90.js +0 -9
- package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
- /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
- /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
|
|
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)
|
|
70
|
-
.filter((profile) =>
|
|
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
|
|
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.
|
|
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
|
|
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 '@
|
|
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'
|
package/server/routes/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '@
|
|
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
|
-
|
|
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
|
|
86
|
-
if (req.method === '
|
|
87
|
-
|
|
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
|
package/server/session-utils.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamSimple } from '@
|
|
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'
|
package/server/storage.mjs
CHANGED
|
@@ -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
|
|
510
|
-
|
|
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
|