Workflows API
This directory holds spwn workflows: scripts that orchestrate AI coding agent sessions
for this project. This file is for an AI agent (or a person) writing one. The exact API is in
spwn.d.ts next to this file — read it before writing code; this file explains how to use it
correctly.
spwn is a session manager for coding agents. Each session is an agent (e.g. Claude Code) running in its own git worktree on its own branch, visible in spwn’s sidebar. A workflow drives sessions from code: start them, prompt them, wait for replies, react to events.
File format
Section titled “File format”A workflow is one ES module at the top level of .spwn/workflows/, named <name>.js, .mjs,
.ts or .mts. The file stem is its name.
import type { Spwn, WorkflowMeta } from "./spwn";
export const meta: WorkflowMeta = { name: "Triage", // display name (optional) description: "Fix new bug reports", // optional keepAlive: false, // true: restart whenever main returns or throws inputs: { // shown as a form when the user clicks Run label: { type: "string", default: "bug", description: "Issue label to work" }, limit: { type: "number", default: 5 }, },};
export default async function main(spwn: Spwn, inputs: { label: string; limit: number }) { // ...}export defaultmust be a function.metais optional.- Input
typeis"string"(default),"number"or"boolean". Missing inputs get theirdefault;required: truerefuses to start without a value. - Not workflows (importable helpers instead): files starting with
_,*.d.ts, and anything in a subdirectory such aslib/. Import them with relative paths:import { x } from "./lib/x.js". - In JavaScript, get editor types with
// @ts-checkand/** @param {import("./spwn").Spwn} spwn */.
The runtime is NOT Node.js
Section titled “The runtime is NOT Node.js”Workflows run in an embedded QuickJS engine. Modern JavaScript works (async/await, classes,
modules, JSON, Map, regexes, setTimeout/setInterval, console.*). There is:
- no
require, npm packages, or Node built-ins (fs,path,child_process,process,Buffer) - no browser globals (
fetch,window,URLmay be missing) - no bare imports — only relative imports of files inside
.spwn/workflows/
Use the spwn API instead:
| Need | Use |
|---|---|
| run a command | await spwn.exec("git", ["status"]) or await spwn.sh("npm test") → { code, ok, stdout, stderr } (non-zero exit does NOT throw; check ok) |
| read/write project files | spwn.fs.read(path), spwn.fs.write(path, text), spwn.fs.exists(path) (paths relative to the project root) |
| HTTP | const r = await spwn.fetch(url, { method, headers, json, body }) → { status, ok, headers, body }, await r.json() |
| GitHub | await spwn.github.graphql(query, variables) → data; await spwn.github.rest("GET", "/repos/o/r/issues") → parsed JSON. Uses the token saved in spwn’s Settings. |
| remember things across runs | spwn.state.get(key, fallback), spwn.state.set(key, jsonValue) (synchronous) |
| log | spwn.log(...), spwn.warn(...), spwn.error(...) or console.* — shown in the Workflows panel |
| wait | await spwn.sleep(ms) |
TypeScript is accepted but only has its types removed — it is never type-checked at run time.
Sessions
Section titled “Sessions”const session = await spwn.sessions.create({ title: "Fix #42", // sidebar title key: "issue-42", // YOUR stable id for this session (see "One session per thing") prompt: "Fix the bug described in issue #42 ...", // sent once the agent is ready // agent: "claude", // agent definition id; default is the user's default agent // onHookPrompt: (q) => ..., // see "Hooks"});
const turn = await session.waitForTurn();if (turn.blocked) { // The agent stopped to ask for permission or an answer. turn.screen is its screen text. spwn.warn(`needs a human: ${session.title}`); return;}spwn.log(turn.text); // the agent's reply to the prompt
const next = await session.prompt("Now add a test."); // send + waitForTurnKey facts:
createstarts a full session: new worktree + branch, the project’s hooks, a sidebar entry. It resolves after the prompt was submitted (or right away with noprompt). If the agent is blocked before it can take the prompt (e.g. a folder-trust question),createthrows.create({ startPoint })starts the new branch at a commit or another session’sbranchinstead of the base’s tip — how one session hands its work to the next (a role per stage, a session each) without merging anything. The base branch is unchanged, so the new branch still lands where it would have, carrying both sessions’ commits.waitForTurn()resolves when the reply is complete AND the turn’s commit hooks have run, so the work is committed onsession.branch. It waits forever unless you pass{ timeoutMs }.- Always handle
turn.blocked. It means permission prompt or question. Options: leave it for a human (the session shows as waiting in the sidebar), or answer withsession.press("Enter"),session.press("Escape"), orsession.send("..."), thenwaitForTurn()again. send(text)thenwaitForTurn()waits for the reply to THAT text. CallingwaitForTurn()twice without a newsendwaits for the next turn after the last one returned.- Other methods:
status(),waitForStatus(status),screen(),press(key),interrupt(),lastMessage(),transcript(),delete()(removes worktree and branch),refresh(). pr()/setPr(attrs)read and write the session’s PR doc —.spwn/pr/<branch>.md, committed on its own branch: free-form TOML front matter plus a markdown body.setPrmerges, so keys you don’t mention (and their comments, and the prose) are untouched; anullvalue deletes a key. spwn does not commit — the next finished turn’s commit hook carries the change.- Pull requests are the doc’s counterpart on github.com:
createPullRequest()pushes the branch and opens one from the doc (idempotent — it adopts an existing one rather than opening a second),pullRequest()reads it back from spwn’s cache ({ refresh: true }polls first),pushPullRequestUpdate()re-sends the doc as the title and body without touching what a reviewer wrote. Project-wide there isspwn.pulls.list()(cached, one snapshot of every PR — cheap in a loop),merge(),setDraft(),setState()andrequestReviewers(). Commits made after the PR was opened only reach GitHub when you push them (spwn.exec("git", ["-C", session.cwd, "push"])). - A session’s PR doc is removed from the base branch when the session lands, along with any
doc whose branch and session are both gone — so don’t treat a missing doc on
mainas meaning the work never had one. It is still in git history. cost()returns spend across every session working the same problem (aproblemkey in the front matter, else the workflow key, else fork lineage).totalis already deduplicated. If you add figures up yourself, add each session’scost.own, never itscost.tokens: a fork’s transcript re-carries its parent’s, so totals double the shared prefix. An absentcost.ownequalscost.tokens; ifcost.ownUnknownis set, leave that session out of the sum.- Session properties:
id,title,key,branch,baseBranch,cwd(the worktree path),agent,sessionId.statusis a METHOD:await session.status(). - For work that needs no conversation,
await spwn.agents.run({ prompt })does a one-shot headless run in its own worktree and resolves with{ ok, error, text, sessionId }.
One session per thing (idempotence)
Section titled “One session per thing (idempotence)”Workflows get re-run and restarted, so never assume a clean slate. The standard pattern:
let session = await spwn.sessions.find(ticket.id); // survives restarts of spwn itselfif (!session) { session = await spwn.sessions.create({ key: ticket.id, title: ticket.title, prompt });} else if (!session.awaitingTurn) { await session.send(prompt);}const turn = await session.waitForTurn();session.awaitingTurn is true when a prompt was submitted but no waitForTurn() has returned its
reply yet — typically because the run was stopped or restarted mid-turn. It is kept across runs and
spwn restarts. Don’t send the prompt again: waitForTurn() on the found session waits for the reply
to that prompt, and returns at once if the agent already finished while the workflow was stopped.
Track progress with spwn.state so work isn’t repeated:
const done = spwn.state.get<Record<string, string>>("done", {});if (done[ticket.id] === ticket.column) continue; // already handled this stage// ... do the work ...done[ticket.id] = ticket.column;spwn.state.set("done", done); // state values must be JSONLong-running workflows
Section titled “Long-running workflows”A poller loops until stopped and sets keepAlive: true so crashes restart it (backoff 5s → 5 min):
export const meta = { keepAlive: true };
export default async function main(spwn: Spwn) { while (!spwn.stopping) { try { await pollOnce(spwn); } catch (e) { spwn.error("poll failed:", e); // log and keep going; don't let one bad poll kill the loop } await spwn.sleep(60_000); // throws when the run is stopped, which ends the loop }}keepAliverestarts the workflow whenmainreturns too, not only when it throws. Don’t set it on a workflow meant to run once.- When the user stops a run, every pending
spwn.*promise rejects with an error whosecode === "stopped", and a busy loop is interrupted. Don’t catch-and-continue in a way that ignores this: checkspwn.stopping, or rethrow errors withcode === "stopped". - To work several items concurrently, start the promises without awaiting each one, and cap the
count yourself. Always attach
.catch(...)to promises you don’t await. - A workflow that only reacts to events should
await spwn.untilStopped()at the end ofmain.
Events
Section titled “Events”spwn.on("session-turn", async (e) => { const s = await spwn.sessions.get(e.terminalId); // e.terminalId is the Session id if (s) spwn.log(`${s.title} finished a turn`);});await spwn.untilStopped();Events: session-created, session-ready, session-turn, session-deleted (payload includes
terminalId, branch, worktree, turnUuid for turns, and workflow if a workflow created
the session) and status ({ terminalId, status }). They fire for every session in the project,
including ones the user started. spwn.on returns an unsubscribe function.
The project may have shell hooks (.spwn/hooks/) that run when sessions are created, finish a
turn, or are deleted. They run for workflow sessions too. If a hook asks a question
(spwn prompt), your onHookPrompt handler answers it; without a handler it’s declined:
await spwn.sessions.create({ title: "with db", onHookPrompt: (q) => { // { event, session, question, header, options: [{label}], multiSelect } if (q.question.includes("Seed the database")) return "Yes"; // return an option label return null; // decline },});Rules that bite
Section titled “Rules that bite”- Top-level code runs whenever spwn lists workflows (to read
meta), with nospwnAPI and a 2-second limit. Keep the top level to imports, constants,meta, and function definitions. Neverawaitor cause side effects at the top level. - Relative imports only, and only within
.spwn/workflows/. - Handle
turn.blockedevery time you wait for a turn. - Use
key+sessions.findinstead of creating a new session on every run. spwn.execdoesn’t throw on failure; checkresult.ok.spwn.stateis synchronous and JSON-only. Don’t store functions,Dates (store ISO strings), or sessions (storesession.idand usesessions.get(id)).- Don’t put secrets in workflows. GitHub calls use the user’s saved token automatically.
- Workflows only run after the user allows workflows for the project in the Workflows panel.
Checking your work
Section titled “Checking your work”- Type-check (if Node/TypeScript are available):
npx tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler <name>.tsin this directory. - In spwn, open the project’s ⚙ Workflows panel: a load error (syntax, bad import, missing default export) shows on the workflow’s card.
- Click Run, watch the log, and open the sessions it links to.
Example: a GitHub board, one session per ticket, a persona per column
Section titled “Example: a GitHub board, one session per ticket, a persona per column”import type { Spwn } from "./spwn";
export const meta = { keepAlive: true, inputs: { owner: { required: true }, project: { type: "number", required: true } },};
const PERSONAS = { engineer: "You are a careful senior engineer. Keep changes focused and tested.", reviewer: "You are a demanding reviewer. Fix problems you find.",};const COLUMNS: Record<string, { persona: keyof typeof PERSONAS; ask: string }> = { "In progress": { persona: "engineer", ask: "Implement this ticket." }, "In review": { persona: "reviewer", ask: "Review the work on this branch against the ticket." },};
export default async function main(spwn: Spwn, inputs: { owner: string; project: number }) { const handled = spwn.state.get<Record<string, string>>("handled", {}); while (!spwn.stopping) { for (const t of await readTickets(spwn, inputs)) { // your GraphQL query const col = COLUMNS[t.column]; if (!col || handled[t.id] === t.column) continue; const prompt = `${PERSONAS[col.persona]}\n\n${col.ask}\n\n# ${t.title}\n${t.body}`; let s = await spwn.sessions.find(t.id); if (s) await s.send(prompt); else s = await spwn.sessions.create({ key: t.id, title: t.title, prompt }); const turn = await s.waitForTurn(); if (turn.blocked) spwn.warn(`${t.title} needs a human`); handled[t.id] = t.column; spwn.state.set("handled", handled); } await spwn.sleep(60_000); }}A complete version (pagination, moving tickets between columns, concurrency) is in spwn’s repo at
examples/workflows/github-board.ts.
Type definitions (spwn.d.ts)
Section titled “Type definitions (spwn.d.ts)”// Types for spwn workflows — the scripts in a project's `.spwn/workflows/`.//// spwn writes this file next to your workflows (from the Workflows panel's "New workflow")// and keeps it current; don't edit it. Use it from a workflow://// TypeScript: import type { Spwn, WorkflowMeta } from "./spwn";// JavaScript: // @ts-check// /** @param {import("./spwn").Spwn} spwn *///// Types are for your editor only: spwn strips them and never type-checks at run time.
/** `export const meta` — optional. */export interface WorkflowMeta { /** Shown in the Workflows panel instead of the file name. */ name?: string; description?: string; /** Restart the workflow (with backoff) whenever it exits or throws, until stopped. */ keepAlive?: boolean; /** Inputs the Run form asks for; `main` receives them as its second argument. */ inputs?: Record<string, WorkflowInput>;}
export interface WorkflowInput { /** How the Run form edits it. Default `"string"`. */ type?: "string" | "number" | "boolean"; /** Used when the input is left empty — including runs started with spwn. */ default?: unknown; description?: string; /** Refuse to start without it (and without a default). */ required?: boolean;}
/** `export default` — the workflow itself. A keep-alive workflow is restarted when this returns. */export type WorkflowMain<Inputs = Record<string, unknown>> = ( spwn: Spwn, inputs: Inputs,) => unknown | Promise<unknown>;
/** A session's live status, as the sidebar shows it. */export type SessionStatus = | "thinking" | "blockedPermission" | "blockedQuestion" | "done" | "error" | "idle";
export interface Spwn { readonly project: { readonly id: string; readonly name: string; readonly dir: string }; readonly workflow: { readonly name: string; readonly runId: string };
/** Write to the run's log in the Workflows panel. `console.*` works too. */ log(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void;
/** Resolves after `ms`; rejects (with `code: "stopped"`) as soon as the run is stopped. */ sleep(ms: number): Promise<void>; /** True once the run has been asked to stop. */ readonly stopping: boolean; /** Resolves when the run is stopped — for workflows that only react to events. */ untilStopped(): Promise<void>;
/** JSON values kept per workflow across runs and restarts (in spwn's data folder). */ readonly state: { get<T = unknown>(key: string, fallback?: T): T; /** Setting `null` or `undefined` deletes the key. */ set(key: string, value: unknown): void; delete(key: string): void; all(): Record<string, unknown>; };
/** Run a program (no shell) in the project dir; a stopped run kills it. */ exec(cmd: string, args?: string[], options?: ExecOptions): Promise<ExecResult>; /** Run a script with `sh -c`. */ sh(script: string, options?: ExecOptions): Promise<ExecResult>;
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
/** GitHub's API with the token saved in spwn's Settings (the token never reaches the script). */ readonly github: { /** Resolves with `data`; throws when GitHub returns errors and no data. */ graphql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T>; /** `path` like `/repos/owner/repo/issues`. Resolves with the parsed JSON body. */ rest<T = any>(method: string, path: string, body?: unknown): Promise<T>; };
/** * This project's pull requests. * * `list()` reads spwn's cache — free to call in a loop, and it tells you when the * snapshot was taken. Everything else is one API call. `spwn.github` above remains the * escape hatch for anything not here. */ readonly pulls: { list(): Promise<RepoPrView>; /** * Merge it. `method` defaults to `squash`, which is the one method that can't fail on * a repository configured to allow only some of them. * * Pass `expectedHead` (a PR's `headSha`) to refuse if the branch moved since you * looked — an unattended run that merges a commit it never saw is how a workflow * lands something nobody reviewed. */ merge( number: number, options?: { method?: MergeMethod; deleteBranch?: boolean; expectedHead?: string; } ): Promise<PullRequest>; /** Draft ↔ ready for review. */ setDraft(number: number, draft: boolean): Promise<PullRequest>; /** `true` reopens, `false` closes. */ setState(number: number, open: boolean): Promise<PullRequest>; requestReviewers(number: number, users: string[], teams?: string[]): Promise<PullRequest>; };
/** Files, relative to (and kept inside) the project dir. */ readonly fs: { read(path: string): Promise<string>; write(path: string, content: string): Promise<void>; exists(path: string): Promise<boolean>; };
readonly sessions: { /** Start an interactive agent session — in its own worktree, with the project's hooks. */ create(options?: CreateSessionOptions): Promise<Session>; /** The session this workflow created with `key`, surviving restarts; null if none. */ find(key: string, options?: { onHookPrompt?: HookPromptHandler }): Promise<Session | null>; /** Any session in the project by id, which this run then owns. Null if there's none. */ get(id: string, options?: { onHookPrompt?: HookPromptHandler }): Promise<Session | null>; /** The sessions this workflow created. */ list(): Promise<Session[]>; };
readonly agents: { list(): Promise<Array<{ id: string; name: string; [key: string]: unknown }>>; /** * A one-shot, non-interactive run (like a scheduled task) in its own worktree. * Resolves when the agent finishes, with its final message. */ run(options: string | AgentRunOptions): Promise<AgentRunResult>; };
/** Listen for a session event anywhere in this project. Returns an unsubscribe function. */ on<E extends keyof SpwnEvents>(event: E, handler: (payload: SpwnEvents[E]) => unknown): () => void; off<E extends keyof SpwnEvents>(event: E, handler: (payload: SpwnEvents[E]) => unknown): void;}
export interface CreateSessionOptions { title?: string; /** Your id for this session (a ticket id, say) — find it again with `sessions.find`. */ key?: string; /** Agent definition id (default: Settings' default agent). */ agent?: string; /** * Start this session's branch at this commit or branch instead of the base's tip — * how one session hands its work to the next: pass the finished session's `branch` and * the new worktree opens with its commits already in it, no merge and no second copy. * * The base branch is unaffected, so the new branch still lands where it would have, * carrying both sessions' commits. */ startPoint?: string; /** Sent once the agent is ready; then `waitForTurn()` waits for the reply. */ prompt?: string; /** A permission mode from the agent's definition, applied at launch. */ permissionMode?: string; /** How long to wait for the agent to be ready for `prompt`. Default 90s. */ readyTimeoutMs?: number; /** Answers `spwn prompt` questions from this session's hooks. Without it they're declined. */ onHookPrompt?: HookPromptHandler;}
export interface SessionMergeStatus { branch: string | null; baseBranch: string | null; /** Commits on this branch not yet in the base. */ ahead: number; /** Commits on the base this session doesn't have — how stale it has grown. */ behind: number; changedFiles: string[]; /** Paths a merge would collide in. Empty means clean, unless `previewUnavailable`. * Non-empty while a session is still working means its base moved under it — worth * telling the agent then, not at land time. */ conflicts: string[]; /** The base's current commit, for spotting a verify result that predates a landing * by another session. */ baseSha: string; /** Why the collision check couldn't run. Never read an empty `conflicts` as "clean" * while this is set. */ previewUnavailable: string | null; uncommitted: boolean; midTurn: boolean; /** Landing is a fast-forward and cannot conflict. `sync()` is what makes this true. */ willFastForward: boolean; /** An unresolved sync still sitting in the worktree. */ syncConflicts: string[]; /** Other sessions editing the same files. Advisory. */ overlaps: Array<{ terminalId: string; title: string; files: string[] }>; /** Branches this work travels through to reach a root, e.g. ["spwn/aaa", "main"]. */ mergePath: string[]; /** Why the merge can't proceed right now, if it can't. A dirty base checkout is NOT * one of these — see `humanBlockers`. */ blocker: string | null; /** Files the human has in progress that landing this would overwrite. Non-empty means * the landing waits; this session's work stays on its own branch until they're * committed, and their working copy is never touched. */ humanBlockers: string[];}
export type SyncOutcome = | { outcome: "upToDate" } | { outcome: "merged"; summary: string } | { outcome: "conflicted"; conflicts: string[] } /** git replayed a resolution recorded earlier and closed the merge. Textual, so it * can be stale if the surrounding code moved. */ | { outcome: "replayedResolution"; files: string[] };
export interface VerifyOutcome { runs: Array<{ script: string; ok: boolean; exitCode: number | null; output: string }>; ok: boolean; /** No `session-integrate` hook exists, so nothing was checked — which is not the * same answer as "nothing is wrong". */ noScripts: boolean; /** The base commit this was produced against. If the base has moved since, what was * tested is not what would land. */ verifiedBase: string;}
export interface Session { /** spwn's id for the session (what `sessions.get` takes). */ readonly id: string; readonly title: string; readonly kind: "agent" | "shell"; readonly agent: string | null; /** The workflow that created it, if one did. */ readonly workflow: string | null; readonly key: string | null; readonly branch: string | null; readonly baseBranch: string | null; /** Its worktree. */ readonly cwd: string; /** The agent's own conversation id. */ readonly sessionId: string | null; /** * A prompt was submitted and `waitForTurn` hasn't returned its reply yet — kept across * runs, so after a stop or restart call `waitForTurn()` instead of sending it again. * Always false for sessions no workflow created. */ readonly awaitingTurn: boolean;
status(): Promise<SessionStatus>; /** Type `text` into the agent and submit it (unless `submit: false`). */ send(text: string, options?: { submit?: boolean }): Promise<void>; /** * Wait for the reply to the last `send` (or the create `prompt`), including its commit. * Returns early with `blocked: true` if the agent stops to ask for permission or input. */ waitForTurn(options?: { timeoutMs?: number }): Promise<TurnResult>; /** `send` then `waitForTurn`. */ prompt(text: string, options?: { timeoutMs?: number }): Promise<TurnResult>; waitForStatus(status: SessionStatus | SessionStatus[], options?: { timeoutMs?: number }): Promise<SessionStatus>; /** The agent's screen as text. */ screen(): Promise<string>; /** Press a key (tmux names: `Enter`, `Escape`, `Up`, `C-c`, `BTab`, …). */ press(key: string): Promise<void>; interrupt(): Promise<void>; transcript(): Promise<TranscriptTurn[]>; /** The agent's reply to the latest prompt. */ lastMessage(): Promise<string | null>; /** How this session's branch stands against its base: what it changes, what would * collide, how far behind it is, and the chain of branches to a root. */ mergeStatus(): Promise<SessionMergeStatus>; /** Merge the base INTO this session's branch, in its own worktree. A conflict is * left there to resolve (hand it to the agent with `prompt`) rather than rolled * back. Afterwards, landing the branch is a fast-forward. */ sync(): Promise<SyncOutcome>; /** Build and test the MERGED result in a throwaway worktree, via `session-integrate` * hooks. The only way to catch a conflict that merges clean and breaks anyway. */ verifyMerge(): Promise<VerifyOutcome>; /** Land this session's branch on its base. */ merge(options?: { commitFirst?: boolean }): Promise<string>; /** * This session's PR doc — `.spwn/pr/<branch>.md`, committed on its own branch: * free-form TOML front matter plus a markdown body. `exists` is false until * something writes one. */ pr(): Promise<PrDoc>; /** * Insert or replace front-matter keys; `null` deletes one. Keys you don't mention, * their comments, the key order and the body are left exactly as they are. spwn does * not commit: the next finished turn's commit hook carries the change. */ setPr(attrs: Record<string, unknown>): Promise<PrDoc>;
/** * Push this session's branch and open a real pull request on GitHub, with the title * and body rendered from its PR doc (`pr()` above). The number, URL and repo are * written back into the doc's front matter, so they travel with the branch. * * Note the pair: `pr()` is the **document**, `pullRequest()` is the **pull request**. * * Idempotent. If the doc already names a pull request GitHub still has for this head, * or GitHub answers "one already exists" (someone ran `gh pr create` in a pane), the * existing one is adopted and `adopted` is true rather than a second being opened. * * Refuses, before spending an API call, when the branch has no commits its base * doesn't have, when the land order owns the worktree, or when the token can't push. * Uncommitted work is left alone — `uncommittedLeftBehind` says when there was some. */ createPullRequest(options?: { /** Overrides the doc's rendering. Omit to use the doc, which is the point. */ title?: string; body?: string; /** Defaults to the session's base branch. */ base?: string; draft?: boolean; reviewers?: string[]; }): Promise<CreatePrResult>;
/** * This session's pull request as spwn last saw it, or null when it has none. * * Reads spwn's cache by default, so looping over sessions costs nothing. Pass * `{ refresh: true }` to poll GitHub first — one request for the whole repository, * not one per session, so refreshing once before a loop is the frugal shape. */ pullRequest(options?: { refresh?: boolean }): Promise<PullRequest | null>;
/** * Re-send the doc as the pull request's title and body. Only spwn's own fenced section * of the body is rewritten, so anything a reviewer typed on github.com survives. */ pushPullRequestUpdate(): Promise<PullRequest>;
/** Spend across every session working the same problem as this one. */ cost(): Promise<CostRollup>; /** Delete the session, its worktree and its branch (like the sidebar's ×). */ delete(): Promise<void>; refresh(): Promise<this>;}
/** A session's PR doc: free-form TOML front matter plus the markdown body. */export interface PrDoc { /** Relative to the session's worktree, e.g. `.spwn/pr/spwn-1a2b3c4d.md`. */ path: string; /** False before anything has written one; `attrs` and `body` are then empty. */ exists: boolean; /** Free-form: any keys, any values. spwn owns `cost` and rewrites it each turn. */ attrs: Record<string, unknown> & { cost?: SessionCost }; body: string;}
/** * What a session has spent, as spwn writes it into the front matter each turn. * * `tokens` is this conversation end to end, inherited prefix included. `own` is the part * that first appeared in this session — the figure you add up across a group. **When * `own` is absent it equals `tokens`** (the session inherited nothing), unless * `ownUnknown` is set, in which case its share could not be determined and it should be * left out of a sum rather than assumed. */export interface SessionCost { /** Estimated, from the rate table in Settings. Absent when no model was priced. */ usd?: number; tokens: CostTokens; /** Absent ⇒ equals `tokens`. */ own?: CostTokens; ownUsd?: number; /** The prefix copied from a parent conversation; only present on a fork. */ inherited?: CostTokens; /** A fork whose parent transcript is gone: `own` can't be determined. */ ownUnknown?: boolean; /** The single model used, when there was only one. */ model?: string; /** Per-model breakdown, when more than one was used. */ byModel?: Record<string, CostTokens & { usd?: number }>; /** Models with no rate in the table: their tokens count, their dollars don't. */ unpriced?: string[]; /** Agent session ids this doc has spent under (more than one after a rewind). */ sessions: string[]; /** Transcript files the scan read. A drop here means lost subagent transcripts. */ files: number;}
export interface CostTokens { input: number; output: number; cacheRead: number; /** `cacheWrite5m + cacheWrite1h`; the two are billed at different rates. */ cacheWrite: number; cacheWrite5m: number; cacheWrite1h: number; requests: number;}
/** * Spend across the sessions working one problem, grouped by a `problem` key in the * front matter, else the workflow key, else fork lineage. * * `total` is the deduplicated union across the group, computed from the transcripts — * **not** the sum of each session's `cost.tokens`. A fork's transcript re-carries its * parent's, so adding those up counts the shared prefix once per branch. Each session's * `cost.own` is the part that is safe to add up, and does sum to `total`. */export interface CostRollup { problem: { kind: "declared" | "workflowKey" | "lineage"; key: string }; sessions: { terminalId: string; title: string; branch: string | null; own: unknown; /** Who opened it; null for a session from before spwn recorded owners. */ owner: { id: string; name: string } | null; }[]; total: Record<string, unknown>; /** * The same spend cut by who did it — a second axis over the group, not another * grouping rule: a session has a problem *and* an owner at the same time. * * Summed from the sessions' frozen `own` figures. Do not re-derive it by scanning * per owner: whoever holds a fork would be charged for the prefix its parent has * already paid for. */ byOwner: OwnerSpend[]; /** * Present only when `byOwner` does not add up to `total`: sessions with no owner, * sessions nothing has costed yet, and a turn that landed between the two reads. * Report it rather than printing a total that is not the sum of its parts. */ unattributed?: { requests: number; usd?: number } | null;}
/** What one person's sessions cost inside a rollup. */export interface OwnerSpend { id: string; name: string; /** spwn's own: an autostarted workflow or a scheduler tick. Nobody's budget. */ automation: boolean; /** How many of the group's sessions are theirs, countable or not. */ sessions: number; tokens: CostTokens; usd?: number | null; /** Any entry means `usd` is a floor, not the figure. */ unpriced: string[]; /** Theirs that carry no addable figure, each with the reason. */ uncostable: { terminalId: string; title: string; reason: string }[];}
export type TurnResult = | { blocked?: undefined; turnUuid: string; text: string | null; status: SessionStatus } | { blocked: true; status: SessionStatus; screen: string };
export interface HookPrompt { /** The hook event that asked (`session-created`, …). */ event: string; /** The session's id. */ session: string; question: string; header?: string | null; multiSelect: boolean; options: Array<{ label: string; description?: string | null }>;}
/** Return an option's label (or several, for `multiSelect`); `null` declines. */export type HookPromptHandler = ( prompt: HookPrompt,) => string | string[] | null | undefined | Promise<string | string[] | null | undefined>;
export interface AgentRunOptions { prompt: string; agent?: string; title?: string; key?: string; onHookPrompt?: HookPromptHandler;}
export interface AgentRunResult { ok: boolean; error: string | null; /** The run's session, which stays in the sidebar to read. */ sessionId: string; text: string | null;}
export interface TranscriptTurn { uuid: string; parentUuid: string | null; role: "user" | "assistant"; timestamp: string | null; model: string | null; blocks: Array<{ kind: "text" | "thinking" | "toolUse" | "toolResult"; text: string | null; name: string | null; isError: boolean | null; id: string | null; }>;}
export interface ExecOptions { /** Relative to the project dir. */ cwd?: string; env?: Record<string, string>; /** Written to stdin. */ input?: string; /** Default 10 minutes. */ timeoutMs?: number;}
export interface ExecResult { /** Null when the process was killed by a signal. */ code: number | null; ok: boolean; stdout: string; stderr: string;}
export interface FetchOptions { method?: string; headers?: Record<string, string>; body?: string; /** Sent as the JSON body (sets Content-Type). */ json?: unknown; /** Default 60s. */ timeoutMs?: number;}
export interface FetchResponse { status: number; ok: boolean; headers: Record<string, string>; body: string; text(): Promise<string>; json<T = any>(): Promise<T>;}
/** A session lifecycle event — its hooks, if any, have already run. */export interface SessionEvent { event: "session-created" | "session-ready" | "session-turn" | "session-deleted"; projectId: string; /** The session's id (`Session.id`). */ terminalId: string; sessionId?: string | null; /** Set for `session-turn`. */ turnUuid?: string | null; branch?: string | null; worktree?: string | null; workflow?: { name: string; key?: string | null } | null;}
export interface SpwnEvents { "session-created": SessionEvent; "session-ready": SessionEvent; "session-turn": SessionEvent; "session-deleted": SessionEvent; /** A session's live status changed. */ status: { terminalId: string; status: SessionStatus };}
/** A pull request on the forge, as `spwn.pulls` and `session.pullRequest()` report it. */export interface PullRequest { number: number; title: string; url: string; /** GraphQL node id. Draft↔ready is only reachable through the mutations. */ nodeId: string; state: "open" | "draft" | "merged" | "closed"; isDraft: boolean; headBranch: string; baseBranch: string; /** Pass this back as `expectedHead` so a merge can't land a commit you didn't see. */ headSha: string; author: string | null; checks: "none" | "pending" | "passing" | "failing"; /** A red rollup is one verdict, not a tally: this is 1 when anything failed. */ checksFailing: number; checksTotal: number; reviewDecision: "approved" | "changesRequested" | "reviewRequired" | "none"; /** Whether git *can* merge. `unknown` means GitHub hasn't worked it out — never * treat it as mergeable. */ mergeable: "mergeable" | "conflicting" | "unknown"; /** Whether GitHub *will*: clean | blocked | behind | dirty | unstable | draft | unknown. */ mergeState: string; updatedAt: number; mergedAt: number | null; /** The session whose branch this is, when spwn has one. */ terminalId: string | null;}
export type MergeMethod = "merge" | "squash" | "rebase";
/** Every pull request spwn knows about for this project's repository. */export interface RepoPrView { /** `owner/name`. */ slug: string; host: string; /** False when there's no remote, or spwn has no forge for its host. */ supported: boolean; unsupportedReason: string | null; /** False when no GitHub token is saved in Settings. */ authenticated: boolean; defaultBranch: string | null; isFork: boolean; /** The token can push here. False means don't try to open or merge anything. */ canPush: boolean; prs: PullRequest[]; /** When this snapshot was read from GitHub (epoch ms); 0 before the first poll. */ fetchedAt: number; /** Sticky until a poll succeeds — a dropped network keeps the last list. */ error: string | null; rateLimitedUntil: number | null;}
export interface CreatePrResult { pr: PullRequest; /** The branch was pushed as part of this. */ pushed: boolean; /** An existing pull request was adopted rather than a new one opened. */ adopted: boolean; /** Uncommitted changes stayed in the worktree; they are not in the pull request. */ uncommittedLeftBehind: boolean; docPath: string;}