Skip to content

[AI-860] Local terminal attach for hosted agents (Phase 1: run-agent / attach / ls)#155

Merged
alexeyzimarev merged 26 commits into
mainfrom
feat/local-terminal-attach
Jun 14, 2026
Merged

[AI-860] Local terminal attach for hosted agents (Phase 1: run-agent / attach / ls)#155
alexeyzimarev merged 26 commits into
mainfrom
feat/local-terminal-attach

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Jun 14, 2026

Copy link
Copy Markdown
Member

Summary

Phase 1 of "local terminal attach" — start a daemon-hosted coding agent from your own terminal, drive it live, detach without killing it, and re-attach later ("tmux for your agent"). Entirely local; no new server contract. Web visibility (registering the agent so you can see/drive it from the web UI) is deferred to Phase 2.

Design + plan are on the branch:

  • Spec: docs/superpowers/specs/2026-06-13-local-attach-hosted-agent-design.md
  • Plan: docs/superpowers/plans/2026-06-14-local-attach-phase1.md

What's new

  • kcap run-agent <vendor> [kcap flags] -- [agent args] — auto-starts the daemon, spawns the agent, attaches your terminal. --worktree (default: in-place in your cwd), --name, --detached. Everything after -- is forwarded to the claude/codex CLI verbatim (launcher-agnostic passthrough; Codex injects mandatory --cd/--no-alt-screen and rejects user duplicates).
  • kcap attach <id> / kcap ls — re-attach to / list daemon-hosted agents. Detach with Ctrl-Q d.

How it works

  • New local control socket (Unix domain socket, 0600) on the daemon; length-prefixed, AOT-safe frame codec shared by CLI + daemon (with bounds-checked parsing — a malformed frame is a clean protocol error, never an OOM).
  • Daemon output fan-out generalized from one client to N, no silent partial-stream corruption (overflow force-detaches that one client, which then reattaches for a fresh replay — never DropOldest, which reintroduced AI-844 corruption; never stalls the shared PTY loop). Snapshot+subscribe is atomic with the read loop, so attach never duplicates live output.
  • Local agents run PrivateLocal: a deny-all guard means no per-agent server call (register/status/events/repo-path/heartbeat/finalize/unregister/reconnect), enforced by a strict-tripwire test. They record as a plain local session: hook env keeps KCAP_URL and re-adds ANTHROPIC_API_KEY, and omits the hosted-agent vars (KCAP_AGENT_ID, KCAP_RENDERED_AGENT, KCAP_DAEMON_URL) — so the agent isn't tagged as a hosted agent and permissions prompt natively in the terminal. (UnixPtyProcess also unsets those in the PTY child so nothing leaks from the daemon's own env.)
  • Owned-worktree vs borrowed-cwd: in-place launches never have their cwd/branch removed on cleanup and skip repo-mutating Prepare() (the catastrophic-rm -rf-your-repo path is the top safety invariant + has a dedicated test); a failed --worktree launch cleans up the worktree it created.
  • Raw-mode CLI client over the socket; terminal I/O uses the raw fds directly (bypassing System.Console, which re-cooks the tty on Unix). Resize min-clamps the PTY across attached local clients (tmux semantics), recomputed on attach/detach/resize. Agent self-exit (e.g. /exit) cleanly tears the client down.

Test plan

  • Unit: 1505 pass (frame codec incl. bounds checks, sink overflow/force-detach, socket path, cleanup guard, Prepare skip, deny-all tripwire + env, passthrough/Codex flag rejection, run-agent arg parsing, detach scanner)
  • Integration: 30 pass (incl. real Unix-socket round-trip through LocalControlServer)
  • AOT publish clean (no IL2026/IL3050) — CLI and daemon
  • Manual smoke: real claude session — clean interactive I/O + cursor, Ctrl-C reaches the agent, Ctrl-Q d detaches (agent keeps running), kcap attach repaints, /exit quits cleanly.

Review follow-ups (all addressed; see thread + commits)

  • Qodo: frame-parse OOM bounds; sink-detach propagation (force-detach now ends the session so the client reattaches).
  • Reviewer: PTY-child env isolation; failed---worktree cleanup; atomic snapshot/subscribe; resize reclamp on detach; spec Scope/Phasing consistency.

Notes / scope

  • Unix only for now (run-agent/attach/ls no-op with a message on Windows).
  • Not offline: the daemon still requires ServerUrl + SignalR as today.
  • Phase 2 (deferred): register the local agent on spawn like a UI-launched agent → owner sees/drives it from their own web UI immediately; teammates via the existing web UI "share" (no kcap-specific share — a kcap share CLI convenience is tracked as AI-861). Web clients attach via the existing SignalR fan-out; first-web-subscribe one-time replay; web-client resize aggregation; Windows support.

🤖 Generated with Claude Code

@qodo-code-review

qodo-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Action required

1. Spawn frame OOM risk 🐞 Bug ⛨ Security
Description
FrameCodec.ParseSpawn allocates new string[n] from an on-wire value without validating n against
the payload size or a sane upper bound, so a local socket client can force an OOM/crash by sending a
Spawn frame with a huge arg count. The length-prefixed string reader (ReadLp) also lacks bounds
checks, so malformed payloads can overrun offsets and throw non-protocol exceptions.
Code

src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[R80-88]

+    static (string vendor, WorkLocation work, string cwd, string[] args, ushort cols, ushort rows) ParseSpawn(byte[] p) {
+        var o = 0;
+        var work = (WorkLocation)p[o++];
+        var cols = Be16(p, o); o += 2; var rows = Be16(p, o); o += 2;
+        var vendor = ReadLp(p, ref o); var cwd = ReadLp(p, ref o);
+        var n = BinaryPrimitives.ReadInt32BigEndian(p.AsSpan(o)); o += 4;
+        var args = new string[n];
+        for (var i = 0; i < n; i++) args[i] = ReadLp(p, ref o);
+        return (vendor, work, cwd, args, cols, rows);
Evidence
The Spawn parser reads a 32-bit n from the payload and immediately allocates an array of that
size, even though the payload length cap (8MB) does not constrain n itself. ReadLp similarly
reads a length prefix and slices without checking that the length fits in the remaining payload.

src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[80-88]
src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[109-110]
src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs[35-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`FrameCodec.ParseSpawn()` trusts the arg-count field from the wire (`n`) and allocates an array of that size without validating it against remaining payload bytes or a reasonable maximum. A malformed/malicious local client can send a small payload with a very large `n` and trigger a huge allocation (OOM) before any further parsing occurs.

## Issue Context
The local control socket is a privileged local interface (0600), but it is still untrusted input from any process running as the same user. A single bad frame should fail fast with a bounded error, not take down the daemon.

## Fix Focus Areas
- src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[80-88]
- src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[109-110]

## Suggested fix
- Add explicit bounds checks before reading each field:
 - Ensure `o + 4 <= p.Length` before reading any 32-bit length.
 - Validate `n >= 0` and cap it to a small maximum (e.g. 4096) *and* validate `n` is plausible given remaining bytes (at minimum `remaining >= n * 4` for the per-arg length prefixes).
 - In `ReadLp`, validate `len >= 0` and `o + len <= p.Length` before calling `Encoding.UTF8.GetString`.
- On violation, throw `InvalidDataException` (protocol error) rather than allowing OOM or `ArgumentOutOfRangeException`/`OutOfMemoryException` to surface.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Sink detach not propagated 🐞 Bug ≡ Correctness
Description
When LocalSocketSink overflows it sets Detached and stops accepting output, but
AttachClientLoopAsync never reacts to sink.Detached and keeps processing client stdin/resize frames
until the client disconnects or explicitly detaches. This can leave an attach session running after
its output path is dead, so the user may continue interacting without a valid terminal view.
Code

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[R100-123]

+        var sink = new LocalSocketSink(capacity: 4096, (chunk, _) => Send(LocalFrame.Stdout(chunk)));
+        lock (agent.SinksLock) agent.LocalSinks.Add(sink);
+
+        try {
+            // Bounded replay BEFORE any live chunk so the client paints a coherent screen.
+            await Send(FrameCodec.Attached(agent.Id, agent.OutputBuffer.Snapshot()));
+            var pump = sink.RunAsync(ct);
+
+            try {
+                while (!ct.IsCancellationRequested) {
+                    var f = await FrameCodec.ReadAsync(stream, ct);
+                    if (f is null || f.Type == FrameType.Detach) break;
+
+                    switch (f.Type) {
+                        case FrameType.Stdin:  await agent.Process.WriteAsync(f.Bytes); break;
+                        case FrameType.Resize: ApplyResizeClamp(agent, sink, f.Cols, f.Rows); break;
+                    }
+                }
+            } catch (Exception ex) when (ex is EndOfStreamException or IOException or OperationCanceledException) {
+                /* client gone */
+            } finally {
+                sink.Complete();
+                await pump.ConfigureAwait(false);
+            }
Evidence
Overflow in the sink sets Detached and completes the channel, but the attach loop only breaks on
Detach/EOF; it keeps forwarding stdin frames regardless of whether the sink is detached, so the
session can continue without output delivery.

src/Capacitor.Cli.Daemon/Services/LocalSocketSink.cs[29-36]
src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[100-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`LocalSocketSink` can become `Detached` (queue overflow or send failure), but the attach session loop never checks that state and does not proactively end/notify the client. This breaks the intended “force-detach and replay on rejoin” behavior and can lead to blind input.

## Issue Context
- Overflow sets `Detached = true` and completes the sink channel, but does not close the socket.
- The attach loop terminates only on explicit `Detach`, EOF, or cancellation.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/LocalSocketSink.cs[29-36]
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[90-123]

## Suggested fix
- Create a per-session CTS (linked to `ct`) and pass it to both `sink.RunAsync(...)` and the input loop.
- Monitor sink termination/detach:
 - e.g. start a task that awaits `pump` and if `sink.Detached` becomes true, attempt to send a terminal frame (Error or Detach) and then cancel the session CTS to break the input loop.
- Consider removing detached sinks from `agent.LocalSinks` immediately when they detach, to avoid accumulating inert sinks until the client eventually disconnects.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. No socket handshake timeout 🐞 Bug ☼ Reliability
Description
LocalControlServer accepts a connection and awaits the first FrameCodec.ReadAsync with no deadline,
so a client can connect and never send any bytes, keeping an open socket and a live handler task
indefinitely. Enough idle connections can exhaust file descriptors and degrade or block daemon
responsiveness.
Code

src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs[R27-40]

+            while (!ct.IsCancellationRequested) {
+                var conn = await listener.AcceptAsync(ct);
+                _ = HandleConnectionAsync(conn, ct); // fire-and-forget; handler owns its lifetime
+            }
+        } catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ }
+        finally { try { File.Delete(path); } catch { /* best-effort */ } }
+    }
+
+    async Task HandleConnectionAsync(Socket conn, CancellationToken ct) {
+        using var _ = conn;
+        await using var stream = new NetworkStream(conn, ownsSocket: false);
+        try {
+            var first = await FrameCodec.ReadAsync(stream, ct);
+            if (first is null) return;
Evidence
The connection handler awaits the first frame read without any timeout, and the frame read routine
loops until it can fill the 5-byte header buffer; if the peer stays silent, the await never
completes.

src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs[26-40]
src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[23-43]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A silent client can connect to the local control socket and never send the opening frame; the daemon then awaits `FrameCodec.ReadAsync` forever. This can accumulate open sockets/handlers and cause resource exhaustion.

## Issue Context
`HandleConnectionAsync` is fired-and-forgotten for each accepted socket, and `FrameCodec.ReadAsync` blocks until it reads a full 5-byte header.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs[26-50]
- src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[23-43]

## Suggested fix
- Apply a handshake deadline for the *first* frame only, e.g.:
 - Create a linked CTS inside `HandleConnectionAsync` and `CancelAfter(TimeSpan.FromSeconds(5))` before reading `first`.
 - If the timeout fires, close the connection and return.
- Optionally add a max concurrent connections / semaphore to bound accepted-but-not-handshaken clients.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@alexeyzimarev alexeyzimarev changed the title [Phase 1] Local terminal attach for hosted agents (run-agent / attach / ls) [AI-860] Local terminal attach for hosted agents (Phase 1: run-agent / attach / ls) Jun 14, 2026
@linear-code

linear-code Bot commented Jun 14, 2026

Copy link
Copy Markdown

AI-860

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Phase 1: Local terminal attach for daemon-hosted agents (run-agent/attach/ls)
✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Add kcap run-agent, attach, and ls to drive daemon-hosted agents from a local terminal.
• Introduce an owner-only Unix domain control socket with a length-prefixed, AOT-safe framing
  protocol.
• Run locally launched agents as PrivateLocal: no per-agent server calls and safe borrowed-cwd
  cleanup.
Diagram
graph TD
  A["User terminal"] --> B["kcap CLI"] --> C["Local control socket"] --> D["LocalControlServer"] --> E["AgentOrchestrator"] --> F["PTY agent"]
  E --> G["LocalSocketSink(s)"]
  subgraph Legend
    direction LR
    _cli["CLI"] ~~~ _sock(["Socket"]) ~~~ _svc["Service"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Reuse existing serialization (JSON/msgpack) over socket
  • ➕ Lower custom protocol surface area
  • ➕ Easier extensibility/versioning with schema tools
  • ➖ JSON/reflection-based serializers can be problematic under NativeAOT
  • ➖ More allocations/overhead on hot PTY data path than a simple framed codec
2. Use an existing multiplexer (tmux/screen) instead of custom attach
  • ➕ Mature detach/reattach semantics and resize handling
  • ➕ Avoids building a terminal client and fan-out logic
  • ➖ Harder cross-platform story and dependency management
  • ➖ Doesn't integrate with daemon agent lifecycle/PrivateLocal safety rules as cleanly
3. Drive local attach through SignalR loopback
  • ➕ Single transport/protocol; fewer new moving parts
  • ➕ Potentially simpler observability/logging
  • ➖ Higher latency for keystrokes; defeats the main UX goal
  • ➖ Would blur 'no new server contract' and complicate offline assumptions

Recommendation: The PR’s approach (simple length-prefixed frames over an owner-only local socket) is the best fit for Phase 1: it stays AOT-safe, keeps latency low, and cleanly isolates local attach from server contracts. The key follow-up is ensuring long-term protocol evolution stays append-only (FrameType discipline) and that sink overflow/force-detach behavior remains well-documented and stable.

Grey Divider

File Changes

Enhancement (20)
DetachScanner.cs Implement Ctrl-Q d detach sequence scanner +44/-0

Implement Ctrl-Q d detach sequence scanner

• Adds a stateful scanner that detects the raw stdin detach chord (Ctrl-Q then 'd'), supports split reads, strips the chord from forwarded bytes, and preserves Ctrl-Q behavior otherwise.

src/Capacitor.Cli.Core/LocalIpc/DetachScanner.cs


FrameCodec.cs Add AOT-safe framed binary codec for local IPC +111/-0

Add AOT-safe framed binary codec for local IPC

• Implements a length-prefixed frame codec ([type][len][payload]) with hard payload caps, exact-read handling, and structured helpers for Spawn/Attached frames; avoids reflection/JSON for NativeAOT.

src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs


FrameType.cs Define local IPC frame types (append-only wire contract) +19/-0

Define local IPC frame types (append-only wire contract)

• Introduces the FrameType byte enum covering Spawn/Attach/List plus streaming (Stdin/Stdout), Resize, Detach, and daemon responses (Attached/Exited/Error/AgentList).

src/Capacitor.Cli.Core/LocalIpc/FrameType.cs


LocalFrame.cs Add LocalFrame payload model and WorkLocation enum +21/-0

Add LocalFrame payload model and WorkLocation enum

• Defines the decoded LocalFrame record plus WorkLocation (BorrowedCwd vs OwnedWorktree) and convenience constructors for common frames (stdin/stdout/resize/detach/exited/error).

src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs


LocalSocketPaths.cs Add per-daemon socket path helper +7/-0

Add per-daemon socket path helper

• Adds a helper to compute the local control socket path colocated with daemon lock/pid files and sanitized by daemon name.

src/Capacitor.Cli.Core/LocalIpc/LocalSocketPaths.cs


RunAgentArgs.cs Parse run-agent flags vs passthrough args via '--' boundary +57/-0

Parse run-agent flags vs passthrough args via '--' boundary

• Implements argument parsing for 'kcap run-agent <vendor> [kcap flags] -- [agent args]', including '--worktree', '--name', '--detached', and an explicit Phase 2 rejection for '--share'.

src/Capacitor.Cli.Core/RunAgentArgs.cs


DaemonRunner.cs Register LocalControlServer as a hosted daemon service +5/-0

Register LocalControlServer as a hosted daemon service

• Adds the LocalControlServer to DI and starts it as a BackgroundService so the daemon always listens for local attach connections when running.

src/Capacitor.Cli.Daemon/DaemonRunner.cs


AgentOrchestrator.LocalIpc.cs Add orchestrator handlers for local spawn/attach/list +148/-0

Add orchestrator handlers for local spawn/attach/list

• Implements local control-socket entry points: list agents, spawn PrivateLocal agents with passthrough launcher args and restricted env, and attach clients with snapshot replay, stdin/resize pumping, and min-clamp resize across clients.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs


AgentOrchestrator.cs Generalize orchestrator for local sinks and PrivateLocal lifecycle +95/-27

Generalize orchestrator for local sinks and PrivateLocal lifecycle

• Extends AgentInstance with local sink tracking and per-client dims, adds OutputBuffer snapshot support, fans PTY output to N local sinks without blocking, and enforces PrivateLocal deny-all (no status/events/heartbeat/reregister/unregister/end-session). Adds a cleanup guard to never remove borrowed cwd worktrees and exposes test-only hooks to drive cleanup/lifecycle.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs


ClaudeLauncher.cs Support borrowed-cwd launches and passthrough argv for Claude +11/-0

Support borrowed-cwd launches and passthrough argv for Claude

• Skips repo-mutating Prepare steps when launching in a borrowed cwd and adds BuildPassthrough that forwards post-'--' user args verbatim for local run-agent.

src/Capacitor.Cli.Daemon/Services/ClaudeLauncher.cs


CodexLauncher.cs Support borrowed-cwd launches and guarded passthrough argv for Codex +51/-17

Support borrowed-cwd launches and guarded passthrough argv for Codex

• Avoids overlay/trust writes for borrowed cwd launches, keeps read-only hook preflight, and adds BuildPassthrough that injects mandatory flags ('--cd', '--no-alt-screen') while rejecting user duplicates to prevent fragile precedence reliance.

src/Capacitor.Cli.Daemon/Services/CodexLauncher.cs


IHostedAgentLauncher.cs Extend launcher interface with passthrough build and work location +14/-2

Extend launcher interface with passthrough build and work location

• Adds BuildPassthrough for local run-agent launches and extends LauncherContext with WorkLocation so launchers can skip mutating setup for borrowed cwd runs.

src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs


ITerminalSink.cs Introduce ITerminalSink abstraction for N-way fan-out +8/-0

Introduce ITerminalSink abstraction for N-way fan-out

• Defines a minimal interface for terminal output consumers so the PTY read loop can enqueue output non-blockingly to multiple sinks.

src/Capacitor.Cli.Daemon/Services/ITerminalSink.cs


LocalControlServer.cs Add owner-only Unix socket server for local attach +56/-0

Add owner-only Unix socket server for local attach

• Implements a BackgroundService that binds a per-daemon Unix domain socket (0600), accepts connections, reads the opening frame, and dispatches to orchestrator spawn/attach/list handlers with error handling.

src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs


LocalSocketSink.cs Add per-client bounded output queue with force-detach on overflow +59/-0

Add per-client bounded output queue with force-detach on overflow

• Implements a lossless-per-sink fan-out queue using Channels: producer TryEnqueue never blocks, and queue overflow marks the sink Detached and completes it to avoid stalling the shared PTY loop or silently dropping chunks.

src/Capacitor.Cli.Daemon/Services/LocalSocketSink.cs


WorktreeManager.cs Add WorktreeInfo.Borrowed factory for in-place launches +5/-1

Add WorktreeInfo.Borrowed factory for in-place launches

• Introduces a constructor helper representing a borrowed cwd (daemon does not own), enabling in-place launches while relying on the orchestrator guard to prevent deletion on cleanup.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs


RunAgentCommand.cs Implement run-agent/attach/ls CLI commands and daemon auto-start +185/-0

Implement run-agent/attach/ls CLI commands and daemon auto-start

• Adds the CLI entry points for spawning an agent (attached or detached), attaching to an existing agent, and listing agents via the local socket. Includes daemon auto-start and a Unix-only guard with clear messaging on Windows.

src/Capacitor.Cli/Commands/RunAgentCommand.cs


LocalAgentClient.cs Add interactive local attach client (raw mode, pumps, resize poll) +126/-0

Add interactive local attach client (raw mode, pumps, resize poll)

• Implements the terminal client that connects to the daemon socket, sends Spawn/Attach, enables raw terminal mode, pumps stdout to the console, forwards stdin with detach scanning, and polls window size to send resize frames.

src/Capacitor.Cli/Local/LocalAgentClient.cs


TerminalRawMode.cs Add termios-based raw mode enable/restore for interactive sessions +67/-0

Add termios-based raw mode enable/restore for interactive sessions

• Adds a NativeAOT-friendly raw-mode helper using libc tcgetattr/tcsetattr/cfmakeraw with an opaque termios blob to support Linux/macOS layout differences and restore on dispose.

src/Capacitor.Cli/Local/TerminalRawMode.cs


Program.cs Wire new CLI verbs: run-agent, attach, ls +6/-0

Wire new CLI verbs: run-agent, attach, ls

• Adds command dispatch for 'run-agent', 'attach', and 'ls' to the CLI program entry point.

src/Capacitor.Cli/Program.cs


Tests (7)
AgentOrchestratorLocalAttachTests.cs Add unit/integration coverage for local attach invariants +249/-0

Add unit/integration coverage for local attach invariants

• Adds tests for borrowed-cwd safety (never delete), owned worktree cleanup, PrivateLocal deny-all server calls and env shaping, vendor passthrough behavior, and a real Unix socket round-trip for 'List' through LocalControlServer.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorLocalAttachTests.cs


AgentOrchestratorVendorTests.cs Refactor vendor tests to support local-attach partial + passthrough +8/-2

Refactor vendor tests to support local-attach partial + passthrough

• Makes the vendor test class partial and updates test doubles to implement the new BuildPassthrough launcher method, plus broadens BuildOrchestrator to accept the base ServerConnection type.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs


DetachScannerTests.cs Test detach chord detection across reads and forwarding rules +50/-0

Test detach chord detection across reads and forwarding rules

• Adds unit tests verifying Ctrl-Q d detection (split and same-chunk), normal forwarding, and correct behavior when prefix is not followed by 'd'.

test/Capacitor.Cli.Tests.Unit/DetachScannerTests.cs


FrameCodecTests.cs Test frame codec round-trips and short-read reassembly +87/-0

Test frame codec round-trips and short-read reassembly

• Adds tests for raw byte frames, resize dimensions, structured spawn/attached payloads, EOF handling, and correct parsing across partial reads.

test/Capacitor.Cli.Tests.Unit/FrameCodecTests.cs


LocalSocketPathsTests.cs Test socket path sanitization and directory placement +13/-0

Test socket path sanitization and directory placement

• Verifies LocalSocketPaths produces a sanitized daemon-name socket filename under the daemon lock directory.

test/Capacitor.Cli.Tests.Unit/LocalSocketPathsTests.cs


LocalSocketSinkTests.cs Test sink delivery ordering and overflow force-detach +32/-0

Test sink delivery ordering and overflow force-detach

• Adds tests ensuring chunks are delivered in order and that overflow does not block the producer and marks the sink detached.

test/Capacitor.Cli.Tests.Unit/LocalSocketSinkTests.cs


RunAgentArgsTests.cs Test run-agent argument splitting and flag validation +47/-0

Test run-agent argument splitting and flag validation

• Adds tests for '--' passthrough splitting, defaults, unknown flag errors, and explicit rejection of the Phase 2 '--share' flag.

test/Capacitor.Cli.Tests.Unit/RunAgentArgsTests.cs


Documentation (3)
README.md Document local run-agent/attach/ls workflow +23/-0

Document local run-agent/attach/ls workflow

• Adds a new README section describing how to start, detach, list, and reattach local terminal sessions to daemon-hosted agents, including the '--' passthrough boundary, worktree vs in-place behavior, and Unix-only limitation.

README.md


2026-06-14-local-attach-phase1.md Add Phase 1 implementation plan for local terminal attach +1183/-0

Add Phase 1 implementation plan for local terminal attach

• Introduces a detailed step-by-step implementation plan covering protocol framing, daemon socket server, CLI client raw-mode pumping, safety invariants (borrowed cwd), and test strategy for Phase 1.

docs/superpowers/plans/2026-06-14-local-attach-phase1.md


2026-06-13-local-attach-hosted-agent-design.md Add design spec for co-driven local terminal attach +429/-0

Add design spec for co-driven local terminal attach

• Adds the design/specification for local terminal attach, including scope boundaries (no new server contract), daemon-owned PTY model, multi-client fan-out, detach/reattach semantics, and key safety decisions.

docs/superpowers/specs/2026-06-13-local-attach-hosted-agent-design.md


Grey Divider

Qodo Logo

Comment on lines +80 to +88
static (string vendor, WorkLocation work, string cwd, string[] args, ushort cols, ushort rows) ParseSpawn(byte[] p) {
var o = 0;
var work = (WorkLocation)p[o++];
var cols = Be16(p, o); o += 2; var rows = Be16(p, o); o += 2;
var vendor = ReadLp(p, ref o); var cwd = ReadLp(p, ref o);
var n = BinaryPrimitives.ReadInt32BigEndian(p.AsSpan(o)); o += 4;
var args = new string[n];
for (var i = 0; i < n; i++) args[i] = ReadLp(p, ref o);
return (vendor, work, cwd, args, cols, rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Spawn frame oom risk 🐞 Bug ⛨ Security

FrameCodec.ParseSpawn allocates new string[n] from an on-wire value without validating n against
the payload size or a sane upper bound, so a local socket client can force an OOM/crash by sending a
Spawn frame with a huge arg count. The length-prefixed string reader (ReadLp) also lacks bounds
checks, so malformed payloads can overrun offsets and throw non-protocol exceptions.
Agent Prompt
## Issue description
`FrameCodec.ParseSpawn()` trusts the arg-count field from the wire (`n`) and allocates an array of that size without validating it against remaining payload bytes or a reasonable maximum. A malformed/malicious local client can send a small payload with a very large `n` and trigger a huge allocation (OOM) before any further parsing occurs.

## Issue Context
The local control socket is a privileged local interface (0600), but it is still untrusted input from any process running as the same user. A single bad frame should fail fast with a bounded error, not take down the daemon.

## Fix Focus Areas
- src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[80-88]
- src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs[109-110]

## Suggested fix
- Add explicit bounds checks before reading each field:
  - Ensure `o + 4 <= p.Length` before reading any 32-bit length.
  - Validate `n >= 0` and cap it to a small maximum (e.g. 4096) *and* validate `n` is plausible given remaining bytes (at minimum `remaining >= n * 4` for the per-arg length prefixes).
  - In `ReadLp`, validate `len >= 0` and `o + len <= p.Length` before calling `Encoding.UTF8.GetString`.
- On violation, throw `InvalidDataException` (protocol error) rather than allowing OOM or `ArgumentOutOfRangeException`/`OutOfMemoryException` to surface.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 75c6a23. ParseSpawn now validates the wire arg-count against a cap (4096) and remaining bytes before allocating, and ReadLp bounds-checks every length prefix — a malformed/oversized local frame is now a clean InvalidDataException (caught + logged by the socket handler), not an OOM or ArgumentOutOfRangeException. Added two codec tests (bogus arg-count, truncated string length).

Comment on lines +100 to +123
var sink = new LocalSocketSink(capacity: 4096, (chunk, _) => Send(LocalFrame.Stdout(chunk)));
lock (agent.SinksLock) agent.LocalSinks.Add(sink);

try {
// Bounded replay BEFORE any live chunk so the client paints a coherent screen.
await Send(FrameCodec.Attached(agent.Id, agent.OutputBuffer.Snapshot()));
var pump = sink.RunAsync(ct);

try {
while (!ct.IsCancellationRequested) {
var f = await FrameCodec.ReadAsync(stream, ct);
if (f is null || f.Type == FrameType.Detach) break;

switch (f.Type) {
case FrameType.Stdin: await agent.Process.WriteAsync(f.Bytes); break;
case FrameType.Resize: ApplyResizeClamp(agent, sink, f.Cols, f.Rows); break;
}
}
} catch (Exception ex) when (ex is EndOfStreamException or IOException or OperationCanceledException) {
/* client gone */
} finally {
sink.Complete();
await pump.ConfigureAwait(false);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Sink detach not propagated 🐞 Bug ≡ Correctness

When LocalSocketSink overflows it sets Detached and stops accepting output, but
AttachClientLoopAsync never reacts to sink.Detached and keeps processing client stdin/resize frames
until the client disconnects or explicitly detaches. This can leave an attach session running after
its output path is dead, so the user may continue interacting without a valid terminal view.
Agent Prompt
## Issue description
`LocalSocketSink` can become `Detached` (queue overflow or send failure), but the attach session loop never checks that state and does not proactively end/notify the client. This breaks the intended “force-detach and replay on rejoin” behavior and can lead to blind input.

## Issue Context
- Overflow sets `Detached = true` and completes the sink channel, but does not close the socket.
- The attach loop terminates only on explicit `Detach`, EOF, or cancellation.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/LocalSocketSink.cs[29-36]
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[90-123]

## Suggested fix
- Create a per-session CTS (linked to `ct`) and pass it to both `sink.RunAsync(...)` and the input loop.
- Monitor sink termination/detach:
  - e.g. start a task that awaits `pump` and if `sink.Detached` becomes true, attempt to send a terminal frame (Error or Detach) and then cancel the session CTS to break the input loop.
- Consider removing detached sinks from `agent.LocalSinks` immediately when they detach, to avoid accumulating inert sinks until the client eventually disconnects.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 75c6a23. When a sink force-detaches (overflow/send failure) its pump completes with Detached set; a continuation now cancels the input loop's CTS so AttachClientLoopAsync stops reading client stdin, removes the sink, and sends an Error frame telling the client to reattach (a fresh kcap attach replays from a clean frame) — no more blind input. The agent-self-exit path (ExitedCts) already broke the loop the same way.

alexeyzimarev and others added 20 commits June 14, 2026 11:34
Spec for letting a human attach their local terminal to a daemon-hosted
agent as one of N clients (alongside the web UI), enabling pair programming
and start-local-continue-remotely. Daemon owns the PTY; local terminal
attaches over a new owner-only local socket, teammates over existing SignalR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses five findings verified against current daemon code:
- Drop the "offline-capable" claim: daemon requires ServerUrl + SignalR
  (DaemonConfig.Validate / DaemonRunner). Phase 1 = no new server CONTRACT,
  not offline; run-agent stays off the offlineCommands list.
- Owned-worktree vs borrowed-cwd distinction: cleanup must never remove a
  borrowed cwd or its branch (CleanupAgentAsync -> WorktreeManager.RemoveAsync
  does Directory.Delete / worktree remove --force / branch -D). Critical test.
- Lossless per-sink terminal queues (never DropOldest, which reintroduced the
  AI-844 TUI corruption); overflow force-detaches that one client and replays
  on rejoin, without stalling the shared PTY loop or other sinks.
- Resize arbitration: clamp the PTY to the smallest attached client (tmux).
- Remote control (per decision): write-by-default once shared, mirroring
  hosted-agent run permissions; private-until-explicitly-shared; observe-only
  is the existing read-only sharing path. Blast-radius trade-off documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five findings verified against current daemon code, all correct:
- PrivateLocal agent state: locally-launched agents suppress ALL server
  interaction (AgentRegistered/status/events/terminal/unregister; SignalR
  sink not attached) until an explicit Phase 2 share flips PrivateLocal->Shared.
  Resolves the private-until-shared vs unconditional-register/stream conflict.
- Borrowed-cwd Prepare() must skip repo-mutating steps (no .mcp.json,
  settings.local.json, ~/.claude.json, ~/.codex trust); only read-only/idempotent
  preflight allowed. Test asserts in-place launch writes no repo/global files.
- Launcher-agnostic passthrough as an IHostedAgentLauncher contract; Codex
  injects mandatory --cd/--no-alt-screen + hooks preflight, then verbatim args.
  run-agent codex works in v1. New "Vendor passthrough" subsection + conflict policy.
- Terminal stream: "no silent partial-stream corruption" + bounded (2 MB
  OutputBuffer) replay, not "never drops bytes".
- Resize: Phase 1 min-clamps local socket clients only; Phase 2 caveat that
  SignalR has only one agent-level resize (no per-web-client dimensions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four findings verified against current daemon code:
- PrivateLocal deny-all: orchestrator makes NO per-agent ServerConnection call
  (register, run events, DaemonUpdateRepoPaths, LaunchFailed, status, run-stopped,
  EndAgentSession, reconnect re-register, heartbeat) — single guard + strict mock
  test that fails on any per-agent server method.
- PrivateLocal second channel (spawned-agent hooks): launch env omits the
  hosted-agent vars (KCAP_AGENT_ID/KCAP_DAEMON_URL/KCAP_RENDERED_AGENT) so
  permissions prompt natively in the terminal and it is not web-controllable;
  KCAP_URL is kept so the session records normally (per decision — private means
  not-a-controllable-hosted-agent, not unrecorded).
- Share does a one-time bounded OutputBuffer replay before live chunks: the
  reconnect path skips replay because the server keeps its own buffer, but a
  freshly-shared PrivateLocal agent the server never saw has none.
- Add --share to the command-surface flags (Phase 2); clarify "private" in the
  security section does not mean unrecorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three findings verified against current daemon/hook code:
- Env is fixed at execvp (UnixPtyProcess.cs:62); hooks read it at runtime
  (ClaudeHookCommand.cs:49, CodexHookCommand.cs:87, PermissionRequestCommand.cs:37,77).
  Removed the false "apply hosted-agent env on share" claim. Private launch now
  KEEPS KCAP_URL + KCAP_AGENT_ID and OMITS KCAP_RENDERED_AGENT + KCAP_DAEMON_URL;
  permission prompts stay native for the agent's whole life (accepted, can't change).
- Tag-and-link (per decision): KCAP_AGENT_ID is a tag only (sets agent_host_id, no
  server call — deny-all holds), so the recorded transcript is link-ready; share
  registers that same id and the server late-links the pre-tagged session. Server
  must tolerate agent_host_id for an as-yet-unregistered agent (Phase 2 contract).
- Codex mandatory passthrough flags: reject a user-supplied duplicate of --cd /
  --no-alt-screen with a clear error rather than relying on arg-precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bite-sized TDD plan for the local-only "tmux for your agent" foundation:
frame codec, daemon local socket, N-client lossless fan-out, PrivateLocal
deny-all + owned-vs-borrowed cleanup + conditional env, raw-mode CLI client
(run-agent/attach/ls), launcher-agnostic passthrough. Six milestones (A-F),
each task TDD with exact files, code, test commands, and AOT gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…overflow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…min-clamp

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d cwd

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gh BuildArgs

Borrowed cwd launches skip repo-mutating Prepare steps (Claude overlay/.mcp.json/
trust; Codex overlay/trust — read-only hooks preflight still runs). New
BuildPassthrough on IHostedAgentLauncher: Claude verbatim; Codex injects mandatory
--cd/--no-alt-screen and rejects user duplicates of them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d + conditional env

HandleLocalSpawnAsync runs a local agent PrivateLocal (IsPrivate) in an owned worktree
or borrowed cwd, with passthrough args. Deny-all: every per-agent ServerConnection call
(status, run events, repo-path, launch-failed, end-session, reconnect re-register,
heartbeat, unregister) is guarded by !IsPrivate. Hook env omits KCAP_RENDERED_AGENT/
KCAP_DAEMON_URL (native terminal permissions), keeps KCAP_AGENT_ID (tag-and-link) +
KCAP_URL (recording), and re-adds ANTHROPIC_API_KEY (survives the headless scrub).
Strict-tripwire test proves no server call fires for a private agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…test

Socket round-trip test exercises LocalControlServer accept loop + frame routing over a
real Unix domain socket (List -> AgentList). Full real-PTY spawn-over-socket is covered
by the deterministic deny-all/env test (spawn+attach logic) plus manual smoke (E4), kept
out of the automated suite to avoid forkpty-in-a-multithreaded-test flakiness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…layout-agnostic)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll, detach, replay)

LocalAgentClient connects to the daemon socket, raw-modes the terminal, pumps stdin<->
stdout, polls window size for resize (SIGWINCH isn't in PosixSignal), and intercepts the
detach sequence (Ctrl-Q d) via a unit-tested DetachScanner. RunAgentCommand parses args,
ensures a daemon is running (auto-start + wait), and wires run-agent/attach/ls + --detached.
Interactive paths are build-verified; they need manual smoke (TTY + live daemon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexeyzimarev alexeyzimarev force-pushed the feat/local-terminal-attach branch from f1ebaf7 to ae45697 Compare June 14, 2026 09:40
alexeyzimarev and others added 3 commits June 14, 2026 11:59
System.Console on Unix re-cooks the terminal, defeating raw mode (double echo, line
buffering, LF-not-CR on Enter so the agent never submits, missing cursor). Read stdin and
write stdout through read(0)/write(1) directly; touch the Console size getter once before
enabling raw mode so .NET's lazy init can't clobber it afterward. Window size still uses
Console.WindowWidth (read-only; ioctl is unreliable via P/Invoke on macOS ARM64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The attach loop was blocked on the client's stdin read and never noticed the agent
exiting on its own, so it never flushed the final output or sent Exited — the local
client hung. Add a per-agent ExitedCts tripped in CleanupAgentAsync; the attach loop's
read is linked to it, so on agent exit it breaks, drains the sink, and sends Exited.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oritative

Visibility & sharing live entirely on the server/web (AgentRegistered carries no
visibility field; no 'share' concept in CLI/daemon). Reframe Phase 2: a local agent
registers like a UI-launched one and inherits the identical owner-only-until-shared
model — owner sees/drives it from their own web UI immediately ('remote control'),
teammates only via the existing UI share. This drops the invented 'private-until-shared'
model, the bespoke kcap share / --share, and tag-and-link (registers from the start).

Phase 1 code: omit KCAP_AGENT_ID so a local agent records as a plain local session (no
agent_host_id for an unregistered agent); remove the --share flag (sharing is a UI action,
future kcap share CLI tracked as AI-861).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Update — rebased on main (#154) and pushed 3 follow-ups, all manually smoke-tested locally (real claude via kcap run-agent):

  • fix(cli): raw-fd I/O — terminal I/O bypasses System.Console (which re-cooks the tty on Unix), so raw mode actually engages. Fixes double-echo, missing cursor, and Enter sending LF instead of CR (agent never submitted).
  • fix(daemon): attach loop reacts to agent self-exit/exit (and any self-exit) now wakes the attach loop, flushes final output, and sends Exited, so the client returns and restores the terminal instead of hanging.
  • Design realignment (spec): visibility/sharing is server/UI-authoritative (AgentRegistered has no visibility field; no "share" in CLI/daemon). A local agent registers like a UI-launched one → owner-only, visible/controllable from your own web UI immediately; teammates only via the existing UI "share". Dropped the invented private-until-shared model, the bespoke kcap share/--share, and tag-and-link. Phase 1 records as a plain local session (omits KCAP_AGENT_ID). A future kcap share CLI is tracked as AI-861.

Manual smoke confirmed: clean input, cursor, Ctrl-Q d detach + attach repaint, and /exit. Unit 1503 + integration 30 green, AOT clean.

alexeyzimarev and others added 3 commits June 14, 2026 13:25
…h (Qodo #155)

- FrameCodec: validate the wire arg-count against a cap (4096) and remaining bytes before
  allocating, and bounds-check every length prefix in ReadLp — a malformed local frame is
  now a clean InvalidDataException, not an OOM or ArgumentOutOfRangeException.
- AttachClientLoopAsync: when a sink force-detaches (overflow/send failure) its pump
  completes with Detached set; cancel the input loop and send an Error frame so the client
  stops typing blind and reattaches for a fresh replay (the intended force-detach behaviour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omic replay, resize)

- UnixPtyProcess: unset KCAP_AGENT_ID/RENDERED/DAEMON_URL in the PTY child before applying
  extraEnv, so a private local agent can't inherit hosted-agent identity/routing from a
  daemon that was itself launched inside a kcap-tracked session (P1).
- HandleLocalSpawnAsync: remove a daemon-created (--worktree) worktree if Prepare /
  passthrough / spawn fails after creation — no leaked worktrees/branches (P1).
- Snapshot+subscribe is now atomic with the read loop's append+fan-out (both under
  SinksLock), so a chunk can't be both replayed and sent live (no duplicate on attach) (P2).
- Resize clamp recomputed on detach too, so a departing smaller client no longer pins
  larger clients to its size (P2).
- Spec: clarify Scope that web client/visibility is Phase 2; Phase 1 is terminal-only (P2 docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ign stale docs

- LocalAgentClient: distinguish a clean end (explicit detach, stdin EOF, Exited, or Error)
  from an unexpected daemon/socket drop. A drop now prints 'lost connection to the daemon'
  and returns exit 1 instead of looking like a clean exit (0).
- Docs: spec testing bullet and the plan's D4 task no longer claim KCAP_AGENT_ID is kept for
  Phase 1 — it's omitted (plain local session, no agent_host_id tag), matching the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexeyzimarev alexeyzimarev merged commit d16ef5b into main Jun 14, 2026
5 checks passed
@alexeyzimarev alexeyzimarev deleted the feat/local-terminal-attach branch June 14, 2026 12:16
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