Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 92 additions & 0 deletions src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) {

HostedRuntimeStart start;

// Captured BEFORE the spawn so the transcript-based session-id fallback
// (DetectClaudeSessionIdAsync) can filter the shared project dir to files
// written by THIS agent's process, not the user's earlier sessions.
var spawnedAtUtc = DateTime.UtcNow;

try {
start = await runtimeFactory.StartAsync(runtimeCtx, _shutdownCts.Token);
} catch (CodexHooksNotInstalledException ex) {
Expand Down Expand Up @@ -448,6 +453,18 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) {
// Start reading output
_ = ReadAgentOutputAsync(agent);

// Fallback session-id discovery: the primary link (the spawned Claude's
// session-start hook POSTing agent_host_id to /hooks/session-start) silently
// breaks when the hook can't authenticate (e.g. expired kcap token → 401),
// leaving the agent's web chat stuck on "Waiting for session to start..."
// forever while the terminal works. The daemon can discover the session id
// itself from the transcript file Claude writes and report it over its own
// authenticated connection. Claude-vendor only — other vendors have different
// transcript layouts. Best-effort background task, cancelled with the agent.
if (string.Equals(cmd.Vendor, "claude", StringComparison.OrdinalIgnoreCase)) {
_ = DetectClaudeSessionIdAsync(agent, spawnedAtUtc);
}

// Report the resolved model so the server can display the real model the agent
// is running (the dispatched `model` may be the "default" no-override sentinel,
// in which case Codex picks the model from ~/.codex/config.toml). The hub contract
Expand Down Expand Up @@ -1310,6 +1327,72 @@ internal static bool IsStartupFailure(DateTime createdAt, DateTime lastOutputAt,

static readonly HashSet<string> ValidEffortLevels = ["low", "medium", "high", "max"];

static readonly TimeSpan SessionIdPollInterval = TimeSpan.FromSeconds(2);
static readonly TimeSpan SessionIdPollTimeout = TimeSpan.FromMinutes(3);

/// <summary>
/// Background fallback that discovers the spawned Claude's session id from its transcript
/// file and reports it to the server, for when the session-start hook (the primary source
/// of the agent↔session link) fails — e.g. an expired kcap token means every /hooks POST
/// 401s, and the server would otherwise never learn the session id (its recovery path reads
/// an AgentRun heartbeat that only the same hook handler writes).
///
/// Polls the Claude project dir for the agent's worktree (symlinked to the SOURCE repo's
/// project dir, so it's shared with the user's own sessions — see
/// <see cref="SessionTranscriptLocator"/> for how candidates are disambiguated by cwd).
/// On a match it sets <see cref="AgentInstance.SessionId"/> and best-effort reports via
/// AgentStatusChanged (live registry link) AND an <see cref="AgentRunHeartbeat"/> (so the
/// server's restart-recovery FindAgentSessionIdAsync path works too). Once SessionId is
/// set, the regular 30 s heartbeat loop and reconnect re-registration keep re-sending it,
/// so a transient report failure here self-heals. Stops when the id is known by other
/// means, the agent exits (ReadCts), or the daemon shuts down. Never breaks the launch.
/// </summary>
async Task DetectClaudeSessionIdAsync(AgentInstance agent, DateTime spawnedAtUtc) {
try {
var projectDir = ClaudePaths.ProjectDir(agent.Worktree.Path);
var deadline = DateTime.UtcNow + SessionIdPollTimeout;

// Transcripts confirmed to belong to another session (a foreign cwd) are remembered
// here so we don't re-open them every 2 s tick. A session's cwd never changes, so a
// definitive non-match is permanent; files still being written (no cwd yet) are NOT
// added, so the agent's own freshly-created transcript is always re-checked.
var ruledOut = new HashSet<string>();

using var cts = CancellationTokenSource.CreateLinkedTokenSource(agent.ReadCts.Token, _shutdownCts.Token);

while (DateTime.UtcNow < deadline) {
if (agent.SessionId is not null) return; // linked by other means (hook succeeded)

if (SessionTranscriptLocator.TryLocate(projectDir, agent.Worktree.Path, spawnedAtUtc, ruledOut) is { } sessionId) {
agent.SessionId ??= sessionId;

LogSessionIdDetected(agent.Id, sessionId);

if (!agent.IsPrivate) {
// Enqueue the run-event first: it's a non-throwing local enqueue, whereas
// the SignalR status update can throw during a reconnect. Ordering it first
// ensures a transient connection hiccup can't skip the durable report — the
// status update then re-sends via the heartbeat loop regardless.
await _server.AppendAgentRunEventAsync(agent.Id, new AgentRunHeartbeat(sessionId));
await _server.AgentStatusChangedAsync(agent.Id, agent.Status, sessionId);
}
Comment on lines +1371 to +1378

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Fallback reports not independent 🐞 Bug ☼ Reliability

In DetectClaudeSessionIdAsync, an exception from the awaited SignalR call AgentStatusChangedAsync
will skip the subsequent AppendAgentRunEventAsync(AgentRunHeartbeat), delaying the server’s recovery
signal until the normal heartbeat/reconnect paths run. This weakens the “report both ways” behavior
specifically during transient connection instability (one of the main scenarios this fallback should
tolerate).
Agent Prompt
### Issue description
`DetectClaudeSessionIdAsync` awaits `_server.AgentStatusChangedAsync(...)` and then awaits `_server.AppendAgentRunEventAsync(...)` in the same block. If the first call throws (e.g., reconnect in progress), the second call is skipped and the method exits via the outer catch, which delays/weakens the fallback’s intended “report both ways” behavior.

### Issue Context
- `AgentStatusChangedAsync` is a SignalR `InvokeAsync` and can throw on disconnect / reconnect windows.
- `AppendAgentRunEventAsync` is a local enqueue into a bounded channel and is effectively non-throwing.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1360-1368]

Suggested implementation approach:
- Ensure the HTTP-backed run-event path is executed even if the SignalR call fails:
  - Either enqueue `AppendAgentRunEventAsync(... new AgentRunHeartbeat(sessionId))` *before* calling `AgentStatusChangedAsync`, and/or
  - Wrap each call in its own try/catch so one failure doesn’t prevent the other.
- Keep the method best-effort (no rethrow), but avoid losing the run-event append when the status update fails.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e00d306. Reordered so the non-throwing AppendAgentRunEventAsync enqueue runs before the AgentStatusChangedAsync SignalR call — a throw during a reconnect can no longer skip the durable run-event append.


return;
}

await Task.Delay(SessionIdPollInterval, cts.Token);
}

LogSessionIdNotDetected(agent.Id, SessionIdPollTimeout.TotalSeconds);
} catch (OperationCanceledException) {
// Agent stopped or daemon shutting down — nothing to report.
} catch (Exception ex) {
// Best-effort by design: if the immediate report failed after SessionId was set,
// the heartbeat loop / reconnect re-registration will re-send it.
LogSessionIdDetectFailed(ex, agent.Id);
}
}

async Task RunHeartbeatLoopAsync(CancellationToken ct) {
while (await _heartbeatTimer.WaitForNextTickAsync(ct)) {
// PrivateLocal agents get no heartbeats and no stuck-Starting auto-stop (deny-all;
Expand Down Expand Up @@ -1540,6 +1623,15 @@ static string ExtractTerminalText(TerminalOutputBuffer buffer) {
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to persist repo path for agent {AgentId}")]
partial void LogRepoPathPersistFailed(Exception ex, string agentId);

[LoggerMessage(Level = LogLevel.Information, Message = "Agent {AgentId} linked to session {SessionId} via its transcript file (session-start hook fallback)")]
partial void LogSessionIdDetected(string agentId, string sessionId);

[LoggerMessage(Level = LogLevel.Information, Message = "No transcript matched agent {AgentId}'s worktree within {Seconds:F0}s; session id stays hook-provided (or unknown if the hook failed)")]
partial void LogSessionIdNotDetected(string agentId, double seconds);

[LoggerMessage(Level = LogLevel.Warning, Message = "Transcript-based session-id detection failed for agent {AgentId} (continuing; the session-start hook remains the primary link)")]
partial void LogSessionIdDetectFailed(Exception ex, string agentId);

[GeneratedRegex(@"\x1B\[[0-9;]*[A-Za-z]|\x1B\].*?\x07|\x1B[()][AB012]|\x1B\[[\?]?[0-9;]*[hlm]")]
private static partial Regex StripAnsiRegex();

Expand Down
208 changes: 208 additions & 0 deletions src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
using System.Text.Json.Nodes;

namespace Capacitor.Cli.Daemon.Services;

/// <summary>
/// Discovers the session id of a freshly spawned hosted Claude agent by scanning the
/// Claude Code project directory for the transcript file the agent writes.
///
/// Why this exists: the web chat can only attach to a hosted agent once the server's
/// registry entry has a SessionId, and until now the ONLY source of that link was the
/// spawned Claude's session-start hook POSTing to <c>/hooks/session-start</c>. When that
/// hook fails (e.g. an expired kcap token → 401), the link is never made and the agent
/// page shows "Waiting for session to start..." forever even though the terminal works.
/// The daemon itself can make the link instead: Claude Code writes its transcript to
/// <c>{ClaudePaths.Projects}/{project-dir-hash}/{session-id}.jsonl</c>, so polling that
/// directory and reporting the discovered id over the daemon's own (authenticated)
/// SignalR connection is a hook-independent fallback.
///
/// Disambiguation: the daemon symlinks the worktree's project dir to the SOURCE repo's
/// project dir (see <c>ClaudeLauncher.SymlinkClaudeProjectDir</c>), which is shared with
/// the user's own sessions in that repo — so a new <c>.jsonl</c> there is not necessarily
/// this agent's. Candidates are verified by reading the first lines of the file and
/// requiring the JSON <c>"cwd"</c> field to equal the agent's worktree path (the worktree
/// is created per-agent, so a cwd match is definitive).
///
/// The decision logic (cwd matching, filename → session id parsing, timestamp filtering)
/// is pure and unit-tested without a filesystem; only <see cref="TryLocate"/> touches disk.
/// </summary>
internal static class SessionTranscriptLocator {
/// <summary>
/// How many non-blank transcript lines to inspect for a <c>cwd</c> match before
/// giving up on a candidate file. The cwd appears on the very first lines of a
/// transcript, so 50 is generous while still bounding the read.
/// </summary>
public const int MaxLinesToInspect = 50;

/// <summary>
/// Slack applied to the spawn-time filter to absorb filesystem timestamp
/// granularity (e.g. FAT's 2 s) and small clock quirks. Safe to be generous:
/// the cwd check — against a worktree path created for this specific agent —
/// is the real disambiguator; the time filter only avoids re-reading old files.
/// </summary>
static readonly TimeSpan FileTimeSkewTolerance = TimeSpan.FromSeconds(5);

/// <summary>
/// Scans <paramref name="projectDir"/> for a transcript written at/after
/// <paramref name="spawnedAtUtc"/> whose early lines report <c>cwd</c> equal to
/// <paramref name="worktreePath"/>. Returns the normalized (dashless, lowercase)
/// session id, or null when no candidate matches yet. Best-effort: unreadable or
/// malformed candidates are skipped, never thrown.
/// </summary>
/// <param name="ruledOut">
/// Optional set of file paths already confirmed to belong to another session (or to not be a
/// transcript at all). Since the caller polls every few seconds over a shared project dir,
/// caching definitive non-matches here avoids re-opening the user's own concurrently-written
/// sessions on every tick. Only <em>definitive</em> non-matches are added: a file whose name
/// isn't a session id, or one whose <c>cwd</c> is present but foreign (a cwd never changes).
/// Files with no <c>cwd</c> yet (still being written) are left out so the agent's own
/// freshly-created transcript is always re-checked.
/// </param>
public static string? TryLocate(string projectDir, string worktreePath, DateTime spawnedAtUtc, ISet<string>? ruledOut = null) {
if (!Directory.Exists(projectDir)) return null;

foreach (var file in Directory.EnumerateFiles(projectDir, "*.jsonl")) {
if (ruledOut?.Contains(file) == true) continue;

try {
if (SessionIdFromFileName(file) is not { } sessionId) {
ruledOut?.Add(file); // not a session transcript — its name won't change
continue;
}

if (!IsNewEnough(File.GetCreationTimeUtc(file), File.GetLastWriteTimeUtc(file), spawnedAtUtc)) continue;

switch (MatchTranscript(ReadFirstLines(file), worktreePath, DefaultPathComparison)) {
case CwdMatch.Yes: return sessionId;
case CwdMatch.No: ruledOut?.Add(file); break; // another session's transcript — cwd is fixed
// CwdMatch.Unknown: no cwd yet (file still being written) — leave uncached, re-check next tick.
}
} catch {
// Candidate vanished mid-scan, is locked, or is otherwise unreadable — skip it;
// the caller polls, so a transiently unreadable match is retried next tick.
}
}
Comment on lines +64 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Repeated transcript directory scans 🐞 Bug ➹ Performance

DetectClaudeSessionIdAsync polls every 2 seconds for up to 3 minutes, and each tick
SessionTranscriptLocator.TryLocate enumerates all *.jsonl files and may open/read up to 50 lines
per candidate, which can create avoidable sustained IO/CPU overhead in large shared Claude project
directories. This can degrade daemon responsiveness during the polling window on machines with many
transcript files.
Agent Prompt
### Issue description
The fallback performs repeated full-directory scans of `*.jsonl` transcripts (and potentially reads up to 50 lines from many files) every 2 seconds for up to 3 minutes. On large project dirs, this can cause unnecessary repeated work and noticeable IO/CPU churn.

### Issue Context
The logic is time-bounded (3 minutes) and per-file line-bounded (50), but it is not bounded by number of files inspected per tick, and it does not avoid re-checking files already known to be non-matches.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1330-1374]
- src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[52-66]

Suggested implementation approach (pick one or combine):
- Prefer inspecting newest files first and cap per tick (e.g., order by `LastWriteTimeUtc` desc and `Take(N)`), since the target transcript should be among the newest after spawn.
- Cache negative candidates across ticks (e.g., keep a `HashSet<string>` of inspected filenames or track `lastSeenWriteTime`) to avoid re-opening the same files repeatedly.
- If you keep a time filter, apply it before opening files, and short-circuit scanning as soon as you hit sufficiently old files when iterating in descending timestamp order.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e00d306. TryLocate now takes a negative-cache set and only remembers definitive non-matches (non-session filename, or a present-but-foreign cwd — a cwd never changes), so foreign transcripts arent re-opened every tick. Introduced a tri-state MatchTranscript: files with no cwd yet return Unknown and are deliberately NOT cached, so the agents own freshly-created transcript is still re-checked each tick. Kept the newest-first/Take(N) approach off the table since a wrong N could skip the real match.


return null;
}

/// <summary>
/// True when any of the first <see cref="MaxLinesToInspect"/> non-blank lines is a JSON
/// object whose <c>cwd</c> equals <paramref name="worktreePath"/>. Tolerates partial or
/// invalid JSON lines (skipped — the file is being written concurrently) and normalizes
/// both paths (separator style, trailing separators) before comparing.
/// </summary>
/// <param name="comparison">
/// Path comparison; defaults to case-insensitive on Windows, case-sensitive elsewhere.
/// Injectable so tests can pin either behaviour regardless of the OS they run on.
/// </param>
public static bool TryMatchTranscript(IEnumerable<string> lines, string worktreePath, StringComparison? comparison = null)
=> MatchTranscript(lines, worktreePath, comparison ?? DefaultPathComparison) == CwdMatch.Yes;

/// <summary>Outcome of inspecting a transcript's early lines for a <c>cwd</c>.</summary>
internal enum CwdMatch {
/// <summary>A <c>cwd</c> equal to the worktree path was found — this is the agent's transcript.</summary>
Yes,
/// <summary>A <c>cwd</c> was found but it belongs to a different session — a definitive, permanent non-match.</summary>
No,
/// <summary>No parseable <c>cwd</c> in the inspected lines — the file may still be being written; re-check later.</summary>
Unknown,
}

/// <summary>
/// Classifies a candidate transcript by scanning up to <see cref="MaxLinesToInspect"/> non-blank
/// lines for a JSON <c>cwd</c>. Returns <see cref="CwdMatch.Yes"/> on the first line whose <c>cwd</c>
/// equals <paramref name="worktreePath"/>, <see cref="CwdMatch.No"/> if a <c>cwd</c> was seen but none
/// matched, or <see cref="CwdMatch.Unknown"/> if no <c>cwd</c> was seen at all. Partial or invalid
/// JSON lines are tolerated (skipped — the file is being written concurrently) and both paths are
/// normalized (separator style, trailing separators) before comparing.
/// </summary>
internal static CwdMatch MatchTranscript(IEnumerable<string> lines, string worktreePath, StringComparison comparison) {
var target = NormalizePath(worktreePath);

var inspected = 0;
var sawCwd = false;

foreach (var line in lines) {
if (inspected >= MaxLinesToInspect) break;
if (string.IsNullOrWhiteSpace(line)) continue;

inspected++;

string? cwd;

try {
cwd = JsonNode.Parse(line)?["cwd"]?.GetValue<string>();
} catch {
continue; // partial/invalid JSONL line (or non-string cwd) — tolerate and move on
}

if (cwd is null) continue;

sawCwd = true;

if (string.Equals(NormalizePath(cwd), target, comparison)) return CwdMatch.Yes;
}

return sawCwd ? CwdMatch.No : CwdMatch.Unknown;
}

/// <summary>
/// Derives the session id from a transcript file name: strips <c>.jsonl</c>, removes
/// dashes and lowercases — the same canonical form the CLI's <c>NormalizeGuidField</c>
/// produces for hook session ids. Returns null unless the stem is a GUID (32 hex chars
/// once dashes are removed), so stray non-session <c>.jsonl</c> files never match.
/// </summary>
public static string? SessionIdFromFileName(string fileNameOrPath) {
var name = Path.GetFileName(fileNameOrPath);

if (!name.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase)) return null;

var stem = name[..^".jsonl".Length];
var normalized = stem.Replace("-", "").ToLowerInvariant();

if (normalized.Length != 32) return null;

foreach (var c in normalized) {
if (!Uri.IsHexDigit(c)) return null;
}

return normalized;
}

/// <summary>
/// True when the file's newest timestamp (creation or last write) is at/after the
/// agent's spawn time, minus <see cref="FileTimeSkewTolerance"/>. Filters out the
/// user's own pre-existing sessions in the (shared, symlinked) project dir.
/// </summary>
public static bool IsNewEnough(DateTime creationTimeUtc, DateTime lastWriteTimeUtc, DateTime spawnedAtUtc) {
var newest = creationTimeUtc > lastWriteTimeUtc ? creationTimeUtc : lastWriteTimeUtc;

return newest >= spawnedAtUtc - FileTimeSkewTolerance;
}

static StringComparison DefaultPathComparison
=> OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

/// <summary>Unifies separator style and strips trailing separators so
/// <c>C:\w\t\</c>, <c>C:/w/t</c> and <c>C:\w\t</c> all compare equal.</summary>
static string NormalizePath(string path) => path.Replace('\\', '/').TrimEnd('/');

/// <summary>
/// Reads up to <see cref="MaxLinesToInspect"/> lines with <see cref="FileShare.ReadWrite"/>
/// (Claude is appending to the file while we read) into memory, so the pure matcher never
/// holds a file handle across JSON parsing.
/// </summary>
static List<string> ReadFirstLines(string path) {
var lines = new List<string>(capacity: 16);

using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var reader = new StreamReader(fs);

while (lines.Count < MaxLinesToInspect && reader.ReadLine() is { } line) {
lines.Add(line);
}

return lines;
}
}
Loading