Repository navigation
Conversation
Move harness-sdk into ai as two experimental areas. A harness drives a coding-agent CLI someone else owns (Claude Code, Codex): run, stream ai.types events, steer, stop, approve tool calls, resume, fork, hand off, and open the CLI's own TUI. A workspace is where it runs: a local directory or a Vercel Sandbox microVM. - Errors gain an Error suffix and split into harnesses and workspaces errors modules; both bases subclass ai.AIError. - claude_code() and VercelSandbox() check their extras (claude-code, sandbox) at construction and raise ai.errors.InstallationError. - codex() refuses sandbox modes that cannot run inside a microVM, and codex transcripts name tool calls by native item kind. - Examples in examples/harnesses, plus the afk and deepysec apps. - Docs pages, reference pages, skill notes, and contributor guidance.
|
You must have Developer access to commit code to Vercel Labs on Vercel. If you contact an administrator and receive Developer access, commit again to see your changes. Learn more: https://vercel.com/docs/accounts/team-members-and-roles/access-roles#team-level-roles |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
vercel/vercel-py#415 merged to main (not released yet). Point the dev group and the afk and deepysec apps at main; uv.lock pins the commit. The merged API moved terminal I/O onto `InteractiveSession.stream`, an anyio byte stream that raises at end of stream instead of returning b"". Use it, and treat a raise in the ready-marker loops as the end.
Local.pty() started the holder with asyncio.create_subprocess_exec, and on Linux asyncio kills a still-tracked child when its event loop closes, so every named pty ended with the process that created it. The holder now daemonizes (--daemon) and releases its launcher over a pipe once its socket and pid file exist; Local.pty() waits for the launcher. Reading the pid file no longer races the holder's cleanup.
The no-extras job installs ai without extras and checks that claude_code() and VercelSandbox() raise InstallationError, and that import ai loads neither optional package. The compatibility job adds claude-agent-sdk at its minimum and latest versions, since the claude-code extra depends on its private transport API.
vercel-sandbox 0.7 takes credentials per client rather than per call, so VercelSandbox(token=, team_id=, project_id=) failed with a TypeError. Explicit credentials now go through a SandboxClient with a credentials factory, closed with the workspace. Missing pieces come from the environment, and a project (OIDC) token alone is enough: it names its own team and project. vercel-sandbox on vercel-py main needs an unreleased vercel-internal-core, so the dev group and the deepysec app take vercel-internal-core and vercel-oidc from the same main.
The first time afk needs a sandbox it signs in through the Vercel CLI (running `vercel login` when needed, or saying how to install the CLI), picks a team (the CLI's current team by default, asked only when there are several), creates an afk-<random> project, and records only its ids in ~/.afk/config.json. Every run then mints a short-lived project token from the login, so there is no `vercel env pull` and nothing to refresh. The same token reaches AI Gateway, so AI_GATEWAY_API_KEY is optional. The first push checks once that the team's gateway will serve it, and offers another team when it will not. `afk setup` chooses a team again. afk takes vercel-internal-core and vercel-oidc from the same vercel-py main as vercel-sandbox: `uv tool install` does not read uv.lock.
… at a time claude switches the terminal to kitty's keyboard protocol, and the bridge forwards that mode to the real terminal like any output. Ctrl-] then arrives as ESC[93;5u rather than 0x1d, so the detach key did nothing while claude ran. The bridge now knows the key in kitty's form and in xterm's modifyOtherKeys form too. The bridge sends each keystroke from a task of its own, and the sandbox's interactive stream refuses a second writer mid-send with anyio's BusyResourceError, reported as "the sandbox terminal connection dropped". FramedPty now sends one frame at a time, in order; detach() and close() wait at most 10s behind a stuck send, so a raw-mode terminal is never held.
The list, drawn with Textual's inline mode under your prompt rather than over the whole screen. Arrow keys choose a conversation; Enter or a letter runs the command shown for it (push, push --bg, attach or peek, pull, pull --files, stop, which asks first), exactly as if you had typed it. The picker leaves that command in the scrollback, by the name the list showed; q, Esc or Ctrl-C leaves the list there instead. Local conversations are there to pick at once, the pushed ones as their sandboxes answer. The picker only returns argv, and the CLI runs it after the app has exited: an inline Textual app cannot suspend, and a harness's TUI needs the whole terminal. Piped, and for every explicit verb, afk is unchanged.
A sandbox process's stdin is an interactive stream that refuses a second sender mid-send (anyio's BusyResourceError), and callers write from several tasks: codex's JSON-RPC client steering a turn while it answers the agent. The live suite's codex steer test failed in a sandbox on exactly that, "Another task is already writing to this resource". SandboxProcess.write now takes a lock around opening the conduit and sending, as FramedPty does, so two first writes also never open two conduits.
The picker's remote rows get `p` for peek. It reads the transcript, so it watches a TUI as well as an unattended agent; where Enter already peeks, the key works but is not shown twice. A sandbox afk cannot ask right now (network, credentials) reached afk as a WorkspaceError, which the list did not catch: `afk` ended in a traceback, and in the picker the probe's failure closed the app. It is now listed `unreachable` and its record kept, as the README says, and a verb that meets one prints a line, not a traceback. The live test for this failed before; it passes now.
`image=` names the root filesystem a new VM boots from: a Vercel Container Registry reference (a managed image such as vercel/sandbox/python:3.14, a project repository, or a team-scoped one, by tag or digest). Omitted, the create call carries no image key at all, so the platform's default stays the platform's choice. Like egress, the image is fixed when the VM is created. It is refused with name= (a reconnect keeps the image its VM booted from) and with snapshot= (a snapshot carries its own filesystem), in the constructor, before anything boots. `sandbox.image` reports what the platform says the VM booted from once open, including after a reconnect by name, which is how a caller like afk that only keeps names can still know it. Measured: the platform reports a digest, not the tag (vercel/sandbox/node:24 comes back as vercel/sandbox/node@sha256:...). Two things only a non-default image reaches, fixed here: - `exec` of a command not on PATH raised the platform's executable_not_found error; it is now exit 127, as in `Local.exec`. The harness adapters probe `<cli> --version` and install on a non-zero exit, but the default image ships both CLIs, so that path had never run in a sandbox. - A process's stdin pump runs python3 in the VM. node:24 and ubuntu have none, and the first write failed as a bare ClosedResourceError; the conduit now raises a WorkspaceError naming python3. The docs say what an image needs for harnesses. Offline tests pin the refusals, the create request, exit 127 and the conduit error. Live tests (-m sandbox) boot ubuntu and node:24 and check what is on PATH, the reported digest, the workdir and copy in a custom image, a reconnect reporting the image, a missing image failing with 404, the python3 error, and both harnesses answering in node:24 once python3 is installed (over https: the gateway's firewall resets plain http).
`SessionInfo.running` read only the SDK's own lock, so a `claude` or `codex` a person typed in another terminal listed as idle. It now reports any process that has the conversation open, and `SessionInfo.pid` says which one, when known. - claude records each of its processes in <config>/sessions/<pid>.json, with the conversation it is on and its start time (UTC, as `ps` prints it). A record is a holder only while `ps` shows that pid with that start time: a record left behind, or a pid reused by a later process, is not. - codex records nothing per process. Its writer lock is held by the app-server daemon its TUI talks to, not by the TUI, and stays held a while after the TUI exits (measured, codex 0.159), so it cannot name a process. A codex whose command line names the thread (`codex resume <id>`, `codex exec resume <id>`) is found; a fresh `codex` is not. - Both read the workspace's own `ps` and files, so this holds in a sandbox as on this machine, and merge with the SDK lock's holders, whose pid the lock now returns. `resume()` still refuses only clients holding the SDK lock; the docs say so. Offline tests pin the record matching (live, left behind, reused pid, malformed), the lock merge, and which codex command lines name a thread, with real processes standing in for the CLIs.
…ewhere In the picker a key now means one thing on every row it is on, and Enter puts you in the conversation wherever it is: - here, it hands the terminal to the harness's own resume (`claude --resume <id>`, `codex resume <id>`): afk execs it, so the scrollback shows the command that ran. Push moves to `u` (`b` is still push --bg), since `p` already peeks. - open in another terminal, it peeks: resuming it too would make two writers. `p` peeks any conversation here, and `s` stops the process it is open in, asking first and naming the pid. Stop is offered only when the harness can say which process that is. `afk peek <id>` and `afk stop <id>` look here when nothing pushed matches. Peek reads this directory's store; stop sends SIGTERM, so that terminal gets its prompt back and the conversation stays, to resume. Neither signs in to Vercel unless something was pushed from here. Which conversations are open, and by which process, comes from the SDK's `SessionInfo.running` and `.pid`: any `claude`, and a `codex` started as `codex resume <id>`. A fresh `codex` names no conversation and shows idle. Tested offline (picker keys, local stop and peek) and end to end against a real claude TUI in a pty: listed in use, peeked, stopped from the CLI and from the picker, then resumed from the picker.
…hen idle Enter on a conversation open in another terminal now takes it over, as attach does a pushed TUI open elsewhere: after a y, the picker runs `afk stop <id> && claude --resume <id>`, so that process ends first and the TUI opens here. A picker key now stands for a chain of commands, run in order, each only if the one before succeeded; the scrollback shows them joined by &&. Where afk cannot name the process, Enter still peeks. `afk stop` on a conversation here waits until the process has exited, so a take-over's resume never overlaps it, and says so if it does not exit within 10s. Peek follows like `tail -f`, which nothing on screen said. On a terminal it now keeps a status line under the transcript, redrawn in place: "following <id> · nothing new for Ns · Ctrl-C to stop", replaced by why it ended. It also ended only when the transcript ended on the agent's reply; an interrupted turn or an unanswered prompt never does, so a session still open was followed forever (3 of the 10 conversations in this repo end that way). A conversation a person drives, here or a pushed TUI, now stops being followed after 30s with nothing new, unless a tool call is out. Ctrl-C says the agent keeps going. Tested offline (picker chains and confirmations, stop waiting and timing out against a process that ignores SIGTERM, peek's quiet stop, a pending tool call, the status line, piped output) and end to end in a pty against a real claude TUI: the status line from the picker, and Enter taking over.
…atus The holder sent the exit frame and closed right away. A client still sending (the bridge's first resize) then wrote to a closed socket, and asyncio drops what it has received but not read yet when a write fails: the output and the exit frame were lost, and tty.bridge reported the connection ended. Seen in CI as test_bridge_shows_a_program_that_ended_ before_it_was_ready failing on Linux. The holder now stops writing after the exit frame and reads until the client hangs up, bounded by LINGER, before closing.
verbs only imports it, and mypy does not let a test reach an imported name through another module, which failed check-examples in CI.
kajogo777
force-pushed
the
harnesses-experimental
branch
from
October 9, 2026 00:30
0adfe94 to
f184ecc
Compare
`afk attach` on a conversation pushed with --bg opened nothing: only a TUI could be attached, and Enter on such a row peeked. Now attach ends the unattended agent that still holds the conversation (asking first if it is mid-turn, since that ends the turn), waits until it has exited, and opens the TUI on the same conversation in its sandbox. The row becomes a TUI from then on, so the next attach reconnects to its pty. Enter in the picker attaches on every remote row.
kajogo777
marked this pull request as ready for review
October 9, 2026 00:42
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves harness-sdk into
aias two experimental areas:ai.harnesses.experimentaldrives Claude Code and Codex (run, stream, steer, stop, approve, resume, fork, TUI), andai.workspaces.experimentalsays where they run (local or Vercel Sandbox).claude-codeandsandbox, checked at construction (InstallationError).import ailoads neither.examples/harnesses/, plusexamples/apps/afkandexamples/apps/deepysec(without its vulnerable fixture).basics/harnesses,basics/workspaces, and their reference pages.-m live); plainpyteststays offline.claude-agent-sdkin the SDK compatibility job.Proposal defaults taken: two top-level areas,
ToolCallkeeps its name, live tests stay out of CI.Verified: full CI check list, the live suite against real CLIs and sandboxes, and every harness example, afk, and deepysec end to end.
Before merge: vercel/vercel-py#415 is merged and
vercel-sandboxnow tracks itsmain; once it's released, set thesandboxextra's floor and drop the git pins. Rerunpytest -m sandboxon the merged API.