@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.
- package/README.md +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +198 -32
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/auto-compaction.mjs +82 -39
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/custom-commands.mjs +13 -1
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +12 -1
- package/server/routes/channels.mjs +145 -0
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +2 -2
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +29 -2
- package/server/system-prompt.mjs +1 -0
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
- package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
- package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
- package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
- package/dist/assets/icons-BWtivFsx.js +0 -1
- package/dist/assets/index-CxOHP41X.css +0 -3
- package/dist/assets/index-Dcf73EL8.js +0 -895
package/server/agent-manager.mjs
CHANGED
|
@@ -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 &&
|
|
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
|
-
!!
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 /
|
|
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
|
|
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
|
|
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 —
|
|
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
|
-
!!
|
|
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
|
|
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
|
|
1169
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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'},
|
|
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
|
|
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.
|
|
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
|
+
}
|