feat(run): wire Workspace to TerminalView, delete the stream-json Run feature
Combines three tasks that couldn't land as separate commits: the pre-commit hook's full test run crashes on any intermediate state where Workspace.tsx still imports the files being deleted, so the deletion (old RunConsole/useRunStream/run-spawner/stream-json-parser), the RunSetup/RunHistory type adjustments, and this file's own TerminalView wiring had to be staged together and committed as one hook-passable unit. - Delete RunConsole.tsx, useRunStream.ts, server/lib/run-spawner.js, server/lib/stream-json-parser.js and their tests (Task 8). - Adjust RunSetup.tsx/RunHistory.tsx to the tmux-backed RunHandle/ RunStartArgs/DashboardRunHistoryItem shapes, remove mode selection UI (Task 9). - Swap Workspace.tsx's chat-bubble run console for TerminalView (xterm.js over /ws-pty/:runId), drop the stream-json envelope plumbing, update Start/Resume to the new RunStartArgs payload. Create onStartFromSetup handler to work with RunSetup's new callback shape. Remove mode state and related plumbing. Remove send/followUp state (no longer using old RunConsole chat interface). - Add promptPlaceholderTerminal i18n key to support RunSetup's new placeholder text (Task 10). - Update Workspace.test.tsx to mock TerminalView component. - Regenerate screens.snapshot.test.tsx snapshot (only Workspace run panel changes: terminal container instead of chat bubbles).
This commit is contained in:
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* @file run-spawner.js
|
||||
* @description Spawns and supervises Claude Code subprocesses for the
|
||||
* dashboard's Run page. Two modes:
|
||||
* - "headless" — single-shot. Stdin is closed after spawn; the prompt
|
||||
* lives in argv via `-p`. Process exits when the model
|
||||
* finishes the turn.
|
||||
* - "conversation" — multi-turn. Stdin stays open; follow-up turns are
|
||||
* delivered via JSON envelopes through stdin and the
|
||||
* caller can pipe more messages until they kill or the
|
||||
* child exits naturally.
|
||||
*
|
||||
* Conversation mode also supports resuming an existing session via
|
||||
* `--resume <session-id>`, so the user can continue any prior Claude Code
|
||||
* conversation from inside the dashboard.
|
||||
*
|
||||
* Output is always `--output-format stream-json --verbose` so the parser can
|
||||
* deliver structured envelopes (system/init, assistant text+tool_use, user
|
||||
* tool_result, result/success, etc). Each envelope is broadcast over the
|
||||
* dashboard's existing WebSocket as a `run_stream` message; status changes
|
||||
* (spawning → running → completed/error/killed) broadcast as `run_status`.
|
||||
*
|
||||
* Concurrency is capped (RUN_MAX_CONCURRENT, default 10) — over the cap we
|
||||
* throw ECONCURRENCY with the running set so the route can return 429.
|
||||
*
|
||||
* When a child truly finishes (real exit, or a spawn that never started) the
|
||||
* handler registered via setRunExitHandler is called once. That inversion is
|
||||
* how a lane gets released without this module requiring the lane router back.
|
||||
*
|
||||
* Each handle keeps a bounded in-memory envelope log (cap 500) so a client
|
||||
* that attaches late can replay what it missed. Completed handles are reaped
|
||||
* after 5 min — but the underlying transcripts persist via the normal hook
|
||||
* ingestion pipeline (every spawned `claude` fires hooks like any other
|
||||
* session, so the run shows up in /sessions automatically).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// cross-spawn (not node:child_process): on Windows the npm-installed `claude`
|
||||
// is a `.cmd` shim that plain spawn can't launch, and the naive fix (`shell:
|
||||
// true`) would run argv — including the user-controlled prompt/model — through
|
||||
// cmd.exe, opening a command-injection hole. cross-spawn resolves the shim and
|
||||
// escapes arguments safely without a shell. On macOS/Linux it is a plain spawn.
|
||||
const spawn = require("cross-spawn");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { createLineParser } = require("./stream-json-parser");
|
||||
|
||||
// Persistence is best-effort and optional — load lazily so unit tests that
|
||||
// don't bring up the full db can still exercise the spawner.
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
dashboardRuns = require("./dashboard-runs");
|
||||
} catch {
|
||||
/* db-less environment, skip persistence */
|
||||
}
|
||||
function recordRun(handle) {
|
||||
if (dashboardRuns) dashboardRuns.recordRun(handle);
|
||||
}
|
||||
function patchRun(args) {
|
||||
if (dashboardRuns) dashboardRuns.patchRun(args);
|
||||
}
|
||||
|
||||
// Whoever owns lanes registers here at boot (routes/lanes.js) so a finished run
|
||||
// can release its lane. The dependency is inverted deliberately: the lane router
|
||||
// already requires THIS module, and releasing needs the router's lanePayload /
|
||||
// lastEventAge to broadcast — requiring it back would be a cycle.
|
||||
let runExitHandler = null;
|
||||
function setRunExitHandler(fn) {
|
||||
runExitHandler = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
/** Announce a truly-exited run. Never lets a listener break run bookkeeping. */
|
||||
function notifyRunExit(handle) {
|
||||
if (!runExitHandler) return;
|
||||
try {
|
||||
runExitHandler({ runId: handle.id, laneId: handle.laneId || null });
|
||||
} catch {
|
||||
/* a broken listener is not the run's problem */
|
||||
}
|
||||
}
|
||||
|
||||
// Effectively uncapped — claude's terminal TUI doesn't gate concurrent
|
||||
// sessions, so we don't either. The number is high enough that a buggy
|
||||
// client still can't fork-bomb the host before someone notices, but low
|
||||
// enough that no human will ever hit it organically. Users who want a
|
||||
// real cap can set RUN_MAX_CONCURRENT.
|
||||
const MAX_CONCURRENT_DEFAULT = 10000;
|
||||
const REAP_AFTER_MS = 5 * 60 * 1000; // keep handle for 5 min after exit
|
||||
const STDOUT_TAIL_BYTES = 4 * 1024;
|
||||
const STDERR_TAIL_BYTES = 4 * 1024;
|
||||
// Cap stored envelopes per handle so a long-running conversation doesn't
|
||||
// balloon memory. Late-attaching clients get this much history; the full
|
||||
// transcript is always available via the existing /sessions/<id> view.
|
||||
const MAX_ENVELOPES_PER_HANDLE = 500;
|
||||
|
||||
const handles = new Map();
|
||||
const reapers = new Map();
|
||||
|
||||
function getMaxConcurrent() {
|
||||
const raw = process.env.RUN_MAX_CONCURRENT;
|
||||
if (!raw) return MAX_CONCURRENT_DEFAULT;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : MAX_CONCURRENT_DEFAULT;
|
||||
}
|
||||
|
||||
function liveCount() {
|
||||
let n = 0;
|
||||
for (const h of handles.values()) {
|
||||
if (h.status === "spawning" || h.status === "running") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function tail(s, n) {
|
||||
if (typeof s !== "string") return "";
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(s.length - n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build argv for the `claude` invocation. The two modes have different argv
|
||||
* shapes because of how Claude Code resolves the first user message:
|
||||
*
|
||||
* - HEADLESS: `-p "<prompt>"` carries the prompt; stdin is closed; Claude
|
||||
* processes one turn and exits.
|
||||
* - CONVERSATION: `--input-format stream-json` puts Claude in multi-turn
|
||||
* mode where ALL user turns (including the first) come via stdin. When
|
||||
* stream-json input is enabled, `-p` is silently ignored — so we OMIT
|
||||
* it and send the initial prompt over stdin in `spawnRun` immediately
|
||||
* after the spawn handshake.
|
||||
*/
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
function buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort }) {
|
||||
const argv = [];
|
||||
argv.push("--output-format", "stream-json");
|
||||
argv.push("--verbose");
|
||||
// Real character-by-character streaming. Without this flag Claude only
|
||||
// emits the *final* assistant envelope, which makes the UI feel like the
|
||||
// response arrives all at once. With it, we also receive `stream_event`
|
||||
// envelopes (Anthropic Messages API streaming events) so the UI can
|
||||
// render text + thinking deltas as they arrive.
|
||||
argv.push("--include-partial-messages");
|
||||
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
||||
if (mode === "headless") {
|
||||
argv.push("-p", prompt);
|
||||
} else {
|
||||
argv.push("--input-format", "stream-json");
|
||||
}
|
||||
if (model) {
|
||||
argv.push("--model", model);
|
||||
}
|
||||
if (effort && EFFORT_LEVELS.has(effort)) {
|
||||
// Drives thinking depth: higher = more reasoning tokens before the
|
||||
// assistant turn. Empty / unset means "inherit from the model's default".
|
||||
argv.push("--effort", effort);
|
||||
}
|
||||
if (resumeSessionId) {
|
||||
argv.push("--resume", resumeSessionId);
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame a stream-json user envelope. Used both for the initial conversation-
|
||||
* mode prompt and for follow-up turns via sendInput.
|
||||
*/
|
||||
function userEnvelope(text, id) {
|
||||
const e = {
|
||||
type: "user",
|
||||
message: { role: "user", content: text },
|
||||
};
|
||||
if (id) e.id = id;
|
||||
return JSON.stringify(e) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip dashboard-internal env vars from the child so the spawned `claude`
|
||||
* doesn't accidentally pick up our hook-handler context (and to keep the
|
||||
* child's auth entirely from the user's existing OAuth in $HOME).
|
||||
*/
|
||||
function cleanSpawnEnv() {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
|
||||
return env;
|
||||
}
|
||||
|
||||
function attachStreamHandlers(handle) {
|
||||
const parser = createLineParser(
|
||||
(envelope) => {
|
||||
// First parsed envelope means the child is producing output → "running".
|
||||
if (handle.status === "spawning") {
|
||||
handle.status = "running";
|
||||
broadcast("run_status", { id: handle.id, status: "running", at: Date.now() });
|
||||
patchRun({ id: handle.id, status: "running" });
|
||||
}
|
||||
// Capture session_id off the system/init envelope — once we have it the
|
||||
// dashboard can deep-link to /sessions/<id> on completion.
|
||||
if (
|
||||
envelope &&
|
||||
envelope.type === "system" &&
|
||||
envelope.subtype === "init" &&
|
||||
typeof envelope.session_id === "string"
|
||||
) {
|
||||
const wasNull = !handle.sessionId;
|
||||
handle.sessionId = envelope.session_id;
|
||||
if (wasNull) patchRun({ id: handle.id, sessionId: envelope.session_id });
|
||||
}
|
||||
handle.envelopeCount += 1;
|
||||
handle.envelopes.push(envelope);
|
||||
// Keep only the most recent N — older entries are still in the disk
|
||||
// transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,
|
||||
// visible via the regular /sessions/<id> dashboard view.
|
||||
if (handle.envelopes.length > MAX_ENVELOPES_PER_HANDLE) {
|
||||
handle.envelopes.splice(0, handle.envelopes.length - MAX_ENVELOPES_PER_HANDLE);
|
||||
}
|
||||
broadcast("run_stream", { id: handle.id, envelope });
|
||||
},
|
||||
(err, raw) => {
|
||||
handle.stderrBuffer += `[parse-error] ${err.message}: ${raw}\n`;
|
||||
}
|
||||
);
|
||||
|
||||
handle.child.stdout.on("data", (chunk) => {
|
||||
const s = chunk.toString("utf8");
|
||||
handle.stdoutBuffer = tail(handle.stdoutBuffer + s, STDOUT_TAIL_BYTES);
|
||||
parser.push(s);
|
||||
});
|
||||
handle.child.stderr.on("data", (chunk) => {
|
||||
handle.stderrBuffer = tail(handle.stderrBuffer + chunk.toString("utf8"), STDERR_TAIL_BYTES);
|
||||
});
|
||||
handle.child.on("error", (err) => {
|
||||
// A spawn error has no corresponding `exit` event: the OS never started
|
||||
// the child, so it can no longer touch the lane directory.
|
||||
handle.actualExitedAt = Date.now();
|
||||
handle.status = "error";
|
||||
handle.error = err.message;
|
||||
handle.endedAt = Date.now();
|
||||
broadcast("run_status", {
|
||||
id: handle.id,
|
||||
status: "error",
|
||||
error: err.message,
|
||||
at: handle.endedAt,
|
||||
});
|
||||
patchRun({ id: handle.id, status: "error", endedAt: handle.endedAt });
|
||||
scheduleReap(handle.id);
|
||||
// A spawn that never started is just as finished as one that ran: without
|
||||
// this the lane stays `running` forever with a dead run_id.
|
||||
notifyRunExit(handle);
|
||||
});
|
||||
handle.child.on("exit", (code, signal) => {
|
||||
parser.flush();
|
||||
// `killRun` deliberately sets status to `killed` immediately after it
|
||||
// requests SIGTERM. Keep this separate, exit-only signal so callers that
|
||||
// must not touch a run's cwd until the OS reaps it can wait truthfully.
|
||||
handle.actualExitedAt = Date.now();
|
||||
if (handle.status === "killed") {
|
||||
// already broadcast — patchRun already happened in stop()
|
||||
} else {
|
||||
handle.status = code === 0 ? "completed" : "error";
|
||||
handle.exitCode = code;
|
||||
handle.signal = signal;
|
||||
handle.endedAt = Date.now();
|
||||
broadcast("run_status", {
|
||||
id: handle.id,
|
||||
status: handle.status,
|
||||
exitCode: code,
|
||||
sessionId: handle.sessionId || null,
|
||||
at: handle.endedAt,
|
||||
});
|
||||
patchRun({
|
||||
id: handle.id,
|
||||
status: handle.status,
|
||||
exitCode: code,
|
||||
sessionId: handle.sessionId || null,
|
||||
endedAt: handle.endedAt,
|
||||
});
|
||||
}
|
||||
scheduleReap(handle.id);
|
||||
// Fires for a killed run too — killRun only flags `killed` before the OS
|
||||
// reaps the child; a killed run is a finished run.
|
||||
notifyRunExit(handle);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReap(id) {
|
||||
const existing = reapers.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
const t = setTimeout(() => {
|
||||
handles.delete(id);
|
||||
reapers.delete(id);
|
||||
}, REAP_AFTER_MS);
|
||||
// Don't keep the process alive just for the reap timer.
|
||||
if (typeof t.unref === "function") t.unref();
|
||||
reapers.set(id, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} args
|
||||
* @param {string} args.prompt
|
||||
* @param {"headless"|"conversation"} args.mode
|
||||
* @param {string} [args.cwd]
|
||||
* @param {string} [args.model]
|
||||
* @param {string} [args.permissionMode]
|
||||
* @param {number} [args.laneId] Lane this run was started through; persisted so
|
||||
* the Workspace page can list one lane's runs. Omitted by POST /api/run.
|
||||
* @returns handle
|
||||
*/
|
||||
function spawnRun(args) {
|
||||
const { prompt, mode, cwd, model, permissionMode, resumeSessionId, effort, laneId } = args || {};
|
||||
if (typeof prompt !== "string") {
|
||||
throw makeErr("EBADPROMPT", "prompt is required");
|
||||
}
|
||||
// Empty prompt is allowed only when resuming a conversation — claude
|
||||
// idles on the resumed transcript until the user types a follow-up.
|
||||
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
|
||||
throw makeErr("EBADPROMPT", "prompt is required");
|
||||
}
|
||||
if (mode !== "headless" && mode !== "conversation") {
|
||||
throw makeErr("EBADMODE", `mode must be "headless" or "conversation"`);
|
||||
}
|
||||
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
|
||||
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
|
||||
}
|
||||
if (resumeSessionId != null) {
|
||||
if (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
// Resume only makes sense in conversation mode (you want to keep talking).
|
||||
// Headless `claude --resume` does run, but the UX of "send one prompt and
|
||||
// exit" on a resumed session is confusing — disallow.
|
||||
if (mode !== "conversation") {
|
||||
throw makeErr("EBADMODE", "resumeSessionId requires conversation mode");
|
||||
}
|
||||
}
|
||||
const max = getMaxConcurrent();
|
||||
if (liveCount() >= max) {
|
||||
const err = makeErr("ECONCURRENCY", `concurrency limit ${max} reached`);
|
||||
err.running = Array.from(handles.values())
|
||||
.filter((h) => h.status === "running" || h.status === "spawning")
|
||||
.map((h) => ({ id: h.id, pid: h.pid, startedAt: h.startedAt, mode: h.mode }));
|
||||
throw err;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const argv = buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort });
|
||||
// cross-spawn handles the Windows `.cmd` shim safely (see the require above);
|
||||
// deliberately no `shell` option, so argv is never parsed by cmd.exe.
|
||||
const child = spawn("claude", argv, {
|
||||
env: cleanSpawnEnv(),
|
||||
cwd: cwd || process.cwd(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const handle = {
|
||||
id,
|
||||
pid: child.pid || null,
|
||||
mode,
|
||||
cwd: cwd || process.cwd(),
|
||||
model: model || null,
|
||||
permissionMode: permissionMode || "acceptEdits",
|
||||
effort: effort || null,
|
||||
prompt,
|
||||
argv,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
laneId: typeof laneId === "number" ? laneId : null,
|
||||
status: "spawning",
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
actualExitedAt: null,
|
||||
sessionId: resumeSessionId || null, // optimistic; will be confirmed by system/init envelope
|
||||
envelopeCount: 0,
|
||||
envelopes: [],
|
||||
stdoutBuffer: "",
|
||||
stderrBuffer: "",
|
||||
child,
|
||||
};
|
||||
handles.set(id, handle);
|
||||
recordRun(handle);
|
||||
|
||||
attachStreamHandlers(handle);
|
||||
|
||||
if (mode === "headless") {
|
||||
// Headless: prompt is in argv; close stdin so Claude knows nothing more
|
||||
// is coming and exits after the one turn.
|
||||
try {
|
||||
child.stdin.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (prompt && prompt.trim()) {
|
||||
// Conversation: deliver the initial prompt over stdin so Claude in
|
||||
// stream-json input mode actually starts processing it. Stdin stays
|
||||
// open for follow-up turns.
|
||||
try {
|
||||
child.stdin.write(userEnvelope(prompt));
|
||||
} catch (err) {
|
||||
handle.stderrBuffer += `[stdin-write-error] ${err.message}\n`;
|
||||
}
|
||||
}
|
||||
// Conversation with empty prompt (resume scenarios) — leave stdin open;
|
||||
// claude will idle on the resumed conversation until the user types a
|
||||
// follow-up via POST /:id/message.
|
||||
|
||||
broadcast("run_status", { id, status: "spawning", at: handle.startedAt });
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a follow-up user turn into a running conversation. Throws if the
|
||||
* handle is not running, not in conversation mode, or stdin is closed.
|
||||
*/
|
||||
function sendInput(id, text) {
|
||||
const handle = handles.get(id);
|
||||
if (!handle) throw makeErr("ENOTFOUND", "run not found");
|
||||
if (handle.mode !== "conversation") {
|
||||
throw makeErr("EWRONGMODE", "only conversation mode accepts follow-up input");
|
||||
}
|
||||
if (handle.status !== "running" && handle.status !== "spawning") {
|
||||
throw makeErr("ENOTRUNNING", `run is ${handle.status}`);
|
||||
}
|
||||
if (typeof text !== "string" || !text) {
|
||||
throw makeErr("EBADINPUT", "text is required");
|
||||
}
|
||||
if (!handle.child || !handle.child.stdin || !handle.child.stdin.writable) {
|
||||
throw makeErr("ESTDINCLOSED", "stdin is not writable");
|
||||
}
|
||||
const messageId = randomUUID();
|
||||
handle.child.stdin.write(userEnvelope(text, messageId));
|
||||
broadcast("run_input_ack", { id, messageId, at: Date.now() });
|
||||
return { messageId };
|
||||
}
|
||||
|
||||
function killRun(id) {
|
||||
const handle = handles.get(id);
|
||||
if (!handle) return false;
|
||||
if (handle.status === "completed" || handle.status === "error" || handle.status === "killed") {
|
||||
return true;
|
||||
}
|
||||
if (handle.child && !handle.child.killed) {
|
||||
try {
|
||||
handle.child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setTimeout(() => {
|
||||
const h = handles.get(id);
|
||||
if (h && h.child && !h.actualExitedAt) {
|
||||
try {
|
||||
h.child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, 5000).unref?.();
|
||||
}
|
||||
handle.status = "killed";
|
||||
handle.endedAt = Date.now();
|
||||
broadcast("run_status", { id, status: "killed", at: handle.endedAt });
|
||||
patchRun({ id, status: "killed", endedAt: handle.endedAt });
|
||||
scheduleReap(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function publicHandle(handle, opts = {}) {
|
||||
if (!handle) return null;
|
||||
const out = {
|
||||
id: handle.id,
|
||||
pid: handle.pid,
|
||||
mode: handle.mode,
|
||||
cwd: handle.cwd,
|
||||
model: handle.model,
|
||||
permissionMode: handle.permissionMode,
|
||||
effort: handle.effort || null,
|
||||
prompt: handle.prompt,
|
||||
argv: handle.argv,
|
||||
resumeSessionId: handle.resumeSessionId || null,
|
||||
status: handle.status,
|
||||
startedAt: handle.startedAt,
|
||||
endedAt: handle.endedAt,
|
||||
exitCode: handle.exitCode,
|
||||
signal: handle.signal,
|
||||
error: handle.error,
|
||||
actualExitedAt: handle.actualExitedAt,
|
||||
sessionId: handle.sessionId,
|
||||
envelopeCount: handle.envelopeCount,
|
||||
stdoutTail: handle.stdoutBuffer,
|
||||
stderrTail: handle.stderrBuffer,
|
||||
};
|
||||
if (opts.includeEnvelopes) {
|
||||
out.envelopes = handle.envelopes.slice();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getRun(id, opts = {}) {
|
||||
return publicHandle(handles.get(id), opts);
|
||||
}
|
||||
|
||||
function listRuns() {
|
||||
return Array.from(handles.values())
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.map(publicHandle);
|
||||
}
|
||||
|
||||
function makeErr(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
// Test seam: inject a fake child (e.g. PassThrough streams) without invoking
|
||||
// the real `claude` binary. Returns the handle.
|
||||
function __injectChildForTest({ child, mode = "conversation", prompt = "test" }) {
|
||||
const id = randomUUID();
|
||||
const handle = {
|
||||
id,
|
||||
pid: 0,
|
||||
mode,
|
||||
cwd: process.cwd(),
|
||||
model: null,
|
||||
permissionMode: "acceptEdits",
|
||||
effort: null,
|
||||
prompt,
|
||||
argv: ["-p", prompt],
|
||||
resumeSessionId: null,
|
||||
status: "spawning",
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
actualExitedAt: null,
|
||||
sessionId: null,
|
||||
envelopeCount: 0,
|
||||
envelopes: [],
|
||||
stdoutBuffer: "",
|
||||
stderrBuffer: "",
|
||||
child,
|
||||
};
|
||||
handles.set(id, handle);
|
||||
attachStreamHandlers(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function __reset() {
|
||||
for (const t of reapers.values()) clearTimeout(t);
|
||||
reapers.clear();
|
||||
handles.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnRun,
|
||||
setRunExitHandler,
|
||||
sendInput,
|
||||
killRun,
|
||||
getRun,
|
||||
listRuns,
|
||||
liveCount,
|
||||
getMaxConcurrent,
|
||||
__injectChildForTest,
|
||||
__reset,
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @file stream-json-parser.js
|
||||
* @description Newline-delimited JSON line buffer for parsing `claude
|
||||
* --output-format stream-json` output. Reassembles arbitrarily chunked stdout
|
||||
* into discrete JSON envelopes (one per line). Robust to partial writes;
|
||||
* malformed lines are reported via onError but never throw.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
function createLineParser(onObject, onError) {
|
||||
let buf = "";
|
||||
return {
|
||||
push(chunk) {
|
||||
buf += chunk;
|
||||
let nlIdx;
|
||||
while ((nlIdx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nlIdx).trim();
|
||||
buf = buf.slice(nlIdx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
onObject(JSON.parse(line));
|
||||
} catch (err) {
|
||||
if (typeof onError === "function") onError(err, line);
|
||||
}
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
const tail = buf.trim();
|
||||
buf = "";
|
||||
if (!tail) return;
|
||||
try {
|
||||
onObject(JSON.parse(tail));
|
||||
} catch (err) {
|
||||
if (typeof onError === "function") onError(err, tail);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLineParser };
|
||||
Reference in New Issue
Block a user