@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.
Files changed (27) hide show
  1. package/README.md +11 -11
  2. package/dist/assets/{anthropic-CDKnv1FQ.js → anthropic-BrbLtQkg.js} +1 -1
  3. package/dist/assets/{azure-openai-responses-BnUwVl-8.js → azure-openai-responses-q9QFpQk3.js} +1 -1
  4. package/dist/assets/{google-DOEyCDZy.js → google-Bv6IeSRf.js} +1 -1
  5. package/dist/assets/{google-vertex-BPPf3car.js → google-vertex-Cwpe8vbn.js} +1 -1
  6. package/dist/assets/{icons-WD3UkVNM.js → icons-DmRYmmql.js} +1 -1
  7. package/dist/assets/index-C4m48ndP.css +3 -0
  8. package/dist/assets/{index-CjTN0qaQ.js → index-s72bxhrh.js} +566 -557
  9. package/dist/assets/{mistral-Ber29mja.js → mistral-DCZ8VphX.js} +1 -1
  10. package/dist/assets/{openai-codex-responses-D8gq8a3l.js → openai-codex-responses-Bx7iyHzd.js} +1 -1
  11. package/dist/assets/{openai-completions-CATWPFBp.js → openai-completions-CihVV11E.js} +1 -1
  12. package/dist/assets/{openai-responses-DxcB6Ksu.js → openai-responses-BigEdUNS.js} +1 -1
  13. package/dist/assets/{openai-responses-shared-a_PAPxTO.js → openai-responses-shared-RzgnIlMf.js} +1 -1
  14. package/dist/assets/react-vendor-BsV2HYbc.js +61 -0
  15. package/dist/index.html +4 -4
  16. package/package.json +4 -1
  17. package/server/agent-manager.mjs +100 -13
  18. package/server/custom-commands.mjs +8 -0
  19. package/server/index.mjs +1 -1
  20. package/server/project-config.mjs +7 -9
  21. package/server/routes/agent.mjs +15 -1
  22. package/server/routes/project.mjs +33 -1
  23. package/server/routes/terminal.mjs +28 -3
  24. package/server/routes/workspace.mjs +43 -1
  25. package/server/terminal/terminal-manager.mjs +12 -0
  26. package/dist/assets/index-eeLjaV06.css +0 -3
  27. 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-CjTN0qaQ.js"></script>
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-WD3UkVNM.js">
19
- <link rel="modulepreload" crossorigin href="/assets/react-vendor-BcQaTQ90.js">
20
- <link rel="stylesheet" crossorigin href="/assets/index-eeLjaV06.css">
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.23",
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",
@@ -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 createCommandToolPermissions(session) {
192
- return (toolName) => {
193
- const permissions = session.activeCommandPermissions
194
- if (!permissions || !commandRestrictedTools.has(toolName)) return null
195
- if (toolName === 'run_command' && permissions.allowCommands === false) {
196
- return `Custom command /${session.activeCommandName} does not allow running shell commands.`
197
- }
198
- if ((toolName === 'write_file' || toolName === 'edit_file') && permissions.allowEdit === false) {
199
- return `Custom command /${session.activeCommandName} does not allow editing files.`
200
- }
201
- return null
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 || 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
 
@@ -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)
@@ -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
@@ -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
@@ -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