@zigai/pi-mode 0.1.2

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.
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
6
+
7
+ let cachedShowModeName: boolean | undefined;
8
+ let cachedSettingsMtimeMs: number | null | undefined;
9
+
10
+ function getSettingsPath(): string {
11
+ return join(homedir(), ".pi", "agent", "settings.json");
12
+ }
13
+
14
+ function getSettingsMtimeMs(): number | null {
15
+ try {
16
+ if (!existsSync(getSettingsPath())) return null;
17
+ return statSync(getSettingsPath()).mtimeMs;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function readSettingsObject(): Record<string, unknown> {
24
+ try {
25
+ const raw = readFileSync(getSettingsPath(), "utf8");
26
+ const parsed = JSON.parse(raw) as unknown;
27
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
28
+ return { ...parsed };
29
+ }
30
+ } catch {
31
+ // Ignore malformed or missing settings files and fall back to defaults.
32
+ }
33
+
34
+ return {};
35
+ }
36
+
37
+ export function shouldShowModeName(): boolean {
38
+ const mtimeMs = getSettingsMtimeMs();
39
+ if (cachedShowModeName !== undefined && cachedSettingsMtimeMs === mtimeMs) {
40
+ return cachedShowModeName;
41
+ }
42
+
43
+ const settings = readSettingsObject();
44
+ cachedSettingsMtimeMs = mtimeMs;
45
+ cachedShowModeName = settings[SHOW_MODE_NAME_SETTINGS_KEY] === true;
46
+ return cachedShowModeName;
47
+ }
48
+
49
+ export function setShowModeName(show: boolean): void {
50
+ const settingsPath = getSettingsPath();
51
+ const settings = readSettingsObject();
52
+ settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
53
+
54
+ mkdirSync(dirname(settingsPath), { recursive: true });
55
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
56
+
57
+ cachedSettingsMtimeMs = getSettingsMtimeMs();
58
+ cachedShowModeName = show;
59
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export function expandUserPath(value: string): string {
6
+ if (value === "~") return os.homedir();
7
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
8
+ return value;
9
+ }
10
+
11
+ export function getGlobalAgentDir(): string {
12
+ const env = process.env.PI_CODING_AGENT_DIR;
13
+ if (env !== undefined && env.length > 0) return expandUserPath(env);
14
+ return path.join(os.homedir(), ".pi", "agent");
15
+ }
16
+
17
+ export function getGlobalModesPath(): string {
18
+ return path.join(getGlobalAgentDir(), "modes.json");
19
+ }
20
+
21
+ export function getProjectModesPath(cwd: string): string {
22
+ return path.join(cwd, ".pi", "modes.json");
23
+ }
24
+
25
+ export async function fileExists(filePath: string): Promise<boolean> {
26
+ try {
27
+ await fs.stat(filePath);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function ensureDirForFile(filePath: string): Promise<void> {
35
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
36
+ }
37
+
38
+ export async function getMtimeMs(filePath: string): Promise<number | null> {
39
+ try {
40
+ const stat = await fs.stat(filePath);
41
+ return stat.mtimeMs;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function sleep(ms: number): Promise<void> {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ function getErrorCode(error: unknown): string | undefined {
52
+ if (!(error instanceof Error)) return undefined;
53
+ const code = (error as NodeJS.ErrnoException).code;
54
+ if (typeof code === "string") return code;
55
+ return undefined;
56
+ }
57
+
58
+ function throwError(error: unknown): never {
59
+ if (error instanceof Error) throw error;
60
+ throw new Error(String(error));
61
+ }
62
+
63
+ function getLockPathForFile(filePath: string): string {
64
+ return `${filePath}.lock`;
65
+ }
66
+
67
+ export async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
68
+ const lockPath = getLockPathForFile(filePath);
69
+ await ensureDirForFile(lockPath);
70
+
71
+ const start = Date.now();
72
+ while (true) {
73
+ try {
74
+ const handle = await fs.open(lockPath, "wx");
75
+ try {
76
+ await handle.writeFile(
77
+ JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) +
78
+ "\n",
79
+ "utf8",
80
+ );
81
+ } catch {
82
+ // ignore best-effort lock metadata
83
+ }
84
+
85
+ try {
86
+ return await fn();
87
+ } finally {
88
+ await handle.close().catch(() => {});
89
+ await fs.unlink(lockPath).catch(() => {});
90
+ }
91
+ } catch (error: unknown) {
92
+ if (getErrorCode(error) !== "EEXIST") throwError(error);
93
+
94
+ try {
95
+ const stat = await fs.stat(lockPath);
96
+ if (Date.now() - stat.mtimeMs > 30_000) {
97
+ await fs.unlink(lockPath);
98
+ continue;
99
+ }
100
+ } catch {
101
+ // ignore stale-lock checks
102
+ }
103
+
104
+ if (Date.now() - start > 5_000) {
105
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
106
+ }
107
+ await sleep(40 + Math.random() * 80);
108
+ }
109
+ }
110
+ }
111
+
112
+ export async function atomicWriteUtf8(filePath: string, content: string): Promise<void> {
113
+ await ensureDirForFile(filePath);
114
+
115
+ const dir = path.dirname(filePath);
116
+ const base = path.basename(filePath);
117
+ const tempPath = path.join(
118
+ dir,
119
+ `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`,
120
+ );
121
+
122
+ await fs.writeFile(tempPath, content, "utf8");
123
+
124
+ try {
125
+ await fs.rename(tempPath, filePath);
126
+ } catch (error: unknown) {
127
+ const code = getErrorCode(error);
128
+ if (code === "EEXIST" || code === "EPERM") {
129
+ await fs.unlink(filePath).catch(() => {});
130
+ await fs.rename(tempPath, filePath);
131
+ } else {
132
+ await fs.unlink(tempPath).catch(() => {});
133
+ throwError(error);
134
+ }
135
+ }
136
+ }
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
+
4
+ export type ModeName = string;
5
+
6
+ export type ModeSpec = {
7
+ provider?: string;
8
+ modelId?: string;
9
+ thinkingLevel?: ThinkingLevel;
10
+ /**
11
+ * Optional theme color token to use for the editor border.
12
+ * If unset, the border color is derived from the current thinking level.
13
+ */
14
+ color?: ThemeColor;
15
+ };
16
+
17
+ export type ModesFile = {
18
+ version: 1;
19
+ currentMode: ModeName;
20
+ modes: Record<ModeName, ModeSpec>;
21
+ };
22
+
23
+ export type ModeSpecPatch = {
24
+ provider?: string | null;
25
+ modelId?: string | null;
26
+ thinkingLevel?: ThinkingLevel | null;
27
+ color?: ThemeColor | null;
28
+ };
29
+
30
+ export type ModesPatch = {
31
+ currentMode?: ModeName;
32
+ modes?: Record<ModeName, ModeSpecPatch | null>;
33
+ };
34
+
35
+ export type ModeRuntime = {
36
+ filePath: string;
37
+ fileMtimeMs: number | null;
38
+ /**
39
+ * Snapshot of what we last loaded or synced from disk. Used to compute patches
40
+ * so multiple running pi processes do not clobber each other's edits.
41
+ */
42
+ baseline: ModesFile | null;
43
+ data: ModesFile;
44
+ /**
45
+ * Last non-overlay mode. Used as cycle base while in the overlay custom mode.
46
+ */
47
+ lastRealMode: string;
48
+ /**
49
+ * The effective current mode. Can temporarily be the overlay custom mode,
50
+ * which is not persisted and not selectable via /mode.
51
+ */
52
+ currentMode: string;
53
+ applying: boolean;
54
+ };