@shawnstack/quickforge 1.4.0 → 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 (65) 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-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-CiCXOLb5.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 +283 -45
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +111 -112
  27. package/server/channels/process-channel.mjs +278 -0
  28. package/server/channels/providers/wechat.mjs +271 -0
  29. package/server/channels/registry.mjs +58 -0
  30. package/server/context-usage.mjs +108 -0
  31. package/server/custom-commands.mjs +157 -28
  32. package/server/frontmatter.mjs +167 -0
  33. package/server/index.mjs +52 -3
  34. package/server/mcp/registry.mjs +40 -0
  35. package/server/project-config.mjs +43 -6
  36. package/server/routes/agent-profiles.mjs +6 -2
  37. package/server/routes/agent.mjs +13 -2
  38. package/server/routes/channels.mjs +145 -0
  39. package/server/routes/mcp.mjs +7 -1
  40. package/server/routes/models.mjs +68 -0
  41. package/server/routes/project.mjs +34 -4
  42. package/server/routes/scheduled-tasks.mjs +6 -5
  43. package/server/routes/shared-conversation.mjs +1 -1
  44. package/server/routes/storage.mjs +4 -2
  45. package/server/routes/system.mjs +27 -0
  46. package/server/routes/tools.mjs +17 -6
  47. package/server/routes/workspace.mjs +138 -0
  48. package/server/session-utils.mjs +10 -2
  49. package/server/storage.mjs +30 -2
  50. package/server/subagents.mjs +8 -6
  51. package/server/system-prompt.mjs +3 -2
  52. package/server/tools/definitions.mjs +19 -1
  53. package/server/tools/index.mjs +83 -0
  54. package/server/utils/package-update.mjs +156 -0
  55. package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
  56. package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
  57. package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
  58. package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
  59. package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
  60. package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
  61. package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
  62. package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
  63. package/dist/assets/icons-Dsc5yL3l.js +0 -1
  64. package/dist/assets/index-CPAWYhzz.css +0 -3
  65. package/dist/assets/index-YTL26wyJ.js +0 -814
@@ -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,
@@ -35,7 +38,6 @@ import { omitDetailsForLlm, serverConvertToLlm, messageText, lastAssistantText }
35
38
  import { isPlainObject, mergeQuickForgeTiming, wrapToolDefinition, wrapMcpToolDefinition, wrapPluginToolDefinition, sessionSkillsContext } from './tool-wiring.mjs'
36
39
  import {
37
40
  APPROVAL_TIMEOUT_MS,
38
- commandRestrictedTools,
39
41
  safeReadTools,
40
42
  pendingApprovals,
41
43
  pendingAutoCompactApprovals,
@@ -93,7 +95,7 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
93
95
  .filter(isAllowed)
94
96
  .map((definition) => wrapToolDefinition(definition, toolContext, toolPermissions))
95
97
 
96
- if (includeWorkspaceTools && projectId && projectContext) {
98
+ if (includeWorkspaceTools && projectContext) {
97
99
  const definitions = workspaceTools.filter((definition) => includeSubagentTool || definition.name !== 'run_subagent')
98
100
  tools.push(...definitions
99
101
  .filter(isAllowed)
@@ -118,7 +120,7 @@ async function rebuildSessionTools(session) {
118
120
  session.projectId,
119
121
  session.projectContext,
120
122
  sessionSkillsContext(session),
121
- !!(session.projectId && session.projectContext),
123
+ !!session.projectContext,
122
124
  createCommandToolPermissions(session),
123
125
  { parentSessionId: session.sessionId },
124
126
  )
@@ -130,7 +132,26 @@ async function rebuildSessionTools(session) {
130
132
 
131
133
  const agentSessions = new Map()
132
134
 
133
- /** @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 */
134
155
 
135
156
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
136
157
  const SUBAGENT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
@@ -371,12 +392,12 @@ function finishManualSessionRun(session, status, errorMessage) {
371
392
  session.agent.state.errorMessage = errorMessage
372
393
  }
373
394
 
374
- async function compactSession(session, initialUserMessage, compactOptions) {
395
+ async function summarySession(session, initialUserMessage, summaryOptions) {
375
396
  if (session.agent.state.isStreaming) {
376
397
  session.agent.state.messages = [
377
398
  ...session.agent.state.messages,
378
399
  initialUserMessage,
379
- 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),
380
401
  ]
381
402
  await persistSession(session)
382
403
  const messages = session.agent.state.messages
@@ -400,13 +421,13 @@ async function compactSession(session, initialUserMessage, compactOptions) {
400
421
 
401
422
  try {
402
423
  const originalMessages = session.agent.state.messages.slice()
403
- const options = parseCompactArgs(compactOptions?.args || '')
424
+ const options = parseCompactArgs(summaryOptions?.args || '')
404
425
 
405
426
  if (options.unsupported?.length) {
406
427
  session.agent.state.messages = [
407
428
  ...originalMessages,
408
429
  initialUserMessage,
409
- 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),
410
431
  ]
411
432
  finishManualSessionRun(session, 'idle')
412
433
  await persistSession(session)
@@ -428,7 +449,7 @@ async function compactSession(session, initialUserMessage, compactOptions) {
428
449
  session.agent.state.messages = [
429
450
  ...originalMessages,
430
451
  initialUserMessage,
431
- 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),
432
453
  ]
433
454
  finishManualSessionRun(session, 'idle')
434
455
  await persistSession(session)
@@ -464,6 +485,7 @@ async function compactSession(session, initialUserMessage, compactOptions) {
464
485
  const compactedSession = await createAgent(compactedSessionId, {
465
486
  scope: session.scope,
466
487
  projectId: session.projectId,
488
+ accessMode: session.accessMode,
467
489
  yoloMode: session.yoloMode,
468
490
  model: session.model,
469
491
  thinkingLevel: session.thinkingLevel,
@@ -513,6 +535,97 @@ async function compactSession(session, initialUserMessage, compactOptions) {
513
535
  }
514
536
  }
515
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
+
516
629
  async function clearSession(session) {
517
630
  if (session.agent.state.isStreaming) {
518
631
  session.agent.state.messages = [
@@ -545,22 +658,68 @@ async function clearSession(session) {
545
658
  return { sessionId: session.sessionId, status: session.status, cleared: true }
546
659
  }
547
660
 
548
- async function resolveCommandState(session, userMessage) {
661
+ const QUICKFORGE_COMMAND_DETAILS_KEY = 'quickforgeCommand'
662
+
663
+ function normalizedPromptCommand(command) {
664
+ return command?.type === 'plan' ? { type: 'plan' } : null
665
+ }
666
+
667
+ function objectDetails(message) {
668
+ const details = message?.details
669
+ return details && typeof details === 'object' && !Array.isArray(details) ? details : {}
670
+ }
671
+
672
+ function promptCommandFromMessage(message) {
673
+ return normalizedPromptCommand(objectDetails(message)[QUICKFORGE_COMMAND_DETAILS_KEY])
674
+ }
675
+
676
+ function messageWithPromptCommand(message, command) {
677
+ const normalized = normalizedPromptCommand(command)
678
+ if (!normalized || !message || typeof message !== 'object') return message
679
+ return {
680
+ ...message,
681
+ details: {
682
+ ...objectDetails(message),
683
+ [QUICKFORGE_COMMAND_DETAILS_KEY]: normalized,
684
+ },
685
+ }
686
+ }
687
+
688
+ function internalInvocationForPromptCommand(userMessage, command) {
689
+ const normalized = normalizedPromptCommand(command)
690
+ if (normalized?.type === 'plan') {
691
+ // Derive the task from the message text. Strip a leading "/plan" so that
692
+ // toggling plan mode while typing "/plan <task>" yields the clean task —
693
+ // matching the slash-command parse path and avoiding a redundant prefix.
694
+ const raw = messageText(userMessage).trim()
695
+ const planPrefix = raw.match(/^\/plan(?:\s+([\s\S]*))?$/i)
696
+ return { type: 'plan', args: planPrefix ? (planPrefix[1] || '').trim() : raw }
697
+ }
698
+ return parseInternalCommandInvocation(userMessage)
699
+ }
700
+
701
+ function planCommandState(userMessage, args) {
702
+ return {
703
+ userMessage: messageWithPromptCommand(userMessage, { type: 'plan' }),
704
+ commandPrompt: formatPlanCommandPrompt(args),
705
+ permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
706
+ commandName: 'plan',
707
+ }
708
+ }
709
+
710
+ async function resolveCommandState(session, userMessage, promptCommand = null) {
711
+ const command = normalizedPromptCommand(promptCommand) || promptCommandFromMessage(userMessage)
549
712
  const internalResponse = await handleInternalCommand(
550
- parseInternalCommandInvocation(userMessage),
713
+ internalInvocationForPromptCommand(userMessage, command),
551
714
  session.projectContext?.workspaceRoot,
552
715
  session.projectContext?.project?.commandDir,
553
716
  )
554
717
  if (typeof internalResponse === 'string') return { textResponse: internalResponse }
555
718
  if (internalResponse?.clear) return { clear: internalResponse }
719
+ if (internalResponse?.summary) return { summary: internalResponse }
556
720
  if (internalResponse?.compact) return { compact: internalResponse }
557
721
  if (internalResponse?.plan) {
558
- return {
559
- userMessage,
560
- commandPrompt: formatPlanCommandPrompt(internalResponse.args),
561
- permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
562
- commandName: 'plan',
563
- }
722
+ return planCommandState(userMessage, internalResponse.args)
564
723
  }
565
724
  if (internalResponse?.review) {
566
725
  return {
@@ -571,7 +730,22 @@ async function resolveCommandState(session, userMessage) {
571
730
  }
572
731
  }
573
732
 
574
- if (!session.projectContext?.workspaceRoot) return { userMessage }
733
+ if (!session.projectContext?.workspaceRoot) {
734
+ // Even without a project, user-level custom commands (~/.quickforge/commands/) are available
735
+ const invocation = await resolveCustomCommandInvocation(
736
+ userMessage,
737
+ null,
738
+ session.projectContext?.project?.commandDir,
739
+ )
740
+ if (!invocation) return { userMessage }
741
+
742
+ return {
743
+ userMessage,
744
+ commandPrompt: invocation.systemPrompt,
745
+ permissions: invocation.permissions,
746
+ commandName: invocation.command.name,
747
+ }
748
+ }
575
749
 
576
750
  const invocation = await resolveCustomCommandInvocation(
577
751
  userMessage,
@@ -645,7 +819,7 @@ ${scopeText}
645
819
  }
646
820
 
647
821
  async function runSubagent(parentSession, params, parentSignal, onUpdate) {
648
- const profile = await getAgentProfile(params?.subagent)
822
+ const profile = await getAgentProfile(params?.subagent, { workspaceRoot: parentSession.projectContext?.workspaceRoot })
649
823
  if (!profile || !profile.enabledAsSubagent) {
650
824
  const error = new Error(`Unknown or disabled subagent: ${params?.subagent || ''}`)
651
825
  error.statusCode = 400
@@ -769,7 +943,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
769
943
  }
770
944
  const commandPermissionError = commandToolPermissionError(parentSession, toolName)
771
945
  if (commandPermissionError) return { block: true, reason: commandPermissionError }
772
- if (!parentSession.yoloMode) {
946
+ if (!hasFullAccess(parentSession)) {
773
947
  if (safeReadTools.has(toolName)) return undefined
774
948
  return createApprovalPromise(parentSession, context.toolCall?.id, toolName, context.args, {
775
949
  type: 'subagent',
@@ -950,8 +1124,15 @@ async function transformSessionContext(session, messages, signal) {
950
1124
  export const agentEvents = new EventEmitter()
951
1125
  agentEvents.setMaxListeners(100)
952
1126
 
1127
+ function isIdleRetainedSession(session) {
1128
+ return session?.idleRetention === 'always'
1129
+ }
1130
+
953
1131
  function resetIdleTimer(session) {
954
1132
  if (session.idleTimer) clearTimeout(session.idleTimer)
1133
+ session.idleTimer = null
1134
+ if (isIdleRetainedSession(session)) return
1135
+
955
1136
  session.idleTimer = setTimeout(() => {
956
1137
  if (session.status === 'running') {
957
1138
  logger.info(`Session ${session.sessionId} idle timer fired but still running, resetting...`, { sessionId: session.sessionId, status: session.status })
@@ -986,6 +1167,7 @@ export function touchSession(sessionId) {
986
1167
  export async function createAgent(sessionId, config = {}) {
987
1168
  const existing = agentSessions.get(sessionId)
988
1169
  if (existing) {
1170
+ if (config.idleRetention !== undefined) existing.idleRetention = config.idleRetention
989
1171
  resetIdleTimer(existing)
990
1172
  return existing
991
1173
  }
@@ -993,6 +1175,7 @@ export async function createAgent(sessionId, config = {}) {
993
1175
  const {
994
1176
  scope = 'global',
995
1177
  projectId = null,
1178
+ accessMode: rawAccessMode,
996
1179
  yoloMode = false,
997
1180
  model = null,
998
1181
  thinkingLevel = 'off',
@@ -1003,17 +1186,23 @@ export async function createAgent(sessionId, config = {}) {
1003
1186
  lastModified = null,
1004
1187
  contextCompaction = null,
1005
1188
  agentProfile = null,
1189
+ idleRetention = null,
1006
1190
  } = config
1191
+ const accessMode = normalizeAccessMode(rawAccessMode, yoloMode)
1192
+ const resolvedYoloMode = yoloModeFromAccessMode(accessMode)
1007
1193
 
1008
- // 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.
1009
1197
  let projectContext = null
1010
1198
  if (projectId) {
1011
1199
  try {
1012
1200
  projectContext = await projectContextFromId(projectId)
1013
1201
  } catch {
1014
- // project not found — run without tools
1202
+ // project not found — fall back to the default workspace below
1015
1203
  }
1016
1204
  }
1205
+ projectContext ??= defaultGlobalWorkspaceContext()
1017
1206
 
1018
1207
  // Build system prompt
1019
1208
  const projectConfig = await readProjectConfig()
@@ -1046,7 +1235,7 @@ export async function createAgent(sessionId, config = {}) {
1046
1235
  projectId,
1047
1236
  projectContext,
1048
1237
  skillsContext,
1049
- !!(projectId && projectContext),
1238
+ !!projectContext,
1050
1239
  (toolName) => {
1051
1240
  if (profileToolNames && !profileToolNames.includes(toolName)) return `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.`
1052
1241
  const session = agentSessions.get(sessionId)
@@ -1100,14 +1289,14 @@ export async function createAgent(sessionId, config = {}) {
1100
1289
  if (profileToolNames && !profileToolNames.includes(toolName)) return { block: true, reason: `Agent profile ${agentProfile.name} is not allowed to use ${toolName}.` }
1101
1290
  if (toolName === 'run_subagent') return undefined
1102
1291
  if (isMcpToolName(toolName) || isPluginToolName(toolName)) {
1103
- if (!currentSession?.yoloMode) return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1292
+ if (!hasFullAccess(currentSession)) return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1104
1293
  return undefined
1105
1294
  }
1106
- if (!projectContext) {
1295
+ if (!projectContext?.workspaceRoot) {
1107
1296
  return { block: true, reason: 'No active project. Select a project to use tools.' }
1108
1297
  }
1109
- if (!currentSession?.yoloMode) {
1110
- // 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
1111
1300
  if (safeReadTools.has(toolName)) return undefined
1112
1301
  return createApprovalPromise(currentSession, toolCallId, toolName, context.args)
1113
1302
  }
@@ -1123,7 +1312,8 @@ export async function createAgent(sessionId, config = {}) {
1123
1312
  agent,
1124
1313
  projectContext,
1125
1314
  projectId,
1126
- yoloMode,
1315
+ accessMode,
1316
+ yoloMode: resolvedYoloMode,
1127
1317
  model: resolvedModel,
1128
1318
  thinkingLevel,
1129
1319
  scope,
@@ -1146,6 +1336,7 @@ export async function createAgent(sessionId, config = {}) {
1146
1336
  getApiKey,
1147
1337
  contextCompaction,
1148
1338
  agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
1339
+ idleRetention,
1149
1340
  lastTransformedContextMessages: null,
1150
1341
  autoCompacting: false,
1151
1342
  stateVersion: 0,
@@ -1157,7 +1348,7 @@ export async function createAgent(sessionId, config = {}) {
1157
1348
  }
1158
1349
 
1159
1350
  // Subscribe to agent lifecycle events and forward to eventBus
1160
- agent.subscribe((event) => {
1351
+ agent.subscribe(async (event) => {
1161
1352
  // The pi-agent-core agent loop emits agent_end with `messages` that only
1162
1353
  // contains messages generated during THIS run (newMessages), not the
1163
1354
  // complete session history. Replace with the authoritative full state
@@ -1187,10 +1378,14 @@ export async function createAgent(sessionId, config = {}) {
1187
1378
  session.status = 'running'
1188
1379
  session.startedAt = session.startedAt ?? new Date().toISOString()
1189
1380
  session.finishedAt = null
1190
- // Persist running state immediately so a browser refresh still shows the green dot
1191
- persistSession(session).catch((err) =>
1192
- logger.error(`Failed to persist session on start ${sessionId}:`, err, { sessionId }),
1193
- )
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
+ }
1194
1389
  }
1195
1390
 
1196
1391
  if (event.type === 'agent_end') {
@@ -1206,15 +1401,28 @@ export async function createAgent(sessionId, config = {}) {
1206
1401
  }
1207
1402
 
1208
1403
  if (event.type === 'message_end') {
1209
- // Debounced persist for crash recovery; coalesces the many message_end
1210
- // events within a single run into infrequent full-session writes.
1211
- 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
+ }
1212
1420
  }
1213
1421
  })
1214
1422
 
1215
1423
  agentSessions.set(sessionId, session)
1216
1424
  resetIdleTimer(session)
1217
- 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 })
1218
1426
  return session
1219
1427
  }
1220
1428
 
@@ -1246,7 +1454,7 @@ function sessionLastModifiedFromMessages(messages, fallback) {
1246
1454
  * Persist session data to storage.
1247
1455
  */
1248
1456
  async function persistSession(session) {
1249
- 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
1250
1458
  const messages = agent.state.messages
1251
1459
 
1252
1460
  if (messages.length === 0) {
@@ -1269,6 +1477,7 @@ async function persistSession(session) {
1269
1477
  title,
1270
1478
  model,
1271
1479
  thinkingLevel,
1480
+ accessMode,
1272
1481
  yoloMode,
1273
1482
  messages,
1274
1483
  createdAt: createdAt || now,
@@ -1279,6 +1488,7 @@ async function persistSession(session) {
1279
1488
  taskStartedAt: startedAt,
1280
1489
  taskFinishedAt: finishedAt,
1281
1490
  contextCompaction: contextCompaction || undefined,
1491
+ idleRetention: session.idleRetention || undefined,
1282
1492
  }
1283
1493
  session.lastModified = lastModified
1284
1494
 
@@ -1320,6 +1530,7 @@ async function persistSession(session) {
1320
1530
  messageCount: messages.length,
1321
1531
  usage,
1322
1532
  thinkingLevel,
1533
+ accessMode,
1323
1534
  yoloMode,
1324
1535
  preview,
1325
1536
  scope,
@@ -1334,6 +1545,7 @@ async function persistSession(session) {
1334
1545
  thresholdPercent: contextCompaction.thresholdPercent,
1335
1546
  usageBefore: contextCompaction.usageBefore,
1336
1547
  } : undefined,
1548
+ idleRetention: session.idleRetention || undefined,
1337
1549
  }
1338
1550
 
1339
1551
  // Write to storage atomically (read-modify-write within queue)
@@ -1452,7 +1664,7 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
1452
1664
  * Send a user message to the agent and start the agent loop.
1453
1665
  * Returns immediately; events are streamed via the event bus.
1454
1666
  */
1455
- export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1667
+ export async function runPrompt(sessionId, message, selectedCapabilities = [], promptCommand = null) {
1456
1668
  let session = agentSessions.get(sessionId)
1457
1669
  if (!session) {
1458
1670
  session = await restoreAgent(sessionId)
@@ -1471,7 +1683,7 @@ export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1471
1683
  const initialUserMessage = typeof message === 'string'
1472
1684
  ? { role: 'user', content: message, timestamp: new Date().toISOString() }
1473
1685
  : message
1474
- const commandState = await resolveCommandState(session, initialUserMessage)
1686
+ const commandState = await resolveCommandState(session, initialUserMessage, promptCommand)
1475
1687
  const userMessage = commandState.userMessage ?? initialUserMessage
1476
1688
 
1477
1689
  if (commandState.textResponse) {
@@ -1491,6 +1703,10 @@ export async function runPrompt(sessionId, message, selectedCapabilities = []) {
1491
1703
  return clearSession(session)
1492
1704
  }
1493
1705
 
1706
+ if (commandState.summary) {
1707
+ return summarySession(session, initialUserMessage, commandState.summary)
1708
+ }
1709
+
1494
1710
  if (commandState.compact) {
1495
1711
  return compactSession(session, initialUserMessage, commandState.compact)
1496
1712
  }
@@ -1569,14 +1785,27 @@ export async function continueSession(sessionId) {
1569
1785
  throw Object.assign(new Error('Cannot continue: no user message found.'), { statusCode: 400 })
1570
1786
  }
1571
1787
 
1572
- const trimmedMessages = messages.slice(0, lastUserIndex + 1)
1788
+ const lastUserMessage = messages[lastUserIndex]
1789
+ const commandState = await resolveCommandState(session, lastUserMessage)
1790
+ const continuedUserMessage = commandState.userMessage ?? lastUserMessage
1791
+ const trimmedMessages = messages.slice(0, lastUserIndex).concat(continuedUserMessage)
1573
1792
  updateSessionMessages(session, trimmedMessages)
1574
1793
  resetSessionCompaction(session)
1575
1794
 
1576
1795
  resetIdleTimer(session)
1796
+ session.activeCommandName = commandState.commandName ?? null
1797
+ session.activeCommandPermissions = commandState.permissions ?? null
1798
+ session.activeCommandPrompt = commandState.commandPrompt ?? null
1799
+ session.activeCapabilityPrompt = null
1800
+
1577
1801
  session.agent.continue().catch((err) => {
1578
1802
  logger.error(`Agent continue error for session ${sessionId}:`, err, { sessionId })
1579
1803
  emitSessionEvent(session, { type: 'error', error: err.message || 'Unknown error' })
1804
+ }).finally(() => {
1805
+ session.activeCommandName = null
1806
+ session.activeCommandPermissions = null
1807
+ session.activeCommandPrompt = null
1808
+ session.activeCapabilityPrompt = null
1580
1809
  })
1581
1810
 
1582
1811
  return { sessionId, status: 'running' }
@@ -1678,6 +1907,7 @@ export function getSessionState(sessionId) {
1678
1907
  sessionId: session.sessionId,
1679
1908
  scope: session.scope,
1680
1909
  projectId: session.projectId,
1910
+ accessMode: session.accessMode,
1681
1911
  yoloMode: session.yoloMode,
1682
1912
  systemPrompt: session.agent.state.systemPrompt,
1683
1913
  model: session.model,
@@ -1822,6 +2052,7 @@ export async function restoreAgent(sessionId) {
1822
2052
  return await createAgent(sessionId, {
1823
2053
  scope: sessionData.scope || 'global',
1824
2054
  projectId: sessionData.projectId || null,
2055
+ accessMode: normalizeAccessMode(sessionData.accessMode, sessionData.yoloMode),
1825
2056
  yoloMode: sessionData.yoloMode || false,
1826
2057
  model: sessionData.model,
1827
2058
  thinkingLevel: sessionData.thinkingLevel || 'off',
@@ -1830,6 +2061,7 @@ export async function restoreAgent(sessionId) {
1830
2061
  createdAt: sessionData.createdAt,
1831
2062
  lastModified: sessionData.lastModified,
1832
2063
  contextCompaction: sessionData.contextCompaction || null,
2064
+ idleRetention: sessionData.idleRetention || null,
1833
2065
  })
1834
2066
  } catch (err) {
1835
2067
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
@@ -1902,6 +2134,7 @@ export function listSessions() {
1902
2134
  scope: session.scope,
1903
2135
  status: session.status,
1904
2136
  title: session.title,
2137
+ idleRetention: session.idleRetention || undefined,
1905
2138
  })
1906
2139
  }
1907
2140
  return result
@@ -1923,20 +2156,25 @@ export async function refreshAllSessionTools() {
1923
2156
  return result
1924
2157
  }
1925
2158
 
1926
- export async function updateSessionYoloMode(sessionId, yoloMode) {
2159
+ export async function updateSessionAccessMode(sessionId, accessMode) {
1927
2160
  const session = agentSessions.get(sessionId)
1928
2161
  if (!session) {
1929
2162
  throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1930
2163
  }
1931
2164
 
1932
- session.yoloMode = Boolean(yoloMode)
2165
+ session.accessMode = normalizeAccessMode(accessMode, session.accessMode)
2166
+ session.yoloMode = yoloModeFromAccessMode(session.accessMode)
1933
2167
  await rebuildSessionTools(session)
1934
2168
  await persistSession(session)
1935
2169
 
1936
2170
  const state = getSessionState(sessionId)
1937
2171
  emitSessionEvent(session, { type: 'state', ...state })
1938
2172
 
1939
- 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)
1940
2178
  }
1941
2179
 
1942
2180
  /**