Repository navigation
[AI-860] Local terminal attach for hosted agents (Phase 1: run-agent / attach / ls) #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
0511e1d
Design: local terminal attach for hosted agents (co-driven sessions)
alexeyzimarev 07f2ef5
Revise local-attach spec per code review
alexeyzimarev 867136b
Revise local-attach spec per second review pass
alexeyzimarev 4dc0c8b
Revise local-attach spec per third review pass
alexeyzimarev 6371588
Revise local-attach spec per fourth review pass
alexeyzimarev 6fba715
Add Phase 1 implementation plan: local terminal attach
alexeyzimarev f1f3a42
feat(local-ipc): frame types for daemon-client socket
alexeyzimarev f6005a5
feat(local-ipc): length-prefixed AOT-safe frame codec + tests
alexeyzimarev c9e50b4
feat(local-ipc): per-daemon-name socket path
alexeyzimarev f0ed4e6
feat(daemon): local control socket listener (0600), routed to orchest…
alexeyzimarev c5ca2b8
feat(daemon): per-client lossless terminal sink with force-detach on …
alexeyzimarev 77a82a1
feat(daemon): N-client terminal fan-out + local attach loop + resize …
alexeyzimarev 949f922
feat(daemon): owned-vs-borrowed work location; never remove a borrowe…
alexeyzimarev c3e04e7
feat(daemon): borrowed-cwd Prepare skip + launcher-agnostic passthrou…
alexeyzimarev 37182de
feat(daemon): local-spawn entry (PrivateLocal) + deny-all server guar…
alexeyzimarev 384cc65
feat(daemon): HandleLocalListAsync (kcap ls) + real-socket transport …
alexeyzimarev 3020e81
feat(cli): run-agent arg parsing (-- boundary, kcap flags) + tests
alexeyzimarev 6565a89
feat(cli): termios raw-mode via opaque-blob + cfmakeraw (Linux/macOS …
alexeyzimarev 188e106
feat(cli): run-agent/attach/ls + local agent client (pumps, resize-po…
alexeyzimarev ae45697
docs: document run-agent / attach / ls (local terminal attach, Phase 1)
alexeyzimarev 88525c5
fix(cli): local client I/O via raw fds, bypassing System.Console
alexeyzimarev 0677643
fix(daemon): attach loop reacts to agent self-exit (e.g. /exit)
alexeyzimarev 9b455e2
Align local-attach with reality: visibility/sharing is server/UI-auth…
alexeyzimarev 75c6a23
fix(local-ipc): bound Spawn frame parsing + react to sink force-detac…
alexeyzimarev 36d2fe5
Harden local-attach per reviewer (env isolation, worktree cleanup, at…
alexeyzimarev b9be612
fix(cli): non-zero exit + message on unexpected daemon disconnect; al…
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
1,183 changes: 1,183 additions & 0 deletions
1,183
docs/superpowers/plans/2026-06-14-local-attach-phase1.md
Large diffs are not rendered by default.
Oops, something went wrong.
424 changes: 424 additions & 0 deletions
424
docs/superpowers/specs/2026-06-13-local-attach-hosted-agent-design.md
Large diffs are not rendered by default.
Oops, something went wrong.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| namespace Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| /// <summary> | ||
| /// Scans the local terminal's raw stdin for the detach sequence — the prefix byte | ||
| /// <c>Ctrl-Q</c> (<c>0x11</c>) followed by <c>d</c> — and strips it before the rest is | ||
| /// forwarded to the agent. The prefix is held back until the next byte arrives (possibly | ||
| /// in a later read), so the sequence is detected even when split across reads. A prefix | ||
| /// not followed by <c>d</c> is forwarded as-is, so Ctrl-Q stays usable otherwise. | ||
| /// </summary> | ||
| public sealed class DetachScanner { | ||
| const byte Prefix = 0x11; // Ctrl-Q | ||
| const byte Detach = (byte)'d'; | ||
|
|
||
| bool _armed; // saw the prefix; the next byte decides detach-vs-forward | ||
|
|
||
| /// <summary> | ||
| /// Feeds a chunk of raw stdin. Returns the bytes to forward to the agent and whether a | ||
| /// detach was requested. When a detach is returned, any bytes after it in the same chunk | ||
| /// are intentionally dropped (the session is ending). | ||
| /// </summary> | ||
| public (byte[] Forward, bool Detach) Process(ReadOnlySpan<byte> input) { | ||
| var outBuf = new List<byte>(input.Length + 1); | ||
|
|
||
| foreach (var b in input) { | ||
| if (_armed) { | ||
| _armed = false; | ||
|
|
||
| if (b == Detach) return (outBuf.ToArray(), true); // strip prefix + 'd' | ||
|
|
||
| outBuf.Add(Prefix); // prefix was not a detach — forward it now | ||
|
|
||
| if (b == Prefix) { _armed = true; continue; } // a fresh prefix — re-arm | ||
|
|
||
| outBuf.Add(b); | ||
| } else if (b == Prefix) { | ||
| _armed = true; // hold; decide on the next byte | ||
| } else { | ||
| outBuf.Add(b); | ||
| } | ||
| } | ||
|
|
||
| return (outBuf.ToArray(), false); | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| using System.Buffers.Binary; | ||
| using System.Text; | ||
|
|
||
| namespace Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| /// Length-prefixed binary codec: [1B type][4B BE len][len payload]. No reflection / | ||
| /// JSON — AOT-safe and allocation-light. Spawn/Attached have structured payloads | ||
| /// (helpers below); other frames are trivial. | ||
| public static class FrameCodec { | ||
| const int MaxPayload = 8 * 1024 * 1024; // hard cap; oversized => protocol error | ||
|
|
||
| public static async Task WriteAsync(Stream s, LocalFrame f, CancellationToken ct) { | ||
| var payload = Encode(f); | ||
| var head = new byte[5]; | ||
| head[0] = (byte)f.Type; | ||
| BinaryPrimitives.WriteInt32BigEndian(head.AsSpan(1), payload.Length); | ||
| await s.WriteAsync(head, ct); | ||
| if (payload.Length > 0) await s.WriteAsync(payload, ct); | ||
| await s.FlushAsync(ct); | ||
| } | ||
|
|
||
| /// Returns null on clean EOF (peer closed between frames). | ||
| public static async Task<LocalFrame?> ReadAsync(Stream s, CancellationToken ct) { | ||
| var head = new byte[5]; | ||
| if (!await ReadExactlyOrEof(s, head, ct)) return null; | ||
| var type = (FrameType)head[0]; | ||
| var len = BinaryPrimitives.ReadInt32BigEndian(head.AsSpan(1)); | ||
| if (len is < 0 or > MaxPayload) throw new InvalidDataException($"frame len {len}"); | ||
| var payload = len == 0 ? [] : new byte[len]; | ||
| if (len > 0 && !await ReadExactlyOrEof(s, payload, ct)) | ||
| throw new EndOfStreamException("truncated frame payload"); | ||
| return Decode(type, payload); | ||
| } | ||
|
|
||
| static async Task<bool> ReadExactlyOrEof(Stream s, byte[] buf, CancellationToken ct) { | ||
| var off = 0; | ||
| while (off < buf.Length) { | ||
| var n = await s.ReadAsync(buf.AsMemory(off), ct); | ||
| if (n == 0) return off != 0 ? throw new EndOfStreamException("truncated frame header") : false; | ||
| off += n; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| static byte[] Encode(LocalFrame f) => f.Type switch { | ||
| FrameType.Stdin or FrameType.Stdout => f.Bytes, | ||
| FrameType.Resize => Dims(f.Cols, f.Rows), | ||
| FrameType.Detach or FrameType.List => [], | ||
| FrameType.Exited => BeInt(f.ExitCode), | ||
| FrameType.Error or FrameType.Attach or FrameType.AgentList => Encoding.UTF8.GetBytes(f.Text), | ||
| FrameType.Attached or FrameType.Spawn => f.Bytes, // pre-encoded by Attached(...)/Spawn(...) | ||
| _ => throw new InvalidDataException($"unencodable frame {f.Type}"), | ||
| }; | ||
|
|
||
| static LocalFrame Decode(FrameType t, byte[] p) => t switch { | ||
| FrameType.Stdin or FrameType.Stdout => new(t) { Bytes = p }, | ||
| FrameType.Resize => new(t) { Cols = Be16(p, 0), Rows = Be16(p, 2) }, | ||
| FrameType.Detach or FrameType.List => new(t), | ||
| FrameType.Exited => new(t) { ExitCode = BinaryPrimitives.ReadInt32BigEndian(p) }, | ||
| FrameType.Error or FrameType.Attach or FrameType.AgentList => new(t) { Text = Encoding.UTF8.GetString(p) }, | ||
| FrameType.Attached or FrameType.Spawn => new(t) { Bytes = p }, | ||
| _ => throw new InvalidDataException($"undecodable frame {t}"), | ||
| }; | ||
|
|
||
| // --- Spawn structured payload --- | ||
| public static LocalFrame Spawn(string vendor, WorkLocation work, string cwd, IReadOnlyList<string> args, ushort cols, ushort rows) { | ||
| using var ms = new MemoryStream(); | ||
| ms.WriteByte((byte)work); | ||
| WriteBe16(ms, cols); WriteBe16(ms, rows); | ||
| WriteLp(ms, vendor); WriteLp(ms, cwd); | ||
| WriteBe32(ms, args.Count); | ||
| foreach (var a in args) WriteLp(ms, a); | ||
| return new(FrameType.Spawn) { Bytes = ms.ToArray(), Text = vendor, Work = work, Cols = cols, Rows = rows }; | ||
| } | ||
| public static string SpawnCwd(LocalFrame f) => ParseSpawn(f.Bytes).cwd; | ||
| public static string[] SpawnArgs(LocalFrame f) => ParseSpawn(f.Bytes).args; | ||
| public static (string vendor, WorkLocation work, string cwd, string[] args, ushort cols, ushort rows) Spawn(LocalFrame f) | ||
| => ParseSpawn(f.Bytes); | ||
|
|
||
| const int MaxSpawnArgs = 4096; // sane cap; the wire arg-count is untrusted (local 0600 socket, same-user) | ||
|
|
||
| static (string vendor, WorkLocation work, string cwd, string[] args, ushort cols, ushort rows) ParseSpawn(byte[] p) { | ||
| var o = 0; | ||
| Require(p, o, 5); // work(1) + cols(2) + rows(2) | ||
| 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); | ||
|
|
||
| Require(p, o, 4); | ||
| var n = BinaryPrimitives.ReadInt32BigEndian(p.AsSpan(o)); o += 4; | ||
| // Validate the arg count against a cap AND remaining bytes before allocating, so a | ||
| // malformed/huge count can't force an OOM (each arg has at least a 4-byte prefix). | ||
| if (n is < 0 or > MaxSpawnArgs) throw new InvalidDataException($"Spawn arg count out of range: {n}"); | ||
| if ((long)(p.Length - o) < (long)n * 4) throw new InvalidDataException("Spawn arg count exceeds payload"); | ||
|
|
||
| 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); | ||
| } | ||
|
|
||
| /// <summary>Throws <see cref="InvalidDataException"/> unless <paramref name="count"/> bytes | ||
| /// remain at offset <paramref name="o"/> — keeps malformed frames a clean protocol error | ||
| /// rather than an <c>ArgumentOutOfRangeException</c> or oversized allocation.</summary> | ||
| static void Require(byte[] p, int o, int count) { | ||
| if (o < 0 || count < 0 || (long)o + count > p.Length) throw new InvalidDataException("Frame payload truncated"); | ||
| } | ||
|
|
||
| // --- Attached structured payload: [4B agentIdLen][agentId][snapshot...] --- | ||
| public static LocalFrame Attached(string agentId, byte[] snapshot) { | ||
| using var ms = new MemoryStream(); | ||
| WriteLp(ms, agentId); | ||
| ms.Write(snapshot); | ||
| return new(FrameType.Attached) { Bytes = ms.ToArray(), Text = agentId }; | ||
| } | ||
| public static (string agentId, byte[] snapshot) Attached(LocalFrame f) { | ||
| var o = 0; var id = ReadLp(f.Bytes, ref o); | ||
| return (id, f.Bytes[o..]); | ||
| } | ||
|
|
||
| // --- byte helpers --- | ||
| static byte[] Dims(ushort c, ushort r) { var b = new byte[4]; BinaryPrimitives.WriteUInt16BigEndian(b, c); BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(2), r); return b; } | ||
| static byte[] BeInt(int v) { var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v); return b; } | ||
| static ushort Be16(byte[] p, int o) => BinaryPrimitives.ReadUInt16BigEndian(p.AsSpan(o)); | ||
| static void WriteBe16(Stream s, ushort v) { var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); s.Write(b); } | ||
| static void WriteBe32(Stream s, int v) { var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v); s.Write(b); } | ||
| static void WriteLp(Stream s, string v) { var u = Encoding.UTF8.GetBytes(v); WriteBe32(s, u.Length); s.Write(u); } | ||
| static string ReadLp(byte[] p, ref int o) { | ||
| Require(p, o, 4); | ||
| var n = BinaryPrimitives.ReadInt32BigEndian(p.AsSpan(o)); o += 4; | ||
| if (n < 0) throw new InvalidDataException($"Negative length-prefix: {n}"); | ||
| Require(p, o, n); | ||
| var v = Encoding.UTF8.GetString(p, o, n); o += n; return v; | ||
| } | ||
| } | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| namespace Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| /// Wire contract for the daemon↔local-client socket. Values are explicit and | ||
| /// MUST be append-only — they are serialized as a single byte (see FrameCodec). | ||
| public enum FrameType : byte { | ||
| // client → daemon | ||
| Spawn = 1, | ||
| Attach = 2, | ||
| Stdin = 3, | ||
| Resize = 4, | ||
| Detach = 5, | ||
| List = 6, // request the daemon's agent list (for `kcap ls`) | ||
| // daemon → client | ||
| Attached = 64, | ||
| Stdout = 65, | ||
| Exited = 66, | ||
| Error = 67, | ||
| AgentList = 68, // UTF-8 table payload: one `id\tstatus\tcwd` line per agent | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| namespace Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| public enum WorkLocation : byte { BorrowedCwd = 0, OwnedWorktree = 1 } | ||
|
|
||
| /// A decoded frame. Exactly one of the payload properties is meaningful per Type; | ||
| /// the codec owns which. Bytes are raw (Stdin/Stdout); strings are UTF-8. | ||
| public sealed record LocalFrame(FrameType Type) { | ||
| public byte[] Bytes { get; init; } = []; | ||
| public string Text { get; init; } = ""; | ||
| public ushort Cols { get; init; } | ||
| public ushort Rows { get; init; } | ||
| public WorkLocation Work { get; init; } | ||
| public int ExitCode { get; init; } | ||
|
|
||
| public static LocalFrame Stdin(byte[] b) => new(FrameType.Stdin) { Bytes = b }; | ||
| public static LocalFrame Stdout(byte[] b) => new(FrameType.Stdout) { Bytes = b }; | ||
| public static LocalFrame Resize(ushort c, ushort r) => new(FrameType.Resize) { Cols = c, Rows = r }; | ||
| public static LocalFrame Detach() => new(FrameType.Detach); | ||
| public static LocalFrame Exited(int code) => new(FrameType.Exited) { ExitCode = code }; | ||
| public static LocalFrame Error(string m) => new(FrameType.Error) { Text = m }; | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| namespace Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| /// Per-daemon-name local control socket, colocated with the daemon's lock/pid files. | ||
| public static class LocalSocketPaths { | ||
| public static string Socket(string daemonName) | ||
| => Path.Combine(DaemonLockPaths.Directory, $"{DaemonLockPaths.Sanitize(daemonName)}.sock"); | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| namespace Capacitor.Cli.Core; | ||
|
|
||
| /// Parses `run-agent <vendor> [kcap flags] -- [agent args]`: kcap's own flags come | ||
| /// before <c>--</c>; everything after <c>--</c> is forwarded to the agent CLI verbatim. | ||
| public sealed class RunAgentArgs { | ||
| public string Vendor { get; private set; } = ""; | ||
| public bool Worktree { get; private set; } | ||
| public string? DaemonName { get; private set; } | ||
| public bool Detached { get; private set; } | ||
| public string[] Passthrough { get; private set; } = []; | ||
| public string? Error { get; private set; } | ||
|
|
||
| public static RunAgentArgs Parse(string[] args) { | ||
| var r = new RunAgentArgs(); | ||
|
|
||
| if (args.Length == 0) { | ||
| r.Error = "usage: kcap run-agent <vendor> [--worktree] [--name <id>] [--detached] [-- <agent args>]"; | ||
|
|
||
| return r; | ||
| } | ||
|
|
||
| var dash = Array.IndexOf(args, "--"); | ||
| var kcap = dash < 0 ? args : args[..dash]; | ||
| r.Passthrough = dash < 0 ? [] : args[(dash + 1)..]; | ||
|
|
||
| if (kcap.Length == 0) { | ||
| r.Error = "missing <vendor>"; | ||
|
|
||
| return r; | ||
| } | ||
|
|
||
| r.Vendor = kcap[0]; | ||
|
|
||
| for (var i = 1; i < kcap.Length; i++) { | ||
| switch (kcap[i]) { | ||
| case "--worktree": r.Worktree = true; break; | ||
| case "--detached": r.Detached = true; break; | ||
| case "--name": | ||
| if (i + 1 >= kcap.Length) { r.Error = "--name requires a value"; return r; } | ||
|
|
||
| r.DaemonName = kcap[++i]; | ||
|
|
||
| break; | ||
| default: | ||
| r.Error = $"unknown flag {kcap[i]} (agent args go after `--`)"; | ||
|
|
||
| return r; | ||
| } | ||
| } | ||
|
|
||
| return r; | ||
| } | ||
| } |
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Spawn frame oom risk
🐞 Bug⛨ SecurityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation toolsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 75c6a23.
ParseSpawnnow validates the wire arg-count against a cap (4096) and remaining bytes before allocating, andReadLpbounds-checks every length prefix — a malformed/oversized local frame is now a cleanInvalidDataException(caught + logged by the socket handler), not an OOM orArgumentOutOfRangeException. Added two codec tests (bogus arg-count, truncated string length).