diff --git a/CHANGELOG.md b/CHANGELOG.md index c857049..10adc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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. + ## [0.3.0] - 2026-05-23 ### Added @@ -102,12 +110,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. diff --git a/README.md b/README.md index 63df214..f2d5f29 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Then disable pi's built-in compaction so handoff stays in control: } ``` -That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `notebook_index`, and `handoff`. The status bar shows context usage and notebook count. +Optional handoff resume preferences can be changed later with `/agenticoding-settings`. + +That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `notebook_index`, `handoff`, and `/agenticoding-settings`. The status bar shows context usage and notebook count. --- @@ -50,7 +52,8 @@ That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `noteb |---------|-------------------| | **Context usage %** | `ctx 65%` in status bar — green < 30%, yellow < 50%, orange < 70%, red ≥ 70% | | **Notebook count** | 📒 `3` when pages exist, dim `📒 0` when empty | -| **`/handoff` command** | Instant pivot — agent drafts brief, compacts context, resumes | +| **`/handoff` command** | 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 | | **`/notebook` command** | Overlay showing all notebook pages with previews | | **Auto-rehydration** | Notebook pages survive session restarts | | **Spawn transparency** | Watch child agents work in real time in the TUI | @@ -114,6 +117,16 @@ 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"`. + +```json +{ + "handoff": { "resumeBehavior": "proceed" } +} +``` + +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 notebook holds reusable learned knowledge. Handoff carries the remaining situational context. --- diff --git a/agenticoding.test.ts b/agenticoding.test.ts index 79c56b5..4f6a798 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 { 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"; import { Text } from "@earendil-works/pi-tui"; import { registerHandoffCommand } from "./handoff/command.js"; @@ -20,6 +23,15 @@ import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; import { saveNotebookPage, resetNotebookWriteLock } from "./notebook/store.js"; import { createNotebookToolDefinitions } from "./notebook/tools.js"; import registerAgenticoding from "./index.js"; +import { + MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS, + buildAgenticodingSettingsModel, + createAgenticodingSettingsComponent, + getAgenticodingSettingsDisplayLines, + readHandoffSettingsState, + resolveHandoffResumeBehavior, + writeGlobalHandoffResumeBehavior, +} from "./settings.js"; import { CONTEXT_PRIMER } from "./system-prompt.js"; import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; @@ -104,6 +116,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 }) { @@ -159,11 +172,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( @@ -328,7 +408,7 @@ 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.notebookPages.set("auth-refresh", "sensitive notebook body"); @@ -336,17 +416,18 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy registerHandoffTool(pi as any, state); let compactOptions: any; - const result = await pi.tools.get("handoff").execute( + const result = await withIsolatedSettings(async ({ cwd }) => pi.tools.get("handoff").execute( "1", { task: "Goal: continue auth-refresh" }, undefined, undefined, { + cwd, compact: (options: any) => { compactOptions = options; }, }, - ); + )); assert.equal(state.pendingHandoff?.source, "tool"); assert.match(state.pendingHandoff?.task ?? "", /## Handoff — Continue Previous Work/); @@ -360,7 +441,400 @@ 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 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" } }, + }); + + 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 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"); + + 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 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"); + 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/); + }); + + 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"), "{"); + 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 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 }) => { + // 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 = { + cwd: home, + hasUI: true, + ui: { notify: (message: string, level: string) => notifications.push({ message, level }) }, + } as any; + + 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 + // 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); + 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 38d08fa..2b2e1e5 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"; /** * Build the enriched task that becomes the compaction summary. @@ -80,13 +81,16 @@ export function registerHandoffTool( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const enrichedTask = buildEnrichedTask(params.task); + 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 f6506f0..7f1180e 100644 --- a/index.ts +++ b/index.ts @@ -30,6 +30,7 @@ import { setActiveNotebookTopic } from "./notebook/topic.js"; import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; +import { registerAgenticodingSettingsCommand } from "./settings.js"; import { registerSpawnTool } from "./spawn/index.js"; import { STATUS_KEY_HANDOFF, @@ -55,6 +56,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerAgenticodingSettingsCommand(pi); // ── /notebook command — interactive page selector ──────────────── pi.registerCommand("notebook", { diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..df21ed6 --- /dev/null +++ b/settings.ts @@ -0,0 +1,419 @@ +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 createSettingsObject(): SettingsObject { + return Object.create(null) as SettingsObject; +} + +function hasOwnSetting(settings: SettingsObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(settings, key); +} + +function getOwnSetting(settings: SettingsObject, key: string): unknown { + return hasOwnSetting(settings, key) ? settings[key] : undefined; +} + +function setOwnSetting(settings: SettingsObject, key: string, value: unknown): void { + Object.defineProperty(settings, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +} + +function cloneSettingsObject(settings: SettingsObject): SettingsObject { + const result = createSettingsObject(); + for (const [key, value] of Object.entries(settings)) { + setOwnSetting(result, key, isPlainObject(value) ? cloneSettingsObject(value) : value); + } + return result; +} + +function mergeSettings(base: SettingsObject, override: SettingsObject): SettingsObject { + const result = cloneSettingsObject(base); + for (const [key, value] of Object.entries(override)) { + const existing = getOwnSetting(result, key); + if (isPlainObject(existing) && isPlainObject(value)) { + setOwnSetting(result, key, mergeSettings(existing, value)); + } else { + setOwnSetting(result, key, isPlainObject(value) ? cloneSettingsObject(value) : value); + } + } + return result; +} + +function extractResumeBehavior(settings: SettingsObject): unknown { + const handoff = getOwnSetting(settings, "handoff"); + return isPlainObject(handoff) && hasOwnSetting(handoff, "resumeBehavior") + ? getOwnSetting(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 (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 { + const parsed = JSON.parse(raw); + 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 }; + } +} + +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 = createSettingsObject(); + 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") { + notify(ctx, `Unable to read global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error"); + return false; + } + } + + if (raw !== undefined) { + try { + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + notify(ctx, `Invalid global settings JSON at ${path}; root must be an object, not writing handoff.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; + } + } + + 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"); + 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); +} + +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})`, + `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: getGlobalEditableHandoffResumeBehavior(model), + 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 () => { + try { + const saved = await model.save(newValue, ctx); + model = await buildAgenticodingSettingsModel(ctx); + settingsList.updateValue("handoff.resumeBehavior", getGlobalEditableHandoffResumeBehavior(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"); + } + })(); + }, + () => 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); + } + }, + }); +}