@steerable/agent-shell 0.6.56 → 0.6.58

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.
@@ -3,7 +3,8 @@ import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
5
  import os from 'node:os';
6
- import { getAppRootDir, shellOpenPath } from '../runtime.js';
6
+ import { getAppRootDir, getUserDataDir, shellOpenPath } from '../runtime.js';
7
+ import { acquireChatWriteLock, ChatBusyError, } from '../storage/process-locks.js';
7
8
  import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
@@ -48,6 +49,8 @@ import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUser
48
49
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
49
50
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
50
51
  import { matchPackBackendRoute } from './pack-backend-routes.js';
52
+ import { invokePackHttpRoute } from '../host/http-routes.js';
53
+ import { installProductCli } from '../cli-install.js';
51
54
  import { collectTurnFiles } from './turn-files.js';
52
55
  import { resolveMentionedPaths } from './mentioned-paths.js';
53
56
  import { getAuthProvider } from '../auth/index.js';
@@ -675,31 +678,48 @@ export class LocalBackendRouter {
675
678
  };
676
679
  }
677
680
  if (method === 'DELETE') {
678
- if (url.searchParams.get('onlyIfEmpty') === '1') {
679
- const deleted = await this.store.deleteChatIfEmpty(chatId);
681
+ const lease = this.tryChatLock(chatId);
682
+ if (!lease)
683
+ return this.chatBusy();
684
+ try {
685
+ if (url.searchParams.get('onlyIfEmpty') === '1') {
686
+ const deleted = await this.store.deleteChatIfEmpty(chatId);
687
+ return {
688
+ status: 200,
689
+ data: { success: true, deleted, chatId },
690
+ };
691
+ }
692
+ const ok = await this.store.deleteChat(chatId);
693
+ if (!ok)
694
+ return this.notFound('Chat not found');
680
695
  return {
681
696
  status: 200,
682
- data: { success: true, deleted, chatId },
697
+ data: {
698
+ success: true,
699
+ message: '删除成功',
700
+ chatId,
701
+ },
683
702
  };
684
703
  }
685
- const ok = await this.store.deleteChat(chatId);
686
- if (!ok)
687
- return this.notFound('Chat not found');
688
- return {
689
- status: 200,
690
- data: {
691
- success: true,
692
- message: '删除成功',
693
- chatId,
694
- },
695
- };
704
+ finally {
705
+ lease.release();
706
+ }
696
707
  }
697
708
  }
698
709
  const chatPinMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/pin$/);
699
710
  if (chatPinMatch && method === 'PUT') {
700
711
  const chatId = chatPinMatch[1];
701
712
  const payload = this.toRecord(request.body);
702
- const updated = await this.store.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
713
+ const lease = this.tryChatLock(chatId);
714
+ if (!lease)
715
+ return this.chatBusy();
716
+ let updated;
717
+ try {
718
+ updated = await this.store.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
719
+ }
720
+ finally {
721
+ lease.release();
722
+ }
703
723
  if (!updated)
704
724
  return this.notFound('Chat not found');
705
725
  return {
@@ -737,12 +757,21 @@ export class LocalBackendRouter {
737
757
  return this.badRequest('projectId 必须是项目 id 字符串或 null');
738
758
  }
739
759
  }
740
- const updated = await this.store.updateChat(chatId, {
741
- title: typeof payload.title === 'string' ? payload.title : undefined,
742
- systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
743
- pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
744
- projectId: projectIdUpdate,
745
- });
760
+ const lease = this.tryChatLock(chatId);
761
+ if (!lease)
762
+ return this.chatBusy();
763
+ let updated;
764
+ try {
765
+ updated = await this.store.updateChat(chatId, {
766
+ title: typeof payload.title === 'string' ? payload.title : undefined,
767
+ systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
768
+ pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
769
+ projectId: projectIdUpdate,
770
+ });
771
+ }
772
+ finally {
773
+ lease.release();
774
+ }
746
775
  if (!updated)
747
776
  return this.notFound('Chat not found');
748
777
  return {
@@ -1488,6 +1517,19 @@ export class LocalBackendRouter {
1488
1517
  // W-llm-diagnose:LLM 链路诊断。设置页「诊断」按钮触发,在主进程内
1489
1518
  // 探测 DNS/TCP/TLS/HTTP/chat 五级连通性,并报告宿主机的 ambient 代理
1490
1519
  // 配置(sidecar 沙箱视角会隐藏用户需要看到的代理问题)。
1520
+ if (method === 'POST' && pathname === '/api/v2/cli/install') {
1521
+ const payload = this.toRecord(request.body);
1522
+ const name = typeof payload.name === 'string' ? payload.name : '';
1523
+ try {
1524
+ return { status: 200, data: installProductCli(name) };
1525
+ }
1526
+ catch (error) {
1527
+ return {
1528
+ status: 400,
1529
+ data: { error: error instanceof Error ? error.message : String(error) },
1530
+ };
1531
+ }
1532
+ }
1491
1533
  if (method === 'POST' && pathname === '/api/v2/llm/diagnose') {
1492
1534
  const payload = this.toRecord(request.body);
1493
1535
  const settings = llmService.getSettings();
@@ -1854,6 +1896,9 @@ export class LocalBackendRouter {
1854
1896
  body: request.body,
1855
1897
  });
1856
1898
  }
1899
+ const packHttp = await invokePackHttpRoute(method, pathname, request.body);
1900
+ if (packHttp)
1901
+ return packHttp;
1857
1902
  return this.fallbackResponse(method, pathname);
1858
1903
  }
1859
1904
  /**
@@ -1949,242 +1994,244 @@ export class LocalBackendRouter {
1949
1994
  emit(this.sse('error', { message: 'chat not found' }));
1950
1995
  return { status: 404 };
1951
1996
  }
1952
- // W7-1: resume=true(仅 send 路由)续跑 durable record 里被中断的 turn,
1953
- // 不追加新用户消息;与 regenerate 互斥(regenerate 有自己的路径参数语义)。
1954
- const isResume = !regenerateMatch && payload.resume === true;
1955
- let userMessageText;
1956
- if (regenerateMatch) {
1957
- // Regenerate = truncate-and-rerun, *not* "append a fresh 'please
1958
- // regenerate' user turn on top of the old reply". The old bug ignored
1959
- // the :messageId path param entirely, so regenerating just piled a new
1960
- // user+assistant pair onto history while the original (possibly bad)
1961
- // assistant reply stayed in place — the model saw both and had no real
1962
- // reason to answer differently.
1963
- //
1964
- // sidecar 门必须先于任何写操作:rerun 回合只能跑在 sidecar 上
1965
- // (handleCoreLoopTurn 无 sidecar 直接 503)。若先截断再 503,旧回复
1966
- // 已删、新回复不会产生、record 也不存在——非破坏性承诺破窗。
1967
- if (!getSidecarSupervisor()) {
1968
- emit(this.sse('error', { message: 'coreloop enabled but sidecar is not running' }));
1969
- return { status: 503 };
1970
- }
1971
- const targetMessageId = regenerateMatch[2];
1972
- // The user turn that prompted the target reply is whatever immediately
1973
- // precedes it — re-derive its text so buildConversationMessages gets
1974
- // the *real* request instead of a generic placeholder (matters for
1975
- // deferred-exec detection, mode preamble, etc., which all key off the
1976
- // actual ask).
1977
- const priorMessages = (await this.store.listMessages(chatId, 200)).reverse();
1978
- const resolution = resolveRegenerateContext(priorMessages, targetMessageId);
1979
- if (!resolution.ok) {
1980
- emit(this.sse('error', { message: 'regenerate target message not found or not an assistant message' }));
1981
- return { status: 404 };
1982
- }
1983
- userMessageText = resolution.userMessageText;
1984
- // W5-2: fork the durable record BEFORE truncating the UI store, so
1985
- // the old tail survives as a discoverable branch instead of being
1986
- // destroyed (UI store) / interleaved with the new tail (record).
1987
- // The fork point is addressed by user-message ordinal: the prompting
1988
- // user turn is the last user message before the target, i.e. index
1989
- // (count of user messages before the target) - 1. An ordinal the sidecar
1990
- // declines to split — no record, or a point inside a branch seed — is the
1991
- // protocol's documented fallback and truncates; a fork that failed instead
1992
- // of answering refuses the request (planRegenerateTruncate).
1993
- const supervisor = getSidecarSupervisor();
1994
- const outcome = supervisor
1995
- ? await supervisor.forkSession({
1996
- recordId: await this.store.getChatRecordId(chatId) ?? chatId,
1997
- beforeUserIndex: resolveRegenerateForkOrdinal(priorMessages, targetMessageId),
1998
- newRecordId: `${chatId}:r${Date.now().toString(36)}`,
1999
- })
2000
- : null;
2001
- if (outcome?.ok) {
2002
- await this.store.setChatRecordId(chatId, outcome.fork.recordId);
2003
- console.log('[local-backend] regenerate: record forked (old tail preserved)', {
1997
+ return this.withChatWriteLock(chatId, emit, async () => {
1998
+ // W7-1: resume=true(仅 send 路由)续跑 durable record 里被中断的 turn,
1999
+ // 不追加新用户消息;与 regenerate 互斥(regenerate 有自己的路径参数语义)。
2000
+ const isResume = !regenerateMatch && payload.resume === true;
2001
+ let userMessageText;
2002
+ if (regenerateMatch) {
2003
+ // Regenerate = truncate-and-rerun, *not* "append a fresh 'please
2004
+ // regenerate' user turn on top of the old reply". The old bug ignored
2005
+ // the :messageId path param entirely, so regenerating just piled a new
2006
+ // user+assistant pair onto history while the original (possibly bad)
2007
+ // assistant reply stayed in place — the model saw both and had no real
2008
+ // reason to answer differently.
2009
+ //
2010
+ // sidecar 门必须先于任何写操作:rerun 回合只能跑在 sidecar 上
2011
+ // (handleCoreLoopTurn 无 sidecar 直接 503)。若先截断再 503,旧回复
2012
+ // 已删、新回复不会产生、record 也不存在——非破坏性承诺破窗。
2013
+ if (!getSidecarSupervisor()) {
2014
+ emit(this.sse('error', { message: 'coreloop enabled but sidecar is not running' }));
2015
+ return { status: 503 };
2016
+ }
2017
+ const targetMessageId = regenerateMatch[2];
2018
+ // The user turn that prompted the target reply is whatever immediately
2019
+ // precedes it — re-derive its text so buildConversationMessages gets
2020
+ // the *real* request instead of a generic placeholder (matters for
2021
+ // deferred-exec detection, mode preamble, etc., which all key off the
2022
+ // actual ask).
2023
+ const priorMessages = (await this.store.listMessages(chatId, 200)).reverse();
2024
+ const resolution = resolveRegenerateContext(priorMessages, targetMessageId);
2025
+ if (!resolution.ok) {
2026
+ emit(this.sse('error', { message: 'regenerate target message not found or not an assistant message' }));
2027
+ return { status: 404 };
2028
+ }
2029
+ userMessageText = resolution.userMessageText;
2030
+ // W5-2: fork the durable record BEFORE truncating the UI store, so
2031
+ // the old tail survives as a discoverable branch instead of being
2032
+ // destroyed (UI store) / interleaved with the new tail (record).
2033
+ // The fork point is addressed by user-message ordinal: the prompting
2034
+ // user turn is the last user message before the target, i.e. index
2035
+ // (count of user messages before the target) - 1. An ordinal the sidecar
2036
+ // declines to split — no record, or a point inside a branch seed — is the
2037
+ // protocol's documented fallback and truncates; a fork that failed instead
2038
+ // of answering refuses the request (planRegenerateTruncate).
2039
+ const supervisor = getSidecarSupervisor();
2040
+ const outcome = supervisor
2041
+ ? await supervisor.forkSession({
2042
+ recordId: await this.store.getChatRecordId(chatId) ?? chatId,
2043
+ beforeUserIndex: resolveRegenerateForkOrdinal(priorMessages, targetMessageId),
2044
+ newRecordId: `${chatId}:r${Date.now().toString(36)}`,
2045
+ })
2046
+ : null;
2047
+ if (outcome?.ok) {
2048
+ await this.store.setChatRecordId(chatId, outcome.fork.recordId);
2049
+ console.log('[local-backend] regenerate: record forked (old tail preserved)', {
2050
+ chatId,
2051
+ branchRecordId: outcome.fork.recordId,
2052
+ label: outcome.fork.label,
2053
+ });
2054
+ }
2055
+ else if (outcome) {
2056
+ console.log('[local-backend] regenerate: no fork', {
2057
+ chatId,
2058
+ declined: outcome.declined,
2059
+ reason: outcome.reason,
2060
+ });
2061
+ }
2062
+ const plan = planRegenerateTruncate(outcome);
2063
+ if (!plan.proceed) {
2064
+ emit(this.sse('error', { message: plan.message }));
2065
+ return { status: 409 };
2066
+ }
2067
+ const deleted = await this.store.deleteMessagesFrom(chatId, targetMessageId);
2068
+ console.log('[local-backend] regenerate: truncated history', {
2004
2069
  chatId,
2005
- branchRecordId: outcome.fork.recordId,
2006
- label: outcome.fork.label,
2070
+ targetMessageId,
2071
+ deletedCount: deleted,
2007
2072
  });
2008
2073
  }
2009
- else if (outcome) {
2010
- console.log('[local-backend] regenerate: no fork', {
2011
- chatId,
2012
- declined: outcome.declined,
2013
- reason: outcome.reason,
2074
+ else if (isResume) {
2075
+ // W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
2076
+ // sidecar 回放 durable record 的投影作为循环种子。只在检测确为
2077
+ // interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
2078
+ // 凭空多接一段回复。
2079
+ const prior = await this.store.listMessages(chatId, 200);
2080
+ const interrupted = detectInterruptedTurn({
2081
+ turnActive: await this.store.getTurnActive(chatId) !== null,
2082
+ streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
2083
+ lastMessageRole: prior[0]?.role ?? null,
2014
2084
  });
2085
+ const lastUser = prior.find(m => m.role === 'user');
2086
+ if (!interrupted || !lastUser) {
2087
+ emit(this.sse('error', { message: '没有可继续的中断回复' }));
2088
+ return { status: 409 };
2089
+ }
2090
+ // 触发轮的用户消息仍在库里(崩溃只丢了回复)——取其原文喂给
2091
+ // buildConversationMessages,让 mode preamble / deferred-exec 检测
2092
+ // 仍然基于真实诉求。发给 sidecar 的 messages 为空(见下方调用点)。
2093
+ userMessageText = lastUser.content;
2015
2094
  }
2016
- const plan = planRegenerateTruncate(outcome);
2017
- if (!plan.proceed) {
2018
- emit(this.sse('error', { message: plan.message }));
2019
- return { status: 409 };
2020
- }
2021
- const deleted = await this.store.deleteMessagesFrom(chatId, targetMessageId);
2022
- console.log('[local-backend] regenerate: truncated history', {
2095
+ else {
2096
+ userMessageText = String(payload.message || '').trim();
2097
+ }
2098
+ if (!userMessageText) {
2099
+ emit(this.sse('error', { message: 'message is required' }));
2100
+ return { status: 400 };
2101
+ }
2102
+ const cleanedResult = await this.cleanUserMessage(userMessageText);
2103
+ const cleanUserMessageText = cleanedResult.cleanText;
2104
+ const forcedSkillName = cleanedResult.skillName;
2105
+ const forcedMcpToolToken = cleanedResult.mcpToolToken;
2106
+ // 仅在以下条件全部成立时才在本轮结束后生成 AI 标题:
2107
+ // 1. 当前标题仍是 storage.createChat 默认值「新对话」(用户没自己改过)
2108
+ // 2. 这是非 regenerate 的真实 user turn(regenerate 不应改写标题)
2109
+ // 3. 历史里目前没有任何 assistant 消息——也就是说,本轮即将产生的是第一条助手回复
2110
+ // 这避免在已经聊到一半的对话里突然把标题换掉。listMessages 是 DESC,limit 拉
2111
+ // 大一点确保看到完整历史(200 已经覆盖绝大多数本地会话)。
2112
+ const existingAssistant = (await this.store
2113
+ .listMessages(chatId, 200))
2114
+ .some((m) => m.role === 'assistant');
2115
+ const shouldGenerateTitle = !regenerateMatch &&
2116
+ !isResume &&
2117
+ !existingAssistant &&
2118
+ (chat.title === '新对话' || chat.title.trim() === '');
2119
+ const firstUserMessageForTitle = cleanUserMessageText;
2120
+ console.log('[local-backend] title-gen decision', {
2023
2121
  chatId,
2024
- targetMessageId,
2025
- deletedCount: deleted,
2026
- });
2027
- }
2028
- else if (isResume) {
2029
- // W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
2030
- // sidecar 回放 durable record 的投影作为循环种子。只在检测确为
2031
- // interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
2032
- // 凭空多接一段回复。
2033
- const prior = await this.store.listMessages(chatId, 200);
2034
- const interrupted = detectInterruptedTurn({
2035
- turnActive: await this.store.getTurnActive(chatId) !== null,
2036
- streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
2037
- lastMessageRole: prior[0]?.role ?? null,
2122
+ shouldGenerateTitle,
2123
+ currentTitle: chat.title,
2124
+ isRegenerate: !!regenerateMatch,
2125
+ existingAssistant,
2038
2126
  });
2039
- const lastUser = prior.find(m => m.role === 'user');
2040
- if (!interrupted || !lastUser) {
2041
- emit(this.sse('error', { message: '没有可继续的中断回复' }));
2042
- return { status: 409 };
2043
- }
2044
- // 触发轮的用户消息仍在库里(崩溃只丢了回复)——取其原文喂给
2045
- // buildConversationMessages,让 mode preamble / deferred-exec 检测
2046
- // 仍然基于真实诉求。发给 sidecar 的 messages 为空(见下方调用点)。
2047
- userMessageText = lastUser.content;
2048
- }
2049
- else {
2050
- userMessageText = String(payload.message || '').trim();
2051
- }
2052
- if (!userMessageText) {
2053
- emit(this.sse('error', { message: 'message is required' }));
2054
- return { status: 400 };
2055
- }
2056
- const cleanedResult = await this.cleanUserMessage(userMessageText);
2057
- const cleanUserMessageText = cleanedResult.cleanText;
2058
- const forcedSkillName = cleanedResult.skillName;
2059
- const forcedMcpToolToken = cleanedResult.mcpToolToken;
2060
- // 仅在以下条件全部成立时才在本轮结束后生成 AI 标题:
2061
- // 1. 当前标题仍是 storage.createChat 默认值「新对话」(用户没自己改过)
2062
- // 2. 这是非 regenerate 的真实 user turn(regenerate 不应改写标题)
2063
- // 3. 历史里目前没有任何 assistant 消息——也就是说,本轮即将产生的是第一条助手回复
2064
- // 这避免在已经聊到一半的对话里突然把标题换掉。listMessages 是 DESC,limit 拉
2065
- // 大一点确保看到完整历史(200 已经覆盖绝大多数本地会话)。
2066
- const existingAssistant = (await this.store
2067
- .listMessages(chatId, 200))
2068
- .some((m) => m.role === 'assistant');
2069
- const shouldGenerateTitle = !regenerateMatch &&
2070
- !isResume &&
2071
- !existingAssistant &&
2072
- (chat.title === '新对话' || chat.title.trim() === '');
2073
- const firstUserMessageForTitle = cleanUserMessageText;
2074
- console.log('[local-backend] title-gen decision', {
2075
- chatId,
2076
- shouldGenerateTitle,
2077
- currentTitle: chat.title,
2078
- isRegenerate: !!regenerateMatch,
2079
- existingAssistant,
2080
- });
2081
- // Regenerate reruns against the (now-truncated) existing history — the
2082
- // triggering user message is already in it, so unlike `send` we must not
2083
- // insert another copy or the model would see it twice. Resume (W7-1)
2084
- // likewise appends nothing: the crashed turn's user message is already
2085
- // persisted (and already in the durable record).
2086
- let currentUserMessageId;
2087
- // 本轮被点名的智能体:菜单点选带来的 id 优先,手打的 `@名字` 从正文
2088
- // 解析补齐(两者缺一,提及就在后端消失,只剩前端徽章)。
2089
- const chatAgents = await this.store.listChatAgents();
2090
- const mentionedAgentIds = resolveMentionedAgentIds(payload, userMessageText, chatAgents);
2091
- if (!regenerateMatch && !isResume) {
2092
- const userMeta = mentionedAgentIds.length > 0
2093
- ? JSON.stringify({ mentionedAgentIds })
2094
- : null;
2095
- const userMessage = await this.store.addMessage(chatId, 'user', userMessageText, userMeta);
2096
- currentUserMessageId = userMessage.id;
2097
- emit(this.sseData({ type: 'user_message', message: userMessage }));
2098
- }
2099
- // Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
2100
- // 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
2101
- const chatMode = resolveTurnChatMode(payload.mode);
2102
- // 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
2103
- // 策略决定工具列表,工具列表又决定技能的触发条件。
2104
- const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
2105
- // Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
2106
- // 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
2107
- // 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
2108
- const turnTools = this.toolRouter
2109
- .listModelSchemas(turnAgents.capability.toolPolicy)
2110
- .filter(schema => chatMode !== 'plan' || schema.mode === 'read')
2111
- .map(schema => ({
2112
- name: schema.name,
2113
- description: schema.description,
2114
- inputSchema: schema.inputSchema,
2115
- }));
2116
- // 用户通过 "/" 显式指定的 MCP 工具:从注册表补齐元信息,available 取决于
2117
- // 该工具本轮是否真的在 turnTools 里(未启用/未连接/被 plan 模式过滤都算
2118
- // 不可用——指令模块会据此渲染"强制调用"或"如实告知不可用"两种版本)。
2119
- let forcedMcpTool;
2120
- if (forcedMcpToolToken) {
2121
- const resolved = this.toolRouter.mcpRegistry?.findToolByToken(forcedMcpToolToken) ?? null;
2122
- const token = resolved?.token ?? forcedMcpToolToken;
2123
- const tokenParts = token.split('__');
2124
- // 显式指定的工具属于 deferred 层、不在模型可见列表——本轮直接提升
2125
- // 进列表(用户点名 = 最强信号),省一轮 tool_search 往返。
2126
- // 智能体的工具策略不因点名而让步:被拒的工具不提升,forcedMcpTool
2127
- // 随之落到 available: false 分支(指令模块如实告知不可用)。
2128
- if (resolved &&
2129
- !turnTools.some((t) => t.name === token) &&
2130
- isToolAllowed(turnAgents.capability.toolPolicy, token)) {
2131
- const schema = this.toolRouter.getSchemaByName(token);
2132
- if (schema && (chatMode !== 'plan' || schema.mode === 'read')) {
2133
- turnTools.push({
2134
- name: schema.name,
2135
- description: schema.description,
2136
- inputSchema: schema.inputSchema,
2137
- });
2127
+ // Regenerate reruns against the (now-truncated) existing history — the
2128
+ // triggering user message is already in it, so unlike `send` we must not
2129
+ // insert another copy or the model would see it twice. Resume (W7-1)
2130
+ // likewise appends nothing: the crashed turn's user message is already
2131
+ // persisted (and already in the durable record).
2132
+ let currentUserMessageId;
2133
+ // 本轮被点名的智能体:菜单点选带来的 id 优先,手打的 `@名字` 从正文
2134
+ // 解析补齐(两者缺一,提及就在后端消失,只剩前端徽章)。
2135
+ const chatAgents = await this.store.listChatAgents();
2136
+ const mentionedAgentIds = resolveMentionedAgentIds(payload, userMessageText, chatAgents);
2137
+ if (!regenerateMatch && !isResume) {
2138
+ const userMeta = mentionedAgentIds.length > 0
2139
+ ? JSON.stringify({ mentionedAgentIds })
2140
+ : null;
2141
+ const userMessage = await this.store.addMessage(chatId, 'user', userMessageText, userMeta);
2142
+ currentUserMessageId = userMessage.id;
2143
+ emit(this.sseData({ type: 'user_message', message: userMessage }));
2144
+ }
2145
+ // Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
2146
+ // 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
2147
+ const chatMode = resolveTurnChatMode(payload.mode);
2148
+ // 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
2149
+ // 策略决定工具列表,工具列表又决定技能的触发条件。
2150
+ const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
2151
+ // Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
2152
+ // 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
2153
+ // 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
2154
+ const turnTools = this.toolRouter
2155
+ .listModelSchemas(turnAgents.capability.toolPolicy)
2156
+ .filter(schema => chatMode !== 'plan' || schema.mode === 'read')
2157
+ .map(schema => ({
2158
+ name: schema.name,
2159
+ description: schema.description,
2160
+ inputSchema: schema.inputSchema,
2161
+ }));
2162
+ // 用户通过 "/" 显式指定的 MCP 工具:从注册表补齐元信息,available 取决于
2163
+ // 该工具本轮是否真的在 turnTools 里(未启用/未连接/被 plan 模式过滤都算
2164
+ // 不可用——指令模块会据此渲染"强制调用"或"如实告知不可用"两种版本)。
2165
+ let forcedMcpTool;
2166
+ if (forcedMcpToolToken) {
2167
+ const resolved = this.toolRouter.mcpRegistry?.findToolByToken(forcedMcpToolToken) ?? null;
2168
+ const token = resolved?.token ?? forcedMcpToolToken;
2169
+ const tokenParts = token.split('__');
2170
+ // 显式指定的工具属于 deferred 层、不在模型可见列表——本轮直接提升
2171
+ // 进列表(用户点名 = 最强信号),省一轮 tool_search 往返。
2172
+ // 智能体的工具策略不因点名而让步:被拒的工具不提升,forcedMcpTool
2173
+ // 随之落到 available: false 分支(指令模块如实告知不可用)。
2174
+ if (resolved &&
2175
+ !turnTools.some((t) => t.name === token) &&
2176
+ isToolAllowed(turnAgents.capability.toolPolicy, token)) {
2177
+ const schema = this.toolRouter.getSchemaByName(token);
2178
+ if (schema && (chatMode !== 'plan' || schema.mode === 'read')) {
2179
+ turnTools.push({
2180
+ name: schema.name,
2181
+ description: schema.description,
2182
+ inputSchema: schema.inputSchema,
2183
+ });
2184
+ }
2138
2185
  }
2186
+ forcedMcpTool = {
2187
+ token,
2188
+ toolName: resolved?.toolName ?? tokenParts.slice(2).join('__'),
2189
+ serverName: resolved?.serverName ?? '',
2190
+ description: resolved?.description ?? '',
2191
+ available: turnTools.some((t) => t.name === token),
2192
+ };
2139
2193
  }
2140
- forcedMcpTool = {
2141
- token,
2142
- toolName: resolved?.toolName ?? tokenParts.slice(2).join('__'),
2143
- serverName: resolved?.serverName ?? '',
2144
- description: resolved?.description ?? '',
2145
- available: turnTools.some((t) => t.name === token),
2146
- };
2147
- }
2148
- const turnToolNames = turnTools.map((tool) => tool.name);
2149
- const mentionRoster = await buildMentionDelegateRoster(turnAgents.delegates, turnToolNames);
2150
- // 常驻可委派名单:其余智能体也进画像,技能/提示里的「交给 X」无需用户
2151
- // `@` 就能落成一次真实委派。父代理自己不进——它就是本轮的执行者,给它
2152
- // 一个自己的画像只会诱导无意义的自委派(`@自己` 走提及那条路,仍然可以)。
2153
- const ambientRoster = await buildAmbientDelegateRoster(chatAgents, turnToolNames, {
2154
- excludeAgentIds: [
2155
- ...mentionRoster.map((row) => row.agentId),
2156
- ...(turnAgents.parent ? [turnAgents.parent.id] : []),
2157
- ],
2158
- reservedProfileNames: [
2159
- ...Object.keys(BUILTIN_SUBAGENT_PROFILES),
2160
- ...mentionRoster.map((row) => row.profileName),
2161
- ],
2162
- });
2163
- const { systemPrompt, messages, skillContext } = await this.buildConversationMessages(chatId, cleanUserMessageText, payload, turnTools, turnAgents, forcedSkillName, chatMode, forcedMcpTool, currentUserMessageId, { mention: mentionRoster, ambient: ambientRoster });
2164
- // A4: the sidecar-hosted CoreLoop is the only chat path (the TS loop
2165
- // was deleted 2026-08-26 after default-on + canary verification). Tools
2166
- // round-trip back to this process over the reverse channel. If the
2167
- // sidecar isn't running, handleCoreLoopTurn fails loud with a 503.
2168
- return this.handleCoreLoopTurn({
2169
- chatId,
2170
- systemPrompt,
2171
- // W7-1: on resume the sidecar replays the durable record's projection
2172
- // as the loop seed and rejects a non-empty host history — the record
2173
- // is authoritative, so the freshly built history stays unsent (only
2174
- // systemPrompt / skillContext ride along).
2175
- messages: isResume ? [] : messages,
2176
- skillContext,
2177
- turnTools,
2178
- toolPolicy: turnAgents.capability.toolPolicy,
2179
- chatMode,
2180
- payload,
2181
- emit,
2182
- signal,
2183
- shouldGenerateTitle,
2184
- firstUserMessageForTitle,
2185
- resume: isResume,
2186
- subagent: mergeTurnSubagentParam(Object.fromEntries(mentionRoster.map((row) => [row.profileName, row.profile])), Object.fromEntries(ambientRoster.map((row) => [row.profileName, row.profile]))),
2187
- parentAgentId: turnAgents.parent?.id ?? null,
2194
+ const turnToolNames = turnTools.map((tool) => tool.name);
2195
+ const mentionRoster = await buildMentionDelegateRoster(turnAgents.delegates, turnToolNames);
2196
+ // 常驻可委派名单:其余智能体也进画像,技能/提示里的「交给 X」无需用户
2197
+ // `@` 就能落成一次真实委派。父代理自己不进——它就是本轮的执行者,给它
2198
+ // 一个自己的画像只会诱导无意义的自委派(`@自己` 走提及那条路,仍然可以)。
2199
+ const ambientRoster = await buildAmbientDelegateRoster(chatAgents, turnToolNames, {
2200
+ excludeAgentIds: [
2201
+ ...mentionRoster.map((row) => row.agentId),
2202
+ ...(turnAgents.parent ? [turnAgents.parent.id] : []),
2203
+ ],
2204
+ reservedProfileNames: [
2205
+ ...Object.keys(BUILTIN_SUBAGENT_PROFILES),
2206
+ ...mentionRoster.map((row) => row.profileName),
2207
+ ],
2208
+ });
2209
+ const { systemPrompt, messages, skillContext } = await this.buildConversationMessages(chatId, cleanUserMessageText, payload, turnTools, turnAgents, forcedSkillName, chatMode, forcedMcpTool, currentUserMessageId, { mention: mentionRoster, ambient: ambientRoster });
2210
+ // A4: the sidecar-hosted CoreLoop is the only chat path (the TS loop
2211
+ // was deleted 2026-08-26 after default-on + canary verification). Tools
2212
+ // round-trip back to this process over the reverse channel. If the
2213
+ // sidecar isn't running, handleCoreLoopTurn fails loud with a 503.
2214
+ return this.handleCoreLoopTurn({
2215
+ chatId,
2216
+ systemPrompt,
2217
+ // W7-1: on resume the sidecar replays the durable record's projection
2218
+ // as the loop seed and rejects a non-empty host history — the record
2219
+ // is authoritative, so the freshly built history stays unsent (only
2220
+ // systemPrompt / skillContext ride along).
2221
+ messages: isResume ? [] : messages,
2222
+ skillContext,
2223
+ turnTools,
2224
+ toolPolicy: turnAgents.capability.toolPolicy,
2225
+ chatMode,
2226
+ payload,
2227
+ emit,
2228
+ signal,
2229
+ shouldGenerateTitle,
2230
+ firstUserMessageForTitle,
2231
+ resume: isResume,
2232
+ subagent: mergeTurnSubagentParam(Object.fromEntries(mentionRoster.map((row) => [row.profileName, row.profile])), Object.fromEntries(ambientRoster.map((row) => [row.profileName, row.profile]))),
2233
+ parentAgentId: turnAgents.parent?.id ?? null,
2234
+ });
2188
2235
  });
2189
2236
  }
2190
2237
  /**
@@ -2952,6 +2999,35 @@ export class LocalBackendRouter {
2952
2999
  }
2953
3000
  return String(value);
2954
3001
  }
3002
+ chatBusy() {
3003
+ return {
3004
+ status: 409,
3005
+ data: { code: 'chat_busy', message: '该会话正在另一个进程中运行' },
3006
+ };
3007
+ }
3008
+ tryChatLock(chatId) {
3009
+ try {
3010
+ return acquireChatWriteLock(getUserDataDir(), chatId);
3011
+ }
3012
+ catch (error) {
3013
+ if (error instanceof ChatBusyError)
3014
+ return null;
3015
+ throw error;
3016
+ }
3017
+ }
3018
+ async withChatWriteLock(chatId, emit, body) {
3019
+ const lease = this.tryChatLock(chatId);
3020
+ if (!lease) {
3021
+ emit(this.sse('error', { code: 'chat_busy', message: '该会话正在另一个进程中运行' }));
3022
+ return { status: 409 };
3023
+ }
3024
+ try {
3025
+ return await body();
3026
+ }
3027
+ finally {
3028
+ lease.release();
3029
+ }
3030
+ }
2955
3031
  sse(event, data) {
2956
3032
  return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
2957
3033
  }