Files
airship/packages/server/src/index.ts
T
Nayan 8e0c91c5dc fix(git): carry every failure's reason, and stop undo deleting files
packages/git funnelled every invocation through `catch { return null }`, so a
missing commit identity, a stale index.lock, a pathspec that matched nothing
and a git that was not installed all reached the user as "commit failed" — and
a git that could not run at all was reported as "not a git repository", which
is what a Windows contributor hit after installing Git for Git Bash only.

That null had a second meaning. `fileAtHead` returned it both for "this file
was not in HEAD" and for "we could not ask", so with git unavailable every
edited file was recorded `isNew` and undo deletes what an edit created: on the
codex and opencode paths, Revert deleted pre-existing tracked source files.
`HeadRead` splits the two, `FileDiff.noBaseline` carries the difference, and
`restoreFiles` checks it before `isNew` and refuses.

One `run()` now keeps exit code, stderr and spawn errno, and `failureText`
turns them into the one line a toast has room for. `stdout` is half its input,
not a fallback: `git commit` with nothing staged exits 1 and says so on stdout.

Also here, each its own small correctness fix:

- `cat-file --filters` instead of `show`, so `before` is the working-tree form.
  The stored blob is LF whatever the tree is, and undo was writing that back
  over CRLF files, rewriting every line ending in them.
- `:(literal)` pathspecs. `git add -- 'a[1].ts'` also stages an unrelated
  `a1.ts`, so one edited file could sweep untouched work into the auto-commit.
- A binary blob is `unavailable` rather than decoded through U+FFFD and then
  written back corrupted.
- `restoreFiles` cannot throw. `undo` is called straight out of the WebSocket
  message handler, so an EACCES there took the whole daemon down.
- `push` and `gh pr create` get a timeout, a closed stdin and
  `GIT_TERMINAL_PROMPT=0`. Git Credential Manager opens a GUI behind the
  browser on Windows, and the awaiting handler never replied.
- `LC_ALL=C`, so classifying "absent from HEAD" does not depend on git's
  messages being untranslated.
- One 64MB buffer ceiling for every call, not just the blob read.

`gitStatus()` is the answer that can be shown to someone: not installed, not a
work tree, bare, no commits yet, no identity. `ghStatus` gains the same
precision and a cwd, since `gh auth status` resolves its host from the remote.

The package had no tests at all, which is how the data-loss bug shipped. It has
34 now, including the reported scenario driven with PATH scrubbed of git.
2026-08-16 13:05:37 +05:30

1005 lines
33 KiB
TypeScript

/**
* @airship/server — the daemon. Runs the universal proxy, serves the overlay,
* speaks the @airship/protocol WebSocket, and drives edits through @airship/core.
*/
import type { AddressInfo, Socket } from "node:net";
import type {
CodexSettings,
OpencodeSettings,
RunEditResult,
} from "@airship/core";
import {
buildEditPrompt,
listAllModels,
namesProvider,
runEdit,
shutdownOpencodeServer,
} from "@airship/core";
import {
commitEdit,
createBranch,
createPr,
currentBranch,
defaultBranch,
type GitStatus,
ghStatus,
gitStatus,
hasRemote,
pushBranch,
restoreFiles,
} from "@airship/git";
import {
type AgentKind,
type AirshipSurface,
type AttrEditTarget,
type ClientMessage,
ClientMessageSchema,
type CreateJobRequest,
type Effort,
type ElementContext,
type GitHealth,
type JobDiffBundle,
type JobStatus,
type ModelCatalogue,
type MoveEdit,
type ReviewComment,
type ServerEvent,
type SourceLocation,
surfaceToMode,
type VisualEditTarget,
} from "@airship/protocol";
import { scanProjectTokens } from "@airship/source/tokens";
import { type RawData, WebSocket, WebSocketServer } from "ws";
import { bindUrl, buildAllowedHosts } from "./access";
import { listHistory, readBundle, thread, writeBundle } from "./history";
import { JobStore } from "./jobs";
import { openInEditor } from "./open-editor";
import { preparePromptInput } from "./prompt-input";
import { createProxyServer } from "./proxy";
export type {
CodexConfigValue,
CodexSettings,
ModelProbeOptions,
OpencodeSettings,
} from "@airship/core";
/** Re-exported so the CLI depends only on @airship/server. */
export { checkAuth, listModels } from "@airship/core";
export type { GitFailure, GitStatus } from "@airship/git";
export { gitStatus, isGitRepo, onGitFailure } from "@airship/git";
export type { AgentKind, AirshipSurface, Effort } from "@airship/protocol";
const WS_PATH = "/__airship/ws";
export interface ServerOptions {
/** Default backend for turns that do not name one. */
agent?: AgentKind;
/**
* Hostnames the server answers for beyond `localhost` and IP literals —
* `/etc/hosts` aliases, tunnel names. Exact matches only.
*/
allowedHosts?: readonly string[];
/** Auto-commit each accepted edit with a Conventional-Commits message. */
autoCommit?: boolean;
/** Codex-only passthrough knobs; opaque here by design. */
codex?: CodexSettings;
/** Project root for file edits. */
cwd: string;
effort?: Effort;
/**
* Interface the proxy *listens* on — not `targetHost`, the upstream dev
* server it forwards to. Loopback by default: this is an unauthenticated
* server that drives a coding agent with write access to the project.
*/
host?: string;
/**
* Keep upstream `Content-Security-Policy` headers on served surfaces
* instead of stripping them. `X-Frame-Options` is dropped regardless.
*/
keepCsp?: boolean;
maxBudgetUsd?: number;
/** Claude-only turn cap. */
maxTurns?: number;
/** Cross-harness model default, from `--model`. Superseded per backend by
* `models`, and by a turn that names its own. Kept because the launch banner
* reads it, and because it is still the right answer for a single-backend run. */
model?: string;
/**
* Per-backend model defaults, already resolved by the CLI (`--claude-model`
* and friends, each falling back to `--model`).
*
* Per backend rather than one string because the overlay's picker can change
* harness mid-session: a single default would hand a `claude-opus-5` to Codex
* the first time someone switched.
*/
models?: Partial<Record<AgentKind, string>>;
/** OpenCode-only passthrough knobs; opaque here by design. */
opencode?: OpencodeSettings;
/** Port Airship's proxy listens on. */
port: number;
/** Sandbox edits to the project and cut network access. A launch-level
* posture, not per-job — the overlay picker chooses the agent, not this. */
safe?: boolean;
/**
* Which surface a top-level navigation lands on. A default, not a lock: the
* editor's own switcher sets a cookie that outranks it, and `?__airship=`
* outranks both.
*/
surface?: AirshipSurface;
targetHost?: string;
/** Port the user's dev server is already running on. */
targetPort: number;
}
export interface RunningServer {
close: () => Promise<void>;
url: string;
}
export async function startServer(opts: ServerOptions): Promise<RunningServer> {
// Default to the hostname (not 127.0.0.1): dev servers like Vite 6 bind IPv6
// `localhost` (::1) only, so a hardcoded IPv4 target gets ECONNREFUSED. Node's
// happy-eyeballs (autoSelectFamily) then reaches whichever family it bound.
const targetHost = opts.targetHost ?? "localhost";
// Explicit, and never a wildcard by default — the same posture as the
// opencode server (opencode-server.ts): this is an unauthenticated HTTP
// server that can drive a coding agent with filesystem write access.
const host = opts.host ?? "127.0.0.1";
const { cwd } = opts;
const jobs = new JobStore();
const clients = new Set<WebSocket>();
const wss = new WebSocketServer({ noServer: true });
// Serialize edits: concurrent edits against the same working tree would
// cross-contaminate diff capture and undo baselines.
let editChain: Promise<void> = Promise.resolve();
/**
* The model catalogue, probed once and kept.
*
* Memoized as the in-flight promise rather than its value, so two tabs
* opening their pickers together share one probe instead of racing to start
* two `opencode serve` processes. A rejection is not possible to observe
* here — `listAllModels` reports failures as groups with a `note` — but the
* memo is cleared on one anyway, so a genuinely broken probe is retried
* rather than cached forever.
*/
let catalogue: Promise<ModelCatalogue> | null = null;
function modelCatalogue(refresh?: boolean): Promise<ModelCatalogue> {
if (refresh) {
catalogue = null;
}
catalogue ??= listAllModels(cwd, {
opencode: opts.opencode,
safe: opts.safe,
})
.then((groups) =>
// The daemon's own resolved default outranks whatever the backend
// reports: if someone launched with `--codex-model gpt-5.4`, that is
// what an unpicked turn will run, so it is what the menu must lead with.
groups.map((group) => ({
...group,
default: opts.models?.[group.agent] ?? group.default,
}))
)
.catch((err) => {
catalogue = null;
throw err;
});
return catalogue;
}
function broadcast(event: ServerEvent): void {
const data = JSON.stringify(event);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
function send(ws: WebSocket, event: ServerEvent): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
}
}
/**
* Can the overlay offer its git verbs at all?
*
* Uncached on purpose. It is a handful of `rev-parse` calls, it runs on
* connect and after a turn rather than per edit, and a repo can gain its
* first commit — or have its git uninstalled — while a tab stays open. A
* stale "yes" here is a click that fails for a reason the user was already
* told about and can no longer see.
*/
function gitHealth(): GitHealth {
return healthOf(gitStatus(cwd));
}
wss.on("connection", (ws: WebSocket) => {
clients.add(ws);
send(ws, {
defaultAgent: opts.agent ?? "claude",
jobs: jobs.snapshots(),
type: "hello",
});
// Beside the handshake rather than deferred: the transcript can paint a
// finished turn's action menu immediately, and a menu that offers Commit
// for half a second before greying it out is worse than one that never
// offered it.
send(ws, { health: gitHealth(), type: "git:health" });
// Push the token scan without being asked. The inspector needs it to render
// its very first selection, and a request/response round trip would leave
// the badges missing for the first element the user clicks. Deferred off the
// handshake so the first connection is never waiting on a tree walk.
setImmediate(() => {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
try {
send(ws, { scan: scanProjectTokens(cwd), type: "tokens:result" });
} catch {
// A token scan is an enhancement, never a reason to break the session.
}
});
ws.on("message", (data: RawData) => handleMessage(ws, data));
ws.on("close", () => clients.delete(ws));
ws.on("error", () => clients.delete(ws));
});
function handleMessage(ws: WebSocket, data: RawData): void {
let parsed: ClientMessage;
try {
parsed = ClientMessageSchema.parse(JSON.parse(data.toString()));
} catch (err) {
send(ws, {
message: `invalid message: ${err instanceof Error ? err.message : String(err)}`,
type: "error",
});
return;
}
switch (parsed.type) {
case "edit": {
const { request } = parsed;
// Refused here rather than inside `startEdit`, so a model opencode
// cannot run neither creates a job nor takes a place in the edit chain.
const refusal = modelRefusal(request, opts);
if (refusal) {
send(ws, { message: refusal, type: "error" });
break;
}
editChain = editChain
.then(() => startEdit(request))
.catch((err) => {
broadcast({
message: `edit failed: ${err instanceof Error ? err.message : String(err)}`,
type: "error",
});
});
break;
}
case "cancel":
jobs.get(parsed.jobId)?.abort?.abort();
break;
case "undo":
undo(ws, parsed.jobId);
break;
case "history":
send(ws, { entries: listHistory(cwd), type: "history" });
break;
// Read-only, so deliberately *not* on `editChain`: a token scan touches no
// files and must not queue behind a running edit, or the inspector would
// sit tokenless for as long as the agent takes.
case "tokens":
send(ws, {
scan: scanProjectTokens(cwd, { refresh: parsed.refresh }),
type: "tokens:result",
});
break;
// Off `editChain` like `tokens`, and for a stronger reason: the probe
// talks to the same backends a running turn is using, and queueing it
// would leave the picker spinning for the length of an edit. Answered to
// the asking socket only — the menu that asked is the one waiting.
case "models":
modelCatalogue(parsed.refresh)
.then((result) =>
send(ws, { catalogue: result, type: "models:result" })
)
.catch((err) => {
send(ws, {
message: `model list failed: ${err instanceof Error ? err.message : String(err)}`,
type: "error",
});
});
break;
// Off `editChain` for the same reason as `tokens`: it only resolves
// sources and renders a string. Queueing it behind a running edit would
// freeze the composer's live preview for the length of a turn. It does
// read files the agent may be mid-write on, which is harmless for a
// preview and cheaper than the alternative.
case "prompt":
sendPromptPreview(ws, parsed.request);
break;
case "thread":
send(ws, {
entries: thread(cwd, parsed.rootJobId),
rootJobId: parsed.rootJobId,
type: "thread",
});
break;
case "commit":
commit(ws, parsed.jobId, parsed.message, parsed.push);
break;
case "open":
send(ws, {
file: parsed.file,
type: "open:result",
...openInEditor(cwd, parsed),
});
break;
case "pr":
createPullRequest(ws, parsed.jobId, parsed.title, parsed.branch);
break;
default:
break;
}
}
async function startEdit(request: CreateJobRequest): Promise<void> {
const displayPrompt = request.prompt.trim() || synthesizeLabel(request);
const rec = jobs.create(displayPrompt);
broadcast({
job: {
createdAt: rec.createdAt,
jobId: rec.jobId,
prompt: displayPrompt,
status: "running",
},
type: "job:created",
});
// The same resolution the `prompt` preview runs, from the same function —
// the two must render the identical string.
const promptInput = preparePromptInput(cwd, request);
const { agent, model } = resolveTarget(request, opts);
const resumeSessionId = resolveResume(cwd, request.parentJobId, agent);
const abort = new AbortController();
rec.abort = abort;
const result = await runEdit(
{
// Spread first, and restate none of its fields below: every
// prompt-relevant input has to come from `preparePromptInput` or the
// preview stops matching what the agent is sent.
...promptInput,
abortController: abort,
agent,
codex: opts.codex,
cwd,
effort: opts.effort,
fork: request.fork,
images: request.images,
maxBudgetUsd: opts.maxBudgetUsd,
maxTurns: opts.maxTurns,
model,
opencode: opts.opencode,
resumeSessionId,
safe: opts.safe,
},
{
onSessionId: (id) => {
rec.sessionId = id;
},
onStep: (step) => {
rec.step = step;
broadcast({ jobId: rec.jobId, step, type: "job:step" });
},
onText: (delta) =>
broadcast({ delta, jobId: rec.jobId, type: "job:text" }),
onTimeline: (item) =>
broadcast({ item, jobId: rec.jobId, type: "job:timeline" }),
onTimelinePatch: (id, patch) =>
broadcast({
id,
jobId: rec.jobId,
patch,
type: "job:timeline:patch",
}),
onTodos: (todos) =>
broadcast({ jobId: rec.jobId, todos, type: "job:todos" }),
}
);
const status = jobStatus(abort.signal.aborted, result.ok);
const bundle = buildBundle({
agent,
createdAt: rec.createdAt,
displayPrompt,
jobId: rec.jobId,
model,
parentJobId: request.parentJobId,
primaryElement: promptInput.element,
result,
source: promptInput.source ?? null,
status,
});
writeBundle(cwd, bundle);
jobs.finish(rec.jobId, status, result.error);
broadcast({
error: result.error,
jobId: rec.jobId,
status,
type: "job:status",
});
// Before `job:done`, not after. That event is what paints the finished
// turn, and the turn menu captures the health it was rendered with — so
// sending it afterwards would leave the newest turn a beat behind.
broadcast({ health: gitHealth(), type: "git:health" });
broadcast({ bundle, jobId: rec.jobId, type: "job:done" });
broadcast({ entries: listHistory(cwd), type: "history" });
if (opts.autoCommit && status === "done" && result.diffs.length) {
const auto = commitEdit(
cwd,
result.diffs.map((d) => d.file),
bundle.summary || displayPrompt
);
broadcast({
error: auto.ok ? undefined : `auto-commit failed: ${auto.error}`,
ok: auto.ok,
sha: auto.sha,
type: "commit:result",
});
}
}
/**
* Render the instruction this request would produce, without running it.
*
* Goes through `preparePromptInput` + `buildEditPrompt` — the same two calls
* `startEdit` makes — so what the user reads is what the agent is handed.
*/
function sendPromptPreview(ws: WebSocket, request: CreateJobRequest): void {
try {
send(ws, {
text: buildEditPrompt(preparePromptInput(cwd, request)),
type: "prompt:result",
});
} catch (err) {
send(ws, {
message: `prompt preview failed: ${err instanceof Error ? err.message : String(err)}`,
type: "error",
});
}
}
function undo(ws: WebSocket, jobId: string): void {
const bundle = readBundle(cwd, jobId);
if (!bundle?.diffs.length) {
send(ws, {
error: "nothing to undo",
jobId,
ok: false,
type: "undo:result",
});
return;
}
// Content-restore from the before-state captured by the SDK PreToolUse
// hooks — instant and reliable. (SDK `rewindEdit` is the alternative.)
const { restored, skipped } = restoreFiles(cwd, bundle.diffs);
const ok = skipped.length === 0;
broadcast({
// The count alone was the whole message, which told the user a number and
// nothing they could act on. The first reason is the useful half; the
// rest are almost always the same one.
error: ok
? undefined
: `restored ${restored.length}/${bundle.diffs.length} files — ${skipped[0].file}: ${skipped[0].reason}`,
jobId,
ok,
type: "undo:result",
});
broadcast({ entries: listHistory(cwd), type: "history" });
}
async function commit(
ws: WebSocket,
jobId: string,
message?: string,
push?: boolean
): Promise<void> {
const bundle = readBundle(cwd, jobId);
if (!bundle?.diffs.length) {
send(ws, {
error: "nothing to commit",
ok: false,
type: "commit:result",
});
return;
}
const { error, ok, sha } = commitEdit(
cwd,
bundle.diffs.map((d) => d.file),
message || bundle.summary || bundle.prompt
);
if (!(ok && push)) {
send(ws, { error, ok, sha, type: "commit:result" });
return;
}
const branch = currentBranch(cwd);
if (!branch) {
send(ws, {
error: "committed, but HEAD is detached — nothing to push",
ok: true,
pushed: false,
sha,
type: "commit:result",
});
return;
}
// Checked here as well as in `prPreflight`: pushing to a remote that does
// not exist otherwise spends the network timeout before saying so.
if (!hasRemote(cwd)) {
send(ws, {
error: "committed, but there is no `origin` remote to push to",
ok: true,
pushed: false,
sha,
type: "commit:result",
});
return;
}
const pushed = await pushBranch(cwd, branch);
send(ws, {
error: pushed.ok
? undefined
: `committed, but push failed: ${pushed.error}`,
ok: true,
pushed: pushed.ok,
sha,
type: "commit:result",
});
}
/**
* Commit → push → `gh pr create`.
*
* Each step fails differently and a bare "failed" is useless, so `stage` says
* which one stopped. The preflight is most of the value: being on the default
* branch or having no `origin` are the two everyday cases, and both are worth
* naming before anything is committed.
*/
async function createPullRequest(
ws: WebSocket,
jobId: string,
title?: string,
branch?: string
): Promise<void> {
const fail = (
stage: "branch" | "commit" | "create" | "preflight" | "push",
error: string
): void => {
send(ws, { error, ok: false, stage, type: "pr:result" });
};
const bundle = readBundle(cwd, jobId);
const blocked = prPreflight(cwd, bundle);
if (blocked || !bundle) {
fail("preflight", blocked ?? "nothing to open a pull request for");
return;
}
const base = defaultBranch(cwd);
const branched = ensureFeatureBranch(cwd, jobId, base, branch);
if (!branched.head) {
fail(branched.stage ?? "preflight", branched.error ?? "no branch");
return;
}
const { head } = branched;
const summary = bundle.summary || bundle.prompt;
const committed = commitEdit(
cwd,
bundle.diffs.map((d) => d.file),
summary
);
if (!committed.ok) {
fail("commit", committed.error ?? "commit failed");
return;
}
const pushed = await pushBranch(cwd, head);
if (!pushed.ok) {
fail("push", pushed.error ?? "push failed");
return;
}
const pr = await createPr(cwd, {
base: base ?? undefined,
body: summary,
head,
title: title || summary.slice(0, 72),
});
send(ws, {
error: pr.error,
ok: pr.ok,
stage: pr.ok ? undefined : "create",
type: "pr:result",
url: pr.url,
});
}
const server = createProxyServer({
allowedHosts: buildAllowedHosts(opts.allowedHosts, opts.host),
defaultMode: surfaceToMode(opts.surface ?? "canvas"),
keepCsp: opts.keepCsp,
onAirshipUpgrade: (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
},
targetHost,
targetPort: opts.targetPort,
wsPath: WS_PATH,
});
// A tunnelled upgrade (Vite HMR) hands the socket off to the proxy, so the
// http server stops accounting for it and `close()` will never reclaim it.
// Track them here so shutdown can hang up itself — otherwise Ctrl-C waits on
// an HMR socket that, by design, stays open forever.
const tunnelled = new Set<Socket>();
server.on("upgrade", (_req, socket) => {
tunnelled.add(socket as Socket);
socket.on("close", () => tunnelled.delete(socket as Socket));
});
// The `error` listener is what makes this rejectable. Without it an
// EADDRINUSE — or, on Windows, the EACCES you get from a port inside a
// Hyper-V/WSL2 reserved range — is emitted with nobody listening, becomes an
// uncaughtException, and escapes the caller's try/catch, so the dev server
// airship started with `--exec` is never stopped either.
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(opts.port, host, () => {
server.removeListener("error", reject);
resolve();
});
});
const addr = server.address() as AddressInfo | null;
const port = addr?.port ?? opts.port;
const url = bindUrl(host, port);
return {
close: () =>
new Promise<void>((resolve) => {
// The OpenCode backend spawns an `opencode serve` child that outlives
// the run and holds the event loop open. It is a no-op on the other
// backends and when no opencode edit ever happened.
shutdownOpencodeServer();
// `terminate`, not `close`: a graceful ws handshake waits on a peer that
// may never answer, and this path is already "we are going away".
for (const c of clients) {
c.terminate();
}
wss.close();
for (const socket of tunnelled) {
socket.destroy();
}
server.close(() => resolve());
// After `close()`, so no new connection sneaks in behind it. Idle
// keep-alive sockets from an open browser tab would otherwise hold the
// server up until they time out on their own.
server.closeAllConnections();
}),
url,
};
}
/**
* Which git problems stop the overlay's git verbs, and which do not.
*
* The verbs are commit, push and open a pull request. Deliberately not gated on
* `hasCommits`: the first commit in a fresh repository is exactly the thing
* that works there, so greying Commit out for want of a HEAD would block the
* action that creates one. Having no HEAD costs the diff *baseline*, which is a
* per-turn fact carried on `FileDiff.noBaseline` and gates Revert instead.
*
* Exported for its own test. `gitStatus` short-circuits in the order these are
* read, so `error` always describes the field that actually failed here.
*/
export function healthOf(status: GitStatus): GitHealth {
if (status.installed && status.workTree) {
return { ok: true };
}
return {
hint: status.hint,
ok: false,
reason: status.error ?? "git is unavailable",
};
}
function preview(prompt: string): string {
return prompt.length > 120 ? `${prompt.slice(0, 117)}…` : prompt;
}
/** A readable label for a deltas-only visual edit (used when no note is given). */
/** Everything that must hold before a PR is possible. Null means "go ahead". */
function prPreflight(cwd: string, bundle: JobDiffBundle | null): string | null {
if (!bundle?.diffs.length) {
return "nothing to open a pull request for";
}
// `gitStatus` rather than `isGitRepo`, because this message is the one the
// user acts on: a git that is not installed, a bare repo and a directory that
// is not a repository all used to arrive here as "not a git repository".
const git: GitStatus = gitStatus(cwd);
if (!(git.workTree && git.hasCommits)) {
return git.error ?? "git is unavailable";
}
if (!hasRemote(cwd)) {
return "no `origin` remote";
}
// Scoped to the repo: `gh auth status` resolves which host to check from the
// remote, so asking without a cwd can pass while the push still fails.
const gh = ghStatus(cwd);
if (!gh.authed) {
return gh.error ?? "gh is unavailable";
}
return null;
}
/**
* The branch to open the PR from. A PR from the default branch to itself is not
* a thing, so being on it means branching first.
*/
function ensureFeatureBranch(
cwd: string,
jobId: string,
base: string | null,
requested?: string
): { error?: string; head?: string; stage?: "branch" | "preflight" } {
const head = currentBranch(cwd);
if (!head) {
return { error: "HEAD is detached", stage: "preflight" };
}
if (head !== base) {
return { head };
}
const name = requested || `airship/${jobId.slice(0, 8)}`;
const branched = createBranch(cwd, name);
if (!branched.ok) {
return {
error: `could not create branch ${name}: ${branched.error}`,
stage: "branch",
};
}
return { head: name };
}
function jobStatus(aborted: boolean, ok: boolean): JobStatus {
if (aborted) {
return "cancelled";
}
return ok ? "done" : "failed";
}
/**
* How far up the parent chain to look for a resumable session. Adapters now
* withhold `sessionId` from a bundle whose turn a provider rejected outright
* (the session is poisoned — resuming it replays the rejected exchange), so
* one bad turn mid-thread should fall through to the last good session, not
* lose the whole conversation. Bounded because bundles written before
* `sessionId` existed would otherwise let the walk reach arbitrarily far into
* unrelated history.
*/
const RESUME_WALK_LIMIT = 3;
/**
* Which backend this turn runs on, and on which model.
*
* Three steps each: what the turn asked for, else the default resolved for the
* backend that is actually running, else the cross-harness one. Unlike `agent`,
* a missing model is fine — every adapter reads an absent model as "use your
* own default".
*
* Per backend rather than one string because the overlay's picker can change
* harness mid-session, so a single default would follow it and hand Codex an id
* only Claude answers to.
*
* One function, and module-level rather than a closure, for two reasons: the
* `edit` handler validates the model that `startEdit` then sends, and resolving
* it in both places would let the checked value drift from the run one; and a
* precedence chain this load-bearing should be reachable from a test without
* standing a server up.
*/
export function resolveTarget(
request: Pick<CreateJobRequest, "agent" | "model">,
opts: Pick<ServerOptions, "agent" | "model" | "models">
): { agent: AgentKind; model?: string } {
const agent = request.agent ?? opts.agent ?? "claude";
return {
agent,
model: request.model ?? opts.models?.[agent] ?? opts.model,
};
}
/**
* Why this turn's model cannot run, or `null` if it can.
*
* `toModelBody` drops an opencode ref that does not name a provider, which makes
* asking for one indistinguishable from asking for nothing: the turn runs on the
* server's default while the composer goes on showing what was picked.
*
* It reads `request.model` and deliberately **not** the resolved model. The
* three ways a model gets here are guarded differently on purpose, and `args.ts`
* spells out why:
*
* - `--opencode-model` names its backend, so a bare id there can only be a
* mistake — a hard error at parse time.
* - `--model` reaches all three backends, where a bare id is correct for two of
* them. It warns at launch and the turn runs on opencode's own default.
* - The picker's custom-model box had no guard at either end. It is the one door
* left, and the one where the user is choosing right now and can act on being
* told.
*
* Guarding the *resolved* model would fold the second case into the first and
* turn a documented warn-and-continue into "every edit refused".
*/
export function modelRefusal(
request: Pick<CreateJobRequest, "agent" | "model">,
opts: Pick<ServerOptions, "agent" | "model" | "models">
): string | null {
const { agent } = resolveTarget(request, opts);
if (agent !== "opencode" || !request.model || namesProvider(request.model)) {
return null;
}
return `opencode cannot run '${request.model}': it resolves a model through its provider, so it needs the provider/model form — try 'anthropic/${request.model}'.`;
}
/**
* The session to resume, or null to start clean.
*
* `sessionId` names a Claude session on one backend and a Codex thread on the
* other, so resuming across a switch would hand an id to a backend that has
* never seen it — the agent check runs per hop for the same reason. Dropping
* it starts a clean session instead: the conversation is lost either way, but
* the turn still runs. Bundles written before `agent` existed are Claude by
* construction.
*/
export function resolveResume(
cwd: string,
parentJobId: string | undefined,
agent: AgentKind
): string | null {
const seen = new Set<string>();
let jobId = parentJobId;
for (let hop = 0; jobId && hop < RESUME_WALK_LIMIT; hop += 1) {
if (seen.has(jobId)) {
return null;
}
seen.add(jobId);
const parent = readBundle(cwd, jobId);
if (!parent) {
return null;
}
if ((parent.agent ?? "claude") !== agent) {
return null;
}
if (parent.sessionId) {
return parent.sessionId;
}
jobId = parent.parentJobId;
}
return null;
}
function buildBundle(args: {
agent: AgentKind;
createdAt: number;
displayPrompt: string;
jobId: string;
model?: string;
parentJobId?: string;
primaryElement?: ElementContext;
result: RunEditResult;
source: SourceLocation | null;
status: JobStatus;
}): JobDiffBundle {
const { displayPrompt, primaryElement, result } = args;
return {
additions: result.diffs.reduce((n, d) => n + d.additions, 0),
agent: args.agent,
checkpointId: result.checkpointId ?? undefined,
completedAt: Date.now(),
createdAt: args.createdAt,
deletions: result.diffs.reduce((n, d) => n + d.deletions, 0),
diffs: result.diffs,
error: result.error,
filesChanged: result.diffs.length,
followUps: result.followUps,
jobId: args.jobId,
model: args.model,
parentJobId: args.parentJobId,
prompt: displayPrompt,
promptPreview: preview(displayPrompt),
sessionId: result.sessionId ?? undefined,
status: args.status,
summary: result.summary,
target: {
displayName: primaryElement?.displayName ?? null,
source: args.source ?? null,
tagName: primaryElement?.tagName ?? "",
},
timeline: result.timeline,
usage: result.usage,
};
}
/**
* Visual-only edits carry an empty prompt; synthesize a readable label for the
* job list, history, and (auto-)commit message.
*/
function synthesizeLabel(request: CreateJobRequest): string {
if (request.comments?.length) {
return summarizeComments(request.comments);
}
if (request.visualChanges?.length) {
return summarizeVisual(request.visualChanges);
}
if (request.attrChanges?.length) {
return summarizeAttrs(request.attrChanges);
}
return summarizeMoves(request.moveChanges);
}
/** A readable label for an attributes-only turn. */
function summarizeAttrs(targets: AttrEditTarget[]): string {
const count = targets.reduce((n, t) => n + t.changes.length, 0);
const first = targets[0].element;
const name = first.displayName ?? `<${first.tagName}>`;
const scope =
targets.length > 1 ? `${name} +${targets.length - 1} more` : name;
return `Attribute edit: ${count} change${count === 1 ? "" : "s"} to ${scope}`;
}
function summarizeVisual(targets?: VisualEditTarget[]): string {
if (!targets?.length) {
return "Visual edit";
}
const changeCount = targets.reduce((n, t) => n + t.changes.length, 0);
const first = targets[0].element;
const name = first.displayName ?? `<${first.tagName}>`;
const scope =
targets.length > 1 ? `${name} +${targets.length - 1} more` : name;
return `Visual edit: ${changeCount} change${changeCount === 1 ? "" : "s"} to ${scope}`;
}
/** A readable label for a comments-only turn (used when no note is given). */
function summarizeComments(comments: ReviewComment[]): string {
const files = new Set(comments.map((c) => c.file));
const where =
files.size === 1
? [...files][0]
: `${files.size} file${files.size === 1 ? "" : "s"}`;
return `Review: ${comments.length} comment${comments.length === 1 ? "" : "s"} on ${where}`;
}
/** A readable label for a moves-only edit (used when no note is given). */
function summarizeMoves(moves?: MoveEdit[]): string {
if (!moves?.length) {
return "Visual edit";
}
const [first] = moves;
const what = first.element.displayName ?? `<${first.element.tagName}>`;
const where = first.newParent?.displayName ?? `<${first.newParent?.tagName}>`;
const scope = moves.length > 1 ? ` +${moves.length - 1} more` : "";
return first.newParent
? `Move ${what} into ${where}${scope}`
: `Reposition ${what}${scope}`;
}