Files
DottaandClaude Opus 5.5 2575b941d0 feat(task-chat): show hook and auto-review decisions as governance rows (#19266)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run on harnesses such as Codex and Claude, and the task chat
shows what each run did
> - Harnesses make automatic decisions: hooks block or allow tool calls,
and Codex auto-review approves or denies actions
> - Today Paperclip drops these decisions or shows them as generic rows.
Codex `safety.review.*` always says `decision: "unknown"`, the native
Rust runner drops `hook/*` and `item/autoApprovalReview/*`, and
claude-agent-acp drops Claude hook messages
> - An operator cannot see why an agent changed course or which action
ran without a person's approval. This conflicts with Paperclip's
approval-gate model
> - This pull request records each hook and review decision with its
reason and shows it as a small governance row in the task chat
> - The benefit is that automatic decisions become auditable, and a
blocked action explains itself without opening run logs

## Linked Issues or Issue Description

Refs #19252 (precedent: canonical `plan.updated` events rendered as a
task-chat card).
Builds on #19259 (ACP `_meta` passthrough from ACPX), now merged.

**Subsystem affected**
Runner provider events (Rust native Codex normalizer, TS drivers, ACPX
sidecar), the claude-agent-acp patch, the PRP provider-event schema, and
the task chat activity UI.

**Problem or motivation**
Hook and auto-review outcomes reach Paperclip but are lost or flattened.
Codex reviews always record an unknown decision. Native Codex runs drop
hook and review notifications in the Rust normalizer. Claude hook
messages never leave the adapter. Codex reuses one `run.id` for every
call of a hook, and the id contains a host path.

**Proposed solution**
Map the real outcome, decision, action, risk and reason into additive
optional fields on `paperclip.hook.v1` and `paperclip.safety.review.v1`.
Forward Claude SDK hook messages. Render small grey rows: a stopped
action is tinted and shows its reason, an approved review folds into the
command it approved as an "auto-approved" mark, and routine hook passes
stay out of activity summaries.

**Alternatives considered**
Separate rows for every approval. This duplicated each approved command,
so approvals fold into the command row instead.

## What Changed

- Runner (Rust): new `governance_events.rs`.
`normalize_codex_notification` now maps `hook/started|completed` and
`item/autoApprovalReview/started|completed`. Each hook call gets its own
id (a digest of the run id and start time), so no host path is stored.
- Runner (TS): new `governance-events.ts` with the same mapping for the
TS Codex driver, and the Claude hook mapping. The ACPX sidecar publishes
Claude hook updates as schema-validated `hook.*` rich events.
- claude-agent-acp patch: turns on `includeHookEvents` and forwards
`hook_started` / `hook_response` on
`session_info_update._meta.claudeCode.hook`, with bounded output.
- Protocol: additive optional fields. Hook records get `outcome`,
`message`, `source` and `toolName`, and the `blocked` and `stopped`
statuses. Review records get `actionKind`, `action`, `riskLevel`,
`userConsent` (the reviewer's user-authorization level; keys that end in
"authorization" are redacted as secrets), `rationale` and `decidedBy`,
and the `timed_out` and `aborted` decisions. Regenerated the manifest
and validators.
- Fixed: the hook `blocking` flag read `executionMode === "blocking"`,
but Codex sends `sync` / `async`.
- UI: `native-run-events` keeps the full hook and review payloads.
`governance-presentation.ts` writes the row labels.
`fold-auto-reviews.ts` attaches an approved review to its command row.
The rows show the tint, the reason line and the "auto-approved" mark.
Activity summaries lead with a block or denial.
- Storybook: "Task chat / Governance rows" shows every outcome, a Codex
run, a Claude run and the live line.
- Docs: added a governance activity section to
`packages/paperclip-runner/docs/protocol-compatibility.md`.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/governance-events.test.ts src/provider-events.test.ts
src/drivers/codex src/drivers/acpx src/cli`. 2305 tests pass. This
includes a replay of real Codex 0.160 notifications from a session with
hooks and auto-review
(`test-fixtures/governance/codex-0.160-hooks-auto-review.json`,
sanitized).
- `cargo test --release -p paperclip-runner-core`. The full suite
passes, and `governance_events::tests` replays the same fixture.
- `cd ui && npx vitest run`. 11,047 tests pass.
- `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, and
the runner contract checks (`check:protocol-manifest`,
`check:protocol-types`, `check:capability-contract`,
`check:protocol-coverage`, replay goldens, contract fingerprints) pass.
- Live test on a local isolated instance. A Codex agent and a Claude
agent each used a preconfigured home with a PreToolUse hook that blocks
edits to `0001_init.sql`, plus UserPromptSubmit and PostToolUse hooks.
Both runs showed "PreToolUse hook blocked a tool call" (Codex) or
"PreToolUse hook blocked Bash" (Claude), with the hook's message. They
also showed "UserPromptSubmit hook added context", and the group summary
started with the block.
- Auto-review cannot run in Paperclip today, because the native Codex
runner only allows `approvalPolicy: never`. The review mapping and the
approval fold are proven against real Codex 0.160 app-server output
(fixture above) and in Storybook.

## Risks

- Low risk to execution. These records are display-only and carry no
authority. All new schema fields are optional, so older records stay
valid and older UIs ignore the new fields.
- The `auto-approved` fold depends on `targetItemId` matching the
command's execution id. Real Codex 0.160 data confirms that it matches.
When there is no match, the review stays as its own row.
- In Paperclip's isolated homes, hooks do not run, because the runner
never copies hooks into an isolated home. Rows appear only for agents
with a preconfigured home, or with future managed hooks.
- The claude-agent-acp patch changes. The ACPX profile check still
passes, because the patch is Paperclip's ACPX layer and not the upstream
release.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, browser and file editing) and extended reasoning.

## 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 <[email protected]>
2026-11-05 14:35:57 -06:00

252 lines
17 KiB
Diff

diff --git a/dist/acp-agent.js b/dist/acp-agent.js
index 0214a50..73f0c16 100644
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2490,11 +2490,88 @@ export class ClaudeAcpAgent {
break;
}
case "hook_started":
+ case "hook_response": {
+ // Paperclip patch: forward hook lifecycle so automatic hook
+ // decisions stay auditable. ACP has no hook update, so it rides
+ // on session_info_update `_meta` (no title/updatedAt change).
+ // Output is bounded; the runner derives the outcome.
+ const bounded = (value) => typeof value === "string" ? value.slice(0, 4000) : undefined;
+ // Read the hook's JSON decision from the full stdout before the
+ // display text is bounded, so a long reason cannot hide a block.
+ const hookDecision = (stdout) => {
+ if (typeof stdout !== "string" || !stdout.trim().startsWith("{")) return undefined;
+ let parsed;
+ try { parsed = JSON.parse(stdout); } catch { return undefined; }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
+ const specific = parsed.hookSpecificOutput && typeof parsed.hookSpecificOutput === "object" ? parsed.hookSpecificOutput : {};
+ const decision = {
+ ...(typeof parsed.continue === "boolean" ? { continue: parsed.continue } : {}),
+ ...(typeof parsed.stopReason === "string" ? { stopReason: bounded(parsed.stopReason) } : {}),
+ ...(typeof parsed.decision === "string" ? { decision: bounded(parsed.decision) } : {}),
+ ...(typeof parsed.reason === "string" ? { reason: bounded(parsed.reason) } : {}),
+ ...(typeof parsed.systemMessage === "string" ? { systemMessage: bounded(parsed.systemMessage) } : {}),
+ ...(typeof specific.permissionDecision === "string" ? { permissionDecision: bounded(specific.permissionDecision) } : {}),
+ ...(typeof specific.permissionDecisionReason === "string" ? { permissionDecisionReason: bounded(specific.permissionDecisionReason) } : {}),
+ ...(typeof specific.additionalContext === "string" ? { additionalContext: bounded(specific.additionalContext) } : {}),
+ };
+ return Object.keys(decision).length > 0 ? decision : undefined;
+ };
+ await sendUpdate({
+ sessionId: message.session_id,
+ update: {
+ sessionUpdate: "session_info_update",
+ _meta: {
+ claudeCode: {
+ hook: {
+ phase: message.subtype === "hook_started" ? "started" : "completed",
+ hookId: message.hook_id,
+ hookName: message.hook_name,
+ hookEvent: message.hook_event,
+ ...(message.subtype === "hook_response" ? {
+ outcome: message.outcome,
+ ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}),
+ output: bounded(message.output),
+ stdout: bounded(message.stdout),
+ stderr: bounded(message.stderr),
+ decision: hookDecision(message.stdout),
+ } : {}),
+ },
+ },
+ },
+ },
+ });
+ break;
+ }
case "hook_progress":
- case "hook_response":
case "files_persisted":
break;
- case "task_progress":
+ case "task_progress": {
+ // Paperclip: a subagent's progress summary (SDK option
+ // agentProgressSummaries) has no ACP home while native
+ // subagent sessions are off. Attach it to the spawning
+ // Agent/Task tool call so the client can show it there.
+ const progressTask = session.liveBackgroundTasks.get(message.task_id);
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" &&
+ progressTask?.isSubagent &&
+ progressTask.parentToolUseId &&
+ (message.summary || message.description)) {
+ await sendUpdate({
+ sessionId: message.session_id,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: progressTask.parentToolUseId,
+ _meta: {
+ claudeCode: {
+ subagentProgress: {
+ ...(message.summary ? { summary: String(message.summary).slice(0, 2000) } : {}),
+ ...(message.description ? { description: String(message.description).slice(0, 2000) } : {}),
+ ...(message.last_tool_name ? { lastToolName: String(message.last_tool_name).slice(0, 160) } : {}),
+ },
+ },
+ },
+ },
+ });
+ }
await asyncTasks.taskProgress({
task_id: message.task_id,
description: message.description,
@@ -2503,6 +2580,7 @@ export class ClaudeAcpAgent {
usage: message.usage,
});
break;
+ }
case "task_started":
// For subagent tasks `task_id` is the subagent's agent id (the
// SDK keys its task registry by agent id) and `tool_use_id` is
@@ -2550,6 +2628,7 @@ export class ClaudeAcpAgent {
case "task_notification":
// The task settled — no further tool calls can originate
// from it, so its registry entry can be dropped.
+ await this.paperclipForwardSubagentState(session, message.session_id, message.task_id, message.tool_use_id ?? null, message.status, message.summary, sendUpdate);
await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
await asyncTasks.taskNotification({
task_id: message.task_id,
@@ -2571,6 +2650,7 @@ export class ClaudeAcpAgent {
if (message.patch.status === "completed" ||
message.patch.status === "failed" ||
message.patch.status === "killed") {
+ await this.paperclipForwardSubagentState(session, message.session_id, message.task_id, undefined, message.patch.status, undefined, sendUpdate);
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
session.liveBackgroundTasks.delete(message.task_id);
}
@@ -2922,9 +3002,19 @@ export class ClaudeAcpAgent {
amount: message.total_cost_usd,
currency: "USD",
},
- ...(message.origin && {
- _meta: { "_claude/origin": message.origin },
- }),
+ _meta: {
+ ...(message.origin && { "_claude/origin": message.origin }),
+ // ACP's core Usage shape exposes context occupancy and
+ // cost, but not the billable input/output split. Keep the
+ // SDK's bounded aggregate in extension metadata so ACPX can
+ // normalize it without persisting a raw provider message.
+ usage: {
+ input_tokens: message.usage.input_tokens,
+ output_tokens: message.usage.output_tokens,
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
+ },
+ },
},
});
}
@@ -4506,6 +4596,48 @@ export class ClaudeAcpAgent {
* `$/cancel_request`, and our local abort race settles even if the client
* ignores it. A `cancelled` outcome, request rejection, and local abort all
* surface the same "Tool use aborted" the callers already expect. */
+ /**
+ * Paperclip: an async subagent's Agent/Task tool call completes as soon as
+ * the subagent launches. With native subagent sessions off, report the
+ * subagent's own terminal state and summary on that tool call so the
+ * client can settle its subagent card. Sent once per task.
+ */
+ async paperclipForwardSubagentState(session, sessionId, taskId, toolUseId, status, summary, sendUpdate) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1")
+ return;
+ // The terminal task_updated branch drops the live registry entry
+ // before task_notification arrives, so remember subagent parents here.
+ const parents = (session.paperclipSubagentParents ??= new Map());
+ const task = session.liveBackgroundTasks.get(taskId);
+ if (task?.isSubagent && task.parentToolUseId)
+ parents.set(taskId, task.parentToolUseId);
+ const parentToolUseId = parents.get(taskId) ?? (task?.isSubagent ? toolUseId : undefined);
+ if (!parentToolUseId)
+ return;
+ // The terminal task_updated patch can precede task_notification, which
+ // alone carries the summary; dedupe only the notification.
+ const forwarded = (session.paperclipForwardedSubagentStates ??= new Set());
+ if (forwarded.has(taskId))
+ return;
+ if (toolUseId !== undefined || summary)
+ forwarded.add(taskId);
+ await sendUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: parentToolUseId,
+ _meta: {
+ claudeCode: {
+ subagentState: {
+ status: String(status ?? "completed").slice(0, 40),
+ // One character past the display bound lets the client mark a cut.
+ ...(summary ? { summary: String(summary).slice(0, 4001) } : {}),
+ },
+ },
+ },
+ },
+ });
+ }
async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
if (signal.aborted)
throw new Error("Tool use aborted");
@@ -5311,14 +5443,24 @@ export class ClaudeAcpAgent {
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
+ // Paperclip Runner owns the complete session context. Its isolated
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
+ // Subagent progress summaries (~30s) feed the task chat's subagent card.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { agentProgressSummaries: true }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
cwd: params.cwd,
includePartialMessages: true,
+ // Paperclip patch: emit hook_started/hook_response for every hook event.
+ includeHookEvents: true,
forwardSubagentText,
mcpServers: {
- ...(userProvidedOptions?.mcpServers || {}),
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
+ ? {}
+ : (userProvidedOptions?.mcpServers || {})),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
@@ -5329,6 +5471,16 @@ export class ClaudeAcpAgent {
allowDangerouslySkipPermissions: ALLOW_BYPASS,
permissionMode: initialPermissionMode,
canUseTool: this.canUseTool(sessionId),
+ // The runner pins this actual MCP endpoint before provider launch.
+ // Exempt only task delivery/control tools at the SDK dispatch boundary,
+ // not arbitrary provider permission metadata or MCP name prefixes.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && {
+ allowedTools: mcpServers.paperclip?.type === "http"
+ && mcpServers.paperclip.url === process.env.PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL
+ ? ["paperclip_finish", "paperclip_block", "read_current_wake_comments", "request_human_input"]
+ .map((tool) => `mcp__paperclip__${tool}`)
+ : [],
+ }),
// Forward MCP elicitation requests onto ACP elicitation. Only attached
// when the client advertised support, so non-supporting clients keep the
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
diff --git a/dist/settings.js b/dist/settings.js
index fff1f03..2d98307 100644
--- a/dist/settings.js
+++ b/dist/settings.js
@@ -88,6 +88,12 @@ export class SettingsManager {
async loadAllSettings() {
try {
const resolved = await resolveSettings({ cwd: this.cwd });
+ const userPath = resolved.sources.find((entry) => entry.source === "user")?.path;
+ for (const entry of resolved.sources) {
+ if (userPath && entry.source === "project" && entry.path === userPath) {
+ entry.source = "user";
+ }
+ }
this.effective = filterEscalatingDefaultMode(resolved);
}
catch (error) {