Skip to content
Merged
Show file tree
Hide file tree
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 Jun 13, 2026
07f2ef5
Revise local-attach spec per code review
alexeyzimarev Jun 13, 2026
867136b
Revise local-attach spec per second review pass
alexeyzimarev Jun 13, 2026
4dc0c8b
Revise local-attach spec per third review pass
alexeyzimarev Jun 14, 2026
6371588
Revise local-attach spec per fourth review pass
alexeyzimarev Jun 14, 2026
6fba715
Add Phase 1 implementation plan: local terminal attach
alexeyzimarev Jun 14, 2026
f1f3a42
feat(local-ipc): frame types for daemon-client socket
alexeyzimarev Jun 14, 2026
f6005a5
feat(local-ipc): length-prefixed AOT-safe frame codec + tests
alexeyzimarev Jun 14, 2026
c9e50b4
feat(local-ipc): per-daemon-name socket path
alexeyzimarev Jun 14, 2026
f0ed4e6
feat(daemon): local control socket listener (0600), routed to orchest…
alexeyzimarev Jun 14, 2026
c5ca2b8
feat(daemon): per-client lossless terminal sink with force-detach on …
alexeyzimarev Jun 14, 2026
77a82a1
feat(daemon): N-client terminal fan-out + local attach loop + resize …
alexeyzimarev Jun 14, 2026
949f922
feat(daemon): owned-vs-borrowed work location; never remove a borrowe…
alexeyzimarev Jun 14, 2026
c3e04e7
feat(daemon): borrowed-cwd Prepare skip + launcher-agnostic passthrou…
alexeyzimarev Jun 14, 2026
37182de
feat(daemon): local-spawn entry (PrivateLocal) + deny-all server guar…
alexeyzimarev Jun 14, 2026
384cc65
feat(daemon): HandleLocalListAsync (kcap ls) + real-socket transport …
alexeyzimarev Jun 14, 2026
3020e81
feat(cli): run-agent arg parsing (-- boundary, kcap flags) + tests
alexeyzimarev Jun 14, 2026
6565a89
feat(cli): termios raw-mode via opaque-blob + cfmakeraw (Linux/macOS …
alexeyzimarev Jun 14, 2026
188e106
feat(cli): run-agent/attach/ls + local agent client (pumps, resize-po…
alexeyzimarev Jun 14, 2026
ae45697
docs: document run-agent / attach / ls (local terminal attach, Phase 1)
alexeyzimarev Jun 14, 2026
88525c5
fix(cli): local client I/O via raw fds, bypassing System.Console
alexeyzimarev Jun 14, 2026
0677643
fix(daemon): attach loop reacts to agent self-exit (e.g. /exit)
alexeyzimarev Jun 14, 2026
9b455e2
Align local-attach with reality: visibility/sharing is server/UI-auth…
alexeyzimarev Jun 14, 2026
75c6a23
fix(local-ipc): bound Spawn frame parsing + react to sink force-detac…
alexeyzimarev Jun 14, 2026
36d2fe5
Harden local-attach per reviewer (env isolation, worktree cleanup, at…
alexeyzimarev Jun 14, 2026
b9be612
fix(cli): non-zero exit + message on unexpected daemon disconnect; al…
alexeyzimarev Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,29 @@ KCAP_DAEMON_LOG_LEVEL=debug kcap daemon # env var; read directly, works in an

Accepted values: `trace`, `debug`, `information` (default), `warning`, `error`, `critical`, `none`. The `--log-level` flag wins over the env var when both are set. `Debug` is verbose — it also enables the SignalR client's framework logs — so use it for a diagnostic window rather than steady state.

### Local agents (run-agent / attach / ls)

Start a coding agent from your own terminal that the daemon hosts for you. Because the daemon owns the agent (not your terminal), you can **detach and the agent keeps running**, then **re-attach later** — like `tmux` for your coding agent.

```bash
kcap run-agent claude # start Claude in the current directory, attached
kcap run-agent claude -- --model opus # everything after `--` is passed to the agent CLI verbatim
kcap run-agent codex --worktree -- -m gpt-5 # run in an isolated git worktree instead of in place
kcap run-agent claude --detached # start without attaching; prints the agent id
```

- **`--` boundary:** flags before `--` are kcap's; everything after `--` is forwarded to the `claude`/`codex` CLI unchanged. kcap flags: `--worktree`, `--name <daemon>`, `--detached`.
- **Work location:** by default the agent runs **in place in your current directory** (it edits your real files). Pass `--worktree` to run in a throwaway git worktree instead.
- **Detach** without stopping the agent with the prefix key **`Ctrl-Q` then `d`**. The agent keeps running in the daemon.
- **Permissions** prompt natively in your terminal, exactly like running the agent directly.

```bash
kcap ls # list daemon-hosted agents (id, status, repo)
kcap attach <agent-id> # re-attach your terminal to a running agent
```

`run-agent` auto-starts the daemon if one isn't already running. It needs a configured server (like the rest of kcap) — it is not an offline command. This release is **local-only**: starting an agent locally does not yet expose it to teammates in the web UI (planned for a later release). Unix only for now.

### Repository paths

Manage known repo paths for the agent launch dialog. Repos are automatically added when agents are launched, but you can also manage the list manually:
Expand Down
1,183 changes: 1,183 additions & 0 deletions docs/superpowers/plans/2026-06-14-local-attach-phase1.md

Large diffs are not rendered by default.

424 changes: 424 additions & 0 deletions docs/superpowers/specs/2026-06-13-local-attach-hosted-agent-design.md

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions src/Capacitor.Cli.Core/LocalIpc/DetachScanner.cs
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);
}
}
134 changes: 134 additions & 0 deletions src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs
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);
Comment on lines +82 to +98

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).

}

/// <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;
}
}
19 changes: 19 additions & 0 deletions src/Capacitor.Cli.Core/LocalIpc/FrameType.cs
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
}
21 changes: 21 additions & 0 deletions src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs
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 };
}
7 changes: 7 additions & 0 deletions src/Capacitor.Cli.Core/LocalIpc/LocalSocketPaths.cs
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");
}
53 changes: 53 additions & 0 deletions src/Capacitor.Cli.Core/RunAgentArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Capacitor.Cli.Core;

/// Parses `run-agent &lt;vendor&gt; [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;
}
}
5 changes: 5 additions & 0 deletions src/Capacitor.Cli.Daemon/DaemonRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ await Console.Error.WriteLineAsync(
builder.Services.AddSingleton<EvalContextCache>();
builder.Services.AddSingleton<EvalRunner>();

// Local control socket: lets `kcap run-agent`/`attach`/`ls` drive daemon-hosted
// agents from the user's own terminal (AI local-attach Phase 1).
builder.Services.AddSingleton<LocalControlServer>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<LocalControlServer>());

var host = builder.Build();
var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("kcap.Daemon");

Expand Down
7 changes: 7 additions & 0 deletions src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ public static UnixPtyProcess Spawn(
UnixPtyInterop.unsetenv("CLAUDECODE");
UnixPtyInterop.unsetenv("CLAUDE_CODE_ENTRYPOINT");
UnixPtyInterop.unsetenv("ANTHROPIC_API_KEY");
// Clear any hosted-agent identity/routing the daemon may have inherited (e.g.
// it was started from inside a kcap-tracked session) so the spawned agent gets
// ONLY what extraEnv sets: hosted launches re-add these below; private local
// launches deliberately leave them unset (no mis-tag, native permissions).
UnixPtyInterop.unsetenv("KCAP_AGENT_ID");
UnixPtyInterop.unsetenv("KCAP_RENDERED_AGENT");
UnixPtyInterop.unsetenv("KCAP_DAEMON_URL");

if (extraEnvKeys is not null) {
for (var i = 0; i < extraEnvKeys.Length; i++) {
Expand Down
Loading
Loading