import type { Platinum } from './index.js'; /** Signal names the API accepts by name (it also takes a raw number, and the * bare form without the `SIG` prefix). Linux amd64/arm64 share these numbers * and the guest is Linux-only, so the table is never wrong for the platform it * runs on. */ export type ProcessSignalName = 'SIGHUP' | 'SIGINT' | 'SIGQUIT' | 'SIGILL' | 'SIGABRT' | 'SIGFPE' | 'SIGKILL' | 'SIGUSR1' | 'SIGSEGV' | 'SIGUSR2' | 'SIGPIPE' | 'SIGALRM' | 'SIGTERM' | 'SIGCHLD' | 'SIGCONT' | 'SIGSTOP' | 'SIGTSTP' | 'SIGWINCH'; /** A signal, by number (15) or by name ('SIGTERM'). */ export type ProcessSignal = number | ProcessSignalName; /** One registry record, exactly as `GET /processes` reports it — wire field * names are kept so this can be diffed against the protocol doc line for line. * * `exit_code` is MEANINGLESS while `running` is true: the guest sends 0 for a * live process, not null, so branch on `running` first. `timed_out` means the * PROCESS hit its own `timeoutMs` deadline and was SIGKILLed — it is not the * same thing as a wait that gave up (see `wait_timed_out`). */ export interface ManagedProcess { handle: string; pid: number; cmd: string[]; /** Unix seconds. */ started_at: number; running: boolean; exit_code: number; /** WHICH signal killed the process — the wire field `signal`, NOT an * `AbortSignal` (the `signal` in this SDK's option bags is that other thing; * this one is a number, and it is a result, never a request). * * PRESENT ONLY FOR A SIGNAL DEATH, absent otherwise. That is not laziness * about defaults: 0 is not a signal any process can be killed by, so absent * and 0 would mean the same thing, and omitting it loses nothing while * keeping `signal !== undefined` an honest test for "killed". * * It saves you reversing the 128+N arithmetic in `exit_code` — but the real * reason it exists is that the arithmetic is AMBIGUOUS. A process that calls * `exit(143)` and a process killed by SIGTERM both report `exit_code: 143`, * and only this field tells them apart: * * p.exit_code === 143 && p.signal === 15 // SIGTERMed * p.exit_code === 143 && p.signal === undefined // it chose to exit 143 */ signal?: number; /** Unix seconds; 0 while running. */ exited_at: number; pty: boolean; timed_out: boolean; /** Total bytes the process has written, ever — not what is still buffered. */ stdout_len: number; stderr_len: number; /** Only on a `info({ waitMs })` reply: the WAIT gave up and the process is * still running. Deliberately not fused with `timed_out`, which would let a * caller conclude a healthy long build had been killed. */ wait_timed_out?: boolean; } /** One offset-addressed read of a process's output. * * `stdout`/`stderr` are the EXACT bytes (the SDK asks the API for base64 and * decodes them here); `*_text` is a UTF-8 decode of the same bytes for the * common case of putting a log on a screen. Process output is arbitrary, and * text decoding replaces every invalid sequence with U+FFFD — fine for a log * line, silently destructive for a tarball. * * `*_offset` are ABSOLUTE byte counts since the process started: hand them * back as the next call's offsets and you continue at exactly the next byte. * * `*_dropped` is a HOLE, not a statistic. Non-zero means the ring buffer * evicted that many bytes before this read reached them, so the returned data * begins at `offset + dropped` and a UI that concatenates without saying so is * quietly showing a log that never existed. */ export interface ProcessLogs { handle: string; stdout: Uint8Array; stderr: Uint8Array; stdout_text: string; stderr_text: string; stdout_offset: number; stderr_offset: number; stdout_dropped: number; stderr_dropped: number; running: boolean; /** True only after the process exited and both output pumps reached EOF. */ drained?: boolean; exit_code: number; /** WHICH signal killed the process, present only for a signal death — the * same field, with the same contract, as `ManagedProcess.signal` (a number * off the wire, not an `AbortSignal`). Absent for a normal exit, because 0 * is not a signal a process can be killed by, so absent and 0 would mean the * same thing. * * It is here so a caller tailing a log does not have to fetch the record to * learn that `exit_code: 143` was a SIGTERM rather than a deliberate * `exit(143)` — the two are indistinguishable from the code alone. */ signal?: number; timed_out?: boolean; } /** What `stream()` yields. Mirrors the SSE payloads the API sends, plus the two * events the SDK itself owns (`gap`, `reconnect`) because reconnection happens * on this side of the wire. Switch on `type`. */ export type ProcessStreamEvent = /** Output. `offset` is the absolute offset of the byte AFTER this chunk — the * value to hand back as `stdoutOffset`/`stderrOffset` to resume. `dropped` * is non-zero only when the ring evicted bytes before this chunk (a `gap` * event is emitted immediately before, in the same position in the stream). */ { type: 'stdout' | 'stderr'; data: Uint8Array; text: string; offset: number; dropped: number; } /** Bytes that no longer exist: the reader fell behind the ring buffer. Emitted * in the exact position the hole occupies, so an event-ordered UI can print * "… N bytes dropped …" inline instead of splicing two unrelated halves of a * log together. */ | { type: 'gap'; stream: 'stdout' | 'stderr'; dropped: number; offset: number; } /** The process exited AND its buffers were drained. Terminal — the iterator * returns after this. * * `signal` is the number of the signal that killed it and is present ONLY * for a signal death (absent otherwise — 0 is not a signal a process can be * killed by, so absent and 0 would mean the same thing). It is what tells a * tail that `exit_code: 143` was a SIGTERM and not a program that chose to * `exit(143)`; the code alone cannot say. Not to be confused with the * `AbortSignal` passed in `ProcessStreamOptions.signal` — this one is a * number, and it is an outcome rather than a control. * * Same caveat as ProcessLogs.signal: the API's `exit` frame does not carry * it yet, so a stream that ends is still one `info()` away from knowing how. * Carried through here so that stops being true without an SDK release. */ | { type: 'exit'; exit_code: number; signal?: number; timed_out?: boolean; stdout_offset: number; stderr_offset: number; } /** The SDK lost the connection (or the server's per-connection cap expired) * and is resuming from these offsets. No bytes are lost across it: the * reconnect asks for exactly the next byte. */ | { type: 'reconnect'; attempt: number; reason: 'max_duration' | 'disconnected'; stdout_offset: number; stderr_offset: number; error?: string; } /** The stream stopped without the process exiting, and the SDK is not * resuming (reconnect disabled, or the attempt budget is spent). Terminal. */ | { type: 'end'; reason: string; stdout_offset: number; stderr_offset: number; }; /** `start()` arguments. `cmd` is argv (`['npm','run','dev']`, direct execve) or * a plain string (`'npm run dev'`, wrapped in `sh -c` by the API). */ export interface ProcessStartSpec extends ProcessStartOptions { cmd: string | string[]; } export interface ProcessStartOptions { /** Working directory. Defaults to `/` in the guest; must exist. */ cwd?: string; /** Extra environment, merged over the sandbox's own create-time env (request * wins). Object form, not the guest's `KEY=VALUE` array — an object cannot * express the one malformed entry an array can. */ env?: Record; /** Allocate a PTY. stdout and stderr are then inherently MERGED: everything * arrives on stdout and stderr stays empty. Required for anything that * behaves differently when it is not on a terminal (colour, progress bars, * a shell's job control) and for `resize()`. */ pty?: boolean; cols?: number; rows?: number; /** The PROCESS's own deadline, in ms, enforced inside the guest — after it, * the process group is SIGKILLed and the record is marked `timed_out`. 0 or * omitted means no deadline, which is the point of a managed process. * * This is NOT the HTTP timeout of the start call (that is * `requestTimeoutMs`). The two are easy to confuse and the failure modes are * opposite: too small a value here kills a healthy build, while too small a * `requestTimeoutMs` only loses the reply to a start that already happened. */ timeoutMs?: number; /** Client-side deadline for THIS HTTP call. */ requestTimeoutMs?: number; signal?: AbortSignal; } /** Per-call HTTP options, the same pair every other SDK method takes. */ export interface ProcessCallOptions { timeoutMs?: number; signal?: AbortSignal; } export interface ProcessLogsOptions extends ProcessCallOptions { /** Absolute byte offset to read from. Default 0 — which replays whatever the * ring still holds, so pass back the previous reply's `stdout_offset` when * you are tailing. */ stdoutOffset?: number; stderrOffset?: number; /** Bytes per stream, default 64 KiB, hard max 4 MiB (the guest's own cap — * asking for more is a 400 rather than a silent clamp). */ limit?: number; } export interface ProcessStreamOptions { /** Resume points. Default 0 on both, which replays the buffered tail. */ stdoutOffset?: number; stderrOffset?: number; /** Server-side cap on ONE connection, 5-1800 s (default 1800). Reaching it is * not the end of the stream: with `reconnect` on, the SDK opens the next * connection at the offsets it already has. */ maxSeconds?: number; /** Resume after a dropped connection. Default true. Set false to get a single * connection whose loss ends the iterator with an `end` event. */ reconnect?: boolean; /** Consecutive failed resumes before giving up. Default 10. Reset to zero by * any event that arrives, so a stream that flaps for hours but keeps * delivering output is never abandoned. */ maxReconnects?: number; /** First backoff step, doubling to 5 s. Default 250 ms. */ reconnectDelayMs?: number; /** Ends the iterator cleanly (`return`, not `throw`) — this is how you stop * tailing without unwinding the caller. Breaking out of the `for await` does * the same thing. */ signal?: AbortSignal; } export interface ProcessWaitOptions extends ProcessCallOptions { /** Overall deadline. Omitted means wait indefinitely, which is usually what * you want for a managed process — the SDK re-issues the server-side wait in * 55 s slices (below the control-plane HTTP edge) rather than polling. Exceeding a deadline * you DID set throws PlatinumTimeoutError and leaves the process running. */ timeoutMs?: number; } /** `stdin()` result. `closed` reflects what you asked for, not a guess. */ export interface ProcessStdinResult { written: number; closed: boolean; } export declare function base64Encode(bytes: Uint8Array): string; export declare function base64Decode(s: string): Uint8Array; /** Did this failure mean "this sandbox's in-VM agent predates managed * processes"? * * Matched on the error CODE the API sets on every proc route, so one predicate * covers all of them. It is a 409 rather than a 5xx on purpose: nothing is * broken, the request reached a healthy guest that simply does not implement * the verb, and the fix is rebuilding the template (or upgrading the agent in * that guest). `exec()` keeps working on the same box, so this is also the * right place to decide to fall back to it. * * catch (e) { if (isManagedProcessesUnsupported(e)) return sbx.exec(cmd); } */ export declare function isManagedProcessesUnsupported(e: unknown): boolean; /** * `sandbox.process` — start, list and reconnect to managed processes. * * The registry lives in guest memory, which is exactly what a memory snapshot * captures, so managed processes survive pause/resume for free. They do NOT * survive a stop/start or a cold boot: handles are unique per agent boot, and a * handle from a previous boot reads as `process_not_found`. */ export declare class SandboxProcesses { private readonly client; /** Sandbox these processes belong to. */ readonly sandboxId: string; constructor(client: Platinum, /** Sandbox these processes belong to. */ sandboxId: string); private get base(); /** * Start a process that keeps running after this call returns. * * const p = await sbx.process.start({ cmd: ['npm','run','dev'], cwd: '/app' }); * const sh = await sbx.process.start('tail -f /var/log/app.log'); * * Returns as soon as the guest has forked — milliseconds, not the process's * lifetime. A fork that failed (`no such file or directory`) is a 400, not a * bad gateway: it is the caller's argv that was wrong. * * A start whose REPLY is lost (a client-side timeout, a dropped connection) * may still have forked. `list()` is how you find out; the SDK deliberately * does not retry, because a retried start is a second process. */ start(spec: ProcessStartSpec): Promise; start(cmd: string | string[], opts?: ProcessStartOptions): Promise; /** * Every record the registry holds, INCLUDING processes that already exited * but are still inside the retention window (10 minutes by default). * * That inclusion is the feature, not a leak: a client that was offline when a * process finished still finds its exit code and its final logs here, instead * of learning only that the process is gone. */ list(opts?: ProcessCallOptions): Promise; /** * Reconnect to a process started earlier — by this client, another client, or * a previous run of the same program. One round trip, and it throws * NotFoundError if the handle is unknown to this guest (garbage-collected * after the retention window, or minted before the last VM stop). * * const p = await sbx.process.get(savedHandle); * for await (const ev of p.stream({ stdoutOffset: savedOffset })) … */ get(handle: string, opts?: ProcessCallOptions): Promise; } /** * A handle on one managed process. Cheap and stateless apart from the identity * it was built with — construct as many as you like for the same handle, from * as many clients as you like; the guest's registry is the only state. */ export declare class ProcessHandle { private readonly client; readonly sandboxId: string; /** Opaque, guest-minted (`p_`), unique per agent boot. Persist it and * hand it to `sbx.process.get()` to reconnect. */ readonly handle: string; /** PID inside the guest. 0 when the handle was built from a record that did * not carry one. */ readonly pid: number; /** Started with `pty: true`, so stdout carries the merged output and * `resize()` works. */ readonly pty: boolean; /** The last record this handle saw (from `get()`, `info()` or `wait()`). * Never fetched implicitly — it is undefined until something asked. */ last?: ManagedProcess; constructor(client: Platinum, sandboxId: string, /** Opaque, guest-minted (`p_`), unique per agent boot. Persist it and * hand it to `sbx.process.get()` to reconnect. */ handle: string, init?: { pid?: number; pty?: boolean; last?: ManagedProcess; }); private get path(); /** * This process's registry record. * * `waitMs` blocks SERVER-side until the process exits or that many ms elapse * (capped at 55 s by the HTTP edge), so "tell me when it is done" is one request * instead of a poll loop that is both slower to notice the exit and more * traffic. The reply's `wait_timed_out` says the WAIT gave up; the record's * own `timed_out` says the PROCESS was killed by its deadline. */ info(opts?: ProcessCallOptions & { waitMs?: number; }): Promise; /** * Read output from an absolute byte offset. * * Offsets do NOT advance implicitly: two callers tailing one process would * otherwise steal each other's bytes, and a retried read would skip the bytes * the failed one had already consumed. Pass back the `stdout_offset` / * `stderr_offset` you were given: * * let at = 0; * for (;;) { * const l = await p.logs({ stdoutOffset: at }); * if (l.stdout_dropped) console.warn(`… ${l.stdout_dropped} bytes dropped …`); * process.stdout.write(l.stdout_text); * at = l.stdout_offset; * if (!l.running) break; * } * * The bytes come over the wire as base64 and are decoded here, so `stdout` is * byte-exact even for output that is not valid UTF-8. `stdout_text` is the * lossy-but-printable view of the same bytes. */ logs(opts?: ProcessLogsOptions): Promise; /** * Tail the process as an async iterator over the server's SSE stream. * * for await (const ev of p.stream()) { * if (ev.type === 'stdout') process.stdout.write(ev.text); * if (ev.type === 'exit') break; * } * * RECONNECTION is the whole point of the offset protocol and is handled here: * a dropped connection, a server that hit its per-connection cap, or a guest * that blinked during a resume all resume from the last offset this iterator * saw, so no byte is delivered twice and none is skipped. Each resume is * announced as a `reconnect` event rather than being invisible — a silent * reconnect makes a hole in a log indistinguishable from a quiet process. * * A `gap` event is emitted wherever the ring buffer evicted bytes before the * reader reached them. It is emitted in the position the hole occupies, so a * consumer that renders events in order shows the break where it happened * instead of splicing two unrelated halves of a log together. * * Breaking out of the loop (or aborting `opts.signal`) closes the connection * on the way out. That matters: an abandoned stream is a poll against the * guest every 100 ms for as long as the process lives. */ stream(opts?: ProcessStreamOptions): AsyncGenerator; /** * Write to the process's stdin. * * A `Uint8Array` is sent byte-exact (base64), a string as UTF-8 text. In PTY * mode these bytes go to the PTY master, so this is also how you deliver * keystrokes and control characters (`p.stdin('\x03')` is ^C). * * `close: true` closes the pipe after the write, which is the only way to * make an EOF-terminated reader (`sort`, `cat`, a REPL fed a script) finish. * `data` may be empty, so "just close it" is one call — or `closeStdin()`. * * Capped at 1 MiB per call by the API: `proc_stdin` blocks in the guest until * the child drains the pipe, so an unbounded body is a request that parks for * as long as the child feels like not reading. Send more in chunks. */ stdin(data: string | Uint8Array, opts?: ProcessCallOptions & { close?: boolean; }): Promise; /** Close stdin without writing anything — sugar for `stdin('', { close: true })`. */ closeStdin(opts?: ProcessCallOptions): Promise; /** * Send a signal, by number (`15`) or name (`'SIGTERM'`). * * `group` defaults to true IN THE GUEST and is only forwarded when you say * otherwise, so the guest stays the single source of that default. It is the * one that matters: signalling only the direct child leaves a shell's real * work running and reparented to PID 1, which looks exactly like the signal * having no effect. * * Signalling a process that already exited throws ConflictError * (`process_not_running`). */ signal(signal: ProcessSignal, opts?: ProcessCallOptions & { group?: boolean; }): Promise<{ ok: boolean; signal: number; }>; /** * SIGKILL the process group. * * The record is RETAINED, not deleted: the final logs and the exit code stay * readable afterwards, which is what makes this safe to call when you are not * sure whether the process already finished. Idempotent — killing an exited * process is still a success. */ kill(opts?: ProcessCallOptions): Promise<{ ok: boolean; handle: string; }>; /** * Resize the terminal. PTY processes only — a non-PTY process has no window * size and throws ConflictError (`not_a_pty`). * * The legacy interactive path hardcodes 80x24 with no way to change it, which * is why a `less` or a `vim` inside it renders into the wrong box forever. */ resize(cols: number, rows: number, opts?: ProcessCallOptions): Promise<{ ok: boolean; cols: number; rows: number; }>; /** * Block until the process exits, and return its final record. * * The wait happens SERVER-side in 55 s slices (below the HTTP edge), not as * a client poll loop: an hour-long build costs about 66 requests and the exit is * noticed the instant it happens. With no `timeoutMs` this waits * indefinitely, which is the normal thing to want from a managed process; a * `timeoutMs` you do set throws PlatinumTimeoutError and leaves the process * RUNNING (nothing is killed by giving up on the wait — use `kill()` for * that). */ wait(opts?: ProcessWaitOptions): Promise; private _stream; /** One SSE connection, parsed into `{event, data}` frames. Ends (returns) * when the server closes the stream; throws on a transport failure or a * non-2xx status. Owns its own AbortController so that breaking out of the * caller's `for await` closes the HTTP connection instead of leaving the * control plane polling the guest for a reader that has gone away. */ private _connect; }