@herbertgao/pi-extensions 2026.8.15 → 2026.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +20 -0
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +46 -3
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +6 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +5 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +34 -30
  9. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  11. package/node_modules/@narumitw/pi-btw/README.md +5 -1
  12. package/node_modules/@narumitw/pi-btw/dist/index.ts +143 -14
  13. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  14. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  15. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +158 -7
  16. package/node_modules/@narumitw/pi-btw/src/text.ts +18 -0
  17. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +5 -16
  18. package/node_modules/@narumitw/pi-caffeinate/LICENSE +21 -0
  19. package/node_modules/@narumitw/pi-caffeinate/README.md +224 -0
  20. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +1112 -0
  21. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +7 -0
  22. package/node_modules/@narumitw/pi-caffeinate/package.json +53 -0
  23. package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +811 -0
  24. package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +153 -0
  25. package/node_modules/@narumitw/pi-caffeinate/src/index.ts +1 -0
  26. package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +43 -0
  27. package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +167 -0
  28. package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +202 -0
  29. package/node_modules/@tifan/pi-handoff/README.md +6 -3
  30. package/node_modules/@tifan/pi-handoff/package.json +1 -1
  31. package/node_modules/@tifan/pi-handoff/src/index.ts +8 -3
  32. package/node_modules/pi-mcp-adapter/CHANGELOG.md +27 -0
  33. package/node_modules/pi-mcp-adapter/README.md +13 -13
  34. package/node_modules/pi-mcp-adapter/commands.ts +58 -21
  35. package/node_modules/pi-mcp-adapter/config.ts +18 -4
  36. package/node_modules/pi-mcp-adapter/direct-tools.ts +26 -6
  37. package/node_modules/pi-mcp-adapter/dist/config.d.ts +4 -0
  38. package/node_modules/pi-mcp-adapter/dist/config.js +13 -4
  39. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  40. package/node_modules/pi-mcp-adapter/dist/types.d.ts +11 -3
  41. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  42. package/node_modules/pi-mcp-adapter/error-signal.ts +15 -4
  43. package/node_modules/pi-mcp-adapter/errors.ts +141 -0
  44. package/node_modules/pi-mcp-adapter/host-html-template.ts +58 -5
  45. package/node_modules/pi-mcp-adapter/index.ts +2 -3
  46. package/node_modules/pi-mcp-adapter/init.ts +5 -0
  47. package/node_modules/pi-mcp-adapter/mcp-panel.ts +30 -9
  48. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +66 -28
  49. package/node_modules/pi-mcp-adapter/mcp-status.ts +2 -0
  50. package/node_modules/pi-mcp-adapter/package.json +2 -1
  51. package/node_modules/pi-mcp-adapter/proxy-modes.ts +66 -13
  52. package/node_modules/pi-mcp-adapter/sandbox-proxy-template.ts +217 -0
  53. package/node_modules/pi-mcp-adapter/server-manager.ts +337 -6
  54. package/node_modules/pi-mcp-adapter/skills/mcp-scripting/SKILL.md +1 -0
  55. package/node_modules/pi-mcp-adapter/types.ts +18 -3
  56. package/node_modules/pi-mcp-adapter/ui-resource-handler.ts +18 -2
  57. package/node_modules/pi-mcp-adapter/ui-server.ts +179 -54
  58. package/node_modules/pi-mcp-adapter/ui-session.ts +28 -8
  59. package/package.json +11 -7
@@ -0,0 +1,153 @@
1
+ import type { Message, MessageBus } from "dbus-native";
2
+
3
+ export const SCREENSAVER_BUS_NAME = "org.freedesktop.ScreenSaver";
4
+ export const SCREENSAVER_INTERFACE = "org.freedesktop.ScreenSaver";
5
+ export const INHIBIT_REASON = "Pi agent is running";
6
+
7
+ const INHIBIT_APPLICATION_NAME = "pi-caffeinate";
8
+ const SCREENSAVER_OBJECT_PATHS = ["/org/freedesktop/ScreenSaver", "/ScreenSaver"];
9
+ const DBUS_CALL_TIMEOUT_MS = 2_000;
10
+
11
+ type DbusFailureHandler = (error: Error) => void;
12
+
13
+ export interface DbusScreenSaverClient {
14
+ setFailureHandler(handler: DbusFailureHandler | undefined): void;
15
+ inhibit(reason: string, signal?: AbortSignal): Promise<void>;
16
+ uninhibit(): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ export type DbusScreenSaverFactory = () => Promise<DbusScreenSaverClient>;
21
+
22
+ export async function defaultDbusScreenSaverFactory(): Promise<DbusScreenSaverClient> {
23
+ const { sessionBus } = await import("dbus-native");
24
+ return new NativeScreenSaverClient(sessionBus());
25
+ }
26
+
27
+ class NativeScreenSaverClient implements DbusScreenSaverClient {
28
+ private cookie?: number;
29
+ private objectPath?: string;
30
+ private failureHandler?: DbusFailureHandler;
31
+ private pendingFailure?: Error;
32
+ private lastConnectionError?: Error;
33
+ private closing = false;
34
+
35
+ private readonly handleConnectionError = (error: unknown) => {
36
+ this.lastConnectionError = toError(error, "D-Bus session connection failed");
37
+ if (this.cookie !== undefined) this.reportFailure(this.lastConnectionError);
38
+ };
39
+
40
+ private readonly handleConnectionClose = (error?: unknown) => {
41
+ if (this.closing || this.cookie === undefined) return;
42
+ this.reportFailure(
43
+ toError(error, this.lastConnectionError?.message ?? "D-Bus session connection closed"),
44
+ );
45
+ };
46
+
47
+ constructor(private readonly bus: MessageBus) {
48
+ this.bus.connection.on("error", this.handleConnectionError);
49
+ this.bus.connection.on("close", this.handleConnectionClose);
50
+ }
51
+
52
+ setFailureHandler(handler: DbusFailureHandler | undefined): void {
53
+ this.failureHandler = handler;
54
+ if (!handler || !this.pendingFailure) return;
55
+ const failure = this.pendingFailure;
56
+ this.pendingFailure = undefined;
57
+ handler(failure);
58
+ }
59
+
60
+ async inhibit(reason: string, signal?: AbortSignal): Promise<void> {
61
+ let lastError: unknown;
62
+ for (const objectPath of SCREENSAVER_OBJECT_PATHS) {
63
+ if (signal?.aborted) throw abortReason(signal);
64
+ try {
65
+ this.cookie = await invoke<number>(
66
+ this.bus,
67
+ {
68
+ destination: SCREENSAVER_BUS_NAME,
69
+ path: objectPath,
70
+ interface: SCREENSAVER_INTERFACE,
71
+ member: "Inhibit",
72
+ signature: "ss",
73
+ body: [INHIBIT_APPLICATION_NAME, reason],
74
+ },
75
+ signal,
76
+ );
77
+ this.objectPath = objectPath;
78
+ return;
79
+ } catch (error) {
80
+ if (signal?.aborted) throw abortReason(signal);
81
+ lastError = error;
82
+ }
83
+ }
84
+ throw new Error(`D-Bus idle inhibit failed: ${formatError(lastError)}`, {
85
+ cause: lastError,
86
+ });
87
+ }
88
+
89
+ async uninhibit(): Promise<void> {
90
+ if (this.cookie === undefined || !this.objectPath) return;
91
+ const cookie = this.cookie;
92
+ this.cookie = undefined;
93
+ const objectPath = this.objectPath;
94
+ this.objectPath = undefined;
95
+ await invoke<void>(this.bus, {
96
+ destination: SCREENSAVER_BUS_NAME,
97
+ path: objectPath,
98
+ interface: SCREENSAVER_INTERFACE,
99
+ member: "UnInhibit",
100
+ signature: "u",
101
+ body: [cookie],
102
+ });
103
+ }
104
+
105
+ async close(): Promise<void> {
106
+ this.closing = true;
107
+ this.cookie = undefined;
108
+ this.objectPath = undefined;
109
+ this.failureHandler = undefined;
110
+ try {
111
+ await this.bus.close();
112
+ } finally {
113
+ this.bus.connection.off("error", this.handleConnectionError);
114
+ this.bus.connection.off("close", this.handleConnectionClose);
115
+ }
116
+ }
117
+
118
+ private reportFailure(error: Error): void {
119
+ if (this.cookie === undefined) return;
120
+ this.cookie = undefined;
121
+ this.objectPath = undefined;
122
+ if (this.failureHandler) this.failureHandler(error);
123
+ else this.pendingFailure = error;
124
+ }
125
+ }
126
+
127
+ function invoke<TResult>(
128
+ bus: MessageBus,
129
+ message: Message,
130
+ signal?: AbortSignal,
131
+ ): Promise<TResult> {
132
+ return new Promise<TResult>((resolve, reject) => {
133
+ bus.invoke(message, { signal, timeout: DBUS_CALL_TIMEOUT_MS }, (error, ...values) => {
134
+ if (error) reject(error);
135
+ else resolve(values[0] as TResult);
136
+ });
137
+ });
138
+ }
139
+
140
+ function abortReason(signal: AbortSignal): unknown {
141
+ return signal.reason ?? new DOMException("D-Bus idle inhibit cancelled", "AbortError");
142
+ }
143
+
144
+ function toError(error: unknown, fallback: string): Error {
145
+ return error instanceof Error ? error : new Error(fallback);
146
+ }
147
+
148
+ function formatError(error: unknown) {
149
+ if (error instanceof Error) {
150
+ return error.name === "Error" ? error.message : `${error.name}: ${error.message}`;
151
+ }
152
+ return String(error);
153
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./caffeinate.js";
@@ -0,0 +1,43 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ import type { InhibitorCommand } from "./inhibitors.js";
4
+
5
+ export function startInhibitorProcess(
6
+ command: InhibitorCommand,
7
+ onError: (error: Error) => void,
8
+ onUnexpectedExit: (description: string) => void,
9
+ ): ChildProcess {
10
+ const child = spawn(command.command, command.args, {
11
+ detached: false,
12
+ stdio: [command.releaseOnStdinClose ? "pipe" : "ignore", "pipe", "pipe"],
13
+ });
14
+ child.once("error", onError);
15
+ child.once("exit", (code, signal) => onUnexpectedExit(formatExit(code, signal)));
16
+ return child;
17
+ }
18
+
19
+ export function stopInhibitorProcess(child: ChildProcess, command: InhibitorCommand | undefined) {
20
+ child.removeAllListeners("exit");
21
+ child.removeAllListeners("error");
22
+ if (child.killed) return;
23
+
24
+ if (command?.releaseOnStdinClose && child.stdin && !child.stdin.destroyed) {
25
+ child.stdin.end();
26
+ if (child.exitCode === null && child.signalCode === null) {
27
+ const killTimer = setTimeout(() => {
28
+ if (child.exitCode === null && child.signalCode === null && !child.killed) child.kill();
29
+ }, 2000);
30
+ killTimer.unref();
31
+ child.once("exit", () => clearTimeout(killTimer));
32
+ }
33
+ } else if (process.platform === "win32") {
34
+ child.kill();
35
+ } else {
36
+ child.kill("SIGTERM");
37
+ }
38
+ }
39
+
40
+ function formatExit(code: number | null, signal: NodeJS.Signals | null) {
41
+ if (signal) return `signal ${signal}`;
42
+ return `code ${code ?? "unknown"}`;
43
+ }
@@ -0,0 +1,167 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import type { CaffeinateMode } from "./settings.js";
5
+
6
+ export interface InhibitorCommand {
7
+ command: string;
8
+ args: string[];
9
+ description: string;
10
+ releaseOnStdinClose?: boolean;
11
+ custom?: boolean;
12
+ /** Display mode also needs D-Bus because logind idle inhibition misses some compositors. */
13
+ addDbusIdleInhibit?: boolean;
14
+ }
15
+
16
+ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
17
+ const customCommand = process.env.PI_CAFFEINATE_COMMAND?.trim();
18
+ if (customCommand) {
19
+ const [command, ...args] = splitCommand(customCommand);
20
+ if (command) return { command, args, description: `custom command (${command})`, custom: true };
21
+ }
22
+ if (process.platform === "darwin") {
23
+ return parentBoundUnixCommand(
24
+ "caffeinate",
25
+ macCaffeinateArgs(mode),
26
+ caffeinateDescription(mode),
27
+ );
28
+ }
29
+ if (process.platform === "linux") {
30
+ if (isWsl() && commandExists("powershell.exe")) {
31
+ return windowsPowerInhibitorCommand("powershell.exe", mode);
32
+ }
33
+ if (commandExists("systemd-inhibit")) {
34
+ const what = mode === "display" ? "idle:sleep" : "sleep";
35
+ return parentBoundUnixCommand(
36
+ "systemd-inhibit",
37
+ [
38
+ `--what=${what}`,
39
+ "--who=pi-caffeinate",
40
+ "--why=Pi agent is running",
41
+ "--mode=block",
42
+ "sleep",
43
+ "infinity",
44
+ ],
45
+ `systemd-inhibit (${formatMode(mode)})`,
46
+ mode === "display",
47
+ );
48
+ }
49
+ if (commandExists("caffeinate")) {
50
+ return parentBoundUnixCommand(
51
+ "caffeinate",
52
+ macCaffeinateArgs(mode),
53
+ caffeinateDescription(mode),
54
+ mode === "display",
55
+ );
56
+ }
57
+ }
58
+ if (process.platform === "win32") return windowsPowerInhibitorCommand("powershell.exe", mode);
59
+ return undefined;
60
+ }
61
+
62
+ function macCaffeinateArgs(mode: CaffeinateMode) {
63
+ return mode === "sleep" ? ["-ims"] : ["-dimsu"];
64
+ }
65
+
66
+ function caffeinateDescription(mode: CaffeinateMode) {
67
+ return `caffeinate (${formatMode(mode)})`;
68
+ }
69
+
70
+ function parentBoundUnixCommand(
71
+ command: string,
72
+ args: string[],
73
+ description: string,
74
+ addDbusIdleInhibit = false,
75
+ ): InhibitorCommand {
76
+ return {
77
+ command: "sh",
78
+ args: [
79
+ "-c",
80
+ unixParentBoundScript(),
81
+ "pi-caffeinate-watch",
82
+ String(process.pid),
83
+ command,
84
+ ...args,
85
+ ],
86
+ description,
87
+ ...(addDbusIdleInhibit ? { addDbusIdleInhibit: true } : {}),
88
+ };
89
+ }
90
+
91
+ function unixParentBoundScript() {
92
+ return `parent=$1; shift; "$@" & child=$!; ( while kill -0 "$parent" 2>/dev/null; do sleep 5; done; kill "$child" 2>/dev/null ) & watcher=$!; cleanup() { kill "$watcher" 2>/dev/null; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; }; trap 'cleanup; exit 0' INT TERM HUP EXIT; wait "$child"; status=$?; kill "$watcher" 2>/dev/null; trap - EXIT; exit "$status"`;
93
+ }
94
+
95
+ function commandExists(command: string) {
96
+ const searchPath = process.env.PATH ?? "";
97
+ const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
98
+ for (const directory of searchPath.split(process.platform === "win32" ? ";" : ":")) {
99
+ if (!directory) continue;
100
+ for (const extension of extensions) {
101
+ if (existsSync(path.join(directory, `${command}${extension}`))) return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
107
+ export function splitCommand(input: string) {
108
+ const parts: string[] = [];
109
+ let current = "";
110
+ let quote: '"' | "'" | undefined;
111
+ let escaping = false;
112
+ for (const char of input) {
113
+ if (escaping) {
114
+ current += char;
115
+ escaping = false;
116
+ continue;
117
+ }
118
+ if (char === "\\") {
119
+ escaping = true;
120
+ continue;
121
+ }
122
+ if ((char === '"' || char === "'") && !quote) {
123
+ quote = char;
124
+ continue;
125
+ }
126
+ if (char === quote) {
127
+ quote = undefined;
128
+ continue;
129
+ }
130
+ if (/\s/.test(char) && !quote) {
131
+ if (current) {
132
+ parts.push(current);
133
+ current = "";
134
+ }
135
+ continue;
136
+ }
137
+ current += char;
138
+ }
139
+ if (current) parts.push(current);
140
+ return parts;
141
+ }
142
+
143
+ function windowsPowerInhibitorCommand(command: string, mode: CaffeinateMode): InhibitorCommand {
144
+ return {
145
+ command,
146
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsInhibitorScript(mode)],
147
+ description: `PowerShell SetThreadExecutionState (${formatMode(mode)})`,
148
+ releaseOnStdinClose: true,
149
+ };
150
+ }
151
+
152
+ export function windowsInhibitorScript(mode: CaffeinateMode) {
153
+ const flags = mode === "sleep" ? "0x80000001" : "0x80000003";
154
+ return `$ErrorActionPreference = 'Stop'; Add-Type -Namespace Native -Name Power -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);'; $flags = [uint32]'${flags}'; $release = [uint32]'0x80000000'; $stdin = [Console]::OpenStandardInput(); $buffer = New-Object byte[] 1; $readTask = $stdin.ReadAsync($buffer, 0, 1); try { while ($true) { [Native.Power]::SetThreadExecutionState($flags) | Out-Null; if ($readTask.Wait(30000)) { break } } } finally { [Native.Power]::SetThreadExecutionState($release) | Out-Null }`;
155
+ }
156
+
157
+ function isWsl() {
158
+ try {
159
+ return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ export function formatMode(mode: CaffeinateMode) {
166
+ return mode === "sleep" ? "system-awake" : "display-awake";
167
+ }
@@ -0,0 +1,202 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import process from "node:process";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
8
+ const NEW_SETTINGS_FILE = "pi-caffeinate.json";
9
+ const LEGACY_SETTINGS_FILE = "pi-caffeinate-settings.json";
10
+
11
+ export type CaffeinateMode = "sleep" | "display";
12
+
13
+ export interface CaffeinateSettings {
14
+ mode: CaffeinateMode;
15
+ quiet: boolean;
16
+ updatedAt: number;
17
+ }
18
+
19
+ export interface SettingsFileOperations {
20
+ write(path: string, data: string): Promise<void>;
21
+ rename(source: string, destination: string): Promise<void>;
22
+ }
23
+
24
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
25
+ write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
26
+ rename,
27
+ };
28
+
29
+ export type SettingsLoadResult =
30
+ | { kind: "missing"; notice?: string }
31
+ | { kind: "invalid"; reason: string; notice?: string }
32
+ | { kind: "loaded"; settings: CaffeinateSettings; notice?: string };
33
+
34
+ export async function loadSettings(): Promise<SettingsLoadResult> {
35
+ await settingsSaveQueue;
36
+ const newPath = settingsFilePath();
37
+ const newSettings = await readSettingsFile(newPath);
38
+ if (newSettings.kind !== "missing") return withLegacyIgnoredNotice(newSettings);
39
+
40
+ const legacyPath = legacySettingsFilePath();
41
+ const legacySettings = await readSettingsFile(legacyPath);
42
+ const concurrentlyCreatedSettings = await readSettingsFile(newPath);
43
+ if (concurrentlyCreatedSettings.kind !== "missing") {
44
+ return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
45
+ }
46
+ if (legacySettings.kind === "missing") return { kind: "missing" };
47
+ if (legacySettings.kind === "invalid") return legacySettings;
48
+
49
+ return {
50
+ ...legacySettings,
51
+ notice: `Using legacy ${LEGACY_SETTINGS_FILE}; rename it to ${NEW_SETTINGS_FILE}. Future saves write ${NEW_SETTINGS_FILE} without modifying the legacy file.`,
52
+ };
53
+ }
54
+
55
+ interface SettingsDocumentResult {
56
+ result: SettingsLoadResult;
57
+ document?: Record<string, unknown>;
58
+ }
59
+
60
+ async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
61
+ let text: string;
62
+ try {
63
+ text = await readFile(filePath, "utf8");
64
+ } catch (error) {
65
+ if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
66
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
67
+ }
68
+ try {
69
+ const document = JSON.parse(text) as unknown;
70
+ const settings = normalizeCaffeinateSettings(document);
71
+ if (settings) {
72
+ return {
73
+ result: { kind: "loaded", settings },
74
+ document: { ...(document as Record<string, unknown>) },
75
+ };
76
+ }
77
+ return {
78
+ result: {
79
+ kind: "invalid",
80
+ reason: `${filePath}: expected { "mode": "sleep" | "display", optional "quiet": boolean }`,
81
+ },
82
+ };
83
+ } catch (error) {
84
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
85
+ }
86
+ }
87
+
88
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
89
+ return (await readSettingsDocument(filePath)).result;
90
+ }
91
+
92
+ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
93
+ if (!(await fileExists(legacySettingsFilePath()))) return settings;
94
+ return {
95
+ ...settings,
96
+ notice: `pi-caffeinate legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
97
+ };
98
+ }
99
+
100
+ async function fileExists(filePath: string) {
101
+ try {
102
+ await access(filePath, constants.F_OK);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ async function pathEntryExists(filePath: string) {
110
+ try {
111
+ await lstat(filePath);
112
+ return true;
113
+ } catch (error) {
114
+ if (isNodeError(error) && error.code === "ENOENT") return false;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
120
+ if (!value || typeof value !== "object") return undefined;
121
+ const settings = value as { mode?: unknown; quiet?: unknown; updatedAt?: unknown };
122
+ if (!isCaffeinateMode(settings.mode)) return undefined;
123
+ if (settings.quiet !== undefined && typeof settings.quiet !== "boolean") return undefined;
124
+ if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
125
+ return {
126
+ mode: settings.mode,
127
+ quiet: settings.quiet ?? false,
128
+ updatedAt: settings.updatedAt ?? 0,
129
+ };
130
+ }
131
+
132
+ function isCaffeinateMode(value: unknown): value is CaffeinateMode {
133
+ return value === "sleep" || value === "display";
134
+ }
135
+
136
+ let settingsSaveQueue: Promise<unknown> = Promise.resolve();
137
+
138
+ export function saveSettings(
139
+ settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
140
+ operations: Partial<SettingsFileOperations> = {},
141
+ ): Promise<CaffeinateSettings> {
142
+ const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
143
+ settingsSaveQueue = operation.catch(() => undefined);
144
+ return operation;
145
+ }
146
+
147
+ async function saveSettingsNow(
148
+ settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
149
+ operations: Partial<SettingsFileOperations>,
150
+ ): Promise<CaffeinateSettings> {
151
+ const filePath = settingsFilePath();
152
+ let current = await readSettingsDocument(filePath);
153
+ const replaceCanonical = current.result.kind !== "missing";
154
+ if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
155
+ if (current.result.kind === "invalid") {
156
+ throw new Error(`Cannot save pi-caffeinate settings until you repair ${current.result.reason}`);
157
+ }
158
+ const document = current.document ?? {};
159
+ const quiet =
160
+ settings.quiet ?? (current.result.kind === "loaded" ? current.result.settings.quiet : false);
161
+ const nextSettings = { mode: settings.mode, quiet, updatedAt: settings.updatedAt };
162
+ const nextDocument = {
163
+ ...document,
164
+ ...nextSettings,
165
+ };
166
+ await mkdir(dirname(filePath), { recursive: true });
167
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
168
+ try {
169
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
170
+ tempFile,
171
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
172
+ );
173
+ if (!replaceCanonical && (await pathEntryExists(filePath))) {
174
+ throw new Error(`${NEW_SETTINGS_FILE} was created concurrently; reopen settings and retry.`);
175
+ }
176
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
177
+ return nextSettings;
178
+ } catch (error) {
179
+ await rm(tempFile, { force: true }).catch(() => undefined);
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ export function settingsFilePath() {
185
+ return join(agentDir(), NEW_SETTINGS_FILE);
186
+ }
187
+
188
+ function legacySettingsFilePath() {
189
+ return join(agentDir(), LEGACY_SETTINGS_FILE);
190
+ }
191
+
192
+ function agentDir() {
193
+ return getAgentDir();
194
+ }
195
+
196
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
197
+ return error instanceof Error && "code" in error;
198
+ }
199
+
200
+ function formatError(error: unknown) {
201
+ return error instanceof Error ? error.message : String(error);
202
+ }
@@ -30,7 +30,7 @@ The marker is consumed before the prompt reaches the agent.
30
30
  - The focus is used in the handoff document.
31
31
  - A handoff document is written under the OS temp directory.
32
32
  - A new session starts automatically from the document.
33
- - When Pi runs inside Herdr, the handoff opens in a new focused tab. The current parent session stays available in its original tab.
33
+ - When Pi runs inside Herdr, the handoff opens in a new tab without changing focus. The current parent session stays available in its original tab.
34
34
  - The handoff session keeps the parent-session link and selected provider/model when available.
35
35
  - Outside Herdr, the current session is replaced.
36
36
 
@@ -45,9 +45,12 @@ Handoff uses the same naming logic as `/rename`:
45
45
  - Uses the first user message and up to three latest user messages.
46
46
  - Reads the model from `$PI_CODING_AGENT_DIR/extensions/pi-rename.json`.
47
47
  - Uses `openai-codex/gpt-5.6-luna` when no config exists.
48
- - Produces lowercase, hyphen-separated names of up to 30 characters.
48
+ - Produces a name such as `[handoff] fix-auth-callback`.
49
+ - Keeps the generated base name under 30 characters before adding the prefix.
49
50
  - Falls back to the latest user message if the naming model is unavailable.
50
- - Uses `handoff-session` when no usable user text exists.
51
+ - Uses `[handoff] handoff-session` when no usable user text exists.
52
+
53
+ The handoff artifact filename uses the unprefixed base name. If you run `/rename` in the new session, it replaces the complete name and can remove the `[handoff]` prefix.
51
54
 
52
55
  Install `@tifan/pi-rename` separately only if you want its `/rename` and `/rename config` commands.
53
56
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tifan/pi-handoff",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Transfer pi session context to a new session and query past sessions.",
5
5
  "keywords": [
6
6
  "handoff",
@@ -26,6 +26,11 @@ import { join } from "node:path"
26
26
 
27
27
  const HANDOFF_SKILL_COMMAND = "skill:handoff"
28
28
  const HANDOFF_BRIDGE_COMMAND = "__pi-handoff-session"
29
+ export const HANDOFF_SESSION_PREFIX = "[handoff] "
30
+
31
+ export function formatHandoffSessionName(baseName: string): string {
32
+ return `${HANDOFF_SESSION_PREFIX}${baseName}`
33
+ }
29
34
 
30
35
  const HANDOFF_SYSTEM_PROMPT = `You are a context transfer assistant. Generate a handoff markdown document for a fresh coding agent.
31
36
 
@@ -170,7 +175,6 @@ async function startHandoffInHerdr(options: {
170
175
  options.ctx.cwd,
171
176
  "--label",
172
177
  options.sessionName,
173
- "--focus",
174
178
  ],
175
179
  { timeout: 5_000 },
176
180
  )
@@ -394,8 +398,9 @@ async function runHandoff(
394
398
  return
395
399
  }
396
400
 
397
- const sessionName = await generateSessionName(ctx, messages)
398
- const handoffPath = await nextHandoffPath(sessionName)
401
+ const baseSessionName = await generateSessionName(ctx, messages)
402
+ const sessionName = formatHandoffSessionName(baseSessionName)
403
+ const handoffPath = await nextHandoffPath(baseSessionName)
399
404
  const document = buildDocumentWithMetadata({
400
405
  generated,
401
406
  focus,
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.32.1] - 2026-09-01
11
+
12
+ ### Fixed
13
+ - The published package now includes the updated public helper build artifacts for the `2.32.0` config and MCP Apps changes.
14
+
15
+ ## [2.32.0] - 2026-09-01
16
+
17
+ ### Highlights
18
+ - You can now enable and disable MCP servers directly from the `/mcp` panel.
19
+ - `/mcp setup` is clearer about where new shared servers will be saved.
20
+ - MCP App views that use browser storage now render reliably without exposing host session access.
21
+ - Long-running sessions handle MCP 2026 input flows, catalog updates, and UI resource refreshes more reliably.
22
+ - OAuth reuse and per-server status messages are less confusing.
23
+
24
+ ### Added
25
+ - The `/mcp` panel now supports enabling and disabling servers in place with `ctrl+d` on a server row. Saving persists the `disabled` flag to the project Pi layer and reloads the session, matching `/mcp disable` and `/mcp enable`. Thanks to [@ericykim](https://github.com/ericykim) for PR #479.
26
+
27
+ ### Changed
28
+ - `/mcp setup` now lets you choose project `.mcp.json` or global `~/.config/mcp/mcp.json` as the write target for new shared MCP servers, while keeping Pi-owned files and compatibility inputs in the advanced flow. The bundled `mcp-scripting` skill is manual-only by default. Thanks to [@w-winter](https://github.com/w-winter) for #477.
29
+
30
+ ### Fixed
31
+ - MCP 2026 multi-round input flows now work more reliably across proxy, direct, resource, and UI-resource calls, with clearer no-UI errors and cancellation cleanup.
32
+ - MCP 2026-07-28 catalog listens now recover from dropped listens, refresh quietly when catalogs change, and notify open UIs when resources update. (#468)
33
+ - MCP App views now load through a separate loopback sandbox proxy origin so storage APIs work without exposing host session capabilities. Thanks to [@drewbitt](https://github.com/drewbitt) for #480.
34
+ - Implicit OAuth now reuses URL-bound stored credentials while preserving anonymous fallback. Thanks to [@wilt00](https://github.com/wilt00) for #471.
35
+ - Per-server proxy lists now distinguish cached lazy tools from servers that need authentication while preserving active failure backoff. Thanks to [@inattendu](https://github.com/inattendu) for PR #474.
36
+
10
37
  ## [2.31.0] - 2026-08-28
11
38
 
12
39
  ### Highlights