diff --git a/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs b/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs
index 2d3f7b19d..18216a25d 100644
--- a/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs
+++ b/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -253,13 +254,79 @@ public static string AgentTypeFrom(string? agentPath, string? agentNickname) {
return "subagent";
}
+ ///
+ /// The parent's spawn_agent call ids, keyed by the agent_path each spawned, which the call's output
+ /// names as its task_name. A spawn whose output is not written yet is absent
+ ///
+ public static IReadOnlyDictionary ReadSpawnCalls(string parentRolloutPath) {
+ var spawns = new HashSet(StringComparer.Ordinal);
+ var paths = new Dictionary(StringComparer.Ordinal);
+
+ try {
+ using var fs = new FileStream(parentRolloutPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ using var reader = new StreamReader(fs);
+
+ while (reader.ReadLine() is { } line) {
+ if (!line.Contains("spawn_agent", StringComparison.Ordinal) && !line.Contains("task_name", StringComparison.Ordinal)) continue;
+
+ try {
+ using var doc = JsonDocument.Parse(line);
+
+ if (doc.RootElement.Obj("payload") is not { } payload || payload.Str("call_id") is not { } callId) continue;
+
+ switch (payload.Str("type")) {
+ case "function_call" when payload.Str("name") == "spawn_agent":
+ spawns.Add(callId);
+ break;
+ case "function_call_output" when spawns.Contains(callId) && TaskNameOf(payload) is { } agentPath:
+ paths.TryAdd(agentPath, callId);
+ break;
+ }
+ } catch (JsonException) {
+ // a line still being written
+ }
+ }
+ } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
+ return FrozenDictionary.Empty;
+ }
+
+ return paths.Count == 0 ? FrozenDictionary.Empty : paths;
+ }
+
+ /// The call that spawned the agent at , or else the one that spawned its
+ /// nearest spawned ancestor: a grandchild is imported onto the root, where only its ancestor's call is
+ public static string? SpawnCallFor(IReadOnlyDictionary spawnCalls, string? agentPath) {
+ for (var path = agentPath; path is { Length: > 0 }; path = path[..Math.Max(path.LastIndexOf('/'), 0)]) {
+ if (spawnCalls.TryGetValue(path, out var callId)) return callId;
+ }
+
+ return null;
+ }
+
+ static string? TaskNameOf(JsonElement output) {
+ if (output.Str("output") is { } body) {
+ try {
+ using var doc = JsonDocument.Parse(body);
+
+ return doc.RootElement.Str("task_name");
+ } catch (JsonException) {
+ return null;
+ }
+ }
+
+ return output.Obj("output")?.Str("task_name");
+ }
+
// ── Hook payloads (the vendor-agnostic /hooks/subagent-start|stop wire shape) ──
/// /hooks/subagent-start body. cwd is "" — the child inherits the parent
/// session's cwd; the server's HookBase merely requires the (non-null) field
- /// (same posture as Gemini/OpenCode subagents).
- public static JsonObject BuildStartPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) =>
- new() {
+ /// (same posture as Gemini/OpenCode subagents). places the agent on the turn of
+ /// the parent's call that spawned it, wherever the start lands on the parent's stream
+ public static JsonObject BuildStartPayload(
+ string parentSessionId, string agentId, string agentType, string subagentTranscriptPath, string? spawnCallId = null
+ ) {
+ var payload = new JsonObject {
["hook_event_name"] = "subagent_start",
["session_id"] = parentSessionId,
["agent_id"] = agentId,
@@ -268,6 +335,11 @@ public static JsonObject BuildStartPayload(string parentSessionId, string agentI
["cwd"] = "",
};
+ if (spawnCallId is not null) payload["spawn_call_id"] = spawnCallId;
+
+ return payload;
+ }
+
/// /hooks/subagent-stop body.
public static JsonObject BuildStopPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) =>
new() {
diff --git a/src/Capacitor.Cli/Commands/SessionImporter.cs b/src/Capacitor.Cli/Commands/SessionImporter.cs
index d6253edd9..92f5d1e23 100644
--- a/src/Capacitor.Cli/Commands/SessionImporter.cs
+++ b/src/Capacitor.Cli/Commands/SessionImporter.cs
@@ -141,12 +141,15 @@ internal static async Task ImportSessionAsync(
// lets chat/trace nest it), strict transcript delivery, and no subagent-stop after a
// failed tail — a re-import retries (deterministic event ids make that idempotent).
if (isCodex) {
+ var spawnCalls = CodexSubagentDiscovery.ReadSpawnCalls(transcriptPath);
+
foreach (var sub in CodexSubagentDiscovery.EnumerateDescendantRollouts(transcriptPath, sessionId)) {
var subType = CodexSubagentDiscovery.AgentTypeFrom(sub.AgentPath, sub.AgentNickname);
var subAgentId = sub.ChildDashlessId;
if (!await PostSubagentHookAsync(httpClient, baseUrl, "subagent-start",
- CodexSubagentDiscovery.BuildStartPayload(sessionId, subAgentId, subType, sub.FilePath), time)) {
+ CodexSubagentDiscovery.BuildStartPayload(sessionId, subAgentId, subType, sub.FilePath,
+ CodexSubagentDiscovery.SpawnCallFor(spawnCalls, sub.AgentPath)), time)) {
continue;
}
diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs
index 099001c26..65cf572b5 100644
--- a/src/Capacitor.Cli/Commands/WatchCommand.cs
+++ b/src/Capacitor.Cli/Commands/WatchCommand.cs
@@ -1687,6 +1687,8 @@ CancellationToken ct
return; // discovery is best-effort — never break the main drain loop
}
+ IReadOnlyDictionary? spawnCalls = null;
+
foreach (var sub in subs) {
if (ct.IsCancellationRequested) return;
@@ -1703,7 +1705,9 @@ CancellationToken ct
// Fail-closed: register the subagent (→ SubagentStarted) before its child watcher
// streams content. On POST failure, drop from `seen` so the next tick retries.
- if (isNew && !await PostCodexSubagentStartAsync(sessionId, childAgentId, agentType, sub.FilePath, ct)) {
+ if (isNew
+ && !await PostCodexSubagentStartAsync(sessionId, childAgentId, agentType, sub.FilePath,
+ CodexSubagentDiscovery.SpawnCallFor(spawnCalls ??= CodexSubagentDiscovery.ReadSpawnCalls(transcriptPath), sub.AgentPath), ct)) {
seen.Remove(sub.FilePath);
continue;
}
@@ -1717,11 +1721,11 @@ await watchers.EnsureWatcherRunning(key: $"{sessionId}-{childAgentId}", transcri
}
async Task PostCodexSubagentStartAsync(
- string sessionId, string agentId, string agentType, string subFile, CancellationToken ct
+ string sessionId, string agentId, string agentType, string subFile, string? spawnCallId, CancellationToken ct
) {
try {
using var client = await http.ForBackgroundAsync(ct);
- var payload = CodexSubagentDiscovery.BuildStartPayload(sessionId, agentId, agentType, subFile);
+ var payload = CodexSubagentDiscovery.BuildStartPayload(sessionId, agentId, agentType, subFile, spawnCallId);
using var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");
using var resp = await client.PostWithRetryAsync($"{Url}/hooks/subagent-start", content, time, ct: ct);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
index 18c3b75a0..5d4b514eb 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
@@ -1,7 +1,13 @@
+using System.Text.Json.Nodes;
using Capacitor.Cli.Commands;
+using Capacitor.Cli.Core;
+using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Codex;
using Capacitor.Cli.Harness.Codex;
using Capacitor.Cli.PrDetection;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
namespace Capacitor.Cli.Tests.Unit.Harness.Codex;
@@ -241,6 +247,61 @@ await Assert.That(subs.Select(s => s.ChildDashlessId).ToList())
.Contains(Dashless(grandDashed));
}
+ // ── Spawning calls ────────────────────────────────────────────────────
+
+ static string Item(string payload) => $$"""{"timestamp":"2026-08-10T15:21:00.000Z","type":"response_item","payload":{{payload}}}""";
+
+ /// An import posts every subagent's start after the whole parent transcript, so each start names the
+ /// parent's spawn_agent call whose output names the agent's path; a grandchild names its ancestor's call, and an
+ /// agent no spawn output names, here one whose path only another tool's output carries, names none
+ [Test]
+ public async Task An_imported_subagent_start_names_the_call_that_spawned_it_or_its_ancestor() {
+ using var tmp = new TempDir();
+ using var server = WireMockServer.Start();
+ server.Given(Request.Create().UsingAnyMethod()).RespondWith(Response.Create().WithStatusCode(200));
+
+ var day = DayDir(tmp.Path);
+ var parent = WriteParent(day);
+ File.AppendAllLines(parent, [
+ Item("""{"type":"function_call","name":"spawn_agent","namespace":"collaboration","call_id":"call_spawn","arguments":"{\"task_name\":\"spec_quality\"}"}"""),
+ Item("""{"type":"function_call_output","call_id":"call_spawn","output":"{\"task_name\":\"/root/spec_quality\"}"}"""),
+ Item("""{"type":"function_call","name":"exec_command","call_id":"call_exec","arguments":"{}"}"""),
+ Item("""{"type":"function_call_output","call_id":"call_exec","output":"{\"task_name\":\"/root/other\"}"}"""),
+ """{"timestamp":"2026-08-10T15:22:00.000Z","type":"response_item","payload":{"type":"function_call","na""",
+ ]);
+
+ const string grandDashed = "019fec46-51e6-7a03-9f0b-cc6f701a9134";
+ WriteChild(day);
+ WriteChild(day, dashedId: grandDashed, parentDashedId: ChildDashed, agentPath: "/root/spec_quality/deep_dive", stamp: "2026-08-10T17-25-00");
+ WriteChild(day, dashedId: OtherDashed, agentPath: "/root/other", stamp: "2026-08-10T17-26-00");
+
+ using var client = new HttpClient();
+ await SessionImporter.ImportSessionAsync(client, server.Url!, parent, Dashless(ParentDashed),
+ new SessionMetadata { Cwd = "/w" }, encodedCwd: null, TimeProvider.System, vendor: HarnessId.Codex);
+
+ var named = server.LogEntries
+ .Where(e => e.RequestMessage.Path == "/hooks/subagent-start")
+ .Select(e => JsonNode.Parse(e.RequestMessage.Body!)!)
+ .ToDictionary(n => (string)n["agent_id"]!, n => (string?)n["spawn_call_id"]);
+
+ await Assert.That(named).IsEquivalentTo(new Dictionary {
+ [Dashless(ChildDashed)] = "call_spawn",
+ [Dashless(grandDashed)] = "call_spawn",
+ [Dashless(OtherDashed)] = null,
+ });
+ }
+
+ [Test]
+ [Arguments("/root/a", "call_a")]
+ [Arguments("/root/a/b/c", "call_a")]
+ [Arguments("/root/ab", null)]
+ [Arguments(null, null)]
+ public async Task A_path_takes_its_own_spawning_call_or_its_nearest_ancestors(string? agentPath, string? expected) {
+ var calls = new Dictionary { ["/root/a"] = "call_a", ["/root/b"] = "call_b" };
+
+ await Assert.That(CodexSubagentDiscovery.SpawnCallFor(calls, agentPath)).IsEqualTo(expected);
+ }
+
// ── CodexImportSource.DiscoverAsync filter ────────────────────────────
[Test]