@shawnstack/quickforge 1.7.5 → 1.7.7

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 (67) hide show
  1. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +1 -0
  2. package/dist/assets/ChatPanelHost-Dul1ahKj.js +307 -0
  3. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +1 -0
  4. package/dist/assets/PluginsPage-CPE5tbbf.js +1 -0
  5. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +2 -0
  6. package/dist/assets/SettingsWorkspacePage-BE2T4d3P.js +1696 -0
  7. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +1 -0
  8. package/dist/assets/SharedConversationPage-BK9XJnEe.js +1 -0
  9. package/dist/assets/TerminalDock-CL7HU9DI.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-D9kdnLDd.js → WorkspaceInspector-MNBLIKMn.js} +5 -5
  11. package/dist/assets/{dist-DLKC_odd.js → dist-yDHbi5ux.js} +1 -1
  12. package/dist/assets/{esm-Df4edDpj.js → esm-sdsK6gaB.js} +2 -2
  13. package/dist/assets/{icons-CXPP1fFT.js → icons-2D18YbSI.js} +1 -1
  14. package/dist/assets/index-BTPzyq7d.js +65 -0
  15. package/dist/assets/index-BiCTgUlY.css +3 -0
  16. package/dist/assets/{mcp-servers-dialog-BUSPvaqI.js → mcp-servers-dialog-GY7sdfIr.js} +2 -2
  17. package/dist/assets/{logger-8REuLPGK.js → model-display-label-C6-0eDxF.js} +1 -1
  18. package/dist/assets/{monaco-D6OhXgjg.js → monaco-DvXErxp2.js} +1 -1
  19. package/dist/assets/{react-vendor-1KC5PyRx.js → react-vendor-3UaA-C9W.js} +1 -1
  20. package/dist/assets/{skills-dialog-BThnIUBs.js → skills-dialog-C1fJ1Jl7.js} +3 -3
  21. package/dist/assets/{web-DoV8MSMn.js → web-B5qgZJRv.js} +1 -1
  22. package/dist/index.html +8 -8
  23. package/dist/sw.js +17 -0
  24. package/package.json +2 -1
  25. package/runtime-assets/agent/darwin-arm64/qf-agent +0 -0
  26. package/runtime-assets/agent/darwin-x64/qf-agent +0 -0
  27. package/runtime-assets/agent/linux-arm64/qf-agent +0 -0
  28. package/runtime-assets/agent/linux-x64/qf-agent +0 -0
  29. package/runtime-assets/agent/win32-x64/qf-agent.exe +0 -0
  30. package/server/access-policy.mjs +11 -0
  31. package/server/agent-manager.mjs +349 -48
  32. package/server/agent-profiles.mjs +27 -8
  33. package/server/auto-compaction.mjs +78 -9
  34. package/server/cloud/client.mjs +4 -7
  35. package/server/cloud/config.mjs +9 -16
  36. package/server/cloud/credential-store.mjs +10 -3
  37. package/server/cloud/identity.mjs +17 -60
  38. package/server/cloud/models.mjs +1 -1
  39. package/server/cloud/qf-agent-process.mjs +571 -0
  40. package/server/cloud/runtime.mjs +11 -5
  41. package/server/cloud/service-config.mjs +8 -4
  42. package/server/context-usage.mjs +11 -4
  43. package/server/index.mjs +96 -37
  44. package/server/mcp/registry.mjs +8 -7
  45. package/server/message-converters.mjs +40 -19
  46. package/server/model-catalog.mjs +11 -4
  47. package/server/opencode-acp-agent.mjs +1151 -0
  48. package/server/public-api.mjs +11 -5
  49. package/server/routes/agent-profiles.mjs +6 -3
  50. package/server/routes/agent.mjs +43 -3
  51. package/server/routes/backup.mjs +5 -4
  52. package/server/routes/cloud.mjs +54 -35
  53. package/server/routes/shares.mjs +7 -8
  54. package/server/routes/storage.mjs +9 -24
  55. package/server/routes/system.mjs +7 -4
  56. package/server/utils/process-tree.mjs +78 -0
  57. package/dist/assets/AgentProfilesPage-XNjuqJs7.js +0 -1
  58. package/dist/assets/ChatPanelHost-CuF-PZpc.js +0 -292
  59. package/dist/assets/CloudAccountSettingsPage-GJSoi6sO.js +0 -1
  60. package/dist/assets/PluginsPage-BQgp0qN-.js +0 -1
  61. package/dist/assets/ScheduledTasksPage-DdekATzq.js +0 -2
  62. package/dist/assets/SettingsWorkspacePage-Cb54Ic91.js +0 -1639
  63. package/dist/assets/ShareLinksSettingsPage-C75WJpaF.js +0 -1
  64. package/dist/assets/SharedConversationPage-CjMtSQTW.js +0 -1
  65. package/dist/assets/TerminalDock-D4zJBJc4.js +0 -2
  66. package/dist/assets/index-_ZhK6bvp.js +0 -65
  67. package/dist/assets/index-iVV2bP_f.css +0 -3
@@ -8,6 +8,7 @@ import { loadSkillToolContext, abortRunningCommand } from './tools/index.mjs'
8
8
  import { createSkillTools, globalMemoryTool, workspaceTools } from './tools/definitions.mjs'
9
9
  import { createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
10
10
  import { createPluginToolDefinitions, isPluginToolName } from './plugins/registry.mjs'
11
+ import { createOpenCodeAcpAgent } from './opencode-acp-agent.mjs'
11
12
  import {
12
13
  composeSubagentSystemPrompt,
13
14
  formatSubagentTask,
@@ -148,6 +149,10 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
148
149
  }
149
150
 
150
151
  async function rebuildSessionTools(session) {
152
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
153
+ session.agent.state.tools = []
154
+ return
155
+ }
151
156
  const profileToolNames = Array.isArray(session.agentProfile?.allowedTools) ? session.agentProfile.allowedTools : null
152
157
  session.agent.state.tools = await createServerTools(
153
158
  session.projectId,
@@ -180,6 +185,25 @@ const agentSessions = new Map()
180
185
 
181
186
  const AGENT_ACCESS_MODE_DEFAULT = 'default'
182
187
  const AGENT_ACCESS_MODE_FULL_ACCESS = 'full-access'
188
+ const AGENT_HARNESS_QUICKFORGE = 'quickforge'
189
+ const AGENT_HARNESS_OPENCODE = 'opencode'
190
+
191
+ export function normalizeAgentHarness(value, fallback = AGENT_HARNESS_QUICKFORGE) {
192
+ if (value === AGENT_HARNESS_QUICKFORGE || value === AGENT_HARNESS_OPENCODE) return value
193
+ if (fallback !== value) return normalizeAgentHarness(fallback, AGENT_HARNESS_QUICKFORGE)
194
+ return AGENT_HARNESS_QUICKFORGE
195
+ }
196
+
197
+ export function validateAgentHarness(value) {
198
+ if (value === undefined || value === null || value === '') return AGENT_HARNESS_QUICKFORGE
199
+ if (value === 'claude-code') {
200
+ throw Object.assign(new Error('Claude Code Harness is not available yet.'), { statusCode: 400 })
201
+ }
202
+ if (value !== AGENT_HARNESS_QUICKFORGE && value !== AGENT_HARNESS_OPENCODE) {
203
+ throw Object.assign(new Error(`Unsupported Harness: ${value}`), { statusCode: 400 })
204
+ }
205
+ return value
206
+ }
183
207
 
184
208
  function normalizeAccessMode(value, fallback = AGENT_ACCESS_MODE_DEFAULT) {
185
209
  if (value === AGENT_ACCESS_MODE_DEFAULT || value === AGENT_ACCESS_MODE_FULL_ACCESS) return value
@@ -279,6 +303,58 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
279
303
  })
280
304
  }
281
305
 
306
+ function createAcpApprovalPromise(session, request) {
307
+ return new Promise((resolve, reject) => {
308
+ let settled = false
309
+ const requestedAt = Date.now()
310
+ const expiresAt = requestedAt + APPROVAL_TIMEOUT_MS
311
+ const timeout = setTimeout(() => {
312
+ if (settled) return
313
+ settled = true
314
+ pendingApprovals.delete(request.toolCallId)
315
+ resolve({ outcome: { outcome: 'cancelled' } })
316
+ }, APPROVAL_TIMEOUT_MS)
317
+
318
+ const cleanup = () => {
319
+ clearTimeout(timeout)
320
+ if (settled) return
321
+ settled = true
322
+ pendingApprovals.delete(request.toolCallId)
323
+ }
324
+ const allowOption = request.options.find((option) => option.kind === 'allow_once' || option.kind === 'allow_always')
325
+ const rejectOption = request.options.find((option) => option.kind === 'reject_once' || option.kind === 'reject_always')
326
+
327
+ pendingApprovals.set(request.toolCallId, {
328
+ resolve: (approved) => {
329
+ cleanup()
330
+ const option = approved ? allowOption : rejectOption
331
+ resolve(option
332
+ ? { outcome: { outcome: 'selected', optionId: option.optionId } }
333
+ : { outcome: { outcome: 'cancelled' } })
334
+ },
335
+ reject: (error) => {
336
+ cleanup()
337
+ reject(error)
338
+ },
339
+ sessionId: session.sessionId,
340
+ toolName: request.toolName,
341
+ args: request.args,
342
+ source: 'opencode',
343
+ requestedAt,
344
+ expiresAt,
345
+ })
346
+
347
+ emitSessionEvent(session, {
348
+ type: 'tool_approval_required',
349
+ sessionId: session.sessionId,
350
+ toolCallId: request.toolCallId,
351
+ toolName: request.toolName,
352
+ args: request.args,
353
+ source: 'opencode',
354
+ })
355
+ })
356
+ }
357
+
282
358
  function createAutoCompactApprovalPromise(session, details = {}) {
283
359
  if (!session) return Promise.resolve(false)
284
360
  const approvalId = randomUUID()
@@ -377,6 +453,24 @@ function userTextMessage(text, details) {
377
453
  }
378
454
  }
379
455
 
456
+ function assistantErrorMessage(text, model) {
457
+ return {
458
+ ...assistantTextMessage('', model),
459
+ stopReason: 'error',
460
+ errorMessage: text,
461
+ timestamp: Date.now(),
462
+ }
463
+ }
464
+
465
+ export function appendAssistantErrorMessageOnce(messages, errorMessage, model) {
466
+ const current = Array.isArray(messages) ? messages : []
467
+ const lastMessage = current[current.length - 1]
468
+ const errorAlreadyShown = lastMessage?.role === 'assistant'
469
+ && lastMessage?.stopReason === 'error'
470
+ && lastMessage?.errorMessage
471
+ return errorAlreadyShown ? current : [...current, assistantErrorMessage(errorMessage, model)]
472
+ }
473
+
380
474
  function compactedSessionTitle(title) {
381
475
  const base = typeof title === 'string' && title.trim() ? title.trim() : 'New chat'
382
476
  if (base === 'New chat') return 'Compacted chat'
@@ -564,6 +658,9 @@ function finishManualSessionRun(session, status, errorMessage) {
564
658
  }
565
659
 
566
660
  async function summarySession(session, initialUserMessage, summaryOptions) {
661
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
662
+ throw Object.assign(new Error('OpenCode Harness does not support QuickForge summary derivation yet.'), { statusCode: 409 })
663
+ }
567
664
  if (session.agent.state.isStreaming) {
568
665
  session.agent.state.messages = [
569
666
  ...session.agent.state.messages,
@@ -657,6 +754,8 @@ async function summarySession(session, initialUserMessage, summaryOptions) {
657
754
  scope: session.scope,
658
755
  projectId: session.projectId,
659
756
  accessMode: session.accessMode,
757
+ harness: session.harness,
758
+ sourceHarnessSessionId: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.harnessSessionId : null,
660
759
  yoloMode: session.yoloMode,
661
760
  model: session.model,
662
761
  modelRef: session.modelRef,
@@ -1562,12 +1661,17 @@ export async function createAgent(sessionId, config = {}) {
1562
1661
  createdAt = new Date().toISOString(),
1563
1662
  lastModified = null,
1564
1663
  contextCompaction = null,
1664
+ harness: rawHarness,
1665
+ harnessSessionId = null,
1666
+ sourceHarnessSessionId = null,
1667
+ openCodeUsage = null,
1565
1668
  agentProfile = null,
1566
1669
  idleRetention = null,
1567
1670
  stateVersion = 0,
1568
1671
  } = config
1569
1672
  const accessMode = normalizeAccessMode(rawAccessMode, yoloMode)
1570
1673
  const resolvedYoloMode = yoloModeFromAccessMode(accessMode)
1674
+ const harness = normalizeAgentHarness(rawHarness)
1571
1675
 
1572
1676
  // Resolve project context for tool calls. Project conversations resolve to
1573
1677
  // their directory; global conversations (no projectId) and any fallback fall
@@ -1582,8 +1686,8 @@ export async function createAgent(sessionId, config = {}) {
1582
1686
  }
1583
1687
  projectContext ??= defaultGlobalWorkspaceContext()
1584
1688
 
1585
- // Build system prompt
1586
- const projectConfig = await readProjectConfig()
1689
+ // Build system prompt and tools only for the native QuickForge runtime.
1690
+ const projectConfig = harness === AGENT_HARNESS_QUICKFORGE ? await readProjectConfig() : { projects: [], globalSkills: [] }
1587
1691
  const configuredProject = projectId
1588
1692
  ? projectConfig.projects.find((project) => project.id === projectId)
1589
1693
  : null
@@ -1592,14 +1696,20 @@ export async function createAgent(sessionId, config = {}) {
1592
1696
  projectSkillNames: configuredProject?.skills,
1593
1697
  }
1594
1698
  const profileSystemPrompt = agentProfileSystemPrompt(agentProfile)
1595
- const resolvedSystemPrompt = systemPrompt ?? `${await buildSystemPrompt(projectId)}${profileSystemPrompt}`
1699
+ const resolvedSystemPrompt = harness === AGENT_HARNESS_QUICKFORGE
1700
+ ? systemPrompt ?? `${await buildSystemPrompt(projectId)}${profileSystemPrompt}`
1701
+ : ''
1702
+ if (harness === AGENT_HARNESS_OPENCODE && messages.length > 0 && !harnessSessionId && !sourceHarnessSessionId) {
1703
+ throw Object.assign(new Error('OpenCode history requires a persisted ACP session ID or an ACP fork source.'), { statusCode: 400 })
1704
+ }
1596
1705
 
1597
1706
  let resolvedAgentProfile = agentProfile
1598
1707
 
1599
- // Resolve model
1708
+ // OpenCode owns its login, model, tools and context. QuickForge runtime keeps
1709
+ // the existing model resolution path unchanged.
1600
1710
  let resolvedModel = model
1601
1711
  let resolvedModelRef = modelRef
1602
- if (!resolvedModel) {
1712
+ if (!resolvedModel && harness === AGENT_HARNESS_QUICKFORGE) {
1603
1713
  // Try to load the active preference from storage. A stale/hidden implicit
1604
1714
  // preference may fall back, while an explicit session binding never does.
1605
1715
  try {
@@ -1620,7 +1730,7 @@ export async function createAgent(sessionId, config = {}) {
1620
1730
  }
1621
1731
  }
1622
1732
  if (resolvedAgentProfile && !resolvedModel) throw new Error('No active model is configured for the agent session.')
1623
- const resolvedBinding = resolvedModel
1733
+ const resolvedBinding = harness === AGENT_HARNESS_QUICKFORGE && resolvedModel
1624
1734
  ? (resolvedModelRef
1625
1735
  ? { model: resolvedModel, modelRef: resolvedModelRef }
1626
1736
  : model
@@ -1632,33 +1742,36 @@ export async function createAgent(sessionId, config = {}) {
1632
1742
  ? resolveAgentProfileThinkingLevel(agentProfile, thinkingLevel, resolvedModel)
1633
1743
  : thinkingLevel
1634
1744
 
1635
- // Build skills tools for enabled skills, plus workspace tools when a project is available.
1745
+ // Build native tools only for QuickForge. OpenCode uses its own tools and
1746
+ // must not receive QuickForge MCP, Skills, Memory or workspace definitions.
1636
1747
  const profileToolNames = Array.isArray(agentProfile?.allowedTools) ? agentProfile.allowedTools : null
1637
- const tools = await createServerTools(
1638
- projectId,
1639
- projectContext,
1640
- skillsContext,
1641
- !!projectContext,
1642
- (toolName) => {
1643
- if (profileToolNames && !profileToolNames.includes(toolName)) return `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.`
1644
- const session = agentSessions.get(sessionId)
1645
- return session ? createCommandToolPermissions(session)(toolName) : null
1646
- },
1647
- agentProfile
1648
- ? {
1649
- allowedToolNames: profileToolNames,
1650
- includeSubagentTool: false,
1651
- includeMcpTools: false,
1652
- parentSessionId: sessionId,
1653
- sessionId,
1654
- scope,
1655
- }
1656
- : {
1657
- parentSessionId: sessionId,
1658
- sessionId,
1659
- scope,
1748
+ const tools = harness === AGENT_HARNESS_QUICKFORGE
1749
+ ? await createServerTools(
1750
+ projectId,
1751
+ projectContext,
1752
+ skillsContext,
1753
+ !!projectContext,
1754
+ (toolName) => {
1755
+ if (profileToolNames && !profileToolNames.includes(toolName)) return `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.`
1756
+ const session = agentSessions.get(sessionId)
1757
+ return session ? createCommandToolPermissions(session)(toolName) : null
1660
1758
  },
1661
- )
1759
+ agentProfile
1760
+ ? {
1761
+ allowedToolNames: profileToolNames,
1762
+ includeSubagentTool: false,
1763
+ includeMcpTools: false,
1764
+ parentSessionId: sessionId,
1765
+ sessionId,
1766
+ scope,
1767
+ }
1768
+ : {
1769
+ parentSessionId: sessionId,
1770
+ sessionId,
1771
+ scope,
1772
+ },
1773
+ )
1774
+ : []
1662
1775
 
1663
1776
  // Resolve API key
1664
1777
  const getApiKey = async (provider) => {
@@ -1670,10 +1783,21 @@ export async function createAgent(sessionId, config = {}) {
1670
1783
  }
1671
1784
  }
1672
1785
 
1673
- const initialMemoryEnabled = await isGlobalMemoryEnabled()
1786
+ const initialMemoryEnabled = harness === AGENT_HARNESS_QUICKFORGE ? await isGlobalMemoryEnabled() : false
1674
1787
  const initialMemoryRevision = initialMemoryEnabled ? await getGlobalMemoryRevision() : null
1675
1788
  let session
1676
- const agent = new Agent({
1789
+ const agent = harness === AGENT_HARNESS_OPENCODE
1790
+ ? await createOpenCodeAcpAgent({
1791
+ sessionId,
1792
+ cwd: projectContext.workspaceRoot,
1793
+ messages,
1794
+ harnessSessionId,
1795
+ sourceHarnessSessionId,
1796
+ restoredUsage: openCodeUsage,
1797
+ logger,
1798
+ requestPermission: (request) => createAcpApprovalPromise(session, request),
1799
+ })
1800
+ : new Agent({
1677
1801
  initialState: {
1678
1802
  systemPrompt: resolvedSystemPrompt,
1679
1803
  model: resolvedModel,
@@ -1729,6 +1853,8 @@ export async function createAgent(sessionId, config = {}) {
1729
1853
  session = {
1730
1854
  sessionId,
1731
1855
  agent,
1856
+ harness,
1857
+ harnessSessionId: agent.harnessSessionId || null,
1732
1858
  projectContext,
1733
1859
  projectId,
1734
1860
  source: typeof source === 'string' && source.trim() ? source.trim() : null,
@@ -1801,6 +1927,15 @@ export async function createAgent(sessionId, config = {}) {
1801
1927
  : undefined
1802
1928
  if (timedEvent.type === 'agent_end') {
1803
1929
  markLatestAssistantProcessFinished(agent.state.messages)
1930
+ // Ensure a failed run is visible in the conversation. Most failures emit
1931
+ // an assistant error message through the agent loop (handleRunFailure),
1932
+ // but a few paths (e.g. concurrent-run rejection) only set
1933
+ // `state.errorMessage`. Append an error message so the user sees the
1934
+ // reason at the end of the transcript instead of only a toast.
1935
+ const runError = session.agent.state.errorMessage
1936
+ if (runError) {
1937
+ agent.state.messages = appendAssistantErrorMessageOnce(agent.state.messages, runError, session.model)
1938
+ }
1804
1939
  }
1805
1940
  const forwardEvent = timedEvent.type === 'agent_end'
1806
1941
  ? {
@@ -1816,6 +1951,12 @@ export async function createAgent(sessionId, config = {}) {
1816
1951
  emitSessionEvent(session, forwardEvent)
1817
1952
  }
1818
1953
 
1954
+ // OpenCode runtime usage snapshots are authoritative and lightweight; the
1955
+ // debounced write keeps the latest usage durable without blocking the run.
1956
+ if (event.type === 'acp_session_usage_update') {
1957
+ scheduleSessionPersist(session)
1958
+ }
1959
+
1819
1960
  // Track status
1820
1961
  if (event.type === 'agent_start') {
1821
1962
  session.abortEndEmitted = false
@@ -1848,7 +1989,7 @@ export async function createAgent(sessionId, config = {}) {
1848
1989
 
1849
1990
  if (event.type === 'message_end') {
1850
1991
  const isUserMessage = event.message?.role === 'user' || event.message?.role === 'user-with-attachments'
1851
- const isInitialUserMessage = isUserMessage && session.agent.state.messages.length === 1
1992
+ const isInitialUserMessage = isUserMessage && (event.isInitialUserMessage === true || session.agent.state.messages.length === 1)
1852
1993
  const requiresDurableCloudMessage = isUserMessage && isManagedCloudModel(session.model) && Boolean(logicalMessageId(event.message))
1853
1994
  if (isInitialUserMessage || requiresDurableCloudMessage) {
1854
1995
  // Persist every managed Cloud user message before the provider stream starts,
@@ -1860,7 +2001,7 @@ export async function createAgent(sessionId, config = {}) {
1860
2001
  session.sessionCreatedEmitted = true
1861
2002
  emitSessionEvent(session, { type: 'session_created', metadata })
1862
2003
  }
1863
- scheduleSessionTitleGeneration(session, event.message)
2004
+ if (session.harness === AGENT_HARNESS_QUICKFORGE) scheduleSessionTitleGeneration(session, event.message)
1864
2005
  }
1865
2006
  } catch (err) {
1866
2007
  logger.error(`Failed to persist user message for session ${sessionId}:`, err, { sessionId })
@@ -1907,7 +2048,7 @@ function sessionLastModifiedFromMessages(messages, fallback) {
1907
2048
  * Persist session data to storage.
1908
2049
  */
1909
2050
  async function persistSessionUnlocked(session) {
1910
- const { sessionId, agent, scope, projectId, source, channelId, channelName, title, titleSource, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, modelRef, thinkingLevel, accessMode, yoloMode, contextCompaction } = session
2051
+ const { sessionId, agent, harness, harnessSessionId, scope, projectId, source, channelId, channelName, title, titleSource, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, modelRef, thinkingLevel, accessMode, yoloMode, contextCompaction } = session
1911
2052
  const messages = agent.state.messages
1912
2053
 
1913
2054
  if (messages.length === 0) {
@@ -1929,6 +2070,9 @@ async function persistSessionUnlocked(session) {
1929
2070
  id: sessionId,
1930
2071
  title,
1931
2072
  titleSource,
2073
+ harness,
2074
+ harnessSessionId: agent.harnessSessionId || harnessSessionId || undefined,
2075
+ openCodeUsage: harness === AGENT_HARNESS_OPENCODE && agent.state.acpSession?.usage ? agent.state.acpSession.usage : undefined,
1932
2076
  model,
1933
2077
  modelRef: modelRef || undefined,
1934
2078
  thinkingLevel,
@@ -1990,6 +2134,8 @@ async function persistSessionUnlocked(session) {
1990
2134
  messageCount: messages.length,
1991
2135
  usage,
1992
2136
  thinkingLevel,
2137
+ harness,
2138
+ harnessSessionId: agent.harnessSessionId || harnessSessionId || undefined,
1993
2139
  accessMode,
1994
2140
  yoloMode,
1995
2141
  preview,
@@ -2128,6 +2274,9 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
2128
2274
  errorCode: 'GENERATION_STILL_RUNNING_BEFORE_ROLLBACK',
2129
2275
  })
2130
2276
  }
2277
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
2278
+ throw Object.assign(new Error('OpenCode does not support rollback from a message position. This action is unavailable for OpenCode conversations.'), { statusCode: 409 })
2279
+ }
2131
2280
 
2132
2281
  const messages = Array.isArray(session.agent.state.messages) ? session.agent.state.messages : []
2133
2282
  const rollbackIndex = rollbackStartIndexFromMessage(messages, rollbackMessageIndex)
@@ -2137,7 +2286,14 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
2137
2286
 
2138
2287
  const nextMessages = messages.slice(0, rollbackIndex)
2139
2288
  updateSessionMessages(session, nextMessages)
2140
- resetSessionCompaction(session)
2289
+ const compactedUpToIndex = Number(session.contextCompaction?.compactedUpToIndex) || 0
2290
+ if (rollbackIndex < compactedUpToIndex) {
2291
+ // 撤回越过压缩点,摘要覆盖的历史被截断,压缩失效
2292
+ resetSessionCompaction(session)
2293
+ } else {
2294
+ // 撤回发生在压缩点之后,摘要仍然有效,保留压缩上下文
2295
+ session.lastTransformedContextMessages = null
2296
+ }
2141
2297
  session.status = 'idle'
2142
2298
  session.finishedAt = new Date().toISOString()
2143
2299
  await persistSession(session)
@@ -2219,7 +2375,7 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
2219
2375
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
2220
2376
  }
2221
2377
  if (modelAccessContext) session.modelAccessContext = modelAccessContext
2222
- await refreshSessionModelBinding(session)
2378
+ if (session.harness === AGENT_HARNESS_QUICKFORGE) await refreshSessionModelBinding(session)
2223
2379
 
2224
2380
  if (session.agent.state.isStreaming || session.abortPending) {
2225
2381
  throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), {
@@ -2228,16 +2384,22 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
2228
2384
  })
2229
2385
  }
2230
2386
 
2231
- await refreshMemoryState(session)
2387
+ if (session.harness === AGENT_HARNESS_QUICKFORGE) await refreshMemoryState(session)
2232
2388
  resetIdleTimer(session)
2233
2389
 
2234
2390
  // Build user message
2235
2391
  const initialUserMessage = typeof message === 'string'
2236
2392
  ? { role: 'user', content: message, timestamp: new Date().toISOString() }
2237
2393
  : message
2238
- const commandState = await resolveCommandState(session, initialUserMessage, promptCommand)
2394
+ const commandState = session.harness === AGENT_HARNESS_QUICKFORGE
2395
+ ? await resolveCommandState(session, initialUserMessage, promptCommand)
2396
+ : { userMessage: initialUserMessage }
2239
2397
  const resolvedUserMessage = commandState.userMessage ?? initialUserMessage
2240
2398
 
2399
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
2400
+ session.agent.validatePrompt(resolvedUserMessage)
2401
+ }
2402
+
2241
2403
  if (commandState.textResponse) {
2242
2404
  session.agent.state.messages = [
2243
2405
  ...session.agent.state.messages,
@@ -2263,7 +2425,9 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
2263
2425
  return compactSession(session, initialUserMessage, commandState.compact)
2264
2426
  }
2265
2427
 
2266
- const userMessage = prepareCloudUserMessage(session, resolvedUserMessage)
2428
+ const userMessage = session.harness === AGENT_HARNESS_QUICKFORGE
2429
+ ? prepareCloudUserMessage(session, resolvedUserMessage)
2430
+ : resolvedUserMessage
2267
2431
 
2268
2432
  // Set a meaningful fallback immediately. The AI title request starts only
2269
2433
  // after the first user message has been persisted by the message_end handler.
@@ -2286,11 +2450,26 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
2286
2450
  // Fire and forget — events come through eventBus
2287
2451
  session.agent.prompt(userMessage).catch((err) => {
2288
2452
  logger.error(`Agent prompt error for session ${sessionId}:`, err, { sessionId })
2289
- const event = {
2290
- type: 'error',
2291
- error: err.message || 'Unknown error',
2292
- }
2293
- emitSessionEvent(session, event)
2453
+ if (session.harness === AGENT_HARNESS_OPENCODE) return
2454
+ const errorMessage = err.message || 'Unknown error'
2455
+ // Surface the failure at the end of the conversation itself so the user
2456
+ // sees the reason in the transcript, not only via toast/notification.
2457
+ session.agent.state.messages = appendAssistantErrorMessageOnce(
2458
+ session.agent.state.messages,
2459
+ errorMessage,
2460
+ session.model,
2461
+ )
2462
+ session.agent.state.errorMessage = errorMessage
2463
+ session.agent.state.isStreaming = false
2464
+ session.status = 'error'
2465
+ session.finishedAt = new Date().toISOString()
2466
+ const messages = session.agent.state.messages
2467
+ emitSessionEvent(session, { type: 'message_end', messages })
2468
+ emitSessionEvent(session, { type: 'error', error: errorMessage })
2469
+ emitSessionEvent(session, { type: 'agent_end', messages, errorMessage, status: 'error' })
2470
+ flushSessionPersist(session).catch((persistErr) =>
2471
+ logger.error(`Failed to persist session ${sessionId} after prompt error:`, persistErr, { sessionId }),
2472
+ )
2294
2473
  }).finally(() => {
2295
2474
  session.activeCommandName = null
2296
2475
  session.activeCommandPermissions = null
@@ -2321,6 +2500,9 @@ export async function continueSession(sessionId, modelAccessContext = null) {
2321
2500
  errorCode: 'GENERATION_ALREADY_RUNNING',
2322
2501
  })
2323
2502
  }
2503
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
2504
+ throw Object.assign(new Error('OpenCode does not support retry from a message position. This action is unavailable for OpenCode conversations.'), { statusCode: 409 })
2505
+ }
2324
2506
  if (modelAccessContext) session.modelAccessContext = modelAccessContext
2325
2507
  await refreshSessionModelBinding(session)
2326
2508
 
@@ -2343,7 +2525,14 @@ export async function continueSession(sessionId, modelAccessContext = null) {
2343
2525
  const continuedUserMessage = prepareCloudUserMessage(session, commandState.userMessage ?? lastUserMessage)
2344
2526
  const trimmedMessages = messages.slice(0, lastUserIndex).concat(continuedUserMessage)
2345
2527
  updateSessionMessages(session, trimmedMessages)
2346
- resetSessionCompaction(session)
2528
+ const compactedUpToIndex = Number(session.contextCompaction?.compactedUpToIndex) || 0
2529
+ if (lastUserIndex < compactedUpToIndex) {
2530
+ // 重试点越过压缩点,摘要覆盖的历史被截断,压缩失效
2531
+ resetSessionCompaction(session)
2532
+ } else {
2533
+ // 重试点位于压缩点之后,摘要仍然有效,保留压缩上下文
2534
+ session.lastTransformedContextMessages = null
2535
+ }
2347
2536
  if (isManagedCloudModel(session.model) && logicalMessageId(continuedUserMessage)) {
2348
2537
  await flushSessionPersist(session)
2349
2538
  }
@@ -2536,6 +2725,8 @@ export function getSessionState(sessionId) {
2536
2725
  source: session.source || undefined,
2537
2726
  channelId: session.channelId || undefined,
2538
2727
  channelName: session.channelName || undefined,
2728
+ harness: session.harness,
2729
+ harnessSessionId: session.agent.harnessSessionId || session.harnessSessionId || undefined,
2539
2730
  accessMode: session.accessMode,
2540
2731
  yoloMode: session.yoloMode,
2541
2732
  systemPrompt: session.agent.state.systemPrompt,
@@ -2557,6 +2748,7 @@ export function getSessionState(sessionId) {
2557
2748
  contextUsage: getSessionContextUsage(session),
2558
2749
  pendingToolApproval: getPendingApprovalForSession(session.sessionId),
2559
2750
  pendingAutoCompactApproval: getPendingAutoCompactApprovalForSession(session.sessionId),
2751
+ acpSession: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.state.acpSession : undefined,
2560
2752
  isStreaming: session.abortPending ? false : session.agent.state.isStreaming,
2561
2753
  errorMessage: session.agent.state.errorMessage,
2562
2754
  }
@@ -2575,6 +2767,8 @@ export function getSessionStatus(sessionId) {
2575
2767
  sessionId: session.sessionId,
2576
2768
  scope: session.scope,
2577
2769
  projectId: session.projectId,
2770
+ harness: session.harness,
2771
+ harnessSessionId: session.agent.harnessSessionId || session.harnessSessionId || undefined,
2578
2772
  source: session.source || undefined,
2579
2773
  channelId: session.channelId || undefined,
2580
2774
  channelName: session.channelName || undefined,
@@ -2646,6 +2840,7 @@ export async function destroyAgent(sessionId) {
2646
2840
 
2647
2841
  try {
2648
2842
  session.agent.abort()
2843
+ if (session.harness === AGENT_HARNESS_OPENCODE) await session.agent.dispose?.()
2649
2844
  } catch {
2650
2845
  // ignore
2651
2846
  }
@@ -2704,11 +2899,15 @@ export async function restoreAgent(sessionId) {
2704
2899
  createdAt: sessionData.createdAt,
2705
2900
  lastModified: sessionData.lastModified,
2706
2901
  contextCompaction: sessionData.contextCompaction || null,
2902
+ harness: normalizeAgentHarness(sessionData.harness),
2903
+ harnessSessionId: typeof sessionData.harnessSessionId === 'string' ? sessionData.harnessSessionId : null,
2904
+ openCodeUsage: sessionData.openCodeUsage || null,
2707
2905
  idleRetention: sessionData.idleRetention || null,
2708
2906
  stateVersion: sessionData.stateVersion,
2709
2907
  })
2710
2908
  } catch (err) {
2711
2909
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
2910
+ if (err?.statusCode === 503 || err?.errorCode === 'OPENCODE_UNAVAILABLE') throw err
2712
2911
  return null
2713
2912
  }
2714
2913
  }
@@ -2778,6 +2977,8 @@ export function listSessions() {
2778
2977
  scope: session.scope,
2779
2978
  status: session.status,
2780
2979
  title: session.title,
2980
+ harness: session.harness,
2981
+ harnessSessionId: session.agent.harnessSessionId || session.harnessSessionId || undefined,
2781
2982
  source: session.source || undefined,
2782
2983
  channelId: session.channelId || undefined,
2783
2984
  channelName: session.channelName || undefined,
@@ -2833,7 +3034,7 @@ export async function updateSessionAccessMode(sessionId, accessMode) {
2833
3034
 
2834
3035
  session.accessMode = normalizeAccessMode(accessMode, session.accessMode)
2835
3036
  session.yoloMode = yoloModeFromAccessMode(session.accessMode)
2836
- await rebuildSessionTools(session)
3037
+ if (session.harness === AGENT_HARNESS_QUICKFORGE) await rebuildSessionTools(session)
2837
3038
  await persistSession(session)
2838
3039
 
2839
3040
  const state = getSessionState(sessionId)
@@ -2846,6 +3047,100 @@ export async function updateSessionYoloMode(sessionId, yoloMode) {
2846
3047
  return updateSessionAccessMode(sessionId, yoloMode ? AGENT_ACCESS_MODE_FULL_ACCESS : AGENT_ACCESS_MODE_DEFAULT)
2847
3048
  }
2848
3049
 
3050
+ function requireOpenCodeHarnessSession(sessionId) {
3051
+ const session = agentSessions.get(sessionId)
3052
+ if (!session) throw Object.assign(new Error('Session not found'), { statusCode: 404 })
3053
+ if (session.harness !== AGENT_HARNESS_OPENCODE) {
3054
+ throw Object.assign(new Error('Harness configuration is only available for OpenCode sessions.'), { statusCode: 409 })
3055
+ }
3056
+ return session
3057
+ }
3058
+
3059
+ export async function updateSessionHarnessConfigOption(sessionId, configId, value) {
3060
+ const session = requireOpenCodeHarnessSession(sessionId)
3061
+ await session.agent.setConfigOption(configId, value)
3062
+ const state = getSessionState(sessionId)
3063
+ emitSessionEvent(session, { type: 'state', ...state })
3064
+ return { sessionId, acpSession: state.acpSession }
3065
+ }
3066
+
3067
+ export async function updateSessionHarnessMode(sessionId, modeId) {
3068
+ const session = requireOpenCodeHarnessSession(sessionId)
3069
+ await session.agent.setMode(modeId)
3070
+ const state = getSessionState(sessionId)
3071
+ emitSessionEvent(session, { type: 'state', ...state })
3072
+ return { sessionId, acpSession: state.acpSession }
3073
+ }
3074
+
3075
+ /**
3076
+ * Fork the entire current OpenCode session into a new QuickForge session.
3077
+ *
3078
+ * OpenCode ACP only supports whole-session `session/fork` (no message-position
3079
+ * fork), so the new agent is created with the full message history and
3080
+ * `sourceHarnessSessionId` pointing at the current ACP session. The new session
3081
+ * is persisted immediately and announced through the existing `session_forked`
3082
+ * event so clients switch to it without a message-level fork semantic.
3083
+ */
3084
+ export async function forkSession(sessionId) {
3085
+ const session = requireOpenCodeHarnessSession(sessionId)
3086
+ if (session.agent.state.isStreaming) {
3087
+ throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes before forking the conversation.'), {
3088
+ statusCode: 409,
3089
+ errorCode: 'GENERATION_STILL_RUNNING_BEFORE_FORK',
3090
+ })
3091
+ }
3092
+ const sourceHarnessSessionId = session.agent.harnessSessionId
3093
+ if (!sourceHarnessSessionId) {
3094
+ throw Object.assign(new Error('This OpenCode conversation has no ACP session to fork.'), { statusCode: 409 })
3095
+ }
3096
+ const messages = session.agent.state.messages
3097
+ if (messages.length === 0) {
3098
+ throw Object.assign(new Error('There is no conversation to fork yet.'), { statusCode: 400 })
3099
+ }
3100
+ const forkedSessionId = randomUUID()
3101
+ const forkedSession = await createAgent(forkedSessionId, {
3102
+ scope: session.scope,
3103
+ projectId: session.projectId,
3104
+ accessMode: session.accessMode,
3105
+ yoloMode: session.yoloMode,
3106
+ model: session.model,
3107
+ modelRef: session.modelRef,
3108
+ modelAccessContext: session.modelAccessContext,
3109
+ resolvePersistedModel: true,
3110
+ thinkingLevel: session.thinkingLevel,
3111
+ messages,
3112
+ title: session.title,
3113
+ titleSource: session.titleSource === 'default' ? 'manual' : session.titleSource,
3114
+ createdAt: new Date().toISOString(),
3115
+ harness: session.harness,
3116
+ sourceHarnessSessionId,
3117
+ idleRetention: session.idleRetention || null,
3118
+ })
3119
+ updateSessionMessages(forkedSession, messages)
3120
+ await persistSession(forkedSession)
3121
+
3122
+ emitSessionEvent(session, {
3123
+ type: 'session_forked',
3124
+ sourceSessionId: session.sessionId,
3125
+ targetSessionId: forkedSessionId,
3126
+ title: forkedSession.title,
3127
+ createdAt: forkedSession.createdAt,
3128
+ scope: forkedSession.scope,
3129
+ projectId: forkedSession.projectId,
3130
+ messages: forkedSession.agent.state.messages,
3131
+ })
3132
+ emitSessionEvent(forkedSession, { type: 'state', ...getSessionState(forkedSessionId) })
3133
+ emitSessionEvent(forkedSession, { type: 'message_end', messages: forkedSession.agent.state.messages })
3134
+ emitSessionEvent(forkedSession, { type: 'agent_end', messages: forkedSession.agent.state.messages })
3135
+ return {
3136
+ sessionId: forkedSessionId,
3137
+ title: forkedSession.title,
3138
+ createdAt: forkedSession.createdAt,
3139
+ scope: forkedSession.scope,
3140
+ projectId: forkedSession.projectId,
3141
+ }
3142
+ }
3143
+
2849
3144
  /**
2850
3145
  * Update the model for an existing session.
2851
3146
  * Syncs the model to both the session record (for persistence) and the agent state (for API calls).
@@ -2857,6 +3152,9 @@ export function updateSessionModel(sessionId, model, modelRef = null) {
2857
3152
  if (!session) {
2858
3153
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
2859
3154
  }
3155
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
3156
+ throw Object.assign(new Error('OpenCode manages its model natively.'), { statusCode: 409 })
3157
+ }
2860
3158
  if (!model) {
2861
3159
  throw Object.assign(new Error('Missing model'), { statusCode: 400 })
2862
3160
  }
@@ -2877,6 +3175,9 @@ export function updateSessionThinkingLevel(sessionId, thinkingLevel) {
2877
3175
  if (!session) {
2878
3176
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
2879
3177
  }
3178
+ if (session.harness === AGENT_HARNESS_OPENCODE) {
3179
+ throw Object.assign(new Error('OpenCode manages its thinking level natively.'), { statusCode: 409 })
3180
+ }
2880
3181
  if (!thinkingLevel) {
2881
3182
  throw Object.assign(new Error('Missing thinkingLevel'), { statusCode: 400 })
2882
3183
  }