diff --git a/src/renderer/components/thread/ThreadGoalDock.tsx b/src/renderer/components/thread/ThreadGoalDock.tsx
index 06e7bb46..8608d8bd 100644
--- a/src/renderer/components/thread/ThreadGoalDock.tsx
+++ b/src/renderer/components/thread/ThreadGoalDock.tsx
@@ -139,7 +139,7 @@ function GoalObjectiveText({
return (
- {text}
+ {text}
{objective}
{lastReason ? (
diff --git a/src/supervisor/agents/acp/canonicalMapping.test.ts b/src/supervisor/agents/acp/canonicalMapping.test.ts
index 87e392c5..576fe180 100644
--- a/src/supervisor/agents/acp/canonicalMapping.test.ts
+++ b/src/supervisor/agents/acp/canonicalMapping.test.ts
@@ -36,6 +36,7 @@ describe("mapAcpSessionUpdate", () => {
action: "set",
objective: "Ship ACP goal support",
status: "active",
+ availableActions: ["pause", "clear"],
timeUsedSeconds: 0,
updatedAt: 1_784_627_753.997,
},
@@ -51,6 +52,7 @@ describe("mapAcpSessionUpdate", () => {
action: "set",
objective: "Ship ACP goal support",
status: "active",
+ availableActions: ["pause", "clear"],
}),
}),
expect.objectContaining({ type: "item.completed" }),
diff --git a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts
index ccbac7e0..8cd6851c 100644
--- a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts
+++ b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts
@@ -154,6 +154,13 @@ export function mapAcpSessionUpdate(
}
case "agent_thought_chunk": {
+ const thoughtMeta =
+ update._meta && typeof update._meta === "object" && !Array.isArray(update._meta)
+ ? (update._meta as Record)
+ : undefined;
+ if (thoughtMeta?.[PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY] === true) {
+ events.push(...closeOpenContentItems(state));
+ }
if (!state.openReasoningItemId) {
// Close any prior assistant — reasoning bracket starts.
if (state.openAssistantItemId) {
diff --git a/src/supervisor/agents/acp/canonicalMapping/goals.ts b/src/supervisor/agents/acp/canonicalMapping/goals.ts
index 0089df77..5839d80e 100644
--- a/src/supervisor/agents/acp/canonicalMapping/goals.ts
+++ b/src/supervisor/agents/acp/canonicalMapping/goals.ts
@@ -4,7 +4,11 @@
* shape before the shared canonical mapper sees it.
*/
-import type { GoalItemPayload, RuntimeEvent } from "@/shared/contracts";
+import {
+ goalControlActionSchema,
+ type GoalItemPayload,
+ type RuntimeEvent,
+} from "@/shared/contracts";
import { startGoalItemEvents, updateGoalItemEvents } from "../../goalRuntime";
import type { AcpMapperState } from "./state";
import { newItemId } from "./state";
@@ -48,6 +52,8 @@ function readAcpCanonicalGoalUpdate(update: unknown): AcpCanonicalGoalUpdate | u
"cancelled",
] as const);
const objective = readString(raw.objective);
+ const availableActionsResult = goalControlActionSchema.array().safeParse(raw.availableActions);
+ const availableActions = availableActionsResult.success ? availableActionsResult.data : undefined;
if (!action && !status && !objective) return undefined;
return {
@@ -70,6 +76,7 @@ function readAcpCanonicalGoalUpdate(update: unknown): AcpCanonicalGoalUpdate | u
...(readString(raw.providerThreadId)
? { providerThreadId: readString(raw.providerThreadId)! }
: {}),
+ ...(availableActions ? { availableActions } : {}),
...(readFiniteNumber(raw.updatedAt) !== undefined
? { updatedAt: readFiniteNumber(raw.updatedAt)! }
: {}),
diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts
index b133d2f3..40a4c479 100644
--- a/src/supervisor/agents/base/types.ts
+++ b/src/supervisor/agents/base/types.ts
@@ -383,6 +383,8 @@ export interface AgentAcpAuth {
export interface AgentPromptFormatter {
shouldDeferPromptToTerminal?(config: ThreadConfig): boolean;
buildTerminalPreInputs?(config: ThreadConfig): string[][] | undefined;
+ /** Translate a canonical goal control into the provider's native prompt command. */
+ buildGoalControlPrompt?(control: ThreadGoalControl): string | undefined;
buildDirectInput?(
prompt: string,
segments?: PromptSegment[],
diff --git a/src/supervisor/agents/grok/acpTransform.test.ts b/src/supervisor/agents/grok/acpTransform.test.ts
new file mode 100644
index 00000000..6303ef87
--- /dev/null
+++ b/src/supervisor/agents/grok/acpTransform.test.ts
@@ -0,0 +1,368 @@
+import type { SessionNotification } from "@agentclientprotocol/sdk";
+import { describe, expect, it } from "vitest";
+import { createAcpMapperState, mapAcpSessionUpdate } from "../acp/canonicalMapping";
+import { createGrokAcpSessionUpdateTransform } from "./acpTransform";
+
+const PARENT_SESSION_ID = "parent-session";
+const CHILD_SESSION_ID = "child-session";
+const TOOL_CALL_ID = "spawn-call";
+
+function notification(sessionId: string, update: Record): SessionNotification {
+ return { sessionId, update } as unknown as SessionNotification;
+}
+
+describe("createGrokAcpSessionUpdateTransform", () => {
+ it("maps Grok goal extension updates into the canonical goal lifecycle", () => {
+ const transform = createGrokAcpSessionUpdateTransform();
+ const state = createAcpMapperState("thread-1");
+
+ const started = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "goal_updated",
+ goal_id: "goal-1",
+ objective: "Update README",
+ status: "active",
+ phase: "executing",
+ token_budget: 100_000,
+ tokens_used: 12_500,
+ elapsed_ms: 4_500,
+ total_worker_rounds: 2,
+ total_verify_rounds: 1,
+ classifier_runs_attempted: 1,
+ last_event: "worker_completed",
+ }),
+ ),
+ state,
+ );
+
+ expect(started).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "item.started",
+ itemType: "goal",
+ payload: expect.objectContaining({
+ action: "set",
+ objective: "Update README",
+ status: "active",
+ tokenBudget: 100_000,
+ tokensUsed: 12_500,
+ timeUsedSeconds: 4.5,
+ iterations: 1,
+ availableActions: ["pause", "clear"],
+ providerThreadId: "goal-1",
+ }),
+ }),
+ ]),
+ );
+
+ const paused = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "goal_updated",
+ goal_id: "goal-1",
+ objective: "Update README",
+ status: "user_paused",
+ phase: "executing",
+ tokens_used: 20_000,
+ elapsed_ms: 8_000,
+ total_worker_rounds: 2,
+ total_verify_rounds: 1,
+ last_event_detail: "user",
+ }),
+ ),
+ state,
+ );
+
+ expect(paused).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "item.updated",
+ payload: expect.objectContaining({
+ action: "updated",
+ status: "paused",
+ availableActions: ["resume", "clear"],
+ lastReason: "user",
+ }),
+ }),
+ ]),
+ );
+
+ const cleared = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "goal_updated",
+ goal_id: "",
+ objective: "",
+ status: "cleared",
+ phase: "idle",
+ }),
+ ),
+ state,
+ );
+ expect(cleared).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "item.updated",
+ payload: expect.objectContaining({ action: "cleared", availableActions: [] }),
+ }),
+ ]),
+ );
+ });
+
+ it("routes Grok child-session chat under its background subagent card", () => {
+ const transform = createGrokAcpSessionUpdateTransform();
+ const state = createAcpMapperState("thread-1");
+
+ const launchEvents = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "tool_call",
+ toolCallId: TOOL_CALL_ID,
+ title: "spawn_subagent",
+ rawInput: {
+ prompt: "Review README",
+ description: "README accuracy review",
+ subagent_type: "general-purpose",
+ background: true,
+ },
+ _meta: {
+ "x.ai/tool": { name: "spawn_subagent" },
+ },
+ }),
+ ),
+ state,
+ );
+ const parentStart = launchEvents.find(
+ (event) =>
+ event.type === "item.started" &&
+ (event.payload as Record | undefined)?.isSubAgent === true,
+ );
+ const parentItemId = parentStart?.type === "item.started" ? parentStart.itemId : undefined;
+ expect(parentItemId).toBeDefined();
+
+ const receipt = transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "tool_call_update",
+ toolCallId: TOOL_CALL_ID,
+ status: "completed",
+ rawOutput: { type: "Text", text: "Subagent started in background." },
+ }),
+ );
+ expect(receipt.update).toMatchObject({ status: "in_progress" });
+ expect(mapAcpSessionUpdate(receipt, state)).not.toContainEqual(
+ expect.objectContaining({ type: "item.completed", itemId: parentItemId }),
+ );
+
+ expect(
+ mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "subagent_spawned",
+ subagent_id: CHILD_SESSION_ID,
+ child_session_id: CHILD_SESSION_ID,
+ parent_session_id: PARENT_SESSION_ID,
+ subagent_type: "general-purpose",
+ description: "README accuracy review (provider normalized)",
+ }),
+ ),
+ state,
+ ),
+ ).toEqual([]);
+
+ const childEvents = mapAcpSessionUpdate(
+ transform(
+ notification(CHILD_SESSION_ID, {
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: "Inspecting README" },
+ }),
+ ),
+ state,
+ );
+ expect(childEvents).toContainEqual(
+ expect.objectContaining({
+ type: "item.started",
+ itemType: "reasoning",
+ parentItemId,
+ }),
+ );
+
+ const parentEvents = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: "Waiting for the review" },
+ }),
+ ),
+ state,
+ );
+ expect(parentEvents).toContainEqual(
+ expect.objectContaining({
+ type: "item.started",
+ itemType: "reasoning",
+ }),
+ );
+ expect(parentEvents).not.toContainEqual(
+ expect.objectContaining({ type: "item.started", parentItemId }),
+ );
+
+ expect(
+ mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "tool_call",
+ toolCallId: "task-output-call",
+ title: "get_command_or_subagent_output",
+ rawInput: { task_ids: [CHILD_SESSION_ID] },
+ }),
+ ),
+ state,
+ ),
+ ).toEqual([]);
+
+ const finished = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "subagent_finished",
+ subagent_id: CHILD_SESSION_ID,
+ child_session_id: CHILD_SESSION_ID,
+ status: "completed",
+ output: "README review complete",
+ }),
+ ),
+ state,
+ );
+ expect(finished).toContainEqual(
+ expect.objectContaining({
+ type: "item.completed",
+ itemId: parentItemId,
+ payload: expect.objectContaining({ result: "README review complete" }),
+ }),
+ );
+
+ expect(
+ mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "task-output-call",
+ status: "completed",
+ rawOutput: {
+ type: "TaskOutput",
+ Result: {
+ task_id: CHILD_SESSION_ID,
+ status: "completed",
+ output: "Full README comparison",
+ truncated: false,
+ },
+ },
+ }),
+ ),
+ state,
+ ),
+ ).toEqual([]);
+
+ expect(
+ mapAcpSessionUpdate(
+ transform(
+ notification(CHILD_SESSION_ID, {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "late child output" },
+ }),
+ ),
+ state,
+ ),
+ ).toEqual([]);
+ });
+
+ it("keeps interleaved Grok child-session reasoning under the correct subagent cards", () => {
+ const transform = createGrokAcpSessionUpdateTransform();
+ const state = createAcpMapperState("thread-1");
+ const childSessions = ["child-a", "child-b"];
+ const parentItemIds = childSessions.map((childSessionId) => {
+ const toolCallId = `spawn-${childSessionId}`;
+ const description = `Review ${childSessionId}`;
+ const events = mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "tool_call",
+ toolCallId,
+ title: "spawn_subagent",
+ rawInput: {
+ prompt: description,
+ description,
+ subagent_type: "explore",
+ background: true,
+ },
+ }),
+ ),
+ state,
+ );
+ mapAcpSessionUpdate(
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "subagent_spawned",
+ child_session_id: childSessionId,
+ parent_session_id: PARENT_SESSION_ID,
+ subagent_type: "explore",
+ description,
+ }),
+ ),
+ state,
+ );
+ const started = events.find(
+ (event) =>
+ event.type === "item.started" &&
+ (event.payload as Record | undefined)?.isSubAgent === true,
+ );
+ expect(started?.type).toBe("item.started");
+ return started?.type === "item.started" ? started.itemId : undefined;
+ });
+
+ for (const [index, childSessionId] of childSessions.entries()) {
+ const events = mapAcpSessionUpdate(
+ transform(
+ notification(childSessionId, {
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: `Reasoning from ${childSessionId}` },
+ }),
+ ),
+ state,
+ );
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "item.started",
+ itemType: "reasoning",
+ parentItemId: parentItemIds[index],
+ }),
+ );
+ }
+ });
+
+ it("suppresses internal Grok goal subagents that have no parent tool card", () => {
+ const transform = createGrokAcpSessionUpdateTransform();
+ const state = createAcpMapperState("thread-1");
+
+ transform(
+ notification(PARENT_SESSION_ID, {
+ sessionUpdate: "subagent_spawned",
+ subagent_id: CHILD_SESSION_ID,
+ child_session_id: CHILD_SESSION_ID,
+ parent_session_id: PARENT_SESSION_ID,
+ subagent_type: "general-purpose",
+ description: "goal plan writer",
+ }),
+ );
+
+ const events = mapAcpSessionUpdate(
+ transform(
+ notification(CHILD_SESSION_ID, {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "internal goal planning" },
+ }),
+ ),
+ state,
+ );
+ expect(events).toEqual([]);
+ });
+});
diff --git a/src/supervisor/agents/grok/acpTransform.ts b/src/supervisor/agents/grok/acpTransform.ts
new file mode 100644
index 00000000..af2fade8
--- /dev/null
+++ b/src/supervisor/agents/grok/acpTransform.ts
@@ -0,0 +1,337 @@
+import type { SessionNotification } from "@agentclientprotocol/sdk";
+import {
+ PORACODE_ACP_GOAL_META_KEY,
+ PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY,
+ type AcpCanonicalGoalUpdate,
+} from "../acp/canonicalMapping";
+import {
+ createAcpSubagentCoordinator,
+ normalizeAcpSubagentToolCall,
+ withAcpSubagentParent,
+ withAcpTopLevelToolCall,
+} from "../acp/subagentCoordinator";
+import type { AcpSessionUpdateTransform } from "../base";
+
+const GROK_SPAWN_SUBAGENT_TOOL = "spawn_subagent";
+
+export function createGrokAcpSessionUpdateTransform(): AcpSessionUpdateTransform {
+ const subagents = createAcpSubagentCoordinator();
+ const pendingToolCallIds: string[] = [];
+ const childSessionByToolCallId = new Map();
+ const toolCallIdByChildSession = new Map();
+ const ignoredChildSessions = new Set();
+ const seenGoalIds = new Set();
+ let parentSessionId: string | undefined;
+ let contentOwnerSessionId: string | undefined;
+
+ return (notification) => {
+ const update = plainRecord(notification.update);
+ const sessionUpdate = readString(update, "sessionUpdate");
+
+ if (sessionUpdate === "goal_updated") {
+ parentSessionId = notification.sessionId;
+ const goal = readGrokGoalUpdate(update, seenGoalIds);
+ return goal ? withGoalMeta(notification, goal) : asNoop(notification);
+ }
+
+ if (sessionUpdate === "subagent_spawned") {
+ parentSessionId = readString(update, "parent_session_id") ?? notification.sessionId;
+ const childSessionId = readString(update, "child_session_id");
+ const toolCallId = findPendingToolCallId(
+ pendingToolCallIds,
+ subagents,
+ readString(update, "description"),
+ readString(update, "subagent_type"),
+ );
+ if (childSessionId && toolCallId) {
+ const taskId = readString(update, "subagent_id") ?? childSessionId;
+ childSessionByToolCallId.set(toolCallId, childSessionId);
+ toolCallIdByChildSession.set(childSessionId, toolCallId);
+ subagents.registerBackgroundLaunch({
+ sessionId: parentSessionId,
+ toolCallId,
+ taskId,
+ });
+ } else if (childSessionId) {
+ ignoredChildSessions.add(childSessionId);
+ }
+ return asNoop(notification);
+ }
+
+ if (sessionUpdate === "subagent_finished") {
+ const childSessionId = readString(update, "child_session_id");
+ const toolCallId = childSessionId ? toolCallIdByChildSession.get(childSessionId) : undefined;
+ if (!childSessionId || !toolCallId) return asNoop(notification);
+
+ const descriptor = subagents.getCall(toolCallId);
+ if (!descriptor?.background) return asNoop(notification);
+
+ toolCallIdByChildSession.delete(childSessionId);
+ childSessionByToolCallId.delete(toolCallId);
+ ignoredChildSessions.add(childSessionId);
+ const result = readString(update, "output");
+ return (
+ subagents
+ .complete({
+ sessionId: parentSessionId ?? notification.sessionId,
+ toolCallId,
+ status: readGrokSubagentStatus(update),
+ ...(result ? { result } : {}),
+ })
+ .at(-1) ?? asNoop(notification)
+ );
+ }
+
+ const childToolCallId = toolCallIdByChildSession.get(notification.sessionId);
+ if (childToolCallId) {
+ const bounded = withContentBoundary(notification, sessionUpdate, contentOwnerSessionId);
+ contentOwnerSessionId = bounded.ownerSessionId;
+ return withAcpSubagentParent(bounded.notification, childToolCallId);
+ }
+ if (
+ ignoredChildSessions.has(notification.sessionId) ||
+ (parentSessionId && notification.sessionId !== parentSessionId)
+ ) {
+ return asNoop(notification);
+ }
+ parentSessionId ??= notification.sessionId;
+
+ const bounded = withContentBoundary(notification, sessionUpdate, contentOwnerSessionId);
+ contentOwnerSessionId = bounded.ownerSessionId;
+ const boundedNotification = bounded.notification;
+
+ if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") {
+ return boundedNotification;
+ }
+ const toolCallId = readString(update, "toolCallId");
+ if (!toolCallId) return boundedNotification;
+ if (isMappedTaskOutput(update, subagents)) return asNoop(notification);
+ const metaTool = plainRecord(plainRecord(update._meta)["x.ai/tool"]);
+ const isSpawn =
+ readString(metaTool, "name") === GROK_SPAWN_SUBAGENT_TOOL ||
+ readString(update, "title") === GROK_SPAWN_SUBAGENT_TOOL ||
+ subagents.getCall(toolCallId) !== undefined;
+ if (!isSpawn) return boundedNotification;
+
+ const descriptor = subagents.updateCall(toolCallId, {
+ rawInput: plainRecord(update.rawInput),
+ });
+ if (!pendingToolCallIds.includes(toolCallId)) pendingToolCallIds.push(toolCallId);
+ const terminal = update.status === "completed" || update.status === "failed";
+ const backgroundLaunchReceipt = descriptor.background && terminal;
+ const normalized = normalizeAcpSubagentToolCall(boundedNotification, {
+ rawInput: subagents.canonicalInput(toolCallId),
+ detached: descriptor.background,
+ keepOpen: backgroundLaunchReceipt,
+ ...(backgroundLaunchReceipt ? { omitContent: true, omitRawOutput: true } : {}),
+ });
+
+ if (terminal && !backgroundLaunchReceipt) {
+ removePendingToolCallId(pendingToolCallIds, toolCallId);
+ const childSessionId = childSessionByToolCallId.get(toolCallId);
+ if (childSessionId) {
+ toolCallIdByChildSession.delete(childSessionId);
+ childSessionByToolCallId.delete(toolCallId);
+ ignoredChildSessions.add(childSessionId);
+ }
+ subagents.forgetCall(toolCallId);
+ }
+ return sessionUpdate === "tool_call" ? withAcpTopLevelToolCall(normalized) : normalized;
+ };
+}
+
+function isMappedTaskOutput(
+ update: Record,
+ subagents: ReturnType,
+): boolean {
+ const rawInput = plainRecord(update.rawInput);
+ const taskIds = readStringArray(rawInput, "task_ids");
+ return taskIds.length === 1 && subagents.resolveBackgroundToolCallId(taskIds[0]!) !== undefined;
+}
+
+function withContentBoundary(
+ notification: SessionNotification,
+ sessionUpdate: string | undefined,
+ ownerSessionId: string | undefined,
+): { notification: SessionNotification; ownerSessionId: string | undefined } {
+ if (sessionUpdate !== "agent_message_chunk" && sessionUpdate !== "agent_thought_chunk") {
+ return { notification, ownerSessionId };
+ }
+ if (!ownerSessionId || ownerSessionId === notification.sessionId) {
+ return { notification, ownerSessionId: notification.sessionId };
+ }
+ const update = plainRecord(notification.update);
+ return {
+ notification: withUpdate(notification, {
+ ...update,
+ _meta: {
+ ...plainRecord(update._meta),
+ [PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY]: true,
+ },
+ }),
+ ownerSessionId: notification.sessionId,
+ };
+}
+
+function readGrokGoalUpdate(
+ update: Record,
+ seenGoalIds: Set,
+): AcpCanonicalGoalUpdate | undefined {
+ const goalId = readString(update, "goal_id");
+ const objective = readString(update, "objective")?.trim();
+ const rawStatus = readString(update, "status");
+ if (rawStatus === "cleared") {
+ return { action: "cleared", availableActions: [] };
+ }
+ if (!goalId || !objective || !rawStatus) return undefined;
+
+ const firstUpdate = !seenGoalIds.has(goalId);
+ seenGoalIds.add(goalId);
+ const status = mapGrokGoalStatus(rawStatus);
+ const lastReason =
+ readString(update, "pause_message") ??
+ readString(update, "last_event_detail") ??
+ readString(update, "last_event");
+ const evaluationChecks =
+ readNonNegativeInteger(update, "classifier_runs_attempted") ??
+ readNonNegativeInteger(update, "total_verify_rounds") ??
+ 0;
+ const tokenBudget = readNullableNonNegativeNumber(update, "token_budget");
+ const tokensUsed = readNonNegativeNumber(update, "tokens_used");
+ const elapsedMs = readNonNegativeNumber(update, "elapsed_ms");
+ return {
+ action: firstUpdate ? "set" : "updated",
+ objective,
+ ...(status ? { status } : {}),
+ ...(tokenBudget !== undefined ? { tokenBudget } : {}),
+ ...(tokensUsed !== undefined ? { tokensUsed } : {}),
+ ...(elapsedMs !== undefined ? { timeUsedSeconds: elapsedMs / 1000 } : {}),
+ iterations: evaluationChecks,
+ availableActions: grokGoalAvailableActions(rawStatus),
+ ...(lastReason ? { lastReason } : {}),
+ providerThreadId: goalId,
+ };
+}
+
+function grokGoalAvailableActions(
+ rawStatus: string,
+): NonNullable {
+ if (rawStatus === "active") return ["pause", "clear"];
+ if (isGrokPausedGoalStatus(rawStatus)) {
+ return ["resume", "clear"];
+ }
+ return rawStatus === "cleared" ? [] : ["clear"];
+}
+
+function mapGrokGoalStatus(rawStatus: string): AcpCanonicalGoalUpdate["status"] {
+ if (rawStatus === "active") return "active";
+ if (rawStatus === "budget_limited") return "budget_limited";
+ if (rawStatus === "complete") return "complete";
+ if (rawStatus === "cleared") return undefined;
+ if (isGrokPausedGoalStatus(rawStatus)) {
+ return "paused";
+ }
+ return undefined;
+}
+
+function isGrokPausedGoalStatus(rawStatus: string): boolean {
+ return (
+ rawStatus === "user_paused" ||
+ rawStatus === "back_off_paused" ||
+ rawStatus === "no_progress_paused" ||
+ rawStatus === "infra_paused" ||
+ rawStatus === "doom_loop_paused" ||
+ rawStatus === "blocked"
+ );
+}
+
+function readGrokSubagentStatus(
+ update: Record,
+): "completed" | "failed" | "cancelled" {
+ const status = readString(update, "status");
+ return status === "failed" || status === "cancelled" ? status : "completed";
+}
+
+function findPendingToolCallId(
+ pendingToolCallIds: string[],
+ subagents: ReturnType,
+ description: string | undefined,
+ subagentType: string | undefined,
+): string | undefined {
+ const matchingIndex = pendingToolCallIds.findIndex((toolCallId) => {
+ const descriptor = subagents.getCall(toolCallId);
+ return (
+ descriptor !== undefined &&
+ (!description || descriptor.description === description) &&
+ (!subagentType || descriptor.subagentType === subagentType)
+ );
+ });
+ const index =
+ matchingIndex >= 0
+ ? matchingIndex
+ : pendingToolCallIds.findIndex((toolCallId) => subagents.getCall(toolCallId) !== undefined);
+ if (index < 0) return undefined;
+ return pendingToolCallIds.splice(index, 1)[0];
+}
+
+function removePendingToolCallId(pendingToolCallIds: string[], toolCallId: string): void {
+ const index = pendingToolCallIds.indexOf(toolCallId);
+ if (index >= 0) pendingToolCallIds.splice(index, 1);
+}
+
+function withGoalMeta(
+ notification: SessionNotification,
+ goal: AcpCanonicalGoalUpdate,
+): SessionNotification {
+ const update = plainRecord(notification.update);
+ return withUpdate(notification, {
+ ...update,
+ _meta: { ...plainRecord(update._meta), [PORACODE_ACP_GOAL_META_KEY]: goal },
+ });
+}
+
+function asNoop(notification: SessionNotification): SessionNotification {
+ return withUpdate(notification, { sessionUpdate: "session_info_update" });
+}
+
+function withUpdate(
+ notification: SessionNotification,
+ update: Record,
+): SessionNotification {
+ return { ...notification, update: update as SessionNotification["update"] };
+}
+
+function plainRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function readString(record: Record, key: string): string | undefined {
+ const value = record[key];
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function readStringArray(record: Record, key: string): string[] {
+ const value = record[key];
+ return Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
+ : [];
+}
+
+function readNonNegativeNumber(record: Record, key: string): number | undefined {
+ const value = record[key];
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
+}
+
+function readNullableNonNegativeNumber(
+ record: Record,
+ key: string,
+): number | null | undefined {
+ return record[key] === null ? null : readNonNegativeNumber(record, key);
+}
+
+function readNonNegativeInteger(record: Record, key: string): number | undefined {
+ const value = readNonNegativeNumber(record, key);
+ return value !== undefined && Number.isInteger(value) ? value : undefined;
+}
diff --git a/src/supervisor/agents/grok/grok.test.ts b/src/supervisor/agents/grok/grok.test.ts
index ebd63ff8..4de82c49 100644
--- a/src/supervisor/agents/grok/grok.test.ts
+++ b/src/supervisor/agents/grok/grok.test.ts
@@ -95,6 +95,22 @@ describe("createGrokAdapter skill roots", () => {
});
});
+describe("createGrokAdapter goal controls", () => {
+ const adapter = createGrokAdapter();
+
+ it("maps Grok-supported goal actions to native slash commands", () => {
+ expect(adapter.buildGoalControlPrompt?.({ action: "pause" })).toBe("/goal pause");
+ expect(adapter.buildGoalControlPrompt?.({ action: "resume" })).toBe("/goal resume");
+ expect(adapter.buildGoalControlPrompt?.({ action: "clear" })).toBe("/goal clear");
+ });
+
+ it("does not advertise an in-place edit command that Grok lacks", () => {
+ expect(
+ adapter.buildGoalControlPrompt?.({ action: "edit", objective: "Replacement goal" }),
+ ).toBeUndefined();
+ });
+});
+
describe("grokDetectionSpec", () => {
it("uses device auth for WSL login to avoid localhost callback nonce mismatches", () => {
expect(typeof grokDetectionSpec.loginCommand).toBe("function");
diff --git a/src/supervisor/agents/grok/index.ts b/src/supervisor/agents/grok/index.ts
index 8b8c4b97..e043ddf0 100644
--- a/src/supervisor/agents/grok/index.ts
+++ b/src/supervisor/agents/grok/index.ts
@@ -15,6 +15,7 @@ import {
import { resolveAgentBinaryPath } from "../binaryResolver";
import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase";
import { buildGrokAcpArgs, buildGrokArgs } from "./argv";
+import { createGrokAcpSessionUpdateTransform } from "./acpTransform";
import { buildGrokCommand, grokDefaultCapabilities, grokDetectionSpec } from "./detection";
import {
installGrokPlugin,
@@ -91,6 +92,12 @@ export function createGrokAdapter(): AgentAdapter {
handleOscNotification: iterm2ProgressOscHint,
handleOscTitle: brailleSpinnerOscTitleHint,
+ buildGoalControlPrompt(control) {
+ return control.action === "pause" || control.action === "resume" || control.action === "clear"
+ ? `/goal ${control.action}`
+ : undefined;
+ },
+
pluginId: "poracode-status@grok",
pluginVersion: GROK_PLUGIN_VERSION,
minProtocolVersion: 1,
@@ -172,7 +179,10 @@ export function createGrokAdapter(): AgentAdapter {
[...acpArgs, "agent", "stdio"],
resolveAgentBinaryPath(input.projectLocation, "grok"),
);
- return createAcpStructuredSession(command, input);
+ return createAcpStructuredSession(command, {
+ ...input,
+ acpSessionUpdateTransform: createGrokAcpSessionUpdateTransform(),
+ });
},
async buildAcpAuthCommand(ctx?: AgentEnvContext) {
diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts
index f354a678..367ec0ea 100644
--- a/src/supervisor/runtime/threadSessionManager.ts
+++ b/src/supervisor/runtime/threadSessionManager.ts
@@ -631,10 +631,13 @@ export class ThreadSessionManager {
async controlThreadGoal(payload: ControlThreadGoalPayload): Promise {
const { threadId, ...control } = payload;
const session = this.requireSession(threadId);
- if (!session.structuredSession?.controlGoal) {
- throw new Error(`${session.adapter.label} does not support goal controls.`);
+ if (session.structuredSession?.controlGoal) {
+ await session.structuredSession.controlGoal(control);
+ return;
}
- await session.structuredSession.controlGoal(control);
+ const prompt = session.adapter.buildGoalControlPrompt?.(control);
+ if (!prompt) throw new Error(`${session.adapter.label} does not support this goal control.`);
+ await this.sendThreadInput({ threadId, prompt, config: session.config });
}
async rollbackThreadConversation(payload: RollbackThreadConversationPayload): Promise {