diff --git a/CLI/CMUXCLI+AgentHookPayload.swift b/CLI/CMUXCLI+AgentHookPayload.swift index c6254bcd6f2e..58d1685b030b 100644 --- a/CLI/CMUXCLI+AgentHookPayload.swift +++ b/CLI/CMUXCLI+AgentHookPayload.swift @@ -77,7 +77,7 @@ extension CMUXCLI { var compact: [String: Any] = [:] for key in [ - "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "transcript_path", "transcriptPath", + "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "transcript_path", "transcriptPath", "agent_id", "agentId", "permission_mode", "permissionMode", "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "assistant_response", "assistantResponse", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "terminationReason", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6e26646c7696..0e3489fe74c8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -35216,6 +35216,84 @@ export default CMUXSessionRestore; ) case .codexSubagentStart, .codexSubagentStop: + if def.name == "omp" || def.name == "pi" { + // Headless nested-child lifecycle: OMP/Pi subagents run inside + // the parent's process with no live bound process of their own, + // so attribute by session identity to the existing parent record + // only. Never upsert the session store, publish resume bindings + // or pids, or supersede sibling sessions; with no live target + // the event still journals unattributed. + let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) + let target = resolveAgentHookTarget(mapped: mapped) + if target == nil { + reportTargetResolutionFailure() + } + // Suppress the generic defer telemetry: it mints a fresh request + // id per event, which would fork a duplicate child. The frame + // below carries the stable child id instead. + didSendFeedTelemetry = true + let agentId = input.rawObject.flatMap { + firstString(in: $0, keys: ["agent_id", "agentId"]) + } ?? input.object.flatMap { + firstString(in: $0, keys: ["agent_id", "agentId"]) + } + let childLabel = input.rawObject.flatMap { + firstString(in: $0, keys: ["description"]) + } ?? input.object.flatMap { + firstString(in: $0, keys: ["description"]) + } + let childStarts = { + if case .codexSubagentStart = action { return true } + return false + }() + if !sessionId.isEmpty, + let workstreamID = Self.feedWorkstreamID(source: def.name, sessionID: sessionId) { + var childEvent: [String: Any] = [ + "session_id": workstreamID, + "hook_event_name": childStarts ? "SubagentStart" : "SubagentStop", + "_source": def.name, + ] + if let agentId { + childEvent["_opencode_request_id"] = agentId + } + if let workspaceId = target?.workspaceId { + childEvent["workspace_id"] = workspaceId + } + if let surfaceId = target?.surfaceId, !surfaceId.isEmpty { + childEvent["surface_id"] = surfaceId + } + if let cwd = hookCwd, !cwd.isEmpty { + childEvent["cwd"] = cwd + } + if childStarts, let childLabel { + childEvent["tool_input"] = ["description": childLabel] + } + let frame: [String: Any] = [ + "method": "feed.push", + "params": [ + "event": childEvent, + "wait_timeout_seconds": 0, + ], + ] + if let data = try? JSONSerialization.data(withJSONObject: frame), + let line = String(data: data, encoding: .utf8) { + sendBestEffortFeedTelemetry( + socketPath: client.socketPath, + line: line, + socketPassword: socketPassword + ) + } + } + emitJournal( + childStarts ? .childSpawned : .childCompleted, + workspaceId: target?.workspaceId, + surfaceId: target?.surfaceId, + unattributedReason: target == nil ? "target-unresolved" : nil, + isSubagent: true, + responseTimeout: target == nil ? 0.5 : nil + ) + break + } guard def.name == "codex", let codexLifecycle else { break } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift index edb35831c5fb..24524ebc1f7a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift @@ -51,6 +51,12 @@ public struct AgentHookDeliveryPolicy: Sendable { "amp": ["title-update", "lifecycle"], "claude": ["pre-tool-use", "push-notification", "feed"], "codex": ["pre-tool-use", "post-tool-use"], + // OMP and Pi run subagents headless inside the parent's process, so a + // child has no live bound process of its own. These lifecycle-only + // events attribute to the existing parent record and never affect the + // agent's next decision. + "omp": ["subagent-start", "subagent-stop"], + "pi": ["subagent-start", "subagent-stop"], ] /// Creates the shared queued-delivery policy. diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift index 695e80cb1e03..300b23892be7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift @@ -32,6 +32,19 @@ struct AgentHookDeliveryPolicyTests { )) } + @Test("OMP and Pi headless subagent lifecycle is queue-safe") + func ompPiSubagentEventsSupportQueuedDelivery() { + for agent in ["omp", "pi"] { + #expect(policy.supportsQueuedDelivery(agent: agent, subcommand: "subagent-start")) + #expect(policy.supportsQueuedDelivery(agent: agent, subcommand: "subagent-stop")) + } + // Scoped to the headless wrappers: never a generic allowance. + #expect(!policy.supportsQueuedDelivery(agent: "future-agent", subcommand: "subagent-start")) + #expect(!policy.supportsQueuedDelivery(agent: "future-agent", subcommand: "subagent-stop")) + #expect(!policy.supportsQueuedDelivery(agent: "claude", subcommand: "subagent-start")) + #expect(!policy.supportsQueuedDelivery(agent: "codex", subcommand: "subagent-start")) + } + @Test("Agent names produce stable ASCII PID environment keys") func pidEnvironmentKey() { #expect(policy.pidEnvironmentVariable(agentName: "claude") == "CMUX_CLAUDE_PID") diff --git a/cmuxTests/CLIOmpHookBindingTests.swift b/cmuxTests/CLIOmpHookBindingTests.swift index 6f16c0f6e93e..3c1403a3a06c 100644 --- a/cmuxTests/CLIOmpHookBindingTests.swift +++ b/cmuxTests/CLIOmpHookBindingTests.swift @@ -437,6 +437,160 @@ struct CLIOmpHookBindingTests { #expect(sessions[currentSessionId] != nil) } + @Test + func ompSubagentStartDispatchesFeedPushAndChildJournal() throws { + let context = try Harness.makeContext(name: "omp-subagent-start") + defer { context.cleanup() } + let parentSessionId = "omp-subagent-parent" + let childId = "kid-7" + let childLabel = "Probe child" + try Self.writePriorSession( + to: context.root.appendingPathComponent("omp-hook-sessions.json"), + sessionId: parentSessionId, + workspaceId: Self.liveWorkspaceId, + surfaceId: Self.liveSurfaceId, + cwd: context.root.path + ) + let serverHandled = Harness.startDeliveryTargetServer( + context: context, + surfacesByWorkspace: [Self.liveWorkspaceId: [Self.liveSurfaceId]], + pidTarget: (workspaceId: Self.liveWorkspaceId, surfaceId: Self.liveSurfaceId) + ) + var environment = Harness.hookEnvironment(context: context) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = context.root.path + environment["CMUX_WORKSPACE_ID"] = Self.liveWorkspaceId + environment["CMUX_SURFACE_ID"] = Self.liveSurfaceId + environment["CMUX_OMP_PID"] = String(Self.ompPID) + + let result = Harness.runHookProcess( + context: context, + arguments: ["hooks", "omp", "subagent-start"], + environment: environment, + standardInput: #"{"session_id":"\#(parentSessionId)","agent_id":"\#(childId)","description":"\#(childLabel)","hook_event_name":"SubagentStart"}"# + ) + + #expect(serverHandled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + + let event = try #require(Self.feedPushEvent(in: context, hookEventName: "SubagentStart")) + #expect(event["_opencode_request_id"] as? String == childId) + #expect((event["tool_input"] as? [String: Any])?["description"] as? String == childLabel) + #expect(event["workspace_id"] as? String == Self.liveWorkspaceId) + #expect(event["surface_id"] as? String == Self.liveSurfaceId) + let workstream = try #require(event["session_id"] as? String) + let components = try #require(Self.decodeWorkstream(rawValue: workstream)) + #expect(components.agent == "omp") + #expect(components.session == parentSessionId) + + let journal = try #require( + AgentJournalAppendCapture.first( + in: context.state.snapshot(), + kind: "agent.child.spawned", + agentKey: "omp" + ) + ) + #expect(journal.isSubagent) + #expect(journal.workspaceId == Self.liveWorkspaceId) + #expect(journal.surfaceId == Self.liveSurfaceId) + } + + @Test + func piSubagentStopDispatchesFeedPushAndChildJournal() throws { + let context = try Harness.makeContext(name: "pi-subagent-stop") + defer { context.cleanup() } + let parentSessionId = "pi-subagent-parent" + let childId = "kid-9" + try Self.writePriorSession( + to: context.root.appendingPathComponent("pi-hook-sessions.json"), + sessionId: parentSessionId, + workspaceId: Self.liveWorkspaceId, + surfaceId: Self.liveSurfaceId, + cwd: context.root.path + ) + let serverHandled = Harness.startDeliveryTargetServer( + context: context, + surfacesByWorkspace: [Self.liveWorkspaceId: [Self.liveSurfaceId]], + pidTarget: (workspaceId: Self.liveWorkspaceId, surfaceId: Self.liveSurfaceId), + surfaceTargets: [Self.liveSurfaceId: Self.liveWorkspaceId] + ) + var environment = Harness.hookEnvironment(context: context) + environment["CMUX_AGENT_HOOK_STATE_DIR"] = context.root.path + environment["CMUX_WORKSPACE_ID"] = Self.liveWorkspaceId + environment["CMUX_SURFACE_ID"] = Self.liveSurfaceId + environment["CMUX_AGENT_LAUNCH_KIND"] = "pi" + environment["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/usr/local/bin/pi" + environment["CMUX_AGENT_LAUNCH_ARGV_B64"] = Self.base64NULSeparated(["/usr/local/bin/pi"]) + environment["CMUX_AGENT_LAUNCH_CWD"] = context.root.path + + let result = Harness.runHookProcess( + context: context, + arguments: [ + "hooks", "pi", "subagent-stop", + "--workspace", Self.liveWorkspaceId, + "--surface", Self.liveSurfaceId, + ], + environment: environment, + standardInput: #"{"session_id":"\#(parentSessionId)","agent_id":"\#(childId)","hook_event_name":"SubagentStop"}"# + ) + + #expect(serverHandled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let targetOutput = try #require(Self.jsonObject(result.stdout)) + #expect(targetOutput["workspace_id"] as? String == Self.liveWorkspaceId) + #expect(targetOutput["surface_id"] as? String == Self.liveSurfaceId) + + let event = try #require(Self.feedPushEvent(in: context, hookEventName: "SubagentStop")) + #expect(event["_opencode_request_id"] as? String == childId) + #expect(event["tool_input"] == nil) + let workstream = try #require(event["session_id"] as? String) + let components = try #require(Self.decodeWorkstream(rawValue: workstream)) + #expect(components.agent == "pi") + #expect(components.session == parentSessionId) + + let journal = try #require( + AgentJournalAppendCapture.first( + in: context.state.snapshot(), + kind: "agent.child.completed", + agentKey: "pi" + ) + ) + #expect(journal.isSubagent) + #expect(journal.workspaceId == Self.liveWorkspaceId) + #expect(journal.surfaceId == Self.liveSurfaceId) + } + + private static func feedPushEvent( + in context: Harness.Context, + hookEventName: String + ) -> [String: Any]? { + let requests = context.state.snapshot().compactMap(Self.jsonObject) + for request in requests { + guard request["method"] as? String == "feed.push", + let params = request["params"] as? [String: Any], + let event = params["event"] as? [String: Any], + event["hook_event_name"] as? String == hookEventName + else { continue } + return event + } + return nil + } + + private static func decodeWorkstream(rawValue: String) -> (agent: String, session: String)? { + let prefix = "cmux-feed-v1:" + guard rawValue.hasPrefix(prefix) else { return nil } + let parts = rawValue.dropFirst(prefix.count).split(separator: ":", maxSplits: 1) + guard parts.count == 2, + let agentData = Data(base64Encoded: String(parts[0])), + let agent = String(data: agentData, encoding: .utf8), + let sessionData = Data(base64Encoded: String(parts[1])), + let session = String(data: sessionData, encoding: .utf8) + else { return nil } + return (agent, session) + } + private static func writePriorSession( to storeURL: URL, sessionId: String, diff --git a/docs/custom-sidebars.md b/docs/custom-sidebars.md index 818d30075628..3107657bdf88 100644 --- a/docs/custom-sidebars.md +++ b/docs/custom-sidebars.md @@ -304,7 +304,17 @@ with: current working/needs-input state began), `title` (first user prompt), `panelId` (the hosting terminal's `tabs[k].id`), `surfaceId` (the hosting tab's `tabs[k].surfaceId`, accepted by `surface.focus`), `directory`, - `transcriptPath`, and `pid`. + `transcriptPath`, `pid`, and `children` (nested subagent runs under the + session, oldest first; omitted when none). Each `children[k]` has `id` + (stable for the child's lifetime), `running` (Bool), and `startedEpoch`; + when available it adds `label` and `endedEpoch` (set when the child + settles; settled children are pruned after a short retention). Headless + OMP/Pi subagents run inside the parent's process, so they appear here via + `cmux hooks omp|pi subagent-start|subagent-stop` with JSON + `{"session_id": "", "agent_id": "", + "description": ""}`: start opens the child on the parent + record, stop closes it by `agent_id` (or the oldest running child when the + id is absent). - `tabs` (per workspace) — array of surfaces. Always: `id`, `title`, `focused` (Bool), `pinned` (Bool). When available: `directory`, `branch` + `dirty`, `ports` (array of Int).