diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
index 188e3e2..73e8dea 100644
--- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
+++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
@@ -81,6 +81,48 @@ public final class ProjectChatUiState {
return nearBottom || forced;
}
+ public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
+ if (conflict == null) {
+ return "当前线程命中冲突保护";
+ }
+ if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
+ return "当前项目默认先走 GUI";
+ }
+ return "当前项目已命中并发保护";
+ }
+
+ public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
+ if (conflict == null) {
+ return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
+ }
+ String projectName = conflict.optString("projectName", "当前项目");
+ String deviceName = conflict.optString("deviceName", "当前设备");
+ if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
+ return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
+ }
+ return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
+ }
+
+ public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
+ if ("allow_once".equals(decision)) {
+ return "允许本次";
+ }
+ if ("allow_always".equals(decision)) {
+ return "永久放行";
+ }
+ return "禁止";
+ }
+
+ public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
+ if ("allow_once".equals(decision)) {
+ return "已允许本次,继续发送中…";
+ }
+ if ("allow_always".equals(decision)) {
+ return "已对当前项目永久放行,继续发送中…";
+ }
+ return "已保持禁止,这次消息没有发出。";
+ }
+
public static SelectionState emptySelection() {
return new SelectionState(new LinkedHashSet<>());
}
diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
index 3c42400..5d97dda 100644
--- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
@@ -649,7 +649,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
+ JSONObject executionConflict = response.json.optJSONObject("executionConflict");
if (!response.ok()) {
+ if (response.statusCode == 409
+ && "THREAD_EXECUTION_CONFLICT".equals(response.json.optString("code", ""))
+ && executionConflict != null) {
+ runOnUiThread(() -> {
+ composerSending = false;
+ setRefreshing(false);
+ removePendingOutgoingBubble();
+ updateComposerSendButtonState();
+ showThreadExecutionConflictDialog(executionConflict, body, kind);
+ });
+ return;
+ }
throw new IllegalStateException(response.message());
}
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
@@ -702,6 +715,85 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
}
+ private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
+ String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
+ ? "GUI"
+ : "CLI";
+ String deviceName = executionConflict.optString("deviceName", "当前设备");
+ String folderKey = executionConflict.optString("folderKey", "").trim();
+ StringBuilder messageBuilder = new StringBuilder(ProjectChatUiState.threadExecutionConflictSummary(executionConflict))
+ .append("\n\n设备:")
+ .append(deviceName)
+ .append(" · 默认模式:")
+ .append(preferredMode);
+ if (!folderKey.isEmpty()) {
+ messageBuilder.append("\n范围:").append(folderKey);
+ }
+ new AlertDialog.Builder(this)
+ .setTitle(ProjectChatUiState.threadExecutionConflictTitle(executionConflict))
+ .setMessage(messageBuilder.toString())
+ .setNegativeButton(
+ ProjectChatUiState.labelForThreadExecutionConflictDecision("forbid"),
+ (dialog, which) -> {
+ showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult("forbid"));
+ updateComposerSendButtonState();
+ }
+ )
+ .setNeutralButton(
+ ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"),
+ (dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_once", body, kind)
+ )
+ .setPositiveButton(
+ ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"),
+ (dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_always", body, kind)
+ )
+ .show();
+ }
+
+ private void applyThreadExecutionConflictDecision(
+ JSONObject executionConflict,
+ String decision,
+ String body,
+ String kind
+ ) {
+ composerSending = true;
+ updateComposerSendButtonState();
+ setRefreshing(true);
+ executor.execute(() -> {
+ try {
+ String folderKey = executionConflict.optString("folderKey", "").trim();
+ BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
+ executionConflict.optString("deviceId", ""),
+ executionConflict.optString("projectId", ""),
+ folderKey.isEmpty() ? null : folderKey,
+ decision
+ );
+ if (!response.ok()) {
+ throw new IllegalStateException(response.message());
+ }
+ runOnUiThread(() -> {
+ showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult(decision));
+ if ("forbid".equals(decision)) {
+ composerSending = false;
+ setRefreshing(false);
+ updateComposerSendButtonState();
+ return;
+ }
+ composerSending = false;
+ updateComposerSendButtonState();
+ sendProjectMessage(kind, body);
+ });
+ } catch (Exception error) {
+ runOnUiThread(() -> {
+ composerSending = false;
+ setRefreshing(false);
+ updateComposerSendButtonState();
+ showMessage("冲突放行设置失败:" + error.getMessage());
+ });
+ }
+ });
+ }
+
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
composerSending = true;
updateComposerSendButtonState();
diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
index fb107be..93fca8b 100644
--- a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
+++ b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
@@ -249,4 +249,27 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
+
+ @Test
+ public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
+ JSONObject conflict = new JSONObject()
+ .put("projectName", "Boss UI 主线程")
+ .put("deviceName", "Mac Studio")
+ .put("reason", "preferred_gui_mode");
+
+ assertEquals("当前项目默认先走 GUI", ProjectChatUiState.threadExecutionConflictTitle(conflict));
+ assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只对这个项目生效"));
+ }
+
+ @Test
+ public void threadExecutionConflictCopyExplainsForbidAsProjectOnly() throws Exception {
+ JSONObject conflict = new JSONObject()
+ .put("projectName", "Boss UI 主线程")
+ .put("reason", "project_conflict_forbid");
+
+ assertEquals("当前项目已命中并发保护", ProjectChatUiState.threadExecutionConflictTitle(conflict));
+ assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只影响这个项目"));
+ assertEquals("允许本次", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"));
+ assertEquals("永久放行", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"));
+ }
}
diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts
index 351ab02..8df3ff4 100644
--- a/src/app/api/v1/projects/[projectId]/messages/route.ts
+++ b/src/app/api/v1/projects/[projectId]/messages/route.ts
@@ -2,10 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
import {
+ getThreadConversationExecutionConflict,
queueGroupDispatchPlan,
queueThreadConversationReplyTask,
replyToMasterAgentUserMessage,
shouldRecommendMasterAgentDispatchPlan,
+ ThreadConversationExecutionConflictError,
} from "@/lib/boss-master-agent";
import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy";
@@ -26,6 +28,8 @@ function threadConversationFailureMessage(error?: string) {
return "当前线程还没有绑定真实 Codex 线程,请先重新导入该线程后再试。";
case "THREAD_TARGET_DEVICE_OFFLINE":
return "当前线程所在设备不在线,请先让对应设备上线后再试。";
+ case "THREAD_EXECUTION_CONFLICT":
+ return "当前线程命中了 GUI / CLI 冲突保护,请先确认本项目是否放行后再继续发送。";
default:
return error ?? "UNKNOWN_ERROR";
}
@@ -80,6 +84,27 @@ export async function POST(
);
}
+ const singleThreadExecutionConflict =
+ project &&
+ projectId !== "master-agent" &&
+ !project.isGroup &&
+ (body.kind ?? "text") === "text" &&
+ (body.body ?? "").trim().length > 0
+ ? await getThreadConversationExecutionConflict(projectId)
+ : null;
+
+ if (singleThreadExecutionConflict) {
+ return NextResponse.json(
+ {
+ ok: false,
+ code: "THREAD_EXECUTION_CONFLICT",
+ message: threadConversationFailureMessage("THREAD_EXECUTION_CONFLICT"),
+ executionConflict: singleThreadExecutionConflict,
+ },
+ { status: 409 },
+ );
+ }
+
const message = await appendProjectMessage({
projectId,
senderLabel: session.displayName || "你",
@@ -209,6 +234,17 @@ export async function POST(
collaborationGate,
});
} catch (error) {
+ if (error instanceof ThreadConversationExecutionConflictError) {
+ return NextResponse.json(
+ {
+ ok: false,
+ code: error.message,
+ message: threadConversationFailureMessage(error.message),
+ executionConflict: error.conflict,
+ },
+ { status: 409 },
+ );
+ }
const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR";
return NextResponse.json(
{
diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx
index f94041d..48af8bf 100644
--- a/src/components/app-ui.tsx
+++ b/src/components/app-ui.tsx
@@ -21,6 +21,15 @@ import {
summarizeDispatchPlanCompact,
summarizeDispatchPlanLightTitle,
} from "@/lib/dispatch-plan-ui";
+import type {
+ ThreadConversationExecutionConflict,
+ ThreadConversationExecutionConflictAction,
+} from "@/lib/thread-execution-conflict";
+import {
+ describeThreadConversationExecutionConflict,
+ labelForThreadConversationExecutionConflictDecision,
+ summarizeThreadConversationExecutionDecisionResult,
+} from "@/lib/thread-execution-conflict-ui";
import type {
Device,
DeviceEnrollment,
@@ -1195,6 +1204,7 @@ export function ChatComposer({
initialLightDispatchReminderEnabled?: boolean;
}) {
const router = useRouter();
+ type ComposerMessageKind = "text" | "voice_intent" | "image_intent" | "video_intent";
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
@@ -1207,6 +1217,11 @@ export function ChatComposer({
const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState(
initialLightDispatchReminderEnabled,
);
+ const [threadExecutionConflict, setThreadExecutionConflict] = useState<{
+ conflict: ThreadConversationExecutionConflict;
+ draftBody: string;
+ kind: ComposerMessageKind;
+ } | null>(null);
const pendingDispatchPlan =
localPendingDispatchPlan ??
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
@@ -1214,6 +1229,9 @@ export function ChatComposer({
: null);
const rejectedDispatchPlan =
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
+ const threadExecutionConflictDescription = threadExecutionConflict
+ ? describeThreadConversationExecutionConflict(threadExecutionConflict.conflict)
+ : null;
async function confirmDispatchPlan(rememberLightReminder = false) {
if (!pendingDispatchPlan) return;
@@ -1352,16 +1370,25 @@ export function ChatComposer({
router.refresh();
}
- async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
+ async function send(
+ kind: ComposerMessageKind,
+ options?: {
+ draftBody?: string;
+ },
+ ) {
+ const draftBody = kind === "text" ? (options?.draftBody ?? value).trim() : "";
+ if (kind === "text" && !draftBody) {
+ return;
+ }
setLoading(true);
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
+ body: JSON.stringify({ body: kind === "text" ? draftBody : undefined, kind }),
});
const result = (await response.json()) as {
ok: boolean;
- message?: { body: string };
+ message?: { body: string } | string;
dispatchPlan?: {
planId: string;
summary?: string;
@@ -1371,17 +1398,30 @@ export function ChatComposer({
requiresMasterAgentApproval?: boolean;
lightDispatchReminderEnabled?: boolean;
};
+ code?: string;
+ executionConflict?: ThreadConversationExecutionConflict;
messageText?: string;
};
setLoading(false);
+ if (!result.ok && response.status === 409 && result.code === "THREAD_EXECUTION_CONFLICT" && result.executionConflict) {
+ setThreadExecutionConflict({
+ conflict: result.executionConflict,
+ draftBody,
+ kind,
+ });
+ setMessageTone("error");
+ setMessage(typeof result.message === "string" ? result.message : "当前线程命中了 GUI / CLI 冲突保护。");
+ return;
+ }
if (result.ok) {
+ setThreadExecutionConflict(null);
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
projectId,
level: "info",
category: "chat.message_sent",
message:
- kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`,
+ kind === "text" ? `已发送文本消息:${draftBody || "空文本"}` : `已发送 ${kind} 意图消息。`,
mirrorToMaster: false,
});
setValue("");
@@ -1419,7 +1459,44 @@ export function ChatComposer({
mirrorToMaster: true,
});
setMessageTone("error");
- setMessage("消息发送失败,请重试。");
+ setMessage(typeof result.message === "string" ? result.message : "消息发送失败,请重试。");
+ }
+
+ async function handleThreadExecutionConflictDecision(
+ decision: ThreadConversationExecutionConflictAction,
+ ) {
+ if (!threadExecutionConflict) {
+ return;
+ }
+ setLoading(true);
+ const response = await fetch(`/api/v1/devices/${threadExecutionConflict.conflict.deviceId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ projectId: threadExecutionConflict.conflict.projectId,
+ folderKey: threadExecutionConflict.conflict.folderKey ?? null,
+ conflictDecision: decision,
+ }),
+ });
+ const result = (await response.json()) as {
+ ok: boolean;
+ message?: string;
+ };
+ setLoading(false);
+ if (!result.ok) {
+ setMessageTone("error");
+ setMessage(result.message ?? "冲突放行设置失败,请重试。");
+ return;
+ }
+
+ const pendingDraft = threadExecutionConflict;
+ setThreadExecutionConflict(null);
+ setMessageTone(decision === "forbid" ? "error" : "success");
+ setMessage(summarizeThreadConversationExecutionDecisionResult(decision));
+ if (decision === "forbid") {
+ return;
+ }
+ await send(pendingDraft.kind, { draftBody: pendingDraft.draftBody });
}
return (
@@ -1477,6 +1554,40 @@ export function ChatComposer({
{dispatchPlanRecoveryHint}
) : null}
+ {threadExecutionConflict && threadExecutionConflictDescription ? (
+
+
+ {threadExecutionConflictDescription.title}
+
+
{threadExecutionConflictDescription.summary}
+
+ 设备:{threadExecutionConflict.conflict.deviceName}
+ {" · "}
+ 默认模式:{threadExecutionConflict.conflict.preferredExecutionMode === "gui" ? "GUI" : "CLI"}
+ {threadExecutionConflict.conflict.folderKey ? ` · ${threadExecutionConflict.conflict.folderKey}` : ""}
+
+
+ {threadExecutionConflict.conflict.actions.map((action) => (
+
+ ))}
+
+
+ ) : null}
{pendingDispatchPlan ? (
diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts
index a1ca264..4ff1f82 100644
--- a/src/lib/boss-master-agent.ts
+++ b/src/lib/boss-master-agent.ts
@@ -25,9 +25,12 @@ import type {
AiProvider,
DispatchPlanTarget,
Project,
+ ProjectExecutionPolicy,
ProjectAgentControls,
ReasoningEffort,
} from "@/lib/boss-data";
+import type { ThreadConversationExecutionConflict } from "@/lib/thread-execution-conflict";
+import { THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS } from "@/lib/thread-execution-conflict";
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import {
CLAW_BACKEND_ID,
@@ -91,6 +94,16 @@ type QueuedMasterAgentReplyEnvelope = {
};
};
+export class ThreadConversationExecutionConflictError extends Error {
+ conflict: ThreadConversationExecutionConflict;
+
+ constructor(conflict: ThreadConversationExecutionConflict) {
+ super("THREAD_EXECUTION_CONFLICT");
+ this.name = "ThreadConversationExecutionConflictError";
+ this.conflict = conflict;
+ }
+}
+
export async function resolveMasterAgentExecutionConfig(
projectId: string,
accountId?: string,
@@ -226,6 +239,116 @@ function buildThreadConversationReplyPrompt(project: Project, requestText: strin
].join("\n");
}
+function buildThreadConversationFolderKey(project: Project) {
+ const deviceId = project.deviceIds[0];
+ const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
+ if (!deviceId || !folderRef) {
+ return undefined;
+ }
+ return `${deviceId}:${folderRef}`;
+}
+
+function findThreadConflictPolicy(
+ policies: ProjectExecutionPolicy[],
+ input: {
+ deviceId: string;
+ projectId: string;
+ folderKey?: string;
+ },
+) {
+ if (input.folderKey) {
+ const folderMatch = policies.find(
+ (policy) => policy.deviceId === input.deviceId && policy.folderKey === input.folderKey,
+ );
+ if (folderMatch) {
+ return folderMatch;
+ }
+ }
+ return policies.find(
+ (policy) => policy.deviceId === input.deviceId && policy.projectId === input.projectId,
+ );
+}
+
+async function resolveThreadConversationExecutionContext(projectId: string) {
+ const state = await readState();
+ const project = state.projects.find((item) => item.id === projectId);
+ if (!project) {
+ throw new Error("PROJECT_NOT_FOUND");
+ }
+ if (project.isGroup) {
+ throw new Error("PROJECT_NOT_SINGLE_THREAD");
+ }
+ if (project.id === "master-agent") {
+ throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
+ }
+ if (!project.threadMeta.codexThreadRef?.trim()) {
+ throw new Error("THREAD_BINDING_REQUIRED");
+ }
+
+ const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
+ const device = state.devices.find((item) => item.id === deviceId);
+ if (!device || device.status !== "online") {
+ throw new Error("THREAD_TARGET_DEVICE_OFFLINE");
+ }
+
+ const folderKey = buildThreadConversationFolderKey(project);
+ const matchingPolicy = findThreadConflictPolicy(state.projectExecutionPolicies, {
+ deviceId,
+ projectId: project.id,
+ folderKey,
+ });
+
+ return {
+ project,
+ device,
+ deviceId,
+ folderKey,
+ matchingPolicy,
+ };
+}
+
+export async function getThreadConversationExecutionConflict(projectId: string) {
+ const context = await resolveThreadConversationExecutionContext(projectId);
+ const { project, device, deviceId, folderKey, matchingPolicy } = context;
+ const preferredExecutionMode = device.preferredExecutionMode ?? "cli";
+
+ if (matchingPolicy?.allowPolicy === "allow_once" || matchingPolicy?.allowPolicy === "allow_always") {
+ return null;
+ }
+
+ if (preferredExecutionMode === "gui") {
+ return {
+ projectId: project.id,
+ projectName: project.name,
+ deviceId,
+ deviceName: device.name,
+ folderKey,
+ preferredExecutionMode,
+ allowPolicy: matchingPolicy?.allowPolicy ?? "forbid",
+ conflictState: matchingPolicy?.conflictState ?? "blocked",
+ reason: "preferred_gui_mode" as const,
+ actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS],
+ };
+ }
+
+ if (matchingPolicy?.conflictState === "blocked" && matchingPolicy.allowPolicy === "forbid") {
+ return {
+ projectId: project.id,
+ projectName: project.name,
+ deviceId,
+ deviceName: device.name,
+ folderKey,
+ preferredExecutionMode,
+ allowPolicy: matchingPolicy.allowPolicy,
+ conflictState: matchingPolicy.conflictState,
+ reason: "project_conflict_forbid" as const,
+ actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS],
+ };
+ }
+
+ return null;
+}
+
function buildRuntimeDigest(
state: Awaited>,
requestText: string,
@@ -1701,26 +1824,11 @@ export async function queueThreadConversationReplyTask(params: {
requestedBy: string;
requestedByAccount: string;
}) {
- const state = await readState();
- const project = state.projects.find((item) => item.id === params.projectId);
- if (!project) {
- throw new Error("PROJECT_NOT_FOUND");
- }
- if (project.isGroup) {
- throw new Error("PROJECT_NOT_SINGLE_THREAD");
- }
- if (project.id === "master-agent") {
- throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
- }
- if (!project.threadMeta.codexThreadRef?.trim()) {
- throw new Error("THREAD_BINDING_REQUIRED");
- }
-
- const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
- const device = state.devices.find((item) => item.id === deviceId);
- if (!device || device.status !== "online") {
- throw new Error("THREAD_TARGET_DEVICE_OFFLINE");
+ const conflict = await getThreadConversationExecutionConflict(params.projectId);
+ if (conflict) {
+ throw new ThreadConversationExecutionConflictError(conflict);
}
+ const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId);
return queueMasterAgentTask({
projectId: project.id,
taskType: "conversation_reply",
diff --git a/src/lib/thread-execution-conflict-ui.ts b/src/lib/thread-execution-conflict-ui.ts
new file mode 100644
index 0000000..70a882d
--- /dev/null
+++ b/src/lib/thread-execution-conflict-ui.ts
@@ -0,0 +1,48 @@
+import type {
+ ThreadConversationExecutionConflict,
+ ThreadConversationExecutionConflictAction,
+} from "@/lib/thread-execution-conflict";
+
+export function describeThreadConversationExecutionConflict(
+ conflict: ThreadConversationExecutionConflict,
+) {
+ if (conflict.reason === "preferred_gui_mode") {
+ return {
+ title: "当前项目默认先走 GUI",
+ summary: `${conflict.deviceName} 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 ${conflict.projectName},需要你先对这个项目放行;这个选择只对这个项目生效。`,
+ };
+ }
+
+ return {
+ title: "当前项目已命中并发保护",
+ summary: `${conflict.projectName} 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。`,
+ };
+}
+
+export function labelForThreadConversationExecutionConflictDecision(
+ decision: ThreadConversationExecutionConflictAction,
+) {
+ switch (decision) {
+ case "allow_once":
+ return "允许本次";
+ case "allow_always":
+ return "永久放行";
+ case "forbid":
+ default:
+ return "禁止";
+ }
+}
+
+export function summarizeThreadConversationExecutionDecisionResult(
+ decision: ThreadConversationExecutionConflictAction,
+) {
+ switch (decision) {
+ case "allow_once":
+ return "已允许本次,继续发送中…";
+ case "allow_always":
+ return "已对当前项目永久放行,继续发送中…";
+ case "forbid":
+ default:
+ return "已保持禁止,这次消息没有发出。";
+ }
+}
diff --git a/src/lib/thread-execution-conflict.ts b/src/lib/thread-execution-conflict.ts
new file mode 100644
index 0000000..6d444e2
--- /dev/null
+++ b/src/lib/thread-execution-conflict.ts
@@ -0,0 +1,25 @@
+export type ThreadConversationExecutionConflictAction = "forbid" | "allow_once" | "allow_always";
+export type ThreadConversationExecutionConflictState = "none" | "warning" | "blocked";
+export type ThreadConversationExecutionPreferredMode = "gui" | "cli";
+export type ThreadConversationExecutionConflictReason =
+ | "preferred_gui_mode"
+ | "project_conflict_forbid";
+
+export interface ThreadConversationExecutionConflict {
+ projectId: string;
+ projectName: string;
+ deviceId: string;
+ deviceName: string;
+ folderKey?: string;
+ preferredExecutionMode: ThreadConversationExecutionPreferredMode;
+ allowPolicy: ThreadConversationExecutionConflictAction;
+ conflictState: ThreadConversationExecutionConflictState;
+ reason: ThreadConversationExecutionConflictReason;
+ actions: ThreadConversationExecutionConflictAction[];
+}
+
+export const THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS: ThreadConversationExecutionConflictAction[] = [
+ "forbid",
+ "allow_once",
+ "allow_always",
+];
diff --git a/tests/single-thread-message-execution.test.ts b/tests/single-thread-message-execution.test.ts
index aa6069d..02bbd57 100644
--- a/tests/single-thread-message-execution.test.ts
+++ b/tests/single-thread-message-execution.test.ts
@@ -100,6 +100,11 @@ function buildSingleThreadProject(projectId: string) {
};
}
+function buildProjectFolderKey(project: ReturnType) {
+ const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
+ return `${project.deviceIds[0]}:${folderRef}`;
+}
+
async function ensureSingleThreadProject() {
const state = await readState();
const existing = findSingleThreadProject(state);
@@ -159,6 +164,131 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
});
+test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
+ await setup();
+ const singleProject = await ensureSingleThreadProject();
+ assert.ok(singleProject, "expected a seeded single-thread project");
+
+ const state = await readState();
+ const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
+ assert.ok(targetDevice, "expected a seeded target device");
+ targetDevice.preferredExecutionMode = "gui";
+ await writeState(state);
+
+ const response = await postMessageRoute(
+ await createAuthedRequest(
+ `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
+ "POST",
+ { body: "继续推进当前线程" },
+ ),
+ { params: Promise.resolve({ projectId: singleProject.id }) },
+ );
+ assert.equal(response.status, 409);
+
+ const payload = (await response.json()) as {
+ ok: boolean;
+ code?: string;
+ message?: string;
+ executionConflict?: {
+ projectId: string;
+ deviceId: string;
+ preferredExecutionMode: "gui" | "cli";
+ allowPolicy: "forbid" | "allow_once" | "allow_always";
+ conflictState: "none" | "warning" | "blocked";
+ reason: string;
+ actions: string[];
+ };
+ };
+
+ assert.equal(payload.ok, false);
+ assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
+ assert.equal(payload.executionConflict?.projectId, singleProject.id);
+ assert.equal(payload.executionConflict?.deviceId, singleProject.deviceIds[0]);
+ assert.equal(payload.executionConflict?.preferredExecutionMode, "gui");
+ assert.equal(payload.executionConflict?.allowPolicy, "forbid");
+ assert.equal(payload.executionConflict?.conflictState, "blocked");
+ assert.equal(payload.executionConflict?.reason, "preferred_gui_mode");
+ assert.deepEqual(payload.executionConflict?.actions, ["forbid", "allow_once", "allow_always"]);
+
+ const nextState = await readState();
+ const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
+ const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续推进当前线程"));
+ assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
+ const queuedTask = nextState.masterAgentTasks.find(
+ (item) =>
+ item.taskType === "conversation_reply" &&
+ item.projectId === singleProject.id &&
+ item.requestText === "继续推进当前线程",
+ );
+ assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
+});
+
+test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the current project folder is forbidden", async () => {
+ await setup();
+ const singleProject = await ensureSingleThreadProject();
+ assert.ok(singleProject, "expected a seeded single-thread project");
+
+ const state = await readState();
+ const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
+ assert.ok(targetDevice, "expected a seeded target device");
+ targetDevice.preferredExecutionMode = "cli";
+ state.projectExecutionPolicies = [
+ {
+ deviceId: singleProject.deviceIds[0],
+ folderKey: buildProjectFolderKey(singleProject),
+ projectId: singleProject.id,
+ allowPolicy: "forbid",
+ conflictState: "blocked",
+ updatedAt: "2026-04-06T13:20:00.000Z",
+ },
+ ];
+ await writeState(state);
+
+ const response = await postMessageRoute(
+ await createAuthedRequest(
+ `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
+ "POST",
+ { body: "继续同步项目进度" },
+ ),
+ { params: Promise.resolve({ projectId: singleProject.id }) },
+ );
+ assert.equal(response.status, 409);
+
+ const payload = (await response.json()) as {
+ ok: boolean;
+ code?: string;
+ executionConflict?: {
+ projectId: string;
+ folderKey?: string;
+ preferredExecutionMode: "gui" | "cli";
+ allowPolicy: "forbid" | "allow_once" | "allow_always";
+ conflictState: "none" | "warning" | "blocked";
+ reason: string;
+ };
+ };
+
+ assert.equal(payload.ok, false);
+ assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
+ assert.equal(payload.executionConflict?.projectId, singleProject.id);
+ assert.equal(payload.executionConflict?.folderKey, buildProjectFolderKey(singleProject));
+ assert.equal(payload.executionConflict?.preferredExecutionMode, "cli");
+ assert.equal(payload.executionConflict?.allowPolicy, "forbid");
+ assert.equal(payload.executionConflict?.conflictState, "blocked");
+ assert.equal(payload.executionConflict?.reason, "project_conflict_forbid");
+
+ const nextState = await readState();
+ const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
+ const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续同步项目进度"));
+ assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
+ const queuedTask = nextState.masterAgentTasks.find(
+ (item) =>
+ item.taskType === "conversation_reply" &&
+ item.projectId === singleProject.id &&
+ item.requestText === "继续同步项目进度",
+ );
+ assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
+});
+
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
diff --git a/tests/thread-execution-conflict-ui.test.ts b/tests/thread-execution-conflict-ui.test.ts
new file mode 100644
index 0000000..d415561
--- /dev/null
+++ b/tests/thread-execution-conflict-ui.test.ts
@@ -0,0 +1,51 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ describeThreadConversationExecutionConflict,
+ labelForThreadConversationExecutionConflictDecision,
+} from "../src/lib/thread-execution-conflict-ui.ts";
+
+test("describeThreadConversationExecutionConflict explains preferred gui mode with project-scoped guidance", () => {
+ const description = describeThreadConversationExecutionConflict({
+ projectId: "thread-ui",
+ projectName: "Boss UI 主线程",
+ deviceId: "mac-studio",
+ deviceName: "Mac Studio",
+ folderKey: "mac-studio:boss",
+ preferredExecutionMode: "gui",
+ allowPolicy: "forbid",
+ conflictState: "blocked",
+ reason: "preferred_gui_mode",
+ actions: ["forbid", "allow_once", "allow_always"],
+ });
+
+ assert.equal(description.title, "当前项目默认先走 GUI");
+ assert.match(description.summary, /Mac Studio/);
+ assert.match(description.summary, /只对这个项目/);
+ assert.match(description.summary, /Boss UI 主线程/);
+});
+
+test("describeThreadConversationExecutionConflict explains project-level forbid without implying a global lock", () => {
+ const description = describeThreadConversationExecutionConflict({
+ projectId: "thread-ui",
+ projectName: "Boss UI 主线程",
+ deviceId: "mac-studio",
+ deviceName: "Mac Studio",
+ folderKey: "mac-studio:boss",
+ preferredExecutionMode: "cli",
+ allowPolicy: "forbid",
+ conflictState: "blocked",
+ reason: "project_conflict_forbid",
+ actions: ["forbid", "allow_once", "allow_always"],
+ });
+
+ assert.equal(description.title, "当前项目已命中并发保护");
+ assert.match(description.summary, /最近检测到 GUI \/ CLI 同时活动/);
+ assert.match(description.summary, /只影响这个项目/);
+});
+
+test("labelForThreadConversationExecutionConflictDecision keeps the three project-scoped actions concise", () => {
+ assert.equal(labelForThreadConversationExecutionConflictDecision("forbid"), "禁止");
+ assert.equal(labelForThreadConversationExecutionConflictDecision("allow_once"), "允许本次");
+ assert.equal(labelForThreadConversationExecutionConflictDecision("allow_always"), "永久放行");
+});