diff --git a/src/main/projectDirectory.test.ts b/src/main/projectDirectory.test.ts
index 2e2c4917..7067c245 100644
--- a/src/main/projectDirectory.test.ts
+++ b/src/main/projectDirectory.test.ts
@@ -6,6 +6,7 @@ import { createProjectDirectory, describeMkdirError } from "./projectDirectory";
describe("createProjectDirectory", () => {
let root: string;
+ const nativeKind = process.platform === "win32" ? "windows" : "posix";
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lc-create-project-"));
@@ -16,7 +17,11 @@ describe("createProjectDirectory", () => {
});
test("creates the folder under the parent and returns its path", async () => {
- const result = await createProjectDirectory({ parent: root, name: "new-app", kind: "posix" });
+ const result = await createProjectDirectory({
+ parent: root,
+ name: "new-app",
+ kind: nativeKind,
+ });
const expected = join(root, "new-app");
expect(result.path).toBe(expected);
@@ -25,16 +30,16 @@ describe("createProjectDirectory", () => {
});
test("throws when a folder with that name already exists", async () => {
- await createProjectDirectory({ parent: root, name: "dup", kind: "posix" });
+ await createProjectDirectory({ parent: root, name: "dup", kind: nativeKind });
await expect(
- createProjectDirectory({ parent: root, name: "dup", kind: "posix" }),
+ createProjectDirectory({ parent: root, name: "dup", kind: nativeKind }),
).rejects.toThrow(/already exists/i);
});
test("surfaces a friendly message when the parent does not exist", async () => {
await expect(
- createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: "posix" }),
+ createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: nativeKind }),
).rejects.toThrow(/no longer exists/i);
});
});
diff --git a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts
index 52270380..f2c3805c 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts
+++ b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.test.ts
@@ -40,6 +40,21 @@ describe("acpToolPayload", () => {
});
});
+ it("unwraps MCP content text blocks from result objects", () => {
+ expect(
+ extractAcpResultPart({
+ result: {
+ content: [{ type: "text", text: '{"count":1}' }],
+ structuredContent: null,
+ _meta: null,
+ },
+ }),
+ ).toEqual({
+ text: '{\n "count": 1\n}',
+ language: "json",
+ });
+ });
+
it("synthesizes a unified diff from replacement-style edit args", () => {
expect(
extractAcpDiffResultPart({
diff --git a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts
index 277e5e04..1192717d 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts
+++ b/src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts
@@ -63,18 +63,26 @@ export function extractAcpResultPart(payload: unknown): ExtractedPart {
return asPart(prettyIfJson(r.detailedContent));
if (typeof r.text === "string" && r.text.length > 0) return asPart(prettyIfJson(r.text));
if (typeof r.content === "string" && r.content.length > 0) return asPart(prettyIfJson(r.content));
+ if (Array.isArray(r.content)) {
+ const part = extractTextBlockPart(r.content);
+ if (part.text.length > 0) return part;
+ }
if (Array.isArray(r.contents)) {
- const parts = r.contents
- .map((c) => (c && typeof c.text === "string" ? prettyIfJson(c.text) : ""))
- .filter((t) => t.length > 0);
- if (parts.length > 0) {
- const joined = parts.join("\n\n");
- return { text: joined, language: parts.every(isJsonText) ? "json" : "plain" };
- }
+ const part = extractTextBlockPart(r.contents);
+ if (part.text.length > 0) return part;
}
return { text: safeJson(result), language: "json" };
}
+function extractTextBlockPart(blocks: readonly ({ text?: unknown } | undefined)[]): ExtractedPart {
+ const parts = blocks
+ .map((c) => (c && typeof c.text === "string" ? prettyIfJson(c.text) : ""))
+ .filter((t) => t.length > 0);
+ if (parts.length === 0) return emptyPart();
+ const joined = parts.join("\n\n");
+ return { text: joined, language: parts.every(isJsonText) ? "json" : "plain" };
+}
+
/**
* Read-tool result serializer. Unwraps OpenCode's
* `…file…` wrapper and strips
diff --git a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts
index 9b0b8831..88429cc8 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts
+++ b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { Eye, GitBranch, ImageIcon, Pencil, SearchCode, Terminal } from "lucide-react";
+import { Eye, GitBranch, ImageIcon, Pencil, SearchCode, Sparkles, Terminal } from "lucide-react";
import type { ToolCallPayload } from "@/shared/contracts";
import { deriveToolDisplay, isSubAgentTool } from "./toolDisplay";
@@ -134,6 +134,19 @@ describe("deriveToolDisplay", () => {
expect(display.Icon).toBe(Eye);
});
+ it("normalizes skill file reads to skill displays", () => {
+ const display = deriveToolDisplay(
+ makePayload({
+ name: "Read",
+ args: { file_path: String.raw`C:\Users\sdsle\.codex\skills\.system\imagegen\SKILL.md` },
+ }),
+ );
+
+ expect(display.title).toBe("Skill: imagegen");
+ expect(display.parts).toBeUndefined();
+ expect(display.Icon).toBe(Sparkles);
+ });
+
it("includes line ranges for read tools that provide offsets", () => {
const display = deriveToolDisplay(
makePayload({
diff --git a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts
index c13b33c7..33d9aeed 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts
+++ b/src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts
@@ -108,7 +108,7 @@ export function deriveToolDisplay(payload: ToolCallPayload): ToolDisplay {
}
if (isSkillTool(payload)) {
- const skill = readStr(args, "skill") ?? readStr(args, "name");
+ const skill = readStr(args, "skill") ?? readStr(args, "name") ?? readSkillName(payload);
return { title: skill ? i18n._(msg`Skill: ${skill}`) : payload.name, Icon: Sparkles };
}
@@ -458,7 +458,30 @@ export function isSkillTool(payload: ToolCallPayload): boolean {
const n = payload.name.trim();
if (n === "Skill" || /^(loaded|using) skill\b/i.test(n)) return true;
const args = readArgsObject(payload);
- return readStr(args, "skill") !== undefined;
+ return readStr(args, "skill") !== undefined || readSkillName(payload) !== undefined;
+}
+
+function readSkillName(payload: ToolCallPayload): string | undefined {
+ const args = readArgsObject(payload);
+ return (
+ readSkillNameFromPath(readPathArg(args)) ??
+ readSkillNameFromPath(payload.title) ??
+ readSkillNameFromPath(payload.name)
+ );
+}
+
+function readSkillNameFromPath(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ const cleaned = value
+ .replace(/^(?:view(?:\s+\d+(?::\d+)?)?|read(?:ing)?|open(?:ing)?)[:\s]+/i, "")
+ .trim()
+ .replace(/^["'`]+|["'`]+$/g, "");
+ const parts = cleaned.split(/[\\/]+/).filter(Boolean);
+ if (parts.at(-1)?.toLowerCase() !== "skill.md") return undefined;
+ const skillsIndex = parts.findLastIndex((part) => part.toLowerCase() === "skills");
+ if (skillsIndex === -1 || skillsIndex >= parts.length - 2) return undefined;
+ const skill = parts.at(-2);
+ return skill && !skill.startsWith(".") ? skill : undefined;
}
function formatAcpPathDisplay(
diff --git a/src/renderer/state/usageRecorder.test.ts b/src/renderer/state/usageRecorder.test.ts
index 95575b0a..c501f5b3 100644
--- a/src/renderer/state/usageRecorder.test.ts
+++ b/src/renderer/state/usageRecorder.test.ts
@@ -37,6 +37,10 @@ function emittedTokenValues(provider: string): number[] {
.map((event) => event.value ?? 0);
}
+function emittedEvents(): UsageEventInputPayload[] {
+ return bridgeMock.appendUsageEvents.mock.calls.flatMap((call) => call[0].events);
+}
+
describe("usageRecorder token baseline", () => {
beforeEach(() => {
flushNow(); // drain anything a prior test left buffered
@@ -74,3 +78,113 @@ describe("usageRecorder token baseline", () => {
expect(emittedTokenValues(provider)).toEqual([1_200]);
});
});
+
+describe("usageRecorder item classification", () => {
+ beforeEach(() => {
+ flushNow();
+ bridgeMock.appendUsageEvents.mockReset();
+ bridgeMock.appendUsageEvents.mockResolvedValue(undefined);
+ });
+ afterEach(() => {
+ flushNow();
+ });
+
+ it("records Codex skill calls with lower-case skill names", () => {
+ const thread = makeThread("skill-thread", "codex");
+ recordRuntimeUsage(
+ "skill-thread",
+ [
+ {
+ type: "item.started",
+ threadId: "skill-thread",
+ itemId: "skill-codex",
+ itemType: "tool_call",
+ payload: { name: "skill", args: { skill: "imagegen" }, status: "running" },
+ },
+ ],
+ [thread],
+ );
+
+ flushNow();
+ expect(emittedEvents()).toContainEqual(
+ expect.objectContaining({ kind: "skill", provider: "codex", name: "imagegen" }),
+ );
+ });
+
+ it("records Codex skill file reads as skills", () => {
+ const thread = makeThread("skill-file-thread", "codex");
+ recordRuntimeUsage(
+ "skill-file-thread",
+ [
+ {
+ type: "item.started",
+ threadId: "skill-file-thread",
+ itemId: "skill-file-codex",
+ itemType: "dynamic_tool_call",
+ payload: {
+ name: "Read",
+ args: {
+ file_path: String.raw`C:\Users\sdsle\.codex\skills\.system\imagegen\SKILL.md`,
+ },
+ status: "running",
+ },
+ },
+ ],
+ [thread],
+ );
+
+ flushNow();
+ expect(emittedEvents()).toContainEqual(
+ expect.objectContaining({ kind: "skill", provider: "codex", name: "imagegen" }),
+ );
+ });
+
+ it("records Codex collab agent tool calls as subagents", () => {
+ const thread = makeThread("subagent-thread", "codex");
+ recordRuntimeUsage(
+ "subagent-thread",
+ [
+ {
+ type: "item.started",
+ threadId: "subagent-thread",
+ itemId: "collab-agent",
+ itemType: "tool_call",
+ payload: {
+ name: "spawn_agent",
+ isSubAgent: true,
+ args: { prompt: "inspect one thing", agentType: "worker" },
+ status: "running",
+ },
+ },
+ ],
+ [thread],
+ );
+
+ flushNow();
+ expect(emittedEvents()).toContainEqual(
+ expect.objectContaining({ kind: "subagent", provider: "codex", name: "worker" }),
+ );
+ });
+
+ it("records Codex MCP calls by server instead of generic mcp", () => {
+ const thread = makeThread("mcp-thread", "codex");
+ recordRuntimeUsage(
+ "mcp-thread",
+ [
+ {
+ type: "item.started",
+ threadId: "mcp-thread",
+ itemId: "mcp-codex",
+ itemType: "mcp_tool_call",
+ payload: { name: "mcpToolCall", server: "browser", status: "running" },
+ },
+ ],
+ [thread],
+ );
+
+ flushNow();
+ expect(emittedEvents()).toContainEqual(
+ expect.objectContaining({ kind: "mcp", provider: "codex", name: "browser" }),
+ );
+ });
+});
diff --git a/src/renderer/state/usageRecorder.ts b/src/renderer/state/usageRecorder.ts
index 1ee74e7d..7811c423 100644
--- a/src/renderer/state/usageRecorder.ts
+++ b/src/renderer/state/usageRecorder.ts
@@ -167,31 +167,46 @@ function classifyItem(itemType: string, payload: unknown): ItemHit | undefined {
const name = str(p, "name") ?? "";
const title = str(p, "title") ?? "";
const args = asRecord(p?.["args"]);
+ const lowerName = name.toLowerCase();
if (itemType === "mcp_tool_call" || /^mcp__/.test(name)) {
const match = /^mcp__(.+?)__/.exec(name);
- return { kind: "mcp", name: match?.[1] ?? str(p, "serverId") ?? "mcp" };
+ return {
+ kind: "mcp",
+ name:
+ match?.[1] ??
+ str(p, "serverId") ??
+ str(p, "server") ??
+ str(args, "serverId") ??
+ str(args, "server") ??
+ "mcp",
+ };
}
if (
- name === "Skill" ||
+ lowerName === "skill" ||
/^(loaded|using) skill\b/i.test(name) ||
- /^(loaded|using) skill\b/i.test(title)
+ /^(loaded|using) skill\b/i.test(title) ||
+ readSkillName(p, args) !== undefined
) {
const skill =
str(args, "skill") ??
str(args, "name") ??
+ readSkillName(p, args) ??
title
.replace(/^(loaded|using) skill[:\s]*/i, "")
.replace(/^skill:\s*/i, "")
.trim();
return { kind: "skill", name: skill || "skill" };
}
- const subagentType = str(args, "subagent_type");
+ const subagentType =
+ str(args, "subagent_type") ?? str(args, "agent_type") ?? str(args, "agentType");
if (
p?.["isSubAgent"] === true ||
- name === "Task" ||
- name === "Workflow" ||
- name === "Agent" ||
+ lowerName === "task" ||
+ lowerName === "workflow" ||
+ lowerName === "agent" ||
+ lowerName === "collabagenttoolcall" ||
+ lowerName === "collab agent tool call" ||
subagentType
) {
// Prefer the agent type (Task/Agent); for workflows use the saved name or
@@ -199,14 +214,53 @@ function classifyItem(itemType: string, payload: unknown): ItemHit | undefined {
// generic "workflow"); otherwise the task description.
const agent =
subagentType ??
- (name === "Workflow"
+ (lowerName === "workflow"
? (str(args, "name") ?? str(args, "description") ?? "workflow")
- : (str(args, "description") ?? "subagent"));
+ : (str(args, "description") ?? str(args, "prompt") ?? "subagent"));
return { kind: "subagent", name: agent };
}
return undefined;
}
+function readSkillName(
+ payload: Record | undefined,
+ args: Record | undefined,
+): string | undefined {
+ return (
+ str(args, "skill") ??
+ str(args, "name") ??
+ readSkillNameFromPath(readPathArg(args)) ??
+ readSkillNameFromPath(str(payload, "title")) ??
+ readSkillNameFromPath(str(payload, "name"))
+ );
+}
+
+function readPathArg(args: Record | undefined): string | undefined {
+ return (
+ str(args, "file_path") ??
+ str(args, "filePath") ??
+ str(args, "path") ??
+ str(args, "relative_path") ??
+ str(args, "relativePath") ??
+ str(args, "notebook_path") ??
+ str(args, "notebookPath")
+ );
+}
+
+function readSkillNameFromPath(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ const cleaned = value
+ .replace(/^(?:view(?:\s+\d+(?::\d+)?)?|read(?:ing)?|open(?:ing)?)[:\s]+/i, "")
+ .trim()
+ .replace(/^["'`]+|["'`]+$/g, "");
+ const parts = cleaned.split(/[\\/]+/).filter(Boolean);
+ if (parts.at(-1)?.toLowerCase() !== "skill.md") return undefined;
+ const skillsIndex = parts.findLastIndex((part) => part.toLowerCase() === "skills");
+ if (skillsIndex === -1 || skillsIndex >= parts.length - 2) return undefined;
+ const skill = parts.at(-2);
+ return skill && !skill.startsWith(".") ? skill : undefined;
+}
+
/** Record an AI-performed git action (commit / PR / conflict) into the buffer. */
export function recordAiAction(type: AiActionType, provider: string, model: string): void {
push({ ts: Date.now(), kind: `ai_${type}`, provider, model, value: 1 });
diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts
index 29220dc0..39ab1a30 100644
--- a/src/supervisor/agents/codex/canonicalMapping.test.ts
+++ b/src/supervisor/agents/codex/canonicalMapping.test.ts
@@ -573,6 +573,132 @@ describe("mapCodexNotification — item lifecycle (item/started, item/completed)
expect(completedPayload).toMatchObject({ status: "success", result: { hits: 3 } });
});
+ it("normalizes Codex mcpToolCall items for MCP display", () => {
+ const state = createCodexMapperState("t-codex");
+ const started = mapCodexNotification(
+ "item/started",
+ {
+ threadId: "x",
+ itemId: "mcp-tool-1",
+ item: {
+ id: "mcp-tool-1",
+ type: "mcpToolCall",
+ server: "browser",
+ tool: "console_logs",
+ arguments: { limit: 10 },
+ },
+ },
+ state,
+ );
+
+ expect((started[0] as { itemType: string }).itemType).toBe("mcp_tool_call");
+ expect((started[0] as { payload: Record }).payload).toMatchObject({
+ name: "mcp__browser__console_logs",
+ serverId: "browser",
+ args: { limit: 10 },
+ status: "running",
+ });
+
+ const completed = mapCodexNotification(
+ "item/completed",
+ {
+ threadId: "x",
+ itemId: "mcp-tool-1",
+ item: {
+ id: "mcp-tool-1",
+ type: "mcpToolCall",
+ status: "completed",
+ result: {
+ content: [{ type: "text", text: '{"count":1}' }],
+ structuredContent: null,
+ _meta: null,
+ },
+ },
+ },
+ state,
+ );
+
+ expect((completed.at(-1) as { payload: Record }).payload).toMatchObject({
+ status: "success",
+ result: {
+ content: [{ type: "text", text: '{"count":1}' }],
+ structuredContent: null,
+ _meta: null,
+ },
+ });
+ });
+
+ it("maps Codex collabAgentToolCall items as subagent tool calls", () => {
+ const state = createCodexMapperState("t-codex");
+ const started = mapCodexNotification(
+ "item/started",
+ {
+ threadId: "x",
+ itemId: "collab-1",
+ item: {
+ id: "collab-1",
+ type: "collabAgentToolCall",
+ tool: "spawn_agent",
+ status: "in_progress",
+ senderThreadId: "parent-thread",
+ receiverThreadIds: ["child-thread"],
+ prompt: "inspect one thing",
+ model: "gpt-5.3-codex",
+ agentsStates: {
+ "child-thread": { status: "pending_init", message: null },
+ },
+ },
+ },
+ state,
+ );
+
+ expect((started[0] as { itemType: string }).itemType).toBe("tool_call");
+ expect((started[0] as { payload: Record }).payload).toMatchObject({
+ name: "spawn_agent",
+ isSubAgent: true,
+ args: {
+ description: "inspect one thing",
+ prompt: "inspect one thing",
+ senderThreadId: "parent-thread",
+ receiverThreadIds: ["child-thread"],
+ model: "gpt-5.3-codex",
+ },
+ progress: {
+ description: "inspect one thing",
+ model: "gpt-5.3-codex",
+ stepCount: 1,
+ },
+ status: "running",
+ });
+
+ const completed = mapCodexNotification(
+ "item/completed",
+ {
+ threadId: "x",
+ itemId: "collab-1",
+ item: {
+ id: "collab-1",
+ type: "collabAgentToolCall",
+ tool: "wait",
+ status: "completed",
+ agentsStates: {
+ "child-thread": { status: "completed", message: "done" },
+ },
+ },
+ },
+ state,
+ );
+
+ expect((completed.at(-1) as { payload: Record }).payload).toMatchObject({
+ status: "success",
+ result: "done",
+ progress: {
+ description: "done",
+ stepCount: 1,
+ },
+ });
+ });
+
it("preserves Codex dynamic and image tool item types", () => {
const state = createCodexMapperState("t-codex");
const dynamic = mapCodexNotification(
diff --git a/src/supervisor/agents/codex/canonicalMapping.ts b/src/supervisor/agents/codex/canonicalMapping.ts
index f6e5dac2..a079bdff 100644
--- a/src/supervisor/agents/codex/canonicalMapping.ts
+++ b/src/supervisor/agents/codex/canonicalMapping.ts
@@ -48,6 +48,7 @@ import {
canonicalTypeFor,
createCodexMapperState,
newItemId,
+ normalizeItemType,
streamForType,
type CodexMapperState,
} from "./canonicalMappingState";
@@ -105,6 +106,23 @@ export interface CodexItemPayload {
changeKind?: string;
changes?: unknown;
content?: unknown;
+ server?: string;
+ serverId?: string;
+ tool?: string;
+ arguments?: unknown;
+ error?: unknown;
+ senderThreadId?: string;
+ sender_thread_id?: string;
+ receiverThreadIds?: unknown;
+ receiver_thread_ids?: unknown;
+ agentsStates?: unknown;
+ agents_states?: unknown;
+ prompt?: string;
+ model?: unknown;
+ reasoningEffort?: unknown;
+ reasoning_effort?: unknown;
+ toolKind?: unknown;
+ tool_kind?: unknown;
/** Generic tool input (codex `mcp` / `dynamic` tool items). */
input?: unknown;
args?: unknown;
@@ -766,9 +784,15 @@ export function buildStartedPayload(
}
if (isToolLikeItemType(itemType)) {
const args = pickToolInput(source);
+ const serverId = toolServerId(source);
+ const isSubAgent = isCodexCollabAgentToolCall(source);
+ const progress = isSubAgent ? readCollabAgentProgress(source) : undefined;
return {
name: toolName(source) ?? "tool",
+ ...(serverId ? { serverId } : {}),
...(args !== undefined ? { args } : {}),
+ ...(progress ? { progress } : {}),
+ ...(isSubAgent ? { isSubAgent: true } : {}),
status: "running" as const,
};
}
@@ -803,9 +827,13 @@ export function buildCompletedPayload(
}
if (isToolLikeItemType(itemType)) {
const result = pickToolOutput(source);
+ const progress = isCodexCollabAgentToolCall(source)
+ ? readCollabAgentProgress(source)
+ : undefined;
return {
status: codexFinalStatus(source.status),
...(result !== undefined ? { result } : {}),
+ ...(progress ? { progress } : {}),
};
}
if (itemType === "file_change") {
@@ -877,8 +905,10 @@ function codexFinalStatus(raw: unknown): "success" | "error" {
* the common aliases — `args` / `input` — without inventing new ones.
*/
function pickToolInput(source: CodexItemPayload): unknown {
+ if (isCodexCollabAgentToolCall(source)) return pickCollabAgentInput(source);
if (source.args !== undefined) return source.args;
if (source.input !== undefined) return source.input;
+ if (source.arguments !== undefined) return source.arguments;
return undefined;
}
@@ -890,6 +920,7 @@ function pickCodexWebSearchInput(source: CodexItemPayload): unknown {
function pickToolOutput(source: CodexItemPayload): unknown {
if (source.result !== undefined) return source.result;
if (source.output !== undefined) return source.output;
+ if (isCodexCollabAgentToolCall(source)) return pickCollabAgentResult(source);
return undefined;
}
@@ -977,12 +1008,111 @@ function extractTitlePath(value: unknown): string | undefined {
}
function toolName(source: CodexItemPayload): string | undefined {
+ const mcpName = mcpToolName(source);
+ if (mcpName) return mcpName;
+ if (isCodexCollabAgentToolCall(source) && readNonEmptyString(source.tool)) return source.tool;
if (typeof source.title === "string" && source.title.length > 0) return source.title;
if (typeof source.name === "string" && source.name.length > 0) return source.name;
+ if (readNonEmptyString(source.tool)) return source.tool;
if (typeof source.type === "string" && source.type.length > 0) return source.type;
return undefined;
}
+function mcpToolName(source: CodexItemPayload): string | undefined {
+ const server = toolServerId(source);
+ const tool = readNonEmptyString(source.tool);
+ return server && tool ? `mcp__${server}__${tool}` : undefined;
+}
+
+function toolServerId(source: CodexItemPayload): string | undefined {
+ if (canonicalTypeFor(source.type ?? source.kind) !== "mcp_tool_call") return undefined;
+ return readNonEmptyString(source.server) ?? readNonEmptyString(source.serverId);
+}
+
+function isCodexCollabAgentToolCall(source: CodexItemPayload): boolean {
+ const type = normalizeItemType(source.type ?? source.kind);
+ return type === "collab agent tool call" || type === "collab agent";
+}
+
+function pickCollabAgentInput(source: CodexItemPayload): unknown {
+ const prompt = readNonEmptyString(source.prompt);
+ const senderThreadId =
+ readNonEmptyString(source.senderThreadId) ?? readNonEmptyString(source.sender_thread_id);
+ const receiverThreadIds = readStringArray(source.receiverThreadIds ?? source.receiver_thread_ids);
+ const agentsStates = readCollabAgentStates(source);
+ const model = readNonEmptyString(source.model);
+ const reasoningEffort =
+ readNonEmptyString(source.reasoningEffort) ?? readNonEmptyString(source.reasoning_effort);
+ const toolKind = readNonEmptyString(source.toolKind) ?? readNonEmptyString(source.tool_kind);
+
+ const input: Record = {};
+ if (prompt) {
+ input.description = prompt;
+ input.prompt = prompt;
+ }
+ if (senderThreadId) input.senderThreadId = senderThreadId;
+ if (receiverThreadIds.length > 0) input.receiverThreadIds = receiverThreadIds;
+ if (agentsStates !== undefined) input.agentsStates = agentsStates;
+ if (model) input.model = model;
+ if (reasoningEffort) input.reasoningEffort = reasoningEffort;
+ if (toolKind) input.toolKind = toolKind;
+ return Object.keys(input).length > 0 ? input : undefined;
+}
+
+function pickCollabAgentResult(source: CodexItemPayload): unknown {
+ const agentsStates = readCollabAgentStates(source);
+ const messages = readCollabAgentMessages(agentsStates);
+ if (messages.length === 1) return messages[0];
+ if (messages.length > 1) return messages.join("\n\n");
+ return agentsStates !== undefined ? { agentsStates } : undefined;
+}
+
+function readCollabAgentProgress(source: CodexItemPayload):
+ | {
+ description?: string;
+ model?: string;
+ stepCount?: number;
+ }
+ | undefined {
+ const agentsStates = readCollabAgentStates(source);
+ const description = readCollabAgentMessages(agentsStates)[0] ?? readNonEmptyString(source.prompt);
+ const model = readNonEmptyString(source.model);
+ const receiverThreadIds = readStringArray(source.receiverThreadIds ?? source.receiver_thread_ids);
+ const stepCount =
+ receiverThreadIds.length > 0
+ ? receiverThreadIds.length
+ : agentsStates && typeof agentsStates === "object" && !Array.isArray(agentsStates)
+ ? Object.keys(agentsStates as Record).length
+ : undefined;
+ const progress = {
+ ...(description ? { description } : {}),
+ ...(model ? { model } : {}),
+ ...(stepCount !== undefined ? { stepCount } : {}),
+ };
+ return Object.keys(progress).length > 0 ? progress : undefined;
+}
+
+function readCollabAgentStates(source: CodexItemPayload): unknown {
+ return source.agentsStates ?? source.agents_states;
+}
+
+function readCollabAgentMessages(states: unknown): string[] {
+ if (!states || typeof states !== "object" || Array.isArray(states)) return [];
+ const messages: string[] = [];
+ for (const state of Object.values(states as Record)) {
+ if (!state || typeof state !== "object" || Array.isArray(state)) continue;
+ const message = readNonEmptyString((state as Record).message);
+ if (message) messages.push(message);
+ }
+ return messages;
+}
+
+function readStringArray(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string" && item.length > 0)
+ : [];
+}
+
function extractCodexWebSearchQuery(source: CodexItemPayload): string | undefined {
const direct = readNonEmptyString(source.query) ?? readNonEmptyString(source.text);
if (direct) return direct;
diff --git a/src/supervisor/agents/codex/canonicalMappingState.ts b/src/supervisor/agents/codex/canonicalMappingState.ts
index 4053d35f..7639b913 100644
--- a/src/supervisor/agents/codex/canonicalMappingState.ts
+++ b/src/supervisor/agents/codex/canonicalMappingState.ts
@@ -66,7 +66,7 @@ export function canonicalTypeFor(raw: string | undefined | null): CanonicalItemT
return "tool_call";
}
-function normalizeItemType(raw: string | undefined | null): string {
+export function normalizeItemType(raw: string | undefined | null): string {
if (!raw) return "";
return raw
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
diff --git a/src/supervisor/agents/commandcode/plugin/install.stage.test.ts b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts
index 49cda320..a0b349f0 100644
--- a/src/supervisor/agents/commandcode/plugin/install.stage.test.ts
+++ b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts
@@ -36,8 +36,8 @@ describe("installCommandCodePlugin (native staging)", () => {
expect(existsSync(join(dir, "plugin.json"))).toBe(true);
expect(existsSync(join(dir, "forward.mjs"))).toBe(true);
expect(existsSync(join(dir, "lightcode-hook-runtime.mjs"))).toBe(true);
- // POSIX wrapper (this test only runs the native posix branch).
- expect(existsSync(join(dir, "lightcode-hook.sh"))).toBe(true);
+ const wrapperName = process.platform === "win32" ? "lightcode-hook.cmd" : "lightcode-hook.sh";
+ expect(existsSync(join(dir, wrapperName))).toBe(true);
const doc = JSON.parse(readFileSync(join(ccDir, "settings.json"), "utf8")) as {
hooks: Record }>>;
@@ -45,8 +45,9 @@ describe("installCommandCodePlugin (native staging)", () => {
for (const ev of ["PreToolUse", "PostToolUse", "Stop"]) {
const entry = doc.hooks[ev]?.[0]?.hooks?.[0];
expect(entry?.type).toBe("command");
- expect(entry?.command).toContain("agent-plugins/commandcode/lightcode-hook.sh");
- expect(entry?.command.endsWith(` ${ev}`)).toBe(true);
+ const command = entry?.command ?? "";
+ expect(command.replace(/\\/g, "/")).toContain(`agent-plugins/commandcode/${wrapperName}`);
+ expect(command.endsWith(` ${ev}`)).toBe(true);
}
});