diff --git a/CHANGELOG.md b/CHANGELOG.md index 761e65f..21a6b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,16 @@ 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 pending. +- 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 the operator request, sends the handoff prompt, and lets the guarded `handoff` tool compact that requested turn. - 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. +- Global `handoff.automaticEnabled` saves now write through a same-directory temporary file and rename over the target, preserving the previous settings file if replacement fails. + ## [0.3.0] - 2026-05-23 ### Added @@ -108,6 +116,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..1450357 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 explicit operator `/handoff ` request. 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, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect prompt guidance on future fresh agent turns, while direct handoff tool calls are checked against the effective setting at execution time. Edit or remove project overrides manually. + **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..68bacff 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,8 +1,8 @@ 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, 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"; @@ -23,6 +23,16 @@ 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 { + MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, + buildAgenticodingSettingsModel, + createAgenticodingSettingsComponent, + getAgenticodingSettingsDisplayLines, + readHandoffSettingsState, + resolveHandoffAutomaticAvailability, + setSettingsAtomicWriteOperationsForTest, + writeGlobalHandoffAutomaticEnabled, +} from "./settings.js"; import { CONTEXT_PRIMER } from "./system-prompt.js"; import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; @@ -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, @@ -352,11 +429,12 @@ test("/handoff sends the direction back through the LLM without opening the edit direction: "implement auth", enforcementAttempts: 0, toolCalled: false, + awaitingAgentTurn: true, }); 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.", + "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. 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.", options: undefined, }, ]); @@ -378,25 +456,87 @@ test("/handoff requires a direction", async () => { assert.deepEqual(pi.sentUserMessages, []); }); -test("handoff tool triggers compaction and resumes with the compacted task", async () => { +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 in progress", 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 = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false }; registerHandoffTool(pi as any, state); let compactOptions: any; - const result = await pi.tools.get("handoff").execute( + 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/); @@ -413,11 +553,806 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy 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 project parent does not erase global disabled", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { automaticEnabled: false } }, + projectSettings: { handoff: null }, + }); + + assert.equal(result.compactOptions, undefined); + assert.equal(result.notifications.length, 1); + assert.match(result.notifications[0].message, /Automatic handoff is 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.merged.handoff as any).automaticEnabled, false); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveAutomaticEnabled, false); + assert.equal(model.effectiveSource, "global"); + assert.equal(model.projectOverride, false); + }); +}); + +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 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 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.deepEqual(state.pendingRequestedHandoff, { + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + awaitingAgentTurn: false, + }); + 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 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("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); +}); + +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 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 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 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("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); + + 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); + } + }); +}); + +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, () => {}); + + setSettingsAtomicWriteOperationsForTest({ + rename: async () => { throw new Error("simulated save failure"); }, + }); + try { + component.handleInput("\r"); + await new Promise(resolve => setTimeout(resolve, 50)); + } finally { + setSettingsAtomicWriteOperationsForTest(null); + } + + 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/); + }); +}); + +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, []); + }); +}); + +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/); + + 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); + }); +}); + +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"); + + 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 = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true, awaitingAgentTurn: false }; state.activeNotebookTopic = "oauth"; state.activeNotebookTopicSource = "human"; registerHandoffCompaction(pi as any, state); @@ -476,10 +1411,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 = { 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 +1434,52 @@ 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, { + 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("handoff compact synchronous throw preserves active manual request for retry", async () => { + const pi = new MockPi(); + const state = createState(); + state.pendingRequestedHandoff = { 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, { + 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("turn_end fallback clears stale requested handoff status", async () => { +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 +1493,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 +1512,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 +1584,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 +1632,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 +1689,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 = { 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 +1711,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 +3161,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 +4429,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 +4462,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 +4475,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 +4494,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 +4581,61 @@ 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 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("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..a025f11 --- /dev/null +++ b/handoff/cleanup.ts @@ -0,0 +1,68 @@ +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; + clearHandoffStatus(ctx); +} + +export function preserveManualHandoffRequestAfterCompactionError( + state: AgenticodingState, + ctx: ExtensionContext, + request: NonNullable | null, + prompt: string | null, +): void { + state.pendingHandoff = null; + if (request) { + if (state.pendingRequestedHandoff !== null && state.pendingRequestedHandoff.direction !== request.direction) { + clearHandoffStatus(ctx); + return; + } + state.pendingRequestedHandoff = { + ...request, + toolCalled: false, + awaitingAgentTurn: false, + }; + state.pendingRequestedHandoffPrompt = prompt; + } else { + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + } + 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; + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + } +} diff --git a/handoff/command.ts b/handoff/command.ts index 314a389..039ce5f 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -9,11 +9,31 @@ 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; + 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,11 +42,15 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } - state.pendingRequestedHandoff = { + const prompt = `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the \`handoff\` tool with the brief as its task so the session actually compacts. Do not answer with only prose.`; + const pendingRequest: NonNullable = { direction, enforcementAttempts: 0, toolCalled: false, + awaitingAgentTurn: true, }; + state.pendingRequestedHandoff = pendingRequest; + state.pendingRequestedHandoffPrompt = prompt; // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { @@ -36,10 +60,14 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat ); } - 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..4c2e054 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -24,6 +24,7 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; clearActiveNotebookTopic(state); // Clear the handoff progress indicator now that compaction is consuming it diff --git a/handoff/tool.ts b/handoff/tool.ts index 790fa11..4b253ca 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,61 @@ 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); + const retryableManualRequest = activeManualRequest + ? { ...activeManualRequest, toolCalled: false, awaitingAgentTurn: false } + : null; + const retryableManualRequestPrompt = activeManualRequest ? state.pendingRequestedHandoffPrompt : null; state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = true; + if (activeManualRequest) { + activeManualRequest.toolCalled = true; + } + try { + ctx.compact({ + onComplete: () => { + pi.sendUserMessage("Proceed."); + }, + onError: () => { + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt); + }, + }); + } catch (error) { + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt); + 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..a839058 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, 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,46 @@ 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 ""; + } +} + +function activatePendingRequestedHandoff(state: AgenticodingState, prompt: string): void { + if ( + state.pendingRequestedHandoff?.awaitingAgentTurn && + state.pendingRequestedHandoffPrompt !== null && + prompt === state.pendingRequestedHandoffPrompt + ) { + state.pendingRequestedHandoff.awaitingAgentTurn = false; + } +} + export default function (pi: ExtensionAPI): void { const state: AgenticodingState = createState(); @@ -55,6 +96,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerAgenticodingSettingsCommand(pi); // ── /notebook command β€” interactive page selector ──────────────── pi.registerCommand("notebook", { @@ -63,13 +105,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 +205,24 @@ 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); + const availability = await resolveHandoffAutomaticAvailability(ctx); + // 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)); 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.`, + (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 +251,10 @@ export default function (pi: ExtensionAPI): void { return { systemPrompt: parts.join("\n\n") }; }); + pi.on("message_start", async (event) => { + activatePendingRequestedHandoff(state, getUserMessageText(event.message)); + }); + // ── context: inject primacy-zone nudge before each LLM call ──── pi.on("context", async (event, ctx: ExtensionContext) => { const usage = ctx.getContextUsage(); @@ -212,7 +266,13 @@ 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 nudge = manualHandoffActive + ? buildManualHandoffNudge(state, percent) + : buildNudge(state, percent, availability.automaticEnabled); state.pendingTopicBoundaryHint = null; return { messages: [ @@ -239,19 +299,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); + }); + + 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 turn ─────────────────────── + // ── 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..59864ad 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: [{ 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..5bb68ab --- /dev/null +++ b/settings.ts @@ -0,0 +1,512 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, 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" | "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 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`); + try { + await atomicWriteOperations.writeFile(tempPath, contents, "utf8"); + 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 formatSettingValue(value: unknown): string { + if (typeof value === "string") return `"${value}"`; + try { + return JSON.stringify(value) ?? String(value); + } 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}.`; + } + 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"; + } + 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); + 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); + + if (state.global.invalid) { + notify(ctx, describeInvalidSource(state.global, "falling back to automatic handoff disabled for handoff.automaticEnabled"), "warning"); + } + if (state.project.invalid) { + notify(ctx, describeInvalidSource(state.project, "falling back to automatic handoff disabled for handoff.automaticEnabled"), "warning"); + } + 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 }; + } + + notify( + ctx, + `Unsupported handoff.automaticEnabled value ${formatSettingValue(automatic.value)}; supported values are true or false, falling back to automatic handoff disabled.`, + "warning", + ); + 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"); + 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..1bf4072 100644 --- a/state.ts +++ b/state.ts @@ -38,8 +38,13 @@ export interface AgenticodingState { 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; + /** * Published child agent sessions keyed by toolCallId. * Lifecycle: executeSpawn publishes β†’ renderSpawnResult claims via get+delete. @@ -78,6 +83,7 @@ export function createState(): AgenticodingState { lastContextPercent: null, pendingHandoff: null, pendingRequestedHandoff: null, + pendingRequestedHandoffPrompt: null, childSessions, liveChildSessions, childSessionEpoch: 0, @@ -111,6 +117,7 @@ export function resetState(state: AgenticodingState): void { state.lastContextPercent = null; state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; abortAndClearChildSessions(state); } diff --git a/system-prompt.ts b/system-prompt.ts index ae7b809..cfc0f62 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -5,11 +5,53 @@ * Teaches the LLM about spawn, notebook, and handoff primitives. */ -export const CONTEXT_PRIMER = ` +function buildContextPrimer(handoffAutomaticEnabled: boolean): string { + const pivotGuidance = 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 = 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 = 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. ${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 +73,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 +96,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): string { + return buildContextPrimer(handoffAutomaticEnabled); +} diff --git a/tui.ts b/tui.ts index 9205b2c..a302d2f 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,13 @@ 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 warning = 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..2fc3aae 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -10,42 +10,74 @@ 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 clean-transition guidance.`; +} + +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,11 +92,8 @@ 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) { + await clearStaleRequestedHandoff(pi, state, ctx); } }