@shawnstack/quickforge 1.3.23 → 1.3.24
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 +11 -11
- package/dist/assets/{anthropic-CDKnv1FQ.js → anthropic-BrbLtQkg.js} +1 -1
- package/dist/assets/{azure-openai-responses-BnUwVl-8.js → azure-openai-responses-q9QFpQk3.js} +1 -1
- package/dist/assets/{google-DOEyCDZy.js → google-Bv6IeSRf.js} +1 -1
- package/dist/assets/{google-vertex-BPPf3car.js → google-vertex-Cwpe8vbn.js} +1 -1
- package/dist/assets/{icons-WD3UkVNM.js → icons-DmRYmmql.js} +1 -1
- package/dist/assets/index-C4m48ndP.css +3 -0
- package/dist/assets/{index-CjTN0qaQ.js → index-s72bxhrh.js} +566 -557
- package/dist/assets/{mistral-Ber29mja.js → mistral-DCZ8VphX.js} +1 -1
- package/dist/assets/{openai-codex-responses-D8gq8a3l.js → openai-codex-responses-Bx7iyHzd.js} +1 -1
- package/dist/assets/{openai-completions-CATWPFBp.js → openai-completions-CihVV11E.js} +1 -1
- package/dist/assets/{openai-responses-DxcB6Ksu.js → openai-responses-BigEdUNS.js} +1 -1
- package/dist/assets/{openai-responses-shared-a_PAPxTO.js → openai-responses-shared-RzgnIlMf.js} +1 -1
- package/dist/assets/react-vendor-BsV2HYbc.js +61 -0
- package/dist/index.html +4 -4
- package/package.json +4 -1
- package/server/agent-manager.mjs +100 -13
- package/server/custom-commands.mjs +8 -0
- package/server/index.mjs +1 -1
- package/server/project-config.mjs +7 -9
- package/server/routes/agent.mjs +15 -1
- package/server/routes/project.mjs +33 -1
- package/server/routes/terminal.mjs +28 -3
- package/server/routes/workspace.mjs +43 -1
- package/server/terminal/terminal-manager.mjs +12 -0
- package/dist/assets/index-eeLjaV06.css +0 -3
- package/dist/assets/react-vendor-BcQaTQ90.js +0 -9
package/dist/index.html
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="QuickForge" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<title>速构 QuickForge</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-s72bxhrh.js"></script>
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CkqCuyE9.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/lit-vendor-Dr3cpBGF.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="/assets/css-utils-rkE68RDy.js">
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/icons-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/assets/react-vendor-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/icons-DmRYmmql.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/assets/react-vendor-BsV2HYbc.js">
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C4m48ndP.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shawnstack/quickforge",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.24",
|
|
4
4
|
"description": "AI chat application with YOLO-mode local workspace tools. React + Vite + Tailwind CSS frontend, local Node.js storage server.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"package.json"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@dnd-kit/core": "^6.3.1",
|
|
46
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
47
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
45
48
|
"@mariozechner/pi-agent-core": "^0.73.1",
|
|
46
49
|
"@mariozechner/pi-ai": "^0.73.1",
|
|
47
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/server/agent-manager.mjs
CHANGED
|
@@ -183,23 +183,28 @@ const agentSessions = new Map()
|
|
|
183
183
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
|
|
184
184
|
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
|
|
185
185
|
const SUBAGENT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
|
186
|
-
const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command'])
|
|
186
|
+
const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command', 'run_subagent'])
|
|
187
187
|
const safeReadTools = new Set(['read_file', 'grep_files'])
|
|
188
188
|
const pendingApprovals = new Map() // toolCallId → { resolve, reject, sessionId, toolName, args, source, timeout }
|
|
189
189
|
const pendingAutoCompactApprovals = new Map() // approvalId → { resolve, reject, sessionId, timeout }
|
|
190
190
|
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
191
|
+
function commandToolPermissionError(session, toolName) {
|
|
192
|
+
const permissions = session?.activeCommandPermissions
|
|
193
|
+
if (!permissions || !commandRestrictedTools.has(toolName)) return null
|
|
194
|
+
if (toolName === 'run_command' && permissions.allowCommands === false) {
|
|
195
|
+
return `Command /${session.activeCommandName} does not allow running shell commands.`
|
|
196
|
+
}
|
|
197
|
+
if (toolName === 'run_subagent' && permissions.allowCommands === false) {
|
|
198
|
+
return `Command /${session.activeCommandName} does not allow running subagents.`
|
|
199
|
+
}
|
|
200
|
+
if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
|
|
201
|
+
return `Command /${session.activeCommandName} does not allow editing files.`
|
|
202
202
|
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createCommandToolPermissions(session) {
|
|
207
|
+
return (toolName) => commandToolPermissionError(session, toolName)
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
/**
|
|
@@ -606,6 +611,14 @@ async function resolveCommandState(session, userMessage) {
|
|
|
606
611
|
if (typeof internalResponse === 'string') return { textResponse: internalResponse }
|
|
607
612
|
if (internalResponse?.clear) return { clear: internalResponse }
|
|
608
613
|
if (internalResponse?.compact) return { compact: internalResponse }
|
|
614
|
+
if (internalResponse?.plan) {
|
|
615
|
+
return {
|
|
616
|
+
userMessage,
|
|
617
|
+
commandPrompt: formatPlanCommandPrompt(internalResponse.args),
|
|
618
|
+
permissions: { allowEdit: false, allowCommands: false },
|
|
619
|
+
commandName: 'plan',
|
|
620
|
+
}
|
|
621
|
+
}
|
|
609
622
|
|
|
610
623
|
if (!session.projectContext?.workspaceRoot) return { userMessage }
|
|
611
624
|
|
|
@@ -624,6 +637,34 @@ async function resolveCommandState(session, userMessage) {
|
|
|
624
637
|
}
|
|
625
638
|
}
|
|
626
639
|
|
|
640
|
+
function formatPlanCommandPrompt(task) {
|
|
641
|
+
const taskText = String(task || '').trim()
|
|
642
|
+
return `<plan_command_invocation name="plan">
|
|
643
|
+
This /plan command applies only to the current user request. Generate an implementation plan before execution.
|
|
644
|
+
|
|
645
|
+
Rules for this turn:
|
|
646
|
+
- Do not modify files.
|
|
647
|
+
- Do not create files.
|
|
648
|
+
- Do not run shell commands.
|
|
649
|
+
- Do not use write_file, edit_file, run_command, or any other state-changing tool.
|
|
650
|
+
- You may use read-only tools such as read_file and grep_files if needed to inspect the project.
|
|
651
|
+
- Output the plan and then stop. Do not start implementation.
|
|
652
|
+
|
|
653
|
+
Plan should include:
|
|
654
|
+
1. Task understanding
|
|
655
|
+
2. Relevant files or areas to inspect/change
|
|
656
|
+
3. Step-by-step implementation plan
|
|
657
|
+
4. Risks or assumptions
|
|
658
|
+
5. Validation commands/checks to run after implementation
|
|
659
|
+
6. Whether documentation/wiki updates are needed
|
|
660
|
+
|
|
661
|
+
End by telling the user they can reply “允许”, “按计划执行”, or an equivalent approval phrase to continue in a normal follow-up turn.
|
|
662
|
+
|
|
663
|
+
User task:
|
|
664
|
+
${taskText}
|
|
665
|
+
</plan_command_invocation>`
|
|
666
|
+
}
|
|
667
|
+
|
|
627
668
|
function omitDetailsForLlm(message) {
|
|
628
669
|
if (!message || typeof message !== 'object' || message.details === undefined) return message
|
|
629
670
|
const copy = { ...message }
|
|
@@ -1050,9 +1091,11 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1050
1091
|
beforeToolCall: async (context) => {
|
|
1051
1092
|
const toolName = context.toolCall?.name
|
|
1052
1093
|
const toolCallId = context.toolCall?.id
|
|
1094
|
+
const currentSession = agentSessions.get(sessionId)
|
|
1095
|
+
const commandPermissionError = commandToolPermissionError(currentSession, toolName)
|
|
1096
|
+
if (commandPermissionError) return { block: true, reason: commandPermissionError }
|
|
1053
1097
|
const isSkillTool = toolName === 'activate_skill' || toolName === 'read_skill_resource'
|
|
1054
1098
|
if (isSkillTool) return undefined
|
|
1055
|
-
const currentSession = agentSessions.get(sessionId)
|
|
1056
1099
|
if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
|
|
1057
1100
|
if (toolName === 'run_subagent') return undefined
|
|
1058
1101
|
if (isMcpToolName(toolName)) {
|
|
@@ -1454,6 +1497,50 @@ export async function runPrompt(sessionId, message) {
|
|
|
1454
1497
|
return { sessionId, status: session.status }
|
|
1455
1498
|
}
|
|
1456
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* Continue generation from the current last message (must be a user or
|
|
1502
|
+
* tool-result message). Used by the retry button to regenerate a response
|
|
1503
|
+
* in-place without appending a new user message.
|
|
1504
|
+
*
|
|
1505
|
+
* Trims messages to keep up to and including the last user message,
|
|
1506
|
+
* removing the assistant response that follows it.
|
|
1507
|
+
*/
|
|
1508
|
+
export async function continueSession(sessionId) {
|
|
1509
|
+
const session = agentSessions.get(sessionId)
|
|
1510
|
+
if (!session) {
|
|
1511
|
+
throw Object.assign(new Error('Session not found'), { statusCode: 404 })
|
|
1512
|
+
}
|
|
1513
|
+
if (session.agent.state.isStreaming) {
|
|
1514
|
+
throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const messages = Array.isArray(session.agent.state.messages) ? session.agent.state.messages : []
|
|
1518
|
+
|
|
1519
|
+
// Find the last user message and trim everything after it (the assistant response)
|
|
1520
|
+
let lastUserIndex = -1
|
|
1521
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1522
|
+
if (messages[i].role === 'user' || messages[i].role === 'user-with-attachments') {
|
|
1523
|
+
lastUserIndex = i
|
|
1524
|
+
break
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (lastUserIndex < 0) {
|
|
1528
|
+
throw Object.assign(new Error('Cannot continue: no user message found.'), { statusCode: 400 })
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const trimmedMessages = messages.slice(0, lastUserIndex + 1)
|
|
1532
|
+
updateSessionMessages(session, trimmedMessages)
|
|
1533
|
+
resetSessionCompaction(session)
|
|
1534
|
+
|
|
1535
|
+
resetIdleTimer(session)
|
|
1536
|
+
session.agent.continue().catch((err) => {
|
|
1537
|
+
logger.error(`Agent continue error for session ${sessionId}:`, err, { sessionId })
|
|
1538
|
+
emitSessionEvent(session, { type: 'error', error: err.message || 'Unknown error' })
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
return { sessionId, status: 'running' }
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1457
1544
|
/**
|
|
1458
1545
|
* Abort the current agent run.
|
|
1459
1546
|
*/
|
|
@@ -251,6 +251,9 @@ export function parseInternalCommandInvocation(message) {
|
|
|
251
251
|
if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
|
|
252
252
|
if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
|
|
253
253
|
|
|
254
|
+
const planMatch = text.match(/^\/plan(?:\s+([\s\S]*))?$/i)
|
|
255
|
+
if (planMatch) return { type: 'plan', args: (planMatch[1] || '').trim() }
|
|
256
|
+
|
|
254
257
|
const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
|
|
255
258
|
if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
|
|
256
259
|
|
|
@@ -270,6 +273,11 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
270
273
|
return { compact: true, args: invocation.args || '' }
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
if (invocation.type === 'plan') {
|
|
277
|
+
if (!invocation.args) return 'Usage: /plan <task>'
|
|
278
|
+
return { plan: true, args: invocation.args }
|
|
279
|
+
}
|
|
280
|
+
|
|
273
281
|
if (invocation.type === 'clear') {
|
|
274
282
|
return { clear: true }
|
|
275
283
|
}
|
package/server/index.mjs
CHANGED
|
@@ -234,7 +234,7 @@ async function handleApi(req, res, url) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
// Project workspace inspector routes
|
|
237
|
-
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file') {
|
|
237
|
+
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
|
|
238
238
|
await handleWorkspaceApi(req, res, url)
|
|
239
239
|
return
|
|
240
240
|
}
|
|
@@ -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
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|