@shawnstack/quickforge 1.4.1 → 1.5.0

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 (59) hide show
  1. package/README.md +12 -12
  2. package/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
  4. package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
  5. package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
  7. package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
  8. package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
  9. package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
  11. package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
  12. package/dist/assets/icons-47L5YLKz.js +1 -0
  13. package/dist/assets/index-CqfScETb.js +1200 -0
  14. package/dist/assets/index-DzkBgHZf.css +3 -0
  15. package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
  17. package/dist/favicon.svg +16 -1
  18. package/dist/index.html +5 -5
  19. package/dist/manifest.webmanifest +30 -30
  20. package/package.json +3 -2
  21. package/server/acp/server.mjs +921 -0
  22. package/server/agent-manager.mjs +198 -32
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/auto-compaction.mjs +82 -39
  26. package/server/channels/process-channel.mjs +278 -0
  27. package/server/channels/providers/wechat.mjs +271 -0
  28. package/server/channels/registry.mjs +58 -0
  29. package/server/custom-commands.mjs +13 -1
  30. package/server/frontmatter.mjs +167 -0
  31. package/server/index.mjs +52 -3
  32. package/server/project-config.mjs +43 -6
  33. package/server/routes/agent-profiles.mjs +6 -2
  34. package/server/routes/agent.mjs +12 -1
  35. package/server/routes/channels.mjs +145 -0
  36. package/server/routes/models.mjs +68 -0
  37. package/server/routes/project.mjs +2 -2
  38. package/server/routes/scheduled-tasks.mjs +6 -5
  39. package/server/routes/storage.mjs +4 -2
  40. package/server/routes/system.mjs +27 -0
  41. package/server/routes/tools.mjs +17 -6
  42. package/server/routes/workspace.mjs +138 -0
  43. package/server/session-utils.mjs +10 -2
  44. package/server/storage.mjs +29 -2
  45. package/server/system-prompt.mjs +1 -0
  46. package/server/tools/definitions.mjs +18 -0
  47. package/server/tools/index.mjs +83 -0
  48. package/server/utils/package-update.mjs +156 -0
  49. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
  50. package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
  51. package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
  52. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
  53. package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
  54. package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
  55. package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
  56. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
  57. package/dist/assets/icons-BWtivFsx.js +0 -1
  58. package/dist/assets/index-CxOHP41X.css +0 -3
  59. package/dist/assets/index-Dcf73EL8.js +0 -895
@@ -11,7 +11,7 @@ import {
11
11
  formatSubagentTask,
12
12
  } from './subagents.mjs'
13
13
  import { agentProfileSnapshot, getAgentProfile } from './agent-profiles.mjs'
14
- import { projectContextFromId, readProjectConfig } from './project-config.mjs'
14
+ import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
15
15
  import { readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue } from './storage.mjs'
16
16
  import { logger } from './utils/logger.mjs'
17
17
  import { buildSystemPrompt, generateAiTitle, generateTitle } from './session-utils.mjs'
@@ -23,8 +23,11 @@ import {
23
23
  } from './conversation-compaction.mjs'
24
24
  import {
25
25
  buildAutoCompactLoopMessages,
26
+ compactSessionInPlace,
27
+ DEFAULT_AUTO_COMPACT_SETTINGS,
26
28
  estimateSessionContextUsage,
27
29
  maybeAutoCompactSession,
30
+ readAutoCompactSettings,
28
31
  } from './auto-compaction.mjs'
29
32
  import {
30
33
  handleInternalCommand,
@@ -92,7 +95,7 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
92
95
  .filter(isAllowed)
93
96
  .map((definition) => wrapToolDefinition(definition, toolContext, toolPermissions))
94
97
 
95
- if (includeWorkspaceTools && projectId && projectContext) {
98
+ if (includeWorkspaceTools && projectContext) {
96
99
  const definitions = workspaceTools.filter((definition) => includeSubagentTool || definition.name !== 'run_subagent')
97
100
  tools.push(...definitions
98
101
  .filter(isAllowed)
@@ -117,7 +120,7 @@ async function rebuildSessionTools(session) {
117
120
  session.projectId,
118
121
  session.projectContext,
119
122
  sessionSkillsContext(session),
120
- !!(session.projectId && session.projectContext),
123
+ !!session.projectContext,
121
124
  createCommandToolPermissions(session),
122
125
  { parentSessionId: session.sessionId },
123
126
  )
@@ -129,7 +132,26 @@ async function rebuildSessionTools(session) {
129
132
 
130
133
  const agentSessions = new Map()
131
134
 
132
- /** @typedef {{ agent: Agent, projectContext: object|null, projectId: string|null, yoloMode: boolean, model: object, thinkingLevel: string, scope: string, title: string, createdAt: string, status: string, startedAt: string|null, finishedAt: string|null, listeners: Set<function>, idleTimer: NodeJS.Timeout|null, eventBus: EventEmitter }} AgentSession */
135
+ const AGENT_ACCESS_MODE_DEFAULT = 'default'
136
+ const AGENT_ACCESS_MODE_FULL_ACCESS = 'full-access'
137
+
138
+ function normalizeAccessMode(value, fallback = AGENT_ACCESS_MODE_DEFAULT) {
139
+ if (value === AGENT_ACCESS_MODE_DEFAULT || value === AGENT_ACCESS_MODE_FULL_ACCESS) return value
140
+ if (value === true || value === 'true') return AGENT_ACCESS_MODE_FULL_ACCESS
141
+ if (value === false || value === 'false') return AGENT_ACCESS_MODE_DEFAULT
142
+ if (fallback !== value) return normalizeAccessMode(fallback, AGENT_ACCESS_MODE_DEFAULT)
143
+ return AGENT_ACCESS_MODE_DEFAULT
144
+ }
145
+
146
+ function yoloModeFromAccessMode(accessMode) {
147
+ return normalizeAccessMode(accessMode) === AGENT_ACCESS_MODE_FULL_ACCESS
148
+ }
149
+
150
+ function hasFullAccess(session) {
151
+ return normalizeAccessMode(session?.accessMode, session?.yoloMode) === AGENT_ACCESS_MODE_FULL_ACCESS
152
+ }
153
+
154
+ /** @typedef {{ agent: Agent, projectContext: object|null, projectId: string|null, accessMode: string, yoloMode: boolean, model: object, thinkingLevel: string, scope: string, title: string, createdAt: string, status: string, startedAt: string|null, finishedAt: string|null, listeners: Set<function>, idleTimer: NodeJS.Timeout|null, eventBus: EventEmitter }} AgentSession */
133
155
 
134
156
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
135
157
  const SUBAGENT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
@@ -370,12 +392,12 @@ function finishManualSessionRun(session, status, errorMessage) {
370
392
  session.agent.state.errorMessage = errorMessage
371
393
  }
372
394
 
373
- async function compactSession(session, initialUserMessage, compactOptions) {
395
+ async function summarySession(session, initialUserMessage, summaryOptions) {
374
396
  if (session.agent.state.isStreaming) {
375
397
  session.agent.state.messages = [
376
398
  ...session.agent.state.messages,
377
399
  initialUserMessage,
378
- assistantTextMessage('Cannot compact while a generation is still running. Stop it or wait until it finishes, then run /compact again.', session.model),
400
+ assistantTextMessage('Cannot summarize while a generation is still running. Stop it or wait until it finishes, then run /summary again.', session.model),
379
401
  ]
380
402
  await persistSession(session)
381
403
  const messages = session.agent.state.messages
@@ -399,13 +421,13 @@ async function compactSession(session, initialUserMessage, compactOptions) {
399
421
 
400
422
  try {
401
423
  const originalMessages = session.agent.state.messages.slice()
402
- const options = parseCompactArgs(compactOptions?.args || '')
424
+ const options = parseCompactArgs(summaryOptions?.args || '')
403
425
 
404
426
  if (options.unsupported?.length) {
405
427
  session.agent.state.messages = [
406
428
  ...originalMessages,
407
429
  initialUserMessage,
408
- assistantTextMessage(`Unsupported /compact option(s): ${options.unsupported.join(', ')}\n\nSupported usage: /compact or /compact keep=0`, session.model),
430
+ assistantTextMessage(`Unsupported /summary option(s): ${options.unsupported.join(', ')}\n\nSupported usage: /summary or /summary keep=0`, session.model),
409
431
  ]
410
432
  finishManualSessionRun(session, 'idle')
411
433
  await persistSession(session)
@@ -427,7 +449,7 @@ async function compactSession(session, initialUserMessage, compactOptions) {
427
449
  session.agent.state.messages = [
428
450
  ...originalMessages,
429
451
  initialUserMessage,
430
- assistantTextMessage('Not enough earlier history to compact. Continue chatting and run /compact again later.', session.model),
452
+ assistantTextMessage('Not enough earlier history to summarize. Continue chatting and run /summary again later.', session.model),
431
453
  ]
432
454
  finishManualSessionRun(session, 'idle')
433
455
  await persistSession(session)
@@ -463,6 +485,7 @@ async function compactSession(session, initialUserMessage, compactOptions) {
463
485
  const compactedSession = await createAgent(compactedSessionId, {
464
486
  scope: session.scope,
465
487
  projectId: session.projectId,
488
+ accessMode: session.accessMode,
466
489
  yoloMode: session.yoloMode,
467
490
  model: session.model,
468
491
  thinkingLevel: session.thinkingLevel,
@@ -512,6 +535,97 @@ async function compactSession(session, initialUserMessage, compactOptions) {
512
535
  }
513
536
  }
514
537
 
538
+ async function compactSession(session, initialUserMessage, compactOptions) {
539
+ if (session.agent.state.isStreaming) {
540
+ session.agent.state.messages = [
541
+ ...session.agent.state.messages,
542
+ initialUserMessage,
543
+ assistantTextMessage('Cannot compact while a generation is still running. Stop it or wait until it finishes, then run /compact again.', session.model),
544
+ ]
545
+ await persistSession(session)
546
+ const messages = session.agent.state.messages
547
+ emitSessionEvent(session, { type: 'message_end', messages })
548
+ emitSessionEvent(session, { type: 'agent_end', messages })
549
+ return { sessionId: session.sessionId, status: session.status }
550
+ }
551
+
552
+ const args = String(compactOptions?.args || '').trim()
553
+ if (args) {
554
+ session.agent.state.messages = [
555
+ ...session.agent.state.messages,
556
+ initialUserMessage,
557
+ assistantTextMessage('Unsupported /compact option(s). Supported usage: /compact', session.model),
558
+ ]
559
+ session.status = 'idle'
560
+ session.finishedAt = new Date().toISOString()
561
+ await persistSession(session)
562
+ const messages = session.agent.state.messages
563
+ emitSessionEvent(session, { type: 'message_end', messages })
564
+ emitSessionEvent(session, { type: 'agent_end', messages })
565
+ return { sessionId: session.sessionId, status: session.status }
566
+ }
567
+
568
+ resetIdleTimer(session)
569
+ session.status = 'running'
570
+ session.startedAt = session.startedAt ?? new Date().toISOString()
571
+ session.finishedAt = null
572
+ session.agent.state.isStreaming = true
573
+ session.agent.state.errorMessage = undefined
574
+ emitSessionEvent(session, { type: 'agent_start' })
575
+
576
+ try {
577
+ const messages = session.agent.state.messages.slice()
578
+ const settings = await readAutoCompactSettings().catch(() => DEFAULT_AUTO_COMPACT_SETTINGS)
579
+ const usage = getSessionContextUsage(session)
580
+ const result = await compactSessionInPlace({
581
+ session,
582
+ messages,
583
+ keepRecentTurns: settings.keepRecentTurns,
584
+ minSourceChars: settings.minSourceChars,
585
+ usage,
586
+ thresholdPercent: settings.thresholdPercent,
587
+ emitSessionEvent,
588
+ persistSession,
589
+ reason: 'manual_compact',
590
+ onBeforePersist: () => {
591
+ finishManualSessionRun(session, 'idle')
592
+ },
593
+ })
594
+
595
+ if (!result.compacted) {
596
+ session.agent.state.messages = [
597
+ ...messages,
598
+ initialUserMessage,
599
+ assistantTextMessage('Not enough earlier history to compact. Continue chatting and run /compact again later.', session.model),
600
+ ]
601
+ finishManualSessionRun(session, 'idle')
602
+ await persistSession(session)
603
+ const nextMessages = session.agent.state.messages
604
+ emitSessionEvent(session, { type: 'message_end', messages: nextMessages })
605
+ emitSessionEvent(session, { type: 'agent_end', messages: nextMessages })
606
+ return { sessionId: session.sessionId, status: session.status }
607
+ }
608
+
609
+ const nextMessages = session.agent.state.messages
610
+ emitSessionEvent(session, { type: 'message_end', messages: nextMessages })
611
+ emitSessionEvent(session, { type: 'agent_end', messages: nextMessages })
612
+ return { sessionId: session.sessionId, status: session.status }
613
+ } catch (err) {
614
+ const errorMessage = err?.message || 'Conversation compaction failed'
615
+ session.agent.state.messages = [
616
+ ...session.agent.state.messages,
617
+ initialUserMessage,
618
+ assistantTextMessage(`Conversation compaction failed: ${errorMessage}`, session.model),
619
+ ]
620
+ finishManualSessionRun(session, 'error', errorMessage)
621
+ await persistSession(session)
622
+ const messages = session.agent.state.messages
623
+ emitSessionEvent(session, { type: 'error', error: errorMessage })
624
+ emitSessionEvent(session, { type: 'agent_end', messages, errorMessage })
625
+ return { sessionId: session.sessionId, status: session.status }
626
+ }
627
+ }
628
+
515
629
  async function clearSession(session) {
516
630
  if (session.agent.state.isStreaming) {
517
631
  session.agent.state.messages = [
@@ -602,6 +716,7 @@ async function resolveCommandState(session, userMessage, promptCommand = null) {
602
716
  )
603
717
  if (typeof internalResponse === 'string') return { textResponse: internalResponse }
604
718
  if (internalResponse?.clear) return { clear: internalResponse }
719
+ if (internalResponse?.summary) return { summary: internalResponse }
605
720
  if (internalResponse?.compact) return { compact: internalResponse }
606
721
  if (internalResponse?.plan) {
607
722
  return planCommandState(userMessage, internalResponse.args)
@@ -704,7 +819,7 @@ ${scopeText}
704
819
  }
705
820
 
706
821
  async function runSubagent(parentSession, params, parentSignal, onUpdate) {
707
- const profile = await getAgentProfile(params?.subagent)
822
+ const profile = await getAgentProfile(params?.subagent, { workspaceRoot: parentSession.projectContext?.workspaceRoot })
708
823
  if (!profile || !profile.enabledAsSubagent) {
709
824
  const error = new Error(`Unknown or disabled subagent: ${params?.subagent || ''}`)
710
825
  error.statusCode = 400
@@ -828,7 +943,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
828
943
  }
829
944
  const commandPermissionError = commandToolPermissionError(parentSession, toolName)
830
945
  if (commandPermissionError) return { block: true, reason: commandPermissionError }
831
- if (!parentSession.yoloMode) {
946
+ if (!hasFullAccess(parentSession)) {
832
947
  if (safeReadTools.has(toolName)) return undefined
833
948
  return createApprovalPromise(parentSession, context.toolCall?.id, toolName, context.args, {
834
949
  type: 'subagent',
@@ -1009,8 +1124,15 @@ async function transformSessionContext(session, messages, signal) {
1009
1124
  export const agentEvents = new EventEmitter()
1010
1125
  agentEvents.setMaxListeners(100)
1011
1126
 
1127
+ function isIdleRetainedSession(session) {
1128
+ return session?.idleRetention === 'always'
1129
+ }
1130
+
1012
1131
  function resetIdleTimer(session) {
1013
1132
  if (session.idleTimer) clearTimeout(session.idleTimer)
1133
+ session.idleTimer = null
1134
+ if (isIdleRetainedSession(session)) return
1135
+
1014
1136
  session.idleTimer = setTimeout(() => {
1015
1137
  if (session.status === 'running') {
1016
1138
  logger.info(`Session ${session.sessionId} idle timer fired but still running, resetting...`, { sessionId: session.sessionId, status: session.status })
@@ -1045,6 +1167,7 @@ export function touchSession(sessionId) {
1045
1167
  export async function createAgent(sessionId, config = {}) {
1046
1168
  const existing = agentSessions.get(sessionId)
1047
1169
  if (existing) {
1170
+ if (config.idleRetention !== undefined) existing.idleRetention = config.idleRetention
1048
1171
  resetIdleTimer(existing)
1049
1172
  return existing
1050
1173
  }
@@ -1052,6 +1175,7 @@ export async function createAgent(sessionId, config = {}) {
1052
1175
  const {
1053
1176
  scope = 'global',
1054
1177
  projectId = null,
1178
+ accessMode: rawAccessMode,
1055
1179
  yoloMode = false,
1056
1180
  model = null,
1057
1181
  thinkingLevel = 'off',
@@ -1062,17 +1186,23 @@ export async function createAgent(sessionId, config = {}) {
1062
1186
  lastModified = null,
1063
1187
  contextCompaction = null,
1064
1188
  agentProfile = null,
1189
+ idleRetention = null,
1065
1190
  } = config
1191
+ const accessMode = normalizeAccessMode(rawAccessMode, yoloMode)
1192
+ const resolvedYoloMode = yoloModeFromAccessMode(accessMode)
1066
1193
 
1067
- // Resolve project context for tool calls
1194
+ // Resolve project context for tool calls. Project conversations resolve to
1195
+ // their directory; global conversations (no projectId) and any fallback fall
1196
+ // back to a synthetic default workspace context so file tools stay available.
1068
1197
  let projectContext = null
1069
1198
  if (projectId) {
1070
1199
  try {
1071
1200
  projectContext = await projectContextFromId(projectId)
1072
1201
  } catch {
1073
- // project not found — run without tools
1202
+ // project not found — fall back to the default workspace below
1074
1203
  }
1075
1204
  }
1205
+ projectContext ??= defaultGlobalWorkspaceContext()
1076
1206
 
1077
1207
  // Build system prompt
1078
1208
  const projectConfig = await readProjectConfig()
@@ -1105,7 +1235,7 @@ export async function createAgent(sessionId, config = {}) {
1105
1235
  projectId,
1106
1236
  projectContext,
1107
1237
  skillsContext,
1108
- !!(projectId && projectContext),
1238
+ !!projectContext,
1109
1239
  (toolName) => {
1110
1240
  if (profileToolNames && !profileToolNames.includes(toolName)) return `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.`
1111
1241
  const session = agentSessions.get(sessionId)
@@ -1159,14 +1289,14 @@ export async function createAgent(sessionId, config = {}) {
1159
1289
  if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
1160
1290
  if (toolName === 'run_subagent') return undefined
1161
1291
  if (isMcpToolName(toolName) || isPluginToolName(toolName)) {
1162
- if (!currentSession?.yoloMode) return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1292
+ if (!hasFullAccess(currentSession)) return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1163
1293
  return undefined
1164
1294
  }
1165
- if (!projectContext) {
1295
+ if (!projectContext?.workspaceRoot) {
1166
1296
  return { block: true, reason: 'No active project. Select a project to use tools.' }
1167
1297
  }
1168
- if (!currentSession?.yoloMode) {
1169
- // YOLO OFF: safe reads auto-pass, dangerous writes require approval
1298
+ if (!hasFullAccess(currentSession)) {
1299
+ // Default access: safe reads auto-pass, state-changing or external tools require approval
1170
1300
  if (safeReadTools.has(toolName)) return undefined
1171
1301
  return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1172
1302
  }
@@ -1182,7 +1312,8 @@ export async function createAgent(sessionId, config = {}) {
1182
1312
  agent,
1183
1313
  projectContext,
1184
1314
  projectId,
1185
- yoloMode,
1315
+ accessMode,
1316
+ yoloMode: resolvedYoloMode,
1186
1317
  model: resolvedModel,
1187
1318
  thinkingLevel,
1188
1319
  scope,
@@ -1205,6 +1336,7 @@ export async function createAgent(sessionId, config = {}) {
1205
1336
  getApiKey,
1206
1337
  contextCompaction,
1207
1338
  agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
1339
+ idleRetention,
1208
1340
  lastTransformedContextMessages: null,
1209
1341
  autoCompacting: false,
1210
1342
  stateVersion: 0,
@@ -1216,7 +1348,7 @@ export async function createAgent(sessionId, config = {}) {
1216
1348
  }
1217
1349
 
1218
1350
  // Subscribe to agent lifecycle events and forward to eventBus
1219
- agent.subscribe((event) => {
1351
+ agent.subscribe(async (event) => {
1220
1352
  // The pi-agent-core agent loop emits agent_end with `messages` that only
1221
1353
  // contains messages generated during THIS run (newMessages), not the
1222
1354
  // complete session history. Replace with the authoritative full state
@@ -1246,10 +1378,14 @@ export async function createAgent(sessionId, config = {}) {
1246
1378
  session.status = 'running'
1247
1379
  session.startedAt = session.startedAt ?? new Date().toISOString()
1248
1380
  session.finishedAt = null
1249
- // Persist running state immediately so a browser refresh still shows the green dot
1250
- persistSession(session).catch((err) =>
1251
- logger.error(`Failed to persist session on start ${sessionId}:`, err, { sessionId }),
1252
- )
1381
+ // Persist running state immediately so a browser refresh still shows the green dot.
1382
+ // Brand-new runs have no messages until the first user message_end; persisting
1383
+ // here would only trigger the empty-session cleanup path.
1384
+ if (session.agent.state.messages.length > 0) {
1385
+ persistSession(session).catch((err) =>
1386
+ logger.error(`Failed to persist session on start ${sessionId}:`, err, { sessionId }),
1387
+ )
1388
+ }
1253
1389
  }
1254
1390
 
1255
1391
  if (event.type === 'agent_end') {
@@ -1265,15 +1401,28 @@ export async function createAgent(sessionId, config = {}) {
1265
1401
  }
1266
1402
 
1267
1403
  if (event.type === 'message_end') {
1268
- // Debounced persist for crash recovery; coalesces the many message_end
1269
- // events within a single run into infrequent full-session writes.
1270
- scheduleSessionPersist(session)
1404
+ const isInitialUserMessage = (event.message?.role === 'user' || event.message?.role === 'user-with-attachments')
1405
+ && session.agent.state.messages.length === 1
1406
+ if (isInitialUserMessage) {
1407
+ // External ACP channels run in a separate process from the web UI. Persist
1408
+ // the first user message immediately so the session becomes visible on
1409
+ // disk before a long/failed agent run can exit or be restarted.
1410
+ try {
1411
+ await flushSessionPersist(session)
1412
+ } catch (err) {
1413
+ logger.error(`Failed to persist initial user message for session ${sessionId}:`, err, { sessionId })
1414
+ }
1415
+ } else {
1416
+ // Debounced persist for crash recovery; coalesces the many message_end
1417
+ // events within a single run into infrequent full-session writes.
1418
+ scheduleSessionPersist(session)
1419
+ }
1271
1420
  }
1272
1421
  })
1273
1422
 
1274
1423
  agentSessions.set(sessionId, session)
1275
1424
  resetIdleTimer(session)
1276
- logger.info(`Created session ${sessionId} (scope: ${scope}, project: ${projectId || 'none'}, yolo: ${yoloMode})`, { sessionId, scope, projectId: projectId || undefined, yoloMode })
1425
+ logger.info(`Created session ${sessionId} (scope: ${scope}, project: ${projectId || 'none'}, access: ${accessMode})`, { sessionId, scope, projectId: projectId || undefined, accessMode, yoloMode: resolvedYoloMode, idleRetention: idleRetention || undefined })
1277
1426
  return session
1278
1427
  }
1279
1428
 
@@ -1305,7 +1454,7 @@ function sessionLastModifiedFromMessages(messages, fallback) {
1305
1454
  * Persist session data to storage.
1306
1455
  */
1307
1456
  async function persistSession(session) {
1308
- const { sessionId, agent, scope, projectId, title, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, thinkingLevel, yoloMode, contextCompaction } = session
1457
+ const { sessionId, agent, scope, projectId, title, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, thinkingLevel, accessMode, yoloMode, contextCompaction } = session
1309
1458
  const messages = agent.state.messages
1310
1459
 
1311
1460
  if (messages.length === 0) {
@@ -1328,6 +1477,7 @@ async function persistSession(session) {
1328
1477
  title,
1329
1478
  model,
1330
1479
  thinkingLevel,
1480
+ accessMode,
1331
1481
  yoloMode,
1332
1482
  messages,
1333
1483
  createdAt: createdAt || now,
@@ -1338,6 +1488,7 @@ async function persistSession(session) {
1338
1488
  taskStartedAt: startedAt,
1339
1489
  taskFinishedAt: finishedAt,
1340
1490
  contextCompaction: contextCompaction || undefined,
1491
+ idleRetention: session.idleRetention || undefined,
1341
1492
  }
1342
1493
  session.lastModified = lastModified
1343
1494
 
@@ -1379,6 +1530,7 @@ async function persistSession(session) {
1379
1530
  messageCount: messages.length,
1380
1531
  usage,
1381
1532
  thinkingLevel,
1533
+ accessMode,
1382
1534
  yoloMode,
1383
1535
  preview,
1384
1536
  scope,
@@ -1393,6 +1545,7 @@ async function persistSession(session) {
1393
1545
  thresholdPercent: contextCompaction.thresholdPercent,
1394
1546
  usageBefore: contextCompaction.usageBefore,
1395
1547
  } : undefined,
1548
+ idleRetention: session.idleRetention || undefined,
1396
1549
  }
1397
1550
 
1398
1551
  // Write to storage atomically (read-modify-write within queue)
@@ -1550,6 +1703,10 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
1550
1703
  return clearSession(session)
1551
1704
  }
1552
1705
 
1706
+ if (commandState.summary) {
1707
+ return summarySession(session, initialUserMessage, commandState.summary)
1708
+ }
1709
+
1553
1710
  if (commandState.compact) {
1554
1711
  return compactSession(session, initialUserMessage, commandState.compact)
1555
1712
  }
@@ -1750,6 +1907,7 @@ export function getSessionState(sessionId) {
1750
1907
  sessionId: session.sessionId,
1751
1908
  scope: session.scope,
1752
1909
  projectId: session.projectId,
1910
+ accessMode: session.accessMode,
1753
1911
  yoloMode: session.yoloMode,
1754
1912
  systemPrompt: session.agent.state.systemPrompt,
1755
1913
  model: session.model,
@@ -1894,6 +2052,7 @@ export async function restoreAgent(sessionId) {
1894
2052
  return await createAgent(sessionId, {
1895
2053
  scope: sessionData.scope || 'global',
1896
2054
  projectId: sessionData.projectId || null,
2055
+ accessMode: normalizeAccessMode(sessionData.accessMode, sessionData.yoloMode),
1897
2056
  yoloMode: sessionData.yoloMode || false,
1898
2057
  model: sessionData.model,
1899
2058
  thinkingLevel: sessionData.thinkingLevel || 'off',
@@ -1902,6 +2061,7 @@ export async function restoreAgent(sessionId) {
1902
2061
  createdAt: sessionData.createdAt,
1903
2062
  lastModified: sessionData.lastModified,
1904
2063
  contextCompaction: sessionData.contextCompaction || null,
2064
+ idleRetention: sessionData.idleRetention || null,
1905
2065
  })
1906
2066
  } catch (err) {
1907
2067
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
@@ -1974,6 +2134,7 @@ export function listSessions() {
1974
2134
  scope: session.scope,
1975
2135
  status: session.status,
1976
2136
  title: session.title,
2137
+ idleRetention: session.idleRetention || undefined,
1977
2138
  })
1978
2139
  }
1979
2140
  return result
@@ -1995,20 +2156,25 @@ export async function refreshAllSessionTools() {
1995
2156
  return result
1996
2157
  }
1997
2158
 
1998
- export async function updateSessionYoloMode(sessionId, yoloMode) {
2159
+ export async function updateSessionAccessMode(sessionId, accessMode) {
1999
2160
  const session = agentSessions.get(sessionId)
2000
2161
  if (!session) {
2001
2162
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
2002
2163
  }
2003
2164
 
2004
- session.yoloMode = Boolean(yoloMode)
2165
+ session.accessMode = normalizeAccessMode(accessMode, session.accessMode)
2166
+ session.yoloMode = yoloModeFromAccessMode(session.accessMode)
2005
2167
  await rebuildSessionTools(session)
2006
2168
  await persistSession(session)
2007
2169
 
2008
2170
  const state = getSessionState(sessionId)
2009
2171
  emitSessionEvent(session, { type: 'state', ...state })
2010
2172
 
2011
- return { sessionId, yoloMode: session.yoloMode }
2173
+ return { sessionId, accessMode: session.accessMode, yoloMode: session.yoloMode }
2174
+ }
2175
+
2176
+ export async function updateSessionYoloMode(sessionId, yoloMode) {
2177
+ return updateSessionAccessMode(sessionId, yoloMode ? AGENT_ACCESS_MODE_FULL_ACCESS : AGENT_ACCESS_MODE_DEFAULT)
2012
2178
  }
2013
2179
 
2014
2180
  /**
@@ -0,0 +1,179 @@
1
+ import { existsSync, promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { dataDir } from './storage.mjs'
5
+ import { firstOptionalBoolean, firstString, parseFrontmatter, splitDelimitedList } from './frontmatter.mjs'
6
+
7
+ const DEFAULT_MAX_RUNTIME_MS = 30 * 60 * 1000
8
+ const DEFAULT_MAX_TOOL_CALLS = 300
9
+ const nameRegex = /^[a-z][a-z0-9_-]{1,39}$/
10
+ const allowedToolNames = new Set(['read_file', 'grep_files', 'write_file', 'edit_file', 'run_command'])
11
+
12
+ const toolAliases = new Map([
13
+ ['Read', 'read_file'],
14
+ ['Grep', 'grep_files'],
15
+ ['Bash', 'run_command'],
16
+ ['Write', 'write_file'],
17
+ ['Edit', 'edit_file'],
18
+ ])
19
+
20
+ const claudeUserAgentsDir = path.join(os.homedir(), '.claude', 'agents')
21
+ const userAgentsDir = path.join(dataDir, 'agents')
22
+
23
+ function normalizeString(value) {
24
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
25
+ }
26
+
27
+ function normalizeName(value) {
28
+ const name = normalizeString(value)?.toLowerCase()
29
+ return name && nameRegex.test(name) ? name : null
30
+ }
31
+
32
+ function normalizeRuntime(value) {
33
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_RUNTIME_MS
34
+ const parsed = Number(value)
35
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_RUNTIME_MS
36
+ return Math.min(Math.max(Math.round(parsed), 1000), DEFAULT_MAX_RUNTIME_MS)
37
+ }
38
+
39
+ function normalizeToolCalls(value) {
40
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_TOOL_CALLS
41
+ const parsed = Number(value)
42
+ if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_MAX_TOOL_CALLS
43
+ return Math.min(parsed, DEFAULT_MAX_TOOL_CALLS)
44
+ }
45
+
46
+ function normalizeTools(value) {
47
+ const tools = []
48
+ const seen = new Set()
49
+ for (const item of splitDelimitedList(value)) {
50
+ const mapped = toolAliases.get(item) || item
51
+ if (!allowedToolNames.has(mapped) || seen.has(mapped)) continue
52
+ seen.add(mapped)
53
+ tools.push(mapped)
54
+ }
55
+ return tools.length ? tools : ['read_file', 'grep_files']
56
+ }
57
+
58
+ function hasMutationTool(allowedTools) {
59
+ return allowedTools.some((toolName) => toolName === 'write_file' || toolName === 'edit_file')
60
+ }
61
+
62
+ export function agentProfileFromMarkdown(file, text, options = {}) {
63
+ const parsed = parseFrontmatter(text)
64
+ if (!parsed.body) return null
65
+
66
+ const metadata = parsed.metadata || {}
67
+ const name = normalizeName(metadata.name) || normalizeName(path.basename(file, '.md'))
68
+ if (!name) return null
69
+ if (options.reservedNames?.has(name)) return null
70
+
71
+ const allowedTools = normalizeTools(
72
+ metadata.tools ?? metadata['allowed-tools'] ?? metadata.allowedTools,
73
+ )
74
+ const label = firstString(metadata.label, metadata.displayName, metadata.title) || name
75
+ const enabledAsSubagent = firstOptionalBoolean(
76
+ metadata['enabled-as-subagent'],
77
+ metadata.enabled_as_subagent,
78
+ metadata.enabledAsSubagent,
79
+ )
80
+
81
+ return {
82
+ id: `${options.idPrefix || 'file'}:${name}`,
83
+ name,
84
+ label: label.slice(0, 80),
85
+ description: String(firstString(metadata.description) || '').slice(0, 500),
86
+ systemPrompt: parsed.body,
87
+ allowedTools,
88
+ maxRuntimeMs: normalizeRuntime(metadata['max-runtime-ms'] ?? metadata.max_runtime_ms ?? metadata.maxRuntimeMs),
89
+ maxToolCalls: normalizeToolCalls(metadata['max-tool-calls'] ?? metadata.max_tool_calls ?? metadata.maxToolCalls),
90
+ enabledAsSubagent: enabledAsSubagent === undefined ? true : enabledAsSubagent,
91
+ builtin: false,
92
+ source: options.source || 'file',
93
+ readonly: true,
94
+ filePath: file,
95
+ relativePath: options.relativePath || path.basename(file),
96
+ allowFileMutations: hasMutationTool(allowedTools),
97
+ createdAt: 'file',
98
+ updatedAt: 'file',
99
+ }
100
+ }
101
+
102
+ async function listAgentFilesFromDirectory(dir, options = {}) {
103
+ if (!dir || !existsSync(dir)) return []
104
+ let entries
105
+ try {
106
+ entries = await fs.readdir(dir, { withFileTypes: true })
107
+ } catch (error) {
108
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EACCES' || error?.code === 'EPERM') return []
109
+ throw error
110
+ }
111
+
112
+ const profiles = []
113
+ for (const entry of entries) {
114
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue
115
+ const file = path.join(dir, entry.name)
116
+ try {
117
+ const relativePath = options.relativeRoot
118
+ ? `${options.relativeRoot}/${entry.name}`.replace(/\\/g, '/')
119
+ : entry.name
120
+ const profile = agentProfileFromMarkdown(file, await fs.readFile(file, 'utf8'), {
121
+ ...options,
122
+ relativePath,
123
+ })
124
+ if (profile) profiles.push(profile)
125
+ } catch (error) {
126
+ console.warn(`Failed to load agent profile ${file}:`, error.message || error)
127
+ }
128
+ }
129
+ return profiles
130
+ }
131
+
132
+ function projectClaudeAgentsDir(workspaceRoot) {
133
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.claude', 'agents') : ''
134
+ }
135
+
136
+ function projectQuickForgeAgentsDir(workspaceRoot) {
137
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.quickforge', 'agents') : ''
138
+ }
139
+
140
+ export async function loadUserAgentProfiles(options = {}) {
141
+ const byName = new Map()
142
+ const sources = [
143
+ { dir: claudeUserAgentsDir, source: 'user-claude', relativeRoot: '~/.claude/agents', idPrefix: 'user-claude' },
144
+ { dir: userAgentsDir, source: 'user', relativeRoot: '~/.quickforge/agents', idPrefix: 'user' },
145
+ ]
146
+ for (const source of sources) {
147
+ for (const profile of await listAgentFilesFromDirectory(source.dir, { ...options, ...source })) {
148
+ byName.set(profile.name, profile)
149
+ }
150
+ }
151
+ return [...byName.values()]
152
+ }
153
+
154
+ export async function loadProjectAgentProfiles(workspaceRoot, options = {}) {
155
+ if (!workspaceRoot) return []
156
+ const byName = new Map()
157
+ const sources = [
158
+ { dir: projectClaudeAgentsDir(workspaceRoot), source: 'project-claude', relativeRoot: '.claude/agents', idPrefix: 'project-claude' },
159
+ { dir: projectQuickForgeAgentsDir(workspaceRoot), source: 'project', relativeRoot: '.quickforge/agents', idPrefix: 'project' },
160
+ ]
161
+ for (const source of sources) {
162
+ for (const profile of await listAgentFilesFromDirectory(source.dir, { ...options, ...source })) {
163
+ byName.set(profile.name, profile)
164
+ }
165
+ }
166
+ return [...byName.values()]
167
+ }
168
+
169
+ export async function loadFileAgentProfiles(workspaceRoot, options = {}) {
170
+ const byName = new Map()
171
+ for (const profile of await loadUserAgentProfiles(options)) byName.set(profile.name, profile)
172
+ for (const profile of await loadProjectAgentProfiles(workspaceRoot, options)) byName.set(profile.name, profile)
173
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
174
+ }
175
+
176
+ export const agentProfileSearchPaths = {
177
+ global: ['~/.claude/agents', '~/.quickforge/agents'],
178
+ project: ['<project>/.claude/agents', '<project>/.quickforge/agents'],
179
+ }