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
+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) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
@@ -380,6 +523,10 @@ const server = createServer((req, res) => {
handleTaskRequest(req, res);
return;
}
if (req.url === "/chat") {
handleChatRequest(req, res);
return;
}
if (req.url === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",