pi-web-ui 0.66.0 → 0.67.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.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
14
  import { randomUUID } from "node:crypto";
15
+ import { createRequire } from "node:module";
15
16
  import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
16
17
  import { basename, dirname, join, resolve, sep } from "node:path";
17
18
  import { fileURLToPath } from "node:url";
@@ -31,8 +32,10 @@ import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./cli
31
32
  import { SubagentTemplatesStore } from "./subagent-templates.js";
32
33
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
33
34
  import { WebUIContext, mockThemeProxy } from "./webui-context.js";
35
+ import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
34
36
  import { makeSubagentTools, subagentTitle, } from "./subagents.js";
35
37
  import { buildAttachmentMessages } from "./attachments.js";
38
+ import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
36
39
  import { serializeMessage, serializeStreamingMessage } from "./serialize.js";
37
40
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
38
41
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -77,6 +80,20 @@ export class QuiesceRejectedError extends Error {
77
80
  // /api/file HTTP endpoint instead of the WebSocket, so they are classified
78
81
  // here but never read into the snapshot path.
79
82
  // ---------------------------------------------------------------------------
83
+ /** 自家内联扩展名(组合模板渲染,见 prompt-composer.ts)。SDK 以其
84
+ * "<inline:<name>>" 作为 path;扩展白名单/禁用过滤必须放行它。 */
85
+ const INLINE_PERSONA_EXT = "<inline:pi-webui-persona>";
86
+ /** Pi 包文档路径(composer 的 {{pi_docs}} 自动内容用)。随安装位置解析一次。 */
87
+ const PI_DOC_PATHS = (() => {
88
+ try {
89
+ const requireLocal = createRequire(import.meta.url);
90
+ const root = dirname(requireLocal.resolve("@earendil-works/pi-coding-agent/package.json"));
91
+ return { readme: join(root, "README.md"), docs: join(root, "docs"), examples: join(root, "examples") };
92
+ }
93
+ catch {
94
+ return { readme: "", docs: "", examples: "" };
95
+ }
96
+ })();
80
97
  /** Windows persona appendix — appended to the SDK system prompt on win32 only.
81
98
  * Two failure modes it guards against: (1) the SDK bash tool has NO default
82
99
  * timeout, so a long-running command hangs the whole conversation forever;
@@ -93,11 +110,6 @@ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash too
93
110
  - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
94
111
 
95
112
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
96
- /** bash 工具输出限制/过滤管道引导:模型习惯套 `| tail/-n`、`| head`、`| grep`、`| less`
97
- * 等限输出。这些管道在持久终端里会①缓冲(可见终端全程哑火、看不到实时进度)②把退出码
98
- * 错报成管道末尾命令(tail 恒 0、grep 无命中恒 1,灾难性掩盖真实失败)③长驻/出错命令会挂到超时。
99
- * 让模型改用 bash 的 `tail` 参数限输出;长驻/交互任务改走持久终端工具。 */
100
- const PIPELESS_BASH_GUIDANCE = `Bash tool output-limiting/filtering: do NOT chain shell pipes to trim or filter output. Avoid \`| tail\`, \`| head\`, \`| grep\`, \`| less\`, \`| more\`, \`| cat\`, \`| sort\`, \`| awk\`, \`| sed\`. They buffer output (so the visible terminal shows nothing live), turn the real exit code into the last pipe command's (tail always 0, grep 1 when no match — hiding the actual failure), and can hang a long-running or failing command until timeout. Instead:\n- To limit returned output use the bash \`tail\` parameter (e.g. \`bash(command=..., tail=20)\`) — the underlying command still streams live to the visible terminal.\n- For a long-running server / watcher / interactive program, use the persistent terminal tools (terminal_create then terminal_read / terminal_input / terminal_key / terminal_wait) instead of piping through bash.\nThe bash tool auto-detects a trailing \`| tail\`/\`| grep\` etc. and runs the underlying command directly so it never hides a failure — but you should still prefer the \`tail\` parameter.`;
101
113
  /**
102
114
  * Killable bash tool: wraps the SDK bash tool (native process spawn, NO terminal).
103
115
  * Used when the「默认 bash 覆盖」setting is OFF. Registers its own AbortController
@@ -184,20 +196,15 @@ function makeMarkersListTool(getActiveId, markerSvc) {
184
196
  return {
185
197
  name: "markers_list",
186
198
  label: "List marker state",
187
- description: "只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] / [[svc:add:...]] 等)写在回答正文里,不要调用本工具做写操作。",
199
+ description: "只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
188
200
  parameters: Type.Object({
189
201
  action: Type.Unsafe({ enum: ["list"] }),
190
- tool: Type.Optional(Type.Union([Type.Literal("todo"), Type.Literal("svc")], { description: "查询哪个命名空间(默认 todo)" })),
202
+ tool: Type.Optional(Type.Literal("todo")),
191
203
  includeDeleted: Type.Optional(Type.Boolean({ description: "是否包含已删除任务(tombstone,仅 todo)" })),
192
204
  }),
193
205
  execute: async (_id, params) => {
194
206
  const p = params;
195
- const which = p.tool ?? "todo";
196
207
  const convId = getActiveId();
197
- if (which === "svc") {
198
- const text = markerSvc.describe(convId, "svc", false);
199
- return { content: [{ type: "text", text }], details: { action: "list", tool: "svc" } };
200
- }
201
208
  const text = markerSvc.describe(convId, "todo", !!p.includeDeleted);
202
209
  const state = markerSvc.getRawState(convId, "todo");
203
210
  const visible = (state?.tasks ?? []).filter((t) => p.includeDeleted || t.status !== "deleted");
@@ -772,44 +779,93 @@ export class ClientSession {
772
779
  * prompt with the same images skips the vision API call entirely — editing
773
780
  * a question doesn't re-burn tokens on re-transcribing identical screenshots.
774
781
  */
775
- /** Most recent built-in (default) system prompt observed by the
776
- * resource-loader override surfaced via settings_state so the
777
- * replace-mode editor can show the prompt it would otherwise replace.
778
- * Only non-empty when the user has a system-prompt file. */
782
+ /** SYSTEM.md 文件内容(最近一次 loader reload 观察到的 base;组合模板下仅作
783
+ * {{soul}} 自动内容,SDK 默认分支不受影响)。非空 = 用户有系统提示词文件。 */
779
784
  lastBaseSystemPrompt = "";
780
- /** The system prompt the replace-mode editor should show as its seed:
781
- * the user's system-prompt file content if one exists, otherwise the
782
- * SDK's built-in default actually in effect (agent.state.systemPrompt,
783
- * which the loader rebuilds at session init). If the user HAS a custom
784
- * prompt the seed is only cosmetic — an unmodified seed is saved as
785
- * empty and the server falls back to the true base. */
786
- effectiveDefaultSystemPrompt() {
787
- if (this.lastBaseSystemPrompt)
788
- return this.lastBaseSystemPrompt;
789
- try {
790
- const sp = this.session.agent.state.systemPrompt;
791
- if (typeof sp === "string" && sp)
792
- return sp;
793
- }
794
- catch {
795
- // Session not ready yet.
796
- }
797
- return "";
785
+ /** SDK APPEND_SYSTEM.md 内容(appendSystemPromptOverride 收到的 base)——
786
+ * composer {{append}} 自动内容。仅主会话(无模板)记录。 */
787
+ lastSdkAppendFiles = [];
788
+ /** 当前活动会话的工具/资源快照 composer 输入。cwd 取活动对话的。 */
789
+ composeInputs(src) {
790
+ return {
791
+ cwd: src.cwd,
792
+ systemPromptFile: this.lastBaseSystemPrompt || undefined,
793
+ builtinSoul: BUILTIN_SOUL,
794
+ selectedTools: src.selectedTools,
795
+ toolSnippets: src.toolSnippets,
796
+ toolGuidelines: src.toolGuidelines,
797
+ piReadme: PI_DOC_PATHS.readme,
798
+ piDocs: PI_DOC_PATHS.docs,
799
+ piExamples: PI_DOC_PATHS.examples,
800
+ appendFiles: this.lastSdkAppendFiles,
801
+ windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
802
+ terminalGuidance: this.settingsSvc.current.terminalToolsEnabled !== false ? TERMINAL_TOOLS_GUIDANCE : "",
803
+ markersGuidance: this.markerSvc.buildGuidance(),
804
+ contextFiles: src.contextFiles,
805
+ skills: src.skills,
806
+ };
798
807
  }
799
- /** The FULL system prompt actually in effect right now (AgentSession getter,
800
- * includes the append/replace override + auto-appended sections like
801
- * project context, skills and tool guidance). Read-only view source for
802
- * the settings panel. */
803
- effectiveSystemPrompt() {
808
+ /** 渲染当前组合模板。模板为空且无任何覆盖时返回 undefined(用 SDK 默认拼装,
809
+ * 零开销且与原始行为逐字节一致)。 */
810
+ renderMainCompose(src) {
811
+ const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
812
+ const ovs = this.settingsSvc.current.promptOverrides ?? {};
813
+ const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
814
+ if (!tpl && !hasOverride)
815
+ return undefined;
816
+ const texts = resolveSectionTexts(this.composeInputs(src));
817
+ return renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
818
+ }
819
+ /** 从活动会话收集工具/资源快照 → 一次算出 ①各来源默认(自动)内容 ②实际生效的
820
+ * 完整提示词。会话未就绪(或出错)返回 undefined,调用方给空值。 */
821
+ sessionPromptSnapshot() {
804
822
  try {
805
- const sp = this.session.systemPrompt;
806
- return typeof sp === "string" ? sp : "";
823
+ const sess = this.session;
824
+ if (!sess)
825
+ return undefined;
826
+ const cwd = this.conv?.cwd ?? this.cwd;
827
+ const active = sess.getActiveToolNames();
828
+ const snippets = {};
829
+ const guidelines = [];
830
+ for (const name of active) {
831
+ const def = sess.getToolDefinition(name);
832
+ if (!def)
833
+ continue;
834
+ if (def.promptSnippet && def.promptSnippet.trim())
835
+ snippets[name] = def.promptSnippet;
836
+ if (def.promptGuidelines)
837
+ guidelines.push(...def.promptGuidelines);
838
+ }
839
+ const loader = sess.resourceLoader;
840
+ const texts = resolveSectionTexts(this.composeInputs({
841
+ cwd,
842
+ selectedTools: active,
843
+ toolSnippets: snippets,
844
+ toolGuidelines: guidelines,
845
+ contextFiles: loader.getAgentsFiles().agentsFiles,
846
+ skills: loader.getSkills().skills.map((s) => ({
847
+ name: s.name,
848
+ description: s.description ?? "",
849
+ filePath: s.filePath ?? "",
850
+ })),
851
+ }));
852
+ // 模板/覆盖渲染(无则保持 SDK 默认拼装,与 renderMainCompose 同规则)。
853
+ const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
854
+ const ovs = this.settingsSvc.current.promptOverrides ?? {};
855
+ const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
856
+ const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
857
+ return { texts, full: rendered ?? sess.systemPrompt };
807
858
  }
808
859
  catch {
809
860
  // Session not ready yet.
810
- return "";
861
+ return undefined;
811
862
  }
812
863
  }
864
+ /** 设置面板预览用的 host 回调(见 SettingsHost.promptSnapshot):完整生效提示词
865
+ * + 各来源默认(自动)内容。会话未就绪时给空值,面板保持可编辑但不预览。 */
866
+ promptSnapshot() {
867
+ return this.sessionPromptSnapshot() ?? { full: "", texts: {} };
868
+ }
813
869
  /** Web-facing extension UI context (widgets, notifications). */
814
870
  webUi = new WebUIContext((msg) => this.emit(msg));
815
871
  /**
@@ -939,11 +995,10 @@ export class ClientSession {
939
995
  reloadSession: async () => {
940
996
  await this.session.reload();
941
997
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
942
- this.applyTerminalToolGating(this.session);
998
+ this.applyToolGating(this.session);
943
999
  await this.pushSlashCommands();
944
1000
  },
945
- effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
946
- effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
1001
+ promptSnapshot: () => this.promptSnapshot(),
947
1002
  getMarkerState: () => ({
948
1003
  markersEnabled: this.markerSvc.current.markersEnabled,
949
1004
  disabledMarkers: [...this.markerSvc.current.disabledMarkers],
@@ -1034,36 +1089,34 @@ export class ClientSession {
1034
1089
  // 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
1035
1090
  // 新对话(新 runtime)也会自动带上当前设置。
1036
1091
  // 子代理带模板(apply)时:prompt/skills/extensions 改读模板视图——
1037
- // replace 模式整体替换为模板提示词;append 模式把模板提示词追加到
1092
+ // replace 模式:无 SYSTEM.md 时把灵魂段替换为模板提示词(见下方
1093
+ // pi-webui-persona 内联扩展);有 SYSTEM.md 时仍由 systemPromptOverride
1094
+ // 整体替换 base。append 模式把模板提示词追加到
1038
1095
  // 末尾(此时主会话的自定义 prompt 不再叠加,角色由模板定义);非空
1039
1096
  // 白名单取代主会话开关(只启用这些),空白名单 = 跟随主会话。
1040
1097
  resourceLoaderOptions: {
1041
- // 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
1098
+ // 系统提示词 base:主会话(组合模板)恒返回 undefined → SDK 走默认分支,
1099
+ // 工具列表/Guidelines/文档指引等自动段照常拼装;SYSTEM.md 内容仅在
1100
+ // 此处捕获(lastBaseSystemPrompt)作 {{soul}} 自动内容。子代理模板
1101
+ // replace 在存在 SYSTEM.md base 时整体替换该 base。
1042
1102
  systemPromptOverride: (base) => {
1043
- // Remember the built-in default so the settings panel can show
1044
- // it when the user edits in replace mode.
1045
1103
  if (typeof base === "string" && base) {
1046
1104
  this.lastBaseSystemPrompt = base;
1105
+ if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
1106
+ return apply.systemPrompt;
1107
+ }
1047
1108
  }
1048
- if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
1049
- return apply.systemPrompt;
1050
- }
1051
- return this.settingsSvc.current.promptMode === "replace" &&
1052
- this.settingsSvc.current.customSystemPrompt.trim()
1053
- ? this.settingsSvc.current.customSystemPrompt
1054
- : base;
1109
+ return undefined;
1055
1110
  },
1056
1111
  appendSystemPromptOverride: (base) => {
1112
+ // 记录 SDK APPEND_SYSTEM.md base(composer {{append}} 自动内容)。
1113
+ if (!apply)
1114
+ this.lastSdkAppendFiles = base.slice();
1057
1115
  const out = [...base];
1058
1116
  if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
1059
1117
  out.push(apply.systemPrompt);
1060
1118
  }
1061
- else {
1062
- const custom = this.settingsSvc.current.customSystemPrompt.trim();
1063
- if (this.settingsSvc.current.promptMode === "append" && custom) {
1064
- out.push(custom);
1065
- }
1066
- }
1119
+ // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
1067
1120
  if (process.platform === "win32") {
1068
1121
  // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
1069
1122
  // 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
@@ -1075,11 +1128,7 @@ export class ClientSession {
1075
1128
  // 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
1076
1129
  out.push(TERMINAL_TOOLS_GUIDANCE);
1077
1130
  }
1078
- // bash 工具输出限制/过滤管道引导:模型习惯套 `| tail/-n`、`| head`、
1079
- // `| grep` 等限输出。这些管道会缓冲(终端全程哑火)、把退出码错报成
1080
- // 管道末尾命令(tail 恒 0、grep 无命中 1)、长驻/出错命令挂到超时。
1081
- // 让模型改用 bash 的 tail 参数,长驻/交互改走持久终端。
1082
- out.push(PIPELESS_BASH_GUIDANCE);
1131
+ // bash 管道限制已并入 bash 工具自身的 description,不再作为独立提示段注入。
1083
1132
  // 内置标记工具引导(按总开关/分组开关过滤)
1084
1133
  const markerGuidance = this.markerSvc.buildGuidance();
1085
1134
  if (markerGuidance)
@@ -1101,15 +1150,61 @@ export class ClientSession {
1101
1150
  // 注意 SDK 在 extensionsOverride 之后才补 sourceInfo,包扩展此处只能靠路径
1102
1151
  // 匹配 —— isExtensionDisabled / isExtensionEnabled 同时比对 npm:<pkg> 候选键。
1103
1152
  extensionsOverride: (res) => {
1153
+ // 自家内联扩展(灵魂替换)是基础设施,不参与白名单/禁用过滤。
1154
+ const keepOwn = (e) => !e.path.startsWith(INLINE_PERSONA_EXT);
1104
1155
  if (apply && apply.enabledExtensions.length > 0) {
1105
1156
  const set = new Set(apply.enabledExtensions);
1106
- return { ...res, extensions: res.extensions.filter((e) => isExtensionEnabled(e, [...set])) };
1157
+ return {
1158
+ ...res,
1159
+ extensions: res.extensions.filter((e) => keepOwn(e) || isExtensionEnabled(e, [...set])),
1160
+ };
1107
1161
  }
1108
1162
  return {
1109
1163
  ...res,
1110
- extensions: res.extensions.filter((e) => !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
1164
+ extensions: res.extensions.filter((e) => keepOwn(e) || !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
1111
1165
  };
1112
1166
  },
1167
+ // 组合模板渲染(主会话)+ 模板灵魂替换(子代理):before_agent_start 在每个
1168
+ // agent run 前触发,SDK 此时已用最新工具/资源拼好基础提示词;若配置了模板或
1169
+ // 覆盖,则用 composer 把 {{token}} 展开为各来源文本(工具列表/项目上下文/技能
1170
+ // 等都取自本次 run 的 systemPromptOptions,永远最新)。
1171
+ extensionFactories: [
1172
+ {
1173
+ name: "pi-webui-persona",
1174
+ hidden: true,
1175
+ factory: (pi) => {
1176
+ pi.on("before_agent_start", (event) => {
1177
+ // 子代理模板 replace(无 SYSTEM.md 时):默认分支拼好的提示词里
1178
+ // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1179
+ // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1180
+ if (apply) {
1181
+ if (apply.promptMode !== "replace" || !apply.systemPrompt.trim())
1182
+ return undefined;
1183
+ const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
1184
+ if (boundary === -1)
1185
+ return undefined;
1186
+ const swapped = apply.systemPrompt.trimEnd() + event.systemPrompt.slice(boundary);
1187
+ return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1188
+ }
1189
+ // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
1190
+ const opts = event.systemPromptOptions;
1191
+ const rendered = this.renderMainCompose({
1192
+ cwd: typeof opts?.cwd === "string" ? opts.cwd : this.cwd,
1193
+ selectedTools: opts?.selectedTools ?? [],
1194
+ toolSnippets: opts?.toolSnippets ?? {},
1195
+ toolGuidelines: opts?.promptGuidelines ?? [],
1196
+ contextFiles: opts?.contextFiles ?? [],
1197
+ skills: (opts?.skills ?? []).map((s) => ({
1198
+ name: s.name,
1199
+ description: s.description ?? "",
1200
+ filePath: s.filePath ?? "",
1201
+ })),
1202
+ });
1203
+ return rendered ? { systemPrompt: rendered } : undefined;
1204
+ });
1205
+ },
1206
+ },
1207
+ ],
1113
1208
  },
1114
1209
  });
1115
1210
  const created = await createAgentSessionFromServices({
@@ -1130,6 +1225,8 @@ export class ClientSession {
1130
1225
  // 设置关 → 原生 bash;开 → 终端 bash。
1131
1226
  () => this.settingsSvc.current.terminalBash),
1132
1227
  ...makePersistentTerminalTools(terminals, effectiveCwd),
1228
+ // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
1229
+ makeEditSoftTool(effectiveCwd),
1133
1230
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
1134
1231
  // refreshPluginTools 动态补入已有会话)。
1135
1232
  ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
@@ -1141,7 +1238,7 @@ export class ClientSession {
1141
1238
  ],
1142
1239
  });
1143
1240
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
1144
- this.applyTerminalToolGating(created.session);
1241
+ this.applyToolGating(created.session);
1145
1242
  return {
1146
1243
  ...created,
1147
1244
  services,
@@ -1505,23 +1602,8 @@ export class ClientSession {
1505
1602
  case "agent_end": {
1506
1603
  this.scheduleSessionsRefresh();
1507
1604
  this.refreshConversationTitle(conv);
1508
- // agent_end 兜底:若 entry_appended 未触发(部分 SDK 路径),扫描最后一条 assistant 消息补处理
1509
- try {
1510
- const msgs = event.messages;
1511
- if (Array.isArray(msgs)) {
1512
- for (let i = msgs.length - 1; i >= 0; i--) {
1513
- const m = msgs[i];
1514
- if (m?.role === "assistant") {
1515
- const text = extractAssistantTextFromContent(m.content);
1516
- if (text && text.includes("[[")) {
1517
- void this.markerSvc.handleAssistantText(conv.id, text);
1518
- }
1519
- break;
1520
- }
1521
- }
1522
- }
1523
- }
1524
- catch { }
1605
+ // 内联标记不在此兜底扫最后一条 assistant:每条气泡结束已走 message_end
1606
+ // 即时解析(含中间文本块);这里再扫会把最后一条标记重复执行(todo 重复建号)。
1525
1607
  // Manual interrupt (Stop button / abort): the last assistant message
1526
1608
  // carries stopReason "aborted". A half-finished run should NOT be
1527
1609
  // reviewed (it would fail and inject a revision, only to be stopped
@@ -1570,15 +1652,21 @@ export class ClientSession {
1570
1652
  break;
1571
1653
  }
1572
1654
  case "entry_appended": {
1655
+ // SDK 仅在扩展 appendEntry 时发 entry_appended(entry 恒为 custom),
1656
+ // assistant 消息不会走这里——气泡级解析见 case "message_end"。
1573
1657
  this.scheduleSessionsRefresh();
1574
1658
  this.refreshConversationTitle(conv);
1575
- // 内置标记:assistant 终稿落库时解析执行(todo/rename 等)
1576
- const entry = event.entry;
1577
- if (entry?.type === "message" && entry.message?.role === "assistant") {
1578
- const text = extractAssistantTextFromContent(entry.message.content);
1579
- if (text)
1580
- void this.markerSvc.handleAssistantText(conv.id, text);
1581
- }
1659
+ break;
1660
+ }
1661
+ case "message_end": {
1662
+ // 每条 assistant 气泡流式结束 → 立即解析其中的内联标记:每个气泡各自
1663
+ // 生效(不再等整轮 agent_end),同一轮里先前消息的标记也不再丢。
1664
+ const mm = event.message;
1665
+ if (mm?.role !== "assistant")
1666
+ break;
1667
+ const text = extractAssistantTextFromContent(mm.content);
1668
+ if (text && text.includes("[["))
1669
+ void this.markerSvc.handleAssistantText(conv.id, text);
1582
1670
  break;
1583
1671
  }
1584
1672
  case "message_update": {
@@ -2160,7 +2248,7 @@ export class ClientSession {
2160
2248
  this.flushSnapshot();
2161
2249
  },
2162
2250
  refreshSessions: () => this.refreshSessions(),
2163
- afterReload: () => this.applyTerminalToolGating(this.session),
2251
+ afterReload: () => this.applyToolGating(this.session),
2164
2252
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
2165
2253
  execPluginCommand: async (name, args) => {
2166
2254
  const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
@@ -2371,7 +2459,7 @@ export class ClientSession {
2371
2459
  if (!this.session.isStreaming) {
2372
2460
  try {
2373
2461
  await this.session.reload();
2374
- this.applyTerminalToolGating(this.session);
2462
+ this.applyToolGating(this.session);
2375
2463
  await this.pushSlashCommands();
2376
2464
  this.pushSettings();
2377
2465
  }
@@ -2406,16 +2494,21 @@ export class ClientSession {
2406
2494
  /** 把终端工具开关应用到 session 的活跃工具集:关闭时从活跃集中剔除
2407
2495
  * terminal_*(工具仍留在注册表,重开时可直接加回)。session.reload() 与新
2408
2496
  * 会话创建都会把 custom 工具加回活跃集,所以这两条路径之后都要重放本方法。 */
2409
- applyTerminalToolGating(session) {
2497
+ applyToolGating(session) {
2410
2498
  try {
2411
- const enabled = this.settingsSvc.current.terminalToolsEnabled !== false;
2499
+ const terminalEnabled = this.settingsSvc.current.terminalToolsEnabled !== false;
2500
+ const softEditEnabled = this.settingsSvc.current.editSoftEnabled !== false;
2412
2501
  const names = new Set(session.getActiveToolNames());
2413
2502
  for (const n of TERMINAL_TOOL_NAMES) {
2414
- if (enabled)
2503
+ if (terminalEnabled)
2415
2504
  names.add(n);
2416
2505
  else
2417
2506
  names.delete(n);
2418
2507
  }
2508
+ if (softEditEnabled)
2509
+ names.add(SOFT_EDIT_TOOL_NAME);
2510
+ else
2511
+ names.delete(SOFT_EDIT_TOOL_NAME);
2419
2512
  session.setActiveToolsByName([...names]);
2420
2513
  }
2421
2514
  catch {
@@ -179,24 +179,41 @@ export class ClientStateStore {
179
179
  /** Last-used settings-panel state for a client, or defaults. */
180
180
  getSettings(clientId) {
181
181
  const s = this.load()[clientId];
182
+ const stored = s?.settings;
183
+ // 旧存档(promptMode/customSystemPrompt)迁移到 compose:追加文字成为独立
184
+ // {{append}} 覆盖、替换文字成为 {{soul}} 覆盖;无自定义则用默认模板。
185
+ let promptTemplate = "";
186
+ let promptOverrides = {};
187
+ if (stored?.promptTemplate !== undefined) {
188
+ promptTemplate = stored.promptTemplate ?? "";
189
+ promptOverrides = { ...stored?.promptOverrides };
190
+ }
191
+ else if (stored && typeof stored.customSystemPrompt === "string" && stored.customSystemPrompt.trim()) {
192
+ promptOverrides = {
193
+ [stored.promptMode === "replace" ? "soul" : "append"]: stored.customSystemPrompt,
194
+ };
195
+ }
182
196
  return {
183
- promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
184
- customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
185
- disabledSkills: s?.settings?.disabledSkills ?? [],
186
- disabledExtensions: s?.settings?.disabledExtensions ?? [],
187
- terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
188
- terminalBash: s?.settings?.terminalBash ?? false,
189
- terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
190
- thinkingWrap: s?.settings?.thinkingWrap ?? false,
191
- toolsWrap: s?.settings?.toolsWrap ?? true,
192
- visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
193
- visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
194
- visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
195
- visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
196
- subagentDefaultModel: s?.settings?.subagentDefaultModel ?? null,
197
- reviewPrompt: s?.settings?.reviewPrompt ?? "",
198
- reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
199
- disabledPlugins: s?.settings?.disabledPlugins ?? [],
197
+ promptMode: stored?.promptMode === "replace" ? "replace" : "append",
198
+ customSystemPrompt: stored?.customSystemPrompt ?? "",
199
+ promptTemplate,
200
+ promptOverrides,
201
+ disabledSkills: stored?.disabledSkills ?? [],
202
+ disabledExtensions: stored?.disabledExtensions ?? [],
203
+ terminalToolsEnabled: stored?.terminalToolsEnabled ?? true,
204
+ terminalBash: stored?.terminalBash ?? false,
205
+ terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
206
+ editSoftEnabled: stored?.editSoftEnabled ?? false,
207
+ thinkingWrap: stored?.thinkingWrap ?? false,
208
+ toolsWrap: stored?.toolsWrap ?? true,
209
+ visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
210
+ visionBridgeModel: stored?.visionBridgeModel ?? null,
211
+ visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
212
+ visionBridgePrompt: stored?.visionBridgePrompt ?? "",
213
+ subagentDefaultModel: stored?.subagentDefaultModel ?? null,
214
+ reviewPrompt: stored?.reviewPrompt ?? "",
215
+ reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
216
+ disabledPlugins: stored?.disabledPlugins ?? [],
200
217
  };
201
218
  }
202
219
  /** Persist the client's settings-panel state (partial merge). */
@@ -207,11 +224,14 @@ export class ClientStateStore {
207
224
  state.settings = {
208
225
  promptMode: settings.promptMode ?? cur.promptMode ?? "append",
209
226
  customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
227
+ promptTemplate: settings.promptTemplate ?? cur.promptTemplate ?? "",
228
+ promptOverrides: { ...(settings.promptOverrides ?? cur.promptOverrides) },
210
229
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
211
230
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
212
231
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
213
232
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
214
233
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
234
+ editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
215
235
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
216
236
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
217
237
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
@@ -107,6 +107,7 @@ const DEFAULT_SETTINGS = {
107
107
  terminalToolsEnabled: true,
108
108
  terminalBash: false,
109
109
  terminalBashIdleMs: 15_000,
110
+ editSoftEnabled: false,
110
111
  thinkingWrap: false,
111
112
  toolsWrap: true,
112
113
  disabledPlugins: [],
@@ -217,6 +218,7 @@ export class DshClientSession {
217
218
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
218
219
  terminalBash: savedSettings.terminalBash,
219
220
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
221
+ editSoftEnabled: savedSettings.editSoftEnabled,
220
222
  thinkingWrap: savedSettings.thinkingWrap,
221
223
  toolsWrap: savedSettings.toolsWrap,
222
224
  disabledPlugins: savedSettings.disabledPlugins ?? [],
@@ -2053,6 +2055,7 @@ export class DshClientSession {
2053
2055
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2054
2056
  terminalBash: this.settings.terminalBash,
2055
2057
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2058
+ editSoftEnabled: this.settings.editSoftEnabled,
2056
2059
  thinkingWrap: this.settings.thinkingWrap,
2057
2060
  toolsWrap: this.settings.toolsWrap,
2058
2061
  visionBridgeEnabled: false,
@@ -2062,8 +2065,10 @@ export class DshClientSession {
2062
2065
  reviewPrompt: this.settings.reviewPrompt,
2063
2066
  reviewDisabledSkills: [],
2064
2067
  disabledPlugins: this.settings.disabledPlugins,
2065
- defaultSystemPrompt: "",
2068
+ promptTemplate: "",
2069
+ promptOverrides: {},
2066
2070
  effectiveSystemPrompt: this.settings.customSystemPrompt,
2071
+ promptSourceDefaults: {},
2067
2072
  visionBridgeDefaultPrompt: "",
2068
2073
  visionModels: [],
2069
2074
  skills: this.skillsCache,
@@ -2095,6 +2100,8 @@ export class DshClientSession {
2095
2100
  this.settings.terminalBash = partial.terminalBash;
2096
2101
  if (partial.terminalBashIdleMs !== undefined)
2097
2102
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2103
+ if (partial.editSoftEnabled !== undefined)
2104
+ this.settings.editSoftEnabled = partial.editSoftEnabled;
2098
2105
  if (partial.thinkingWrap !== undefined)
2099
2106
  this.settings.thinkingWrap = partial.thinkingWrap;
2100
2107
  if (partial.toolsWrap !== undefined)
@@ -2112,6 +2119,7 @@ export class DshClientSession {
2112
2119
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2113
2120
  terminalBash: this.settings.terminalBash,
2114
2121
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2122
+ editSoftEnabled: this.settings.editSoftEnabled,
2115
2123
  thinkingWrap: this.settings.thinkingWrap,
2116
2124
  toolsWrap: this.settings.toolsWrap,
2117
2125
  disabledPlugins: this.settings.disabledPlugins,
@@ -2161,11 +2169,14 @@ export class DshClientSession {
2161
2169
  name,
2162
2170
  promptMode: this.settings.promptMode,
2163
2171
  customSystemPrompt: this.settings.customSystemPrompt,
2172
+ promptTemplate: "",
2173
+ promptOverrides: {},
2164
2174
  disabledSkills: this.settings.disabledSkills,
2165
2175
  disabledExtensions: this.settings.disabledExtensions,
2166
2176
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2167
2177
  terminalBash: this.settings.terminalBash,
2168
2178
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2179
+ editSoftEnabled: this.settings.editSoftEnabled,
2169
2180
  visionBridgePromptMode: "append",
2170
2181
  visionBridgePrompt: "",
2171
2182
  reviewPrompt: this.settings.reviewPrompt,
@@ -2198,6 +2209,7 @@ export class DshClientSession {
2198
2209
  this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
2199
2210
  this.settings.terminalBash = preset.terminalBash;
2200
2211
  this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
2212
+ this.settings.editSoftEnabled = preset.editSoftEnabled ?? this.settings.editSoftEnabled;
2201
2213
  this.settings.reviewPrompt = preset.reviewPrompt ?? "";
2202
2214
  this.stateStore.saveSettings(this.clientId, {
2203
2215
  promptMode: this.settings.promptMode,
@@ -2207,6 +2219,7 @@ export class DshClientSession {
2207
2219
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2208
2220
  terminalBash: this.settings.terminalBash,
2209
2221
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2222
+ editSoftEnabled: this.settings.editSoftEnabled,
2210
2223
  thinkingWrap: this.settings.thinkingWrap,
2211
2224
  toolsWrap: this.settings.toolsWrap,
2212
2225
  disabledPlugins: this.settings.disabledPlugins,