mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. Codex and Claude Code each have their own plan mode, and each produces a proposed-plan document. > - Paperclip already has a planning work mode, an issue `plan` document, and a `request_confirmation` approval gate for plans. > - These parts were not connected. The Codex proposed-plan item was transcript text only. The server path never put Codex into plan mode. Claude was never put into plan mode, and its ExitPlanMode plan was dropped. A planning run could close its task as done with no approval. > - This pull request turns a harness's own proposed plan into the issue's Plan document and the existing approval gate, for Codex and Claude. > - The benefit is that users approve or send back the agent's real plan from the task chat. Approve resumes the agent. Request changes sends the reason back to the agent. ## Linked Issues or Issue Description Refs #19252 **Subsystem affected** Native runner provider events (Codex and ACPX Claude), the runner Codex provider (Rust), server native plan synchronization, and the task chat UI. **Problem or motivation** Codex and Claude both produce a proposed-plan document in their native plan mode. Paperclip did not use it. In live tests on the server path, a Codex planning run was not in Codex plan mode, saved its own `implementation-plan` document, and marked the task done without approval. Claude planning runs were not in Claude plan mode, and the ExitPlanMode plan text never reached Paperclip. **Proposed solution** Add a canonical `plan.proposed` event for a harness's proposed plan. Put Codex and Claude into their native plan mode for planning runs. Save a completed proposal as the issue `plan` document with the existing `request_confirmation` gate. Agents can also propose a plan on a standard task; the work mode stays standard. **Alternatives considered** Reuse `plan.updated`. Its schema is the within-turn checklist and is not the durable Plan document, so a separate event keeps the two clear. Stronger prompt text without native plan mode. Live tests showed that the model kept its own document workflow. **Roadmap alignment** This reuses the issue plan document, planning work mode and approval gate that already exist. It adds one canonical provider event. ## What Changed - `packages/paperclip-runner/protocol/schemas/*`: add the `plan.proposed` event (`paperclip.plan.proposed.v1`: `proposalId`, `status`, `markdown` up to 60k characters, `source`). Regenerate the schema bundle, validators and manifest. - `packages/paperclip-runner/src/provider-events.ts`: map the Codex `plan` item and a `<proposed_plan>` block in a completed Codex message to `plan.proposed`. Add `claudePlanProposalEvent`. - `packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs`: send Codex's native `collaborationMode: plan` on `turn/start` for a read-only (planning) Codex run. Before this change the server path never sent it. - `runner-core/src/acpx_event_payload.rs`: admit `plan.proposed` as ACPX rich display activity. - `drivers/acpx/codex-runtime-adapter.ts`: set Claude Code to native plan mode for a read-only run. Capture Claude's ExitPlanMode plan from its permission request, record it, and decline the native "Ready to code?" prompt. - `drivers/acpx/codex-acpx-driver.ts`, `cli/acpx-runtime-sidecar.ts`: emit the captured proposal as a rich event. Complete that turn instead of reporting it as cancelled. - `backends/codex-native-backend.ts`: per-harness plan-mode instructions, and a short opt-in hint for Claude on standard tasks. - `server/src/services/native-runtime/native-session-executor.ts`: synchronize a completed `plan.proposed` into the `plan` document and a `request_confirmation` in planning and standard mode. Do not change the issue status during the run (that revoked the run's lease). The run finalizer turns the pending confirmation into the review wait. - `server/src/services/heartbeat-run-summary.ts`: keep a `<proposed_plan>` block out of the posted reply. - UI: new `plan_proposal` provider family. The task chat shows a live "Writing plan" card while a proposal streams and a one-line record when it completes. Resolved plan reviews keep a receipt ("Approved Plan v1", "Requested changes to Plan v1: reason"). Plan previews skip section headings. - Storybook: `Tasks and conversations/.../Task chat/Proposed plan approval` shows the journey. - `doc/SPEC-implementation.md`: describe harness-proposed plan approval. ## Verification - `cargo test -p paperclip-runner-core --lib` (new: `read_only_codex_turns_use_native_plan_collaboration_mode`) - `cd packages/paperclip-runner && npx vitest run src/provider-events.test.ts src/drivers/acpx/codex-runtime-adapter.test.ts` - `cd server && npx vitest run src/__tests__/native-plan-proposal-sync.test.ts src/__tests__/heartbeat-run-summary.test.ts` - `cd ui && npx vitest run src/components/transcript/native-run-events.test.ts src/components/task-chat` - Live test on a local worktree instance with real harnesses: - Codex (`gpt-5.6-sol`), planning task: Codex asked one question, then produced a native plan item. The task showed Plan v1 with Approve plan / Request changes and moved to in review. Approve changed the work mode to standard. Codex then implemented the plan and its tests passed. - Codex, Request changes with a reason: Codex woke in planning mode and produced Plan v2 that includes the change. - Claude (`claude-opus-5-5`), planning task: Claude was in plan mode. ExitPlanMode became Plan v1 and the turn completed. Approve resumed Claude in standard mode, and Claude implemented the plan. - Claude, standard task that asks for a plan first: Claude stopped before code changes and asked for plan approval. The work mode stayed standard. - Storybook: open `Proposed plan approval` and check each stage. ## Risks - Codex planning runs now really use Codex plan mode on the server path. Codex can ask a clarifying question before it proposes. This matches Codex plan mode. - Claude planning runs now use Claude plan mode. A declined ExitPlanMode ends the Claude turn; the runner reports it as completed. Claude's tool row for that request records a rejection from Claude's side. - Codex does not stream the proposed plan on the server path yet, so the live card shows a placeholder until the plan completes. - A `<proposed_plan>` block in any completed Codex message on a standard task now asks for approval. This is the documented opt-in path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, browser). Long context. No extended thinking mode was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
import {
|
||
AlertTriangle,
|
||
BookOpen,
|
||
Bot,
|
||
CircleCheck,
|
||
CircleDashed,
|
||
CircleX,
|
||
Box,
|
||
Clock3,
|
||
Database,
|
||
FileCheck2,
|
||
FilePenLine,
|
||
FileText,
|
||
Globe,
|
||
Info,
|
||
ListChecks,
|
||
PackageCheck,
|
||
Search,
|
||
TerminalSquare,
|
||
Users,
|
||
} from "lucide-react";
|
||
import type {
|
||
TaskChatMaterializedResourceItem,
|
||
TaskChatProtocolItem,
|
||
TaskChatProviderActivityItem,
|
||
TaskChatWorkspaceChangeItem,
|
||
TaskChatWorkspaceFileItem,
|
||
} from "./task-chat-model";
|
||
import {
|
||
humanizeToolName,
|
||
isGenericToolName,
|
||
mcpToolIdentity,
|
||
toolActivityPresentation,
|
||
type ToolIcon,
|
||
} from "./tool-taxonomy";
|
||
import { governancePresentation, type GovernanceTone } from "./governance-presentation";
|
||
|
||
export interface TaskChatActivityPresentation {
|
||
icon: ToolIcon;
|
||
runningLabel: string;
|
||
completedLabel: string;
|
||
failedLabel?: string;
|
||
interruptedLabel?: string;
|
||
detail?: string;
|
||
/** Governance rows tint a stopped action and carry its reason as a note. */
|
||
tone?: GovernanceTone;
|
||
note?: string;
|
||
}
|
||
|
||
export function providerNoticeSeverity(item: TaskChatProviderActivityItem): "info" | "warning" | "error" {
|
||
const severity = item.details.find((entry) => entry.label === "Severity")?.value;
|
||
if (item.status === "failed" || severity === "error") return "error";
|
||
return severity === "info" ? "info" : "warning";
|
||
}
|
||
|
||
function providerDetail(item: TaskChatProviderActivityItem, ...labels: string[]): string | undefined {
|
||
return item.details.find((entry) => labels.includes(entry.label))?.value;
|
||
}
|
||
|
||
function meaningfulToolDetail(value: string | undefined): string | undefined {
|
||
if (!value || isGenericToolName(value) || /^tool(?:\s+|_)call\b/i.test(value)) return undefined;
|
||
return value;
|
||
}
|
||
|
||
/** Host and path without scheme or query, for one-line web activity. */
|
||
export function compactUrl(href: string): string {
|
||
try {
|
||
const url = new URL(href);
|
||
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||
return `${url.hostname.replace(/^www\./, "")}${path}`;
|
||
} catch {
|
||
return href;
|
||
}
|
||
}
|
||
|
||
/** Whole-second duration such as "30s" or "2m 5s" from a millisecond detail. */
|
||
export function durationLabel(value: string | undefined): string | undefined {
|
||
if (!value) return undefined;
|
||
const ms = Number(value);
|
||
if (!Number.isFinite(ms) || ms < 0) return undefined;
|
||
const totalSeconds = Math.round(ms / 1000);
|
||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
function modelRouteDetail(item: TaskChatProviderActivityItem): string | undefined {
|
||
const from = providerDetail(item, "From Model") ?? providerDetail(item, "Requested Model");
|
||
const to = providerDetail(item, "Effective Model");
|
||
if (!to || to === "unknown") return undefined;
|
||
const reason = providerDetail(item, "Reason");
|
||
const route = from && from !== "unknown" && from !== to ? `${from} → ${to}` : to;
|
||
return reason && reason !== "provider reroute" ? `${route} · ${reason}` : route;
|
||
}
|
||
|
||
export function providerActivityPresentation(item: TaskChatProviderActivityItem): TaskChatActivityPresentation {
|
||
const detail = item.summary
|
||
?? providerDetail(item, "Query", "URL", "Target", "Name", "Reference", "Reason", "Summary");
|
||
switch (item.family) {
|
||
case "research": {
|
||
const action = providerDetail(item, "Action")?.toLowerCase();
|
||
const query = providerDetail(item, "Query");
|
||
const url = providerDetail(item, "Url");
|
||
if (action === "open_page") {
|
||
return { icon: Globe, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail: url ? compactUrl(url) : detail };
|
||
}
|
||
if (action === "find_in_page") {
|
||
const pattern = providerDetail(item, "Pattern");
|
||
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail: [pattern ? `“${pattern}”` : undefined, url ? compactUrl(url) : undefined].filter(Boolean).join(" in ") || detail };
|
||
}
|
||
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail: query ? `“${query}”` : detail };
|
||
}
|
||
case "tool_execution": {
|
||
const name = providerDetail(item, "Name");
|
||
const target = providerDetail(item, "Target");
|
||
const progress = providerDetail(item, "Progress");
|
||
const tool = toolActivityPresentation({
|
||
name,
|
||
transport: providerDetail(item, "Transport"),
|
||
namespace: providerDetail(item, "Namespace"),
|
||
operation: providerDetail(item, "Operation"),
|
||
target,
|
||
progress,
|
||
});
|
||
const boundedActivity = meaningfulToolDetail(item.summary)
|
||
?? meaningfulToolDetail(progress)
|
||
?? meaningfulToolDetail(target);
|
||
const activityIsIdentity = Boolean(
|
||
boundedActivity && name && humanizeToolName(boundedActivity) === humanizeToolName(name),
|
||
);
|
||
const technicalName = name ? mcpToolIdentity(name)?.name ?? name : tool.displayName;
|
||
const source = tool.sourceLabel
|
||
? `${tool.sourceLabel} · ${technicalName}`
|
||
: name && !isGenericToolName(name) && humanizeToolName(name) !== tool.runningLabel.replace(/^Running /, "")
|
||
? name
|
||
: undefined;
|
||
return {
|
||
icon: tool.icon,
|
||
runningLabel: tool.runningLabel,
|
||
completedLabel: tool.completedLabel,
|
||
failedLabel: tool.failedLabel,
|
||
interruptedLabel: tool.interruptedLabel,
|
||
detail: [activityIsIdentity ? undefined : boundedActivity, source].filter(Boolean).join(" · ") || undefined,
|
||
};
|
||
}
|
||
case "plan_proposal":
|
||
return { icon: FileText, runningLabel: "Writing a plan", completedLabel: "Proposed a plan", failedLabel: "Plan proposal failed", interruptedLabel: "Plan proposal stopped", detail };
|
||
case "plan":
|
||
return { icon: ListChecks, runningLabel: "Updating the plan", completedLabel: "Updated the plan", failedLabel: "Plan update failed", interruptedLabel: "Plan update stopped", detail };
|
||
case "delegation": {
|
||
const action = providerDetail(item, "Action")?.toLowerCase();
|
||
if (action === "message") return { icon: Users, runningLabel: "Messaging a subagent", completedLabel: "Messaged a subagent", failedLabel: "Subagent message failed", interruptedLabel: "Subagent message stopped", detail };
|
||
if (action === "resume") return { icon: Users, runningLabel: "Resuming a subagent", completedLabel: "Resumed a subagent", failedLabel: "Couldn’t resume the subagent", interruptedLabel: "Subagent resume stopped", detail };
|
||
if (action === "close") return { icon: Users, runningLabel: "Closing a subagent", completedLabel: "Closed a subagent", failedLabel: "Couldn’t close the subagent", interruptedLabel: "Subagent close stopped", detail };
|
||
if (action === "wait") return { icon: Users, runningLabel: "Waiting for subagents", completedLabel: "Finished waiting for subagents", failedLabel: "Subagent wait failed", interruptedLabel: "Stopped waiting for subagents", detail };
|
||
return { icon: Users, runningLabel: "Starting a subagent", completedLabel: "Started a subagent", failedLabel: "Subagent start failed", interruptedLabel: "Subagent start stopped", detail };
|
||
}
|
||
case "model_identity":
|
||
return item.eventType === "model.route.changed"
|
||
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail: modelRouteDetail(item) ?? detail }
|
||
: { icon: Bot, runningLabel: "Verifying the model", completedLabel: "Verified the model", failedLabel: "Model verification failed", interruptedLabel: "Model verification stopped", detail };
|
||
case "context":
|
||
return { icon: Database, runningLabel: "Compacting context", completedLabel: "Compacted context", failedLabel: "Context compaction failed", interruptedLabel: "Context compaction stopped", detail };
|
||
case "artifact": {
|
||
const viewed = item.eventType === "artifact.viewed";
|
||
return viewed
|
||
? { icon: Box, runningLabel: "Viewing an artifact", completedLabel: "Viewed an artifact", failedLabel: "Couldn’t view the artifact", interruptedLabel: "Artifact view stopped", detail }
|
||
: { icon: Box, runningLabel: "Generating an artifact", completedLabel: "Generated an artifact", failedLabel: "Artifact generation failed", interruptedLabel: "Artifact generation stopped", detail };
|
||
}
|
||
case "review": {
|
||
const state = providerDetail(item, "State")?.toLowerCase();
|
||
return state === "exited"
|
||
? { icon: FileCheck2, runningLabel: "Leaving review mode", completedLabel: "Left review mode", failedLabel: "Couldn’t leave review mode", interruptedLabel: "Review-mode change stopped", detail }
|
||
: { icon: FileCheck2, runningLabel: "Entering review mode", completedLabel: "Entered review mode", failedLabel: "Couldn’t enter review mode", interruptedLabel: "Review-mode change stopped", detail };
|
||
}
|
||
case "hook":
|
||
case "safety": {
|
||
const governance = governancePresentation(item)!;
|
||
return {
|
||
icon: governance.icon,
|
||
runningLabel: governance.label,
|
||
completedLabel: governance.label,
|
||
failedLabel: governance.label,
|
||
interruptedLabel: governance.label,
|
||
detail: governance.detail,
|
||
tone: governance.tone,
|
||
note: governance.note,
|
||
};
|
||
}
|
||
case "memory":
|
||
return { icon: BookOpen, runningLabel: "Checking memory", completedLabel: "Referenced memory", failedLabel: "Memory lookup failed", interruptedLabel: "Memory lookup stopped", detail };
|
||
case "terminal":
|
||
return { icon: TerminalSquare, runningLabel: "Sending terminal input", completedLabel: "Sent terminal input", failedLabel: "Terminal input failed", interruptedLabel: "Terminal input stopped", detail };
|
||
case "wait": {
|
||
const planned = durationLabel(providerDetail(item, "Planned Duration Ms"));
|
||
const elapsed = durationLabel(providerDetail(item, "Elapsed Duration Ms"));
|
||
return {
|
||
icon: Clock3,
|
||
runningLabel: planned ? `Waiting ${planned}` : "Waiting",
|
||
completedLabel: elapsed ?? planned ? `Waited ${elapsed ?? planned}` : "Finished waiting",
|
||
failedLabel: "Wait failed",
|
||
interruptedLabel: elapsed ? `Stopped waiting after ${elapsed}` : "Wait stopped",
|
||
};
|
||
}
|
||
case "verification": {
|
||
const status = providerDetail(item, "Status");
|
||
return {
|
||
icon: status === "passed" ? CircleCheck : status === "failed" ? CircleX : CircleDashed,
|
||
runningLabel: "Running a check",
|
||
completedLabel: status === "not_run" ? "Check not run" : "Check passed",
|
||
failedLabel: "Check failed",
|
||
interruptedLabel: "Check not run",
|
||
detail: item.summary,
|
||
};
|
||
}
|
||
case "provider_notice":
|
||
return { icon: providerNoticeSeverity(item) === "info" ? Info : AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
|
||
}
|
||
}
|
||
|
||
function workspaceChangePresentation(item: TaskChatWorkspaceChangeItem): TaskChatActivityPresentation {
|
||
const count = item.totals.files || item.files.length;
|
||
const detail = count > 0 ? `${count} ${count === 1 ? "file" : "files"}` : undefined;
|
||
return { icon: FilePenLine, runningLabel: "Editing files", completedLabel: "Edited files", detail };
|
||
}
|
||
|
||
function workspaceFilePresentation(item: TaskChatWorkspaceFileItem): TaskChatActivityPresentation {
|
||
return {
|
||
icon: FileText,
|
||
runningLabel: "Referencing a file",
|
||
completedLabel: "Referenced a file",
|
||
detail: item.line == null ? item.path : `${item.path}:${item.line}`,
|
||
};
|
||
}
|
||
|
||
function resourcePresentation(item: TaskChatMaterializedResourceItem): TaskChatActivityPresentation {
|
||
return {
|
||
icon: item.resourceKind === "document" ? FileText : PackageCheck,
|
||
runningLabel: "Saving a resource",
|
||
completedLabel: item.resourceKind === "document" ? "Added a document" : "Added a deliverable",
|
||
detail: item.title,
|
||
};
|
||
}
|
||
|
||
export function protocolActivityPresentation(item: TaskChatProtocolItem): TaskChatActivityPresentation | null {
|
||
switch (item.surface) {
|
||
case "provider_activity": return providerActivityPresentation(item);
|
||
case "workspace_change": return workspaceChangePresentation(item);
|
||
case "workspace_file": return workspaceFilePresentation(item);
|
||
case "resource": return resourcePresentation(item);
|
||
case "runtime_request":
|
||
case "run_result":
|
||
case "run_terminal":
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function protocolActivityIsRunning(item: TaskChatProtocolItem): boolean {
|
||
if (item.surface === "provider_activity") return item.status === "running";
|
||
if (item.surface === "workspace_change") return !item.complete;
|
||
return false;
|
||
}
|
||
|
||
export function protocolActivityLabel(item: TaskChatProtocolItem, presentation: TaskChatActivityPresentation): string {
|
||
if (item.surface !== "provider_activity") {
|
||
return protocolActivityIsRunning(item) ? presentation.runningLabel : presentation.completedLabel;
|
||
}
|
||
if (item.status === "failed") return presentation.failedLabel ?? `${presentation.completedLabel} · failed`;
|
||
if (item.status === "interrupted") return presentation.interruptedLabel ?? `${presentation.completedLabel} · interrupted`;
|
||
return item.status === "running" ? presentation.runningLabel : presentation.completedLabel;
|
||
}
|