From 1169956ac664b8a5389e4d6fb768ddcf282728e1 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 24 May 2026 17:06:30 +0000 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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"); }