From 1169956ac664b8a5389e4d6fb768ddcf282728e1 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 17:06:30 +0000 Subject: [PATCH 01/16] pi-agenticoding/story-03-handoff-resume-control: add handoff settings TUI --- CHANGELOG.md | 8 + README.md | 22 ++- agenticoding.test.ts | 307 +++++++++++++++++++++++++++++++++++- handoff/tool.ts | 6 +- index.ts | 2 + package.json | 2 +- settings.ts | 367 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 705 insertions(+), 9 deletions(-) create mode 100644 settings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f96d272..742946e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - No changes yet. +## [0.3.0] - 2026-05-24 + +### Changed + +- **Breaking:** handoff now defaults to waiting after compaction instead of auto-sending `Proceed.`. Users who want the previous auto-resume behavior can opt in with `/agenticoding-settings` or with `"handoff": { "resumeBehavior": "proceed" }` in `~/.pi/agent/settings.json` or `/.pi/settings.json`. +- Added `handoff.resumeBehavior` settings support with supported values `"wait"` (default) and `"proceed"`; unsupported values and invalid settings JSON fail safe to `wait` with a warning diagnostic. +- Added the extension-owned `/agenticoding-settings` TUI panel for handoff resume behavior. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, and visibly warn when a project override masks the global value. + ## [0.2.0] - 2026-05-21 ### Added diff --git a/README.md b/README.md index bd6860a..641a8b0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # pi-agenticoding [![pi.dev package](https://img.shields.io/badge/pi.dev-package-purple)](https://pi.dev/packages/pi-agenticoding) -[![npm version](https://img.shields.io/badge/npm-0.1.0-blue)](https://www.npmjs.com/package/pi-agenticoding) +[![npm version](https://img.shields.io/badge/npm-0.3.0-blue)](https://www.npmjs.com/package/pi-agenticoding) [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) ![Status](https://img.shields.io/badge/status-active-brightgreen) @@ -40,7 +40,18 @@ Then disable pi's built-in compaction so handoff stays in control: } ``` -That's it. Your agent now has `spawn`, `ledger_add`, `ledger_get`, `ledger_list`, and `handoff`. The status bar shows context usage and ledger count. +By default, handoff waits after compaction for your next explicit input. To keep the older automatic continuation behavior, add: + +```json +// ~/.pi/agent/settings.json or /.pi/settings.json +{ + "handoff": { "resumeBehavior": "proceed" } +} +``` + +Supported `handoff.resumeBehavior` values are `"wait"` (default) and `"proceed"`. You can also run `/agenticoding-settings` in the Pi TUI to open the extension-owned settings panel for this value. TUI saves are global-only to `~/.pi/agent/settings.json`; if `/.pi/settings.json` contains a project override, it continues to win at runtime and the panel warns that you must edit/remove the project setting manually before the global save affects that project. + +That's it. Your agent now has `spawn`, `ledger_add`, `ledger_get`, `ledger_list`, `handoff`, and `/agenticoding-settings`. The status bar shows context usage and ledger count. --- @@ -50,7 +61,8 @@ That's it. Your agent now has `spawn`, `ledger_add`, `ledger_get`, `ledger_list` |---------|-------------------| | **Context usage %** | `ctx 65%` in status bar — green < 30%, yellow < 50%, orange < 70%, red ≥ 70% | | **Ledger count** | 📒 `3` when entries exist, hidden when empty | -| **`/handoff` command** | Instant pivot — agent drafts brief, compacts context, resumes | +| **`/handoff` command** | Instant pivot — agent drafts brief, compacts context, waits for next input (configurable auto-resume) | +| **`/agenticoding-settings` command** | TUI panel for global handoff resume behavior, with project override warnings | | **`/ledger` command** | Overlay showing all entries with previews | | **Auto-rehydration** | Ledger entries survive session restarts | | **Spawn transparency** | Watch child agents work in real time in the TUI | @@ -114,6 +126,10 @@ A sparse continuity cache the agent curates while working. After discovering som When context degrades or the job changes, the agent saves reusable state to the ledger, writes a focused brief preserving what's still missing, and restarts clean. The new context starts with the brief front-and-center, all ledger entries accessible, and zero noise. +Handoff resume behavior is controlled by raw Pi settings JSON: global `~/.pi/agent/settings.json` nested-merged with project `/.pi/settings.json`, with project values overriding global. Set `handoff.resumeBehavior` to `"wait"` (default, no automatic continuation message) or `"proceed"` (send one `Proceed.` message after compaction to auto-resume). + +Run `/agenticoding-settings` to configure the setting through pi-agenticoding's extension-owned TUI panel. The panel writes only the global file (`~/.pi/agent/settings.json`) and preserves unrelated JSON keys while reserializing the file. It does not offer project-scope writes. If a project `.pi/settings.json` defines `handoff.resumeBehavior`, that project override masks the global value; the panel shows a warning and saving globally will not affect the current project until you edit or remove the project setting manually. + **Rule of thumb:** The ledger holds reusable learned knowledge. Handoff carries the remaining situational context. --- diff --git a/agenticoding.test.ts b/agenticoding.test.ts index c54213a..ac2a3c7 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,5 +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 { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff/command.js"; @@ -19,6 +22,11 @@ import { saveLedgerEntry, resetLedgerWriteLock } from "./ledger/store.js"; import { createLedgerToolDefinitions } from "./ledger/tools.js"; import registerAgenticoding from "./index.js"; import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; +import { + MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, + buildAgenticodingSettingsModel, + getAgenticodingSettingsDisplayLines, +} from "./settings.js"; // Safety net: reset module-level mutable state after all tests. // Individual tests should also call reset*() at the start for explicit isolation. @@ -78,6 +86,7 @@ class MockPi { activeTools: string[] = []; 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 }) { @@ -133,11 +142,78 @@ 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 }> = []; + + 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; + }, + }, + ); + compactOptions.onComplete({}); + + return { sentUserMessages: pi.sentUserMessages, notifications }; + }); +} + // ── TUI indicator tests ─────────────────────────────────────────────── function makeTUICtx( @@ -292,24 +368,25 @@ test("/handoff requires a direction", async () => { assert.deepEqual(pi.sentUserMessages, []); }); -test("handoff tool triggers compaction and resumes with the compacted task", async () => { +test("handoff resume setting defaults to wait when absent", async () => { const pi = new MockPi(); const state = createState(); state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: 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" }, undefined, undefined, { + cwd, compact: (options: any) => { compactOptions = options; }, }, - ); + )); assert.equal(state.pendingHandoff?.source, "tool"); assert.match(state.pendingHandoff?.task ?? "", /## Handoff — Continue Previous Work/); @@ -320,7 +397,229 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy assert.equal(result.terminate, true); compactOptions.onComplete({}); - assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); + assert.deepEqual(pi.sentUserMessages, []); +}); + +test("handoff resume setting wait suppresses automatic continuation", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { resumeBehavior: "proceed" } }, + projectSettings: { handoff: { resumeBehavior: "wait" } }, + }); + + assert.deepEqual(result.sentUserMessages, []); + assert.deepEqual(result.notifications, []); +}); + +test("handoff resume setting proceed sends exactly one automatic continuation", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { resumeBehavior: "wait" } }, + projectSettings: { handoff: { resumeBehavior: "proceed" } }, + }); + + assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); + assert.deepEqual(result.notifications, []); +}); + +test("handoff resume setting unsupported value falls back to wait with diagnostic", async () => { + const result = await runHandoffResumeScenario({ + projectSettings: { handoff: { resumeBehavior: "surprise" } }, + }); + + assert.deepEqual(result.sentUserMessages, []); + assert.equal(result.notifications.length, 1); + assert.equal(result.notifications[0].level, "warning"); + assert.match(result.notifications[0].message, /Unsupported handoff\.resumeBehavior/); + assert.match(result.notifications[0].message, /surprise/); + assert.match(result.notifications[0].message, /falling back to wait/); +}); + +test("handoff resume setting invalid JSON falls back to wait with diagnostic", async () => { + const globalResult = await runHandoffResumeScenario({ globalSettings: "{" }); + assert.deepEqual(globalResult.sentUserMessages, []); + assert.equal(globalResult.notifications.length, 1); + assert.equal(globalResult.notifications[0].level, "warning"); + assert.match(globalResult.notifications[0].message, /Invalid global settings JSON/); + assert.match(globalResult.notifications[0].message, /falling back to wait/); + + const projectResult = await runHandoffResumeScenario({ + globalSettings: { handoff: { resumeBehavior: "proceed" } }, + projectSettings: "{", + }); + assert.deepEqual(projectResult.sentUserMessages, []); + assert.equal(projectResult.notifications.length, 1); + assert.equal(projectResult.notifications[0].level, "warning"); + assert.match(projectResult.notifications[0].message, /Invalid project settings JSON/); + assert.match(projectResult.notifications[0].message, /falling back to wait/); +}); + +test("handoff resume 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\.resumeBehavior/); + assert.match(readme, /wait/); + assert.match(readme, /proceed/); + assert.match(readme, /default/i); + assert.match(changelog, /handoff\.resumeBehavior/); + assert.match(changelog, /default.*wait/i); + assert.match(changelog, /proceed/); +}); + +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\.resumeBehavior: wait/); + assert.match(rendered, /Supported values: wait, proceed/); + assert.match(rendered, /global-only/); + }); +}); + +test("agenticoding settings TUI persists handoff resume behavior globally", 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("proceed", 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.resumeBehavior, "proceed"); + await assert.rejects(() => readFile(projectPath, "utf8")); + assert.deepEqual(notifications, [{ message: 'Saved global handoff.resumeBehavior = "proceed".', level: "info" }]); + + const roundTrip = await buildAgenticodingSettingsModel(ctx); + assert.equal(roundTrip.effectiveBehavior, "proceed"); + assert.equal(roundTrip.effectiveSource, "global"); + }); +}); + +test("agenticoding settings TUI warns when project override masks global setting", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { resumeBehavior: "proceed" } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveBehavior, "wait"); + 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: .*"wait"/); + assert.match(display, /Warning: Project settings/); + }); +}); + +test("agenticoding settings TUI handles invalid JSON policies", 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("proceed", 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/); + }); + + 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("proceed", ctx), true); + const saved = JSON.parse(await readFile(globalPath, "utf8")); + assert.equal(saved.handoff.resumeBehavior, "proceed"); + assert.equal(notifications.at(-1)?.level, "info"); + }); +}); + +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); + + 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(changelog, /\/agenticoding-settings/); + assert.match(changelog, /global-only/i); + assert.match(changelog, /project.*override/i); }); test("handoff compaction replaces old context with the queued task", async () => { diff --git a/handoff/tool.ts b/handoff/tool.ts index 2a699a2..82a8a58 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -13,6 +13,7 @@ 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 { resolveHandoffResumeBehavior } from "../settings.js"; const MAX_INLINE_ENTRIES = 3; const MAX_INLINE_CHARS = 4000; @@ -124,13 +125,16 @@ export function registerHandoffTool( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const enrichedTask = buildEnrichedTask(params.task, state); + const resumeBehavior = await resolveHandoffResumeBehavior(ctx); state.pendingHandoff = { task: enrichedTask, source: "tool" }; if (state.pendingRequestedHandoff) { state.pendingRequestedHandoff.toolCalled = true; } ctx.compact({ onComplete: () => { - pi.sendUserMessage("Proceed."); + if (resumeBehavior === "proceed") { + pi.sendUserMessage("Proceed."); + } }, onError: () => { state.pendingHandoff = null; diff --git a/index.ts b/index.ts index 87e9a21..6c81794 100644 --- a/index.ts +++ b/index.ts @@ -28,6 +28,7 @@ import { registerLedgerRehydration } from "./ledger/rehydration.js"; import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; +import { registerAgenticodingSettingsCommand } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { STATUS_KEY_HANDOFF, @@ -51,6 +52,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerAgenticodingSettingsCommand(pi); // ── /ledger command — interactive entry selector ──────────────── pi.registerCommand("ledger", { diff --git a/package.json b/package.json index 0d2364e..893bea6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-agenticoding", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "description": "Context management primitives for the pi coding agent — spawn, ledger, handoff", "license": "MIT", diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..cbe6275 --- /dev/null +++ b/settings.ts @@ -0,0 +1,367 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { 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 HandoffResumeBehavior = "wait" | "proceed"; + +type SettingsObject = Record; +type SettingsSourceLabel = "global" | "project"; + +export interface SettingsSourceState { + label: SettingsSourceLabel; + path: string; + exists: boolean; + invalid: boolean; + settings: SettingsObject; + resumeBehavior: unknown; +} + +export interface HandoffSettingsState { + global: SettingsSourceState; + project: SettingsSourceState; + merged: SettingsObject; +} + +export interface AgenticodingSettingsModel { + state: HandoffSettingsState; + effectiveBehavior: HandoffResumeBehavior; + effectiveSource: "default" | "global" | "project" | "fallback"; + projectOverride: boolean; + projectOverrideWarning?: string; + globalWriteBlocked: boolean; + messages: string[]; + save: (value: HandoffResumeBehavior, ctx?: ExtensionContext) => Promise; +} + +const SUPPORTED_HANDOFF_RESUME_BEHAVIORS: HandoffResumeBehavior[] = ["wait", "proceed"]; + +export const MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS = + "No interactive settings TUI is available. Edit ~/.pi/agent/settings.json and set { \"handoff\": { \"resumeBehavior\": \"wait\" } } or \"proceed\". 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"); +} + +function isPlainObject(value: unknown): value is SettingsObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mergeSettings(base: SettingsObject, override: SettingsObject): SettingsObject { + const result: SettingsObject = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = result[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + result[key] = mergeSettings(existing, value); + } else { + result[key] = value; + } + } + return result; +} + +function extractResumeBehavior(settings: SettingsObject): unknown { + return isPlainObject(settings.handoff) ? settings.handoff.resumeBehavior : undefined; +} + +function isHandoffResumeBehavior(value: unknown): value is HandoffResumeBehavior { + return value === "wait" || value === "proceed"; +} + +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); + } +} + +async function readSettingsSource(label: SettingsSourceLabel, path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + return { label, path, exists: false, invalid: false, settings: {}, resumeBehavior: undefined }; + } + + try { + const parsed = JSON.parse(raw); + const settings = isPlainObject(parsed) ? parsed : {}; + return { label, path, exists: true, invalid: false, settings, resumeBehavior: extractResumeBehavior(settings) }; + } catch { + return { label, path, exists: true, invalid: true, settings: {}, resumeBehavior: 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), + }; +} + +export async function resolveHandoffResumeBehavior(ctx: ExtensionContext): Promise { + const state = await readHandoffSettingsState(ctx.cwd); + + if (state.global.invalid) { + notify(ctx, `Invalid global settings JSON at ${state.global.path}; falling back to wait for handoff.resumeBehavior.`, "warning"); + } + if (state.project.invalid) { + notify(ctx, `Invalid project settings JSON at ${state.project.path}; falling back to wait for handoff.resumeBehavior.`, "warning"); + } + if (state.global.invalid || state.project.invalid) { + return "wait"; + } + + const resumeBehavior = extractResumeBehavior(state.merged); + if (resumeBehavior === undefined) { + return "wait"; + } + if (isHandoffResumeBehavior(resumeBehavior)) { + return resumeBehavior; + } + + notify( + ctx, + `Unsupported handoff.resumeBehavior value ${formatSettingValue(resumeBehavior)}; supported values are "wait" or "proceed", falling back to wait.`, + "warning", + ); + return "wait"; +} + +export async function writeGlobalHandoffResumeBehavior( + value: HandoffResumeBehavior, + ctx?: ExtensionContext, +): Promise { + const path = getGlobalSettingsPath(); + let settings: SettingsObject = {}; + let raw: string | undefined; + + try { + raw = await readFile(path, "utf8"); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + if (code !== "ENOENT") { + // Unreadable files are treated like missing by the resolver. Let the write + // path report any real filesystem failure from writeFile below. + } + } + + if (raw !== undefined) { + try { + const parsed = JSON.parse(raw); + settings = isPlainObject(parsed) ? parsed : {}; + } catch { + notify(ctx, `Invalid global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); + return false; + } + } + + const handoff = isPlainObject(settings.handoff) ? { ...settings.handoff } : {}; + handoff.resumeBehavior = value; + settings.handoff = handoff; + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); + notify(ctx, `Saved global handoff.resumeBehavior = "${value}".`, "info"); + return true; +} + +export async function buildAgenticodingSettingsModel(ctx: ExtensionContext): Promise { + const state = await readHandoffSettingsState(ctx.cwd); + const messages: string[] = []; + let effectiveBehavior: HandoffResumeBehavior = "wait"; + let effectiveSource: AgenticodingSettingsModel["effectiveSource"] = "default"; + + if (state.global.invalid) { + messages.push(`Invalid global settings JSON at ${state.global.path}; global TUI saves are blocked until it is fixed.`); + effectiveSource = "fallback"; + } else if (state.project.invalid) { + messages.push(`Invalid project settings JSON at ${state.project.path}; runtime falls back to wait, but global TUI saves are still allowed.`); + effectiveSource = "fallback"; + } else { + const mergedValue = extractResumeBehavior(state.merged); + if (isHandoffResumeBehavior(mergedValue)) { + effectiveBehavior = mergedValue; + effectiveSource = state.project.resumeBehavior !== undefined ? "project" : "global"; + } else if (mergedValue !== undefined) { + messages.push(`Unsupported handoff.resumeBehavior value ${formatSettingValue(mergedValue)}; runtime falls back to wait.`); + effectiveSource = "fallback"; + } + } + + const projectOverride = !state.project.invalid && state.project.resumeBehavior !== undefined; + const projectOverrideWarning = projectOverride + ? `Project settings at ${state.project.path} define handoff.resumeBehavior 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, + effectiveBehavior, + effectiveSource, + projectOverride, + projectOverrideWarning, + globalWriteBlocked: state.global.invalid, + messages, + save: (value, saveCtx) => writeGlobalHandoffResumeBehavior(value, saveCtx ?? ctx), + }; +} + +function describeValue(value: unknown): string { + return value === undefined ? "unset" : formatSettingValue(value); +} + +export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsModel): string[] { + const lines = [ + `Resolved handoff.resumeBehavior: ${model.effectiveBehavior} (${model.effectiveSource})`, + `Supported values: wait, proceed. Default: wait (no automatic continuation).`, + `Proceed sends exactly one \"Proceed.\" message after compaction.`, + `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.resumeBehavior)})`, + `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.resumeBehavior)})`, + `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.resumeBehavior", + label: "Handoff resume behavior (global save)", + currentValue: model.effectiveBehavior, + values: SUPPORTED_HANDOFF_RESUME_BEHAVIORS, + }]; + + 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.resumeBehavior" || !isHandoffResumeBehavior(newValue)) return; + void (async () => { + const saved = await model.save(newValue, ctx); + model = await buildAgenticodingSettingsModel(ctx); + settingsList.updateValue("handoff.resumeBehavior", model.effectiveBehavior); + if (saved && model.projectOverrideWarning) { + notify(ctx, model.projectOverrideWarning, "warning"); + } + refreshSummary(); + tui.requestRender(); + })(); + }, + () => 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 handoff resume behavior", + 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); + } + }, + }); +} From c267cac04558d0347d96fd30901bcbd0092c3eea Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 19:18:30 +0000 Subject: [PATCH 02/16] docs: streamline handoff resume README guidance --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9c74e26..f5755c0 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,7 @@ Then disable pi's built-in compaction so handoff stays in control: } ``` -By default, handoff waits after compaction for your next explicit input. To keep the older automatic continuation behavior, add: - -```json -// ~/.pi/agent/settings.json or /.pi/settings.json -{ - "handoff": { "resumeBehavior": "proceed" } -} -``` - -Supported `handoff.resumeBehavior` values are `"wait"` (default) and `"proceed"`. You can also run `/agenticoding-settings` in the Pi TUI to open the extension-owned settings panel for this value. TUI saves are global-only to `~/.pi/agent/settings.json`; if `/.pi/settings.json` contains a project override, it continues to win at runtime and the panel warns that you must edit/remove the project setting manually before the global save affects that project. +Optional handoff resume preferences can be changed later with `/agenticoding-settings`. That's it. Your agent now has `spawn`, `ledger_add`, `ledger_get`, `ledger_list`, `handoff`, and `/agenticoding-settings`. The status bar shows context usage and ledger count. @@ -126,9 +117,15 @@ A sparse continuity cache the agent curates while working. After discovering som When context degrades or the job changes, the agent saves reusable state to the ledger, writes a focused brief preserving what's still missing, and restarts clean. The new context starts with the brief front-and-center, all ledger entries accessible, and zero noise. -Handoff resume behavior is controlled by raw Pi settings JSON: global `~/.pi/agent/settings.json` nested-merged with project `/.pi/settings.json`, with project values overriding global. Set `handoff.resumeBehavior` to `"wait"` (default, no automatic continuation message) or `"proceed"` (send one `Proceed.` message after compaction to auto-resume). +By default, handoff waits after compaction for your next input. To auto-resume, set `handoff.resumeBehavior` to `"proceed"`; valid values are `"wait"` and `"proceed"`. + +```json +{ + "handoff": { "resumeBehavior": "proceed" } +} +``` -Run `/agenticoding-settings` to configure the setting through pi-agenticoding's extension-owned TUI panel. The panel writes only the global file (`~/.pi/agent/settings.json`) and preserves unrelated JSON keys while reserializing the file. It does not offer project-scope writes. If a project `.pi/settings.json` defines `handoff.resumeBehavior`, that project override masks the global value; the panel shows a warning and saving globally will not affect the current project until you edit or remove the project setting manually. +Run `/agenticoding-settings` to change this from the TUI. It saves global-only to `~/.pi/agent/settings.json`; project `.pi/settings.json` values still override global settings, and the panel warns when an override is active. **Rule of thumb:** The ledger holds reusable learned knowledge. Handoff carries the remaining situational context. From 85879c3c3378df04c2e8b295abc0d0c608a70933 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 19:29:38 +0000 Subject: [PATCH 03/16] fix: ignore prototype settings keys --- agenticoding.test.ts | 26 +++++++++++++++++++ settings.ts | 59 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/agenticoding.test.ts b/agenticoding.test.ts index e927690..ca226f7 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -443,6 +443,32 @@ test("handoff resume setting proceed sends exactly one automatic continuation", assert.deepEqual(result.notifications, []); }); +test("handoff resume setting ignores prototype/meta keys unless resumeBehavior is own nested setting", async () => { + const topLevelPrototypeResult = await runHandoffResumeScenario({ + globalSettings: '{"__proto__":{"handoff":{"resumeBehavior":"proceed"}}}', + }); + assert.deepEqual(topLevelPrototypeResult.sentUserMessages, []); + assert.deepEqual(topLevelPrototypeResult.notifications, []); + + const nestedPrototypeResult = await runHandoffResumeScenario({ + globalSettings: { handoff: { other: true } }, + projectSettings: '{"handoff":{"__proto__":{"resumeBehavior":"proceed"}}}', + }); + assert.deepEqual(nestedPrototypeResult.sentUserMessages, []); + assert.deepEqual(nestedPrototypeResult.notifications, []); + + await withIsolatedSettings(async ({ home, cwd }) => { + await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), '{"__proto__":{"handoff":{"resumeBehavior":"proceed"}}}'); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), '{"handoff":{"__proto__":{"resumeBehavior":"proceed"}}}'); + + const model = await buildAgenticodingSettingsModel({ cwd, hasUI: true, ui: { notify: () => {} } } as any); + assert.equal(model.effectiveBehavior, "wait"); + assert.equal(model.effectiveSource, "default"); + assert.equal(model.projectOverride, false); + assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Resolved handoff\.resumeBehavior: wait \(default\)/); + }); +}); + test("handoff resume setting unsupported value falls back to wait with diagnostic", async () => { const result = await runHandoffResumeScenario({ projectSettings: { handoff: { resumeBehavior: "surprise" } }, diff --git a/settings.ts b/settings.ts index cbe6275..ac0f28b 100644 --- a/settings.ts +++ b/settings.ts @@ -59,21 +59,53 @@ 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: SettingsObject = { ...base }; + const result = cloneSettingsObject(base); for (const [key, value] of Object.entries(override)) { - const existing = result[key]; + const existing = getOwnSetting(result, key); if (isPlainObject(existing) && isPlainObject(value)) { - result[key] = mergeSettings(existing, value); + setOwnSetting(result, key, mergeSettings(existing, value)); } else { - result[key] = value; + setOwnSetting(result, key, isPlainObject(value) ? cloneSettingsObject(value) : value); } } return result; } function extractResumeBehavior(settings: SettingsObject): unknown { - return isPlainObject(settings.handoff) ? settings.handoff.resumeBehavior : undefined; + const handoff = getOwnSetting(settings, "handoff"); + return isPlainObject(handoff) && hasOwnSetting(handoff, "resumeBehavior") + ? getOwnSetting(handoff, "resumeBehavior") + : undefined; } function isHandoffResumeBehavior(value: unknown): value is HandoffResumeBehavior { @@ -100,15 +132,15 @@ async function readSettingsSource(label: SettingsSourceLabel, path: string): Pro try { raw = await readFile(path, "utf8"); } catch { - return { label, path, exists: false, invalid: false, settings: {}, resumeBehavior: undefined }; + return { label, path, exists: false, invalid: false, settings: createSettingsObject(), resumeBehavior: undefined }; } try { const parsed = JSON.parse(raw); - const settings = isPlainObject(parsed) ? parsed : {}; + const settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject(); return { label, path, exists: true, invalid: false, settings, resumeBehavior: extractResumeBehavior(settings) }; } catch { - return { label, path, exists: true, invalid: true, settings: {}, resumeBehavior: undefined }; + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; } } @@ -156,7 +188,7 @@ export async function writeGlobalHandoffResumeBehavior( ctx?: ExtensionContext, ): Promise { const path = getGlobalSettingsPath(); - let settings: SettingsObject = {}; + let settings = createSettingsObject(); let raw: string | undefined; try { @@ -172,16 +204,17 @@ export async function writeGlobalHandoffResumeBehavior( if (raw !== undefined) { try { const parsed = JSON.parse(raw); - settings = isPlainObject(parsed) ? parsed : {}; + settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject(); } catch { notify(ctx, `Invalid global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); return false; } } - const handoff = isPlainObject(settings.handoff) ? { ...settings.handoff } : {}; - handoff.resumeBehavior = value; - settings.handoff = handoff; + const existingHandoff = getOwnSetting(settings, "handoff"); + const handoff = isPlainObject(existingHandoff) ? cloneSettingsObject(existingHandoff) : createSettingsObject(); + setOwnSetting(handoff, "resumeBehavior", value); + setOwnSetting(settings, "handoff", handoff); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); From 0fe3cac6aa0f056fb857dd0af29815cc37e412c9 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 19:35:03 +0000 Subject: [PATCH 04/16] docs: move handoff changes to unreleased changelog --- CHANGELOG.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae6ddd..434d9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **Breaking:** handoff now defaults to waiting after compaction instead of auto-sending `Proceed.`. Users who want auto-resume can opt in with `/agenticoding-settings` or with `"handoff": { "resumeBehavior": "proceed" }` in `~/.pi/agent/settings.json` or `/.pi/settings.json`. +- Added `handoff.resumeBehavior` settings support with supported values `"wait"` (default) and `"proceed"`; unsupported values and invalid settings JSON fail safe to `wait` with a warning diagnostic. +- Added the extension-owned `/agenticoding-settings` TUI panel for handoff resume behavior. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, and visibly warn when a project override masks the global value. + +### Fixed + +- Hardened raw settings JSON handling so prototype/meta keys such as `__proto__` cannot masquerade as an own `handoff.resumeBehavior` setting. + ## [0.3.0] - 2026-05-23 ### Added @@ -23,14 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Stray ANSI reset codes in spawn shell** — `truncateToWidth` no longer injects escape sequences that break background color styling in collapsed spawn renderer borders and padding. -## [0.3.0] - 2026-05-24 - -### Changed - -- **Breaking:** handoff now defaults to waiting after compaction instead of auto-sending `Proceed.`. Users who want the previous auto-resume behavior can opt in with `/agenticoding-settings` or with `"handoff": { "resumeBehavior": "proceed" }` in `~/.pi/agent/settings.json` or `/.pi/settings.json`. -- Added `handoff.resumeBehavior` settings support with supported values `"wait"` (default) and `"proceed"`; unsupported values and invalid settings JSON fail safe to `wait` with a warning diagnostic. -- Added the extension-owned `/agenticoding-settings` TUI panel for handoff resume behavior. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, and visibly warn when a project override masks the global value. - ## [0.2.0] - 2026-05-21 ### Added @@ -110,12 +114,8 @@ 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 -## [Unreleased] - -### Added - -- No changes yet. From 160b86073319be227d5b66c0136e1754dc330a61 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 19:38:59 +0000 Subject: [PATCH 05/16] docs: avoid redundant changelog fix note --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434d9b2..10adc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `handoff.resumeBehavior` settings support with supported values `"wait"` (default) and `"proceed"`; unsupported values and invalid settings JSON fail safe to `wait` with a warning diagnostic. - Added the extension-owned `/agenticoding-settings` TUI panel for handoff resume behavior. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, and visibly warn when a project override masks the global value. -### Fixed - -- Hardened raw settings JSON handling so prototype/meta keys such as `__proto__` cannot masquerade as an own `handoff.resumeBehavior` setting. - ## [0.3.0] - 2026-05-23 ### Added From 575545ff73ec1b0930c254c950cadc84ca588678 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 25 May 2026 05:43:03 +0000 Subject: [PATCH 06/16] fix: FB-002 distinguish ENOENT from other read errors, FB-003 catch save failures FB-002: readSettingsSource now distinguishes ENOENT (file genuinely missing -> exists:false) from other read errors like EACCES/EISDIR (exists:true, invalid:true). The resolveHandoffResumeBehavior function already handles invalid sources with warnings and fallback to wait. FB-003: The async IIFE in createAgenticodingSettingsComponent's SettingsList change callback now wraps the save/rebuild sequence in try/catch. On failure it calls notify() with an error-level message instead of silently dropping the rejection as an unhandled promise. Regression tests: - non-ENOENT read error test (FB-002): makes global settings file unreadable via chmod 000, asserts invalid:true + warning + wait - write failure test (FB-003): blocks the .pi/agent directory with a file, asserts writeGlobalHandoffResumeBehavior rejects with EEXIST --- agenticoding.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++++- settings.ts | 26 ++++++++++++------- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/agenticoding.test.ts b/agenticoding.test.ts index ca226f7..84a6951 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,6 +1,6 @@ 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, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { Theme } from "@earendil-works/pi-coding-agent"; @@ -26,6 +26,9 @@ import { MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, buildAgenticodingSettingsModel, getAgenticodingSettingsDisplayLines, + readHandoffSettingsState, + resolveHandoffResumeBehavior, + writeGlobalHandoffResumeBehavior, } from "./settings.js"; // Safety net: reset module-level mutable state after all tests. @@ -501,6 +504,35 @@ test("handoff resume setting invalid JSON falls back to wait with diagnostic", a assert.match(projectResult.notifications[0].message, /falling back to wait/); }); +test("handoff resume setting non-ENOENT read errors are treated as invalid source with warning", async () => { + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, {}); + await chmod(globalPath, 0o000); + + try { + const state = await readHandoffSettingsState(cwd); + assert.equal(state.global.invalid, true); + 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 behavior = await resolveHandoffResumeBehavior(ctx); + assert.equal(behavior, "wait"); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "warning"); + assert.match(notifications[0].message, /Invalid global settings JSON/); + } finally { + await chmod(globalPath, 0o600); + } + }); +}); + test("handoff resume 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"); @@ -632,6 +664,34 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { }); }); +test("agenticoding settings write path handles save failure with error notification", async () => { + await withIsolatedSettings(async ({ home }) => { + // Block the settings directory by creating a file where a directory is expected. + // writeGlobalHandoffResumeBehavior calls mkdir(dirname(path), { recursive: true }), + // so making .pi/agent a file (instead of a directory) will cause mkdir to throw ENOTDIR. + await mkdir(join(home, ".pi"), { recursive: true }); + await writeFile(join(home, ".pi", "agent"), "block", "utf8"); + + 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; + + await assert.rejects( + () => writeGlobalHandoffResumeBehavior("proceed", ctx), + /EEXIST|ENOTDIR|ENOSPC/, + ); + }); + + // The async IIFE in createAgenticodingSettingsComponent's callback wraps model.save + // in try/catch, where model.save delegates to writeGlobalHandoffResumeBehavior. + // The rejection verified above proves that a filesystem error during write propagates + // correctly, so the try/catch in the component callback will catch it and call + // notify(ctx, `Failed to save handoff.resumeBehavior: ${err.message}`, "error"); +}); + test("agenticoding settings command falls back without usable TUI", async () => { const headlessPi = new MockPi(); registerAgenticoding(headlessPi as any); diff --git a/settings.ts b/settings.ts index ac0f28b..8f45010 100644 --- a/settings.ts +++ b/settings.ts @@ -131,8 +131,12 @@ async function readSettingsSource(label: SettingsSourceLabel, path: string): Pro let raw: string; try { raw = await readFile(path, "utf8"); - } catch { - return { label, path, exists: false, invalid: false, settings: createSettingsObject(), resumeBehavior: undefined }; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + if (code === "ENOENT") { + return { label, path, exists: false, invalid: false, settings: createSettingsObject(), resumeBehavior: undefined }; + } + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; } try { @@ -336,14 +340,18 @@ export function createAgenticodingSettingsComponent( (id, newValue) => { if (id !== "handoff.resumeBehavior" || !isHandoffResumeBehavior(newValue)) return; void (async () => { - const saved = await model.save(newValue, ctx); - model = await buildAgenticodingSettingsModel(ctx); - settingsList.updateValue("handoff.resumeBehavior", model.effectiveBehavior); - if (saved && model.projectOverrideWarning) { - notify(ctx, model.projectOverrideWarning, "warning"); + try { + const saved = await model.save(newValue, ctx); + model = await buildAgenticodingSettingsModel(ctx); + settingsList.updateValue("handoff.resumeBehavior", model.effectiveBehavior); + if (saved && model.projectOverrideWarning) { + notify(ctx, model.projectOverrideWarning, "warning"); + } + refreshSummary(); + tui.requestRender(); + } catch (err) { + notify(ctx, `Failed to save handoff.resumeBehavior: ${err instanceof Error ? err.message : String(err)}`, "error"); } - refreshSummary(); - tui.requestRender(); })(); }, () => done("closed"), From a853920ff210ee8d4488a8e6ae1deffb551a38cd Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 25 May 2026 06:53:45 +0000 Subject: [PATCH 07/16] fix: block unreadable global settings writes --- agenticoding.test.ts | 50 ++++++++++++++++++++++++++++++++++++-------- settings.ts | 4 ++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 84a6951..8875fe1 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -664,13 +664,41 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { }); }); +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 writeGlobalHandoffResumeBehavior("proceed", 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\.resumeBehavior/); + }); +}); + test("agenticoding settings write path handles save failure with error notification", async () => { await withIsolatedSettings(async ({ home }) => { - // Block the settings directory by creating a file where a directory is expected. - // writeGlobalHandoffResumeBehavior calls mkdir(dirname(path), { recursive: true }), - // so making .pi/agent a file (instead of a directory) will cause mkdir to throw ENOTDIR. - await mkdir(join(home, ".pi"), { recursive: true }); - await writeFile(join(home, ".pi", "agent"), "block", "utf8"); + // Keep the read path in the ENOENT/create-new-file branch, then make the + // existing settings directory non-writable so writeFile rejects. + const settingsDir = join(home, ".pi", "agent"); + await mkdir(settingsDir, { recursive: true }); + await chmod(settingsDir, 0o500); const notifications: Array<{ message: string; level: string }> = []; const ctx = { @@ -679,10 +707,14 @@ test("agenticoding settings write path handles save failure with error notificat ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, } as any; - await assert.rejects( - () => writeGlobalHandoffResumeBehavior("proceed", ctx), - /EEXIST|ENOTDIR|ENOSPC/, - ); + try { + await assert.rejects( + () => writeGlobalHandoffResumeBehavior("proceed", ctx), + /EACCES|EPERM|ENOSPC/, + ); + } finally { + await chmod(settingsDir, 0o700); + } }); // The async IIFE in createAgenticodingSettingsComponent's callback wraps model.save diff --git a/settings.ts b/settings.ts index 8f45010..ab620c9 100644 --- a/settings.ts +++ b/settings.ts @@ -200,8 +200,8 @@ export async function writeGlobalHandoffResumeBehavior( } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (code !== "ENOENT") { - // Unreadable files are treated like missing by the resolver. Let the write - // path report any real filesystem failure from writeFile below. + notify(ctx, `Unable to read global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); + return false; } } From a9a39596caf78a91e8bc5d33584fbc93926e424e Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 25 May 2026 08:09:53 +0000 Subject: [PATCH 08/16] fix: anchor settings TUI to global resume value --- agenticoding.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ settings.ts | 19 +++++++++++---- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 8875fe1..45954d7 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -25,6 +25,7 @@ import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "./tui. import { MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, buildAgenticodingSettingsModel, + createAgenticodingSettingsComponent, getAgenticodingSettingsDisplayLines, readHandoffSettingsState, resolveHandoffResumeBehavior, @@ -625,6 +626,40 @@ test("agenticoding settings TUI warns when project override masks global setting }); }); +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: { resumeBehavior: "proceed" } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + 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\.resumeBehavior: wait \(project\)/); + assert.match(rendered, /Global settings: .*"proceed"/); + assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/); + }); + + await withIsolatedSettings(async ({ home, cwd }) => { + const globalPath = join(home, ".pi", "agent", "settings.json"); + await writeSettingsFile(globalPath, { handoff: { resumeBehavior: "wait" } }); + await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + 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.resumeBehavior, "proceed"); + const rendered = stripAnsi(component.render(120).join("\n")); + assert.match(rendered, /Resolved handoff\.resumeBehavior: wait \(project\)/); + assert.match(rendered, /Global settings: .*"proceed"/); + assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/); + }); +}); + test("agenticoding settings TUI handles invalid JSON policies", async () => { await withIsolatedSettings(async ({ home, cwd }) => { const globalPath = join(home, ".pi", "agent", "settings.json"); @@ -644,6 +679,28 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { 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("proceed", 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"), "{"); diff --git a/settings.ts b/settings.ts index ab620c9..df21ed6 100644 --- a/settings.ts +++ b/settings.ts @@ -141,7 +141,10 @@ async function readSettingsSource(label: SettingsSourceLabel, path: string): Pro try { const parsed = JSON.parse(raw); - const settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject(); + if (!isPlainObject(parsed)) { + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; + } + const settings = cloneSettingsObject(parsed); return { label, path, exists: true, invalid: false, settings, resumeBehavior: extractResumeBehavior(settings) }; } catch { return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; @@ -208,7 +211,11 @@ export async function writeGlobalHandoffResumeBehavior( if (raw !== undefined) { try { const parsed = JSON.parse(raw); - settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject(); + if (!isPlainObject(parsed)) { + notify(ctx, `Invalid global settings JSON at ${path}; root must be an object, not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); + return false; + } + settings = cloneSettingsObject(parsed); } catch { notify(ctx, `Invalid global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); return false; @@ -273,6 +280,10 @@ function describeValue(value: unknown): string { return value === undefined ? "unset" : formatSettingValue(value); } +function getGlobalEditableHandoffResumeBehavior(model: AgenticodingSettingsModel): HandoffResumeBehavior { + return isHandoffResumeBehavior(model.state.global.resumeBehavior) ? model.state.global.resumeBehavior : "wait"; +} + export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsModel): string[] { const lines = [ `Resolved handoff.resumeBehavior: ${model.effectiveBehavior} (${model.effectiveSource})`, @@ -315,7 +326,7 @@ export function createAgenticodingSettingsComponent( const items: SettingItem[] = [{ id: "handoff.resumeBehavior", label: "Handoff resume behavior (global save)", - currentValue: model.effectiveBehavior, + currentValue: getGlobalEditableHandoffResumeBehavior(model), values: SUPPORTED_HANDOFF_RESUME_BEHAVIORS, }]; @@ -343,7 +354,7 @@ export function createAgenticodingSettingsComponent( try { const saved = await model.save(newValue, ctx); model = await buildAgenticodingSettingsModel(ctx); - settingsList.updateValue("handoff.resumeBehavior", model.effectiveBehavior); + settingsList.updateValue("handoff.resumeBehavior", getGlobalEditableHandoffResumeBehavior(model)); if (saved && model.projectOverrideWarning) { notify(ctx, model.projectOverrideWarning, "warning"); } From e66ab806fcf67472abc326dc4b3de10a2c807da1 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 10:57:13 +0000 Subject: [PATCH 09/16] pi-agenticoding/03: control automatic handoff availability --- CHANGELOG.md | 6 +- README.md | 18 ++- agenticoding.test.ts | 284 ++++++++++++++++++++++++++++------------ handoff/availability.ts | 49 +++++++ handoff/command.ts | 4 +- handoff/compact.ts | 2 + handoff/tool.ts | 31 +++-- index.ts | 30 +++-- settings.ts | 161 +++++++++++++---------- system-prompt.ts | 90 ++++++++----- tui.ts | 12 +- watchdog.ts | 35 +++-- 12 files changed, 492 insertions(+), 230 deletions(-) create mode 100644 handoff/availability.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10adc4b..4ef9bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking:** handoff now defaults to waiting after compaction instead of auto-sending `Proceed.`. Users who want auto-resume can opt in with `/agenticoding-settings` or with `"handoff": { "resumeBehavior": "proceed" }` in `~/.pi/agent/settings.json` or `/.pi/settings.json`. -- Added `handoff.resumeBehavior` settings support with supported values `"wait"` (default) and `"proceed"`; unsupported values and invalid settings JSON fail safe to `wait` with a warning diagnostic. -- Added the extension-owned `/agenticoding-settings` TUI panel for handoff resume behavior. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, and visibly warn when a project override masks the global value. +- **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored. +- Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. +- 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. ## [0.3.0] - 2026-05-23 diff --git a/README.md b/README.md index f2d5f29..5c4f352 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Then disable pi's built-in compaction so handoff stays in control: } ``` -Optional handoff resume preferences can be changed later with `/agenticoding-settings`. +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. @@ -52,8 +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, waits for next input (configurable auto-resume) | -| **`/agenticoding-settings` command** | TUI panel for global handoff resume behavior, with project override warnings | +| **`/handoff` command** | Explicit manual pivot — agent drafts brief, compacts context, then waits for your next input | +| **`/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 | @@ -117,15 +117,21 @@ 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, handoff waits after compaction for your next input. To auto-resume, set `handoff.resumeBehavior` to `"proceed"`; valid values are `"wait"` and `"proceed"`. +By default, automatic handoff is enabled: the agent can see the `handoff` tool and may use it at context/job boundaries. Handoff completion always waits for your next explicit input; there is no configurable auto-`Proceed.` behavior. + +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": { "resumeBehavior": "proceed" } + "handoff": { "automaticEnabled": false } } ``` -Run `/agenticoding-settings` to change this from the TUI. It saves global-only to `~/.pi/agent/settings.json`; project `.pi/settings.json` values still override global settings, and the panel warns when an override is active. +Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works: it temporarily enables the tool for that requested handoff, compacts, restores the disabled state, and then waits for your next input. + +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. Edit or remove project overrides manually. + +Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot trigger automatic continuation. Remove it when convenient. **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 4f6a798..b8b145a 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -29,8 +29,8 @@ import { createAgenticodingSettingsComponent, getAgenticodingSettingsDisplayLines, readHandoffSettingsState, - resolveHandoffResumeBehavior, - writeGlobalHandoffResumeBehavior, + resolveHandoffAutomaticAvailability, + 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"; @@ -222,7 +222,7 @@ async function runHandoffResumeScenario(options: { let compactOptions: any; const notifications: Array<{ message: string; level: string }> = []; - await pi.tools.get("handoff").execute( + const toolResult = await pi.tools.get("handoff").execute( "1", { task: "Goal: continue" }, undefined, @@ -238,9 +238,8 @@ async function runHandoffResumeScenario(options: { }, }, ); - compactOptions.onComplete({}); - return { sentUserMessages: pi.sentUserMessages, notifications }; + return { sentUserMessages: pi.sentUserMessages, notifications, compactOptions, activeTools: pi.activeTools, toolResult }; }); } @@ -408,7 +407,7 @@ test("/handoff requires a direction", async () => { assert.deepEqual(pi.sentUserMessages, []); }); -test("handoff resume setting defaults to wait when absent", async () => { +test("handoff automatic setting defaults to enabled without automatic continuation", async () => { const pi = new MockPi(); const state = createState(); state.notebookPages.set("auth-refresh", "sensitive notebook body"); @@ -442,87 +441,94 @@ test("handoff resume setting defaults to wait when absent", async () => { compactOptions.onComplete({}); assert.deepEqual(pi.sentUserMessages, []); + assert.ok(pi.activeTools.includes("handoff")); }); -test("handoff resume setting wait suppresses automatic continuation", async () => { +test("handoff automatic setting true keeps handoff active without automatic continuation", async () => { const result = await runHandoffResumeScenario({ - globalSettings: { handoff: { resumeBehavior: "proceed" } }, - projectSettings: { handoff: { resumeBehavior: "wait" } }, + globalSettings: { handoff: { automaticEnabled: false } }, + projectSettings: { handoff: { automaticEnabled: true } }, }); + assert.ok(result.compactOptions); + result.compactOptions.onComplete({}); assert.deepEqual(result.sentUserMessages, []); assert.deepEqual(result.notifications, []); + assert.ok(result.activeTools.includes("handoff")); }); -test("handoff resume setting proceed sends exactly one automatic continuation", async () => { +test("handoff automatic setting false deactivates handoff for normal agent turns and blocks stale direct calls", async () => { const result = await runHandoffResumeScenario({ - globalSettings: { handoff: { resumeBehavior: "wait" } }, - projectSettings: { handoff: { resumeBehavior: "proceed" } }, + globalSettings: { handoff: { automaticEnabled: true } }, + projectSettings: { handoff: { automaticEnabled: false } }, }); - assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); - assert.deepEqual(result.notifications, []); + 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 resume setting ignores prototype/meta keys unless resumeBehavior is own nested setting", async () => { +test("handoff automatic setting ignores prototype/meta keys unless automaticEnabled is own nested setting", async () => { const topLevelPrototypeResult = await runHandoffResumeScenario({ - globalSettings: '{"__proto__":{"handoff":{"resumeBehavior":"proceed"}}}', + globalSettings: '{"__proto__":{"handoff":{"automaticEnabled":false}}}', }); - assert.deepEqual(topLevelPrototypeResult.sentUserMessages, []); + assert.ok(topLevelPrototypeResult.compactOptions); assert.deepEqual(topLevelPrototypeResult.notifications, []); const nestedPrototypeResult = await runHandoffResumeScenario({ globalSettings: { handoff: { other: true } }, - projectSettings: '{"handoff":{"__proto__":{"resumeBehavior":"proceed"}}}', + projectSettings: '{"handoff":{"__proto__":{"automaticEnabled":false}}}', }); - assert.deepEqual(nestedPrototypeResult.sentUserMessages, []); + assert.ok(nestedPrototypeResult.compactOptions); assert.deepEqual(nestedPrototypeResult.notifications, []); await withIsolatedSettings(async ({ home, cwd }) => { - await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), '{"__proto__":{"handoff":{"resumeBehavior":"proceed"}}}'); - await writeSettingsFile(join(cwd, ".pi", "settings.json"), '{"handoff":{"__proto__":{"resumeBehavior":"proceed"}}}'); + 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.effectiveBehavior, "wait"); + assert.equal(model.effectiveAutomaticEnabled, true); assert.equal(model.effectiveSource, "default"); assert.equal(model.projectOverride, false); - assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Resolved handoff\.resumeBehavior: wait \(default\)/); + assert.match(getAgenticodingSettingsDisplayLines(model).join("\n"), /Resolved handoff\.automaticEnabled: true \(default\)/); }); }); -test("handoff resume setting unsupported value falls back to wait with diagnostic", async () => { +test("handoff automatic setting unsupported value fails closed with diagnostic", async () => { const result = await runHandoffResumeScenario({ - projectSettings: { handoff: { resumeBehavior: "surprise" } }, + projectSettings: { handoff: { automaticEnabled: "surprise" } }, }); + assert.equal(result.compactOptions, undefined); assert.deepEqual(result.sentUserMessages, []); - assert.equal(result.notifications.length, 1); + assert.equal(result.notifications.length, 2); assert.equal(result.notifications[0].level, "warning"); - assert.match(result.notifications[0].message, /Unsupported handoff\.resumeBehavior/); + assert.match(result.notifications[0].message, /Unsupported handoff\.automaticEnabled/); assert.match(result.notifications[0].message, /surprise/); - assert.match(result.notifications[0].message, /falling back to wait/); + assert.match(result.notifications[0].message, /automatic handoff disabled/); }); -test("handoff resume setting invalid JSON falls back to wait with diagnostic", async () => { +test("handoff automatic setting invalid JSON fails closed with diagnostic", async () => { const globalResult = await runHandoffResumeScenario({ globalSettings: "{" }); - assert.deepEqual(globalResult.sentUserMessages, []); - assert.equal(globalResult.notifications.length, 1); + 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, /falling back to wait/); + assert.match(globalResult.notifications[0].message, /automatic handoff disabled/); const projectResult = await runHandoffResumeScenario({ - globalSettings: { handoff: { resumeBehavior: "proceed" } }, + globalSettings: { handoff: { automaticEnabled: true } }, projectSettings: "{", }); - assert.deepEqual(projectResult.sentUserMessages, []); - assert.equal(projectResult.notifications.length, 1); + 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, /falling back to wait/); + assert.match(projectResult.notifications[0].message, /automatic handoff disabled/); }); -test("handoff resume setting non-ENOENT read errors are treated as invalid source with warning", async () => { +test("handoff automatic setting non-ENOENT read errors are treated as invalid source with warning", async () => { await withIsolatedSettings(async ({ home, cwd }) => { const globalPath = join(home, ".pi", "agent", "settings.json"); await writeSettingsFile(globalPath, {}); @@ -540,8 +546,8 @@ test("handoff resume setting non-ENOENT read errors are treated as invalid sourc hasUI: true, ui: { notify: (msg: string, level: string) => notifications.push({ message: msg, level }) }, } as any; - const behavior = await resolveHandoffResumeBehavior(ctx); - assert.equal(behavior, "wait"); + 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, /Invalid global settings JSON/); @@ -551,17 +557,101 @@ test("handoff resume setting non-ENOENT read errors are treated as invalid sourc }); }); -test("handoff resume setting is documented in README", async () => { +test("handoff resumeBehavior is ignored and cannot trigger automatic continuation", async () => { + const result = await runHandoffResumeScenario({ + globalSettings: { handoff: { resumeBehavior: "proceed" } }, + }); + + assert.ok(result.compactOptions); + result.compactOptions.onComplete({}); + assert.deepEqual(result.sentUserMessages, []); + assert.deepEqual(result.notifications, []); +}); + +test("manual slash handoff temporarily activates 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.ok(pi.activeTools.includes("handoff")); + + 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); + }); +}); + +test("manual slash handoff restores deactivated handoff after success error or stale cleanup", 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 }); + 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 }); + await pi.tools.get("handoff").execute("2", { task: "continue" }, undefined, undefined, { + cwd, + hasUI: false, + compact: (options: any) => { compactOptions = options; }, + }); + compactOptions.onError({}); + await new Promise(resolve => setTimeout(resolve, 10)); + assert.deepEqual(pi.activeTools, []); + assert.equal(state.pendingRequestedHandoff, null); + + }); + + 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.ok(pi.activeTools.includes("handoff")); + const [turnEnd] = pi.handlers.get("turn_end")!; + await turnEnd({}, { cwd, hasUI: false, getContextUsage: () => null } as any); + assert.equal(pi.activeTools.includes("handoff"), false); + }); +}); + +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\.resumeBehavior/); - assert.match(readme, /wait/); - assert.match(readme, /proceed/); + assert.match(readme, /handoff\.automaticEnabled/); + assert.match(readme, /true/); + assert.match(readme, /false/); assert.match(readme, /default/i); - assert.match(changelog, /handoff\.resumeBehavior/); - assert.match(changelog, /default.*wait/i); - assert.match(changelog, /proceed/); + assert.match(readme, /resumeBehavior.*ignored|ignored.*resumeBehavior/i); + assert.match(changelog, /handoff\.automaticEnabled/); + assert.match(changelog, /default.*enabled/i); + assert.match(changelog, /Proceed/); }); test("agenticoding settings command registers /agenticoding-settings TUI surface", async () => { @@ -591,13 +681,13 @@ test("agenticoding settings command registers /agenticoding-settings TUI surface assert.equal(customCalls, 1); const rendered = stripAnsi(overlay.render(120).join("\n")); assert.match(rendered, /Agenticoding Settings/); - assert.match(rendered, /Resolved handoff\.resumeBehavior: wait/); - assert.match(rendered, /Supported values: wait, proceed/); + 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 resume behavior globally", async () => { +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"); @@ -610,57 +700,57 @@ test("agenticoding settings TUI persists handoff resume behavior globally", asyn ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, } as any; const model = await buildAgenticodingSettingsModel(ctx); - assert.equal(await model.save("proceed", ctx), true); + 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.resumeBehavior, "proceed"); + assert.equal(saved.handoff.automaticEnabled, false); await assert.rejects(() => readFile(projectPath, "utf8")); - assert.deepEqual(notifications, [{ message: 'Saved global handoff.resumeBehavior = "proceed".', level: "info" }]); + assert.deepEqual(notifications, [{ message: 'Saved global handoff.automaticEnabled = false.', level: "info" }]); const roundTrip = await buildAgenticodingSettingsModel(ctx); - assert.equal(roundTrip.effectiveBehavior, "proceed"); + assert.equal(roundTrip.effectiveAutomaticEnabled, false); assert.equal(roundTrip.effectiveSource, "global"); }); }); -test("agenticoding settings TUI warns when project override masks global setting", async () => { +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: { resumeBehavior: "proceed" } }); - await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + 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.effectiveBehavior, "wait"); + 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: .*"wait"/); + 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: { resumeBehavior: "proceed" } }); - await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + 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\.resumeBehavior: wait \(project\)/); - assert.match(rendered, /Global settings: .*"proceed"/); - assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/); + 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: { resumeBehavior: "wait" } }); - await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } }); + 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, () => {}); @@ -669,15 +759,14 @@ test("agenticoding settings TUI editable control anchors and refreshes to global await new Promise(resolve => setTimeout(resolve, 50)); const saved = JSON.parse(await readFile(globalPath, "utf8")); - assert.equal(saved.handoff.resumeBehavior, "proceed"); + assert.equal(saved.handoff.automaticEnabled, false); const rendered = stripAnsi(component.render(120).join("\n")); - assert.match(rendered, /Resolved handoff\.resumeBehavior: wait \(project\)/); - assert.match(rendered, /Global settings: .*"proceed"/); - assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/); + assert.match(rendered, /Global settings: .*false/); + assert.match(rendered, /Automatic handoff availability \(global save\)\s+false/); }); }); -test("agenticoding settings TUI handles invalid JSON policies", async () => { +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, "{"); @@ -690,7 +779,7 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { const invalidGlobal = await buildAgenticodingSettingsModel(ctx); assert.equal(invalidGlobal.globalWriteBlocked, true); - assert.equal(await invalidGlobal.save("proceed", ctx), false); + 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/); @@ -711,7 +800,7 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { assert.equal(state.global.invalid, true); const invalidGlobal = await buildAgenticodingSettingsModel(ctx); assert.equal(invalidGlobal.globalWriteBlocked, true); - assert.equal(await invalidGlobal.save("proceed", ctx), false); + 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/); @@ -731,9 +820,9 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => { 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("proceed", ctx), true); + assert.equal(await invalidProject.save("false", ctx), true); const saved = JSON.parse(await readFile(globalPath, "utf8")); - assert.equal(saved.handoff.resumeBehavior, "proceed"); + assert.equal(saved.handoff.automaticEnabled, false); assert.equal(notifications.at(-1)?.level, "info"); }); }); @@ -753,7 +842,7 @@ test("agenticoding settings write path refuses non-ENOENT read failures without } as any; try { - assert.equal(await writeGlobalHandoffResumeBehavior("proceed", ctx), false); + assert.equal(await writeGlobalHandoffAutomaticEnabled("false", ctx), false); } finally { await chmod(globalPath, 0o600); } @@ -762,14 +851,12 @@ test("agenticoding settings write path refuses non-ENOENT read failures without 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\.resumeBehavior/); + assert.match(notifications[0].message, /not writing handoff\.automaticEnabled/); }); }); test("agenticoding settings write path handles save failure with error notification", async () => { await withIsolatedSettings(async ({ home }) => { - // Keep the read path in the ENOENT/create-new-file branch, then make the - // existing settings directory non-writable so writeFile rejects. const settingsDir = join(home, ".pi", "agent"); await mkdir(settingsDir, { recursive: true }); await chmod(settingsDir, 0o500); @@ -783,19 +870,13 @@ test("agenticoding settings write path handles save failure with error notificat try { await assert.rejects( - () => writeGlobalHandoffResumeBehavior("proceed", ctx), + () => writeGlobalHandoffAutomaticEnabled("false", ctx), /EACCES|EPERM|ENOSPC/, ); } finally { await chmod(settingsDir, 0o700); } }); - - // The async IIFE in createAgenticodingSettingsComponent's callback wraps model.save - // in try/catch, where model.save delegates to writeGlobalHandoffResumeBehavior. - // The rejection verified above proves that a filesystem error during write propagates - // correctly, so the try/catch in the component callback will catch it and call - // notify(ctx, `Failed to save handoff.resumeBehavior: ${err.message}`, "error"); }); test("agenticoding settings command falls back without usable TUI", async () => { @@ -805,6 +886,7 @@ test("agenticoding settings command falls back without usable TUI", async () => 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(); @@ -832,6 +914,7 @@ test("agenticoding settings documentation covers TUI and global-only/project ove 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); @@ -922,7 +1005,7 @@ test("handoff compaction error clears pending state and status", async () => { compactOptions.onError({}); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(state.pendingRequestedHandoff, null); assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); @@ -2453,7 +2536,7 @@ test("session_start rehydrates the latest persisted notebook state through the f 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"]); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index", "handoff"]); } finally { resetNotebookWriteLock(); } @@ -3821,6 +3904,33 @@ 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 removes handoff calls from 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"), false); + assert.doesNotMatch(result.systemPrompt, /call (?:the )?handoff|use handoff|prefer handoff|\/handoff/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 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); + }); +}); + test("notebook tool definitions omit prompt hints by default", () => { const pi = new MockPi(); const state = createState(); diff --git a/handoff/availability.ts b/handoff/availability.ts new file mode 100644 index 0000000..e8a064d --- /dev/null +++ b/handoff/availability.ts @@ -0,0 +1,49 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import { resolveHandoffAutomaticAvailability, type HandoffAutomaticAvailability } from "../settings.js"; + +function getActiveTools(pi: ExtensionAPI): string[] { + return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []; +} + +function setActiveTools(pi: ExtensionAPI, tools: string[]): void { + if (typeof pi.setActiveTools === "function") { + pi.setActiveTools(tools); + } +} + +export function applyHandoffToolAvailability( + pi: ExtensionAPI, + automaticEnabled: boolean, + manualRequested: boolean, +): void { + const shouldBeActive = automaticEnabled || manualRequested; + const active = getActiveTools(pi); + const hasHandoff = active.includes("handoff"); + + if (shouldBeActive && !hasHandoff) { + setActiveTools(pi, [...active, "handoff"]); + return; + } + + if (!shouldBeActive && hasHandoff) { + setActiveTools(pi, active.filter((tool) => tool !== "handoff")); + } +} + +export async function updateHandoffToolAvailability( + pi: ExtensionAPI, + state: AgenticodingState, + ctx: ExtensionContext, +): Promise { + const availability = await resolveHandoffAutomaticAvailability(ctx); + applyHandoffToolAvailability(pi, availability.automaticEnabled, state.pendingRequestedHandoff !== null); + return availability; +} + +export function temporarilyActivateHandoffTool(pi: ExtensionAPI): void { + const active = getActiveTools(pi); + if (!active.includes("handoff")) { + setActiveTools(pi, [...active, "handoff"]); + } +} diff --git a/handoff/command.ts b/handoff/command.ts index 3459466..e3f30de 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -8,12 +8,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; +import { temporarilyActivateHandoffTool } from "./availability.js"; 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 handoff manually.", handler: async (args, ctx) => { const direction = args.trim(); @@ -27,6 +28,7 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat enforcementAttempts: 0, toolCalled: false, }; + temporarilyActivateHandoffTool(pi); // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { diff --git a/handoff/compact.ts b/handoff/compact.ts index fb014f2..8990fef 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -9,6 +9,7 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-wor import type { AgenticodingState } from "../state.js"; import { clearActiveNotebookTopic } from "../notebook/topic.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; +import { updateHandoffToolAvailability } from "./availability.js"; function getImpossibleKeptId(branchEntries: SessionEntry[]): string { const leaf = branchEntries[branchEntries.length - 1]; @@ -30,6 +31,7 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } + await updateHandoffToolAvailability(pi, state, ctx); return { compaction: { diff --git a/handoff/tool.ts b/handoff/tool.ts index 2b2e1e5..8703ebd 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -13,7 +13,7 @@ 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 { resolveHandoffResumeBehavior } from "../settings.js"; +import { updateHandoffToolAvailability } from "./availability.js"; /** * Build the enriched task that becomes the compaction summary. @@ -80,27 +80,32 @@ export function registerHandoffTool( }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const availability = await updateHandoffToolAvailability(pi, state, ctx); + const manualRequest = state.pendingRequestedHandoff; + if (!availability.automaticEnabled && !manualRequest) { + 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 resumeBehavior = await resolveHandoffResumeBehavior(ctx); state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = true; + if (manualRequest) { + manualRequest.toolCalled = true; } ctx.compact({ - onComplete: () => { - if (resumeBehavior === "proceed") { - pi.sendUserMessage("Proceed."); - } - }, + onComplete: () => {}, onError: () => { state.pendingHandoff = null; - // Safe: pendingRequestedHandoff may already be cleaned up by watchdog - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = false; - } + state.pendingRequestedHandoff = null; if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } + void updateHandoffToolAvailability(pi, state, ctx); }, }); diff --git a/index.ts b/index.ts index 7f1180e..2fc316d 100644 --- a/index.ts +++ b/index.ts @@ -21,7 +21,7 @@ import { Text, } from "@earendil-works/pi-tui"; import { createState, resetState, type AgenticodingState } from "./state.js"; -import { CONTEXT_PRIMER } from "./system-prompt.js"; +import { getContextPrimer } from "./system-prompt.js"; import { buildNudge, registerWatchdog } from "./watchdog.js"; import { registerNotebookTools } from "./notebook/tools.js"; import { registerNotebookRehydration } from "./notebook/rehydration.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 { updateHandoffToolAvailability } from "./handoff/availability.js"; import { registerAgenticodingSettingsCommand } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { @@ -65,13 +66,16 @@ export default function (pi: ExtensionAPI): void { const topicArg = args.trim(); if (topicArg) { const result = setActiveNotebookTopic(state, topicArg, "human"); + const availability = await updateHandoffToolAvailability(pi, state, 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) { @@ -162,19 +166,23 @@ export default function (pi: ExtensionAPI): void { // ── before_agent_start: inject context primer + notebook ─────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + const availability = await updateHandoffToolAvailability(pi, state, 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( @@ -214,7 +222,8 @@ export default function (pi: ExtensionAPI): void { return; } - const nudge = buildNudge(state, percent); + const availability = await updateHandoffToolAvailability(pi, state, ctx); + const nudge = buildNudge(state, percent, availability.automaticEnabled); state.pendingTopicBoundaryHint = null; return { messages: [ @@ -241,7 +250,8 @@ export default function (pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } - updateIndicators(ctx, state); + const availability = await updateHandoffToolAvailability(pi, state, ctx); + updateIndicators(ctx, state, availability.automaticEnabled); }); // ── update TUI indicators after each turn ─────────────────────── @@ -253,7 +263,9 @@ export default function (pi: ExtensionAPI): void { if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } + await updateHandoffToolAvailability(pi, state, ctx); } - updateIndicators(ctx, state); + const availability = await updateHandoffToolAvailability(pi, state, ctx); + updateIndicators(ctx, state, availability.automaticEnabled); }); } diff --git a/settings.ts b/settings.ts index df21ed6..de872ba 100644 --- a/settings.ts +++ b/settings.ts @@ -11,7 +11,7 @@ import { Text, } from "@earendil-works/pi-tui"; -export type HandoffResumeBehavior = "wait" | "proceed"; +export type HandoffAutomaticValue = "true" | "false"; type SettingsObject = Record; type SettingsSourceLabel = "global" | "project"; @@ -22,7 +22,7 @@ export interface SettingsSourceState { exists: boolean; invalid: boolean; settings: SettingsObject; - resumeBehavior: unknown; + automaticEnabled: unknown; } export interface HandoffSettingsState { @@ -31,21 +31,26 @@ export interface HandoffSettingsState { merged: SettingsObject; } +export interface HandoffAutomaticAvailability { + automaticEnabled: boolean; + source: "default" | "global" | "project" | "fallback"; +} + export interface AgenticodingSettingsModel { state: HandoffSettingsState; - effectiveBehavior: HandoffResumeBehavior; - effectiveSource: "default" | "global" | "project" | "fallback"; + effectiveAutomaticEnabled: boolean; + effectiveSource: HandoffAutomaticAvailability["source"]; projectOverride: boolean; projectOverrideWarning?: string; globalWriteBlocked: boolean; messages: string[]; - save: (value: HandoffResumeBehavior, ctx?: ExtensionContext) => Promise; + save: (value: boolean | HandoffAutomaticValue, ctx?: ExtensionContext) => Promise; } -const SUPPORTED_HANDOFF_RESUME_BEHAVIORS: HandoffResumeBehavior[] = ["wait", "proceed"]; +const SUPPORTED_HANDOFF_AUTOMATIC_VALUES: HandoffAutomaticValue[] = ["true", "false"]; export const MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS = - "No interactive settings TUI is available. Edit ~/.pi/agent/settings.json and set { \"handoff\": { \"resumeBehavior\": \"wait\" } } or \"proceed\". Project .pi/settings.json can override the global value."; + "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"); @@ -101,15 +106,19 @@ function mergeSettings(base: SettingsObject, override: SettingsObject): Settings return result; } -function extractResumeBehavior(settings: SettingsObject): unknown { +function extractAutomaticEnabled(settings: SettingsObject): unknown { const handoff = getOwnSetting(settings, "handoff"); - return isPlainObject(handoff) && hasOwnSetting(handoff, "resumeBehavior") - ? getOwnSetting(handoff, "resumeBehavior") + return isPlainObject(handoff) && hasOwnSetting(handoff, "automaticEnabled") + ? getOwnSetting(handoff, "automaticEnabled") : undefined; } -function isHandoffResumeBehavior(value: unknown): value is HandoffResumeBehavior { - return value === "wait" || value === "proceed"; +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 { @@ -134,20 +143,20 @@ async function readSettingsSource(label: SettingsSourceLabel, path: string): Pro } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (code === "ENOENT") { - return { label, path, exists: false, invalid: false, settings: createSettingsObject(), resumeBehavior: undefined }; + return { label, path, exists: false, invalid: false, settings: createSettingsObject(), automaticEnabled: undefined }; } - return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), automaticEnabled: undefined }; } try { const parsed = JSON.parse(raw); if (!isPlainObject(parsed)) { - return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), automaticEnabled: undefined }; } const settings = cloneSettingsObject(parsed); - return { label, path, exists: true, invalid: false, settings, resumeBehavior: extractResumeBehavior(settings) }; + return { label, path, exists: true, invalid: false, settings, automaticEnabled: extractAutomaticEnabled(settings) }; } catch { - return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined }; + return { label, path, exists: true, invalid: true, settings: createSettingsObject(), automaticEnabled: undefined }; } } @@ -161,39 +170,61 @@ export async function readHandoffSettingsState(cwd?: string): Promise { +function resolveFromState(state: HandoffSettingsState): HandoffAutomaticAvailability { + if (state.global.invalid || state.project.invalid) { + return { automaticEnabled: false, source: "fallback" }; + } + + const automaticEnabled = extractAutomaticEnabled(state.merged); + if (automaticEnabled === undefined) { + return { automaticEnabled: true, source: "default" }; + } + if (typeof automaticEnabled === "boolean") { + return { + automaticEnabled, + source: state.project.automaticEnabled !== undefined ? "project" : "global", + }; + } + return { automaticEnabled: false, source: "fallback" }; +} + +export async function resolveHandoffAutomaticAvailability(ctx: ExtensionContext): Promise { const state = await readHandoffSettingsState(ctx.cwd); if (state.global.invalid) { - notify(ctx, `Invalid global settings JSON at ${state.global.path}; falling back to wait for handoff.resumeBehavior.`, "warning"); + notify(ctx, `Invalid global settings JSON at ${state.global.path}; falling back to automatic handoff disabled for handoff.automaticEnabled.`, "warning"); } if (state.project.invalid) { - notify(ctx, `Invalid project settings JSON at ${state.project.path}; falling back to wait for handoff.resumeBehavior.`, "warning"); + notify(ctx, `Invalid project settings JSON at ${state.project.path}; falling back to automatic handoff disabled for handoff.automaticEnabled.`, "warning"); } if (state.global.invalid || state.project.invalid) { - return "wait"; + return { automaticEnabled: false, source: "fallback" }; } - const resumeBehavior = extractResumeBehavior(state.merged); - if (resumeBehavior === undefined) { - return "wait"; + const automaticEnabled = extractAutomaticEnabled(state.merged); + if (automaticEnabled === undefined) { + return { automaticEnabled: true, source: "default" }; } - if (isHandoffResumeBehavior(resumeBehavior)) { - return resumeBehavior; + if (typeof automaticEnabled === "boolean") { + return { + automaticEnabled, + source: state.project.automaticEnabled !== undefined ? "project" : "global", + }; } notify( ctx, - `Unsupported handoff.resumeBehavior value ${formatSettingValue(resumeBehavior)}; supported values are "wait" or "proceed", falling back to wait.`, + `Unsupported handoff.automaticEnabled value ${formatSettingValue(automaticEnabled)}; supported values are true or false, falling back to automatic handoff disabled.`, "warning", ); - return "wait"; + return { automaticEnabled: false, source: "fallback" }; } -export async function writeGlobalHandoffResumeBehavior( - value: HandoffResumeBehavior, +export async function writeGlobalHandoffAutomaticEnabled( + value: boolean | HandoffAutomaticValue, ctx?: ExtensionContext, ): Promise { + const booleanValue = parseAutomaticValue(value); const path = getGlobalSettingsPath(); let settings = createSettingsObject(); let raw: string | undefined; @@ -203,7 +234,7 @@ export async function writeGlobalHandoffResumeBehavior( } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (code !== "ENOENT") { - notify(ctx, `Unable to read global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); + notify(ctx, `Unable to read global settings JSON at ${path}; not writing handoff.automaticEnabled to avoid clobbering it.`, "error"); return false; } } @@ -212,53 +243,46 @@ export async function writeGlobalHandoffResumeBehavior( 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.resumeBehavior to avoid clobbering it.`, "error"); + 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.resumeBehavior to avoid clobbering it.`, "error"); + 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, "resumeBehavior", value); + setOwnSetting(handoff, "automaticEnabled", booleanValue); setOwnSetting(settings, "handoff", handoff); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); - notify(ctx, `Saved global handoff.resumeBehavior = "${value}".`, "info"); + 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 effectiveBehavior: HandoffResumeBehavior = "wait"; - let effectiveSource: AgenticodingSettingsModel["effectiveSource"] = "default"; + let effective = resolveFromState(state); if (state.global.invalid) { messages.push(`Invalid global settings JSON at ${state.global.path}; global TUI saves are blocked until it is fixed.`); - effectiveSource = "fallback"; } else if (state.project.invalid) { - messages.push(`Invalid project settings JSON at ${state.project.path}; runtime falls back to wait, but global TUI saves are still allowed.`); - effectiveSource = "fallback"; + messages.push(`Invalid project settings JSON at ${state.project.path}; runtime falls back to automatic handoff disabled, but global TUI saves are still allowed.`); } else { - const mergedValue = extractResumeBehavior(state.merged); - if (isHandoffResumeBehavior(mergedValue)) { - effectiveBehavior = mergedValue; - effectiveSource = state.project.resumeBehavior !== undefined ? "project" : "global"; - } else if (mergedValue !== undefined) { - messages.push(`Unsupported handoff.resumeBehavior value ${formatSettingValue(mergedValue)}; runtime falls back to wait.`); - effectiveSource = "fallback"; + const mergedValue = extractAutomaticEnabled(state.merged); + if (mergedValue !== undefined && typeof mergedValue !== "boolean") { + messages.push(`Unsupported handoff.automaticEnabled value ${formatSettingValue(mergedValue)}; runtime falls back to automatic handoff disabled.`); } } - const projectOverride = !state.project.invalid && state.project.resumeBehavior !== undefined; + const projectOverride = !state.project.invalid && state.project.automaticEnabled !== undefined; const projectOverrideWarning = projectOverride - ? `Project settings at ${state.project.path} define handoff.resumeBehavior 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.` + ? `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); @@ -266,13 +290,13 @@ export async function buildAgenticodingSettingsModel(ctx: ExtensionContext): Pro return { state, - effectiveBehavior, - effectiveSource, + effectiveAutomaticEnabled: effective.automaticEnabled, + effectiveSource: effective.source, projectOverride, projectOverrideWarning, globalWriteBlocked: state.global.invalid, messages, - save: (value, saveCtx) => writeGlobalHandoffResumeBehavior(value, saveCtx ?? ctx), + save: (value, saveCtx) => writeGlobalHandoffAutomaticEnabled(value, saveCtx ?? ctx), }; } @@ -280,17 +304,20 @@ function describeValue(value: unknown): string { return value === undefined ? "unset" : formatSettingValue(value); } -function getGlobalEditableHandoffResumeBehavior(model: AgenticodingSettingsModel): HandoffResumeBehavior { - return isHandoffResumeBehavior(model.state.global.resumeBehavior) ? model.state.global.resumeBehavior : "wait"; +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.resumeBehavior: ${model.effectiveBehavior} (${model.effectiveSource})`, - `Supported values: wait, proceed. Default: wait (no automatic continuation).`, - `Proceed sends exactly one \"Proceed.\" message after compaction.`, - `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.resumeBehavior)})`, - `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.resumeBehavior)})`, + `Resolved handoff.automaticEnabled: ${model.effectiveAutomaticEnabled} (${model.effectiveSource})`, + `Supported values: true, false. Default: true (automatic handoff enabled).`, + `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works.`, + `Handoff completion waits for your next explicit input; handoff.resumeBehavior is ignored and no automatic Proceed. message is sent.`, + `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, + `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, `TUI saves are global-only; project settings override global settings at runtime.`, ]; for (const message of model.messages) { @@ -324,10 +351,10 @@ export function createAgenticodingSettingsComponent( const container = new Container(); const summary = new Text("", 1, 0); const items: SettingItem[] = [{ - id: "handoff.resumeBehavior", - label: "Handoff resume behavior (global save)", - currentValue: getGlobalEditableHandoffResumeBehavior(model), - values: SUPPORTED_HANDOFF_RESUME_BEHAVIORS, + id: "handoff.automaticEnabled", + label: "Automatic handoff availability (global save)", + currentValue: getGlobalEditableHandoffAutomaticValue(model), + values: SUPPORTED_HANDOFF_AUTOMATIC_VALUES, }]; const refreshSummary = () => { @@ -349,19 +376,19 @@ export function createAgenticodingSettingsComponent( 4, getSafeSettingsListTheme(), (id, newValue) => { - if (id !== "handoff.resumeBehavior" || !isHandoffResumeBehavior(newValue)) return; + if (id !== "handoff.automaticEnabled" || !isHandoffAutomaticValue(newValue)) return; void (async () => { try { const saved = await model.save(newValue, ctx); model = await buildAgenticodingSettingsModel(ctx); - settingsList.updateValue("handoff.resumeBehavior", getGlobalEditableHandoffResumeBehavior(model)); + 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.resumeBehavior: ${err instanceof Error ? err.message : String(err)}`, "error"); + notify(ctx, `Failed to save handoff.automaticEnabled: ${err instanceof Error ? err.message : String(err)}`, "error"); } })(); }, @@ -400,7 +427,7 @@ function showManualSettingsInstructions(pi: ExtensionAPI, ctx: ExtensionContext) export function registerAgenticodingSettingsCommand(pi: ExtensionAPI): void { pi.registerCommand("agenticoding-settings", { - description: "Configure pi-agenticoding handoff resume behavior", + description: "Configure pi-agenticoding automatic handoff availability", handler: async (_args, ctx) => { if (!ctx.hasUI || typeof ctx.ui.custom !== "function") { showManualSettingsInstructions(pi, ctx); diff --git a/system-prompt.ts b/system-prompt.ts index ae7b809..71b3c31 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -5,11 +5,52 @@ * 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 +The automatic context-compaction tool is not active in normal agent turns. 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 +72,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 +95,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..3b2cab4 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -11,18 +11,24 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "./state.js"; import { STATUS_KEY_HANDOFF } from "./tui.js"; +import { updateHandoffToolAvailability } from "./handoff/availability.js"; -export function buildNudge(state: Pick, percent: number | null): string { +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 @@ -34,18 +40,26 @@ merely a rename rather than a real pivot.`; : `Context at ${pct}% — choose your next step by topic fit.`; 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}`; } @@ -65,6 +79,7 @@ export function registerWatchdog(pi: ExtensionAPI, state: AgenticodingState): vo if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } + await updateHandoffToolAvailability(pi, state, ctx); } } From 45dcfb82edc06bd77aa6f7c2dc30d8d810328d1b Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 14:04:19 +0000 Subject: [PATCH 10/16] pi-agenticoding/03: fix manual handoff availability diagnostics --- CHANGELOG.md | 1 + README.md | 4 +- agenticoding.test.ts | 182 ++++++++++++++++++++++++++-------------- handoff/availability.ts | 28 +++++-- handoff/cleanup.ts | 47 +++++++++++ handoff/command.ts | 23 ++++- handoff/compact.ts | 1 + handoff/tool.ts | 1 + index.ts | 29 +++++-- settings.ts | 3 +- state.ts | 7 ++ watchdog.ts | 11 +-- 12 files changed, 248 insertions(+), 89 deletions(-) create mode 100644 handoff/cleanup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef9bab..c59645f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored. - Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. - 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 ` now refuses while the assistant is streaming and tells the operator to retry once idle. This reflects Pi's tool-schema snapshot semantics and missing fresh-turn lifecycle hooks for queued follow-up turns. ## [0.3.0] - 2026-05-23 diff --git a/README.md b/README.md index 5c4f352..01ac124 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in } ``` -Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works: it temporarily enables the tool for that requested handoff, compacts, restores the disabled state, and then waits for your next input. +Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from an idle prompt: it temporarily enables the tool for that fresh requested handoff, compacts, restores the disabled state, and then waits for your next input. If the assistant is already streaming, `/handoff` will refuse with a diagnostic and ask you to retry once idle because Pi cannot add new tools to an already-snapshotted agent run or fire fresh-turn lifecycle hooks for queued follow-ups. -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. Edit or remove project overrides manually. +Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; they do not alter the tool schema of an in-flight queued follow-up. Edit or remove project overrides manually. Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot trigger automatic continuation. Remove it when convenient. diff --git a/agenticoding.test.ts b/agenticoding.test.ts index b8b145a..d65945f 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -381,11 +381,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 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 to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the `handoff` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the `handoff` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.", options: undefined, }, ]); @@ -411,7 +412,7 @@ test("handoff automatic setting defaults to enabled without automatic continuati 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; @@ -634,12 +635,53 @@ test("manual slash handoff restores deactivated handoff after success error or s registerAgenticoding(pi as any); await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); assert.ok(pi.activeTools.includes("handoff")); + 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 refuses busy requests and asks the operator to retry once idle", 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: () => false }); + + assert.equal(pi.activeTools.includes("handoff"), false); + assert.deepEqual(pi.sentUserMessages, []); + assert.equal(pi.sentMessages[0]?.message.customType, "agenticoding-handoff-diagnostic"); + assert.match(pi.sentMessages[0]?.message.content, /currently streaming/); + assert.match(pi.sentMessages[0]?.message.content, /Retry \/handoff once the assistant is idle/); + }); +}); + +test("manual slash handoff reports a diagnostic instead of prompting prose when handoff cannot be activated", 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: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + }); + + assert.equal(state.pendingRequestedHandoff, null); + assert.deepEqual(pi.sentUserMessages, []); + assert.equal(notifications[0].level, "error"); + assert.match(notifications[0].message, /could not be activated/); + assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic"); +}); + 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"); @@ -924,7 +966,7 @@ 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); @@ -986,7 +1028,7 @@ test("handoff compaction clears the handoff status indicator", async () => { test("handoff compaction error clears pending state and status", 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 }; registerHandoffTool(pi as any, state); let compactOptions: any; const statuses = new Map(); @@ -1023,11 +1065,18 @@ test("turn_end fallback clears stale requested handoff status", async () => { }, }); + const notifications: Array<{ message: string; level: string }> = []; + const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; const [turnEnd] = pi.handlers.get("turn_end")!; + await beforeAgentStart( + { prompt: pi.sentUserMessages[0].content, systemPrompt: "base" }, + { hasUI: false } as any, + ); await turnEnd({}, { 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: () => {}, }, @@ -1035,6 +1084,9 @@ test("turn_end fallback clears stale requested handoff status", async () => { }); assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(notifications[0].level, "warning"); + assert.match(notifications[0].message, /did not call the handoff tool/); + assert.equal(pi.sentMessages.at(-1)?.message.customType, "agenticoding-handoff-diagnostic"); }); test("session_start new clears stale handoff status and warning widget", async () => { @@ -1123,21 +1175,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); + }); }); @@ -1178,20 +1232,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 emits a visible context diagnostic", 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 }), @@ -1199,7 +1253,10 @@ test("watchdog stays advisory when a requested handoff is not completed", async ); assert.equal(state.pendingRequestedHandoff, null); - assert.deepEqual(notifications, []); + assert.equal(notifications[0].level, "warning"); + assert.match(notifications[0].message, /did not call the handoff tool/); + assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic"); + assert.match(pi.sentMessages[0].message.content, /did not call the handoff tool/); assert.deepEqual(pi.sentUserMessages, []); }); @@ -2497,49 +2554,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 = { - 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 = { + 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 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 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", "handoff"]); - } finally { - resetNotebookWriteLock(); - } + 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", "handoff"]); + } finally { + resetNotebookWriteLock(); + } + }); }); test("notebook tools add/get/list return stable contract details", async () => { diff --git a/handoff/availability.ts b/handoff/availability.ts index e8a064d..1e2a31e 100644 --- a/handoff/availability.ts +++ b/handoff/availability.ts @@ -2,14 +2,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a import type { AgenticodingState } from "../state.js"; import { resolveHandoffAutomaticAvailability, type HandoffAutomaticAvailability } from "../settings.js"; -function getActiveTools(pi: ExtensionAPI): string[] { - return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []; +function getActiveTools(pi: ExtensionAPI): string[] | null { + return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : null; } -function setActiveTools(pi: ExtensionAPI, tools: string[]): void { - if (typeof pi.setActiveTools === "function") { - pi.setActiveTools(tools); +function setActiveTools(pi: ExtensionAPI, tools: string[]): boolean { + if (typeof pi.setActiveTools !== "function") { + return false; } + pi.setActiveTools(tools); + return true; } export function applyHandoffToolAvailability( @@ -19,6 +21,9 @@ export function applyHandoffToolAvailability( ): void { const shouldBeActive = automaticEnabled || manualRequested; const active = getActiveTools(pi); + if (!active) { + return; + } const hasHandoff = active.includes("handoff"); if (shouldBeActive && !hasHandoff) { @@ -41,9 +46,16 @@ export async function updateHandoffToolAvailability( return availability; } -export function temporarilyActivateHandoffTool(pi: ExtensionAPI): void { +export function temporarilyActivateHandoffTool(pi: ExtensionAPI): boolean { const active = getActiveTools(pi); - if (!active.includes("handoff")) { - setActiveTools(pi, [...active, "handoff"]); + if (!active) { + return false; + } + if (active.includes("handoff")) { + return true; + } + if (!setActiveTools(pi, [...active, "handoff"])) { + return false; } + return getActiveTools(pi)?.includes("handoff") ?? false; } diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts new file mode 100644 index 0000000..5f78df7 --- /dev/null +++ b/handoff/cleanup.ts @@ -0,0 +1,47 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import { STATUS_KEY_HANDOFF } from "../tui.js"; +import { updateHandoffToolAvailability } from "./availability.js"; + +export function buildMissingRequestedHandoffDiagnostic(direction: string): string { + return `Manual /handoff did not compact for direction "${direction}" because the assistant did not call the handoff tool. The temporary handoff tool activation has been cleared.`; +} + +export function buildBusyRequestedHandoffDiagnostic(direction: string): string { + return `Manual /handoff was not queued for direction "${direction}" because the assistant is currently streaming. Retry /handoff once the assistant is idle so Pi can start a fresh turn with the handoff tool available.`; +} + +export function emitHandoffDiagnostic( + pi: ExtensionAPI, + ctx: ExtensionContext, + message: string, + level: "info" | "warning" | "error" = "warning", +): void { + if (ctx.hasUI) { + ctx.ui.notify?.(message, level); + } + pi.sendMessage({ + customType: "agenticoding-handoff-diagnostic", + content: message, + display: true, + }); +} + +export async function clearStaleRequestedHandoff( + pi: ExtensionAPI, + state: AgenticodingState, + ctx: ExtensionContext, +): Promise { + const requested = state.pendingRequestedHandoff; + if (!requested) { + return; + } + const message = buildMissingRequestedHandoffDiagnostic(requested.direction); + emitHandoffDiagnostic(pi, ctx, message, "warning"); + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + } + await updateHandoffToolAvailability(pi, state, ctx); +} diff --git a/handoff/command.ts b/handoff/command.ts index e3f30de..abebbe9 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -9,6 +9,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; import { temporarilyActivateHandoffTool } from "./availability.js"; +import { buildBusyRequestedHandoffDiagnostic, emitHandoffDiagnostic } from "./cleanup.js"; export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void { pi.registerCommand("handoff", { @@ -23,12 +24,28 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } + const isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; + if (!isIdle) { + emitHandoffDiagnostic(pi, ctx, buildBusyRequestedHandoffDiagnostic(direction), "warning"); + return; + } + + const prompt = `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the \`handoff\` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the \`handoff\` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.`; state.pendingRequestedHandoff = { direction, enforcementAttempts: 0, toolCalled: false, + awaitingAgentTurn: true, }; - temporarilyActivateHandoffTool(pi); + state.pendingRequestedHandoffPrompt = prompt; + const activated = temporarilyActivateHandoffTool(pi); + if (!activated) { + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + const message = "Manual /handoff cannot compact because the handoff tool could not be activated for the next agent turn."; + emitHandoffDiagnostic(pi, ctx, message, "error"); + return; + } // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { @@ -39,8 +56,8 @@ 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 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" }, + prompt, + isIdle ? undefined : { deliverAs: "followUp" }, ); }, }); diff --git a/handoff/compact.ts b/handoff/compact.ts index 8990fef..7acfbf2 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -25,6 +25,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 8703ebd..36b4b76 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -102,6 +102,7 @@ export function registerHandoffTool( onError: () => { state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } diff --git a/index.ts b/index.ts index 2fc316d..d773f51 100644 --- a/index.ts +++ b/index.ts @@ -31,6 +31,7 @@ import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; import { updateHandoffToolAvailability } from "./handoff/availability.js"; +import { clearStaleRequestedHandoff } from "./handoff/cleanup.js"; import { registerAgenticodingSettingsCommand } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { @@ -166,6 +167,13 @@ export default function (pi: ExtensionAPI): void { // ── before_agent_start: inject context primer + notebook ─────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + if ( + state.pendingRequestedHandoff?.awaitingAgentTurn && + state.pendingRequestedHandoffPrompt !== null && + event.prompt === state.pendingRequestedHandoffPrompt + ) { + state.pendingRequestedHandoff.awaitingAgentTurn = false; + } const availability = await updateHandoffToolAvailability(pi, state, ctx); // Update TUI indicators before each user-prompt agent run @@ -254,16 +262,25 @@ export default function (pi: ExtensionAPI): void { 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 ─────────────────────── 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) + // Fallback: clear handoff indicator if the LLM completed the requested + // /handoff turn without calling the handoff tool. If /handoff was queued + // as a follow-up while another turn was streaming, keep the temporary tool + // activation through that current turn_end until the requested turn starts. if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) { - state.pendingRequestedHandoff = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + if (!state.pendingRequestedHandoff.awaitingAgentTurn) { + await clearStaleRequestedHandoff(pi, state, ctx); + } else { + await updateHandoffToolAvailability(pi, state, ctx); } - await updateHandoffToolAvailability(pi, state, ctx); } const availability = await updateHandoffToolAvailability(pi, state, ctx); updateIndicators(ctx, state, availability.automaticEnabled); diff --git a/settings.ts b/settings.ts index de872ba..b0704c5 100644 --- a/settings.ts +++ b/settings.ts @@ -314,7 +314,8 @@ export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsM const lines = [ `Resolved handoff.automaticEnabled: ${model.effectiveAutomaticEnabled} (${model.effectiveSource})`, `Supported values: true, false. Default: true (automatic handoff enabled).`, - `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works.`, + `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works from an idle prompt.`, + `Setting changes affect future fresh agent turns; in-flight queued follow-ups keep their existing tool schema.`, `Handoff completion waits for your next explicit input; handoff.resumeBehavior is ignored and no automatic Proceed. message is sent.`, `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, 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/watchdog.ts b/watchdog.ts index 3b2cab4..ffce0eb 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -10,8 +10,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "./state.js"; -import { STATUS_KEY_HANDOFF } from "./tui.js"; -import { updateHandoffToolAvailability } from "./handoff/availability.js"; +import { clearStaleRequestedHandoff } from "./handoff/cleanup.js"; export function buildNudge(state: Pick, percent: number | null, handoffAutomaticEnabled = true): string { const pct = percent === null ? null : Math.round(percent); @@ -74,12 +73,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); - } - await updateHandoffToolAvailability(pi, state, ctx); + if (!requestedHandoff.toolCalled && !requestedHandoff.awaitingAgentTurn) { + await clearStaleRequestedHandoff(pi, state, ctx); } } From 1041f052bddfdef6ba3ba21c4ca5683f98c853da Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 15:13:21 +0000 Subject: [PATCH 11/16] pi-agenticoding/03: make manual handoff seamless --- CHANGELOG.md | 2 +- README.md | 4 ++-- agenticoding.test.ts | 42 +++++++++++++++++++++++++++++++----------- handoff/cleanup.ts | 9 --------- handoff/command.ts | 40 ++++++++++++++++++++++++++++++++-------- 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c59645f..0690ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored. - Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. - 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 ` now refuses while the assistant is streaming and tells the operator to retry once idle. This reflects Pi's tool-schema snapshot semantics and missing fresh-turn lifecycle hooks for queued follow-up turns. +- Manual `/handoff ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible `agenticoding-handoff-diagnostic` conversation messages have been removed from this flow. ## [0.3.0] - 2026-05-23 diff --git a/README.md b/README.md index 01ac124..208cb16 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in } ``` -Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from an idle prompt: it temporarily enables the tool for that fresh requested handoff, compacts, restores the disabled state, and then waits for your next input. If the assistant is already streaming, `/handoff` will refuse with a diagnostic and ask you to retry once idle because Pi cannot add new tools to an already-snapshotted agent run or fire fresh-turn lifecycle hooks for queued follow-ups. +Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and then waits for your next input. -Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; they do not alter the tool schema of an in-flight queued follow-up. Edit or remove project overrides manually. +Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. Edit or remove project overrides manually. Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot trigger automatic continuation. Remove it when convenient. diff --git a/agenticoding.test.ts b/agenticoding.test.ts index d65945f..3838e2f 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -646,23 +646,44 @@ test("manual slash handoff restores deactivated handoff after success error or s }); }); -test("manual slash handoff refuses busy requests and asks the operator to retry once idle", async () => { +test("manual slash handoff waits for busy runs then starts a fresh handoff turn", async () => { await withIsolatedSettings(async ({ cwd }) => { await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); const pi = new MockPi(); registerAgenticoding(pi as any); + const deferred = createDeferred(); + let idle = false; + let waitCalls = 0; - await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false }); + const command = pi.commands.get("handoff")!.handler("implement auth", { + cwd, + hasUI: false, + isIdle: () => idle, + waitForIdle: async () => { + waitCalls += 1; + await deferred.promise; + idle = true; + }, + }); + await Promise.resolve(); + assert.equal(waitCalls, 1); assert.equal(pi.activeTools.includes("handoff"), false); assert.deepEqual(pi.sentUserMessages, []); - assert.equal(pi.sentMessages[0]?.message.customType, "agenticoding-handoff-diagnostic"); - assert.match(pi.sentMessages[0]?.message.content, /currently streaming/); - assert.match(pi.sentMessages[0]?.message.content, /Retry \/handoff once the assistant is idle/); + assert.deepEqual(pi.sentMessages, []); + + deferred.resolve(); + await command; + + assert.ok(pi.activeTools.includes("handoff")); + assert.equal(pi.sentUserMessages.length, 1); + assert.equal(pi.sentUserMessages[0].options, undefined); + assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/); + assert.deepEqual(pi.sentMessages, []); }); }); -test("manual slash handoff reports a diagnostic instead of prompting prose when handoff cannot be activated", async () => { +test("manual slash handoff reports only a UI error when handoff cannot be activated", async () => { const pi = new MockPi(); (pi as any).setActiveTools = undefined; const state = createState(); @@ -679,7 +700,7 @@ test("manual slash handoff reports a diagnostic instead of prompting prose when assert.deepEqual(pi.sentUserMessages, []); assert.equal(notifications[0].level, "error"); assert.match(notifications[0].message, /could not be activated/); - assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic"); + assert.deepEqual(pi.sentMessages, []); }); test("handoff automatic setting is documented in README", async () => { @@ -1086,7 +1107,7 @@ test("turn_end fallback clears stale requested handoff status", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); assert.equal(notifications[0].level, "warning"); assert.match(notifications[0].message, /did not call the handoff tool/); - assert.equal(pi.sentMessages.at(-1)?.message.customType, "agenticoding-handoff-diagnostic"); + assert.deepEqual(pi.sentMessages, []); }); test("session_start new clears stale handoff status and warning widget", async () => { @@ -1232,7 +1253,7 @@ test("buildNudge handles null percent and boundary hints before topic guidance", assert.match(noTopic, /No active notebook topic is set/); }); -test("watchdog stale requested handoff cleanup emits a visible context diagnostic", async () => { +test("watchdog stale requested handoff cleanup avoids conversation diagnostics", async () => { const pi = new MockPi(); const state = createState(); state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false }; @@ -1255,8 +1276,7 @@ test("watchdog stale requested handoff cleanup emits a visible context diagnosti assert.equal(state.pendingRequestedHandoff, null); assert.equal(notifications[0].level, "warning"); assert.match(notifications[0].message, /did not call the handoff tool/); - assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic"); - assert.match(pi.sentMessages[0].message.content, /did not call the handoff tool/); + assert.deepEqual(pi.sentMessages, []); assert.deepEqual(pi.sentUserMessages, []); }); diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts index 5f78df7..e33a496 100644 --- a/handoff/cleanup.ts +++ b/handoff/cleanup.ts @@ -7,10 +7,6 @@ export function buildMissingRequestedHandoffDiagnostic(direction: string): strin return `Manual /handoff did not compact for direction "${direction}" because the assistant did not call the handoff tool. The temporary handoff tool activation has been cleared.`; } -export function buildBusyRequestedHandoffDiagnostic(direction: string): string { - return `Manual /handoff was not queued for direction "${direction}" because the assistant is currently streaming. Retry /handoff once the assistant is idle so Pi can start a fresh turn with the handoff tool available.`; -} - export function emitHandoffDiagnostic( pi: ExtensionAPI, ctx: ExtensionContext, @@ -20,11 +16,6 @@ export function emitHandoffDiagnostic( if (ctx.hasUI) { ctx.ui.notify?.(message, level); } - pi.sendMessage({ - customType: "agenticoding-handoff-diagnostic", - content: message, - display: true, - }); } export async function clearStaleRequestedHandoff( diff --git a/handoff/command.ts b/handoff/command.ts index abebbe9..3c1f4c3 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -8,8 +8,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; -import { temporarilyActivateHandoffTool } from "./availability.js"; -import { buildBusyRequestedHandoffDiagnostic, emitHandoffDiagnostic } from "./cleanup.js"; +import { temporarilyActivateHandoffTool, updateHandoffToolAvailability } from "./availability.js"; +import { emitHandoffDiagnostic } from "./cleanup.js"; export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void { pi.registerCommand("handoff", { @@ -24,9 +24,25 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } - const isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; + let isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; if (!isIdle) { - emitHandoffDiagnostic(pi, ctx, buildBusyRequestedHandoffDiagnostic(direction), "warning"); + if (ctx.hasUI && ctx.ui.theme) { + ctx.ui.setStatus?.( + STATUS_KEY_HANDOFF, + ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff pending"), + ); + } + if (typeof ctx.waitForIdle === "function") { + await ctx.waitForIdle(); + } + isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; + } + + if (!isIdle) { + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + ctx.ui.notify?.("Manual /handoff is waiting for the assistant to become idle before starting a fresh handoff turn.", "warning"); + } return; } @@ -55,10 +71,18 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat ); } - pi.sendUserMessage( - prompt, - isIdle ? undefined : { deliverAs: "followUp" }, - ); + void Promise.resolve(pi.sendUserMessage(prompt)).catch(async (error) => { + 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", + ); + } + await updateHandoffToolAvailability(pi, state, ctx); + }); }, }); } From 3d35d41e29ec3c5d0222ed876527851581d89cbc Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 15:50:36 +0000 Subject: [PATCH 12/16] pi-agenticoding/03: silence stale manual handoff cleanup --- CHANGELOG.md | 2 +- agenticoding.test.ts | 10 ++++------ handoff/cleanup.ts | 6 ------ 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0690ceb..4ecb5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored. - Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. - 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 ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible `agenticoding-handoff-diagnostic` conversation messages have been removed from this flow. +- Manual `/handoff ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible stale-cleanup diagnostics have been removed from this flow, including TUI warnings and `agenticoding-handoff-diagnostic` conversation messages. ## [0.3.0] - 2026-05-23 diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 3838e2f..5048377 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1072,7 +1072,7 @@ test("handoff compaction error clears pending state and status", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("turn_end fallback clears stale requested handoff status", async () => { +test("turn_end fallback silently clears stale requested handoff status", async () => { const pi = new MockPi(); registerAgenticoding(pi as any); const statuses = new Map(); @@ -1105,8 +1105,7 @@ test("turn_end fallback clears stale requested handoff status", async () => { }); assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); - assert.equal(notifications[0].level, "warning"); - assert.match(notifications[0].message, /did not call the handoff tool/); + assert.deepEqual(notifications, []); assert.deepEqual(pi.sentMessages, []); }); @@ -1253,7 +1252,7 @@ test("buildNudge handles null percent and boundary hints before topic guidance", assert.match(noTopic, /No active notebook topic is set/); }); -test("watchdog stale requested handoff cleanup avoids conversation diagnostics", 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, awaitingAgentTurn: false }; @@ -1274,8 +1273,7 @@ test("watchdog stale requested handoff cleanup avoids conversation diagnostics", ); assert.equal(state.pendingRequestedHandoff, null); - assert.equal(notifications[0].level, "warning"); - assert.match(notifications[0].message, /did not call the handoff tool/); + assert.deepEqual(notifications, []); assert.deepEqual(pi.sentMessages, []); assert.deepEqual(pi.sentUserMessages, []); }); diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts index e33a496..f9a39c9 100644 --- a/handoff/cleanup.ts +++ b/handoff/cleanup.ts @@ -3,10 +3,6 @@ import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; import { updateHandoffToolAvailability } from "./availability.js"; -export function buildMissingRequestedHandoffDiagnostic(direction: string): string { - return `Manual /handoff did not compact for direction "${direction}" because the assistant did not call the handoff tool. The temporary handoff tool activation has been cleared.`; -} - export function emitHandoffDiagnostic( pi: ExtensionAPI, ctx: ExtensionContext, @@ -27,8 +23,6 @@ export async function clearStaleRequestedHandoff( if (!requested) { return; } - const message = buildMissingRequestedHandoffDiagnostic(requested.direction); - emitHandoffDiagnostic(pi, ctx, message, "warning"); state.pendingRequestedHandoff = null; state.pendingRequestedHandoffPrompt = null; if (ctx.hasUI) { From 140f8b7cf889b9a343e9c8aa6d9cd010fd8b4d19 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 17:29:40 +0000 Subject: [PATCH 13/16] pi-agenticoding/03: restore post-handoff proceed --- CHANGELOG.md | 2 +- README.md | 8 ++++---- agenticoding.test.ts | 15 +++++++++------ handoff/tool.ts | 4 +++- settings.ts | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ecb5ec..a8937f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored. +- Handoff keeps auto-sending `Proceed.` after successful compaction, matching core Pi behavior, while the superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and no longer configures continuation. - Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. - 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 ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible stale-cleanup diagnostics have been removed from this flow, including TUI warnings and `agenticoding-handoff-diagnostic` conversation messages. diff --git a/README.md b/README.md index 208cb16..99a0c40 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ 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** | Explicit manual pivot — agent drafts brief, compacts context, then waits for your next input | +| **`/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 | @@ -117,7 +117,7 @@ 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. Handoff completion always waits for your next explicit input; there is no configurable auto-`Proceed.` behavior. +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`. @@ -127,11 +127,11 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in } ``` -Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and then waits for your next input. +Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and auto-sends `Proceed.` after successful compaction. Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. Edit or remove project overrides manually. -Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot trigger automatic continuation. Remove it when convenient. +Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot change the fixed post-compaction `Proceed.` continuation. Remove it when convenient. **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 cc4748f..6ff4833 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -408,7 +408,7 @@ test("/handoff requires a direction", async () => { assert.deepEqual(pi.sentUserMessages, []); }); -test("handoff automatic setting defaults to enabled without automatic continuation", async () => { +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"); @@ -441,11 +441,11 @@ test("handoff automatic setting defaults to enabled without automatic continuati assert.equal(result.terminate, true); compactOptions.onComplete({}); - assert.deepEqual(pi.sentUserMessages, []); + assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); assert.ok(pi.activeTools.includes("handoff")); }); -test("handoff automatic setting true keeps handoff active without automatic continuation", async () => { +test("handoff automatic setting true keeps handoff active and proceeds after compaction", async () => { const result = await runHandoffResumeScenario({ globalSettings: { handoff: { automaticEnabled: false } }, projectSettings: { handoff: { automaticEnabled: true } }, @@ -453,7 +453,7 @@ test("handoff automatic setting true keeps handoff active without automatic cont assert.ok(result.compactOptions); result.compactOptions.onComplete({}); - assert.deepEqual(result.sentUserMessages, []); + assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); assert.deepEqual(result.notifications, []); assert.ok(result.activeTools.includes("handoff")); }); @@ -558,14 +558,14 @@ test("handoff automatic setting non-ENOENT read errors are treated as invalid so }); }); -test("handoff resumeBehavior is ignored and cannot trigger automatic continuation", async () => { +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, []); + assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); assert.deepEqual(result.notifications, []); }); @@ -593,6 +593,9 @@ test("manual slash handoff temporarily activates handoff when automatic handoff }); assert.ok(compactOptions); assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + + compactOptions.onComplete({}); + assert.equal(pi.sentUserMessages.at(-1)?.content, "Proceed."); }); }); diff --git a/handoff/tool.ts b/handoff/tool.ts index 9210dab..c14ab3b 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -98,7 +98,9 @@ export function registerHandoffTool( manualRequest.toolCalled = true; } ctx.compact({ - onComplete: () => {}, + onComplete: () => { + pi.sendUserMessage("Proceed."); + }, onError: () => { state.pendingHandoff = null; state.pendingRequestedHandoff = null; diff --git a/settings.ts b/settings.ts index b0704c5..2235751 100644 --- a/settings.ts +++ b/settings.ts @@ -316,7 +316,7 @@ export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsM `Supported values: true, false. Default: true (automatic handoff enabled).`, `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works from an idle prompt.`, `Setting changes affect future fresh agent turns; in-flight queued follow-ups keep their existing tool schema.`, - `Handoff completion waits for your next explicit input; handoff.resumeBehavior is ignored and no automatic Proceed. message is sent.`, + `After successful handoff compaction, Pi auto-sends Proceed.; handoff.resumeBehavior is ignored and cannot change that behavior.`, `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, `TUI saves are global-only; project settings override global settings at runtime.`, From 79a93d97d90fa5a11e672f12c67f3218bd4f0e4c Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 27 May 2026 19:29:09 +0000 Subject: [PATCH 14/16] pi-agenticoding/03: keep changelog scoped to main diff --- CHANGELOG.md | 3 +-- README.md | 2 -- agenticoding.test.ts | 4 ++-- settings.ts | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8937f8..440d831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Handoff keeps auto-sending `Proceed.` after successful compaction, matching core Pi behavior, while the superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and no longer configures continuation. - Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. - 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 ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible stale-cleanup diagnostics have been removed from this flow, including TUI warnings and `agenticoding-handoff-diagnostic` conversation messages. +- Manual `/handoff ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. ## [0.3.0] - 2026-05-23 diff --git a/README.md b/README.md index 99a0c40..6c23c7a 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,6 @@ Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.j Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. Edit or remove project overrides manually. -Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot change the fixed post-compaction `Proceed.` continuation. Remove it when convenient. - **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 6ff4833..0f4f716 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -714,10 +714,10 @@ test("handoff automatic setting is documented in README", async () => { assert.match(readme, /true/); assert.match(readme, /false/); assert.match(readme, /default/i); - assert.match(readme, /resumeBehavior.*ignored|ignored.*resumeBehavior/i); + assert.match(readme, /Proceed/); + assert.doesNotMatch(readme, /PR-only/i); assert.match(changelog, /handoff\.automaticEnabled/); assert.match(changelog, /default.*enabled/i); - assert.match(changelog, /Proceed/); }); test("agenticoding settings command registers /agenticoding-settings TUI surface", async () => { diff --git a/settings.ts b/settings.ts index 2235751..36bbcd9 100644 --- a/settings.ts +++ b/settings.ts @@ -316,7 +316,7 @@ export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsM `Supported values: true, false. Default: true (automatic handoff enabled).`, `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works from an idle prompt.`, `Setting changes affect future fresh agent turns; in-flight queued follow-ups keep their existing tool schema.`, - `After successful handoff compaction, Pi auto-sends Proceed.; handoff.resumeBehavior is ignored and cannot change that behavior.`, + `After successful handoff compaction, Pi auto-sends Proceed.; this continuation is fixed, not configurable.`, `Global settings: ${model.state.global.path} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, `TUI saves are global-only; project settings override global settings at runtime.`, From 5a3ca23a5f5413994ce2e3bb506d49212a1b0a5c Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 28 May 2026 08:42:21 +0000 Subject: [PATCH 15/16] pi-agenticoding/03: guard manual handoff requests --- CHANGELOG.md | 10 +- README.md | 4 +- agenticoding.test.ts | 480 ++++++++++++++++++++++++++++++++++++---- handoff/availability.ts | 61 ----- handoff/cleanup.ts | 13 +- handoff/command.ts | 79 +++---- handoff/compact.ts | 2 - handoff/tool.ts | 73 +++--- index.ts | 93 +++++--- notebook/tools.ts | 6 +- notebook/topic-tool.ts | 6 +- settings.ts | 77 +++++-- system-prompt.ts | 11 +- watchdog.ts | 33 ++- 14 files changed, 672 insertions(+), 276 deletions(-) delete mode 100644 handoff/availability.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 440d831..9427d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,14 @@ 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` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff `. +- 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 ` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. +- 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. + +### 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 @@ -114,4 +119,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 6c23c7a..a4a73d6 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in } ``` -Settings are read from `~/.pi/agent/settings.json` and `/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff ` still works from idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and auto-sends `Proceed.` after successful compaction. +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 future fresh agent turns; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. Edit or remove project overrides manually. +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 0f4f716..9b74690 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -1,6 +1,6 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; -import { chmod, 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 { dirname, join } from "node:path"; import type { Theme } from "@earendil-works/pi-coding-agent"; @@ -30,6 +30,7 @@ import { getAgenticodingSettingsDisplayLines, readHandoffSettingsState, resolveHandoffAutomaticAvailability, + setSettingsAtomicWriteOperationsForTest, writeGlobalHandoffAutomaticEnabled, } from "./settings.js"; import { CONTEXT_PRIMER } from "./system-prompt.js"; @@ -386,7 +387,7 @@ test("/handoff sends the direction back through the LLM without opening the edit 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. After drafting the brief, you must call the `handoff` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the `handoff` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.", + "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, }, ]); @@ -408,6 +409,67 @@ test("/handoff requires a direction", async () => { assert.deepEqual(pi.sentUserMessages, []); }); +test("/handoff clears pending manual request when sendUserMessage throws synchronously", async () => { + const pi = new MockPi(); + pi.sendUserMessage = () => { throw new Error("send failed"); }; + const state = createState(); + registerHandoffCommand(pi as any, state); + const notifications: Array<{ message: string; level: string }> = []; + const statuses: Array = []; + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme, + setStatus: (_key: string, status: string | undefined) => statuses.push(status), + notify: (message: string, level: string) => notifications.push({ message, level }), + }, + }); + + assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingRequestedHandoffPrompt, null); + assert.deepEqual(statuses, ["🤝 Handoff 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(); @@ -442,10 +504,9 @@ test("handoff automatic setting defaults to enabled with post-compaction Proceed compactOptions.onComplete({}); assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); - assert.ok(pi.activeTools.includes("handoff")); }); -test("handoff automatic setting true keeps handoff active and proceeds after compaction", async () => { +test("handoff automatic setting true proceeds after compaction", async () => { const result = await runHandoffResumeScenario({ globalSettings: { handoff: { automaticEnabled: false } }, projectSettings: { handoff: { automaticEnabled: true } }, @@ -455,10 +516,10 @@ test("handoff automatic setting true keeps handoff active and proceeds after com result.compactOptions.onComplete({}); assert.deepEqual(result.sentUserMessages, [{ content: "Proceed.", options: undefined }]); assert.deepEqual(result.notifications, []); - assert.ok(result.activeTools.includes("handoff")); + assert.deepEqual(result.activeTools, []); }); -test("handoff automatic setting false deactivates handoff for normal agent turns and blocks stale direct calls", async () => { +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 } }, @@ -498,6 +559,30 @@ test("handoff automatic setting ignores prototype/meta keys unless automaticEnab }); }); +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" } }, @@ -569,7 +654,7 @@ test("handoff resumeBehavior is ignored and completion still uses fixed Proceed" assert.deepEqual(result.notifications, []); }); -test("manual slash handoff temporarily activates handoff when automatic handoff is disabled", async () => { +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(); @@ -583,7 +668,8 @@ test("manual slash handoff temporarily activates handoff when automatic handoff isIdle: () => true, ui: { theme, notify: () => {}, setStatus: () => {} }, }); - assert.ok(pi.activeTools.includes("handoff")); + assert.deepEqual(pi.activeTools, []); + state.pendingRequestedHandoff!.awaitingAgentTurn = false; let compactOptions: any; await pi.tools.get("handoff").execute("1", { task: "continue" }, undefined, undefined, { @@ -599,7 +685,7 @@ test("manual slash handoff temporarily activates handoff when automatic handoff }); }); -test("manual slash handoff restores deactivated handoff after success error or stale cleanup", async () => { +test("manual slash handoff does not mutate active tools after success error or stale cleanup", async () => { await withIsolatedSettings(async ({ cwd }) => { await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } }); const pi = new MockPi(); @@ -610,6 +696,7 @@ test("manual slash handoff restores deactivated handoff after success error or s 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, @@ -620,6 +707,7 @@ test("manual slash handoff restores deactivated handoff after success error or s 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, @@ -629,7 +717,6 @@ test("manual slash handoff restores deactivated handoff after success error or s await new Promise(resolve => setTimeout(resolve, 10)); assert.deepEqual(pi.activeTools, []); assert.equal(state.pendingRequestedHandoff, null); - }); await withIsolatedSettings(async ({ cwd }) => { @@ -637,7 +724,7 @@ test("manual slash handoff restores deactivated handoff after success error or s const pi = new MockPi(); registerAgenticoding(pi as any); await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true }); - assert.ok(pi.activeTools.includes("handoff")); + assert.deepEqual(pi.activeTools, []); const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; const [turnEnd] = pi.handlers.get("turn_end")!; await beforeAgentStart( @@ -649,44 +736,188 @@ test("manual slash handoff restores deactivated handoff after success error or s }); }); -test("manual slash handoff waits for busy runs then starts a fresh handoff turn", async () => { +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); - const deferred = createDeferred(); - let idle = false; - let waitCalls = 0; - const command = pi.commands.get("handoff")!.handler("implement auth", { + await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, - isIdle: () => idle, - waitForIdle: async () => { - waitCalls += 1; - await deferred.promise; - idle = true; + 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; }, }); - await Promise.resolve(); - assert.equal(waitCalls, 1); - assert.equal(pi.activeTools.includes("handoff"), false); - assert.deepEqual(pi.sentUserMessages, []); - assert.deepEqual(pi.sentMessages, []); + 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); + }); +}); - deferred.resolve(); - await command; +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.ok(pi.activeTools.includes("handoff")); + assert.equal(waitCalls, 0); + assert.deepEqual(pi.activeTools, []); assert.equal(pi.sentUserMessages.length, 1); - assert.equal(pi.sentUserMessages[0].options, undefined); + 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 reports only a UI error when handoff cannot be activated", async () => { +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(); @@ -696,13 +927,12 @@ test("manual slash handoff reports only a UI error when handoff cannot be activa await pi.commands.get("handoff")!.handler("implement auth", { hasUI: true, isIdle: () => true, - ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + ui: { theme, notify: (message: string, level: string) => notifications.push({ message, level }), setStatus: () => {} }, }); - assert.equal(state.pendingRequestedHandoff, null); - assert.deepEqual(pi.sentUserMessages, []); - assert.equal(notifications[0].level, "error"); - assert.match(notifications[0].message, /could not be activated/); + assert.equal(state.pendingRequestedHandoff?.direction, "implement auth"); + assert.equal(pi.sentUserMessages.length, 1); + assert.deepEqual(notifications, []); assert.deepEqual(pi.sentMessages, []); }); @@ -921,7 +1151,7 @@ test("agenticoding settings write path refuses non-ENOENT read failures without }); }); -test("agenticoding settings write path handles save failure with error notification", async () => { +test("agenticoding settings write path rejects save failures", async () => { await withIsolatedSettings(async ({ home }) => { const settingsDir = join(home, ".pi", "agent"); await mkdir(settingsDir, { recursive: true }); @@ -945,6 +1175,65 @@ test("agenticoding settings write path handles save failure with error notificat }); }); +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); @@ -984,6 +1273,8 @@ test("agenticoding settings documentation covers TUI and global-only/project ove 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 () => { @@ -1075,7 +1366,36 @@ test("handoff compaction error clears pending state and status", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("turn_end fallback silently clears stale requested handoff status", async () => { +test("handoff compact synchronous throw clears pending state and status", 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.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingRequestedHandoffPrompt, null); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("agent_end fallback silently clears stale requested handoff status", async () => { const pi = new MockPi(); registerAgenticoding(pi as any); const statuses = new Map(); @@ -1091,12 +1411,12 @@ test("turn_end fallback silently clears stale requested handoff status", async ( const notifications: Array<{ message: string; level: string }> = []; const [beforeAgentStart] = pi.handlers.get("before_agent_start")!; - const [turnEnd] = pi.handlers.get("turn_end")!; + const [agentEnd] = pi.handlers.get("agent_end")!; await beforeAgentStart( { prompt: pi.sentUserMessages[0].content, systemPrompt: "base" }, { hasUI: false } as any, ); - await turnEnd({}, { + await agentEnd({}, { hasUI: true, ui: { theme: { fg: (_name: string, text: string) => text }, @@ -1180,6 +1500,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); @@ -2616,7 +2966,7 @@ test("session_start rehydrates the latest persisted notebook state through the f 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", "handoff"]); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); } finally { resetNotebookWriteLock(); } @@ -3843,7 +4193,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); @@ -3875,6 +4226,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"); @@ -3886,9 +4239,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(); @@ -3902,7 +4258,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(); @@ -3985,7 +4345,7 @@ 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 removes handoff calls from primer and watchdog guidance", async () => { +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(); @@ -3997,8 +4357,12 @@ test("handoff automatic setting false removes handoff calls from primer and watc cwd, }); - assert.equal(pi.activeTools.includes("handoff"), false); + 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); @@ -4006,12 +4370,36 @@ test("handoff automatic setting false removes handoff calls from primer and watc 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/availability.ts b/handoff/availability.ts deleted file mode 100644 index 1e2a31e..0000000 --- a/handoff/availability.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import type { AgenticodingState } from "../state.js"; -import { resolveHandoffAutomaticAvailability, type HandoffAutomaticAvailability } from "../settings.js"; - -function getActiveTools(pi: ExtensionAPI): string[] | null { - return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : null; -} - -function setActiveTools(pi: ExtensionAPI, tools: string[]): boolean { - if (typeof pi.setActiveTools !== "function") { - return false; - } - pi.setActiveTools(tools); - return true; -} - -export function applyHandoffToolAvailability( - pi: ExtensionAPI, - automaticEnabled: boolean, - manualRequested: boolean, -): void { - const shouldBeActive = automaticEnabled || manualRequested; - const active = getActiveTools(pi); - if (!active) { - return; - } - const hasHandoff = active.includes("handoff"); - - if (shouldBeActive && !hasHandoff) { - setActiveTools(pi, [...active, "handoff"]); - return; - } - - if (!shouldBeActive && hasHandoff) { - setActiveTools(pi, active.filter((tool) => tool !== "handoff")); - } -} - -export async function updateHandoffToolAvailability( - pi: ExtensionAPI, - state: AgenticodingState, - ctx: ExtensionContext, -): Promise { - const availability = await resolveHandoffAutomaticAvailability(ctx); - applyHandoffToolAvailability(pi, availability.automaticEnabled, state.pendingRequestedHandoff !== null); - return availability; -} - -export function temporarilyActivateHandoffTool(pi: ExtensionAPI): boolean { - const active = getActiveTools(pi); - if (!active) { - return false; - } - if (active.includes("handoff")) { - return true; - } - if (!setActiveTools(pi, [...active, "handoff"])) { - return false; - } - return getActiveTools(pi)?.includes("handoff") ?? false; -} diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts index f9a39c9..0615988 100644 --- a/handoff/cleanup.ts +++ b/handoff/cleanup.ts @@ -1,7 +1,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; -import { updateHandoffToolAvailability } from "./availability.js"; export function emitHandoffDiagnostic( pi: ExtensionAPI, @@ -14,8 +13,17 @@ export function emitHandoffDiagnostic( } } +export function clearPendingHandoffCompaction(state: AgenticodingState, ctx: ExtensionContext): void { + state.pendingHandoff = null; + state.pendingRequestedHandoff = null; + state.pendingRequestedHandoffPrompt = null; + if (ctx.hasUI) { + ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + } +} + export async function clearStaleRequestedHandoff( - pi: ExtensionAPI, + _pi: ExtensionAPI, state: AgenticodingState, ctx: ExtensionContext, ): Promise { @@ -28,5 +36,4 @@ export async function clearStaleRequestedHandoff( if (ctx.hasUI) { ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); } - await updateHandoffToolAvailability(pi, state, ctx); } diff --git a/handoff/command.ts b/handoff/command.ts index 4a7fd73..039ce5f 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -8,14 +8,32 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; -import { temporarilyActivateHandoffTool, updateHandoffToolAvailability } from "./availability.js"; -import { emitHandoffDiagnostic } from "./cleanup.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 manually.", + "your direction, then perform the requested handoff.", handler: async (args, ctx) => { const direction = args.trim(); @@ -24,44 +42,15 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } - let isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; - if (!isIdle) { - if (ctx.hasUI && ctx.ui.theme) { - ctx.ui.setStatus?.( - STATUS_KEY_HANDOFF, - ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff pending"), - ); - } - if (typeof ctx.waitForIdle === "function") { - await ctx.waitForIdle(); - } - isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true; - } - - if (!isIdle) { - if (ctx.hasUI) { - ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); - ctx.ui.notify?.("Manual /handoff is waiting for the assistant to become idle before starting a fresh handoff turn.", "warning"); - } - return; - } - - const prompt = `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the \`handoff\` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the \`handoff\` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.`; - 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; - const activated = temporarilyActivateHandoffTool(pi); - if (!activated) { - state.pendingRequestedHandoff = null; - state.pendingRequestedHandoffPrompt = null; - const message = "Manual /handoff cannot compact because the handoff tool could not be activated for the next agent turn."; - emitHandoffDiagnostic(pi, ctx, message, "error"); - return; - } // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { @@ -71,18 +60,14 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat ); } - void Promise.resolve(pi.sendUserMessage(prompt)).catch(async (error) => { - 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", - ); - } - await updateHandoffToolAvailability(pi, state, ctx); - }); + 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 7acfbf2..4c2e054 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -9,7 +9,6 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-wor import type { AgenticodingState } from "../state.js"; import { clearActiveNotebookTopic } from "../notebook/topic.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; -import { updateHandoffToolAvailability } from "./availability.js"; function getImpossibleKeptId(branchEntries: SessionEntry[]): string { const leaf = branchEntries[branchEntries.length - 1]; @@ -32,7 +31,6 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); } - await updateHandoffToolAvailability(pi, state, ctx); return { compaction: { diff --git a/handoff/tool.ts b/handoff/tool.ts index c14ab3b..043b37f 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -12,8 +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 { updateHandoffToolAvailability } from "./availability.js"; +import { resolveHandoffAutomaticAvailability } from "../settings.js"; +import { clearPendingHandoffCompaction } from "./cleanup.js"; /** * Build the enriched task that becomes the compaction summary. @@ -47,42 +47,30 @@ 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 updateHandoffToolAvailability(pi, state, ctx); + const availability = await resolveHandoffAutomaticAvailability(ctx); const manualRequest = state.pendingRequestedHandoff; - if (!availability.automaticEnabled && !manualRequest) { + 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"); } @@ -94,23 +82,22 @@ export function registerHandoffTool( const enrichedTask = buildEnrichedTask(params.task); state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (manualRequest) { - manualRequest.toolCalled = true; + if (activeManualRequest) { + activeManualRequest.toolCalled = true; + } + try { + ctx.compact({ + onComplete: () => { + pi.sendUserMessage("Proceed."); + }, + onError: () => { + clearPendingHandoffCompaction(state, ctx); + }, + }); + } catch (error) { + clearPendingHandoffCompaction(state, ctx); + throw error; } - ctx.compact({ - onComplete: () => { - pi.sendUserMessage("Proceed."); - }, - onError: () => { - state.pendingHandoff = null; - state.pendingRequestedHandoff = null; - state.pendingRequestedHandoffPrompt = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - void updateHandoffToolAvailability(pi, state, ctx); - }, - }); return { content: [{ type: "text", text: "Handoff started." }], diff --git a/index.ts b/index.ts index d773f51..a839058 100644 --- a/index.ts +++ b/index.ts @@ -22,7 +22,7 @@ import { } from "@earendil-works/pi-tui"; import { createState, resetState, type AgenticodingState } from "./state.js"; import { getContextPrimer } from "./system-prompt.js"; -import { buildNudge, registerWatchdog } from "./watchdog.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,9 +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 { updateHandoffToolAvailability } from "./handoff/availability.js"; -import { clearStaleRequestedHandoff } from "./handoff/cleanup.js"; -import { registerAgenticodingSettingsCommand } from "./settings.js"; +import { registerAgenticodingSettingsCommand, resolveHandoffAutomaticAvailability } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { STATUS_KEY_HANDOFF, @@ -42,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(); @@ -67,7 +105,7 @@ export default function (pi: ExtensionAPI): void { const topicArg = args.trim(); if (topicArg) { const result = setActiveNotebookTopic(state, topicArg, "human"); - const availability = await updateHandoffToolAvailability(pi, state, ctx); + const availability = await resolveHandoffAutomaticAvailability(ctx); if (ctx.hasUI) { const message = result.boundaryHint ? (availability.automaticEnabled @@ -167,14 +205,8 @@ export default function (pi: ExtensionAPI): void { // ── before_agent_start: inject context primer + notebook ─────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { - if ( - state.pendingRequestedHandoff?.awaitingAgentTurn && - state.pendingRequestedHandoffPrompt !== null && - event.prompt === state.pendingRequestedHandoffPrompt - ) { - state.pendingRequestedHandoff.awaitingAgentTurn = false; - } - const availability = await updateHandoffToolAvailability(pi, state, ctx); + activatePendingRequestedHandoff(state, event.prompt); + const availability = await resolveHandoffAutomaticAvailability(ctx); // Update TUI indicators before each user-prompt agent run updateIndicators(ctx, state, availability.automaticEnabled); @@ -219,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(); @@ -230,8 +266,13 @@ export default function (pi: ExtensionAPI): void { return; } - const availability = await updateHandoffToolAvailability(pi, state, ctx); - const nudge = buildNudge(state, percent, availability.automaticEnabled); + 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: [ @@ -258,7 +299,7 @@ export default function (pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } - const availability = await updateHandoffToolAvailability(pi, state, ctx); + const availability = await resolveHandoffAutomaticAvailability(ctx); updateIndicators(ctx, state, availability.automaticEnabled); }); @@ -269,20 +310,14 @@ export default function (pi: ExtensionAPI): void { // 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 the requested - // /handoff turn without calling the handoff tool. If /handoff was queued - // as a follow-up while another turn was streaming, keep the temporary tool - // activation through that current turn_end until the requested turn starts. - if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) { - if (!state.pendingRequestedHandoff.awaitingAgentTurn) { - await clearStaleRequestedHandoff(pi, state, ctx); - } else { - await updateHandoffToolAvailability(pi, state, ctx); - } - } - const availability = await updateHandoffToolAvailability(pi, state, ctx); + // 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 index 36bbcd9..7474ab7 100644 --- a/settings.ts +++ b/settings.ts @@ -1,6 +1,7 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +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 { @@ -15,6 +16,11 @@ export type HandoffAutomaticValue = "true" | "false"; type SettingsObject = Record; type SettingsSourceLabel = "global" | "project"; +type AtomicWriteOperations = { + writeFile: typeof writeFile; + rename: typeof rename; + rm: typeof rm; +}; export interface SettingsSourceState { label: SettingsSourceLabel; @@ -48,6 +54,12 @@ export interface AgenticodingSettingsModel { } 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."; @@ -60,6 +72,18 @@ 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); } @@ -97,6 +121,9 @@ function mergeSettings(base: SettingsObject, override: SettingsObject): Settings 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 { @@ -113,6 +140,16 @@ function extractAutomaticEnabled(settings: SettingsObject): unknown { : 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"; } @@ -175,15 +212,12 @@ function resolveFromState(state: HandoffSettingsState): HandoffAutomaticAvailabi return { automaticEnabled: false, source: "fallback" }; } - const automaticEnabled = extractAutomaticEnabled(state.merged); - if (automaticEnabled === undefined) { + const automatic = getLayeredAutomaticEnabled(state); + if (automatic.value === undefined) { return { automaticEnabled: true, source: "default" }; } - if (typeof automaticEnabled === "boolean") { - return { - automaticEnabled, - source: state.project.automaticEnabled !== undefined ? "project" : "global", - }; + if (typeof automatic.value === "boolean") { + return { automaticEnabled: automatic.value, source: automatic.source }; } return { automaticEnabled: false, source: "fallback" }; } @@ -201,20 +235,17 @@ export async function resolveHandoffAutomaticAvailability(ctx: ExtensionContext) return { automaticEnabled: false, source: "fallback" }; } - const automaticEnabled = extractAutomaticEnabled(state.merged); - if (automaticEnabled === undefined) { + const automatic = getLayeredAutomaticEnabled(state); + if (automatic.value === undefined) { return { automaticEnabled: true, source: "default" }; } - if (typeof automaticEnabled === "boolean") { - return { - automaticEnabled, - source: state.project.automaticEnabled !== undefined ? "project" : "global", - }; + if (typeof automatic.value === "boolean") { + return { automaticEnabled: automatic.value, source: automatic.source }; } notify( ctx, - `Unsupported handoff.automaticEnabled value ${formatSettingValue(automaticEnabled)}; supported values are true or false, falling back to automatic handoff disabled.`, + `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" }; @@ -259,7 +290,7 @@ export async function writeGlobalHandoffAutomaticEnabled( setOwnSetting(settings, "handoff", handoff); await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); + await writeFileAtomically(path, JSON.stringify(settings, null, 2) + "\n"); notify(ctx, `Saved global handoff.automaticEnabled = ${booleanValue}.`, "info"); return true; } @@ -274,9 +305,9 @@ export async function buildAgenticodingSettingsModel(ctx: ExtensionContext): Pro } else if (state.project.invalid) { messages.push(`Invalid project settings JSON at ${state.project.path}; runtime falls back to automatic handoff disabled, but global TUI saves are still allowed.`); } else { - const mergedValue = extractAutomaticEnabled(state.merged); - if (mergedValue !== undefined && typeof mergedValue !== "boolean") { - messages.push(`Unsupported handoff.automaticEnabled value ${formatSettingValue(mergedValue)}; runtime falls back to automatic handoff disabled.`); + 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.`); } } @@ -314,8 +345,8 @@ export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsM const lines = [ `Resolved handoff.automaticEnabled: ${model.effectiveAutomaticEnabled} (${model.effectiveSource})`, `Supported values: true, false. Default: true (automatic handoff enabled).`, - `When false, the agent-facing handoff tool is inactive for normal turns; manual /handoff still works from an idle prompt.`, - `Setting changes affect future fresh agent turns; in-flight queued follow-ups keep their existing tool schema.`, + `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} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, diff --git a/system-prompt.ts b/system-prompt.ts index 71b3c31..cfc0f62 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -22,11 +22,12 @@ 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 -The automatic context-compaction tool is not active in normal agent turns. 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.`; +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 diff --git a/watchdog.ts b/watchdog.ts index ffce0eb..2fc3aae 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -12,6 +12,31 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a import type { AgenticodingState } from "./state.js"; import { clearStaleRequestedHandoff } from "./handoff/cleanup.js"; +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; @@ -30,13 +55,7 @@ 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 = handoffAutomaticEnabled From 8010da84242edc5084478fcea4f40301a8baf2cb Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 5 Jun 2026 16:25:08 +0000 Subject: [PATCH 16/16] pi-agenticoding/03: preserve manual handoff retry state --- agenticoding.test.ts | 97 ++++++++++++++++++++++++++++++-------------- handoff/cleanup.ts | 33 ++++++++++++++- handoff/tool.ts | 10 +++-- settings.ts | 56 ++++++++++++++++++++----- 4 files changed, 150 insertions(+), 46 deletions(-) diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 9b74690..94cc907 100644 --- a/agenticoding.test.ts +++ b/agenticoding.test.ts @@ -614,32 +614,50 @@ test("handoff automatic setting invalid JSON fails closed with diagnostic", asyn assert.match(projectResult.notifications[0].message, /automatic handoff disabled/); }); -test("handoff automatic setting non-ENOENT read errors are treated as invalid source with warning", async () => { +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 writeSettingsFile(globalPath, {}); - await chmod(globalPath, 0o000); + await mkdir(globalPath, { recursive: true }); - try { - const state = await readHandoffSettingsState(cwd); - assert.equal(state.global.invalid, true); - assert.equal(state.global.exists, true); - assert.equal(state.project.invalid, false); + 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, /Invalid global settings JSON/); - } finally { - await chmod(globalPath, 0o600); - } + 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\)/); }); }); @@ -685,7 +703,7 @@ test("manual slash handoff permits handoff when automatic handoff is disabled", }); }); -test("manual slash handoff does not mutate active tools after success error or stale cleanup", async () => { +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(); @@ -716,7 +734,13 @@ test("manual slash handoff does not mutate active tools after success error or s compactOptions.onError({}); await new Promise(resolve => setTimeout(resolve, 10)); assert.deepEqual(pi.activeTools, []); - assert.equal(state.pendingRequestedHandoff, null); + 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 }) => { @@ -1340,10 +1364,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, awaitingAgentTurn: 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(); @@ -1362,11 +1387,18 @@ test("handoff compaction error clears pending state and status", async () => { compactOptions.onError({}); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); + 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 clears pending state and status", async () => { +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 }; @@ -1390,8 +1422,13 @@ test("handoff compact synchronous throw clears pending state and status", async ); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); - assert.equal(state.pendingRequestedHandoffPrompt, 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); }); diff --git a/handoff/cleanup.ts b/handoff/cleanup.ts index 0615988..a025f11 100644 --- a/handoff/cleanup.ts +++ b/handoff/cleanup.ts @@ -13,13 +13,42 @@ export function emitHandoffDiagnostic( } } +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; - if (ctx.hasUI) { - ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined); + 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( diff --git a/handoff/tool.ts b/handoff/tool.ts index 043b37f..4b253ca 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -13,7 +13,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; import { resolveHandoffAutomaticAvailability } from "../settings.js"; -import { clearPendingHandoffCompaction } from "./cleanup.js"; +import { preserveManualHandoffRequestAfterCompactionError } from "./cleanup.js"; /** * Build the enriched task that becomes the compaction summary. @@ -81,6 +81,10 @@ export function registerHandoffTool( } 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 (activeManualRequest) { activeManualRequest.toolCalled = true; @@ -91,11 +95,11 @@ export function registerHandoffTool( pi.sendUserMessage("Proceed."); }, onError: () => { - clearPendingHandoffCompaction(state, ctx); + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt); }, }); } catch (error) { - clearPendingHandoffCompaction(state, ctx); + preserveManualHandoffRequestAfterCompactionError(state, ctx, retryableManualRequest, retryableManualRequestPrompt); throw error; } diff --git a/settings.ts b/settings.ts index 7474ab7..5bb68ab 100644 --- a/settings.ts +++ b/settings.ts @@ -16,6 +16,7 @@ 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; @@ -27,6 +28,8 @@ export interface SettingsSourceState { path: string; exists: boolean; invalid: boolean; + invalidReason?: SettingsInvalidReason; + readErrorCode?: string; settings: SettingsObject; automaticEnabled: unknown; } @@ -173,27 +176,58 @@ function formatSettingValue(value: unknown): string { } } +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 = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + 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, 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, settings: createSettingsObject(), automaticEnabled: undefined }; + 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, settings: createSettingsObject(), automaticEnabled: undefined }; + return { label, path, exists: true, invalid: true, invalidReason: "invalid-json", settings: createSettingsObject(), automaticEnabled: undefined }; } } @@ -226,10 +260,10 @@ export async function resolveHandoffAutomaticAvailability(ctx: ExtensionContext) const state = await readHandoffSettingsState(ctx.cwd); if (state.global.invalid) { - notify(ctx, `Invalid global settings JSON at ${state.global.path}; falling back to automatic handoff disabled for handoff.automaticEnabled.`, "warning"); + notify(ctx, describeInvalidSource(state.global, "falling back to automatic handoff disabled for handoff.automaticEnabled"), "warning"); } if (state.project.invalid) { - notify(ctx, `Invalid project settings JSON at ${state.project.path}; falling back to automatic handoff disabled for handoff.automaticEnabled.`, "warning"); + 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" }; @@ -263,7 +297,7 @@ export async function writeGlobalHandoffAutomaticEnabled( try { raw = await readFile(path, "utf8"); } catch (error) { - const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + 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; @@ -301,9 +335,9 @@ export async function buildAgenticodingSettingsModel(ctx: ExtensionContext): Pro let effective = resolveFromState(state); if (state.global.invalid) { - messages.push(`Invalid global settings JSON at ${state.global.path}; global TUI saves are blocked until it is fixed.`); + messages.push(describeInvalidSource(state.global, "global TUI saves are blocked until it is fixed")); } else if (state.project.invalid) { - messages.push(`Invalid project settings JSON at ${state.project.path}; runtime falls back to automatic handoff disabled, but global TUI saves are still allowed.`); + 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") { @@ -348,8 +382,8 @@ export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsM `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} (${model.state.global.invalid ? "invalid JSON" : describeValue(model.state.global.automaticEnabled)})`, - `Project settings: ${model.state.project.path} (${model.state.project.invalid ? "invalid JSON" : describeValue(model.state.project.automaticEnabled)})`, + `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) {