diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts
index d8a2e5f0e..f522f9a98 100644
--- a/apps/electron/src/main/lib/agent-orchestrator.ts
+++ b/apps/electron/src/main/lib/agent-orchestrator.ts
@@ -51,7 +51,7 @@ import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getSdkConfigDir, g
import { getRuntimeStatus } from './runtime-init'
import { getSettings } from './settings-service'
import { buildSystemPrompt, buildDynamicContext } from './agent-prompt-builder'
-import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt } from './agent-session-context-prompt'
+import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt, mergeSessionCleanerSkillMention } from './agent-session-context-prompt'
import { permissionService } from './agent-permission-service'
import type { PermissionResult, CanUseToolOptions } from './agent-permission-service'
import { askUserService } from './agent-ask-user-service'
@@ -1623,6 +1623,9 @@ export class AgentOrchestrator {
})
}
const piCustomTools = [...piBuiltinTools, ...piMcpTools]
+ const piSkillMentions = agentRuntime === 'pi'
+ ? mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds)
+ : mentionedSkills
const queryOptions: ClaudeAgentQueryOptions | PiAgentQueryOptions = agentRuntime === 'pi' ? {
agentRuntime: 'pi',
sessionId,
@@ -1647,7 +1650,7 @@ export class AgentOrchestrator {
piSessionDir: join(getSdkConfigDir(), 'sessions'),
...(allAdditionalDirectories.length > 0 && { additionalDirectories: allAdditionalDirectories }),
...(workspaceSlug ? { additionalSkillPaths: [getWorkspaceSkillsDir(workspaceSlug)] } : {}),
- ...(mentionedSkills?.length ? { skillMentions: mentionedSkills } : {}),
+ ...(piSkillMentions?.length ? { skillMentions: piSkillMentions } : {}),
...(isCompactCommand ? { compactRequest: true } : {}),
...(sessionMeta?.codexFastMode && channel.provider === 'openai-codex' ? { codexFastMode: true } : {}),
...(codexOAuthCredentials && {
@@ -2677,6 +2680,7 @@ export class AgentOrchestrator {
enrichedText = `\n${toolLines.join('\n')}\n\n\n${enrichedText}`
}
+ const skillMentions = mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds)
const uuid = presetUuid || randomUUID()
// 防重记录
@@ -2706,7 +2710,9 @@ export class AgentOrchestrator {
}
}
- await this.adapter.sendQueuedMessage(sessionId, sdkMessage)
+ await this.adapter.sendQueuedMessage(sessionId, sdkMessage, {
+ ...(skillMentions?.length ? { skillMentions } : {}),
+ })
console.log(`[Agent 编排] 追加消息已注入: sessionId=${sessionId}, uuid=${uuid}, interrupt=${!!opts?.interrupt}`)
// 立即持久化到 JSONL — 仅存原始文本,不含 prompt 工程块(与 sendMessage 路径一致)
diff --git a/apps/electron/src/main/lib/agent-runtime-env.test.ts b/apps/electron/src/main/lib/agent-runtime-env.test.ts
index a3cd23f5c..6b2163594 100644
--- a/apps/electron/src/main/lib/agent-runtime-env.test.ts
+++ b/apps/electron/src/main/lib/agent-runtime-env.test.ts
@@ -88,4 +88,17 @@ describe('Agent Windows Shell 运行环境', () => {
expect(result).toEqual({ PATH: 'C:\\Proma;C:\\Windows\\System32' })
})
+
+ test('Given 打包版 CLI When 构建 Pi Bash 环境 Then 注入 PROMA_CLI 并优先其目录', () => {
+ const result = buildAgentRuntimeEnv({
+ bundledCliPath: '/Applications/Proma.app/Contents/Resources/bin/proma',
+ platform: 'darwin',
+ processEnv: { PATH: '/usr/bin:/bin' },
+ })
+
+ expect(result.env).toMatchObject({
+ PROMA_CLI: '/Applications/Proma.app/Contents/Resources/bin/proma',
+ PATH: '/Applications/Proma.app/Contents/Resources/bin:/usr/bin:/bin',
+ })
+ })
})
diff --git a/apps/electron/src/main/lib/agent-session-context-prompt.test.ts b/apps/electron/src/main/lib/agent-session-context-prompt.test.ts
new file mode 100644
index 000000000..34f0b928a
--- /dev/null
+++ b/apps/electron/src/main/lib/agent-session-context-prompt.test.ts
@@ -0,0 +1,129 @@
+import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import * as os from 'node:os'
+import { join } from 'node:path'
+
+type SessionContextPrompt = typeof import('./agent-session-context-prompt')
+
+let sessionContextPrompt: SessionContextPrompt
+let tempHome: string
+let resourcesPath: string
+const originalHome = process.env.HOME
+const originalPromaDev = process.env.PROMA_DEV
+const originalResourcesPath = process.resourcesPath
+
+mock.module('electron', () => ({
+ app: {
+ isPackaged: true,
+ getPath: () => join(process.env.HOME ?? tempHome, 'Library', 'Application Support'),
+ getAppPath: () => join(tempHome, 'app'),
+ },
+ BrowserWindow: class {},
+ clipboard: {},
+ dialog: {},
+ nativeImage: { createFromPath: () => ({}) },
+ nativeTheme: {},
+ powerMonitor: {},
+ powerSaveBlocker: {},
+ screen: {},
+ shell: {},
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString('utf-8'),
+ },
+}))
+
+mock.module('node:os', () => ({
+ ...os,
+ homedir: () => tempHome,
+}))
+
+function writeSessionIndex(): void {
+ const configDir = join(tempHome, '.proma')
+ mkdirSync(configDir, { recursive: true })
+ writeFileSync(join(configDir, 'agent-sessions.json'), JSON.stringify({
+ version: 1,
+ sessions: [
+ { id: 'current', title: '当前会话', workspaceId: 'workspace-a', createdAt: 1, updatedAt: 2 },
+ { id: 'source', title: '来源 <会话>', workspaceId: 'workspace-a', createdAt: 3, updatedAt: 4 },
+ { id: 'large', title: '超大来源会话', workspaceId: 'workspace-a', createdAt: 5, updatedAt: 6 },
+ { id: 'other-workspace', title: '不应出现', workspaceId: 'workspace-b', createdAt: 7, updatedAt: 8 },
+ ],
+ }), 'utf-8')
+}
+
+function writeSourceSession(): void {
+ const sessionsDir = join(tempHome, '.proma', 'agent-sessions')
+ mkdirSync(sessionsDir, { recursive: true })
+ const rows = [
+ { type: 'user', message: { content: [{ type: 'text', text: '请检查 的实现' }] }, parent_tool_use_id: null },
+ { type: 'assistant', message: { content: [{ type: 'text', text: '已完成检查' }] }, parent_tool_use_id: null },
+ ]
+ writeFileSync(join(sessionsDir, 'source.jsonl'), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf-8')
+}
+
+beforeAll(async () => {
+ tempHome = mkdtempSync(join(os.tmpdir(), 'proma-session-context-prompt-'))
+ resourcesPath = join(tempHome, 'resources')
+ mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
+ writeFileSync(join(resourcesPath, 'bin', 'proma'), '', 'utf-8')
+ process.env.HOME = tempHome
+ process.env.PROMA_DEV = '0'
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath })
+ writeSessionIndex()
+ writeSourceSession()
+ sessionContextPrompt = await import('./agent-session-context-prompt')
+})
+
+afterAll(() => {
+ if (originalHome === undefined) delete process.env.HOME
+ else process.env.HOME = originalHome
+ if (originalPromaDev === undefined) delete process.env.PROMA_DEV
+ else process.env.PROMA_DEV = originalPromaDev
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: originalResourcesPath })
+ rmSync(tempHome, { recursive: true, force: true })
+})
+
+describe('引用 Agent 会话上下文', () => {
+ test('Given 同工作区会话被显式引用 When 构建 prompt Then 注入受限地图和明确 CLI 深读命令', () => {
+ const prompt = sessionContextPrompt.buildReferencedSessionsPrompt(
+ 'current',
+ ['source', 'other-workspace'],
+ 'workspace-a',
+ 'proma-dev',
+ )
+
+ expect(prompt).toContain('')
+ expect(prompt).toContain('')
+ expect(prompt).toContain('#0 用户 · 请检查 <untrusted> 的实现')
+ expect(prompt).toContain('"$PROMA_CLI" session info source')
+ expect(prompt).toContain('"$PROMA_CLI" session export source --turns A-B')
+ expect(prompt).toContain('不得只依据标题、元数据或地图回答')
+ expect(prompt).not.toContain('other-workspace')
+ })
+
+ test('Given 引用会话超过地图阈值 When 构建 prompt Then 不同步扫描正文并要求 CLI 深读', () => {
+ const sessionsDir = join(tempHome, '.proma', 'agent-sessions')
+ const largeText = 'x'.repeat(2 * 1024 * 1024 + 1)
+ writeFileSync(join(sessionsDir, 'large.jsonl'), `${JSON.stringify({
+ type: 'user',
+ message: { content: [{ type: 'text', text: largeText }] },
+ parent_tool_use_id: null,
+ })}\n`, 'utf-8')
+
+ const prompt = sessionContextPrompt.buildReferencedSessionsPrompt('current', ['large'], 'workspace-a')
+
+ expect(prompt).toContain(' {
+ expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], ['source']))
+ .toEqual(['automation', 'session-cleaner'])
+ expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['session-cleaner'], ['source']))
+ .toEqual(['session-cleaner'])
+ expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], [])).toEqual(['automation'])
+ })
+})
diff --git a/apps/electron/src/main/lib/agent-session-context-prompt.ts b/apps/electron/src/main/lib/agent-session-context-prompt.ts
index d301d3121..1e31a7822 100644
--- a/apps/electron/src/main/lib/agent-session-context-prompt.ts
+++ b/apps/electron/src/main/lib/agent-session-context-prompt.ts
@@ -1,9 +1,18 @@
+import { statSync } from 'node:fs'
+import { groupIntoTurns, formatOutlineLine, outline, toTranscript } from '@proma/session-core'
+import { readSessionMessages } from '@proma/session-core/node'
import { getAgentSessionMeta, getAgentSessionSDKMessages } from './agent-session-manager'
-import { getBundledCliPath, getConfigDirName } from './config-paths'
+import { getAgentSessionMessagesPath, getBundledCliPath, getConfigDirName, getDevCliEntryPath } from './config-paths'
/** 最大回填消息条数 */
export const MAX_CONTEXT_MESSAGES = 20
+/** 显式引用会话时注入的 turn 地图上限,正文仍由 Agent 按需通过 CLI 读取。 */
+const MAX_REFERENCED_SESSION_OUTLINE_TURNS = 16
+
+/** 超过此大小的会话只注入读取指引,避免主进程为引用地图同步扫描超大 JSONL。 */
+const MAX_REFERENCED_SESSION_OUTLINE_BYTES = 2 * 1024 * 1024
+
/** 单条工具摘要最大字符数 */
const MAX_TOOL_SUMMARY_LENGTH = 200
@@ -16,22 +25,29 @@ function getSessionHistoryPath(sessionId: string): string {
return `~/${getConfigDirName()}/agent-sessions/${sessionId}.jsonl`
}
-function canUseSessionCleaner(): boolean {
- return !!getBundledCliPath()
-}
-
function getSessionCleanerSkillName(workspaceSlug?: string): string {
return workspaceSlug
? `proma-workspace-${workspaceSlug}:session-cleaner`
: 'session-cleaner'
}
-function getSessionCliCommandPrefix(): string {
- return getBundledCliPath() ? '"$PROMA_CLI"' : 'proma'
+function quoteShellArgument(value: string): string {
+ return `"${value.replace(/[$`\\"]/g, '\\$&')}"`
+}
+
+function getSessionCliCommandPrefix(): string | undefined {
+ if (getBundledCliPath()) return '"$PROMA_CLI"'
+
+ const devCliEntryPath = getDevCliEntryPath()
+ return devCliEntryPath ? `bun ${quoteShellArgument(devCliEntryPath)} --dev` : undefined
}
function buildSessionCliAccessGuide(sessionId: string, historyPath: string, workspaceSlug?: string): string {
const cli = getSessionCliCommandPrefix()
+ if (!cli) {
+ return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}`
+ }
+
const skillName = getSessionCleanerSkillName(workspaceSlug)
return [
`优先使用 session-cleaner skill(${skillName})读取当前会话历史;它是 Proma CLI 的薄封装,会把 Agent JSONL 清洗为干净对话。`,
@@ -46,21 +62,60 @@ function buildSessionCliAccessGuide(sessionId: string, historyPath: string, work
}
function buildCurrentSessionHistoryInstruction(sessionId: string, workspaceSlug?: string): string {
- const historyPath = getSessionHistoryPath(sessionId)
- if (canUseSessionCleaner()) {
- return buildSessionCliAccessGuide(sessionId, historyPath, workspaceSlug)
- }
+ return buildSessionCliAccessGuide(sessionId, getSessionHistoryPath(sessionId), workspaceSlug)
+}
+
+function buildReferencedSessionOutline(sessionId: string): string | undefined {
+ try {
+ const sessionPath = getAgentSessionMessagesPath(sessionId)
+ const bytes = statSync(sessionPath).size
+ if (bytes > MAX_REFERENCED_SESSION_OUTLINE_BYTES) {
+ return [
+ ``,
+ '宿主未扫描超大历史;请先使用 CLI 的 info 和 outline 命令渐进读取。',
+ '',
+ ].join('\n')
+ }
- return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}`
+ const entries = outline(toTranscript(groupIntoTurns(readSessionMessages(sessionPath))))
+ const visibleEntries = entries.slice(-MAX_REFERENCED_SESSION_OUTLINE_TURNS)
+ const omittedEntries = entries.length - visibleEntries.length
+ const lines = visibleEntries.map((entry) => escapeContextText(formatOutlineLine(entry)))
+
+ return [
+ ``,
+ '以下是宿主生成的受限定位地图,不是完整会话正文;预览可能截断,不能据此声称已读取完整历史。',
+ ...(omittedEntries > 0 ? [`已省略最早 ${omittedEntries} 个 turn。`] : []),
+ ...lines,
+ '',
+ ].join('\n')
+ } catch (error) {
+ console.warn(`[Agent 编排] 无法生成引用会话地图: sessionId=${sessionId}`, error)
+ return undefined
+ }
}
function buildReferencedSessionsHistoryInstruction(workspaceSlug?: string): string {
- if (canUseSessionCleaner()) {
+ const cli = getSessionCliCommandPrefix()
+ if (cli) {
const skillName = getSessionCleanerSkillName(workspaceSlug)
- return `需要这些会话的上下文时,优先使用 session-cleaner skill(${skillName})或 Proma CLI 读取清洗后的会话历史。按 info → outline/search → export 的顺序渐进式读取;不要假设会话内容,也不要直接 Read 原始 .jsonl 历史文件。`
+ return [
+ `这些会话已由宿主提供受限 turn 地图,但不含完整正文。若回答、总结、继续执行或决策依赖被引用会话的内容,必须先用 session-cleaner skill(${skillName})或 Proma CLI 读取相关 turn;不得只依据标题、元数据或地图回答。`,
+ `可用 CLI 命令前缀: ${cli}`,
+ '按 info → outline/search → export 的顺序渐进读取;不要直接 Read 原始 .jsonl 历史文件。',
+ ].join('\n')
}
- return `不要假设这些会话的内容;需要上下文时,请先读取对应的 History path,再基于读取结果继续完成任务。\n\n重要提示:会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大。请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。避免一次性 Read 整个大文件。`
+ return `这些会话仅提供受限 turn 地图,不含完整正文。若回答依赖被引用会话的内容,必须先读取对应的 History path,再基于读取结果继续完成任务。会话历史文件(.jsonl)可能包含大量消息和 tool results,应优先用 Grep 定位后局部读取,避免一次性 Read 整个文件。`
+}
+
+/** Pi 没有原生 Skill 工具;显式会话引用必须显式展开 session-cleaner 的 SOP。 */
+export function mergeSessionCleanerSkillMention(
+ mentionedSkills: string[] | undefined,
+ mentionedSessionIds: string[] | undefined,
+): string[] | undefined {
+ if (!mentionedSessionIds?.some(Boolean)) return mentionedSkills
+ return [...new Set([...(mentionedSkills ?? []), 'session-cleaner'])]
}
/**
@@ -172,14 +227,17 @@ export function buildRecoveryPrompt(
return `${recoveryBlock}\n\n${currentUserMessage}`
}
-function escapeContextAttr(value: string): string {
+function escapeContextText(value: string): string {
return value
.replace(/&/g, '&')
- .replace(/"/g, '"')
.replace(//g, '>')
}
+function escapeContextAttr(value: string): string {
+ return escapeContextText(value).replace(/"/g, '"')
+}
+
export function buildReferencedSessionsPrompt(
currentSessionId: string,
mentionedSessionIds?: string[],
@@ -201,10 +259,21 @@ export function buildReferencedSessionsPrompt(
const title = escapeContextAttr(meta.title)
const historyPath = getSessionHistoryPath(referencedSessionId)
+ const cli = getSessionCliCommandPrefix()
+ const commands = cli
+ ? [
+ `读取此会话:${cli} session info ${referencedSessionId}`,
+ `${cli} session outline ${referencedSessionId}`,
+ `根据地图选择 ${cli} session export ${referencedSessionId} --turns A-B 或 --tail N。`,
+ ].join('\n')
+ : ''
+ const sessionOutline = buildReferencedSessionOutline(referencedSessionId)
sessionBlocks.push(
`\n` +
`CLI target: ${referencedSessionId}\n` +
`History path: ${historyPath}\n` +
+ `${commands}\n` +
+ `${sessionOutline ?? ''}\n` +
'',
)
}
diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts
index 527fbe6a7..cc673ddd9 100644
--- a/apps/electron/src/main/lib/config-paths.ts
+++ b/apps/electron/src/main/lib/config-paths.ts
@@ -412,6 +412,23 @@ export function getBundledCliPath(): string | undefined {
return existsSync(cliPath) ? cliPath : undefined
}
+/**
+ * 获取开发模式下 CLI 的 TypeScript 入口。
+ *
+ * 打包版使用自包含二进制;开发模式则由 Bun 直接运行源码,并显式加 --dev
+ * 以读取 ~/.proma-dev,避免误读正式版会话库。
+ */
+export function getDevCliEntryPath(): string | undefined {
+ try {
+ const { app } = require('electron')
+ if (app.isPackaged || typeof app.getAppPath !== 'function') return undefined
+ const entryPath = join(app.getAppPath(), '..', 'cli', 'src', 'index.ts')
+ return existsSync(entryPath) ? entryPath : undefined
+ } catch {
+ return undefined
+ }
+}
+
/**
* 从 SKILL.md 的 YAML frontmatter 中解析 version 字段
*