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:
@@ -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 1–8000 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",
|
||||
|
||||
Reference in New Issue
Block a user