LIVE 模式改為雙向對話(codex exec --json + resume 接力)

摘要:
與 claude-office 同功能:側邊面板聊天室,每則訊息 spawn
codex exec --json,捕捉 thread_id 後下一則以 codex exec resume 接力。

根本原因:
單向 /task 看不到回覆也無法追問。

影響:
watcher 新增 POST /chat 與 chat_* SSE 事件;已實測一輪對話
(thread.started → agent_message → turn.completed)正確回傳。

修法:
- watch.mjs:launchChat 解析 --json 事件流(thread_id / agent_message /
  turn.completed),chatThreads 記憶 thread
- 前端與 claude-office 同步(ChatPanel / chats store / chat_* 對映)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:59:24 +08:00
co-authored by Claude Fable 5
parent ef07f4b8ad
commit 89a8b75b61
6 changed files with 817 additions and 399 deletions
+11 -1
View File
@@ -41,7 +41,17 @@ npm run watch # http://localhost:5181/events (SSE)
注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。 注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。
### 在辦公室下任務(LIVE 模式) ### 在辦公室對話(LIVE 模式)
點選專案 agent 後,側邊面板是一個**雙向聊天室**:每則訊息 spawn 一次
`codex exec --json`,從輸出捕捉 `thread_id`,下一則自動 `codex exec resume`
接力。回覆同時顯示在聊天串和小人頭上的對話氣泡。
- 「接續專案最近 session」:第一句改從該專案最近的 rollout 接續
- 「🔄 新對話」:清空 office 對話串(會終止進行中的執行)
- 事件:`POST /chat` → SSE 廣播 `chat_start` / `chat_text` / `chat_done`
### 在辦公室下任務(API)
點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過 點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過
`POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session: `POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session:
+147
View File
@@ -294,6 +294,149 @@ function launchTask(slug, prompt, resume) {
}); });
} }
/* ── two-way chat: one office thread per project, stitched with `codex exec resume` ── */
/** slug → { threadId, child } */
const chatThreads = new Map();
function launchChat(slug, message, fromProject) {
const proj = projects.get(slug);
if (!proj?.cwd) return { ok: false, error: "unknown project" };
let thread = chatThreads.get(slug);
if (!thread) {
thread = { threadId: null, child: null };
chatThreads.set(slug, thread);
}
if (thread.child) return { ok: false, error: "busy" };
let resumeId = thread.threadId;
if (!resumeId && fromProject) {
const sid = newestSessionId(slug);
if (sid) resumeId = sid;
}
const args = ["exec"];
if (resumeId) args.push("resume", resumeId);
args.push("--json", "--full-auto", "--skip-git-repo-check", "-");
// Message goes through stdin ("-") — never through shell arguments.
const child = spawn("codex", args, {
cwd: proj.cwd,
shell: true,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
thread.child = child;
let stderrTail = "";
let stdoutRemainder = "";
let sawDone = false;
let lastText = "";
child.stderr.on("data", (c) => {
stderrTail = (stderrTail + c.toString("utf8")).slice(-400);
});
child.stdout.on("data", (c) => {
const chunk = stdoutRemainder + c.toString("utf8");
const lines = chunk.split("\n");
stdoutRemainder = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (typeof o.thread_id === "string") thread.threadId = o.thread_id;
if (o.type === "thread.started") {
broadcast({ type: "chat_start", slug });
} else if (o.type === "item.completed" && o.item?.type === "agent_message" && o.item.text) {
lastText = String(o.item.text);
broadcast({ type: "chat_text", slug, text: lastText });
} else if (o.type === "turn.completed") {
sawDone = true;
broadcast({ type: "chat_done", slug, ok: true, text: lastText });
} else if (o.type === "turn.failed" || o.type === "error") {
sawDone = true;
broadcast({
type: "chat_done",
slug,
ok: false,
text: "",
error: String(o.error?.message ?? o.message ?? "turn failed").slice(0, 200),
});
}
}
});
child.stdin.write(message);
child.stdin.end();
const killer = setTimeout(() => child.kill(), TASK_TIMEOUT_MS);
child.on("close", (code) => {
clearTimeout(killer);
thread.child = null;
if (!sawDone) {
broadcast({
type: "chat_done",
slug,
ok: code === 0 && Boolean(lastText),
text: lastText,
error: code === 0 ? undefined : stderrTail.trim().slice(-200) || `exit ${code}`,
});
}
});
child.on("error", (err) => {
clearTimeout(killer);
thread.child = null;
broadcast({ type: "chat_done", slug, ok: false, text: "", error: String(err).slice(0, 200) });
});
return { ok: true, resumed: Boolean(resumeId) };
}
async function handleChatRequest(req, res) {
const cors = corsFor(req);
if (req.method === "OPTIONS") {
res.writeHead(Object.keys(cors).length ? 204 : 403, cors);
res.end();
return;
}
if (!Object.keys(cors).length) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "origin not allowed" }));
return;
}
const reply = (status, payload) => {
res.writeHead(status, { ...cors, "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
};
try {
const body = JSON.parse(await readBody(req));
const slug = String(body.slug ?? "");
if (!projects.has(slug)) return reply(404, { ok: false, error: "unknown project" });
if (body.reset) {
const thread = chatThreads.get(slug);
if (thread?.child) thread.child.kill();
chatThreads.delete(slug);
return reply(200, { ok: true });
}
const message = String(body.message ?? "").trim();
if (!message || message.length > 8000) {
return reply(400, { ok: false, error: "message must be 18000 chars" });
}
const result = launchChat(slug, message, Boolean(body.fromProject));
reply(result.ok ? 200 : 409, result);
} catch {
reply(400, { ok: false, error: "bad request" });
}
}
function corsFor(req) { function corsFor(req) {
const origin = req.headers.origin; const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) { if (origin && ALLOWED_ORIGINS.has(origin)) {
@@ -380,6 +523,10 @@ const server = createServer((req, res) => {
handleTaskRequest(req, res); handleTaskRequest(req, res);
return; return;
} }
if (req.url === "/chat") {
handleChatRequest(req, res);
return;
}
if (req.url === "/events") { if (req.url === "/events") {
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
+67 -36
View File
@@ -1,62 +1,93 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants"; import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { generateAppearance } from "@/lib/appearance"; import { generateAppearance } from "@/lib/appearance";
import { useOfficeStore } from "@/store/office-store"; import { useOfficeStore } from "@/store/office-store";
import { getDirector } from "@/sim/runtime"; import { getDirector } from "@/sim/runtime";
import { dispatchTask } from "@/gateway/live"; import { resetChat, sendChat } from "@/gateway/live";
import { Pawn } from "./Pawn"; import { Pawn } from "./Pawn";
const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const; const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const;
/** LIVE mode: dispatch a real headless task to this project via the watcher. */ /** LIVE mode: two-way chat with this project — each message runs headless, stitched with --resume. */
function TaskComposer({ agentId }: { agentId: string }) { function ChatPanel({ agentId }: { agentId: string }) {
const [prompt, setPrompt] = useState(""); const thread = useOfficeStore((s) => s.chats.get(agentId));
const [resume, setResume] = useState(false); const [input, setInput] = useState("");
const [sending, setSending] = useState(false); const [fromProject, setFromProject] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const send = async () => { const busy = thread?.busy ?? false;
const text = prompt.trim(); const messages = thread?.messages ?? [];
if (!text || sending) return; const started = messages.length > 0;
setSending(true);
const result = await dispatchTask(agentId, text, resume); useEffect(() => {
setSending(false); listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
if (result.ok) { }, [messages.length, busy]);
setPrompt("");
if (result.queued) { const send = () => {
useOfficeStore.getState().addEvent("⏳", `任務已排隊(第 ${result.queued} 位)`); const text = input.trim();
} if (!text || busy) return;
} else { sendChat(agentId, text, fromProject);
useOfficeStore.getState().addEvent("⚠️", `派發失敗:${result.error ?? "未知錯誤"}`); setInput("");
}
}; };
return ( return (
<div className="task-composer"> <div className="chat-panel">
<div className="chat-head">
<h3></h3>
{started && (
<button className="link-btn" onClick={() => resetChat(agentId)} disabled={busy}>
🔄
</button>
)}
</div>
{started && (
<div className="chat-list" ref={listRef}>
{messages.map((m, i) => (
<div key={i} className={`chat-msg ${m.role}`}>
{m.text}
</div>
))}
{busy && <div className="chat-msg pending"> ,</div>}
</div>
)}
<textarea <textarea
value={prompt} value={input}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="輸入要派發的任務,將以 headless session 在該專案執行…" placeholder={started ? "繼續對話…(Enter 送出)" : "跟這個專案的 Codex 對話…(Enter 送出)"}
rows={3} rows={2}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send(); if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}} }}
/> />
<div className="task-composer-row"> <div className="task-composer-row">
{!started ? (
<label <label
className="tip" className="tip"
data-tip="延續該專案最近一段「已結束」的 session 繼續對話(codex exec resume)。注意:無法插入正在終端機進行中的互動對話;若該專案已有任務在跑,新任務會自動排隊。" data-tip="第一句話從該專案最近一段「已結束」的 session 接續(codex exec resume)。注意:無法插入正在終端機進行中的互動對話。"
> >
<input type="checkbox" checked={resume} onChange={(e) => setResume(e.target.checked)} /> <input
session type="checkbox"
checked={fromProject}
onChange={(e) => setFromProject(e.target.checked)}
/>
session
</label> </label>
) : (
<span <span
className="tip" className="chat-hint tip"
data-tip="在該專案目錄開一個 headless session 執行此任務,過程會即時顯示在辦公室,完成後於事件紀錄回報。" data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,每則訊息逐則接力。"
> >
<button className="btn assign-btn" onClick={send} disabled={sending || !prompt.trim()}>
{sending ? "派發中…" : "🚀 派發任務"}
</button>
</span> </span>
)}
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
{busy ? "執行中…" : "送出"}
</button>
</div> </div>
</div> </div>
); );
@@ -155,7 +186,7 @@ export function SidePanel() {
</button> </button>
)} )}
{agent.role !== "subagent" && mode === "live" && <TaskComposer agentId={agent.id} />} {agent.role !== "subagent" && mode === "live" && <ChatPanel agentId={agent.id} />}
</div> </div>
) : ( ) : (
<div className="panel-hint"> <div className="panel-hint">
+81
View File
@@ -160,6 +160,40 @@ function onEvent(e: MessageEvent) {
bump(id); bump(id);
break; break;
} }
case "chat_start": {
if (!S().agents.has(id)) break;
S().setStatus(id, "thinking");
bump(id);
break;
}
case "chat_text": {
if (!S().agents.has(id)) break;
S().chatAppend(id, { role: "assistant", text: ev.text as string });
S().setStatus(id, "speaking");
S().setSpeech(id, ev.text as string);
bump(id);
break;
}
case "chat_done": {
if (!S().agents.has(id)) break;
S().chatSetBusy(id, false);
const thread = S().chats.get(id);
const lastAssistant = [...(thread?.messages ?? [])].reverse().find((m) => m.role === "assistant");
const finalText = (ev.text as string) ?? "";
if (ev.ok) {
// stream-json 的 result 可能與最後一則 assistant 相同,避免重複
if (finalText && finalText !== lastAssistant?.text) {
S().chatAppend(id, { role: "assistant", text: finalText });
S().setSpeech(id, finalText);
S().setStatus(id, "speaking");
}
} else {
S().chatAppend(id, { role: "system", text: `執行失敗:${(ev.error as string) ?? "未知錯誤"}` });
S().setStatus(id, "error");
}
bump(id);
break;
}
} }
} }
@@ -182,6 +216,53 @@ export async function dispatchTask(
} }
} }
/** Send a chat message to the project's office thread (auto-resumed server-side). */
export async function sendChat(
agentId: string,
message: string,
fromProject: boolean,
): Promise<boolean> {
const slug = agentId.replace(/^proj-/, "");
S().chatAppend(agentId, { role: "user", text: message });
S().chatSetBusy(agentId, true);
try {
const res = await fetch(`${WATCH_ORIGIN}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, message, fromProject }),
});
const data = (await res.json()) as { ok: boolean; error?: string };
if (!data.ok) {
S().chatSetBusy(agentId, false);
S().chatAppend(agentId, {
role: "system",
text: data.error === "busy" ? "上一則還在執行中,請稍候" : `送出失敗:${data.error ?? "未知錯誤"}`,
});
return false;
}
return true;
} catch {
S().chatSetBusy(agentId, false);
S().chatAppend(agentId, { role: "system", text: "無法連線監看伺服器 (npm run watch)" });
return false;
}
}
/** Reset the office chat thread (kills any in-flight run server-side). */
export async function resetChat(agentId: string): Promise<void> {
const slug = agentId.replace(/^proj-/, "");
S().chatClear(agentId);
try {
await fetch(`${WATCH_ORIGIN}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, reset: true }),
});
} catch {
/* offline — local clear is enough */
}
}
/** Fade statuses back to idle / offline when a project goes quiet. */ /** Fade statuses back to idle / offline when a project goes quiet. */
function decayPass() { function decayPass() {
const now = Date.now(); const now = Date.now();
+48 -1
View File
@@ -13,11 +13,23 @@ import type {
VisualAgent, VisualAgent,
} from "@/lib/types"; } from "@/lib/types";
export interface ChatMessage {
role: "user" | "assistant" | "system";
text: string;
}
export interface ChatThread {
messages: ChatMessage[];
busy: boolean;
}
interface OfficeState { interface OfficeState {
agents: Map<string, VisualAgent>; agents: Map<string, VisualAgent>;
links: CollaborationLink[]; links: CollaborationLink[];
meetings: Meeting[]; meetings: Meeting[];
events: OfficeEvent[]; events: OfficeEvent[];
/** LIVE-mode chat threads, keyed by agent id */
chats: Map<string, ChatThread>;
selectedAgentId: string | null; selectedAgentId: string | null;
theme: "light" | "dark"; theme: "light" | "dark";
paused: boolean; paused: boolean;
@@ -37,6 +49,10 @@ interface OfficeState {
togglePanel: () => void; togglePanel: () => void;
clearWorld: () => void; clearWorld: () => void;
chatAppend: (agentId: string, msg: ChatMessage) => void;
chatSetBusy: (agentId: string, busy: boolean) => void;
chatClear: (agentId: string) => void;
addEvent: (icon: string, text: string) => void; addEvent: (icon: string, text: string) => void;
spawnAgent: (a: { spawnAgent: (a: {
@@ -101,6 +117,7 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
links: [], links: [],
meetings: [], meetings: [],
events: [], events: [],
chats: new Map(),
selectedAgentId: null, selectedAgentId: null,
theme: savedTheme === "dark" ? "dark" : "light", theme: savedTheme === "dark" ? "dark" : "light",
paused: false, paused: false,
@@ -126,7 +143,37 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
return { panelOpen: !state.panelOpen }; return { panelOpen: !state.panelOpen };
}), }),
clearWorld: () => clearWorld: () =>
set({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null }), set({
agents: new Map(),
links: [],
meetings: [],
events: [],
chats: new Map(),
selectedAgentId: null,
}),
chatAppend: (agentId, msg) =>
set((state) => {
const chats = new Map(state.chats);
const thread = chats.get(agentId) ?? { messages: [], busy: false };
chats.set(agentId, { ...thread, messages: [...thread.messages, msg].slice(-100) });
return { chats };
}),
chatSetBusy: (agentId, busy) =>
set((state) => {
const chats = new Map(state.chats);
const thread = chats.get(agentId) ?? { messages: [], busy: false };
chats.set(agentId, { ...thread, busy });
return { chats };
}),
chatClear: (agentId) =>
set((state) => {
const chats = new Map(state.chats);
chats.delete(agentId);
return { chats };
}),
addEvent: (icon, text) => addEvent: (icon, text) =>
set((state) => ({ set((state) => ({
+102
View File
@@ -348,6 +348,108 @@
border-color: transparent; border-color: transparent;
} }
.chat-panel {
margin-top: 14px;
}
.chat-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.chat-head h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.chat-head .link-btn:disabled {
opacity: 0.4;
cursor: default;
}
.chat-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
padding: 8px;
margin-bottom: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
}
.chat-msg {
max-width: 88%;
font-size: 12px;
line-height: 1.5;
padding: 6px 10px;
border-radius: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
animation: card-in 0.2s ease-out;
}
.chat-msg.user {
align-self: flex-end;
background: var(--terracotta);
color: #fff;
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--bg-panel);
border: 1px solid var(--border);
border-bottom-left-radius: 3px;
}
.chat-msg.system {
align-self: center;
color: var(--text-dim);
font-size: 11px;
font-style: italic;
background: none;
padding: 2px 6px;
}
.chat-msg.pending {
align-self: flex-start;
color: var(--text-dim);
font-size: 11px;
background: none;
animation: emote-bob 1.6s ease-in-out infinite;
}
.chat-panel textarea {
width: 100%;
resize: vertical;
font-family: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
.chat-panel textarea:focus {
outline: none;
border-color: var(--terracotta);
}
.chat-hint {
font-size: 11px;
color: var(--text-dim);
}
.task-composer { .task-composer {
margin-top: 14px; margin-top: 14px;
} }