diff --git a/CHANGELOG.md b/CHANGELOG.md index 761e65f..1a07236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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 ` request is active in its generated agent turn. +- 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. +- Manual `/handoff ` remains available even when automatic handoff is disabled: it records a unique operator request, queues the generated handoff prompt, and lets the guarded `handoff` tool compact only after that requested turn becomes active. +- Successful enabled or manual handoff compaction now always resumes with Pi's fixed post-compaction `Proceed.` continuation; this behavior is not configurable. - Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools. +### Fixed + +- 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, including repeated same-direction requests with identical user directions. +- Queued manual `/handoff` status/warnings distinguish pending follow-up delivery from the active generated turn, so the current agent is not told to call a tool that will reject until the generated turn starts. +- Malformed present `handoff` settings parents such as `{ "handoff": null }` now fail closed instead of falling through to defaults. +- Unsupported `handoff.automaticEnabled` string diagnostics and TUI display values are JSON-escaped to prevent newline/control-character spoofing. +- Global `handoff.automaticEnabled` saves now write through a same-directory temporary file and rename over the target, preserve the previous settings file if replacement fails, and keep an existing settings file's mode bits when replacing it. + ## [0.3.0] - 2026-05-23 ### Added @@ -108,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Comprehensive test suite** β€” 50+ tests covering spawn execution and rendering (concurrency, cancellation, truncation, stale detection, ownership lifecycle, microtask batching), ledger tools (add/get/list, staleness, rehydration, empty states, prompt hints), handoff (tool, command, compaction), watchdog (nudge injection, enforcement), and extension lifecycle. - **MIT licensed** β€” open-source permissive license. +[Unreleased]: https://github.com/agenticoding/pi-agenticoding/compare/v0.3.0...HEAD [0.3.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/agenticoding/pi-agenticoding/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/agenticoding/pi-agenticoding/releases/tag/v0.1.0 diff --git a/README.md b/README.md index bc91c1c..3064d3c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Then disable pi's built-in compaction so handoff stays in control: } ``` -That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `notebook_index`, and `handoff`. The status bar shows context usage and notebook count. +Optional automatic handoff availability can be changed later with `/agenticoding-settings`. + +That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `notebook_index`, `handoff`, and `/agenticoding-settings`. The status bar shows context usage and notebook count. --- @@ -50,7 +52,8 @@ That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `noteb |---------|-------------------| | **Context usage %** | `ctx 65%` in status bar β€” green < 30%, yellow < 50%, orange < 70%, red β‰₯ 70% | | **Notebook count** | πŸ“’ `3` when pages exist, dim `πŸ“’ 0` when empty | -| **`/handoff` command** | Instant pivot β€” agent drafts brief, compacts context, resumes | +| **`/handoff` command** | Explicit manual pivot β€” agent drafts brief, compacts context, then Pi sends `Proceed.` in the fresh context | +| **`/agenticoding-settings` command** | TUI panel for global `handoff.automaticEnabled`, with project override warnings | | **`/notebook` command** | Overlay showing all notebook pages with previews | | **Auto-rehydration** | Notebook pages survive session restarts | | **Spawn transparency** | Watch child agents work in real time in the TUI | @@ -114,6 +117,20 @@ A sparse pocket notebook the agent curates while working. After discovering some When context degrades or the job changes, the agent saves reusable state to the notebook, writes a focused brief preserving what's still missing, and restarts clean. The new context starts with the brief front-and-center, all notebook pages accessible, and zero noise. +By default, automatic handoff is enabled: the agent can see the `handoff` tool and may use it at context/job boundaries. After successful handoff compaction, Pi auto-sends `Proceed.` so the fresh context continues immediately; this continuation is fixed, not configurable. + +To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in raw Pi settings JSON. Supported persisted values are JSON booleans `true` and `false`; missing settings default to `true`. + +```json +{ + "handoff": { "automaticEnabled": false } +} +``` + +Settings are read from `~/.pi/agent/settings.json` and `/.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 active explicit operator `/handoff ` request in the generated user turn. A queued manual request is only pending delivery; it does not authorize the current turn to call the tool. The tool remains registered; the setting is enforced by runtime guards rather than provider-schema removal. + +Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys and an existing file's mode bits, 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. Malformed present `handoff` parents such as `{ "handoff": null }` fail closed; edit or remove project overrides manually. + **Rule of thumb:** The notebook holds reusable learned knowledge. Handoff carries the remaining situational context. --- diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 5468314..b993a09 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,14 +1,14 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { AuthStorage, ModelRegistry, type Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; -import { buildNudge, registerWatchdog } from "./watchdog.js"; +import { buildNudge, buildQueuedManualHandoffNudge, registerWatchdog } from "./watchdog.js"; import { createState, resetState } from "./state.js"; import { buildChildToolNames, @@ -23,7 +23,17 @@ import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; import { saveNotebookPage, resetNotebookWriteLock } from "./notebook/store.js"; import { createNotebookToolDefinitions } from "./notebook/tools.js"; import registerAgenticoding from "./index.js"; -import { CONTEXT_PRIMER } from "./system-prompt.js"; +import { + MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, + buildAgenticodingSettingsModel, + createAgenticodingSettingsComponent, + getAgenticodingSettingsDisplayLines, + readHandoffSettingsState, + resolveHandoffAutomaticAvailability, + setSettingsAtomicWriteOperationsForTest, + writeGlobalHandoffAutomaticEnabled, +} from "./settings.js"; +import { CONTEXT_PRIMER, getContextPrimer } from "./system-prompt.js"; import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; // Safety net: reset module-level mutable state after all tests. @@ -108,6 +118,7 @@ class MockPi { allToolNames: string[] | undefined; toolSources = new Map(); sentUserMessages: Array<{ content: string; options: any }> = []; + sentMessages: Array<{ message: any; options: any }> = []; appendedEntries: Array<{ customType: string; data: any }> = []; registerCommand(name: string, definition: { description?: string; handler: Handler }) { @@ -172,11 +183,77 @@ class MockPi { this.sentUserMessages.push({ content, options }); } + sendMessage(message: any, options?: any) { + this.sentMessages.push({ message, options }); + } + appendEntry(customType: string, data: any) { this.appendedEntries.push({ customType, data }); } } +async function writeSettingsFile(path: string, content: unknown) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, typeof content === "string" ? content : JSON.stringify(content), "utf8"); +} + +async function withIsolatedSettings(fn: (paths: { home: string; cwd: string }) => Promise): Promise { + const tmp = await mkdtemp(join(tmpdir(), "pi-agenticoding-settings-")); + const previousHome = process.env.HOME; + process.env.HOME = join(tmp, "home"); + const cwd = join(tmp, "project"); + await mkdir(cwd, { recursive: true }); + try { + return await fn({ home: process.env.HOME, cwd }); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await rm(tmp, { recursive: true, force: true }); + } +} + +async function runHandoffResumeScenario(options: { + globalSettings?: unknown; + projectSettings?: unknown; +} = {}) { + return withIsolatedSettings(async ({ home, cwd }) => { + if (options.globalSettings !== undefined) { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), options.globalSettings); + } + if (options.projectSettings !== undefined) { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), options.projectSettings); + } + + const pi = new MockPi(); + const state = createState(); + registerHandoffTool(pi as any, state); + let compactOptions: any; + const notifications: Array<{ message: string; level: string }> = []; + + const toolResult = await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + cwd, + hasUI: true, + ui: { + notify: (message: string, level: string) => notifications.push({ message, level }), + }, + compact: (compactOptionsArg: any) => { + compactOptions = compactOptionsArg; + }, + }, + ); + + return { sentUserMessages: pi.sentUserMessages, notifications, compactOptions, activeTools: pi.activeTools, toolResult }; + }); +} + const EMPTY_USAGE = { input: 0, output: 0, @@ -320,104 +397,1298 @@ test("updateIndicators shows active notebook topic when set", () => { const record = { statuses: new Map(), widgets: new Map() }; const ctx = makeTUICtx({ percent: 30, record }); - updateIndicators(ctx, state); - assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); + updateIndicators(ctx, state); + assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); +}); + +test("updateIndicators hides widget below 70% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + // Pre-set a widget to verify it gets cleared + record.widgets.set("agenticoding-warning", ["existing"]); + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); +}); + +// ── Handoff tests ───────────────────────────────────────────────────── + +test("/handoff sends the direction back through the LLM without opening the editor", async () => { + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { notify: (_message: string) => {} }, + }); + + assert.equal(state.pendingRequestedHandoff?.direction, "implement auth"); + assert.equal(state.pendingRequestedHandoff?.enforcementAttempts, 0); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(state.pendingRequestedHandoff?.awaitingAgentTurn, true); + assert.match(state.pendingRequestedHandoff?.requestId ?? "", /^[0-9a-f-]{36}$/i); + assert.equal(pi.sentUserMessages.length, 1); + assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/); + assert.match(pi.sentUserMessages[0].content, /Manual handoff request id: [0-9a-f-]{36}/i); + assert.match(pi.sentUserMessages[0].content, /After drafting the brief, you must call the `handoff` tool/); + assert.equal(pi.sentUserMessages[0].options, undefined); +}); + +test("/handoff requires a direction", async () => { + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + const notifications: string[] = []; + await pi.commands.get("handoff")!.handler(" ", { + hasUI: true, + isIdle: () => true, + ui: { notify: (message: string) => notifications.push(message) }, + }); + + assert.deepEqual(notifications, ["Usage: /handoff "]); + assert.deepEqual(pi.sentUserMessages, []); +}); + +test("/handoff clears pending manual request when sendUserMessage throws synchronously", async () => { + const pi = new MockPi(); + pi.sendUserMessage = () => { throw new Error("send failed"); }; + const state = createState(); + registerHandoffCommand(pi as any, state); + const notifications: Array<{ message: string; level: string }> = []; + const statuses: Array = []; + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme, + setStatus: (_key: string, status: string | undefined) => statuses.push(status), + notify: (message: string, level: string) => notifications.push({ message, level }), + }, + }); + + assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingRequestedHandoffPrompt, null); + assert.deepEqual(statuses, ["🀝 Handoff queued", undefined]); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "error"); + assert.match(notifications[0].message, /send failed/); +}); + +test("/handoff async send failure does not clear a later manual request", async () => { + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + let sendCount = 0; + let rejectFirst!: (error: Error) => void; + const firstSend = new Promise((_resolve, reject) => { rejectFirst = reject; }); + pi.sendUserMessage = (content: string, options?: any) => { + pi.sentUserMessages.push({ content, options }); + sendCount++; + return sendCount === 1 ? firstSend : new Promise(() => {}); + }; + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + hasUI: true, + isIdle: () => true, + ui: { + theme, + setStatus: () => {}, + notify: (message: string, level: string) => notifications.push({ message, level }), + }, + }; + + await pi.commands.get("handoff")!.handler("first", ctx); + await pi.commands.get("handoff")!.handler("second", ctx); + assert.equal(state.pendingRequestedHandoff?.direction, "second"); + + rejectFirst(new Error("first failed later")); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.equal(state.pendingRequestedHandoff?.direction, "second"); + assert.match(state.pendingRequestedHandoffPrompt ?? "", /Handoff direction: second/); + assert.deepEqual(notifications, []); +}); + +test("handoff automatic setting defaults to enabled with post-compaction Proceed", async () => { + const pi = new MockPi(); + const state = createState(); + state.notebookPages.set("auth-refresh", "sensitive notebook body"); + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false }; + registerHandoffTool(pi as any, state); + + let compactOptions: any; + const result = await withIsolatedSettings(async ({ cwd }) => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue auth-refresh" }, + undefined, + undefined, + { + cwd, + compact: (options: any) => { + compactOptions = options; + }, + }, + )); + + assert.equal(state.pendingHandoff?.source, "tool"); + assert.match(state.pendingHandoff?.task ?? "", /## Handoff β€” Continue Previous Work/); + assert.match(state.pendingHandoff?.task ?? "", /Notebook pages hold durable grounding knowledge/); + assert.match(state.pendingHandoff?.task ?? "", /distilled next task and immediate situational context/); + assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); + assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + assert.equal(typeof compactOptions?.onComplete, "function"); + assert.equal(result.content[0].text, "Handoff started."); + assert.equal(result.terminate, true); + + compactOptions.onComplete({}); + assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); +}); + +test("handoff automatic setting true proceeds after compaction", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { automaticEnabled: false } }, + projectSettings: { handoff: { automaticEnabled: true } }, + }); + + assert.ok(result.compactOptions); + result.compactOptions.onComplete({}); + assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); + assert.deepEqual(result.notifications, []); + assert.deepEqual(result.activeTools, []); +}); + +test("handoff automatic setting false leaves tool registered but blocks stale direct calls", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { automaticEnabled: true } }, + projectSettings: { handoff: { automaticEnabled: false } }, + }); + + assert.equal(result.compactOptions, undefined); + assert.deepEqual(result.sentUserMessages, []); + assert.equal(result.notifications.length, 1); + assert.match(result.notifications[0].message, /Automatic handoff is disabled/); + assert.deepEqual(result.activeTools, []); + assert.match(result.toolResult.content[0].text, /No compaction was started/); +}); + +test("handoff automatic setting ignores prototype/meta keys unless automaticEnabled is own nested setting", async () => { + const topLevelPrototypeResult = await runHandoffResumeScenario({ + globalSettings: '{"__proto__":{"handoff":{"automaticEnabled":false}}}', + }); + assert.ok(topLevelPrototypeResult.compactOptions); + assert.deepEqual(topLevelPrototypeResult.notifications, []); + + const nestedPrototypeResult = await runHandoffResumeScenario({ + globalSettings: { handoff: { other: true } }, + projectSettings: '{"handoff":{"__proto__":{"automaticEnabled":false}}}', + }); + assert.ok(nestedPrototypeResult.compactOptions); + assert.deepEqual(nestedPrototypeResult.notifications, []); + + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), '{"__proto__":{"handoff":{"automaticEnabled":false}}}'); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), '{"handoff":{"__proto__":{"automaticEnabled":false}}}'); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveAutomaticEnabled, true); + assert.equal(model.effectiveSource, "default"); + assert.equal(model.projectOverride, false); + assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Resolved handoff\.automaticEnabled: true \(default\)/); + }); +}); + +test("handoff automatic setting malformed handoff parent fails closed", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { automaticEnabled: true } }, + projectSettings: { handoff: null }, + }); + + assert.equal(result.compactOptions, undefined); + assert.equal(result.notifications.length, 2); + assert.match(result.notifications[0].message, /handoff must be an object/); + assert.match(result.notifications[0].message, /automatic handoff disabled/); + + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { automaticEnabled: false } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: null }); + + const state = await readHandoffSettingsState(cwd); + assert.equal(state.project.invalid, true); + assert.equal(state.project.invalidReason, "malformed-handoff"); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveAutomaticEnabled, false); + assert.equal(model.effectiveSource, "fallback"); + assert.equal(model.projectOverride, false); + assert.match(model.messages.join("\n"), /handoff must be an object/); + assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Project settings: .*malformed handoff/); + }); +}); + +test("handoff automatic setting unsupported value fails closed with diagnostic", async () => { + const result = await runHandoffResumeScenario({ + projectSettings: { handoff: { automaticEnabled: "surprise" } }, + }); + + assert.equal(result.compactOptions, undefined); + assert.deepEqual(result.sentUserMessages, []); + assert.equal(result.notifications.length, 2); + assert.equal(result.notifications[0].level, "warning"); + assert.match(result.notifications[0].message, /Unsupported handoff\.automaticEnabled/); + assert.match(result.notifications[0].message, /surprise/); + assert.match(result.notifications[0].message, /automatic handoff disabled/); +}); + +test("handoff automatic setting unsupported string diagnostics are JSON escaped", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const unsafe = "bad\"\n[spoofed warning]"; + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: unsafe } }); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { cwd, hasUI: true, ui: { notify: (message: string, level: string) => notifications.push({ message, level }) } } as any; + + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + assert.equal(notifications.length, 1); + assert.match(notifications[0].message, /"bad\\"\\n\[spoofed warning\]"/); + assert.doesNotMatch(notifications[0].message, /bad"\n\[spoofed warning\]/); + + const model = await buildAgenticodingSettingsModel(ctx); + const display = getAgenticodingSettingsDisplayLines(model).join("\n"); + assert.match(display, /"bad\\"\\n\[spoofed warning\]"/); + assert.doesNotMatch(display, /bad"\n\[spoofed warning\]/); + }); +}); + +test("handoff automatic setting invalid JSON fails closed with diagnostic", async () => { + const globalResult = await runHandoffResumeScenario({ globalSettings: "{" }); + assert.equal(globalResult.compactOptions, undefined); + assert.equal(globalResult.notifications[0].level, "warning"); + assert.match(globalResult.notifications[0].message, /Invalid global settings JSON/); + assert.match(globalResult.notifications[0].message, /automatic handoff disabled/); + + const projectResult = await runHandoffResumeScenario({ + globalSettings: { handoff: { automaticEnabled: true } }, + projectSettings: "{", + }); + assert.equal(projectResult.compactOptions, undefined); + assert.equal(projectResult.notifications[0].level, "warning"); + assert.match(projectResult.notifications[0].message, /Invalid project settings JSON/); + assert.match(projectResult.notifications[0].message, /automatic handoff disabled/); +}); + +test("handoff automatic setting diagnostics are deduplicated across repeated availability resolution", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const projectSettingsPath = join(cwd, ".pi", "settings.json"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: "surprise" } }); + assert.equal((await resolveHandoffAutomaticAvailability({ ...ctx, hasUI: false })).automaticEnabled, false); + for (let i = 0; i < 3; i++) { + const availability = await resolveHandoffAutomaticAvailability(ctx); + assert.equal(availability.automaticEnabled, false); + } + assert.equal(notifications.filter((n) => /Unsupported handoff\.automaticEnabled/.test(n.message)).length, 1); + + await writeFile(projectSettingsPath, "{", "utf8"); + for (let i = 0; i < 3; i++) { + const availability = await resolveHandoffAutomaticAvailability(ctx); + assert.equal(availability.automaticEnabled, false); + } + assert.equal(notifications.filter((n) => /Invalid project settings JSON/.test(n.message)).length, 1); + assert.equal(notifications.length, 2); + assert.deepEqual(notifications.map((n) => n.level), ["warning", "warning"]); + }); +}); + +test("handoff automatic setting diagnostics re-emit after fixed settings break again", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const projectSettingsPath = join(cwd, ".pi", "settings.json"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: "surprise" } }); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + assert.equal(notifications.filter((n) => /Unsupported handoff\.automaticEnabled/.test(n.message)).length, 1); + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: true } }); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, true); + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: "surprise" } }); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + assert.equal(notifications.filter((n) => /Unsupported handoff\.automaticEnabled/.test(n.message)).length, 2); + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: false } }); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + await writeFile(projectSettingsPath, "{", "utf8"); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + assert.equal(notifications.filter((n) => /Invalid project settings JSON/.test(n.message)).length, 1); + + await writeSettingsFile(projectSettingsPath, { handoff: { automaticEnabled: true } }); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, true); + await writeFile(projectSettingsPath, "{", "utf8"); + assert.equal((await resolveHandoffAutomaticAvailability(ctx)).automaticEnabled, false); + assert.equal(notifications.filter((n) => /Invalid project settings JSON/.test(n.message)).length, 2); + }); +}); + +test("handoff automatic setting non-ENOENT read errors are distinguished from invalid JSON", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await mkdir(globalPath, { recursive: true }); + + const state = await readHandoffSettingsState(cwd); + assert.equal(state.global.invalid, true); + assert.equal(state.global.invalidReason, "read-error"); + assert.equal(state.global.readErrorCode, "EISDIR"); + assert.equal(state.global.exists, true); + assert.equal(state.project.invalid, false); + + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (msg: string, level: string) => notifications.push({ message: msg, level }) }, + } as any; + const availability = await resolveHandoffAutomaticAvailability(ctx); + assert.equal(availability.automaticEnabled, false); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "warning"); + assert.match(notifications[0].message, /Unable to read global settings/); + assert.match(notifications[0].message, /EISDIR/); + assert.doesNotMatch(notifications[0].message, /Invalid global settings JSON/); + }); + + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { automaticEnabled: true } }); + const projectPath = join(cwd, ".pi", "settings.json"); + await mkdir(projectPath, { recursive: true }); + + const notifications: Array<{ message: string; level: string }> = []; + const model = await buildAgenticodingSettingsModel({ + cwd, + hasUI: true, + ui: { notify: (msg: string, level: string) => notifications.push({ message: msg, level }) }, + } as any); + assert.equal(model.state.project.invalidReason, "read-error"); + assert.equal(model.effectiveAutomaticEnabled, false); + assert.match(model.messages.join("\n"), /Unable to read project settings/); + assert.match(model.messages.join("\n"), /EISDIR/); + assert.doesNotMatch(model.messages.join("\n"), /Invalid project settings JSON/); + assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Project settings: .*unreadable \(EISDIR\)/); + }); +}); + +test("handoff resumeBehavior is ignored and completion still uses fixed Proceed", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { resumeBehavior: "proceed" } }, + }); + + assert.ok(result.compactOptions); + result.compactOptions.onComplete({}); + assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); + assert.deepEqual(result.notifications, []); +}); + +test("manual slash handoff permits handoff when automatic handoff is disabled", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: true, + isIdle: () => true, + ui: { theme, notify: () => {}, setStatus: () => {} }, + }); + assert.deepEqual(pi.activeTools, []); + state.pendingRequestedHandoff!.awaitingAgentTurn = false; + + let compactOptions: any; + await pi.tools.get("handoff").execute("1", { task: "continue" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.ok(compactOptions); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + + compactOptions.onComplete({}); + assert.equal(pi.sentUserMessages.at(-1)?.content, "Proceed."); + }); +}); + +test("manual slash handoff preserves retry request after compaction error without mutating active tools", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + registerHandoffCompaction(pi as any, state); + const [compactHandler] = pi.handlers.get("session_before_compact")!; + + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); + state.pendingRequestedHandoff!.awaitingAgentTurn = false; + let compactOptions: any; + await pi.tools.get("handoff").execute("1", { task: "continue" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + await compactHandler({ branchEntries: [], preparation: { tokensBefore: 10 } }, { cwd, hasUI: false } as any); + assert.deepEqual(pi.activeTools, []); + + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); + state.pendingRequestedHandoff!.awaitingAgentTurn = false; + await pi.tools.get("handoff").execute("2", { task: "continue" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + compactOptions.onError({}); + await new Promise(resolve => setTimeout(resolve, 10)); + assert.deepEqual(pi.activeTools, []); + assert.equal(state.pendingRequestedHandoff?.direction, "implement auth"); + assert.equal(state.pendingRequestedHandoff?.enforcementAttempts, 0); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(state.pendingRequestedHandoff?.awaitingAgentTurn, false); + assert.match(state.pendingRequestedHandoff?.requestId ?? "", /^[0-9a-f-]{36}$/i); + assert.match(state.pendingRequestedHandoffPrompt ?? "", /Handoff direction: implement auth/); + }); + + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); + assert.deepEqual(pi.activeTools, []); + const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; + const [turnEnd] = pi.handlers.get("turn_end")!; + await beforeAgentStart( + { prompt: pi.sentUserMessages[0].content, systemPrompt: "base" }, + { cwd, hasUI: false } as any, + ); + await turnEnd({}, { cwd, hasUI: false, getContextUsage: () => null } as any); + assert.equal(pi.activeTools.includes("handoff"), false); + }); +}); + +test("manual slash handoff permits disabled-mode handoff when the queued user message starts", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => true, + }); + + const [messageStart] = pi.handlers.get("message_start")!; + await messageStart({ + message: { + role: "user", + content: [{ type: "text", text: pi.sentUserMessages[0].content }], + }, + }, { cwd, hasUI: false } as any); + + let compactOptions: any; + const result = await pi.tools.get("handoff").execute("1", { task: "continue" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + + assert.ok(compactOptions); + assert.equal(result.terminate, true); + assert.match(result.content[0].text, /Handoff started/); + }); +}); + +test("manual slash handoff stays active across notebook/tool turns before handoff", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => true, + }); + + const [messageStart] = pi.handlers.get("message_start")!; + await messageStart({ + message: { role: "user", content: pi.sentUserMessages[0].content }, + }, { cwd, hasUI: false } as any); + + const [turnEnd] = pi.handlers.get("turn_end")!; + await turnEnd({}, { + cwd, + hasUI: true, + ui: { theme, setStatus: () => {}, setWidget: () => {} }, + getContextUsage: () => null, + } as any); + + let compactOptions: any; + const result = await pi.tools.get("handoff").execute("1", { task: "continue after notebook writes" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + + assert.ok(compactOptions); + assert.equal(result.terminate, true); + assert.match(result.content[0].text, /Handoff started/); + }); +}); + +test("manual slash handoff message_start parsing fails closed for malformed payloads", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => true, + }); + + const [messageStart] = pi.handlers.get("message_start")!; + await assert.doesNotReject(() => messageStart({}, { cwd, hasUI: false } as any)); + await assert.doesNotReject(() => messageStart({ message: null }, { cwd, hasUI: false } as any)); + + let compactOptions: any; + const blockedResult = await pi.tools.get("handoff").execute("1", { task: "old turn" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.equal(compactOptions, undefined); + assert.match(blockedResult.content[0].text, /No compaction was started/); + + await messageStart({ + message: { role: "user", content: [{ type: "text", text: pi.sentUserMessages[0].content }] }, + }, { cwd, hasUI: false } as any); + + const allowedResult = await pi.tools.get("handoff").execute("2", { task: "requested turn" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.ok(compactOptions); + assert.equal(allowedResult.terminate, true); + }); +}); + +test("manual slash handoff queues a follow-up when invoked during a busy run", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + let waitCalls = 0; + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => false, + waitForIdle: async () => { waitCalls += 1; }, + }); + + assert.equal(waitCalls, 0); + assert.deepEqual(pi.activeTools, []); + assert.equal(pi.sentUserMessages.length, 1); + assert.equal(pi.sentUserMessages[0].options?.deliverAs, "followUp"); + assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/); + + let compactOptions: any; + const blockedResult = await pi.tools.get("handoff").execute("1", { task: "old turn" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.equal(compactOptions, undefined); + assert.match(blockedResult.content[0].text, /No compaction was started/); + assert.equal(blockedResult.terminate, undefined); + assert.deepEqual(pi.sentMessages, []); + }); +}); + +test("manual slash handoff follow-up is not preempted by old-turn automatic handoff", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => false, + }); + assert.equal(pi.sentUserMessages[0].options?.deliverAs, "followUp"); + + let compactOptions: any; + const blockedResult = await pi.tools.get("handoff").execute("old", { task: "old automatic turn" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.equal(compactOptions, undefined); + assert.match(blockedResult.content[0].text, /generated user turn has not started/); + assert.equal(blockedResult.terminate, undefined); + + const [messageStart] = pi.handlers.get("message_start")!; + await messageStart({ + message: { role: "user", content: [{ type: "text", text: pi.sentUserMessages[0].content }] }, + }, { cwd, hasUI: false } as any); + + const allowedResult = await pi.tools.get("handoff").execute("requested", { task: "requested manual turn" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.ok(compactOptions); + assert.equal(allowedResult.terminate, true); + assert.match(allowedResult.content[0].text, /Handoff started/); + }); +}); + +test("manual slash handoff same-direction activation uses request identity instead of prompt equality", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false }); + const oldPrompt = pi.sentUserMessages[0].content; + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false }); + const newPrompt = pi.sentUserMessages[1].content; + assert.notEqual(oldPrompt, newPrompt); + assert.match(oldPrompt, /Manual handoff request id: [0-9a-f-]{36}/i); + assert.match(newPrompt, /Manual handoff request id: [0-9a-f-]{36}/i); + + const [messageStart] = pi.handlers.get("message_start")!; + await messageStart({ + message: { role: "user", content: [{ type: "text", text: oldPrompt }] }, + }, { cwd, hasUI: false } as any); + + let compactOptions: any; + const stillQueued = await pi.tools.get("handoff").execute("old", { task: "old prompt" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.equal(compactOptions, undefined); + assert.match(stillQueued.content[0].text, /generated user turn has not started/); + + await messageStart({ + message: { role: "user", content: [{ type: "text", text: newPrompt }] }, + }, { cwd, hasUI: false } as any); + const allowed = await pi.tools.get("handoff").execute("new", { task: "new prompt" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + assert.ok(compactOptions); + assert.equal(allowed.terminate, true); + }); +}); + +test("stale automatic handoff compaction error does not clear newer queued manual status", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + const statuses: Array = []; + const ctx = { + cwd, + hasUI: true, + isIdle: () => false, + ui: { theme, setStatus: (_key: string, status: string | undefined) => statuses.push(status), notify: () => {} }, + compact: (options: any) => { oldCompactOptions = options; }, + } as any; + let oldCompactOptions: any; + + await pi.tools.get("handoff").execute("automatic", { task: "old automatic" }, undefined, undefined, ctx); + await pi.commands.get("handoff")!.handler("implement auth", ctx); + assert.match(statuses.at(-1) ?? "", /Handoff queued/); + + oldCompactOptions.onError({}); + + assert.equal(state.pendingRequestedHandoff?.direction, "implement auth"); + assert.equal(state.pendingRequestedHandoff?.awaitingAgentTurn, true); + assert.match(statuses.at(-1) ?? "", /Handoff queued/); + }); +}); + +test("manual slash handoff stale pending compaction does not clear newer queued request", async () => { + const pi = new MockPi(); + const state = createState(); + state.pendingRequestedHandoffGeneration = 2; + state.pendingHandoff = { + task: "Goal: old handoff", + source: "tool", + manualRequestGeneration: 1, + manualRequestId: "old-request", + }; + const newerRequest = { + requestId: "new-request", + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + awaitingAgentTurn: true, + }; + state.pendingRequestedHandoff = newerRequest; + state.pendingRequestedHandoffPrompt = "Handoff direction: implement auth"; + state.pendingRequestedHandoffRetryProtected = true; + registerHandoffCompaction(pi as any, state); + const statuses: Array = []; + + const [handler] = pi.handlers.get("session_before_compact")!; + const result = await handler( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + { hasUI: true, ui: { setStatus: (_key: string, status: string | undefined) => statuses.push(status) } }, + ); + + assert.equal(state.pendingHandoff, null); + assert.strictEqual(state.pendingRequestedHandoff, newerRequest); + assert.equal(state.pendingRequestedHandoffPrompt, "Handoff direction: implement auth"); + assert.equal(state.pendingRequestedHandoffRetryProtected, true); + assert.deepEqual(statuses, []); + assert.equal(result.compaction.summary, "Goal: old handoff"); +}); + +test("manual slash handoff compaction error does not overwrite newer same-direction request", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + const statuses: Array = []; + const uiCtx = { + cwd, + hasUI: true, + isIdle: () => true, + ui: { theme, setStatus: (_key: string, status: string | undefined) => statuses.push(status), notify: () => {} }, + }; + + await pi.commands.get("handoff")!.handler("implement auth", uiCtx); + state.pendingRequestedHandoff!.awaitingAgentTurn = false; + let oldCompactOptions: any; + await pi.tools.get("handoff").execute("old", { task: "old request" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { oldCompactOptions = options; }, + }); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + + await pi.commands.get("handoff")!.handler("implement auth", uiCtx); + const newerRequest = state.pendingRequestedHandoff!; + assert.equal(newerRequest.direction, "implement auth"); + assert.equal(newerRequest.enforcementAttempts, 0); + assert.equal(newerRequest.toolCalled, false); + assert.equal(newerRequest.awaitingAgentTurn, true); + assert.match(newerRequest.requestId, /^[0-9a-f-]{36}$/i); + assert.match(statuses.at(-1) ?? "", /Handoff queued/); + + oldCompactOptions.onError({}); + + assert.strictEqual(state.pendingRequestedHandoff, newerRequest); + assert.equal(state.pendingRequestedHandoff?.awaitingAgentTurn, true); + assert.match(state.pendingRequestedHandoffPrompt ?? "", /Handoff direction: implement auth/); + assert.match(statuses.at(-1) ?? "", /Handoff queued/); + assert.equal(pi.sentUserMessages.length, 2); + }); +}); + +test("manual slash handoff does not require active-tool APIs", async () => { + const pi = new MockPi(); + (pi as any).setActiveTools = undefined; + const state = createState(); + registerHandoffCommand(pi as any, state); + const notifications: Array<{ message: string; level: string }> = []; + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { theme, notify: (message: string, level: string) => notifications.push({ message, level }), setStatus: () => {} }, + }); + + assert.equal(state.pendingRequestedHandoff?.direction, "implement auth"); + assert.equal(pi.sentUserMessages.length, 1); + assert.deepEqual(notifications, []); + assert.deepEqual(pi.sentMessages, []); +}); + +test("CHANGELOG documents fixed post-compaction Proceed continuation", async () => { + const changelog = await readFile(new URL("./CHANGELOG.md", import.meta.url), "utf8"); + const unreleased = changelog.split("## [0.3.0]")[0]; + + assert.match(unreleased, /Proceed\./); + assert.match(unreleased, /fixed|not configurable|non-configurable/i); +}); + +test("handoff automatic setting is documented in README", async () => { + const readme = await readFile(new URL("./README.md", import.meta.url), "utf8"); + const changelog = await readFile(new URL("./CHANGELOG.md", import.meta.url), "utf8"); + + assert.match(readme, /handoff\.automaticEnabled/); + assert.match(readme, /true/); + assert.match(readme, /false/); + assert.match(readme, /default/i); + assert.match(readme, /Proceed/); + assert.doesNotMatch(readme, /PR-only/i); + assert.match(changelog, /handoff\.automaticEnabled/); + assert.match(changelog, /default.*enabled/i); + assert.match(readme, /queued manual request is only pending delivery/i); + assert.match(readme, /active explicit operator/i); + assert.match(changelog, /queued.*active generated turn/i); +}); + +test("agenticoding settings command registers /agenticoding-settings TUI surface", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + + assert.ok(pi.commands.has("agenticoding-settings")); + assert.ok(pi.commands.has("handoff"), "/handoff remains registered separately"); + + let overlay: any; + let customCalls = 0; + await pi.commands.get("agenticoding-settings")!.handler("", { + cwd, + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + customCalls++; + overlay = build({ requestRender: () => {} }, theme, {}, () => {}); + return "closed"; + }, + notify: () => {}, + }, + }); + + assert.equal(customCalls, 1); + const rendered = stripAnsi(overlay.render(120).join("\n")); + assert.match(rendered, /Agenticoding Settings/); + assert.match(rendered, /Resolved handoff\.automaticEnabled: true/); + assert.match(rendered, /Supported values: true, false/); + assert.match(rendered, /global-only/); + }); +}); + +test("agenticoding settings TUI persists handoff automaticEnabled globally as boolean", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + const projectPath = join(cwd, ".pi", "settings.json"); + await writeSettingsFile(globalPath, { packages: ["keep"], handoff: { other: true } }); + + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + const model = await buildAgenticodingSettingsModel(ctx); + assert.equal(await model.save("false", ctx), true); + + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.deepEqual(saved.packages, ["keep"]); + assert.equal(saved.handoff.other, true); + assert.equal(saved.handoff.automaticEnabled, false); + await assert.rejects(() => readFile(projectPath, "utf8")); + assert.deepEqual(notifications, [{ message: 'Saved global handoff.automaticEnabled = false.', level: "info" }]); + + const roundTrip = await buildAgenticodingSettingsModel(ctx); + assert.equal(roundTrip.effectiveAutomaticEnabled, false); + assert.equal(roundTrip.effectiveSource, "global"); + }); +}); + +test("agenticoding settings global save preserves existing file mode", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, { packages: ["keep"], handoff: { automaticEnabled: true } }); + await chmod(globalPath, 0o640); + + const ctx = { cwd, hasUI: true, ui: { notify: () => {} } } as any; + assert.equal(await writeGlobalHandoffAutomaticEnabled("false", ctx), true); + + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.equal(saved.handoff.automaticEnabled, false); + assert.equal((await stat(globalPath)).mode & 0o777, 0o640); + }); +}); + +test("agenticoding settings TUI warns when project override masks global automatic handoff", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { automaticEnabled: true } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveAutomaticEnabled, false); + assert.equal(model.effectiveSource, "project"); + assert.equal(model.projectOverride, true); + assert.match(model.projectOverrideWarning ?? "", /override\/mask/); + assert.match(model.projectOverrideWarning ?? "", /Saving here writes only/); + + const display = getAgenticodingSettingsDisplayLines(model).join("\n"); + assert.match(display, /Project settings: .*false/); + assert.match(display, /Warning: Project settings/); + }); +}); + +test("agenticoding settings TUI editable control anchors and refreshes to global value when project override masks it", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { automaticEnabled: true } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const ctx = { cwd, hasUI: true, ui: { notify: () => {} } } as any; + const model = await buildAgenticodingSettingsModel(ctx); + const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {}); + + const rendered = stripAnsi(component.render(120).join("\n")); + assert.match(rendered, /Resolved handoff\.automaticEnabled: false \(project\)/); + assert.match(rendered, /Global settings: .*true/); + assert.match(rendered, /Automatic handoff availability \(global save\)\s+true/); + }); + + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, { handoff: { automaticEnabled: true } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: true } }); + const ctx = { cwd, hasUI: true, ui: { notify: () => {} } } as any; + const model = await buildAgenticodingSettingsModel(ctx); + const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {}); + + component.handleInput("\r"); + await new Promise(resolve => setTimeout(resolve, 50)); + + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.equal(saved.handoff.automaticEnabled, false); + const rendered = stripAnsi(component.render(120).join("\n")); + assert.match(rendered, /Global settings: .*false/); + assert.match(rendered, /Automatic handoff availability \(global save\)\s+false/); + }); +}); + +test("agenticoding settings TUI warns after save when project override still masks global automatic handoff", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, { handoff: { automaticEnabled: true } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: true } }); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + const model = await buildAgenticodingSettingsModel(ctx); + const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {}); + + component.handleInput("\r"); + await new Promise(resolve => setTimeout(resolve, 50)); + + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.equal(saved.handoff.automaticEnabled, false); + assert.deepEqual(notifications.map(n => n.level), ["info", "warning"]); + assert.match(notifications[0].message, /Saved global handoff\.automaticEnabled = false/); + assert.match(notifications[1].message, /Project settings .*override\/mask the global value/); + assert.match(stripAnsi(component.render(120).join("\n")), /Resolved handoff\.automaticEnabled: true \(project\)/); + }); +}); + +test("agenticoding settings TUI handles invalid JSON policies for automatic handoff", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, "{"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + const invalidGlobal = await buildAgenticodingSettingsModel(ctx); + assert.equal(invalidGlobal.globalWriteBlocked, true); + assert.equal(await invalidGlobal.save("false", ctx), false); + assert.equal(await readFile(globalPath, "utf8"), "{"); + assert.equal(notifications.at(-1)?.level, "error"); + assert.match(notifications.at(-1)?.message ?? "", /Invalid global settings JSON/); + }); + + for (const nonObjectRoot of ["[]", "\"x\"", "42"]) { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, nonObjectRoot); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + const state = await readHandoffSettingsState(cwd); + assert.equal(state.global.invalid, true); + const invalidGlobal = await buildAgenticodingSettingsModel(ctx); + assert.equal(invalidGlobal.globalWriteBlocked, true); + assert.equal(await invalidGlobal.save("false", ctx), false); + assert.equal(await readFile(globalPath, "utf8"), nonObjectRoot); + assert.equal(notifications.at(-1)?.level, "error"); + assert.match(notifications.at(-1)?.message ?? "", /root must be an object/); + }); + } + + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), "{"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + const invalidProject = await buildAgenticodingSettingsModel(ctx); + assert.equal(invalidProject.globalWriteBlocked, false); + assert.match(invalidProject.messages.join("\n"), /Invalid project settings JSON/); + assert.equal(await invalidProject.save("false", ctx), true); + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.equal(saved.handoff.automaticEnabled, false); + assert.equal(notifications.at(-1)?.level, "info"); + }); +}); + +test("agenticoding settings TUI refuses to clobber malformed global handoff parent on save", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + const original = JSON.stringify({ packages: ["keep"], handoff: null }); + await writeSettingsFile(globalPath, original); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + const state = await readHandoffSettingsState(cwd); + assert.equal(state.global.invalid, true); + assert.equal(state.global.invalidReason, "malformed-handoff"); + const model = await buildAgenticodingSettingsModel(ctx); + assert.equal(model.globalWriteBlocked, true); + + assert.equal(await writeGlobalHandoffAutomaticEnabled("false", ctx), false); + assert.equal(await readFile(globalPath, "utf8"), original); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "error"); + assert.match(notifications[0].message, /handoff must be an object when present/); + assert.match(notifications[0].message, /not writing handoff\.automaticEnabled/); + }); +}); + +test("agenticoding settings write path refuses non-ENOENT read failures without clobbering global settings", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + const original = JSON.stringify({ packages: ["keep"], handoff: { other: true } }); + await writeSettingsFile(globalPath, original); + await chmod(globalPath, 0o200); + + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + try { + assert.equal(await writeGlobalHandoffAutomaticEnabled("false", ctx), false); + } finally { + await chmod(globalPath, 0o600); + } + + assert.equal(await readFile(globalPath, "utf8"), original); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "error"); + assert.match(notifications[0].message, /Unable to read global settings JSON/); + assert.match(notifications[0].message, /not writing handoff\.automaticEnabled/); + }); }); -test("updateIndicators hides widget below 70% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - // Pre-set a widget to verify it gets cleared - record.widgets.set("agenticoding-warning", ["existing"]); - const ctx = makeTUICtx({ percent: 30, record }); +test("agenticoding settings write path rejects save failures", async () => { + await withIsolatedSettings(async ({ home }) => { + const settingsDir = join(home, ".pi", "agent"); + await mkdir(settingsDir, { recursive: true }); + await chmod(settingsDir, 0o500); - updateIndicators(ctx, state); - assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd: home, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + try { + await assert.rejects( + () => writeGlobalHandoffAutomaticEnabled("false", ctx), + /EACCES|EPERM|ENOSPC/, + ); + } finally { + await chmod(settingsDir, 0o700); + } + }); }); -// ── Handoff tests ───────────────────────────────────────────────────── +test("agenticoding settings TUI reports save failure notification", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, { handoff: { automaticEnabled: true } }); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + const model = await buildAgenticodingSettingsModel(ctx); + const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {}); -test("/handoff sends the direction back through the LLM without opening the editor", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); + setSettingsAtomicWriteOperationsForTest({ + rename: async () => { throw new Error("simulated save failure"); }, + }); + try { + component.handleInput("\r"); + await new Promise(resolve => setTimeout(resolve, 50)); + } finally { + setSettingsAtomicWriteOperationsForTest(null); + } - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { notify: (_message: string) => {} }, + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "error"); + assert.match(notifications[0].message, /Failed to save handoff\.automaticEnabled/); + assert.match(notifications[0].message, /simulated save failure/); }); +}); - assert.deepEqual(state.pendingRequestedHandoff, { - direction: "implement auth", - enforcementAttempts: 0, - toolCalled: false, +test("agenticoding settings atomic save preserves original settings when rename fails", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + const original = JSON.stringify({ packages: ["keep"], handoff: { automaticEnabled: true } }); + await writeSettingsFile(globalPath, original); + const renameError = new Error("simulated rename failure"); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + cwd, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + setSettingsAtomicWriteOperationsForTest({ + rename: async () => { throw renameError; }, + }); + try { + await assert.rejects(() => writeGlobalHandoffAutomaticEnabled("false", ctx), /simulated rename failure/); + } finally { + setSettingsAtomicWriteOperationsForTest(null); + } + + assert.equal(await readFile(globalPath, "utf8"), original); + const files = await readdir(dirname(globalPath)); + assert.equal(files.some(file => file.startsWith(".settings.json.") && file.endsWith(".tmp")), false); + assert.deepEqual(notifications, []); }); - assert.deepEqual(pi.sentUserMessages, [ - { - content: - "Handoff direction: implement auth\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.", - options: undefined, - }, - ]); }); -test("/handoff requires a direction", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); +test("agenticoding settings atomic save writes temp file with preserved restrictive mode", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + const writeModes: number[] = []; + const ctx = { cwd, hasUI: false } as any; - const notifications: string[] = []; - await pi.commands.get("handoff")!.handler(" ", { - hasUI: true, - isIdle: () => true, - ui: { notify: (message: string) => notifications.push(message) }, - }); + setSettingsAtomicWriteOperationsForTest({ + writeFile: (async (pathArg: string, contents: string, options: any) => { + writeModes.push(options?.mode); + await writeFile(pathArg, contents, options); + }) as any, + }); + try { + await writeGlobalHandoffAutomaticEnabled("false", ctx); + await chmod(globalPath, 0o640); + await writeGlobalHandoffAutomaticEnabled("true", ctx); + } finally { + setSettingsAtomicWriteOperationsForTest(null); + } - assert.deepEqual(notifications, ["Usage: /handoff "]); - assert.deepEqual(pi.sentUserMessages, []); + assert.deepEqual(writeModes.map((mode) => mode & 0o777), [0o600, 0o640]); + assert.equal((await stat(globalPath)).mode & 0o777, 0o640); + }); }); -test("handoff tool triggers compaction and resumes with the compacted task", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("auth-refresh", "sensitive notebook body"); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerHandoffTool(pi as any, state); +test("agenticoding settings command falls back without usable TUI", async () => { + const headlessPi = new MockPi(); + registerAgenticoding(headlessPi as any); + await headlessPi.commands.get("agenticoding-settings")!.handler("", { hasUI: false }); + assert.equal(headlessPi.sentMessages.length, 1); + assert.match(headlessPi.sentMessages[0].message.content, /Edit ~\/\.pi\/agent\/settings\.json/); + assert.equal(headlessPi.sentMessages[0].message.content, MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS); + assert.match(MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, /handoff\.automaticEnabled/); - let compactOptions: any; - const result = await pi.tools.get("handoff").execute( - "1", - { task: "Goal: continue auth-refresh" }, - undefined, - undefined, - { - compact: (options: any) => { - compactOptions = options; + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + const notifications: Array<{ message: string; level: string }> = []; + await pi.commands.get("agenticoding-settings")!.handler("", { + cwd, + hasUI: true, + ui: { + custom: async () => undefined, + notify: (message: string, level: string) => notifications.push({ message, level }), }, - }, - ); + }); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "info"); + assert.equal(notifications[0].message, MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS); + }); +}); - assert.equal(state.pendingHandoff?.source, "tool"); - assert.match(state.pendingHandoff?.task ?? "", /## Handoff β€” Continue Previous Work/); - assert.match(state.pendingHandoff?.task ?? "", /Notebook pages hold durable grounding knowledge/); - assert.match(state.pendingHandoff?.task ?? "", /distilled next task and immediate situational context/); - assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); - assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); - assert.equal(state.pendingRequestedHandoff?.toolCalled, true); - assert.equal(typeof compactOptions?.onComplete, "function"); - assert.equal(result.content[0].text, "Handoff started."); - assert.equal(result.terminate, true); +test("agenticoding settings documentation covers TUI and global-only/project override semantics", async () => { + const readme = await readFile(new URL("./README.md", import.meta.url), "utf8"); + const changelog = await readFile(new URL("./CHANGELOG.md", import.meta.url), "utf8"); - compactOptions.onComplete({}); - assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); + assert.match(readme, /\/agenticoding-settings/); + assert.match(readme, /global-only/i); + assert.match(readme, /project.*override/i); + assert.match(readme, /~\/\.pi\/agent\/settings\.json/); + assert.match(readme, /handoff\.automaticEnabled/); + assert.match(changelog, /\/agenticoding-settings/); + assert.match(changelog, /global-only/i); + assert.match(changelog, /project.*override/i); + assert.match(changelog, /\n$/); + assert.doesNotMatch(changelog, /\n\n$/); }); test("handoff compaction replaces old context with the queued task", async () => { const pi = new MockPi(); const state = createState(); state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 1, toolCalled: true, awaitingAgentTurn: false }; state.activeNotebookTopic = "oauth"; state.activeNotebookTopicSource = "human"; registerHandoffCompaction(pi as any, state); @@ -457,7 +1728,7 @@ test("/handoff sets the handoff status indicator", async () => { }, }); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🀝 Handoff in progress"); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🀝 Handoff queued"); }); test("handoff compaction clears the handoff status indicator", async () => { @@ -476,10 +1747,11 @@ test("handoff compaction clears the handoff status indicator", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("handoff compaction error clears pending state and status", async () => { +test("handoff compaction error preserves active manual request for retry", async () => { const pi = new MockPi(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 1, toolCalled: false, awaitingAgentTurn: false }; + state.pendingRequestedHandoffPrompt = "Handoff direction: implement auth"; registerHandoffTool(pi as any, state); let compactOptions: any; const statuses = new Map(); @@ -498,11 +1770,97 @@ test("handoff compaction error clears pending state and status", async () => { compactOptions.onError({}); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.deepEqual(state.pendingRequestedHandoff, { + requestId: "test-request", + direction: "implement auth", + enforcementAttempts: 1, + toolCalled: false, + awaitingAgentTurn: false, + }); + assert.equal(state.pendingRequestedHandoffPrompt, "Handoff direction: implement auth"); + assert.equal(pi.sentUserMessages.length, 0); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("manual slash handoff preserves retry-ready request after compaction onError followed by agent_end", async () => { + const pi = new MockPi(); + const state = createState(); + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 1, toolCalled: false, awaitingAgentTurn: false }; + state.pendingRequestedHandoffPrompt = "Handoff direction: implement auth"; + registerHandoffTool(pi as any, state); + registerWatchdog(pi as any, state); + let compactOptions: any; + const statuses = new Map(); + + await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onError({}); + + const [agentEnd] = pi.handlers.get("agent_end")!; + await agentEnd({}, { + hasUI: true, + ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + getContextUsage: () => null, + }); + + assert.equal(state.pendingHandoff, null); + assert.deepEqual(state.pendingRequestedHandoff, { + requestId: "test-request", + direction: "implement auth", + enforcementAttempts: 2, + toolCalled: false, + awaitingAgentTurn: false, + }); + assert.equal(state.pendingRequestedHandoffPrompt, "Handoff direction: implement auth"); + assert.equal(pi.sentUserMessages.length, 0); assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("turn_end fallback clears stale requested handoff status", async () => { +test("handoff compact synchronous throw preserves active manual request for retry", async () => { + const pi = new MockPi(); + const state = createState(); + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false }; + state.pendingRequestedHandoffPrompt = "Handoff direction: implement auth"; + registerHandoffTool(pi as any, state); + const statuses = new Map(); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + compact: () => { throw new Error("compact start failed"); }, + }, + ), + /compact start failed/, + ); + + assert.equal(state.pendingHandoff, null); + assert.deepEqual(state.pendingRequestedHandoff, { + requestId: "test-request", + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + awaitingAgentTurn: false, + }); + assert.equal(state.pendingRequestedHandoffPrompt, "Handoff direction: implement auth"); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("agent_end fallback silently clears stale requested handoff status", async () => { const pi = new MockPi(); registerAgenticoding(pi as any); const statuses = new Map(); @@ -516,11 +1874,18 @@ test("turn_end fallback clears stale requested handoff status", async () => { }, }); - const [turnEnd] = pi.handlers.get("turn_end")!; - await turnEnd({}, { + const notifications: Array<{ message: string; level: string }> = []; + const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; + const [agentEnd] = pi.handlers.get("agent_end")!; + await beforeAgentStart( + { prompt: pi.sentUserMessages[0].content, systemPrompt: "base" }, + { hasUI: false } as any, + ); + await agentEnd({}, { hasUI: true, ui: { theme: { fg: (_name: string, text: string) => text }, + notify: (message: string, level: string) => notifications.push({ message, level }), setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, setWidget: () => {}, }, @@ -528,6 +1893,8 @@ test("turn_end fallback clears stale requested handoff status", async () => { }); assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.deepEqual(notifications, []); + assert.deepEqual(pi.sentMessages, []); }); test("session_start new clears stale handoff status and warning widget", async () => { @@ -598,6 +1965,36 @@ test("context injects watchdog reminder before each LLM call", async () => { assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); }); +test("disabled-mode context nudge follows an active manual handoff request", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + + await pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => true, + }); + const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; + await beforeAgentStart( + { prompt: pi.sentUserMessages[0].content, systemPrompt: "base" }, + { cwd, hasUI: false } as any, + ); + + const [contextHandler] = pi.handlers.get("context")!; + const result = await contextHandler( + { messages: [{ role: "user", content: "handoff", timestamp: 1 }] }, + { cwd, getContextUsage: () => ({ percent: 70 }) }, + ); + + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.match(result.messages[1].content, /manual \/handoff request is active/i); + assert.match(result.messages[1].content, /call the handoff tool/i); + assert.doesNotMatch(result.messages[1].content, /tell the operator|continue inline only if safe/i); + }); +}); + test("context injects a boundary nudge below 30% after an explicit topic change", async () => { const pi = new MockPi(); registerAgenticoding(pi as any); @@ -616,21 +2013,23 @@ test("context injects a boundary nudge below 30% after an explicit topic change" test("context injects a no-topic nudge when context is high", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 70 }) }, - ); + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { cwd, getContextUsage: () => ({ percent: 70 }) }, + ); - assert.equal(result.messages.length, 2); - assert.equal(result.messages[1].role, "custom"); - assert.equal(result.messages[1].customType, "agenticoding-watchdog"); - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /No active notebook topic is set/); - assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].role, "custom"); + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /No active notebook topic is set/); + assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); + }); }); @@ -671,20 +2070,20 @@ test("buildNudge handles null percent and boundary hints before topic guidance", assert.match(noTopic, /No active notebook topic is set/); }); -test("watchdog stays advisory when a requested handoff is not completed", async () => { +test("watchdog stale requested handoff cleanup stays silent", async () => { const pi = new MockPi(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { requestId: "test-request", direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false }; registerWatchdog(pi as any, state); const [handler] = pi.handlers.get("agent_end")!; - const notifications: string[] = []; + const notifications: Array<{ message: string; level: string }> = []; await handler( {}, { hasUI: true, ui: { - notify: (message: string) => notifications.push(message), + notify: (message: string, level: string) => notifications.push({ message, level }), setStatus: () => {}, }, getContextUsage: () => ({ percent: 20 }), @@ -693,6 +2092,7 @@ test("watchdog stays advisory when a requested handoff is not completed", async assert.equal(state.pendingRequestedHandoff, null); assert.deepEqual(notifications, []); + assert.deepEqual(pi.sentMessages, []); assert.deepEqual(pi.sentUserMessages, []); }); @@ -2142,49 +3542,52 @@ test("notebook rehydration clears stale in-memory notebook state when persisted test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - pi.activeTools = ["read", "notebook_read"]; - registerAgenticoding(pi as any); + await withIsolatedSettings(async ({ cwd }) => { + resetNotebookWriteLock(); + const pi = new MockPi(); + pi.activeTools = ["read", "notebook_read"]; + registerAgenticoding(pi as any); + + try { + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute( + "seed", + { name: "stale-page", content: "stale body" }, + undefined, + undefined, + makeTUICtx({ hasUI: false }), + ); - try { - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute( - "seed", - { name: "stale-page", content: "stale body" }, - undefined, - undefined, - makeTUICtx({ hasUI: false }), - ); + const sessionStartHandlers = pi.handlers.get("session_start")!; + const ctx = { + cwd, + hasUI: false, + getContextUsage: () => null, + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, + ], + }, + }; + for (const sessionStart of sessionStartHandlers) { + await sessionStart({ reason: "resume" }, ctx as any); + } - const sessionStartHandlers = pi.handlers.get("session_start")!; - const ctx = { - hasUI: false, - getContextUsage: () => null, - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, - ], - }, - }; - for (const sessionStart of sessionStartHandlers) { - await sessionStart({ reason: "resume" }, ctx as any); + const notebookIndex = pi.tools.get("notebook_index"); + const notebookRead = pi.tools.get("notebook_read"); + const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); + assert.deepEqual(indexResult.details.entries, ["keep"]); + + const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); + assert.equal(readResult.details.found, true); + assert.equal(readResult.details.body, "newer"); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); + } finally { + resetNotebookWriteLock(); } - - const notebookIndex = pi.tools.get("notebook_index"); - const notebookRead = pi.tools.get("notebook_read"); - const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); - assert.deepEqual(indexResult.details.entries, ["keep"]); - - const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); - assert.equal(readResult.details.found, true); - assert.equal(readResult.details.body, "newer"); - assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); - } finally { - resetNotebookWriteLock(); - } + }); }); test("notebook tools add/get/list return stable contract details", async () => { @@ -3407,7 +4810,8 @@ test("notebook tool definitions include prompt hints when withPromptHints is tru const writeGuidelines = notebookWrite.promptGuidelines!.join(" "); assert.match(writeGuidelines, /subject-oriented pages/i); assert.match(writeGuidelines, /fresh context/i); - assert.match(writeGuidelines, /belongs in handoff/i); + assert.match(writeGuidelines, /immediate next-step state/i); + assert.doesNotMatch(writeGuidelines, /handoff|\/handoff/i); // Conceptual: descriptions mention the notebook-page metaphor assert.match(notebookWrite.description, /page|future contexts/i); @@ -3439,6 +4843,8 @@ test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses o registerNotebookTopicTool(pi as any, state); const tool = pi.tools.get("notebook_topic_set"); + assert.doesNotMatch(tool.promptGuidelines.join(" "), /tell the operator/i); + assert.match(tool.promptGuidelines.join(" "), /context-pivot guidance/i); const first = await tool.execute("1", { topic: "OAuth" }); assert.equal(first.details.topic, "oauth"); assert.equal(state.activeNotebookTopic, "oauth"); @@ -3450,9 +4856,12 @@ test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses o assert.match(second.content[0].text, /already set to "oauth"/i); await assert.rejects(() => tool.execute("3", { topic: "billing" }), /already exists/); + await assert.rejects(() => tool.execute("4", { topic: "billing" }), (error: unknown) => { + assert.doesNotMatch(String(error), /handoff|\/handoff/i); + return true; + }); }); - test("notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics", async () => { const pi = new MockPi(); const state = createState(); @@ -3466,7 +4875,11 @@ test("notebook_topic_set preserves human authority, stays idempotent for equal t assert.match(same.content[0].text, /already set to "oauth"/i); await assert.rejects( () => tool.execute("2", { topic: "billing" }), - /human-set notebook topic is authoritative/i, + (error: unknown) => { + assert.match(String(error), /human-set notebook topic is authoritative/i); + assert.doesNotMatch(String(error), /handoff|\/handoff/i); + return true; + }, ); const freshPi = new MockPi(); @@ -3549,6 +4962,164 @@ test("before_agent_start injects no-topic guidance when the topic is unset", asy assert.match(result.systemPrompt, /notebook_topic_set/); }); +test("handoff automatic setting false suppresses handoff calls in primer and watchdog guidance", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + pi.setActiveTools(["read", "handoff", "spawn"]); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("before_agent_start")!; + const result = await handler({ systemPrompt: "Base system prompt." }, { + ...makeTUICtx({ hasUI: false }), + cwd, + }); + + assert.equal(pi.activeTools.includes("handoff"), true); + assert.doesNotMatch(result.systemPrompt, /call (?:the )?handoff|use handoff|prefer handoff|\/handoff/i); + assert.match( + result.systemPrompt, + /handoff\s+tool is disabled for normal turns; use it only after an explicit manual\s+handoff request/i, + ); + assert.match(result.systemPrompt, /save durable/i); + assert.match(result.systemPrompt, /tell the operator/i); + + const disabledNudge = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 70, false); + assert.doesNotMatch(disabledNudge, /handoff|\/handoff/i); + assert.match(disabledNudge, /tell the operator/i); + + const topicTool = pi.tools.get("notebook_topic_set")!; + assert.doesNotMatch(topicTool.promptGuidelines.join("\n"), /handoff|\/handoff/i); + const notebookWrite = pi.tools.get("notebook_write")!; + assert.doesNotMatch(notebookWrite.promptGuidelines.join("\n"), /handoff|\/handoff/i); + const notebookIndex = pi.tools.get("notebook_index")!; + assert.doesNotMatch(notebookIndex.promptGuidelines.join("\n"), /handoff|\/handoff|after handoff/i); + + const record = { statuses: new Map(), widgets: new Map() }; + updateIndicators(makeTUICtx({ percent: 70, record }), { ...createState(), activeNotebookTopic: "oauth" }, false); + assert.doesNotMatch(record.widgets.get(WIDGET_KEY_WARNING)?.join("\n") ?? "", /handoff|\/handoff/i); + + const writeRecord = { statuses: new Map(), widgets: new Map() }; + await notebookWrite.execute("1", { name: "disabled-hints", content: "saved" }, undefined, undefined, { + ...makeTUICtx({ percent: 70, record: writeRecord }), + cwd, + }); + assert.doesNotMatch(writeRecord.widgets.get(WIDGET_KEY_WARNING)?.join("\n") ?? "", /handoff|\/handoff/i); + }); +}); + +test("automatic disabled active manual generated turn overrides disabled primer and topic guidance", async () => { + await withIsolatedSettings(async ({ cwd }) => { + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); + const pi = new MockPi(); + registerAgenticoding(pi as any); + await pi.commands.get("notebook")!.handler("oauth", { cwd, hasUI: false, getContextUsage: () => null }); + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); + + const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; + const result = await beforeAgentStart( + { prompt: pi.sentUserMessages[0].content, systemPrompt: "Base system prompt." }, + { cwd, hasUI: false } as any, + ); + + assert.match(result.systemPrompt, /Manual handoff.*explicit operator request active/i); + assert.match(result.systemPrompt, /active manual \/handoff request/i); + assert.match(result.systemPrompt, /call the handoff tool/i); + assert.match(result.systemPrompt, /Current topic: `oauth`/); + assert.doesNotMatch(result.systemPrompt, /continue inline only if safe|tell the operator|clean-transition fallback|fallback guidance/i); + + const [contextHandler] = pi.handlers.get("context")!; + const lowContext = await contextHandler({ messages: [] }, { cwd, hasUI: false, getContextUsage: () => ({ percent: 20 }) } as any); + assert.equal(lowContext, undefined, "low context emits no watchdog nudge, so before_agent_start guidance must carry the manual instruction"); + }); +}); + +test("automatic disabled active manual primer has no clean-transition footer contradiction", () => { + const primer = getContextPrimer(false, true); + + assert.match(primer, /Manual handoff.*explicit operator request active/i); + assert.match(primer, /call the handoff tool/i); + assert.doesNotMatch(primer, /continue inline only if safe|tell the operator|clean-transition fallback|fallback guidance/i); +}); + +test("handoff automatic setting false uses disabled topic-boundary watchdog guidance", () => { + const nudge = buildNudge({ + activeNotebookTopic: "billing", + pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + }, 20, false); + + assert.match(nudge, /Notebook topic changed from oauth to billing/); + assert.match(nudge, /Save durable findings to the\s+notebook/i); + assert.match(nudge, /continue inline/i); + assert.match(nudge, /tell the operator/i); + assert.doesNotMatch(nudge, /call handoff|call the handoff tool|prefer .*handoff|\/handoff/i); +}); + +test("automatic enabled watchdog distinguishes queued manual handoff request", async () => { + await withIsolatedSettings(async ({ cwd }) => { + const pi = new MockPi(); + registerAgenticoding(pi as any); + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false }); + const [contextHandler] = pi.handlers.get("context")!; + + const result = await contextHandler({ messages: [] }, { + cwd, + hasUI: false, + getContextUsage: () => ({ percent: 70 }), + } as any); + const nudge = result.messages.at(-1).content; + + assert.match(nudge, /manual \/handoff request is waiting/i); + assert.match(nudge, /do not call the handoff tool/i); + assert.doesNotMatch(nudge, /prefer (?:a deliberate )?handoff|draft .*brief.*call/i); + }); + + const boundaryNudge = buildQueuedManualHandoffNudge({ + activeNotebookTopic: "billing", + pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + }, 20); + assert.match(boundaryNudge, /queued manual handoff/i); + assert.match(boundaryNudge, /do not call the handoff tool/i); + assert.doesNotMatch(boundaryNudge, /prefer (?:a deliberate )?handoff|draft .*brief.*call/i); +}); + +test("automatic disabled high context warning honors active manual handoff request", () => { + const record = { statuses: new Map(), widgets: new Map() }; + const state = createState(); + state.activeNotebookTopic = "oauth"; + state.pendingRequestedHandoff = { + requestId: "test-request", + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + awaitingAgentTurn: false, + }; + + updateIndicators(makeTUICtx({ percent: 70, record }), state, false); + const warning = record.widgets.get(WIDGET_KEY_WARNING)?.join("\n") ?? ""; + assert.match(warning, /manual \/handoff request is active/); + assert.match(warning, /call the handoff tool/); + assert.doesNotMatch(warning, /tell operator|continue inline only if safe/); + + const queuedRecord = { statuses: new Map(), widgets: new Map() }; + state.pendingRequestedHandoff.awaitingAgentTurn = true; + updateIndicators(makeTUICtx({ percent: 70, record: queuedRecord }), state, false); + const queuedWarning = queuedRecord.widgets.get(WIDGET_KEY_WARNING)?.join("\n") ?? ""; + assert.match(queuedWarning, /manual \/handoff request is queued/); + assert.match(queuedWarning, /do not call the handoff tool/i); +}); + +test("handoff tool metadata omits prompt hints and call guidance", () => { + const pi = new MockPi(); + const state = createState(); + registerHandoffTool(pi as any, state); + + const tool = pi.tools.get("handoff")!; + assert.equal(tool.promptSnippet, undefined); + assert.equal(tool.promptGuidelines, undefined); + assert.doesNotMatch(tool.description, /WHEN TO USE|call handoff|use handoff|\/handoff/i); + assert.doesNotMatch(tool.parameters.properties.task.description, /what to do next|capture the distilled|notebook/i); +}); + test("notebook tool definitions omit prompt hints by default", () => { const pi = new MockPi(); const state = createState(); diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts new file mode 100644 index 0000000..6f5f1f2 --- /dev/null +++ b/handoff/cleanup.ts @@ -0,0 +1,79 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import { STATUS_KEY_HANDOFF } from "../tui.js"; + +export function emitHandoffDiagnostic( + pi: ExtensionAPI, + ctx: ExtensionContext, + message: string, + level: "info" | "warning" | "error" = "warning", +): void { + if (ctx.hasUI) { + ctx.ui.notify?.(message, level); + } +} + +function clearHandoffStatus(ctx: ExtensionContext): void { + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + } +} + +export function clearPendingHandoffCompaction(state: AgenticodingState, ctx: ExtensionContext): void { + state.pendingHandoff = null; + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffRetryProtected = false; + clearHandoffStatus(ctx); +} + +export function preserveManualHandoffRequestAfterCompactionError( + state: AgenticodingState, + ctx: ExtensionContext, + request: NonNullable | null, + prompt: string | null, + requestGeneration: number | null = null, +): void { + const pendingGeneration = state.pendingHandoff?.manualRequestGeneration ?? null; + if (pendingGeneration === requestGeneration) { + state.pendingHandoff = null; + } + + if (request) { + if (requestGeneration !== state.pendingRequestedHandoffGeneration) { + return; + } + if (state.pendingRequestedHandoff !== null && state.pendingRequestedHandoff.requestId !== request.requestId) { + return; + } + state.pendingRequestedHandoff = { + ...request, + toolCalled: false, + awaitingAgentTurn: false, + }; + state.pendingRequestedHandoffPrompt = prompt; + state.pendingRequestedHandoffRetryProtected = true; + clearHandoffStatus(ctx); + } else if (state.pendingRequestedHandoff === null) { + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffRetryProtected = false; + clearHandoffStatus(ctx); + } +} + +export async function clearStaleRequestedHandoff( + _pi: ExtensionAPI, + state: AgenticodingState, + ctx: ExtensionContext, +): Promise { + const requested = state.pendingRequestedHandoff; + if (!requested) { + return; + } + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffRetryProtected = false; + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + } +} diff --git a/handoff/command.ts b/handoff/command.ts index 314a389..d0879e5 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -5,15 +5,37 @@ * handoff brief, and lets the handoff tool perform the actual compaction. */ +import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; +function clearPendingManualHandoffStartFailure( + state: AgenticodingState, + ctx: { hasUI?: boolean; ui?: { setStatus?: (key: string, status: string | undefined) => void; notify?: (message: string, level: "error") => void } }, + error: unknown, + expectedRequest: NonNullable, +): void { + if (state.pendingRequestedHandoff !== expectedRequest) { + return; + } + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffRetryProtected = false; + if (ctx.hasUI) { + ctx.ui?.setStatus?.(STATUS_KEY_HANDOFF, undefined); + ctx.ui?.notify?.( + `Manual /handoff could not start: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } +} + export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void { pi.registerCommand("handoff", { description: "Ask the LLM to draft a handoff brief that completes the picture from " + - "your direction, then perform the handoff automatically.", + "your direction, then perform the requested handoff.", handler: async (args, ctx) => { const direction = args.trim(); @@ -22,24 +44,36 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } - state.pendingRequestedHandoff = { + const requestId = randomUUID(); + const prompt = `Handoff direction: ${direction}\nManual handoff request id: ${requestId}\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.`; + state.pendingRequestedHandoffGeneration += 1; + const pendingRequest: NonNullable = { + requestId, direction, enforcementAttempts: 0, toolCalled: false, + awaitingAgentTurn: true, }; + state.pendingRequestedHandoff = pendingRequest; + state.pendingRequestedHandoffPrompt = prompt; + state.pendingRequestedHandoffRetryProtected = false; // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { ctx.ui.setStatus( STATUS_KEY_HANDOFF, - ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff in progress"), + ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff queued"), ); } - pi.sendUserMessage( - `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.`, - ctx.isIdle() ? undefined : { deliverAs: "followUp" }, - ); + const isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; + try { + void Promise.resolve(pi.sendUserMessage(prompt, isIdle ? undefined : { deliverAs: "followUp" })).catch((error) => { + clearPendingManualHandoffStartFailure(state, ctx, error, pendingRequest); + }); + } catch (error) { + clearPendingManualHandoffStartFailure(state, ctx, error, pendingRequest); + } }, }); } diff --git a/handoff/compact.ts b/handoff/compact.ts index fb014f2..5884ff3 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -23,11 +23,29 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS } state.pendingHandoff = null; - state.pendingRequestedHandoff = null; + const manualRequestGeneration = pending.manualRequestGeneration ?? null; + const manualRequestId = pending.manualRequestId ?? null; + const ownsPendingManualRequest = state.pendingRequestedHandoff !== null && + manualRequestGeneration !== null && + manualRequestGeneration === state.pendingRequestedHandoffGeneration && + manualRequestId !== null && + manualRequestId === state.pendingRequestedHandoff.requestId; + const legacyToolCalledRequest = state.pendingRequestedHandoff !== null && + manualRequestGeneration === null && + manualRequestId === null && + state.pendingRequestedHandoff.toolCalled && + !state.pendingRequestedHandoff.awaitingAgentTurn; + const shouldClearManualState = state.pendingRequestedHandoff === null || ownsPendingManualRequest || legacyToolCalledRequest; + if (shouldClearManualState) { + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffRetryProtected = false; + } clearActiveNotebookTopic(state); - // Clear the handoff progress indicator now that compaction is consuming it - if (ctx.hasUI) { + // Clear the handoff progress indicator only when this compaction owns the + // visible manual request state; newer queued requests keep their status. + if (shouldClearManualState && ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } diff --git a/handoff/tool.ts b/handoff/tool.ts index 790fa11..44166bc 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -12,7 +12,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; -import { STATUS_KEY_HANDOFF } from "../tui.js"; +import { resolveHandoffAutomaticAvailability } from "../settings.js"; +import { preserveManualHandoffRequestAfterCompactionError } from "./cleanup.js"; /** * Build the enriched task that becomes the compaction summary. @@ -46,59 +47,68 @@ export function registerHandoffTool( name: "handoff", label: "Handoff", description: - "Replace the active context with a compact task brief at the end of " + - "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" + - "WHEN TO USE:\n" + - " 1. Context past ~30% and the current job is no longer cleanly " + - "represented near the front of attention.\n" + - " 2. Context is filled with mechanics irrelevant to what comes " + - "next (research traces, planning deliberation, dead ends).\n" + - " 3. The current job is complete and a new distinct task starts.\n\n" + - "Rule: one context, one job. When the job changes, call handoff.\n\n" + - "AFTER HANDOFF the LLM sees:\n" + - " β€’ System prompt + context primer\n" + - " β€’ The handoff task β€” the distilled next work at the top of context\n" + - " β€’ All notebook pages β€” durable grounding accessible via notebook_read / notebook_index", - - promptSnippet: "Pivot to a new job via deliberate handoff compaction", - promptGuidelines: [ - "Before handoff, promote any missing durable grounding knowledge that the next context will need to the notebook. " + - "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.", - ], + "Performs authorized context compaction with a supplied task brief. " + + "Availability is enforced at execution time by extension state and settings.", executionMode: "sequential", parameters: Type.Object({ task: Type.String({ description: - "What to do next. A concise but sufficiently detailed handoff brief. " + - "This becomes the FIRST thing the LLM sees after handoff. Capture the distilled next task, " + - "immediate starting state, blockers, failed paths worth avoiding, and relevant notebook page names. " + - "The notebook is the long-term grounding store; this brief should carry only the remaining situational context.", + "Task brief to place at the start of the next compacted context when this handoff request is authorized.", }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const availability = await resolveHandoffAutomaticAvailability(ctx); + const manualRequest = state.pendingRequestedHandoff; + const awaitingManualRequest = manualRequest?.awaitingAgentTurn === true; + const activeManualRequest = manualRequest?.awaitingAgentTurn === false ? manualRequest : null; + if (awaitingManualRequest) { + return { + content: [{ type: "text", text: "A manual /handoff request is queued, but its generated user turn has not started yet. No compaction was started." }], + details: { automaticEnabled: availability.automaticEnabled, manualRequest: "awaiting_agent_turn" }, + }; + } + if (!availability.automaticEnabled && !activeManualRequest) { + if (ctx.hasUI) { + ctx.ui.notify("Automatic handoff is disabled by handoff.automaticEnabled=false; use the explicit /handoff command to request a manual handoff.", "warning"); + } + return { + content: [{ type: "text", text: "Automatic handoff is disabled, and there is no active manual /handoff request. No compaction was started." }], + details: { automaticEnabled: false, manualRequest: false }, + }; + } + const enrichedTask = buildEnrichedTask(params.task); - state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = true; + const retryableManualRequest = activeManualRequest + ? { ...activeManualRequest, toolCalled: false, awaitingAgentTurn: false } + : null; + const retryableManualRequestPrompt = activeManualRequest ? state.pendingRequestedHandoffPrompt : null; + const retryableManualRequestGeneration = activeManualRequest ? state.pendingRequestedHandoffGeneration : null; + const retryableManualRequestId = activeManualRequest ? activeManualRequest.requestId : null; + state.pendingHandoff = { + task: enrichedTask, + source: "tool", + manualRequestGeneration: retryableManualRequestGeneration, + manualRequestId: retryableManualRequestId, + }; + if (activeManualRequest) { + activeManualRequest.toolCalled = true; + } + try { + ctx.compact({ + onComplete: () => { + pi.sendUserMessage("Proceed."); + }, + onError: () => { + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt, retryableManualRequestGeneration); + }, + }); + } catch (error) { + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt, retryableManualRequestGeneration); + throw error; } - ctx.compact({ - onComplete: () => { - pi.sendUserMessage("Proceed."); - }, - onError: () => { - state.pendingHandoff = null; - // Safe: pendingRequestedHandoff may already be cleaned up by watchdog - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = false; - } - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - }, - }); return { content: [{ type: "text", text: "Handoff started." }], diff --git a/index.ts b/index.ts index f6506f0..8db4494 100644 --- a/index.ts +++ b/index.ts @@ -21,8 +21,8 @@ import { Text, } from "@earendil-works/pi-tui"; import { createState, resetState, type AgenticodingState } from "./state.js"; -import { CONTEXT_PRIMER } from "./system-prompt.js"; -import { buildNudge, registerWatchdog } from "./watchdog.js"; +import { getContextPrimer } from "./system-prompt.js"; +import { buildManualHandoffNudge, buildNudge, buildQueuedManualHandoffNudge, registerWatchdog } from "./watchdog.js"; import { registerNotebookTools } from "./notebook/tools.js"; import { registerNotebookRehydration } from "./notebook/rehydration.js"; import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; @@ -30,6 +30,7 @@ import { setActiveNotebookTopic } from "./notebook/topic.js"; import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; +import { registerAgenticodingSettingsCommand, resolveHandoffAutomaticAvailability } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { STATUS_KEY_HANDOFF, @@ -39,6 +40,53 @@ import { } from "./tui.js"; import { formatPagePreview } from "./notebook/store.js"; +function getUserMessageText(message: unknown): string { + try { + if (typeof message !== "object" || message === null) { + return ""; + } + const candidate = message as { role?: unknown; content?: unknown }; + if (candidate.role !== "user") { + return ""; + } + const content = candidate.content; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .map((part) => { + if (typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text") { + const text = (part as { text?: unknown }).text; + return typeof text === "string" ? text : ""; + } + return ""; + }) + .join(""); + } catch { + return ""; + } +} + +const MANUAL_HANDOFF_REQUEST_ID_PATTERN = /^Manual handoff request id: ([0-9a-fA-F-]{36})$/m; + +function getManualHandoffRequestId(prompt: string): string | null { + return MANUAL_HANDOFF_REQUEST_ID_PATTERN.exec(prompt)?.[1] ?? null; +} + +function activatePendingRequestedHandoff(state: AgenticodingState, prompt: string, ctx?: ExtensionContext): void { + const request = state.pendingRequestedHandoff; + const requestId = getManualHandoffRequestId(prompt); + if (request?.awaitingAgentTurn && requestId !== null && requestId === request.requestId) { + request.awaitingAgentTurn = false; + if (ctx?.hasUI && ctx.ui.theme) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff active")); + } + } +} + export default function (pi: ExtensionAPI): void { const state: AgenticodingState = createState(); @@ -55,6 +103,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerAgenticodingSettingsCommand(pi); // ── /notebook command β€” interactive page selector ──────────────── pi.registerCommand("notebook", { @@ -63,13 +112,16 @@ export default function (pi: ExtensionAPI): void { const topicArg = args.trim(); if (topicArg) { const result = setActiveNotebookTopic(state, topicArg, "human"); + const availability = await resolveHandoffAutomaticAvailability(ctx); if (ctx.hasUI) { const message = result.boundaryHint - ? `Active notebook topic changed: ${result.boundaryHint.from} β†’ ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` + ? (availability.automaticEnabled + ? `Active notebook topic changed: ${result.boundaryHint.from} β†’ ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` + : `Active notebook topic changed: ${result.boundaryHint.from} β†’ ${result.boundaryHint.to}. This is a likely task boundary; save notebook findings and tell the operator if a clean transition is needed.`) : `Active notebook topic: ${result.current}`; ctx.ui.notify(message, result.boundaryHint ? "warning" : "info"); } - updateIndicators(ctx, state); + updateIndicators(ctx, state, availability.automaticEnabled); return; } if (!ctx.hasUI) { @@ -160,19 +212,29 @@ export default function (pi: ExtensionAPI): void { // ── before_agent_start: inject context primer + notebook ─────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + activatePendingRequestedHandoff(state, event.prompt, ctx); + const availability = await resolveHandoffAutomaticAvailability(ctx); + const manualHandoffActive = state.pendingRequestedHandoff !== null && + !state.pendingRequestedHandoff.awaitingAgentTurn && + !state.pendingRequestedHandoff.toolCalled; + // Update TUI indicators before each user-prompt agent run - updateIndicators(ctx, state); + updateIndicators(ctx, state, availability.automaticEnabled); const parts: string[] = [event.systemPrompt]; // Inject context management primer at the end of the system prompt - parts.push("\n" + CONTEXT_PRIMER); + parts.push("\n" + getContextPrimer(availability.automaticEnabled, manualHandoffActive)); if (state.activeNotebookTopic) { parts.push( `\n## Active Notebook Topic\n` + `Current topic: \`${state.activeNotebookTopic}\` (${state.activeNotebookTopicSource ?? "unknown"}-set).\n` + - `Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, prefer handoff over dragging stale context forward.`, + (manualHandoffActive + ? `Treat this as context to preserve while following the active manual /handoff request: save durable notebook findings, draft the brief, and call the handoff tool.` + : availability.automaticEnabled + ? `Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, prefer handoff over dragging stale context forward.` + : `Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, save durable notebook findings, continue inline only if safe, or tell the operator.`), ); } else { parts.push( @@ -201,6 +263,10 @@ export default function (pi: ExtensionAPI): void { return { systemPrompt: parts.join("\n\n") }; }); + pi.on("message_start", async (event, ctx?: ExtensionContext) => { + activatePendingRequestedHandoff(state, getUserMessageText(event.message), ctx); + }); + // ── context: inject primacy-zone nudge before each LLM call ──── pi.on("context", async (event, ctx: ExtensionContext) => { const usage = ctx.getContextUsage(); @@ -212,7 +278,18 @@ export default function (pi: ExtensionAPI): void { return; } - const nudge = buildNudge(state, percent); + const availability = await resolveHandoffAutomaticAvailability(ctx); + const manualHandoffActive = state.pendingRequestedHandoff !== null && + !state.pendingRequestedHandoff.awaitingAgentTurn && + !state.pendingRequestedHandoff.toolCalled; + const manualHandoffQueued = state.pendingRequestedHandoff !== null && + state.pendingRequestedHandoff.awaitingAgentTurn && + !state.pendingRequestedHandoff.toolCalled; + const nudge = manualHandoffActive + ? buildManualHandoffNudge(state, percent) + : manualHandoffQueued + ? buildQueuedManualHandoffNudge(state, percent) + : buildNudge(state, percent, availability.automaticEnabled); state.pendingTopicBoundaryHint = null; return { messages: [ @@ -239,19 +316,25 @@ export default function (pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } - updateIndicators(ctx, state); + const availability = await resolveHandoffAutomaticAvailability(ctx); + updateIndicators(ctx, state, availability.automaticEnabled); }); - // ── update TUI indicators after each turn ─────────────────────── + pi.on("turn_start", async (_event, _ctx: ExtensionContext) => { + // Manual /handoff follow-up detection is intentionally handled in + // before_agent_start by matching the extension-injected user message. + // turn_start fires for every internal LLM/tool turn in an already-running + // agent loop, so using it here would prematurely consume queued follow-ups. + }); + + // ── update TUI indicators after each provider turn ─────────────── pi.on("turn_end", async (_event, ctx: ExtensionContext) => { - // Fallback: clear handoff indicator if the LLM completed a turn - // without calling the handoff tool (ignored the direction) - if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) { - state.pendingRequestedHandoff = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - } - updateIndicators(ctx, state); + // Do not clear pending manual /handoff here: a requested handoff run may + // span multiple provider turns while the LLM reads/writes notebook pages + // before finally calling the handoff tool. Stale requested handoffs are + // cleared at agent_end by the watchdog once the whole requested user run + // completes without a handoff tool call. + const availability = await resolveHandoffAutomaticAvailability(ctx); + updateIndicators(ctx, state, availability.automaticEnabled); }); } diff --git a/notebook/tools.ts b/notebook/tools.ts index 4f13c1d..1b852c7 100644 --- a/notebook/tools.ts +++ b/notebook/tools.ts @@ -10,6 +10,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; +import { resolveHandoffAutomaticAvailability } from "../settings.js"; import { updateIndicators } from "../tui.js"; import { formatPageList, formatPagePreview, getPageNames, saveNotebookPage } from "./store.js"; @@ -50,7 +51,7 @@ export function createNotebookToolDefinitions( "Reuse or refine an existing page when possible.", "Prefer stable subject-oriented pages over workflow-phase pages.", "Write for a fresh context: keep reusable facts, architecture, decisions, constraints, expensive discoveries, and durable open questions.", - "Avoid transient task state, scratch reasoning, transcripts, logs, or large tool output; the immediate next task belongs in handoff.", + "Avoid transient task state, scratch reasoning, transcripts, logs, or large tool output; keep immediate next-step state out of durable notebook pages.", ], } : {}), @@ -94,7 +95,8 @@ export function createNotebookToolDefinitions( async execute(_toolCallId, params, _signal, onUpdate, ctx) { assertFresh(); const saved = await saveNotebookPage(pi, state, params.name, params.content, assertFresh); - updateIndicators(ctx, state); + const availability = await resolveHandoffAutomaticAvailability(ctx); + updateIndicators(ctx, state, availability.automaticEnabled); onUpdate?.({ content: [{ @@ -198,7 +200,7 @@ export function createNotebookToolDefinitions( ? { promptSnippet: "List pages via notebook index", promptGuidelines: [ - "Scan the index before new work, after handoff, before replanning, or when stuck.", + "Scan the index before new work, before replanning, or when stuck.", "Use the index to find relevant grounding pages, then open only those pages with notebook_read.", ], } diff --git a/notebook/topic-tool.ts b/notebook/topic-tool.ts index 2a23177..5dea75f 100644 --- a/notebook/topic-tool.ts +++ b/notebook/topic-tool.ts @@ -17,7 +17,7 @@ export function registerNotebookTopicTool( promptSnippet: "Set the active notebook topic for the current session", promptGuidelines: [ "Use this early in a fresh session when no active notebook topic exists yet.", - "Do not use this to override a human-set topic. If the work no longer fits the current topic, prefer handoff instead.", + "Do not use this to override a human-set topic. If the work no longer fits the current topic, keep the current topic and follow the session's context-pivot guidance before continuing in a different semantic frame.", ], parameters: Type.Object({ topic: Type.String({ @@ -30,8 +30,8 @@ export function registerNotebookTopicTool( if (state.activeNotebookTopic !== normalized) { throw new Error( state.activeNotebookTopicSource === "human" - ? "Human-set notebook topic is authoritative. Use handoff instead of overriding it." - : "Active notebook topic already exists. Use handoff instead of changing it mid-session.", + ? "Human-set notebook topic is authoritative. Keep the current topic and follow the session's context-pivot guidance before switching work." + : "Active notebook topic already exists. Keep the current topic and follow the session's context-pivot guidance before switching work.", ); } return { diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..826aadd --- /dev/null +++ b/settings.ts @@ -0,0 +1,595 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent"; +import { + Container, + type SettingItem, + SettingsList, + type SettingsListTheme, + Text, +} from "@earendil-works/pi-tui"; + +export type HandoffAutomaticValue = "true" | "false"; + +type SettingsObject = Record; +type SettingsSourceLabel = "global" | "project"; +type SettingsInvalidReason = "invalid-json" | "non-object" | "malformed-handoff" | "read-error"; +type AtomicWriteOperations = { + writeFile: typeof writeFile; + rename: typeof rename; + rm: typeof rm; +}; + +export interface SettingsSourceState { + label: SettingsSourceLabel; + path: string; + exists: boolean; + invalid: boolean; + invalidReason?: SettingsInvalidReason; + readErrorCode?: string; + settings: SettingsObject; + automaticEnabled: unknown; +} + +export interface HandoffSettingsState { + global: SettingsSourceState; + project: SettingsSourceState; + merged: SettingsObject; +} + +export interface HandoffAutomaticAvailability { + automaticEnabled: boolean; + source: "default" | "global" | "project" | "fallback"; +} + +export interface AgenticodingSettingsModel { + state: HandoffSettingsState; + effectiveAutomaticEnabled: boolean; + effectiveSource: HandoffAutomaticAvailability["source"]; + projectOverride: boolean; + projectOverrideWarning?: string; + globalWriteBlocked: boolean; + messages: string[]; + save: (value: boolean | HandoffAutomaticValue, ctx?: ExtensionContext) => Promise; +} + +const SUPPORTED_HANDOFF_AUTOMATIC_VALUES: HandoffAutomaticValue[] = ["true", "false"]; +const MAX_DEDUPED_AVAILABILITY_WARNINGS = 100; +const dedupedAvailabilityWarningKeys = new Set(); +const defaultAtomicWriteOperations: AtomicWriteOperations = { writeFile, rename, rm }; +let atomicWriteOperations: AtomicWriteOperations = defaultAtomicWriteOperations; + +export function setSettingsAtomicWriteOperationsForTest(operations: Partial | null): void { + atomicWriteOperations = operations ? { ...defaultAtomicWriteOperations, ...operations } : defaultAtomicWriteOperations; +} + +export const MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS = + "No interactive settings TUI is available. Edit ~/.pi/agent/settings.json and set handoff.automaticEnabled, for example { \"handoff\": { \"automaticEnabled\": true } } or false. Project .pi/settings.json can override the global value."; + +function getGlobalSettingsPath(): string { + return join(homedir(), ".pi", "agent", "settings.json"); +} + +function getProjectSettingsPath(cwd: string | undefined): string { + return join(cwd ?? process.cwd(), ".pi", "settings.json"); +} + +async function writeFileAtomically(path: string, contents: string): Promise { + const directory = dirname(path); + const tempPath = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`); + const existingMode = await stat(path) + .then((stats) => stats.mode & 0o7777) + .catch((error) => getErrorCode(error) === "ENOENT" ? undefined : Promise.reject(error)); + try { + await atomicWriteOperations.writeFile(tempPath, contents, { + encoding: "utf8", + mode: existingMode ?? 0o600, + }); + if (existingMode !== undefined) { + await chmod(tempPath, existingMode); + } + await atomicWriteOperations.rename(tempPath, path); + } catch (error) { + await atomicWriteOperations.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } +} + +function isPlainObject(value: unknown): value is SettingsObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function createSettingsObject(): SettingsObject { + return Object.create(null) as SettingsObject; +} + +function hasOwnSetting(settings: SettingsObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(settings, key); +} + +function getOwnSetting(settings: SettingsObject, key: string): unknown { + return hasOwnSetting(settings, key) ? settings[key] : undefined; +} + +function setOwnSetting(settings: SettingsObject, key: string, value: unknown): void { + Object.defineProperty(settings, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +} + +function cloneSettingsObject(settings: SettingsObject): SettingsObject { + const result = createSettingsObject(); + for (const [key, value] of Object.entries(settings)) { + setOwnSetting(result, key, isPlainObject(value) ? cloneSettingsObject(value) : value); + } + return result; +} + +function mergeSettings(base: SettingsObject, override: SettingsObject): SettingsObject { + const result = cloneSettingsObject(base); + for (const [key, value] of Object.entries(override)) { + const existing = getOwnSetting(result, key); + if (key === "handoff" && !isPlainObject(value)) { + continue; + } + if (isPlainObject(existing) && isPlainObject(value)) { + setOwnSetting(result, key, mergeSettings(existing, value)); + } else { + setOwnSetting(result, key, isPlainObject(value) ? cloneSettingsObject(value) : value); + } + } + return result; +} + +function extractAutomaticEnabled(settings: SettingsObject): unknown { + const handoff = getOwnSetting(settings, "handoff"); + return isPlainObject(handoff) && hasOwnSetting(handoff, "automaticEnabled") + ? getOwnSetting(handoff, "automaticEnabled") + : undefined; +} + +function getLayeredAutomaticEnabled(state: HandoffSettingsState): { value: unknown; source: "default" | "global" | "project" } { + if (state.project.automaticEnabled !== undefined) { + return { value: state.project.automaticEnabled, source: "project" }; + } + if (state.global.automaticEnabled !== undefined) { + return { value: state.global.automaticEnabled, source: "global" }; + } + return { value: undefined, source: "default" }; +} + +function isHandoffAutomaticValue(value: unknown): value is HandoffAutomaticValue { + return value === "true" || value === "false"; +} + +function parseAutomaticValue(value: boolean | HandoffAutomaticValue): boolean { + return value === true || value === "true"; +} + +function notify(ctx: ExtensionContext | undefined, message: string, level: "info" | "warning" | "error"): void { + if (ctx?.hasUI) { + ctx.ui.notify(message, level); + } +} + +function notifyAvailabilityWarningOnce(ctx: ExtensionContext | undefined, key: string, message: string): void { + if (!ctx?.hasUI) { + return; + } + if (dedupedAvailabilityWarningKeys.has(key)) { + return; + } + dedupedAvailabilityWarningKeys.add(key); + if (dedupedAvailabilityWarningKeys.size > MAX_DEDUPED_AVAILABILITY_WARNINGS) { + const oldest = dedupedAvailabilityWarningKeys.values().next().value; + if (typeof oldest === "string") { + dedupedAvailabilityWarningKeys.delete(oldest); + } + } + ctx.ui.notify(message, "warning"); +} + +function invalidSourceWarningKey(source: SettingsSourceState): string { + return [ + "invalid-source", + source.label, + source.path, + source.invalidReason ?? "unknown", + source.readErrorCode ?? "", + ].join("\0"); +} + +function unsupportedValueWarningKey(source: SettingsSourceState, value: unknown): string { + return ["unsupported-value", source.label, source.path, formatSettingValue(value)].join("\0"); +} + +function forgetAvailabilityWarningKeysForSource(source: SettingsSourceState): void { + const prefix = `\0${source.label}\0${source.path}\0`; + for (const key of [...dedupedAvailabilityWarningKeys]) { + if (key.includes(prefix)) { + dedupedAvailabilityWarningKeys.delete(key); + } + } +} + +function resetAvailabilityWarningDedupeForValidSources(state: HandoffSettingsState): void { + for (const source of [state.global, state.project]) { + if (!source.invalid && (source.automaticEnabled === undefined || typeof source.automaticEnabled === "boolean")) { + forgetAvailabilityWarningKeysForSource(source); + } + } +} + +function formatSettingValue(value: unknown): string { + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } +} + +function getErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : undefined; +} + +function describeInvalidSource(source: SettingsSourceState, consequence: string): string { + const sourceName = source.label === "global" ? "global" : "project"; + if (source.invalidReason === "read-error") { + const code = source.readErrorCode ? ` (${source.readErrorCode})` : ""; + return `Unable to read ${sourceName} settings at ${source.path}${code}; ${consequence}.`; + } + if (source.invalidReason === "non-object") { + return `Invalid ${sourceName} settings JSON at ${source.path}; root must be an object; ${consequence}.`; + } + if (source.invalidReason === "malformed-handoff") { + return `Invalid ${sourceName} settings JSON at ${source.path}; handoff must be an object when present; ${consequence}.`; + } + return `Invalid ${sourceName} settings JSON at ${source.path}; ${consequence}.`; +} + +function describeSourceStateForDisplay(source: SettingsSourceState): string { + if (!source.invalid) { + return describeValue(source.automaticEnabled); + } + if (source.invalidReason === "read-error") { + return source.readErrorCode ? `unreadable (${source.readErrorCode})` : "unreadable"; + } + if (source.invalidReason === "non-object") { + return "non-object JSON"; + } + if (source.invalidReason === "malformed-handoff") { + return "malformed handoff"; + } + return "invalid JSON"; +} + +async function readSettingsSource(label: SettingsSourceLabel, path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + const code = getErrorCode(error); + if (code === "ENOENT") { + return { label, path, exists: false, invalid: false, settings: createSettingsObject(), automaticEnabled: undefined }; + } + return { label, path, exists: true, invalid: true, invalidReason: "read-error", readErrorCode: code, settings: createSettingsObject(), automaticEnabled: undefined }; + } + + try { + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + return { label, path, exists: true, invalid: true, invalidReason: "non-object", settings: createSettingsObject(), automaticEnabled: undefined }; + } + const settings = cloneSettingsObject(parsed); + const handoff = getOwnSetting(settings, "handoff"); + if (handoff !== undefined && !isPlainObject(handoff)) { + return { label, path, exists: true, invalid: true, invalidReason: "malformed-handoff", settings: createSettingsObject(), automaticEnabled: undefined }; + } + return { label, path, exists: true, invalid: false, settings, automaticEnabled: extractAutomaticEnabled(settings) }; + } catch { + return { label, path, exists: true, invalid: true, invalidReason: "invalid-json", settings: createSettingsObject(), automaticEnabled: undefined }; + } +} + +export async function readHandoffSettingsState(cwd?: string): Promise { + const global = await readSettingsSource("global", getGlobalSettingsPath()); + const project = await readSettingsSource("project", getProjectSettingsPath(cwd)); + return { + global, + project, + merged: mergeSettings(global.settings, project.settings), + }; +} + +function resolveFromState(state: HandoffSettingsState): HandoffAutomaticAvailability { + if (state.global.invalid || state.project.invalid) { + return { automaticEnabled: false, source: "fallback" }; + } + + const automatic = getLayeredAutomaticEnabled(state); + if (automatic.value === undefined) { + return { automaticEnabled: true, source: "default" }; + } + if (typeof automatic.value === "boolean") { + return { automaticEnabled: automatic.value, source: automatic.source }; + } + return { automaticEnabled: false, source: "fallback" }; +} + +export async function resolveHandoffAutomaticAvailability(ctx: ExtensionContext): Promise { + const state = await readHandoffSettingsState(ctx.cwd); + resetAvailabilityWarningDedupeForValidSources(state); + + if (state.global.invalid) { + notifyAvailabilityWarningOnce( + ctx, + invalidSourceWarningKey(state.global), + describeInvalidSource(state.global, "falling back to automatic handoff disabled for handoff.automaticEnabled"), + ); + } + if (state.project.invalid) { + notifyAvailabilityWarningOnce( + ctx, + invalidSourceWarningKey(state.project), + describeInvalidSource(state.project, "falling back to automatic handoff disabled for handoff.automaticEnabled"), + ); + } + if (state.global.invalid || state.project.invalid) { + return { automaticEnabled: false, source: "fallback" }; + } + + const automatic = getLayeredAutomaticEnabled(state); + if (automatic.value === undefined) { + return { automaticEnabled: true, source: "default" }; + } + if (typeof automatic.value === "boolean") { + return { automaticEnabled: automatic.value, source: automatic.source }; + } + + const automaticSource = automatic.source === "project" ? state.project : state.global; + notifyAvailabilityWarningOnce( + ctx, + unsupportedValueWarningKey(automaticSource, automatic.value), + `Unsupported handoff.automaticEnabled value ${formatSettingValue(automatic.value)}; supported values are true or false, falling back to automatic handoff disabled.`, + ); + return { automaticEnabled: false, source: "fallback" }; +} + +export async function writeGlobalHandoffAutomaticEnabled( + value: boolean | HandoffAutomaticValue, + ctx?: ExtensionContext, +): Promise { + const booleanValue = parseAutomaticValue(value); + const path = getGlobalSettingsPath(); + let settings = createSettingsObject(); + let raw: string | undefined; + + try { + raw = await readFile(path, "utf8"); + } catch (error) { + const code = getErrorCode(error); + if (code !== "ENOENT") { + notify(ctx, `Unable to read global settings JSON at ${path}; not writing handoff.automaticEnabled to avoid clobbering it.`, "error"); + return false; + } + } + + if (raw !== undefined) { + try { + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + notify(ctx, `Invalid global settings JSON at ${path}; root must be an object, not writing handoff.automaticEnabled to avoid clobbering it.`, "error"); + return false; + } + settings = cloneSettingsObject(parsed); + } catch { + notify(ctx, `Invalid global settings JSON at ${path}; not writing handoff.automaticEnabled to avoid clobbering it.`, "error"); + return false; + } + } + + const existingHandoff = getOwnSetting(settings, "handoff"); + if (existingHandoff !== undefined && !isPlainObject(existingHandoff)) { + notify(ctx, `Invalid global settings JSON at ${path}; handoff must be an object when present, not writing handoff.automaticEnabled to avoid clobbering it.`, "error"); + return false; + } + const handoff = isPlainObject(existingHandoff) ? cloneSettingsObject(existingHandoff) : createSettingsObject(); + setOwnSetting(handoff, "automaticEnabled", booleanValue); + setOwnSetting(settings, "handoff", handoff); + + await mkdir(dirname(path), { recursive: true }); + await writeFileAtomically(path, JSON.stringify(settings, null, 2) + "\n"); + notify(ctx, `Saved global handoff.automaticEnabled = ${booleanValue}.`, "info"); + return true; +} + +export async function buildAgenticodingSettingsModel(ctx: ExtensionContext): Promise { + const state = await readHandoffSettingsState(ctx.cwd); + const messages: string[] = []; + let effective = resolveFromState(state); + + if (state.global.invalid) { + messages.push(describeInvalidSource(state.global, "global TUI saves are blocked until it is fixed")); + } else if (state.project.invalid) { + messages.push(describeInvalidSource(state.project, "runtime falls back to automatic handoff disabled, but global TUI saves are still allowed")); + } else { + const automatic = getLayeredAutomaticEnabled(state); + if (automatic.value !== undefined && typeof automatic.value !== "boolean") { + messages.push(`Unsupported handoff.automaticEnabled value ${formatSettingValue(automatic.value)}; runtime falls back to automatic handoff disabled.`); + } + } + + const projectOverride = !state.project.invalid && state.project.automaticEnabled !== undefined; + const projectOverrideWarning = projectOverride + ? `Project settings at ${state.project.path} define handoff.automaticEnabled and override/mask the global value. Saving here writes only ${state.global.path}; edit or remove the project setting manually before the global save affects this project.` + : undefined; + if (projectOverrideWarning) { + messages.push(projectOverrideWarning); + } + + return { + state, + effectiveAutomaticEnabled: effective.automaticEnabled, + effectiveSource: effective.source, + projectOverride, + projectOverrideWarning, + globalWriteBlocked: state.global.invalid, + messages, + save: (value, saveCtx) => writeGlobalHandoffAutomaticEnabled(value, saveCtx ?? ctx), + }; +} + +function describeValue(value: unknown): string { + return value === undefined ? "unset" : formatSettingValue(value); +} + +function getGlobalEditableHandoffAutomaticValue(model: AgenticodingSettingsModel): HandoffAutomaticValue { + return typeof model.state.global.automaticEnabled === "boolean" + ? (model.state.global.automaticEnabled ? "true" : "false") + : "true"; +} + +export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsModel): string[] { + const lines = [ + `Resolved handoff.automaticEnabled: ${model.effectiveAutomaticEnabled} (${model.effectiveSource})`, + `Supported values: true, false. Default: true (automatic handoff enabled).`, + `When false, automatic agent-initiated handoff is blocked; explicit /handoff still works.`, + `Prompt guidance updates on future fresh agent turns; direct tool calls are guarded at execution time.`, + `After successful handoff compaction, Pi auto-sends Proceed.; this continuation is fixed, not configurable.`, + `Global settings: ${model.state.global.path} (${describeSourceStateForDisplay(model.state.global)})`, + `Project settings: ${model.state.project.path} (${describeSourceStateForDisplay(model.state.project)})`, + `TUI saves are global-only; project settings override global settings at runtime.`, + ]; + for (const message of model.messages) { + lines.push(`Warning: ${message}`); + } + return lines; +} + +function getSafeSettingsListTheme(): SettingsListTheme { + try { + return getSettingsListTheme(); + } catch { + return { + label: (text) => text, + value: (text) => text, + description: (text) => text, + cursor: ">", + hint: (text) => text, + }; + } +} + +export function createAgenticodingSettingsComponent( + initialModel: AgenticodingSettingsModel, + ctx: ExtensionContext, + tui: { requestRender: () => void }, + theme: { fg: (name: string, text: string) => string; bold: (text: string) => string }, + done: (value: "closed") => void, +) { + let model = initialModel; + const container = new Container(); + const summary = new Text("", 1, 0); + const items: SettingItem[] = [{ + id: "handoff.automaticEnabled", + label: "Automatic handoff availability (global save)", + currentValue: getGlobalEditableHandoffAutomaticValue(model), + values: SUPPORTED_HANDOFF_AUTOMATIC_VALUES, + }]; + + const refreshSummary = () => { + const lines = getAgenticodingSettingsDisplayLines(model).map((line) => { + if (line.startsWith("Warning:")) return theme.fg("warning", line); + if (line.startsWith("Resolved")) return theme.fg("accent", line); + return theme.fg("muted", line); + }); + summary.setText(lines.join("\n")); + }; + refreshSummary(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold(" Agenticoding Settings ")), 1, 0)); + container.addChild(summary); + + const settingsList = new SettingsList( + items, + 4, + getSafeSettingsListTheme(), + (id, newValue) => { + if (id !== "handoff.automaticEnabled" || !isHandoffAutomaticValue(newValue)) return; + void (async () => { + try { + const saved = await model.save(newValue, ctx); + model = await buildAgenticodingSettingsModel(ctx); + settingsList.updateValue("handoff.automaticEnabled", getGlobalEditableHandoffAutomaticValue(model)); + if (saved && model.projectOverrideWarning) { + notify(ctx, model.projectOverrideWarning, "warning"); + } + refreshSummary(); + tui.requestRender(); + } catch (err) { + notify(ctx, `Failed to save handoff.automaticEnabled: ${err instanceof Error ? err.message : String(err)}`, "error"); + } + })(); + }, + () => done("closed"), + { enableSearch: false }, + ); + container.addChild(settingsList); + container.addChild(new Text(theme.fg("dim", " ↑↓ navigate β€’ enter change β€’ esc close "), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (width: number) => container.render(width), + invalidate: () => { + container.invalidate(); + refreshSummary(); + }, + handleInput: (data: string) => { + settingsList.handleInput?.(data); + tui.requestRender(); + }, + }; +} + +function showManualSettingsInstructions(pi: ExtensionAPI, ctx: ExtensionContext): void { + if (ctx.hasUI) { + ctx.ui.notify(MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, "info"); + return; + } + + pi.sendMessage({ + customType: "agenticoding-settings", + content: MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, + display: true, + }); +} + +export function registerAgenticodingSettingsCommand(pi: ExtensionAPI): void { + pi.registerCommand("agenticoding-settings", { + description: "Configure pi-agenticoding automatic handoff availability", + handler: async (_args, ctx) => { + if (!ctx.hasUI || typeof ctx.ui.custom !== "function") { + showManualSettingsInstructions(pi, ctx); + return; + } + + const model = await buildAgenticodingSettingsModel(ctx); + const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => + createAgenticodingSettingsComponent(model, ctx, tui, theme, done), + ); + if (result === undefined) { + showManualSettingsInstructions(pi, ctx); + } + }, + }); +} diff --git a/state.ts b/state.ts index 626c696..dc760e5 100644 --- a/state.ts +++ b/state.ts @@ -31,15 +31,31 @@ export interface AgenticodingState { lastContextPercent: number | null; /** Handoff task queued by the tool until the compaction hook consumes it. */ - pendingHandoff: { task: string; source: "tool" } | null; + pendingHandoff: { task: string; source: "tool"; manualRequestGeneration?: number | null; manualRequestId?: string | null } | null; /** User-requested handoff that must result in a real tool-driven compaction. */ pendingRequestedHandoff: { + /** Unique identity for this manual request; prompt text/direction is not identity. */ + requestId: string; direction: string; enforcementAttempts: number; toolCalled: boolean; + /** True until the LLM run created by /handoff has actually started. */ + awaitingAgentTurn: boolean; } | null; + /** Exact extension-injected user message that should start the pending /handoff run. */ + pendingRequestedHandoffPrompt: string | null; + + /** Monotonic identity for manual /handoff requests; same direction is not identity. */ + pendingRequestedHandoffGeneration: number; + + /** + * One-shot grace after compaction onError restores a manual request. Prevents + * same failed turn cleanup from treating the retry-ready request as stale. + */ + pendingRequestedHandoffRetryProtected: boolean; + /** * Published child agent sessions keyed by toolCallId. * Lifecycle: executeSpawn publishes β†’ renderSpawnResult claims via get+delete. @@ -78,6 +94,9 @@ export function createState(): AgenticodingState { lastContextPercent: null, pendingHandoff: null, pendingRequestedHandoff: null, + pendingRequestedHandoffPrompt: null, + pendingRequestedHandoffGeneration: 0, + pendingRequestedHandoffRetryProtected: false, childSessions, liveChildSessions, childSessionEpoch: 0, @@ -111,6 +130,9 @@ export function resetState(state: AgenticodingState): void { state.lastContextPercent = null; state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + state.pendingRequestedHandoffGeneration = 0; + state.pendingRequestedHandoffRetryProtected = false; abortAndClearChildSessions(state); } diff --git a/system-prompt.ts b/system-prompt.ts index ae7b809..cd57fd4 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -5,11 +5,68 @@ * Teaches the LLM about spawn, notebook, and handoff primitives. */ -export const CONTEXT_PRIMER = ` +function buildContextPrimer(handoffAutomaticEnabled: boolean, manualHandoffActive = false): string { + const pivotGuidance = manualHandoffActive + ? `### Manual handoff β€” explicit operator request active +A manual /handoff request from the operator is active for this generated turn. +Save any durable reusable knowledge that aligns with the request to the +notebook first, then draft the handoff brief and call the handoff tool. Do not +replace the operator-requested handoff with ordinary automatic-disabled inline +continuation guidance.` + : handoffAutomaticEnabled + ? `### Handoff β€” distilled next task +When the job changes, or when context is noisy past the ~30% heuristic, use +handoff to finish extracting what matters from the current context before the +cut. Save durable reusable knowledge to the notebook first, then draft a +handoff brief that carries only the situational context still missing: current +state, blockers, unresolved questions, failed paths worth avoiding, and next +steps. Handoff compacts the active session around that brief so the next turn +starts in a clean context with the right direction already in view. Full history +remains in the session file for the user. + +The next context should use the notebook for grounding and the handoff brief +for direction. Reference notebook pages by name; do not duplicate their content +in the brief. The handoff should help the next context start well without +re-deriving what you already learned.` + : `### Context pivoting when automatic handoff is disabled +Automatic context compaction is guarded in normal agent turns. The handoff +tool is disabled for normal turns; use it only after an explicit manual +handoff request. At job boundaries or when context gets noisy, save durable +reusable knowledge to the notebook first. Then either continue inline if it is +still safe and clear, or tell the operator that a clean-context transition +would help and summarize the next direction they should provide.`; + + const topicGuidance = manualHandoffActive + ? `A manual /handoff request is active in this generated turn. Use the topic as +context to preserve, save durable notebook findings, draft the brief, and call +the handoff tool for the operator-requested transition.` + : handoffAutomaticEnabled + ? `If the current work still fits that topic, prefer spawn for isolated noisy +subtasks so the parent stays focused. If the work no longer fits that topic, +prefer handoff over dragging stale context forward. After handoff, assign a +fresh topic again in the next context.` + : `If the current work still fits that topic, prefer spawn for isolated noisy +subtasks so the parent stays focused. If the work no longer fits that topic, +save durable findings, continue inline only if safe, or tell the operator what +clean-context direction is needed.`; + + const jobBoundaryRule = manualHandoffActive + ? `- Complete the active manual /handoff request for this generated turn +- Save durable findings first, then call handoff with the distilled brief +- After handoff, fetch only the pages you need and assign a fresh topic again` + : handoffAutomaticEnabled + ? `- Call handoff at job boundaries: researchβ†’execution, planningβ†’execution +- Use handoff to pass the distilled next task and immediate starting state +- After handoff, fetch only the pages you need and assign a fresh topic again` + : `- At job boundaries, save durable findings and avoid dragging stale context forward +- If continuing inline is unsafe, tell the operator the clean next direction clearly +- In any fresh context, fetch only the pages you need and assign a fresh topic again`; + + return ` ## Context management One context, one job. Research is one job. Planning is one job. Execution -is one job. When the job changes, call the handoff tool. +is one job. ${manualHandoffActive ? "For this generated turn, complete the active manual /handoff request." : handoffAutomaticEnabled ? "When the job changes, call the handoff tool." : "When the job changes, save durable findings and keep the next direction explicit."} ### The primacy-zone heuristic You use long context unevenly. Performance can degrade as context grows β€” @@ -31,34 +88,18 @@ by subject rather than workflow phase. Store only reusable knowledge worth carrying across resets: verified facts, architecture learned, decisions and rationale, constraints, expensive discoveries, and durable open questions. -Treat notebook_index as the notebook index. Scan it at task start, after handoff, -before replanning, or when stuck. Use notebook_read to open only relevant pages. -Use them to ground a fresh context, avoid repeated work, and resume a subject -quickly. Verify stale notes before relying on them. Avoid raw transcripts, logs, -or large tool output. Reference pages by name; fetch on demand; never pre-load -bodies. +Treat notebook_index as the notebook index. Scan it at task start, after a clean +context transition, before replanning, or when stuck. Use notebook_read to open +only relevant pages. Use them to ground a fresh context, avoid repeated work, +and resume a subject quickly. Verify stale notes before relying on them. Avoid +raw transcripts, logs, or large tool output. Reference pages by name; fetch on +demand; never pre-load bodies. ### Active notebook topic β€” current semantic frame The active notebook topic names the current high-level frame for this session. -If the current work still fits that topic, prefer spawn for isolated noisy -subtasks so the parent stays focused. If the work no longer fits that topic, -prefer handoff over dragging stale context forward. After handoff, assign a -fresh topic again in the next context. - -### Handoff β€” distilled next task -When the job changes, or when context is noisy past the ~30% heuristic, use -handoff to finish extracting what matters from the current context before the -cut. Save durable reusable knowledge to the notebook first, then draft a -handoff brief that carries only the situational context still missing: current -state, blockers, unresolved questions, failed paths worth avoiding, and next -steps. Handoff compacts the active session around that brief so the next turn -starts in a clean context with the right direction already in view. Full history -remains in the session file for the user. +${topicGuidance} -The next context should use the notebook for grounding and the handoff brief -for direction. Reference notebook pages by name; do not duplicate their content -in the brief. The handoff should help the next context start well without -re-deriving what you already learned. +${pivotGuidance} ### Rules - Maintain the notebook deliberately; update it when you learn durable knowledge worth carrying across contexts @@ -70,8 +111,13 @@ re-deriving what you already learned. - Use compact sections such as Facts / Architecture / Decisions / Constraints / Open questions when helpful - Separate facts, guesses, and decisions when useful - Use spawn to delegate isolated subtasks when it helps; parent orchestrates and merges results -- Treat the active notebook topic as the current semantic frame: same topic β†’ spawn bias, different topic β†’ handoff bias -- Call handoff at job boundaries: researchβ†’execution, planningβ†’execution -- Use handoff to pass the distilled next task and immediate starting state -- After handoff, fetch only the pages you need and assign a fresh topic again +- Treat the active notebook topic as the current semantic frame: same topic β†’ spawn bias, different topic β†’ ${handoffAutomaticEnabled ? "handoff bias" : "clean-transition caution"} +${jobBoundaryRule} `.trim(); +} + +export const CONTEXT_PRIMER = buildContextPrimer(true); + +export function getContextPrimer(handoffAutomaticEnabled: boolean, manualHandoffActive = false): string { + return buildContextPrimer(handoffAutomaticEnabled, manualHandoffActive); +} diff --git a/tui.ts b/tui.ts index 9205b2c..4021d3f 100644 --- a/tui.ts +++ b/tui.ts @@ -26,7 +26,7 @@ export const STATUS_KEY_NOTEBOOK = "agenticoding-notebook"; export const STATUS_KEY_TOPIC = "agenticoding-topic"; /** Update TUI indicators: context usage, notebook count, topic, warning widget. */ -export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void { +export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState, handoffAutomaticEnabled = true): void { if (!ctx.hasUI) return; const theme = ctx.ui.theme; @@ -58,9 +58,19 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState // High-context warning widget (above editor) if (usage && usage.percent !== null && usage.percent >= 70) { - const warning = state.activeNotebookTopic - ? `Context at ${Math.round(usage.percent)}% β€” use topic fit: same topic β†’ spawn, different topic β†’ handoff` - : `Context at ${Math.round(usage.percent)}% β€” no active topic; handoff soon unless you can assign one cleanly`; + const manualHandoffRequest = state.pendingRequestedHandoff !== null && !state.pendingRequestedHandoff.toolCalled; + const manualHandoffQueued = manualHandoffRequest && state.pendingRequestedHandoff!.awaitingAgentTurn; + const warning = manualHandoffRequest + ? (manualHandoffQueued + ? `Context at ${Math.round(usage.percent)}% β€” manual /handoff request is queued for its generated user turn; do not call the handoff tool from the current turn` + : `Context at ${Math.round(usage.percent)}% β€” manual /handoff request is active; follow the request, save durable findings, draft the brief, and call the handoff tool`) + : handoffAutomaticEnabled + ? (state.activeNotebookTopic + ? `Context at ${Math.round(usage.percent)}% β€” use topic fit: same topic β†’ spawn, different topic β†’ handoff` + : `Context at ${Math.round(usage.percent)}% β€” no active topic; handoff soon unless you can assign one cleanly`) + : (state.activeNotebookTopic + ? `Context at ${Math.round(usage.percent)}% β€” use topic fit: same topic β†’ spawn, different topic β†’ save notes and tell operator if a clean transition is needed` + : `Context at ${Math.round(usage.percent)}% β€” no active topic; save notebook findings and continue inline only if safe`); ctx.ui.setWidget(WIDGET_KEY_WARNING, [ theme.fg("error", "\u26A0 ") + theme.fg("warning", warning), ]); diff --git a/watchdog.ts b/watchdog.ts index 2800817..0db6e6d 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -10,42 +10,88 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "./state.js"; -import { STATUS_KEY_HANDOFF } from "./tui.js"; +import { clearStaleRequestedHandoff } from "./handoff/cleanup.js"; -export function buildNudge(state: Pick, percent: number | null): string { +function formatContextLead(percent: number | null): string { + const pct = percent === null ? null : Math.round(percent); + return pct === null + ? "Topic-aware context reminder." + : pct >= 70 + ? `Context at ${pct}% β€” topic discipline is urgent.` + : pct >= 50 + ? `Context at ${pct}% β€” topic discipline matters now.` + : `Context at ${pct}% β€” choose your next step by topic fit.`; +} + +export function buildManualHandoffNudge(state: Pick, percent: number | null): string { + const topic = state.activeNotebookTopic; + const boundary = state.pendingTopicBoundaryHint; + const boundaryText = boundary + ? `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. Treat this as context to capture, but keep following the active manual handoff request.` + : "An explicit manual /handoff request is active."; + const topicText = topic ? `Active notebook topic: ${topic}.` : "No active notebook topic is set."; + + return `${formatContextLead(percent)} +${boundaryText} +${topicText} +Follow the user's manual /handoff direction: save durable findings to the notebook, draft the handoff brief, and call the handoff tool. Do not replace this with normal disabled-mode inline continuation guidance.`; +} + +export function buildQueuedManualHandoffNudge(state: Pick, percent: number | null): string { + const topic = state.activeNotebookTopic; + const boundary = state.pendingTopicBoundaryHint; + const boundaryText = boundary + ? `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. Preserve useful context for the queued manual handoff, but do not call the handoff tool from this current turn.` + : "An explicit manual /handoff request is queued for its generated user turn."; + const topicText = topic ? `Active notebook topic: ${topic}.` : "No active notebook topic is set."; + + return `${formatContextLead(percent)} +${boundaryText} +${topicText} +A manual /handoff request is waiting for the generated user turn that carries its request id. The current turn is not authorized to satisfy it: save durable findings if useful, continue only if safe, and do not call the handoff tool until that generated turn starts.`; +} + +export function buildNudge(state: Pick, percent: number | null, handoffAutomaticEnabled = true): string { const pct = percent === null ? null : Math.round(percent); const topic = state.activeNotebookTopic; const boundary = state.pendingTopicBoundaryHint; if (boundary) { - return `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. + return handoffAutomaticEnabled + ? `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. Treat this as a strong task-boundary signal. Prefer a deliberate handoff before continuing under the new topic: save durable findings to the notebook, draft a concise situational brief, and call handoff. Only continue inline if this was -merely a rename rather than a real pivot.`; +merely a rename rather than a real pivot.` + : `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. +Treat this as a strong task-boundary signal. Save durable findings to the +notebook, then continue inline only if this was merely a rename or still safe. +If this is a real pivot, tell the operator the clean next direction needed.`; } - const contextLead = pct === null - ? "Topic-aware context reminder." - : pct >= 70 - ? `Context at ${pct}% β€” topic discipline is urgent.` - : pct >= 50 - ? `Context at ${pct}% β€” topic discipline matters now.` - : `Context at ${pct}% β€” choose your next step by topic fit.`; + const contextLead = formatContextLead(percent); if (topic) { - const urgency = pct !== null && pct >= 70 - ? "If the work no longer fits this topic, prefer a deliberate handoff now. If it still fits and only a focused noisy branch is needed, spawn it instead of polluting the parent context." - : "If the current work still fits this topic, prefer spawn for isolated noisy subtasks. If it no longer fits, prefer handoff instead of dragging stale context forward."; + const urgency = handoffAutomaticEnabled + ? (pct !== null && pct >= 70 + ? "If the work no longer fits this topic, prefer a deliberate handoff now. If it still fits and only a focused noisy branch is needed, spawn it instead of polluting the parent context." + : "If the current work still fits this topic, prefer spawn for isolated noisy subtasks. If it no longer fits, prefer handoff instead of dragging stale context forward.") + : (pct !== null && pct >= 70 + ? "If the work no longer fits this topic, save notebook findings and tell the operator the clean next direction needed. If it still fits and only a focused noisy branch is needed, spawn it instead of polluting the parent context." + : "If the current work still fits this topic, prefer spawn for isolated noisy subtasks. If it no longer fits, save notebook findings, continue inline only if safe, or tell the operator."); return `${contextLead} Active notebook topic: ${topic}. Use the topic as the current semantic frame. ${urgency} -Save durable findings to the notebook before handoff.`; +Save durable findings to the notebook before any clean transition.`; } - const noTopicUrgency = pct !== null && pct >= 70 - ? "Assign a fresh topic in the next clean context after handoff." - : "Assign a short stable topic soon. If the work stays within that topic, prefer spawn for noisy subtasks. If the work shifts beyond it, prefer handoff."; + const noTopicUrgency = handoffAutomaticEnabled + ? (pct !== null && pct >= 70 + ? "Assign a fresh topic in the next clean context after handoff." + : "Assign a short stable topic soon. If the work stays within that topic, prefer spawn for noisy subtasks. If the work shifts beyond it, prefer handoff.") + : (pct !== null && pct >= 70 + ? "Save notebook findings, tell the operator if a clean transition is needed, and assign a fresh topic in any new context." + : "Assign a short stable topic soon. If the work stays within that topic, prefer spawn for noisy subtasks. If the work shifts beyond it, save notebook findings and continue inline only if safe."); return `${contextLead} No active notebook topic is set. ${noTopicUrgency}`; } @@ -60,10 +106,11 @@ export function registerWatchdog(pi: ExtensionAPI, state: AgenticodingState): vo const requestedHandoff = state.pendingRequestedHandoff; if (requestedHandoff) { requestedHandoff.enforcementAttempts += 1; - if (!requestedHandoff.toolCalled) { - state.pendingRequestedHandoff = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + if (!requestedHandoff.toolCalled && !requestedHandoff.awaitingAgentTurn) { + if (state.pendingRequestedHandoffRetryProtected) { + state.pendingRequestedHandoffRetryProtected = false; + } else { + await clearStaleRequestedHandoff(pi, state, ctx); } } }