Skip to content

feat(sdk): Phase 2a surface — PTY, stdin, events, lifecycle, capabilities, re-attach (CORE-58) - #583

Merged
AprilNEA merged 8 commits into
masterfrom
feat/sdk-phase2
Aug 9, 2026
Merged

feat(sdk): Phase 2a surface — PTY, stdin, events, lifecycle, capabilities, re-attach (CORE-58)#583
AprilNEA merged 8 commits into
masterfrom
feat/sdk-phase2

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 9, 2026

Copy link
Copy Markdown
Member

Extends both SDKs (@arcbox/sandbox, arcbox) with the daemon-ready Phase 2a surface. Scope was verified against the connect handlers: everything here is served today; anything answering UNIMPLEMENTED stays in 2b.

Surface

  • PTYcommands.run gains pty: {cols, rows} / pty=PtySize(cols, rows) (tty + tty_size on StartExecution; output arrives merged on the pty channel); CommandHandle.resize(cols, rows) drives ResizeExecutionTty.
  • stdin — background handles gain writeStdin/closeStdin/stdinStatus (write_stdin/close_stdin/stdin_status). The handle tracks the stdin cursor and advances it only on success, so a retried lost write lands at the same offset and the daemon's dedup makes it idempotent — never a double feed. Foreground run gains stdin: string|bytes (write-then-close, subprocess semantics: a feed racing the process's early exit is noise when the execution has exited); stdin: true keeps it open for background handles.
  • Offset-resume (the design doc's USP) — the attach loop shared by output and waitForExit's result collection tracks per-channel delivered offsets and re-attaches from them on transport death, so consumers see one seamless, gapless stream. Only consecutive dead dials count against the retry budget (3; delivered output resets it); exhaustion surfaces as the new ConnectionLostError (subclass of ConnectionFailedError). Daemon-typed stream errors are never retried.
  • commands.get(id) — re-attach a handle by execution id (WaitExecution poll validates existence and seeds the stdin cursor from Execution.stdin).
  • sandbox.events() — typed lifecycle events, keepalives filtered, deterministic termination; a mid-stream drop surfaces as ConnectionLostError (reconnect deferred to 2b by design — missed events cannot be replayed). Python sync reads genuinely block (httpx sync stream), no polling.
  • setLifecycle tri-state — presence-honest against the proto's optional fields:
    • TS: omitted = unchanged, null = restore default (no TTL / no idle detection / default action), value = replace.
    • Python: omitted (UNCHANGED sentinel, exported) = unchanged, None = restore default — the same meaning None already has on create — value = replace.
  • arcbox.capabilities() — GetCapabilities mapped to a DTO, cached per client (failed fetches uncached). The SDK does not gate on it; fail-fast stays daemon-side.

Deferred to 2b (daemon evidence)

commands.list()ListExecutions answers UNIMPLEMENTED (app/arcbox-api/src/connect/process.rs, "CORE-58 phase 2"); likewise WaitForPort, the filesystem path verbs (connect/filesystem.rs), and Template CRUD (connect/template.rs). README feature tables record this.

Validation

  • Unit: TS 67 passed (createRouterTransport mocks incl. mid-stream kills asserting seamless re-attach at exact offsets, stdin idempotency, tri-state wire presence, capability caching); Python 107 passed (httpx.MockTransport twins; truncated Connect bodies as the drop; sync + async trees, parity + gen_sync lockstep green).
  • Gates all exit 0: TS lint/format:check/test/tsc/build; Py ruff check/format --check/pyright/gen_sync --check/pytest.
  • Live hardware e2e (isolated daemon, VZ): cargo test -p arcbox-e2e --test sdk_ts -- --ignored → vitest 3/3; --test sdk_py → pytest 4/4. Covers the capabilities handshake, stdin echo, offset-idempotent background stdin, get(id) re-attach with replayed output, PTY stty size roundtrip (40 120) plus live resize (50 200), setLifecycle against ttl_deadline, and events() observing the idle auto-pause (pausing with reason=idle_timeoutpaused) — the Python flavor through the blocking sync stream.

commands.run gains pty (tty + initial size; output arrives merged on the
pty channel) and stdin (string/bytes = write-then-close, subprocess
semantics; true = keep open for the handle). CommandHandle gains
writeStdin/closeStdin/stdinStatus/resize with an offset-tracked cursor
that advances only on success, so retried writes are deduplicated by the
daemon (offset-idempotent). commands.get(id) re-attaches a handle by
execution id and seeds the stdin cursor from the daemon's accepted
count. A one-shot feed racing the process's early exit is swallowed when
the execution has exited — the exit result is the truth.
commands.run gains pty=PtySize(cols, rows) (tty + initial size; output
arrives merged on the pty channel) and stdin (str/bytes =
write-then-close, subprocess semantics; True = keep open for the
handle). CommandHandle gains write_stdin/close_stdin/stdin_status/resize
with an offset-tracked cursor that advances only on success, so retried
writes are deduplicated by the daemon (offset-idempotent).
commands.get(id) re-attaches a handle by execution id and seeds the
stdin cursor from the daemon's accepted count. A one-shot feed racing
the process's early exit is swallowed when the execution has exited.
Sync tree regenerated via gen_sync.py.
The attach loop shared by CommandHandle.output and waitForExit's result
collection now tracks the per-channel delivered offsets and, when the
transport drops mid-stream, re-attaches from them — the daemon replays
nothing already delivered, so consumers see one seamless, gapless
stream. Only consecutive dead dials count against the retry budget
(delivered output resets it); exhaustion surfaces as the new
ConnectionLostError (subclass of ConnectionFailedError), which also
gains the mid-stream teardown syscall shapes (ECONNRESET/EPIPE/
ECONNABORTED) in the connection-failure classifier. Daemon-typed stream
errors are never retried.
The attach loop shared by CommandHandle.output and wait_for_exit's
result collection now tracks the per-channel delivered offsets and,
when the transport drops mid-stream, re-attaches from them — the daemon
replays nothing already delivered, so consumers see one seamless,
gapless stream. Only consecutive dead dials count against the retry
budget (delivered output resets it); exhaustion surfaces as the new
ConnectionLostError (subclass of ConnectionFailedError). A truncated
streaming body — no terminal EndStreamResponse — now raises
ConnectionLostError too: truncation means the connection died mid-body.
Daemon-typed stream errors are never retried. Sync tree regenerated.
sandbox.events() yields typed lifecycle events (keepalives filtered;
clean server end ends the iterator; a mid-stream drop after frames
flowed surfaces as ConnectionLostError — re-subscribing is the caller's
call since missed events cannot be replayed). sandbox.setLifecycle takes
a tri-state update: an omitted knob is absent on the wire (unchanged),
null is an explicit zero/UNSPECIFIED (restore the daemon default), a
value replaces — presence-honest against the proto's optional fields.
arcbox.capabilities() exposes the daemon handshake (version, protocol,
features, nested_virt), cached per client with failed fetches uncached;
the SDK does not gate on it. Stale CORE-21-stub comments dropped —
Pause/Resume are implemented daemon-side.
sandbox.events() yields typed SandboxEvent values through the
Async/EventStream pair (keepalives filtered; clean server end ends the
iterator; the context-manager form cancels the subscription on early
exit; a mid-stream drop surfaces as ConnectionLostError).
sandbox.set_lifecycle is tri-state via the exported UNCHANGED sentinel:
omitted (UNCHANGED) leaves a knob alone, None restores the daemon
default — the same meaning None already has on create — and a value
replaces, mapped presence-honestly onto the proto's optional fields.
arcbox.capabilities() exposes the daemon handshake, cached per client
with failed fetches uncached; the SDK does not gate on it. Stale
CORE-21-stub comments dropped. Sync tree regenerated.
Both live-daemon loops now cover the capabilities handshake (protocol,
features, nested_virt supported), foreground stdin echo, background
offset-idempotent stdin writes, commands.get re-attach with replayed
output, a PTY stty-size roundtrip plus a live resize, and the
setLifecycle tri-state against Inspect's ttl_deadline; a second sandbox
with a 4s idle timeout and the PAUSE policy proves events() observes
PAUSING (reason idle_timeout) then PAUSED — the Python flavor through
the genuinely blocking sync stream. README feature tables updated to
the phase 2a surface, with ListExecutions/WaitForPort/path verbs/
Template CRUD recorded as 2b (the daemon answers Unimplemented).
@pullfrog

pullfrog Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your Claude subscription has hit its usage limit. It resets at 12am (UTC). Re-trigger Pullfrog after the reset, or add an ANTHROPIC_API_KEY repo secret — Pullfrog routes around an exhausted subscription automatically when one is present.

Add repo secret → · Model settings → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@linear-code

linear-code Bot commented Aug 9, 2026

Copy link
Copy Markdown

CORE-58

@pullfrog

pullfrog Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your Claude subscription has hit its usage limit. It resets at 12am (UTC). Re-trigger Pullfrog after the reset, or add an ANTHROPIC_API_KEY repo secret — Pullfrog routes around an exhausted subscription automatically when one is present.

Add repo secret → · Model settings → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown

Greptile Summary

The PR extends the Python and TypeScript SDKs with the daemon-supported Phase 2a execution and lifecycle surface.

  • Adds PTY execution, interactive and one-shot stdin, execution re-attachment, and offset-resumable output collection.
  • Adds typed lifecycle event streams, tri-state lifecycle updates, capability discovery, and corresponding public DTOs and errors.
  • Updates generated sync Python surfaces, SDK documentation, and unit/e2e coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the eligible follow-up scope.

No blocking failure remains.

Important Files Changed

Filename Overview
sdk/python/src/arcbox/_async/commands.py Adds asynchronous PTY and stdin controls, execution lookup, and offset-resumable output and result collection.
sdk/python/src/arcbox/_sync/commands.py Mirrors the asynchronous command additions on the generated synchronous SDK surface.
sdk/typescript/src/commands.ts Adds the TypeScript command execution, stdin, PTY, re-attachment, and resumable-stream APIs.
sdk/python/src/arcbox/_async/sandbox.py Adds asynchronous capabilities discovery, lifecycle updates, and typed lifecycle event streaming.
sdk/typescript/src/sandbox.ts Adds TypeScript capabilities caching, lifecycle mutation, and lifecycle event streaming.
sdk/python/src/arcbox/_types.py Defines the new Python public DTOs, event types, PTY size, stdin status, and unchanged-value sentinel.
sdk/typescript/src/types.ts Defines the corresponding TypeScript Phase 2a public data contracts.
sdk/python/src/arcbox/_async/_client.py Classifies truncated unary and streaming Connect responses as connection-loss errors.
sdk/typescript/test/reattach.test.ts Exercises transport-loss recovery and exact-offset output re-attachment behavior.
sdk/python/tests/test_stdin_pty.py Covers stdin cursor semantics, one-shot feeding, PTY configuration, and process cleanup behavior.

Sequence Diagram

sequenceDiagram
    participant App
    participant SDK
    participant Daemon
    App->>SDK: run(command, PTY/stdin options)
    SDK->>Daemon: StartExecution(execution_id, tty, stdin)
    Daemon-->>SDK: Execution
    opt One-shot stdin
        SDK->>Daemon: WriteStdin(offset, data)
        SDK->>Daemon: "WriteStdin(offset, eof=true)"
    end
    SDK->>Daemon: AttachExecution(stdout_offset, stderr_offset)
    loop Stream execution events
        Daemon-->>SDK: Output(channel, offset, data)
        SDK->>SDK: Advance delivered channel offset
    end
    alt Transport loss
        SDK->>Daemon: AttachExecution(last delivered offsets)
        Daemon-->>SDK: Replay remaining output
    else Execution exits
        Daemon-->>SDK: Exited(result)
        SDK-->>App: CommandResult
    end
Loading

Reviews (2): Last reviewed commit: "fix(sdk): treat server-signaled unavaila..." | Re-trigger Greptile

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc5f8c864a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sdk/python/src/arcbox/_async/commands.py Outdated
Comment thread sdk/typescript/src/commands.ts
Comment thread sdk/python/src/arcbox/_async/sandbox.py Outdated
…ked stdin feeds

Review findings (Codex): the daemon losing its upstream stream ends the
HTTP stream cleanly with a Connect unavailable error frame, which the
Python decoder types as ConnectionFailedError — neither httpx.HTTPError
nor ConnectionLostError — so the attach resume loop and the events
stream-death reclassification both missed it. Both now catch
ConnectionFailedError (daemon-typed errors still map to other classes
and are never retried), matching the TypeScript predicate. And a
one-shot stdin feed that fails while the process still runs now
best-effort SIGKILLs it before surfacing the feed error: a thrown run()
returns no handle, so the fed process (cat waiting on input) would
otherwise keep the sandbox RUNNING with no way to reach it — both
languages. Sync tree regenerated.
@pullfrog

pullfrog Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your Claude subscription has hit its usage limit. It resets at 12am (UTC). Re-trigger Pullfrog after the reset, or add an ANTHROPIC_API_KEY repo secret — Pullfrog routes around an exhausted subscription automatically when one is present.

Add repo secret → · Model settings → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@AprilNEA
AprilNEA merged commit 73bced0 into master Aug 9, 2026
11 of 12 checks passed
@AprilNEA
AprilNEA deleted the feat/sdk-phase2 branch August 9, 2026 23:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant