Skip to content

Commit ff0ab7f

Browse files
committed
pi-agenticoding/03: guard manual handoff requests
1 parent 79a93d9 commit ff0ab7f

13 files changed

Lines changed: 533 additions & 265 deletions

CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12-
- Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff <direction>`.
12+
- Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` suppresses automatic handoff guidance and blocks direct agent-initiated handoff unless an explicit `/handoff <direction>` request is pending.
1313
- Added the extension-owned `/agenticoding-settings` TUI panel for automatic handoff availability. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, persist real booleans, and visibly warn when a project override masks the global value.
14-
- Manual `/handoff <direction>` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle.
14+
- Manual `/handoff <direction>` remains available even when automatic handoff is disabled: it records the operator request, sends the handoff prompt, and lets the guarded `handoff` tool compact that requested turn.
15+
16+
### Fixed
17+
18+
- Queued manual `/handoff` follow-up prompts can no longer be preempted by an older agent turn's automatic handoff call before the generated user turn starts.
19+
- Global `handoff.automaticEnabled` saves now write through a same-directory temporary file and rename over the target, preserving the previous settings file if replacement fails.
1520

1621
## [0.3.0] - 2026-05-23
1722

@@ -114,4 +119,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
114119
[0.3.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.2.0...v0.3.0
115120
[0.2.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.1.0...v0.2.0
116121
[0.1.0]: https://github.com/agenticoding/pi-agenticoding/releases/tag/v0.1.0
117-

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in
127127
}
128128
```
129129

130-
Settings are read from `~/.pi/agent/settings.json` and `<project>/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff <direction>` still works from idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and auto-sends `Proceed.` after successful compaction.
130+
Settings are read from `~/.pi/agent/settings.json` and `<project>/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, handoff-call guidance is removed from normal turns and direct `handoff` tool calls are rejected unless they are satisfying an explicit operator `/handoff <direction>` request. The tool remains registered; the setting is enforced by runtime guards rather than provider-schema removal.
131131

132-
Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. Edit or remove project overrides manually.
132+
Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect prompt guidance on future fresh agent turns, while direct handoff tool calls are checked against the effective setting at execution time. Edit or remove project overrides manually.
133133

134134
**Rule of thumb:** The notebook holds reusable learned knowledge. Handoff carries the remaining situational context.
135135

agenticoding.test.ts

Lines changed: 338 additions & 45 deletions
Large diffs are not rendered by default.

handoff/availability.ts

Lines changed: 0 additions & 61 deletions
This file was deleted.

handoff/cleanup.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
import type { AgenticodingState } from "../state.js";
33
import { STATUS_KEY_HANDOFF } from "../tui.js";
4-
import { updateHandoffToolAvailability } from "./availability.js";
54

65
export function emitHandoffDiagnostic(
76
pi: ExtensionAPI,
@@ -14,8 +13,17 @@ export function emitHandoffDiagnostic(
1413
}
1514
}
1615

16+
export function clearPendingHandoffCompaction(state: AgenticodingState, ctx: ExtensionContext): void {
17+
state.pendingHandoff = null;
18+
state.pendingRequestedHandoff = null;
19+
state.pendingRequestedHandoffPrompt = null;
20+
if (ctx.hasUI) {
21+
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
22+
}
23+
}
24+
1725
export async function clearStaleRequestedHandoff(
18-
pi: ExtensionAPI,
26+
_pi: ExtensionAPI,
1927
state: AgenticodingState,
2028
ctx: ExtensionContext,
2129
): Promise<void> {
@@ -28,5 +36,4 @@ export async function clearStaleRequestedHandoff(
2836
if (ctx.hasUI) {
2937
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
3038
}
31-
await updateHandoffToolAvailability(pi, state, ctx);
3239
}

handoff/command.ts

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@
88
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
99
import type { AgenticodingState } from "../state.js";
1010
import { STATUS_KEY_HANDOFF } from "../tui.js";
11-
import { temporarilyActivateHandoffTool, updateHandoffToolAvailability } from "./availability.js";
12-
import { emitHandoffDiagnostic } from "./cleanup.js";
11+
12+
function clearPendingManualHandoffStartFailure(state: AgenticodingState, ctx: { hasUI?: boolean; ui?: { setStatus?: (key: string, status: string | undefined) => void; notify?: (message: string, level: "error") => void } }, error: unknown): void {
13+
state.pendingRequestedHandoff = null;
14+
state.pendingRequestedHandoffPrompt = null;
15+
if (ctx.hasUI) {
16+
ctx.ui?.setStatus?.(STATUS_KEY_HANDOFF, undefined);
17+
ctx.ui?.notify?.(
18+
`Manual /handoff could not start: ${error instanceof Error ? error.message : String(error)}`,
19+
"error",
20+
);
21+
}
22+
}
1323

1424
export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void {
1525
pi.registerCommand("handoff", {
1626
description:
1727
"Ask the LLM to draft a handoff brief that completes the picture from " +
18-
"your direction, then perform the handoff manually.",
28+
"your direction, then perform the requested handoff.",
1929

2030
handler: async (args, ctx) => {
2131
const direction = args.trim();
@@ -24,44 +34,14 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat
2434
return;
2535
}
2636

27-
let isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
28-
if (!isIdle) {
29-
if (ctx.hasUI && ctx.ui.theme) {
30-
ctx.ui.setStatus?.(
31-
STATUS_KEY_HANDOFF,
32-
ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff pending"),
33-
);
34-
}
35-
if (typeof ctx.waitForIdle === "function") {
36-
await ctx.waitForIdle();
37-
}
38-
isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
39-
}
40-
41-
if (!isIdle) {
42-
if (ctx.hasUI) {
43-
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
44-
ctx.ui.notify?.("Manual /handoff is waiting for the assistant to become idle before starting a fresh handoff turn.", "warning");
45-
}
46-
return;
47-
}
48-
49-
const prompt = `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the \`handoff\` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the \`handoff\` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.`;
37+
const prompt = `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the \`handoff\` tool with the brief as its task so the session actually compacts. Do not answer with only prose.`;
5038
state.pendingRequestedHandoff = {
5139
direction,
5240
enforcementAttempts: 0,
5341
toolCalled: false,
5442
awaitingAgentTurn: true,
5543
};
5644
state.pendingRequestedHandoffPrompt = prompt;
57-
const activated = temporarilyActivateHandoffTool(pi);
58-
if (!activated) {
59-
state.pendingRequestedHandoff = null;
60-
state.pendingRequestedHandoffPrompt = null;
61-
const message = "Manual /handoff cannot compact because the handoff tool could not be activated for the next agent turn.";
62-
emitHandoffDiagnostic(pi, ctx, message, "error");
63-
return;
64-
}
6545

6646
// Show live progress indicator in footer
6747
if (ctx.hasUI && ctx.ui.theme) {
@@ -71,18 +51,14 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat
7151
);
7252
}
7353

74-
void Promise.resolve(pi.sendUserMessage(prompt)).catch(async (error) => {
75-
state.pendingRequestedHandoff = null;
76-
state.pendingRequestedHandoffPrompt = null;
77-
if (ctx.hasUI) {
78-
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
79-
ctx.ui.notify?.(
80-
`Manual /handoff could not start: ${error instanceof Error ? error.message : String(error)}`,
81-
"error",
82-
);
83-
}
84-
await updateHandoffToolAvailability(pi, state, ctx);
85-
});
54+
const isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
55+
try {
56+
void Promise.resolve(pi.sendUserMessage(prompt, isIdle ? undefined : { deliverAs: "followUp" })).catch((error) => {
57+
clearPendingManualHandoffStartFailure(state, ctx, error);
58+
});
59+
} catch (error) {
60+
clearPendingManualHandoffStartFailure(state, ctx, error);
61+
}
8662
},
8763
});
8864
}

handoff/compact.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-wor
99
import type { AgenticodingState } from "../state.js";
1010
import { clearActiveNotebookTopic } from "../notebook/topic.js";
1111
import { STATUS_KEY_HANDOFF } from "../tui.js";
12-
import { updateHandoffToolAvailability } from "./availability.js";
1312

1413
function getImpossibleKeptId(branchEntries: SessionEntry[]): string {
1514
const leaf = branchEntries[branchEntries.length - 1];
@@ -32,7 +31,6 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS
3231
if (ctx.hasUI) {
3332
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
3433
}
35-
await updateHandoffToolAvailability(pi, state, ctx);
3634

3735
return {
3836
compaction: {

handoff/tool.ts

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1313
import { Type } from "typebox";
1414
import type { AgenticodingState } from "../state.js";
15-
import { STATUS_KEY_HANDOFF } from "../tui.js";
16-
import { updateHandoffToolAvailability } from "./availability.js";
15+
import { resolveHandoffAutomaticAvailability } from "../settings.js";
16+
import { clearPendingHandoffCompaction } from "./cleanup.js";
1717

1818
/**
1919
* Build the enriched task that becomes the compaction summary.
@@ -47,42 +47,30 @@ export function registerHandoffTool(
4747
name: "handoff",
4848
label: "Handoff",
4949
description:
50-
"Replace the active context with a compact task brief at the end of " +
51-
"the current turn while keeping full history in the session file. Handoff clears the active notebook topic so the next clean context can assign a fresh one.\n\n" +
52-
"WHEN TO USE:\n" +
53-
" 1. Context past ~30% and the current job is no longer cleanly " +
54-
"represented near the front of attention.\n" +
55-
" 2. Context is filled with mechanics irrelevant to what comes " +
56-
"next (research traces, planning deliberation, dead ends).\n" +
57-
" 3. The current job is complete and a new distinct task starts.\n\n" +
58-
"Rule: one context, one job. When the job changes, call handoff.\n\n" +
59-
"AFTER HANDOFF the LLM sees:\n" +
60-
" • System prompt + context primer\n" +
61-
" • The handoff task — the distilled next work at the top of context\n" +
62-
" • All notebook pages — durable grounding accessible via notebook_read / notebook_index",
63-
64-
promptSnippet: "Pivot to a new job via deliberate handoff compaction",
65-
promptGuidelines: [
66-
"Before handoff, promote any missing durable grounding knowledge that the next context will need to the notebook. " +
67-
"Then draft a concise but sufficiently detailed brief with the distilled next task and immediate starting state for the next clean context. The active notebook topic will reset after handoff, so the next context should assign a fresh topic from the brief or user direction.",
68-
],
50+
"Performs authorized context compaction with a supplied task brief. " +
51+
"Availability is enforced at execution time by extension state and settings.",
6952

7053
executionMode: "sequential",
7154

7255
parameters: Type.Object({
7356
task: Type.String({
7457
description:
75-
"What to do next. A concise but sufficiently detailed handoff brief. " +
76-
"This becomes the FIRST thing the LLM sees after handoff. Capture the distilled next task, " +
77-
"immediate starting state, blockers, failed paths worth avoiding, and relevant notebook page names. " +
78-
"The notebook is the long-term grounding store; this brief should carry only the remaining situational context.",
58+
"Task brief to place at the start of the next compacted context when this handoff request is authorized.",
7959
}),
8060
}),
8161

8262
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
83-
const availability = await updateHandoffToolAvailability(pi, state, ctx);
63+
const availability = await resolveHandoffAutomaticAvailability(ctx);
8464
const manualRequest = state.pendingRequestedHandoff;
85-
if (!availability.automaticEnabled && !manualRequest) {
65+
const awaitingManualRequest = manualRequest?.awaitingAgentTurn === true;
66+
const activeManualRequest = manualRequest?.awaitingAgentTurn === false ? manualRequest : null;
67+
if (awaitingManualRequest) {
68+
return {
69+
content: [{ type: "text", text: "A manual /handoff request is queued, but its generated user turn has not started yet. No compaction was started." }],
70+
details: { automaticEnabled: availability.automaticEnabled, manualRequest: "awaiting_agent_turn" },
71+
};
72+
}
73+
if (!availability.automaticEnabled && !activeManualRequest) {
8674
if (ctx.hasUI) {
8775
ctx.ui.notify("Automatic handoff is disabled by handoff.automaticEnabled=false; use the explicit /handoff <direction> command to request a manual handoff.", "warning");
8876
}
@@ -94,23 +82,22 @@ export function registerHandoffTool(
9482

9583
const enrichedTask = buildEnrichedTask(params.task);
9684
state.pendingHandoff = { task: enrichedTask, source: "tool" };
97-
if (manualRequest) {
98-
manualRequest.toolCalled = true;
85+
if (activeManualRequest) {
86+
activeManualRequest.toolCalled = true;
87+
}
88+
try {
89+
ctx.compact({
90+
onComplete: () => {
91+
pi.sendUserMessage("Proceed.");
92+
},
93+
onError: () => {
94+
clearPendingHandoffCompaction(state, ctx);
95+
},
96+
});
97+
} catch (error) {
98+
clearPendingHandoffCompaction(state, ctx);
99+
throw error;
99100
}
100-
ctx.compact({
101-
onComplete: () => {
102-
pi.sendUserMessage("Proceed.");
103-
},
104-
onError: () => {
105-
state.pendingHandoff = null;
106-
state.pendingRequestedHandoff = null;
107-
state.pendingRequestedHandoffPrompt = null;
108-
if (ctx.hasUI) {
109-
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
110-
}
111-
void updateHandoffToolAvailability(pi, state, ctx);
112-
},
113-
});
114101

115102
return {
116103
content: [{ type: "text", text: "Handoff started." }],

0 commit comments

Comments
 (0)