Capability: terminal — EVERY command prompts until the user allows that program (see prefix approvals to skip the prompts).
Two APIs. Use exec for one-shot commands (returns when done). Use spawn for live interactive CLIs that stay alive and stream output.
One-shot commands
const result = await window.chatoss.terminal.exec('git status', { cwd: null, timeoutMs: 30000 });
// null = user denied. Otherwise: { output, exitCode, timedOut, cancelled }
Live interactive sessions
const session = await window.chatoss.terminal.spawn('ollama', { args: ['run', 'codex'], cwd: null, cols: 80, rows: 24 });
// null = user denied. Otherwise a handle with:
// session.id — the session id (pass to mount)
// await session.paste(text) — 🔴 send TEXT to an interactive CLI (see below)
// await session.key('enter') — 🔴 send ONE keypress (see below)
// await session.write(data) — raw stdin bytes (no read-boundary handling)
// await session.modes() — { bracketedPaste } as the child has it set
// await session.resize(cols, rows) — resize the PTY
// await session.kill() — kill the session + clean up
// const unsub = session.onData(cb) — cb(chunk, seq) per output chunk (fires live; seq is a
// monotonically increasing sequence number)
// const unsub = session.onExit(cb) — cb(code|null) once on exit
// const text = await session.getOutput() — ALL output so far (up to ~64KB)
// const text = await session.getOutput({ since }) — only output after the seq onData gave you
// const screen = await session.getScreen() — { lines, cursor, cols, rows }: the current
// VISIBLE screen, ANSI interpreted by a host-side headless
// xterm — what a user would SEE, not raw escape sequences
// const cursor = await session.getCursor() — { x, y } (0-based)
// const waiting = await session.isWaitingForInput() — true | false | 'unsupported' (see below)
// const unsub = session.onStateChange(cb) — cb({ busy, foregroundProcess }) on every
// busy/waiting transition
// const unsub = session.onDegenerateOutput(cb) — cb({ pattern, count, kind }) when the output
// collapses into a repeated-token / incoherent loop
// const info = await session.getDegenerateInfo() — one-shot read of the last such episode (or null)
// const answered = await session.answerPrompt(i) — detect a live multiple-choice prompt on screen
// and pick option i (0-based); resolves the chosen text,
// or null when no prompt was detected
Render a real terminal widget
No vendored library — the OS bundles xterm.js. mount() wires session output → terminal display + terminal input → session stdin:
const view = window.chatoss.terminal.mount(document.getElementById('term'), session.id, { fontSize: 14 });
// view.dispose() tears it down.
Driving a coding-agent CLI (claude, codex, aider)
🔴 Use
paste()for text andkey()for keys. NEVERwrite(text + '\r'). This is the single most common way an orchestrator app breaks.writeis raw bytes, so the text and the\rreach the CLI in ONE read() — and an Ink-based TUI (Claude Code, Codex) treats a large single read as a PASTE, which makes that\ra literal newline in the input box instead of Enter. The task then sits in the input box, unsubmitted, forever.paste()+key('enter')is the fix and needs no delays, no retries, and no scraping the screen to check.
// Submit a task to the CLI agent — the ONLY correct pattern:
await session.paste('Fix the login bug in src/auth.ts and run the tests');
await session.key('enter');
// paste() also keeps NEWLINES literal, so a multi-line prompt arrives as ONE
// message instead of submitting itself line by line. Don't flatten your prompts.
await session.paste('Do these in order:\n1. fix the bug\n2. run tests');
await session.key('enter');
Keys: 'enter' 'up' 'down' 'left' 'right' 'escape' 'tab' 'shift+tab' 'backspace' 'delete' 'home' 'end' 'pageup' 'pagedown' 'space' 'ctrl+c' 'ctrl+d' 'ctrl+u'
await session.key('down'); // navigate a menu
await session.key('enter'); // choose the highlighted option
await session.key('escape'); // dismiss a dialog
await session.key('ctrl+c'); // interrupt the agent
// Read what the terminal currently shows (to decide the next step):
const screen = await session.getOutput();
// React to output in real time:
session.onData(chunk => { if (chunk.includes('Done')) { /* task finished */ } });
Why the OS does this and not your app: key() is delivered as its OWN read() (ChatOSS waits for the CLI to finish rendering first), and paste() wraps the text in bracketed-paste markers only when the child actually enabled that mode — a fact that lives in the CLI's output stream, which your app never sees. Bracketing a CLI that has the mode off would type literal [200~ into its input box, so this decision cannot be made from app code. await session.modes() → { bracketedPaste } if you want to see what paste() will do. Both calls resolve after the bytes are written; paste() resolves true when it bracketed.
"Is the agent's turn done?"
🔴
isWaitingForInput()has THREE states, not two.
It resolves true (the foreground process is the shell at its prompt, or a REPL blocked on a tty read), false (something is running), or the literal string 'unsupported'. 'unsupported' means the host could not observe the state at all — it is not "false", and it is emphatically not "the turn is done". Treat it as unknown and fall back to your own evidence (onData quiet time, a sentinel the agent prints, getScreen()), because reading it as "done" makes an orchestrator declare victory on a still-running agent.
Platform caveat — terminal state observation is a STUB on Windows. The signal is read from the PTY's foreground process group via tcgetpgrp, which only exists on Unix. On Windows there is no equivalent, so isWaitingForInput() always resolves 'unsupported' and onStateChange reports the unsupported state. Everything else about the terminal — spawn, write/paste/key, onData, getOutput, getScreen, kill, persistence — works on all three platforms. If your app drives a CLI agent, design the turn-completion check so it still works with no state signal at all.
Runaway-output detection
session.onDegenerateOutput(cb) fires when the session's output collapses into a repeated-token or incoherent-gibberish loop (the same detector ChatOSS runs on its own chat streams, pointed at the PTY); getDegenerateInfo() reads the last episode on demand. Kill the session when it fires rather than letting a stuck agent burn the user's quota.
Answering a menu prompt without screen-scraping
session.answerPrompt(optionIndex) detects a multiple-choice prompt on the current screen and selects option optionIndex (0-based), resolving the option text it chose — or null if no prompt was on screen, which is your cue that the situation is something else and needs getScreen().
Declare your command prefixes to skip the prompts
List the first tokens you'll run in "terminalCommandPrefixes" (e.g. ["git", "ls", "grep", "npm"]). They are disclosed and approved at install, then run WITHOUT per-command prompts. Undeclared prefixes still prompt. In headless/background runs, ONLY declared prefixes may run (there's no window to prompt in — undeclared commands are auto-denied with a clear error). An unanswered permission prompt settles as denied after 5 minutes — a tool call never hangs forever.
"Allow always" is scoped to the command's first word — approving git never covers rm. Full machine access is a big grant: request the terminal capability only if the app genuinely needs to run programs.
While a terminal call is waiting on its approval prompt, approvals.pending() lists your app's own unanswered prompts, so you can show "waiting for your approval" instead of appearing to hang.
Launching a coding-agent CLI — use spawnCodingAgent
A bare spawn('claude') starts the CLI in its default manual-permission mode, so it stalls on the first edit-approval prompt with nobody to answer it. spawnCodingAgent builds the right flags for you:
const session = await window.chatoss.terminal.spawnCodingAgent('claude', {
permissionMode: 'acceptEdits', // or 'bypassPermissions'
model: modelId, // optional
cwd: folder, cols: 120, rows: 40,
extraArgs: [], // optional extra CLI args
});
// → the SAME session handle shape as spawn() (paste/key/write/onData/getScreen/…), or null if denied.
Agents: 'claude' and 'codex'. It rides the same terminal capability and per-prefix approval as spawn.
Persistent sessions
Sessions survive window close AND a full app restart (metadata + output history are persisted by the OS). If your app declares "background", its spawned sessions are NOT killed when ChatOSS quits: they keep running and your app re-attaches on next launch.
const sessions = await window.chatoss.terminal.listSessions();
// → [{ id, command, cwd, appId, createdAt, lastActiveAt, live }] newest first
// live: true = the process is still running in this app run
const attached = await window.chatoss.terminal.attachSession(id);
// → { id, command, cwd, createdAt, lastActiveAt, live, output }
// output = the full persisted output history (base64 — decode with atob())
const handle = await window.chatoss.terminal.reattachSession(id);
// → a LIVE session handle (same shape as spawn(): onData/onExit/write/paste/key/resize/kill)
// only for sessions whose process is still alive (live: true)
await window.chatoss.terminal.killSession(id);
// → kills the live process (if any) + deletes the persisted metadata/output
Use listSessions() to show the user what ran before (even after a restart), attachSession(id) to read a session's full history, and reattachSession(id) to take control of a still-running session. killSession(id) is the explicit cleanup — the OS no longer kills sessions when the window closes.