@steerable/agent-shell 0.6.19 → 0.6.21

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.
@@ -68,7 +68,6 @@ export function createHostRuntime(options) {
68
68
  const maybeExecInTerminal = createVisibleTerminalExec({
69
69
  localExecutor,
70
70
  terminalManager,
71
- broadcast,
72
71
  });
73
72
  const toolRouter = new ToolRouter(localExecutor, localScriptRegistry, async (request) => {
74
73
  const viaTerminal = await maybeExecInTerminal(request);
@@ -1,10 +1,8 @@
1
1
  import { type LocalExecRequest, type LocalExecResult, type LocalExecutor } from '../local-executor.js';
2
2
  import type { TerminalManager } from '../terminal-manager.js';
3
- export type HostBroadcast = (channel: string, payload: unknown) => void;
4
3
  export interface VisibleTerminalExecDeps {
5
4
  localExecutor: LocalExecutor;
6
5
  terminalManager: TerminalManager;
7
- broadcast: HostBroadcast;
8
6
  }
9
7
  /**
10
8
  * Map a raw PTY shell binary name (e.g. `powershell.exe`, `cmd.exe`, `zsh`) to
@@ -1,8 +1,7 @@
1
1
  /**
2
- * 可见 PTY 执行路由(Codex 式"看 agent 打字"):shell 命令优先打进共享的
3
- * 可见终端,多行/heredoc 等 sentinel 抓不住的形态回退 headless。
4
- * Electron main 与 BS server 共用;`terminal:reveal` 等事件经注入的
5
- * broadcast 出口(Electron 窗口广播 / BS SSE 总线)。
2
+ * 可见 PTY 执行路由:shell 命令优先打进共享的可见终端,多行/heredoc 等
3
+ * sentinel 抓不住的形态回退 headless。Electron main 与 BS server 共用。
4
+ * 命令进入 PTY 时不通知 renderer 展开面板——终端只由用户手动打开。
6
5
  */
7
6
  import log from 'electron-log';
8
7
  import { GUI_LAUNCH_WAIT_MS, getConfiguredExecTimeoutMs, isGuiLaunchCommand, rewriteExeCommandIfNeeded, } from '../local-executor.js';
@@ -32,7 +31,7 @@ export function mapSessionShell(shell) {
32
31
  * process.
33
32
  */
34
33
  export function createVisibleTerminalExec(deps) {
35
- const { localExecutor, terminalManager, broadcast } = deps;
34
+ const { localExecutor, terminalManager } = deps;
36
35
  return async function maybeExecInTerminal(req) {
37
36
  if (req.command) {
38
37
  req.command = rewriteExeCommandIfNeeded(req.command);
@@ -73,9 +72,6 @@ export function createVisibleTerminalExec(deps) {
73
72
  timeoutMs = getConfiguredExecTimeoutMs() ?? undefined;
74
73
  }
75
74
  const session = terminalManager.ensurePrimary({ cwd: req.cwd });
76
- // Codex 式面板自动展开:命令进入可见 PTY 时通知 renderer 打开内嵌终端
77
- // 面板。幂等——面板已打开时 renderer 端是 no-op。
78
- broadcast('terminal:reveal', { sessionId: session.id });
79
75
  const resolvedShell = mapSessionShell(session.shell);
80
76
  // 项目模式(ToolRouter 会把项目根塞进 req.cwd):可见 PTY 是共享交互
81
77
  // 会话,已有 session 不会随 ensurePrimary 改目录——显式 cd 过去。
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
3
+ *
4
+ * 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
5
+ * 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
6
+ *
7
+ * 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
8
+ * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
9
+ */
10
+ export declare const SUGGESTED_REPLY_COUNT = 3;
11
+ /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
12
+ export declare function cleanSuggestedReply(raw: string): string;
13
+ /**
14
+ * 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
15
+ */
16
+ export declare function parseSuggestedReplies(raw: string): string[];
17
+ /**
18
+ * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
19
+ * 始终返回正好 3 条(输入全空时仍给通用三条)。
20
+ */
21
+ export declare function fallbackSuggestedReplies(userText: string, assistantText: string): string[];
22
+ export interface GenerateSuggestedRepliesResult {
23
+ suggestions: string[];
24
+ /** true = LLM 失败或输出不合格,suggestions 来自启发式兜底。 */
25
+ usedFallback: boolean;
26
+ }
27
+ /**
28
+ * 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
29
+ */
30
+ export declare function generateSuggestedReplies(userText: string, assistantText: string, opts?: {
31
+ perAttemptTimeoutMs?: number;
32
+ }): Promise<GenerateSuggestedRepliesResult>;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
3
+ *
4
+ * 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
5
+ * 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
6
+ *
7
+ * 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
8
+ * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
9
+ */
10
+ import { llmService } from '../llm/index.js';
11
+ export const SUGGESTED_REPLY_COUNT = 3;
12
+ const MAX_SUGGESTION_CHARS = 36;
13
+ const USER_TEXT_LIMIT = 500;
14
+ const ASSISTANT_TEXT_LIMIT = 1600;
15
+ const SYSTEM_PROMPT = `你是对话追问建议助手。根据用户上一轮请求和助手刚刚完成的回复,生成 3 条用户可以点一下就发出去的下一轮输入。
16
+
17
+ 要求:
18
+ 1. 正好 3 条,彼此不重复
19
+ 2. 每条 8-28 个字,用用户口吻,像用户会打的话
20
+ 3. 紧扣刚刚完成的工作:改内容、改样式、继续深入、换方向;不要空泛的「再详细说说」
21
+ 4. 如果助手已经邀请后续操作(例如「如需修改内容或调整样式」),把那些邀请写成具体可执行的短句
22
+ 5. 不要编号、不要引号、不要解释
23
+ 6. 只输出 JSON 字符串数组,例如 ["调整封面配色","把个人简介写得更具体","再加一页项目案例"]`;
24
+ function withTimeout(promise, timeoutMs, label) {
25
+ if (timeoutMs <= 0)
26
+ return promise;
27
+ return new Promise((resolve, reject) => {
28
+ const timer = setTimeout(() => {
29
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
30
+ }, timeoutMs);
31
+ promise.then((value) => {
32
+ clearTimeout(timer);
33
+ resolve(value);
34
+ }, (err) => {
35
+ clearTimeout(timer);
36
+ reject(err);
37
+ });
38
+ });
39
+ }
40
+ function clip(text, limit) {
41
+ const chars = Array.from((text ?? '').trim());
42
+ if (chars.length <= limit)
43
+ return chars.join('');
44
+ return `${chars.slice(0, limit).join('')}…`;
45
+ }
46
+ /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
47
+ export function cleanSuggestedReply(raw) {
48
+ let text = (raw ?? '').trim();
49
+ text = text.replace(/^[\s"'“”‘’「」『』《》【】]+|[\s"'“”‘’「」『』《》【】。.!!??,,;;::]+$/g, '');
50
+ text = text.replace(/^(?:\d+[\.\)、]|[-*•])\s*/, '');
51
+ text = text.replace(/\s+/g, ' ').trim();
52
+ if (text.length < 2)
53
+ return '';
54
+ const chars = Array.from(text);
55
+ if (chars.length > MAX_SUGGESTION_CHARS) {
56
+ text = chars.slice(0, MAX_SUGGESTION_CHARS).join('').trim();
57
+ }
58
+ return text;
59
+ }
60
+ function uniqueThree(candidates, extras = []) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const raw of [...candidates, ...extras]) {
64
+ const cleaned = cleanSuggestedReply(raw);
65
+ if (!cleaned || seen.has(cleaned))
66
+ continue;
67
+ seen.add(cleaned);
68
+ out.push(cleaned);
69
+ if (out.length === SUGGESTED_REPLY_COUNT)
70
+ break;
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
76
+ */
77
+ export function parseSuggestedReplies(raw) {
78
+ const trimmed = (raw ?? '').trim();
79
+ if (!trimmed)
80
+ return [];
81
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
82
+ const candidate = (fenced?.[1] ?? trimmed).trim();
83
+ const start = candidate.indexOf('[');
84
+ const end = candidate.lastIndexOf(']');
85
+ if (start >= 0 && end > start) {
86
+ try {
87
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
88
+ if (Array.isArray(parsed)) {
89
+ return uniqueThree(parsed.filter((item) => typeof item === 'string'));
90
+ }
91
+ }
92
+ catch {
93
+ // 落到分行解析。
94
+ }
95
+ }
96
+ const lines = candidate
97
+ .split(/\r?\n/)
98
+ .map((line) => line.trim())
99
+ .filter((line) => line.length > 0 && !/^```/.test(line));
100
+ const listLines = lines.filter((line) => /^(?:\d+[\.\)、]|[-*•])\s+/.test(line));
101
+ return uniqueThree(listLines.length > 0 ? listLines : lines);
102
+ }
103
+ const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
104
+ const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
105
+ const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
106
+ const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
107
+ const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
108
+ /**
109
+ * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
110
+ * 始终返回正好 3 条(输入全空时仍给通用三条)。
111
+ */
112
+ export function fallbackSuggestedReplies(userText, assistantText) {
113
+ const blob = `${userText}\n${assistantText}`;
114
+ if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
115
+ if (/修改内容|调整样式/.test(assistantText)) {
116
+ return uniqueThree(PPT_OFFER_FALLBACK, PPT_FALLBACK);
117
+ }
118
+ return uniqueThree(PPT_FALLBACK, GENERIC_FALLBACK);
119
+ }
120
+ if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
121
+ return uniqueThree(PLAN_FALLBACK, GENERIC_FALLBACK);
122
+ }
123
+ if (/```|单元测试|函数实现|补测试/.test(blob)) {
124
+ return uniqueThree(CODE_FALLBACK, GENERIC_FALLBACK);
125
+ }
126
+ return uniqueThree(GENERIC_FALLBACK);
127
+ }
128
+ /**
129
+ * 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
130
+ */
131
+ export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
132
+ const fallback = fallbackSuggestedReplies(userText, assistantText);
133
+ const user = clip(userText, USER_TEXT_LIMIT);
134
+ const assistant = clip(assistantText, ASSISTANT_TEXT_LIMIT);
135
+ if (!user && !assistant) {
136
+ return { suggestions: fallback, usedFallback: true };
137
+ }
138
+ const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
139
+ try {
140
+ const result = await withTimeout(llmService.generate({
141
+ messages: [
142
+ { role: 'system', content: SYSTEM_PROMPT },
143
+ {
144
+ role: 'user',
145
+ content: `用户请求:\n${user || '(空)'}\n\n助手回复:\n${assistant || '(空)'}`,
146
+ },
147
+ ],
148
+ temperature: 0.6,
149
+ }), perAttemptTimeoutMs, 'ai-suggestions');
150
+ const parsed = parseSuggestedReplies(result.content ?? '');
151
+ const suggestions = uniqueThree(parsed, fallback);
152
+ if (suggestions.length === SUGGESTED_REPLY_COUNT && parsed.length > 0) {
153
+ return { suggestions, usedFallback: false };
154
+ }
155
+ }
156
+ catch (err) {
157
+ console.warn('[ai-suggestions] LLM 追问建议失败,用启发式兜底', err);
158
+ }
159
+ return { suggestions: fallback, usedFallback: true };
160
+ }
@@ -99,7 +99,7 @@ export async function streamCoreLoopTurn(options) {
99
99
  streamRawChunks: options.streamRawChunks,
100
100
  // W8: advertise the sidecar-hosted ask_user tool; the desktop answers
101
101
  // `ask_user.request` reverse calls with the renderer question card
102
- // (AskUserModalHost). Under toolsViaHost the sidecar intercepts
102
+ // (AskUserPromptProvider). Under toolsViaHost the sidecar intercepts
103
103
  // ask_user locally, so it never reaches the host's tool.invoke.
104
104
  // 4.6a: background task streams opt out (options.askUser === false).
105
105
  askUser: options.askUser ?? true,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 对话正文里提到的文件路径的存在性解析(「点击打开正文里的路径」用)。
3
+ *
4
+ * 渲染层从助手回答的行内代码里挑出「看着像路径」的字面量,批量送到这里
5
+ * 落地成绝对路径并 stat 验证。只有真实存在的条目会回给渲染层——路径是
6
+ * 模型写出来的自然语言片段,猜错在所难免,靠 stat 证伪而不是靠解析可靠。
7
+ *
8
+ * 相对路径的基准目录由调用方给(绑定项目的对话用项目根,否则用 home),
9
+ * 与 exec 工具缺省 cwd 的回落规则一致,这样模型写 `./out.bin` 时指向的
10
+ * 就是它当时真正落盘的位置。
11
+ */
12
+ export interface ResolvedMentionedPath {
13
+ /** 渲染层送来的原始字面量(用于回填到对应的行内代码节点)。 */
14
+ candidate: string;
15
+ /** 落地后的绝对路径(点击打开直接用)。 */
16
+ path: string;
17
+ /** 目录会用文件管理器打开,渲染层据此换图标。 */
18
+ isDirectory: boolean;
19
+ }
20
+ /**
21
+ * 批量解析候选路径,只返回真实存在的条目。`baseDir` 是相对路径的基准
22
+ * 目录;`homeDir` 仅测试注入用,默认 os.homedir()。
23
+ */
24
+ export declare function resolveMentionedPaths(options: {
25
+ candidates: readonly string[];
26
+ baseDir: string;
27
+ homeDir?: string;
28
+ }): Promise<ResolvedMentionedPath[]>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 对话正文里提到的文件路径的存在性解析(「点击打开正文里的路径」用)。
3
+ *
4
+ * 渲染层从助手回答的行内代码里挑出「看着像路径」的字面量,批量送到这里
5
+ * 落地成绝对路径并 stat 验证。只有真实存在的条目会回给渲染层——路径是
6
+ * 模型写出来的自然语言片段,猜错在所难免,靠 stat 证伪而不是靠解析可靠。
7
+ *
8
+ * 相对路径的基准目录由调用方给(绑定项目的对话用项目根,否则用 home),
9
+ * 与 exec 工具缺省 cwd 的回落规则一致,这样模型写 `./out.bin` 时指向的
10
+ * 就是它当时真正落盘的位置。
11
+ */
12
+ import fs from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ /** 单次请求的候选上限与单条长度上限:渲染层是不可信输入,挡住刷 stat。 */
16
+ const MAX_CANDIDATES = 64;
17
+ const MAX_CANDIDATE_LENGTH = 512;
18
+ /**
19
+ * 批量解析候选路径,只返回真实存在的条目。`baseDir` 是相对路径的基准
20
+ * 目录;`homeDir` 仅测试注入用,默认 os.homedir()。
21
+ */
22
+ export async function resolveMentionedPaths(options) {
23
+ const homeDir = options.homeDir ?? os.homedir();
24
+ const out = [];
25
+ const seen = new Set();
26
+ for (const raw of options.candidates.slice(0, MAX_CANDIDATES)) {
27
+ if (typeof raw !== 'string')
28
+ continue;
29
+ const candidate = raw.trim();
30
+ if (!candidate || candidate.length > MAX_CANDIDATE_LENGTH)
31
+ continue;
32
+ // 换行/NUL 不可能是单个路径字面量,且会污染后续 stat。
33
+ if (/[\n\r\0]/.test(candidate))
34
+ continue;
35
+ if (seen.has(candidate))
36
+ continue;
37
+ seen.add(candidate);
38
+ const full = toAbsolute(candidate, options.baseDir, homeDir);
39
+ if (!full)
40
+ continue;
41
+ let stat;
42
+ try {
43
+ stat = await fs.stat(full);
44
+ }
45
+ catch {
46
+ // 不存在/无权限:模型写错或文件已被删,静默丢弃。
47
+ continue;
48
+ }
49
+ if (!stat.isFile() && !stat.isDirectory())
50
+ continue;
51
+ out.push({ candidate, path: full, isDirectory: stat.isDirectory() });
52
+ }
53
+ return out;
54
+ }
55
+ /** `~` 展开 → 绝对路径原样 → 其余按 baseDir 解析。 */
56
+ function toAbsolute(candidate, baseDir, homeDir) {
57
+ if (candidate === '~')
58
+ return homeDir;
59
+ if (candidate.startsWith('~/') || candidate.startsWith('~\\')) {
60
+ return path.resolve(path.join(homeDir, candidate.slice(2)));
61
+ }
62
+ if (path.isAbsolute(candidate))
63
+ return path.resolve(candidate);
64
+ if (!baseDir)
65
+ return null;
66
+ return path.resolve(baseDir, candidate);
67
+ }
@@ -62,6 +62,7 @@ export declare class LocalBackendRouter {
62
62
  * `accumulatedContent += parsedData.content` 一行追加渲染。
63
63
  * - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
64
64
  * - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
65
+ * - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
65
66
  * - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
66
67
  * - 结束:标准 SSE `[DONE]` 串。
67
68
  */
@@ -158,6 +159,13 @@ export declare class LocalBackendRouter {
158
159
  * the reverse-channel handler registered in main.ts.
159
160
  */
160
161
  private handleCoreLoopTurn;
162
+ /**
163
+ * 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
164
+ * 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
165
+ * 之后渲染端已经不再监听这条流。
166
+ */
167
+ private runSuggestedRepliesInBackground;
168
+ private publishSuggestedReplies;
161
169
  /**
162
170
  * 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
163
171
  * 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { getAppRootDir } from '../runtime.js';
5
+ import { getAppRootDir, shellOpenPath } from '../runtime.js';
6
6
  import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
8
  import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
@@ -26,6 +26,7 @@ import { parseUserMessageTriggers } from './message-triggers.js';
26
26
  import { findSkill, loadSkills, classifySkillOrigin, listSkillRoots } from './skill-loader.js';
27
27
  import { installSkillFromDirectory } from './skill-install.js';
28
28
  import { generateChatTitle } from './ai-title.js';
29
+ import { fallbackSuggestedReplies, generateSuggestedReplies, } from './ai-suggestions.js';
29
30
  import { detectDeferredExecution } from './deferred-detector.js';
30
31
  import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js';
31
32
  import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
@@ -37,6 +38,8 @@ import { loadProjectRuleFiles } from '../project-rules.js';
37
38
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
38
39
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
39
40
  import { matchPackBackendRoute } from './pack-backend-routes.js';
41
+ import { collectTurnFiles } from './turn-files.js';
42
+ import { resolveMentionedPaths } from './mentioned-paths.js';
40
43
  /**
41
44
  * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
42
45
  * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
@@ -1642,6 +1645,37 @@ export class LocalBackendRouter {
1642
1645
  },
1643
1646
  };
1644
1647
  }
1648
+ // 回合产物列表的「点击打开」:用系统默认应用打开本地路径(与 agent 的
1649
+ // local_open_path 工具同一宿主能力,这里给渲染层一个 HTTP 入口,CS/BS
1650
+ // 两种模式都经 localBackend.request 到达)。
1651
+ if (method === 'POST' && pathname === '/api/v2/local/open-path') {
1652
+ const payload = this.toRecord(request.body);
1653
+ const target = typeof payload.path === 'string' ? payload.path.trim() : '';
1654
+ if (!target || !path.isAbsolute(target)) {
1655
+ return { status: 400, data: { detail: 'absolute path is required' } };
1656
+ }
1657
+ const openError = await shellOpenPath(target);
1658
+ if (openError) {
1659
+ return { status: 200, data: { success: false, error: openError } };
1660
+ }
1661
+ return { status: 200, data: { success: true } };
1662
+ }
1663
+ // 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
1664
+ // 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
1665
+ // 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
1666
+ if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
1667
+ const payload = this.toRecord(request.body);
1668
+ const candidates = Array.isArray(payload.candidates)
1669
+ ? payload.candidates.filter((item) => typeof item === 'string')
1670
+ : [];
1671
+ if (candidates.length === 0) {
1672
+ return { status: 200, data: { resolved: [] } };
1673
+ }
1674
+ const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
1675
+ const baseDir = (chatId ? this.resolveChatProject(chatId)?.folderPath : null) ?? os.homedir();
1676
+ const resolved = await resolveMentionedPaths({ candidates, baseDir });
1677
+ return { status: 200, data: { resolved } };
1678
+ }
1645
1679
  // ─── 场景包路由(1.2:/api/v2/<包前缀>/* 由包经 pack-backend-routes
1646
1680
  // 注册表贡献;在宿主自带路由之后、fallback 之前匹配,包不能遮蔽宿主
1647
1681
  // 路由)。 ───
@@ -1696,6 +1730,7 @@ export class LocalBackendRouter {
1696
1730
  * `accumulatedContent += parsedData.content` 一行追加渲染。
1697
1731
  * - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
1698
1732
  * - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
1733
+ * - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
1699
1734
  * - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
1700
1735
  * - 结束:标准 SSE `[DONE]` 串。
1701
1736
  */
@@ -2760,6 +2795,14 @@ export class LocalBackendRouter {
2760
2795
  const live = registerLiveStream(chatId, { executedActions, timeline, children });
2761
2796
  // 场景包的回合观察器(如识别包技能调用并广播产物更新)。
2762
2797
  const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
2798
+ // 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
2799
+ // (项目根 + 包工作区),与沙箱是否启用无关——「完整权限」下根列表只是
2800
+ // 不再被强制,产物仍大概率落在这里;根之外的显式写由工具参数并集补。
2801
+ const turnStartedAtMs = Date.now();
2802
+ const turnFileRoots = [
2803
+ ...collectPackExecWritableRoots(chatId),
2804
+ ...(chatProject ? [chatProject.folderPath] : []),
2805
+ ];
2763
2806
  try {
2764
2807
  const coreLoopOptions = {
2765
2808
  supervisor,
@@ -3012,6 +3055,20 @@ export class LocalBackendRouter {
3012
3055
  }
3013
3056
  }
3014
3057
  const durationMs = turnDurationMs(localStore.getTurnActive(chatId)?.startedAt, Date.now());
3058
+ // 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
3059
+ // 产物)。收集失败只记日志,永远不该拖垮回合收尾。
3060
+ let turnFiles = [];
3061
+ try {
3062
+ turnFiles = await collectTurnFiles({
3063
+ roots: turnFileRoots,
3064
+ sinceMs: turnStartedAtMs,
3065
+ actions: executedActions,
3066
+ projectRoot: chatProject?.folderPath ?? null,
3067
+ });
3068
+ }
3069
+ catch (turnFilesErr) {
3070
+ console.warn('[local-backend] collect turn files failed', turnFilesErr);
3071
+ }
3015
3072
  const assistant = localStore.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
3016
3073
  completionStatus,
3017
3074
  completionReason,
@@ -3022,6 +3079,7 @@ export class LocalBackendRouter {
3022
3079
  traceId: outcomeTraceId,
3023
3080
  ...(durationMs != null ? { durationMs } : {}),
3024
3081
  ...(autoContinuations > 0 ? { autoContinuations } : {}),
3082
+ ...(turnFiles.length > 0 ? { turnFiles } : {}),
3025
3083
  }));
3026
3084
  try {
3027
3085
  recordInsightTurn({
@@ -3094,11 +3152,65 @@ export class LocalBackendRouter {
3094
3152
  });
3095
3153
  }
3096
3154
  }
3155
+ // 产物文件列表赶在 message_id 之前发:前端按 message_id 把本轮队列
3156
+ // 归档到落库消息上,顺序保证产物列表随同一批次归档。
3157
+ if (turnFiles.length > 0) {
3158
+ emit(this.sseData({ type: 'turn_files', files: turnFiles }));
3159
+ }
3097
3160
  emit(this.sseData({ type: 'message_id', messageId: assistant.id }));
3098
3161
  emit('data: [DONE]\n\n');
3099
3162
  this.runTitleGenInBackground(chatId, shouldGenerateTitle, firstUserMessageForTitle);
3163
+ this.runSuggestedRepliesInBackground({
3164
+ chatId,
3165
+ messageId: assistant.id,
3166
+ userText: firstUserMessageForTitle,
3167
+ assistantText,
3168
+ completionStatus,
3169
+ });
3100
3170
  return { status: 200 };
3101
3171
  }
3172
+ /**
3173
+ * 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
3174
+ * 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
3175
+ * 之后渲染端已经不再监听这条流。
3176
+ */
3177
+ runSuggestedRepliesInBackground(args) {
3178
+ const { chatId, messageId, userText, assistantText, completionStatus } = args;
3179
+ if (completionStatus === 'cancelled' || completionStatus === 'failed')
3180
+ return;
3181
+ if (!assistantText.trim())
3182
+ return;
3183
+ const fallback = fallbackSuggestedReplies(userText, assistantText);
3184
+ this.publishSuggestedReplies(chatId, messageId, fallback);
3185
+ void (async () => {
3186
+ try {
3187
+ const result = await generateSuggestedReplies(userText, assistantText, {
3188
+ perAttemptTimeoutMs: 60_000,
3189
+ });
3190
+ if (result.usedFallback)
3191
+ return;
3192
+ const same = result.suggestions.length === fallback.length &&
3193
+ result.suggestions.every((item, i) => item === fallback[i]);
3194
+ if (same)
3195
+ return;
3196
+ this.publishSuggestedReplies(chatId, messageId, result.suggestions);
3197
+ }
3198
+ catch (err) {
3199
+ console.warn('[local-backend] suggested-replies failed', { chatId, err });
3200
+ }
3201
+ })();
3202
+ }
3203
+ publishSuggestedReplies(chatId, messageId, suggestions) {
3204
+ if (suggestions.length === 0)
3205
+ return;
3206
+ try {
3207
+ localStore.patchMessageMetadata(chatId, messageId, { suggestedReplies: suggestions });
3208
+ }
3209
+ catch (err) {
3210
+ console.warn('[local-backend] suggested-replies persist failed', { chatId, messageId, err });
3211
+ }
3212
+ this.broadcast?.('suggested-replies', { chatId, messageId, suggestions });
3213
+ }
3102
3214
  /**
3103
3215
  * 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
3104
3216
  * 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 回合产物文件收集(「本轮写了哪些文件」列表)。
3
+ *
4
+ * 四个来源取并集:
5
+ *
6
+ * 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
7
+ * 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
8
+ * 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
9
+ * 工具参数解析。
10
+ * 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
11
+ * 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
12
+ * 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
13
+ * local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
14
+ * 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
15
+ * exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
16
+ * 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
17
+ * 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
18
+ * 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
19
+ *
20
+ * 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
21
+ * 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
22
+ * stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
23
+ * 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
24
+ *
25
+ * 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
26
+ * Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
27
+ * 的准确性不依赖 birthtime。
28
+ *
29
+ * 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
30
+ * 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
31
+ */
32
+ export interface TurnFile {
33
+ /** 绝对路径(点击打开直接用)。 */
34
+ path: string;
35
+ /** created = 本轮新建;modified = 已有文件被改动。 */
36
+ kind: 'created' | 'modified';
37
+ /** 字节数(展示用)。 */
38
+ size: number;
39
+ }
40
+ /** 扫描入参里只需要工具行动的这几个字段(与 router 的 executedActions 行结构对齐)。 */
41
+ export interface TurnFileAction {
42
+ tool?: unknown;
43
+ arguments?: unknown;
44
+ success?: unknown;
45
+ }
46
+ /**
47
+ * 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
48
+ * 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
49
+ * `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
50
+ */
51
+ export declare function collectTurnFiles(options: {
52
+ roots: string[];
53
+ sinceMs: number;
54
+ actions?: readonly TurnFileAction[];
55
+ projectRoot?: string | null;
56
+ homeDir?: string;
57
+ }): Promise<TurnFile[]>;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * 回合产物文件收集(「本轮写了哪些文件」列表)。
3
+ *
4
+ * 四个来源取并集:
5
+ *
6
+ * 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
7
+ * 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
8
+ * 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
9
+ * 工具参数解析。
10
+ * 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
11
+ * 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
12
+ * 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
13
+ * local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
14
+ * 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
15
+ * exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
16
+ * 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
17
+ * 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
18
+ * 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
19
+ *
20
+ * 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
21
+ * 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
22
+ * stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
23
+ * 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
24
+ *
25
+ * 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
26
+ * Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
27
+ * 的准确性不依赖 birthtime。
28
+ *
29
+ * 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
30
+ * 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
31
+ */
32
+ import fs from 'node:fs/promises';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ /** 这些目录要么体量巨大(node_modules/target),要么是框架内部状态(.steerable),扫了只有噪音。 */
36
+ const IGNORED_DIR_NAMES = new Set([
37
+ 'node_modules',
38
+ '.git',
39
+ '.hg',
40
+ '.svn',
41
+ '__pycache__',
42
+ '.venv',
43
+ 'venv',
44
+ '.tox',
45
+ '.next',
46
+ '.nuxt',
47
+ '.cache',
48
+ 'target',
49
+ '.steerable',
50
+ ]);
51
+ const IGNORED_FILE_NAMES = new Set(['.DS_Store']);
52
+ /** 单次扫描的遍历上限:深度 10、条目 10 万、结果 100 条。 */
53
+ const MAX_DEPTH = 10;
54
+ const MAX_VISITED = 100_000;
55
+ const MAX_FILES = 100;
56
+ /** 显式写工具:参数里的 path 并入产物列表(覆盖可写根之外的写入)。 */
57
+ const WRITE_TOOL_NAMES = new Set(['local_write_file', 'local_edit_file']);
58
+ /** exec 类工具:cwd 浅扫描与命令文本路径字面量的来源。 */
59
+ const EXEC_TOOL_NAMES = new Set(['local_exec_shell', 'local_run_snippet']);
60
+ /** 每条命令/代码文本最多提取的路径字面量个数(防超长命令刷 stat)。 */
61
+ const MAX_PATH_LITERALS_PER_ACTION = 50;
62
+ /**
63
+ * Linux overlay/tmpfs 常把 mtime/birthtime 截到 1s。回合开始后立刻写出的
64
+ * 产物会 stat 成略早于 sinceMs,被水位线丢掉。1s 容差远小于一轮对话,
65
+ * 只影响展示列表,允许把回合前 1s 内的文件算进来。
66
+ */
67
+ const TOUCHED_SLACK_MS = 1000;
68
+ /**
69
+ * 引号包裹的绝对路径字面量:python 代码里的 doc.save('/x/a.pdf')、shell
70
+ * 里的 "/x/a.pdf"。要求以 /、~/ 或 Windows 盘符开头、带扩展名结尾。
71
+ * 贪婪匹配到闭引号再回溯到最后一个点——目录名里带点(.venv/python3.11)
72
+ * 不会把路径截断在中间。
73
+ */
74
+ const QUOTED_PATH_PATTERN = /['"]((?:~\/|\/|[A-Za-z]:[\\/])[^'"]{1,300}\.[A-Za-z0-9]{1,10})['"]/g;
75
+ /**
76
+ * 裸绝对路径字面量(无引号、无空格):shell 重定向/参数里常见的写法。
77
+ * 排除引号/空白/shell 元字符;URL 的 // 开头被 (?!\/) 挡掉;同样贪婪到
78
+ * token 尾再回溯到最后一个点,(?![A-Za-z0-9]) 保证扩展名不被截断。
79
+ */
80
+ const BARE_PATH_PATTERN = /(?:~\/|\/(?!\/))[^\s"'`<>|;,()[\]{}\\*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])|[A-Za-z]:\\[^\s"'`<>|;,*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])/g;
81
+ /**
82
+ * 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
83
+ * 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
84
+ * `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
85
+ */
86
+ export async function collectTurnFiles(options) {
87
+ const { roots, sinceMs, projectRoot = null } = options;
88
+ const homeDir = options.homeDir ?? os.homedir();
89
+ const byPath = new Map();
90
+ for (const root of roots) {
91
+ await scanRoot(root, sinceMs, byPath);
92
+ }
93
+ const shallowRoots = new Set();
94
+ for (const action of options.actions ?? []) {
95
+ await collectWriteToolPath(action, sinceMs, projectRoot, byPath);
96
+ const cwd = execCwdOf(action, projectRoot, homeDir);
97
+ if (cwd && !isCoveredByRoots(cwd, roots))
98
+ shallowRoots.add(cwd);
99
+ await collectPathLiterals(action, sinceMs, homeDir, byPath);
100
+ }
101
+ for (const dir of shallowRoots) {
102
+ await scanShallow(dir, sinceMs, byPath);
103
+ }
104
+ return [...byPath.values()]
105
+ .sort((a, b) => a.path.localeCompare(b.path))
106
+ .slice(0, MAX_FILES);
107
+ }
108
+ async function scanRoot(root, sinceMs, out) {
109
+ const state = { visited: 0 };
110
+ await walk(path.resolve(root), sinceMs, 0, state, out);
111
+ }
112
+ async function walk(dir, sinceMs, depth, state, out) {
113
+ if (depth > MAX_DEPTH || state.visited >= MAX_VISITED)
114
+ return;
115
+ let entries;
116
+ try {
117
+ entries = await fs.readdir(dir, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ // 根目录被删/无权限:跳过该根,不影响其他根。
121
+ return;
122
+ }
123
+ for (const entry of entries) {
124
+ if (state.visited >= MAX_VISITED)
125
+ return;
126
+ state.visited += 1;
127
+ const full = path.join(dir, entry.name);
128
+ // 不跟随符号链接:避免链接环把扫描拖出根外。
129
+ if (entry.isSymbolicLink())
130
+ continue;
131
+ if (entry.isDirectory()) {
132
+ if (IGNORED_DIR_NAMES.has(entry.name))
133
+ continue;
134
+ await walk(full, sinceMs, depth + 1, state, out);
135
+ continue;
136
+ }
137
+ if (!entry.isFile() || IGNORED_FILE_NAMES.has(entry.name))
138
+ continue;
139
+ const file = await statTurnFile(full, sinceMs);
140
+ if (file)
141
+ out.set(full, file);
142
+ }
143
+ }
144
+ /** stat 一个文件,命中「本轮触碰过」则返回 TurnFile,否则 null。 */
145
+ async function statTurnFile(full, sinceMs) {
146
+ let stat;
147
+ try {
148
+ stat = await fs.stat(full);
149
+ }
150
+ catch {
151
+ // 扫描窗口内被删掉的文件直接略过。
152
+ return null;
153
+ }
154
+ // 路径字面量可能指到目录(如 xxx.app 包);产物列表只收文件。
155
+ if (!stat.isFile())
156
+ return null;
157
+ const touched = stat.mtimeMs + TOUCHED_SLACK_MS >= sinceMs ||
158
+ stat.birthtimeMs + TOUCHED_SLACK_MS >= sinceMs;
159
+ if (!touched)
160
+ return null;
161
+ return { path: full, kind: kindOf(stat, sinceMs), size: stat.size };
162
+ }
163
+ function kindOf(stat, sinceMs) {
164
+ // birthtime 不可用的文件系统返回 0/负值——此时无法区分新建与修改,
165
+ // 统一标 modified(标签降级,不漏文件)。
166
+ return stat.birthtimeMs >= sinceMs ? 'created' : 'modified';
167
+ }
168
+ async function collectWriteToolPath(action, sinceMs, projectRoot, out) {
169
+ if (typeof action.tool !== 'string' || !WRITE_TOOL_NAMES.has(action.tool))
170
+ return;
171
+ // success === false 的调用没写成;undefined(流中未落定)按成功处理——
172
+ // 文件是否真存在由下面的 stat 把关。
173
+ if (action.success === false)
174
+ return;
175
+ const args = action.arguments;
176
+ if (!args || typeof args !== 'object')
177
+ return;
178
+ const raw = args.path;
179
+ if (typeof raw !== 'string' || !raw.trim())
180
+ return;
181
+ const full = path.isAbsolute(raw)
182
+ ? path.normalize(raw)
183
+ : projectRoot
184
+ ? path.resolve(projectRoot, raw)
185
+ : null;
186
+ if (!full || out.has(full))
187
+ return;
188
+ const file = await statTurnFile(full, sinceMs);
189
+ if (file)
190
+ out.set(full, file);
191
+ }
192
+ /**
193
+ * exec 行动的实际工作目录(与 tool-router / local-executor 的解析规则对齐):
194
+ * 显式 cwd 展开 ~ 后按绝对/相对解析(相对在项目模式下按项目根、否则按
195
+ * 进程 cwd);缺省 cwd 回落到项目根,无项目时回落到 home——无项目对话里
196
+ * 脚本产物最常见的落点。
197
+ */
198
+ function execCwdOf(action, projectRoot, homeDir) {
199
+ if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
200
+ return null;
201
+ const args = action.arguments;
202
+ if (!args || typeof args !== 'object')
203
+ return null;
204
+ const raw = args.cwd;
205
+ const cwdArg = typeof raw === 'string' ? raw.trim() : '';
206
+ if (!cwdArg)
207
+ return projectRoot ?? homeDir;
208
+ const expanded = cwdArg.startsWith('~') ? path.join(homeDir, cwdArg.slice(1)) : cwdArg;
209
+ if (path.isAbsolute(expanded))
210
+ return path.normalize(expanded);
211
+ return projectRoot ? path.resolve(projectRoot, expanded) : path.resolve(expanded);
212
+ }
213
+ /** cwd 已落在某个递归根之内时无需再浅扫(递归扫描已覆盖且更深)。 */
214
+ function isCoveredByRoots(cwd, roots) {
215
+ return roots.some((root) => {
216
+ const rel = path.relative(path.resolve(root), cwd);
217
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
218
+ });
219
+ }
220
+ /**
221
+ * exec cwd 的浅扫描:只看顶层一层、跳过点文件。home 顶层的变化几乎全是
222
+ * 工具状态(.zsh_history 之类),点文件过滤掉它们;真正的产物文档极少
223
+ * 以点开头。浅扫不递归——home 这种根递归起来既慢又全是无关变化。
224
+ */
225
+ async function scanShallow(dir, sinceMs, out) {
226
+ let entries;
227
+ try {
228
+ entries = await fs.readdir(dir, { withFileTypes: true });
229
+ }
230
+ catch {
231
+ // cwd 被删/无权限:跳过,不影响其他来源。
232
+ return;
233
+ }
234
+ for (const entry of entries) {
235
+ if (entry.name.startsWith('.') || IGNORED_FILE_NAMES.has(entry.name))
236
+ continue;
237
+ if (entry.isSymbolicLink() || !entry.isFile())
238
+ continue;
239
+ const full = path.join(dir, entry.name);
240
+ if (out.has(full))
241
+ continue;
242
+ const file = await statTurnFile(full, sinceMs);
243
+ if (file)
244
+ out.set(full, file);
245
+ }
246
+ }
247
+ /**
248
+ * 从 exec 命令 / snippet 代码文本里提取绝对路径字面量,stat + 水位线
249
+ * 验证后并入。覆盖脚本写到与 cwd 无关的任意位置。local_run_script 的
250
+ * 脚本内容在注册表里、行动参数只有 scriptId,不在此覆盖(其 cwd 语义
251
+ * 与 exec_shell 相同,浅扫描已兜底常见落点)。
252
+ */
253
+ async function collectPathLiterals(action, sinceMs, homeDir, out) {
254
+ if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
255
+ return;
256
+ const args = action.arguments;
257
+ if (!args || typeof args !== 'object')
258
+ return;
259
+ const record = args;
260
+ const text = [record.command, record.code]
261
+ .filter((value) => typeof value === 'string')
262
+ .join('\n');
263
+ if (!text)
264
+ return;
265
+ const candidates = new Set();
266
+ for (const pattern of [QUOTED_PATH_PATTERN, BARE_PATH_PATTERN]) {
267
+ pattern.lastIndex = 0;
268
+ for (const match of text.matchAll(pattern)) {
269
+ if (candidates.size >= MAX_PATH_LITERALS_PER_ACTION)
270
+ break;
271
+ const literal = match[1] ?? match[0];
272
+ const expanded = literal.startsWith('~/')
273
+ ? path.join(homeDir, literal.slice(2))
274
+ : literal;
275
+ candidates.add(path.normalize(expanded));
276
+ }
277
+ }
278
+ for (const full of candidates) {
279
+ if (out.has(full))
280
+ continue;
281
+ const file = await statTurnFile(full, sinceMs);
282
+ if (file)
283
+ out.set(full, file);
284
+ }
285
+ }
package/dist/main.js CHANGED
@@ -64,7 +64,7 @@ const RETRY_DELAYS = [2000, 5000, 10000, 30000];
64
64
  // 导致模型退化成"假后台")。CS 的差异只剩广播(IPC)与生命周期。
65
65
  const runtime = createHostRuntime({
66
66
  broadcast: (channel, payload) => broadcastTerminalEvent(channel, payload),
67
- // LocalBackendRouter 的后台事件(目前只有 chat-title-updated)只推主窗口。
67
+ // LocalBackendRouter 的后台事件(chat-title-updated / suggested-replies)只推主窗口。
68
68
  // 没拿到 mainWindow 时静默丢——title 是 nice-to-have,不该让启动顺序影响功能。
69
69
  broadcastMain: (eventName, payload) => {
70
70
  if (!mainWindow || mainWindow.isDestroyed())
@@ -544,9 +544,11 @@ function setupIpcHandlers() {
544
544
  // validated and routed to the pending sidecar `approval.request` call;
545
545
  // unknown requestIds (late/stale answers) are dropped.
546
546
  ipcMain.handle('approval:decide', (_event, payload) => approvalBridge.decide(payload));
547
+ ipcMain.handle('approval:pending', () => approvalBridge.pending());
547
548
  // W8: the renderer's ask-user card answers here; routed to the pending
548
549
  // sidecar `ask_user.request` call (fail-open empty answers on mismatch).
549
550
  ipcMain.handle('ask-user:answer', (_event, payload) => askUserBridge.answer(payload));
551
+ ipcMain.handle('ask-user:pending', () => askUserBridge.pending());
550
552
  ipcMain.handle('local:update-safety-config', async (_event, config) => {
551
553
  localExecutor.updateSafetyConfig(config);
552
554
  return { success: true };
package/dist/preload.cjs CHANGED
@@ -87,6 +87,25 @@ function buildPreloadApi(packContributions = {}) {
87
87
  import_electron.ipcRenderer.removeListener("chat-title-updated", handler);
88
88
  };
89
89
  },
90
+ /**
91
+ * 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
92
+ * 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
93
+ */
94
+ onSuggestedReplies: (callback) => {
95
+ const handler = (_event, payload) => {
96
+ if (typeof payload?.chatId === "string" && typeof payload?.messageId === "string" && Array.isArray(payload.suggestions) && payload.suggestions.every((item) => typeof item === "string")) {
97
+ callback({
98
+ chatId: payload.chatId,
99
+ messageId: payload.messageId,
100
+ suggestions: payload.suggestions
101
+ });
102
+ }
103
+ };
104
+ import_electron.ipcRenderer.on("suggested-replies", handler);
105
+ return () => {
106
+ import_electron.ipcRenderer.removeListener("suggested-replies", handler);
107
+ };
108
+ },
90
109
  /**
91
110
  * 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
92
111
  * 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
@@ -154,9 +173,9 @@ function buildPreloadApi(packContributions = {}) {
154
173
  },
155
174
  /**
156
175
  * W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
157
- * 请求广播到 renderer(approval:request),审批弹窗应答后走
158
- * approval:decide 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
159
- * 弹窗监听者(AgentPage 挂载的 ApprovalModalHost)。
176
+ * 请求广播到 renderer(approval:request),输入区审批菜单应答后走
177
+ * approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
178
+ * 的请求。
160
179
  */
161
180
  approval: {
162
181
  onRequest: (callback) => {
@@ -173,13 +192,16 @@ function buildPreloadApi(packContributions = {}) {
173
192
  },
174
193
  decide: async (decision) => {
175
194
  await import_electron.ipcRenderer.invoke("approval:decide", decision);
195
+ },
196
+ pending: async () => {
197
+ return await import_electron.ipcRenderer.invoke("approval:pending");
176
198
  }
177
199
  },
178
200
  /**
179
201
  * W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
180
202
  * 请求广播到 renderer(ask-user:request),问题卡片应答后走
181
- * ask-user:answer 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
182
- * 卡片监听者(AgentLayout 挂载的 AskUserModalHost)。
203
+ * ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
204
+ * 的请求。
183
205
  */
184
206
  askUser: {
185
207
  onRequest: (callback) => {
@@ -196,6 +218,9 @@ function buildPreloadApi(packContributions = {}) {
196
218
  },
197
219
  answer: async (reply) => {
198
220
  await import_electron.ipcRenderer.invoke("ask-user:answer", reply);
221
+ },
222
+ pending: async () => {
223
+ return await import_electron.ipcRenderer.invoke("ask-user:pending");
199
224
  }
200
225
  },
201
226
  local: {
@@ -380,11 +405,6 @@ function buildPreloadApi(packContributions = {}) {
380
405
  const fn = (_event, session) => callback(session);
381
406
  import_electron.ipcRenderer.on("terminal:spawned", fn);
382
407
  return () => import_electron.ipcRenderer.removeListener("terminal:spawned", fn);
383
- },
384
- onReveal: (callback) => {
385
- const fn = (_event, payload) => callback(payload);
386
- import_electron.ipcRenderer.on("terminal:reveal", fn);
387
- return () => import_electron.ipcRenderer.removeListener("terminal:reveal", fn);
388
408
  }
389
409
  }
390
410
  };
package/dist/preload.js CHANGED
@@ -103,6 +103,28 @@ export function buildPreloadApi(packContributions = {}) {
103
103
  ipcRenderer.removeListener('chat-title-updated', handler);
104
104
  };
105
105
  },
106
+ /**
107
+ * 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
108
+ * 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
109
+ */
110
+ onSuggestedReplies: (callback) => {
111
+ const handler = (_event, payload) => {
112
+ if (typeof payload?.chatId === 'string' &&
113
+ typeof payload?.messageId === 'string' &&
114
+ Array.isArray(payload.suggestions) &&
115
+ payload.suggestions.every((item) => typeof item === 'string')) {
116
+ callback({
117
+ chatId: payload.chatId,
118
+ messageId: payload.messageId,
119
+ suggestions: payload.suggestions,
120
+ });
121
+ }
122
+ };
123
+ ipcRenderer.on('suggested-replies', handler);
124
+ return () => {
125
+ ipcRenderer.removeListener('suggested-replies', handler);
126
+ };
127
+ },
106
128
  /**
107
129
  * 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
108
130
  * 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
@@ -171,9 +193,9 @@ export function buildPreloadApi(packContributions = {}) {
171
193
  },
172
194
  /**
173
195
  * W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
174
- * 请求广播到 renderer(approval:request),审批弹窗应答后走
175
- * approval:decide 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
176
- * 弹窗监听者(AgentPage 挂载的 ApprovalModalHost)。
196
+ * 请求广播到 renderer(approval:request),输入区审批菜单应答后走
197
+ * approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
198
+ * 的请求。
177
199
  */
178
200
  approval: {
179
201
  onRequest: (callback) => {
@@ -191,12 +213,15 @@ export function buildPreloadApi(packContributions = {}) {
191
213
  decide: async (decision) => {
192
214
  await ipcRenderer.invoke('approval:decide', decision);
193
215
  },
216
+ pending: async () => {
217
+ return await ipcRenderer.invoke('approval:pending');
218
+ },
194
219
  },
195
220
  /**
196
221
  * W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
197
222
  * 请求广播到 renderer(ask-user:request),问题卡片应答后走
198
- * ask-user:answer 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
199
- * 卡片监听者(AgentLayout 挂载的 AskUserModalHost)。
223
+ * ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
224
+ * 的请求。
200
225
  */
201
226
  askUser: {
202
227
  onRequest: (callback) => {
@@ -214,6 +239,9 @@ export function buildPreloadApi(packContributions = {}) {
214
239
  answer: async (reply) => {
215
240
  await ipcRenderer.invoke('ask-user:answer', reply);
216
241
  },
242
+ pending: async () => {
243
+ return await ipcRenderer.invoke('ask-user:pending');
244
+ },
217
245
  },
218
246
  local: {
219
247
  selectDirectory: async (options) => {
@@ -398,11 +426,6 @@ export function buildPreloadApi(packContributions = {}) {
398
426
  ipcRenderer.on('terminal:spawned', fn);
399
427
  return () => ipcRenderer.removeListener('terminal:spawned', fn);
400
428
  },
401
- onReveal: (callback) => {
402
- const fn = (_event, payload) => callback(payload);
403
- ipcRenderer.on('terminal:reveal', fn);
404
- return () => ipcRenderer.removeListener('terminal:reveal', fn);
405
- },
406
429
  },
407
430
  };
408
431
  contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...packContributions });
@@ -230,11 +230,19 @@ export function createBsServer(deps) {
230
230
  sendJson(res, 200, await approvalBridge.decide(body));
231
231
  return;
232
232
  }
233
+ if (method === 'GET' && pathname === '/host/approval/pending') {
234
+ sendJson(res, 200, approvalBridge.pending());
235
+ return;
236
+ }
233
237
  // ─── ask_user(对齐 ask-user:answer IPC) ───
234
238
  if (method === 'POST' && pathname === '/host/ask-user/answer') {
235
239
  sendJson(res, 200, askUserBridge.answer(body));
236
240
  return;
237
241
  }
242
+ if (method === 'GET' && pathname === '/host/ask-user/pending') {
243
+ sendJson(res, 200, askUserBridge.pending());
244
+ return;
245
+ }
238
246
  // ─── local(对齐 local:* IPC) ───
239
247
  if (method === 'POST' && pathname === '/host/local/exec-shell') {
240
248
  const request = body;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * BS 模式的 SSE 事件总线:替代 Electron 的 `webContents.send(channel, payload)`
3
3
  * 广播。所有宿主事件(terminal:data、approval:request、chat-title-updated、
4
- * 场景包事件……)经这一条 `GET /api/v2/events` 长连接推给浏览器,
4
+ * suggested-replies、场景包事件……)经这一条 `GET /api/v2/events` 长连接推给浏览器,
5
5
  * channel 名与 IPC 通道保持一致,前端 http-bridge 按名分发。
6
6
  */
7
7
  import type { ServerResponse } from 'node:http';
@@ -43,6 +43,8 @@ export interface ApprovalBridgeDeps {
43
43
  export interface ApprovalBridge {
44
44
  /** Reverse-channel handler for the sidecar's `approval.request`. */
45
45
  handler: SidecarReverseHandler;
46
+ /** Requests still awaiting a renderer decision, used after page refresh. */
47
+ pending: () => ApprovalPromptRequest[];
46
48
  /**
47
49
  * IPC entry point for the renderer's answer (`ipcMain.handle(
48
50
  * 'approval:decide')`). Unknown requestIds are dropped (late answer to an
@@ -54,7 +54,7 @@ export function createApprovalBridge(deps) {
54
54
  };
55
55
  deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
56
56
  return await new Promise((resolve) => {
57
- pending.set(requestId, { resolve });
57
+ pending.set(requestId, { prompt, resolve });
58
58
  try {
59
59
  deps.broadcast('approval:request', prompt);
60
60
  }
@@ -64,6 +64,7 @@ export function createApprovalBridge(deps) {
64
64
  }
65
65
  });
66
66
  },
67
+ pending: () => Array.from(pending.values(), ({ prompt }) => prompt),
67
68
  decide: (payload) => {
68
69
  const p = (payload ?? {});
69
70
  const requestId = typeof p.requestId === 'string' ? p.requestId : '';
@@ -26,6 +26,8 @@ export interface AskUserBridgeDeps {
26
26
  export interface AskUserBridge {
27
27
  /** 注册为 sidecar 的 `ask_user.request` 反向方法处理器。 */
28
28
  handler: SidecarReverseHandler;
29
+ /** 返回仍在等待 renderer 应答的请求,供刷新后的页面恢复提问。 */
30
+ pending: () => AskUserPromptRequest[];
29
31
  /** renderer `ask-user:answer` 的入口;返回是否匹配到待应答请求。 */
30
32
  answer: (payload: unknown) => {
31
33
  ok: boolean;
@@ -32,7 +32,7 @@ export function createAskUserBridge(deps) {
32
32
  questions: questions,
33
33
  };
34
34
  return new Promise((resolve) => {
35
- pending.set(requestId, { resolve });
35
+ pending.set(requestId, { prompt, resolve });
36
36
  try {
37
37
  deps.broadcast('ask-user:request', prompt);
38
38
  }
@@ -43,6 +43,7 @@ export function createAskUserBridge(deps) {
43
43
  }
44
44
  });
45
45
  },
46
+ pending: () => Array.from(pending.values(), ({ prompt }) => prompt),
46
47
  answer: (payload) => {
47
48
  const p = (payload ?? {});
48
49
  const entry = typeof p.requestId === 'string' ? pending.get(p.requestId) : undefined;
@@ -239,6 +239,12 @@ export declare class LocalStore {
239
239
  */
240
240
  deleteMessagesFrom(chatId: string, messageId: string): number;
241
241
  addMessage(chatId: string, role: ChatMessageRecord['role'], content: string, messageMetadata?: string | null): ChatMessageRecord;
242
+ /**
243
+ * 浅合并助手消息的 JSON metadata(例如回合结束后写入 suggestedReplies)。
244
+ * 不碰 updated_at:这类补丁不是用户可见的会话活动。
245
+ * metadata 损坏或非对象时从空对象开始合。
246
+ */
247
+ patchMessageMetadata(chatId: string, messageId: string, patch: Record<string, unknown>): ChatMessageRecord | null;
242
248
  /**
243
249
  * W1.2.1: replace the chat's whole message list with a branch projection.
244
250
  * The desktop store is the UI projection of the ACTIVE framework record —
@@ -669,6 +669,33 @@ export class LocalStore {
669
669
  const row = this.db.prepare(`SELECT * FROM chat_messages WHERE id = ?`).get(id);
670
670
  return this.mapChatMessage(row);
671
671
  }
672
+ /**
673
+ * 浅合并助手消息的 JSON metadata(例如回合结束后写入 suggestedReplies)。
674
+ * 不碰 updated_at:这类补丁不是用户可见的会话活动。
675
+ * metadata 损坏或非对象时从空对象开始合。
676
+ */
677
+ patchMessageMetadata(chatId, messageId, patch) {
678
+ const msg = this.getMessage(chatId, messageId);
679
+ if (!msg)
680
+ return null;
681
+ let current = {};
682
+ if (msg.messageMetadata) {
683
+ try {
684
+ const parsed = JSON.parse(msg.messageMetadata);
685
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
686
+ current = parsed;
687
+ }
688
+ }
689
+ catch {
690
+ current = {};
691
+ }
692
+ }
693
+ const next = JSON.stringify({ ...current, ...patch });
694
+ this.db
695
+ .prepare(`UPDATE chat_messages SET message_metadata = ? WHERE id = ? AND chat_id = ?`)
696
+ .run(next, messageId, chatId);
697
+ return this.getMessage(chatId, messageId);
698
+ }
672
699
  /**
673
700
  * W1.2.1: replace the chat's whole message list with a branch projection.
674
701
  * The desktop store is the UI projection of the ACTIVE framework record —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -41,9 +41,9 @@
41
41
  "electron-store": "^8.1.0",
42
42
  "he": "^1.2.0",
43
43
  "node-pty": "^1.1.0",
44
- "@steerable/agent-harness": "0.6.19",
45
- "@steerable/agent-protocol": "0.6.19",
46
- "@steerable/pack-sdk": "0.6.19"
44
+ "@steerable/agent-harness": "0.6.21",
45
+ "@steerable/agent-protocol": "0.6.21",
46
+ "@steerable/pack-sdk": "0.6.21"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/better-sqlite3": "^7.6.13",