Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
// `agentsView` fall back to `agentArgs` — an autonomous session with native
// approvals bypassed/auto-approved.
import { spawn } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

import { followFile, ptyEnabled, ptySpec, scriptFlavor, stripScriptBanner } from "./pty.mjs";

export const ENGINES = {
opencode: {
desc: "opencode — the open-source coding agent (SST/anomalyco)",
Expand Down Expand Up @@ -248,19 +251,55 @@ export function exitReason(r) {
* environment keys to be stripped (Claude uses this to avoid nested-session
* markers). Resolves { ok, code, signal } when the child exits.
*/
export function openPassthrough(target, args = []) {
export function openPassthrough(target, args = [], { onOutput } = {}) {
return new Promise((resolve) => {
let env = process.env;
if (target.stripEnv?.length) {
env = { ...process.env };
for (const k of target.stripEnv) delete env[k];
}
let child;
const spec = spawnSpec(target.bin, args);
try { child = spawn(spec.cmd, spec.args, { stdio: "inherit", env }); }
catch (e) { resolve({ ok: false, error: e }); return; }
child.on("error", (e) => resolve({ ok: false, error: e }));
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));

// With a mirror attached, run the child under a pseudo-terminal so a copy
// of its output can be streamed to the session page. `inherit` alone hands
// the child the tty's own file descriptors, so none of its bytes ever pass
// through this process. See src/pty.mjs for why this is script(1) and not
// a pipe or node-pty.
let transcript = null;
let workDir = null;
let stopFollow = null;
let launch = { ...spec, stdio: "inherit" };
if (ptyEnabled(onOutput)) {
try {
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
transcript = path.join(workDir, "transcript");
writeFileSync(transcript, "");
const wrapped = ptySpec(spec.cmd, spec.args, transcript, scriptFlavor());
if (wrapped) {
launch = { ...wrapped, stdio: "inherit" };
let first = true;
stopFollow = followFile(transcript, (chunk) => {
const clean = stripScriptBanner(chunk, first);
first = false;
if (clean) onOutput(clean);
});
}
} catch {
// Capture is a nicety; never let it stop the session from opening.
transcript = null;
}
}

const cleanup = () => {
try { stopFollow?.(); } catch { /* nothing left to drain */ }
if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
};

let child;
try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }
catch (e) { cleanup(); resolve({ ok: false, error: e }); return; }
child.on("error", (e) => { cleanup(); resolve({ ok: false, error: e }); });
child.on("exit", (code, signal) => { cleanup(); resolve({ ok: true, code, signal }); });
});
}

Expand Down
158 changes: 158 additions & 0 deletions src/pty.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// PTY capture for the session mirror.
//
// Children are launched with `stdio: "inherit"` so they own the real terminal —
// which is why an engine or tool feels native, and also why the mirror never
// saw a byte of their output: those writes go to the tty's file descriptors and
// never pass through this process (see src/mirror.mjs).
//
// To see them we have to be in the middle, but a plain pipe is not an option:
// every one of these programs checks isTTY and degrades (no colour, no prompts,
// no full-screen UI) the moment it is talking to a pipe. So we run the child
// under a real pseudo-terminal and read a copy of the stream from the side.
//
// node-pty would be the obvious tool and is deliberately not used: it is a
// native module, and moshcode installs by untarring a release and running node
// (see install.sh) — there is no compiler in that path. `script(1)` allocates
// the same pseudo-terminal using nothing but the base system.
//
// Capability detection is required, not optional: util-linux and BSD/macOS
// `script` disagree on both flag names and argument order, and anything we
// cannot positively identify falls back to today's plain `inherit`.
import { spawnSync } from "node:child_process";
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";

/**
* POSIX single-quote escaping, for argv that has to survive being flattened
* into the single command string util-linux `script -c` accepts. A bare
* interpolation here would let an argument like `it's` break the command, or
* worse, run something else.
*/
export function shQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

/**
* Which `script(1)` this machine has: "util-linux", "bsd", or null when there
* is none we can drive. util-linux answers `--version`; BSD's has no version
* flag and exits non-zero on it, so darwin is identified by platform.
*/
export function scriptFlavor({ platform = process.platform, runner = spawnSync } = {}) {
let out = "";
try {
const r = runner("script", ["--version"], { encoding: "utf8" });
if (r?.error) return null;
out = `${r?.stdout || ""}${r?.stderr || ""}`;
} catch {
return null;
}
if (/util-linux/i.test(out)) return "util-linux";
// BSD script printed a usage error rather than a version — that is still a
// usable script, but only on darwin do we know the flag set for certain.
if (platform === "darwin") return "bsd";
return null;
}

/**
* The spawn spec that runs `cmd args…` under a pseudo-terminal while recording
* a copy of everything to `transcript`. Returns null for an unknown flavour.
*
* -q / -Q suppress script's own "Script started/done" banner, so the transcript
* holds the child's bytes and nothing else. -f / -F flush on every write, which
* is what makes this realtime rather than a post-mortem log. util-linux -e
* makes script exit with the child's status, which callers rely on.
*/
export function ptySpec(cmd, args = [], transcript, flavor) {
if (!cmd || !transcript) return null;
if (flavor === "util-linux") {
const line = [cmd, ...args].map(shQuote).join(" ");
return { cmd: "script", args: ["-q", "-e", "-f", "-c", line, transcript] };
}
if (flavor === "bsd") {
// BSD takes the transcript first and then a real argv, so no quoting.
return { cmd: "script", args: ["-q", "-F", transcript, cmd, ...args] };
}
return null;
}

/**
* Follow a transcript as it is written, handing each new slice to `onChunk`.
*
* Polls the size rather than using fs.watch: watch is unreliable across
* platforms and filesystems for a file being appended to by another process,
* and the mirror already batches on a 150ms timer, so a short poll costs
* nothing in perceived latency. Returns a stop() that drains whatever landed
* after the last tick before closing — the tail of a session is usually the
* part you care about.
*/
export function followFile(file, onChunk, { intervalMs = 100 } = {}) {
let fd = null;
let offset = 0;
let stopped = false;

const readNew = () => {
try {
if (fd === null) {
if (!existsSync(file)) return;
fd = openSync(file, "r");
}
const { size } = statSync(file);
// A transcript only grows; a smaller size means it was rotated or
// replaced, so resync rather than read garbage from the middle.
if (size < offset) offset = 0;
while (offset < size) {
const buf = Buffer.allocUnsafe(Math.min(65536, size - offset));
const read = readSync(fd, buf, 0, buf.length, offset);
if (read <= 0) break;
offset += read;
onChunk(buf.subarray(0, read).toString("utf8"));
}
} catch {
/* the child owns this file; a transient read error is not our problem */
}
};

const timer = setInterval(readNew, intervalMs);
timer.unref?.(); // never hold the process open for the sake of the mirror

return function stop() {
if (stopped) return;
stopped = true;
clearInterval(timer);
readNew(); // final drain
if (fd !== null) {
try { closeSync(fd); } catch { /* already gone */ }
fd = null;
}
};
}

// `script -q` silences the "Script started/done" notices on the terminal but
// still writes them into the transcript, so without this the mirror opens every
// engine session with a line of script(1) bookkeeping — including the fully
// quoted command line — and closes it with an exit-code footer. Both are
// anchored (header at the very start, footer at the very end), so this never
// touches output that merely happens to contain the words.
const HEADER = /^Script started on [^\n]*\n/;
const FOOTER = /\r?\nScript done on [^\n]*\n?$/;

/**
* Remove script(1)'s own bookkeeping from a transcript slice. `first` marks the
* opening slice, the only place a header can legitimately appear.
*/
export function stripScriptBanner(text, first = false) {
const withoutHeader = first ? String(text).replace(HEADER, "") : String(text);
return withoutHeader.replace(FOOTER, "");
}

/**
* Should we capture this launch? Only when someone is actually watching (a
* mirror sink is attached) and the box has a script(1) we understand. Users who
* are not mirroring keep the exact `inherit` path they have today, so the
* blast radius of this feature is limited to mirrored sessions.
* MOSHCODE_MIRROR_PTY=0 forces it off.
*/
export function ptyEnabled(sink, flavor = scriptFlavor()) {
if (typeof sink !== "function") return false;
if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
return Boolean(flavor);
}
4 changes: 2 additions & 2 deletions src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ export function toolUpgradeSpec(tool) {
}

/** Invoke a tool without parsing or modifying its arguments or streams. */
export function openTool(tool, args = []) {
return openPassthrough(tool, args);
export function openTool(tool, args = [], opts = {}) {
return openPassthrough(tool, args, opts);
}

// Generic utilities used by the app/package surface.
Expand Down
15 changes: 13 additions & 2 deletions src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,17 @@ async function upgradeAll(targets) {
// through every call.
let activeMirror = null;

/**
* Where a child's output should be copied to: the live mirror, or nowhere.
*
* teeOutput only sees what *this* process prints; a child launched with
* `stdio: "inherit"` writes straight to the tty and is invisible to it. Handing
* this sink down lets the launcher capture the child through a pty instead (see
* src/pty.mjs). Returning undefined when nothing is watching is what keeps
* unmirrored sessions on the untouched `inherit` path.
*/
const childSink = () => (activeMirror ? (chunk) => activeMirror?.write(chunk) : undefined);

async function openEngine(key, engine, args, { agentMode = false } = {}) {
if (!engine.installed && !args.length) {
console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
Expand All @@ -213,7 +224,7 @@ async function openEngine(key, engine, args, { agentMode = false } = {}) {
console.log(info(`opening ${bone(key)}${agentMode ? " autonomously" : " raw"} — hand-off to its CLI, exit it to come back…`));
console.log(hr());
activeMirror?.setEngine(key);
const r = await openSession(engine, agentMode ? agentLaunchArgs(engine, args) : args);
const r = await openSession(engine, agentMode ? agentLaunchArgs(engine, args) : args, { onOutput: childSink() });
activeMirror?.setEngine(null);
console.log(hr());
if (!r.ok) {
Expand All @@ -231,7 +242,7 @@ async function openWorkflowTool(key, tool, args) {
}
console.log(info(`opening ${bone(key)} — native CLI owns the terminal until it exits…`));
console.log(hr());
const result = await openTool(tool, args);
const result = await openTool(tool, args, { onOutput: childSink() });
console.log(hr());
if (!result.ok) {
console.log(result.error?.code === "ENOENT"
Expand Down
Loading
Loading