From ff965ba977ea0419eebd18e9221af78a06a746f9 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 19:24:44 -0700 Subject: [PATCH] Merge nucleic/upbeat-ember-urchin-80xw into dev --- .../Models/RemoteIntelligence.swift | 4 +- .../Views/IntelligenceRail.swift | 2 +- .../Views/IntelligenceRoute.swift | 2 +- .../Views/Transcript/SandboxToolDisplay.swift | 4 +- .../Views/Transcript/ToolGroupRow.swift | 97 +++++++++++-------- .../Transcript/TranscriptProjection.swift | 18 +++- .../NucleicRemote/Views/TranscriptRow.swift | 4 +- 7 files changed, 75 insertions(+), 56 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift index 3a5a09d..0f0e5bd 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift @@ -86,7 +86,7 @@ extension RemoteStore { } /// Turn Orchestra on for an open chat. It rides the existing effort sentinel rather than a - /// stop of its own (the host resolves the sentinel to xhigh plus standing subagent consent), + /// stop of its own (the host resolves the sentinel to xhigh plus standing worker consent), /// so this is the ordinary effort verb — which also means an older host honors it. func setSessionOrchestra(_ id: SessionID) { setSessionEffort(id, modelCatalog.orchestraSentinelOrFallback) @@ -169,7 +169,7 @@ extension RemoteStore { defaultLevel: 3, orchestraDisplayName: "Orchestra", orchestraBlurb: - "Max, plus managed subagents: the work fans out to parallel workers and one " + "Max, plus managed workers: the work fans out in parallel and one " + "supervisor pulls the results together. Nucleic Control projects only.", orchestraAvailable: true) } diff --git a/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift b/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift index 52df1a2..b6fbcc1 100644 --- a/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift +++ b/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift @@ -527,7 +527,7 @@ struct IntelligenceRail: View { + "thorough results and cost more. Swipe up or down to change it." if orchestraAvailable { hint += " The stop past \(catalog.displayName(forLevel: maxRawLevel)) is " - + "\(catalog.orchestraDisplayName), which fans work out to parallel subagents." + + "\(catalog.orchestraDisplayName), which fans work out to parallel workers." } return hint } diff --git a/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift index 9bf26d9..e421dae 100644 --- a/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift +++ b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift @@ -47,7 +47,7 @@ struct IntelligenceRouteLine: View { /// which the rail above already shows. Orchestra states its mode rather than a pair — the /// supervisor model isn't the interesting fact there. private var summary: String { - if orchestra { return "Parallel subagents" } + if orchestra { return "Parallel workers" } if let unavailableReason { return unavailableReason.localizedCapitalized } guard let model, let effort else { return "" } return "\(modelCatalog.displayName(model)) · " diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift index b16fb35..0407758 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift @@ -39,7 +39,7 @@ enum SandboxToolDisplay { case "linux_vm_exec", "linux_vm_control": "Linux VM" case "linux_vm_computer", "linux_vm_computer_batch": "Linux VM screen" case "linux_container": "Linux container" - case "nucleic_supervise": "Supervising subagents" + case "nucleic_supervise": "Supervising workers" case AskUserQuestion.nucleicToolName: "Question for you" case "nucleic_knowledge_search": "Search project knowledge" case "nucleic_knowledge_get": "Read project knowledge" @@ -73,7 +73,7 @@ enum SandboxToolDisplay { case "mac_vm_request_operator": "Waiting for you to drive the VM…" case "mac_vm_control": "Managing the macOS VM…" case "linux_vm_control": "Managing the Linux VM…" - case "nucleic_supervise": "Waiting on subagents…" + case "nucleic_supervise": "Waiting on workers…" case AskUserQuestion.nucleicToolName: "Waiting on your answer…" case "nucleic_knowledge_search": "Searching project knowledge…" case "nucleic_knowledge_get": "Reading project knowledge…" diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 54c2320..5453cec 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -29,7 +29,7 @@ enum ToolRailMetrics { } /// The hairline a run of tool lines hangs off. Draws no fill and no border: nested inside a gold -/// subagent card it adds a rule, not another box. +/// delegation card it adds a rule, not another box. struct ToolRail: View { /// Overrides the hairline colour — the danger tint when a call in the run failed. var tint: Color? = nil @@ -228,22 +228,24 @@ struct ToolBlockCard: View { } } -// MARK: - Subagent & multi-agent cards +// MARK: - Delegation & multi-agent cards // -// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated work in -// the orchestra gold so it reads distinctly from the agent's own tool calls: a lone `Task` -// becomes a `SubagentCard`, and a parallel wave of them becomes an `OrchestrationCard`. Both -// build from the projection's nested `children` (a subagent's inner Read/Bash/Edit/thinking), -// mirroring the Mac at phone fidelity. +// Two things land here: Claude Code's own subagents, spawned through the `Task` tool, and +// Nucleic's managed workers, spawned through `nucleic_worker`. Both are delegated work, so both +// get the orchestra gold that sets them apart from the agent's own tool calls: a lone spawn +// becomes a `SubagentCard` (which names itself Worker or Subagent accordingly), and a parallel +// wave becomes an `OrchestrationCard`. Both build from the projection's nested `children` (the +// delegate's inner Read/Bash/Edit/thinking), mirroring the Mac at phone fidelity. -/// The lifecycle state of a spawned subagent, read from its `Task` group: not finished → still -/// working; an error result → failed; otherwise → done. -enum SubagentRunState { +/// The lifecycle state of a spawned agent, read from its group: not finished → still working; +/// an error result → failed; otherwise → done. Shared by the worker and `Task` cards, so the +/// two speak one status vocabulary. +enum WorkerRunState { case running, done, failed init(group: ToolGroup) { if !group.finished { self = .running } - // A `Task` subagent surfaces failure as an error result. A `nucleic_subagent` worker + // A `Task` subagent surfaces failure as an error result. A `nucleic_worker` worker // instead returns a *non-error* result whose JSON envelope carries `{"ok": false}` (or // `{"denied": true}`) in-band, so read that too — otherwise a failed worker reads "Done". else if group.isError || Subagent.workerFailed(group) { self = .failed } @@ -276,7 +278,7 @@ enum Subagent { return (raw?.isEmpty == false) ? raw : nil } - /// The one-line task the parent handed the subagent: a `nucleic_subagent` worker's `task` + /// The one-line task the parent handed the subagent: a `nucleic_worker` worker's `task` /// label, or a `Task` subagent's `description`, falling back to the full `prompt`, then a /// generic label. static func taskLabel(_ group: ToolGroup) -> String { @@ -305,7 +307,7 @@ enum Subagent { /// The subagent's returned report, or nil while it's still working / reported nothing. static func report(_ group: ToolGroup) -> String? { guard let result = group.result else { return nil } - // A `nucleic_subagent` worker returns a JSON envelope — surface its actual `output` (or + // A `nucleic_worker` worker returns a JSON envelope — surface its actual `output` (or // failure `message`) rather than a one-line gist of the raw `{ok,session_id,…}` object. A // `Task` subagent returns prose, which falls through unchanged — via `resultText`, so a // report delivered as an MCP content array reads as the report and not as "[1 items]". @@ -314,7 +316,7 @@ enum Subagent { return text.isEmpty ? nil : text } - /// Whether a `nucleic_subagent` worker result reports failure — a `{"ok": false}` completion + /// Whether a `nucleic_worker` worker result reports failure — a `{"ok": false}` completion /// or a `{"denied": true}` refusal, both delivered as non-error tool results. False for any /// other tool (a `Task` subagent, whose failure is an error result handled separately). static func workerFailed(_ group: ToolGroup) -> Bool { @@ -325,7 +327,7 @@ enum Subagent { return false } - /// The human-facing text of a `nucleic_subagent` worker envelope — the worker's `output` on + /// The human-facing text of a `nucleic_worker` worker envelope — the worker's `output` on /// success, or its `message` on failure/denial — or nil when `text` isn't such an envelope /// (so a `Task` subagent's prose report falls through unchanged). private static func workerText(_ text: String) -> String? { @@ -338,7 +340,7 @@ enum Subagent { : (envelope["message"] as? String ?? "") } - /// Parses a `nucleic_subagent` result string into its JSON envelope, gated on the envelope's + /// Parses a `nucleic_worker` result string into its JSON envelope, gated on the envelope's /// signature keys (`ok` / `denied`) so an arbitrary JSON-shaped tool result isn't mistaken for /// one. Nil for anything that isn't a worker envelope. private static func workerEnvelope(_ text: String) -> [String: Any]? { @@ -350,10 +352,10 @@ enum Subagent { } } -/// A small leading status glyph for a subagent — a gold spinner while it works, a green check or +/// A small leading status glyph for a worker — a gold spinner while it works, a green check or /// red cross once it settles. -struct SubagentStatusGlyph: View { - let state: SubagentRunState +struct WorkerStatusGlyph: View { + let state: WorkerRunState var body: some View { Group { switch state { @@ -366,12 +368,12 @@ struct SubagentStatusGlyph: View { } } -/// A status pill (spinner / check / cross + word) for a subagent card header. -struct SubagentStatusChip: View { - let state: SubagentRunState +/// A status pill (spinner / check / cross + word) for the header of a worker card. +struct WorkerStatusChip: View { + let state: WorkerRunState var body: some View { HStack(spacing: 4) { - SubagentStatusGlyph(state: state).frame(width: 14) + WorkerStatusGlyph(state: state).frame(width: 14) Text(state.label) } .font(.caption2.weight(.medium)) @@ -381,14 +383,16 @@ struct SubagentStatusChip: View { } } -/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a live -/// Working / Done / Failed status, and — on expand — its nested activity stream (or, for a -/// replayed transcript with none, its returned report). Mirrors the Mac's `SubagentCard`. +/// One delegated agent as a gold card — a Nucleic-managed worker (`nucleic_worker`) or a +/// provider-native subagent (`Task`), which the header names accordingly: its type and the task +/// it was given, a live Working / Done / Failed status, and — on expand — its nested activity +/// stream (or, for a replayed transcript with none, its returned report). Mirrors the Mac's +/// `SubagentCard`. struct SubagentCard: View { let group: ToolGroup @State private var expanded = false - private var state: SubagentRunState { SubagentRunState(group: group) } + private var state: WorkerRunState { WorkerRunState(group: group) } private var report: String? { Subagent.report(group) } private var hasDetail: Bool { !group.children.isEmpty || report != nil } @@ -415,11 +419,11 @@ struct SubagentCard: View { HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: "person.2.fill") .font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18) - (Text("Subagent") + (Text(group.isManagedWorker ? "Worker" : "Subagent") + (Subagent.type(group).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text(""))) .font(.callout.weight(.semibold)) Spacer(minLength: 6) - SubagentStatusChip(state: state) + WorkerStatusChip(state: state) if hasDetail { Image(systemName: expanded ? "chevron.up" : "chevron.down") .font(.caption2).foregroundStyle(.secondary) @@ -434,15 +438,20 @@ struct SubagentCard: View { } } -/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together. The -/// header sums up the wave (count + overall status); each subagent is its own row that expands -/// independently to reveal its activity stream and report. Mirrors the Mac's `OrchestrationCard`. +/// A multi-agent fan-out: a wave of two or more delegates spawned together. The header sums up +/// the wave (count + overall status); each one is its own row that expands independently to +/// reveal its activity stream and report. Mirrors the Mac's `OrchestrationCard`. struct OrchestrationCard: View { let groups: [ToolGroup] + /// Whether every call in the wave is a Nucleic-managed worker spawn, which is what entitles + /// the card to say "Workers". A mixed wave takes the broader word. + private var allManaged: Bool { groups.allSatisfy(\.isManagedWorker) } + private var noun: String { allManaged ? "worker" : "subagent" } + /// Overall wave status: failed if any failed, else running if any is still working, else done. - private var overall: SubagentRunState { - let states = groups.map { SubagentRunState(group: $0) } + private var overall: WorkerRunState { + let states = groups.map { WorkerRunState(group: $0) } if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed } if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running } return .done @@ -467,25 +476,26 @@ struct OrchestrationCard: View { Image(systemName: "rectangle.3.group.fill") .font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18) VStack(alignment: .leading, spacing: 1) { - Text("Multiple Subagents").font(.callout.weight(.semibold)) - Text("\(groups.count) subagents · \(doneCount) done") + Text(allManaged ? "Multiple Workers" : "Multiple Subagents") + .font(.callout.weight(.semibold)) + Text("\(groups.count) \(noun)s · \(doneCount) done") .font(.caption2).foregroundStyle(.secondary) } Spacer(minLength: 6) - SubagentStatusChip(state: overall) + WorkerStatusChip(state: overall) } .padding(.horizontal, 12).padding(.vertical, 10) } } -/// One subagent within a fan-out: a one-line status header that expands on tap to reveal that +/// One agent within a fan-out: a one-line status header that expands on tap to reveal that /// agent's own activity stream — and, failing that, its returned report. Each row tracks its own /// expansion so agents open independently. private struct OrchestrationAgentRow: View { let group: ToolGroup @State private var expanded = false - private var state: SubagentRunState { SubagentRunState(group: group) } + private var state: WorkerRunState { WorkerRunState(group: group) } private var report: String? { Subagent.report(group) } private var hasDetail: Bool { !group.children.isEmpty || report != nil } @@ -505,7 +515,7 @@ private struct OrchestrationAgentRow: View { private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 9) { - SubagentStatusGlyph(state: state) + WorkerStatusGlyph(state: state) Text(Subagent.headline(group)) .font(.caption).lineLimit(1).truncationMode(.tail) Spacer(minLength: 6) @@ -518,8 +528,8 @@ private struct OrchestrationAgentRow: View { } } -/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with the -/// same row views the main transcript uses, recursing into nested subagents' own cards. Falls +/// A delegate's nested activity — the calls, thinking, and prose it produced — rendered with the +/// same row views the main transcript uses, recursing into nested delegates' own cards. Falls /// back to the returned report when there's no captured inner activity (a replayed transcript). private struct SubagentActivityStream: View { let children: [TranscriptItem] @@ -784,7 +794,8 @@ enum ToolGlyph { case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil" case "Grep", "Glob", "Search": return "magnifyingglass" case "WebFetch", "WebSearch": return "globe" - case "Task", "Agent", ToolGroup.orchestraSubagentToolName: return "person.2" + case "Task", "Agent", ToolGroup.orchestraWorkerToolName, + ToolGroup.legacyOrchestraWorkerToolName: return "person.2" case "TodoWrite": return "checklist" case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName: return "list.bullet.clipboard" diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 8be37d6..60b8a2d 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -107,18 +107,26 @@ struct ToolGroup: Equatable { struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind } - /// Nucleic's own Orchestra worker spawn — the qualified `nucleic_subagent` MCP wire name. + /// Nucleic's own Orchestra worker spawn — the qualified `nucleic_worker` MCP wire name. /// Unlike a `Task` subagent, a worker runs as its own observed session, so it carries no /// nested `children` here; its card shows the final output it returned instead. - static let orchestraSubagentToolName = "mcp__nucleic__nucleic_subagent" + static let orchestraWorkerToolName = "mcp__nucleic__nucleic_worker" + /// The pre-rename wire name for the same spawn, still matched so worker cards in transcripts + /// recorded before the rename keep their gold card instead of falling back to a generic row. + static let legacyOrchestraWorkerToolName = "mcp__nucleic__nucleic_subagent" /// The supervisor's blocking worker-event drain. Holder-level Orchestra lock notes that have /// no edit card in the parent transcript fold onto this card. static let orchestraSuperviseToolName = "mcp__nucleic__nucleic_supervise" - /// Subagent orchestration — a `Task`/`Agent` spawn or a `nucleic_subagent` worker — gets the - /// gold card treatment, like the Mac. + /// Whether this call is a Nucleic-managed worker spawn, under either wire name. + var isManagedWorker: Bool { + name == Self.orchestraWorkerToolName || name == Self.legacyOrchestraWorkerToolName + } + + /// Delegation — a `Task`/`Agent` subagent spawn or a `nucleic_worker` worker — gets the gold + /// card treatment, like the Mac. var isOrchestration: Bool { - name == "Task" || name == "Agent" || name == Self.orchestraSubagentToolName + name == "Task" || name == "Agent" || isManagedWorker } var isAskUserQuestion: Bool { AskUserQuestion.matches(toolName: name) } var isExitPlanMode: Bool { PlanReview.isPlanTool(name) } diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 03579cb..a0de7da 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -37,7 +37,7 @@ struct TranscriptRow: View { case .tool(let group): // A completed AskUserQuestion reads as a pretty "You answered" Q&A card; a git-commit // pipeline as a structured commit card; a host_exec call as a host card (purpose + - // command + breakdown); a lone subagent spawn as its own gold subagent card; every + // command + breakdown); a lone worker or subagent spawn as its own gold delegation card; every // other tool — and an as-yet-unanswered question — is a plain collapsible card. Mirrors // the Mac's `toolRow` routing so each family reads the same on both platforms. if group.isOrchestration { @@ -54,7 +54,7 @@ struct TranscriptRow: View { ToolCallCard(group: group) } case .toolBlock(let groups): - // A run made entirely of subagent spawns is a multi-agent fan-out — the gold + // A run made entirely of delegation spawns is a multi-agent fan-out — the gold // orchestration card; any other run is one contiguous tool block. if groups.allSatisfy(\.isOrchestration) { OrchestrationCard(groups: groups)