pi-monofold 0.10.0 → 0.11.1

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.
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
8
  [![Pi Package](https://img.shields.io/badge/Pi-package-6f42c1)](https://github.com/eiei114/pi-monofold)
9
9
  [![Trusted Publishing](https://img.shields.io/badge/npm-provenance-yellow)](https://docs.npmjs.com/generating-provenance-statements)
10
+ <a href="https://buymeacoffee.com/ekawano114m"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="217" height="60"></a>
10
11
 
11
12
  Pi extension that folds multiple local repositories and folders into a guarded **Virtual Monorepo** for AI agents.
12
13
 
@@ -86,7 +87,7 @@ pi -e npm:pi-monofold
86
87
  1. Install the extension (see [Install](#install)).
87
88
  2. In your control repository, create `.pi/monofold.yaml` with at least one workspace entry (or run `/monofold:init`).
88
89
  3. Start Pi in the control repository and run `/monofold:explore show the project workspaces`.
89
- 4. Use `/monofold:focus`, `ctrl+shift+m`, or `shift+ctrl+f` to switch focus presets when `focusPresets` are configured.
90
+ 4. Use `/monofold:focus`, `ctrl+shift+m`, or `shift+ctrl+f` to switch focus presets when `focusPresets` are configured. Active Focus is restored automatically on the next Pi session in the same control repository.
90
91
  5. Use `/monofold:write` for routed Markdown outputs and `/monofold:git` for guarded git workflows.
91
92
 
92
93
  Example command flows: [docs/examples.md](./docs/examples.md).
package/focus-preset.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type FocusSessionLoadResult } from "./focus-session-state.js";
1
2
  import { type MonofoldRouteType, parseDefaultRouteOverride } from "./focus-route-override.js";
2
3
  import {
3
4
  parseDecisionNoteDestination,
@@ -213,14 +214,59 @@ export function warnZeroTargetMatchesForPreset(
213
214
  }
214
215
  }
215
216
 
217
+ export type ActiveFocusInitSource = "restored" | "default" | "stale" | "cleared" | "malformed";
218
+
216
219
  let activeFocusPresetId: string | null = null;
217
220
  let activeFocusInitialized = false;
221
+ let activeFocusInitSource: ActiveFocusInitSource | null = null;
218
222
 
219
- /** Initializes the active focus preset once per process/session. */
220
- export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
223
+ /** Returns how the current session initialized Active Focus, when applicable. */
224
+ export function getActiveFocusInitSource(): ActiveFocusInitSource | null {
225
+ return activeFocusInitSource;
226
+ }
227
+
228
+ /** Returns true after Active Focus has been initialized for this Pi session. */
229
+ export function isActiveFocusSessionInitialized(): boolean {
230
+ return activeFocusInitialized;
231
+ }
232
+
233
+ function clearActiveFocusInitSource(): void {
234
+ activeFocusInitSource = null;
235
+ }
236
+
237
+ /** Applies persisted or default Active Focus once per Pi session. */
238
+ export function applyActiveFocusSession(
239
+ focusPresets: FocusPreset[] | undefined,
240
+ persisted: FocusSessionLoadResult = { kind: "missing" },
241
+ ): void {
221
242
  if (activeFocusInitialized) return;
222
243
  activeFocusInitialized = true;
223
- activeFocusPresetId = pickDefaultFocusPresetId(focusPresets);
244
+ const presets = focusPresets ?? [];
245
+
246
+ if (persisted.kind === "loaded") {
247
+ const savedId = persisted.activeFocusPresetId;
248
+ if (savedId === null) {
249
+ activeFocusPresetId = null;
250
+ activeFocusInitSource = "cleared";
251
+ return;
252
+ }
253
+ if (findFocusPresetById(presets, savedId)) {
254
+ activeFocusPresetId = savedId;
255
+ activeFocusInitSource = "restored";
256
+ return;
257
+ }
258
+ activeFocusPresetId = pickDefaultFocusPresetId(presets);
259
+ activeFocusInitSource = "stale";
260
+ return;
261
+ }
262
+
263
+ activeFocusPresetId = pickDefaultFocusPresetId(presets);
264
+ activeFocusInitSource = persisted.kind === "malformed" ? "malformed" : "default";
265
+ }
266
+
267
+ /** Initializes the active focus preset once per process/session. */
268
+ export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
269
+ applyActiveFocusSession(focusPresets, { kind: "missing" });
224
270
  }
225
271
 
226
272
  /** Returns the current active focus preset id, if any. */
@@ -235,6 +281,7 @@ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] |
235
281
  }
236
282
  activeFocusPresetId = id;
237
283
  activeFocusInitialized = true;
284
+ clearActiveFocusInitSource();
238
285
  resetFocusSkillsWarningState();
239
286
  resetDecisionNoteWarningState();
240
287
  }
@@ -315,6 +362,7 @@ export function cycleActiveFocusPresetBackward(focusPresets: FocusPreset[] | und
315
362
  export function clearActiveFocusPresetId(): void {
316
363
  activeFocusPresetId = null;
317
364
  activeFocusInitialized = true;
365
+ clearActiveFocusInitSource();
318
366
  resetFocusSkillsWarningState();
319
367
  resetDecisionNoteWarningState();
320
368
  }
@@ -323,6 +371,7 @@ export function clearActiveFocusPresetId(): void {
323
371
  export function resetActiveFocusSessionState(): void {
324
372
  activeFocusPresetId = null;
325
373
  activeFocusInitialized = false;
374
+ activeFocusInitSource = null;
326
375
  resetFocusSkillsWarningState();
327
376
  resetDecisionNoteWarningState();
328
377
  }
@@ -0,0 +1,58 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { normalizeGuardPath } from "./path-normalize.js";
4
+
5
+ export const FOCUS_SESSION_STATE_RELATIVE_PATH = path.join(".pi", "monofold-focus-session.json");
6
+
7
+ export type FocusSessionStateFile = {
8
+ version: 1;
9
+ activeFocusPresetId: string | null;
10
+ };
11
+
12
+ export type FocusSessionLoadResult =
13
+ | { kind: "missing" }
14
+ | { kind: "malformed"; error: string }
15
+ | { kind: "loaded"; activeFocusPresetId: string | null };
16
+
17
+ function assertFocusSessionStateFile(value: unknown): asserts value is FocusSessionStateFile {
18
+ if (typeof value !== "object" || value === null) throw new Error("focus session state file must be an object");
19
+ if (!("version" in value) || (value as { version?: unknown }).version !== 1) {
20
+ throw new Error("focus session state file requires version: 1");
21
+ }
22
+ const activeFocusPresetId = (value as { activeFocusPresetId?: unknown }).activeFocusPresetId;
23
+ if (activeFocusPresetId !== null && typeof activeFocusPresetId !== "string") {
24
+ throw new Error("focus session state file requires string|null activeFocusPresetId");
25
+ }
26
+ if (typeof activeFocusPresetId === "string" && activeFocusPresetId.trim() === "") {
27
+ throw new Error("focus session state file activeFocusPresetId must be non-empty when set");
28
+ }
29
+ }
30
+
31
+ export function resolveFocusSessionStatePath(root: string): string {
32
+ return path.join(normalizeGuardPath(root), FOCUS_SESSION_STATE_RELATIVE_PATH);
33
+ }
34
+
35
+ export async function loadFocusSessionState(root: string): Promise<FocusSessionLoadResult> {
36
+ const filePath = resolveFocusSessionStatePath(root);
37
+ try {
38
+ const text = await readFile(filePath, "utf8");
39
+ const parsed = JSON.parse(text) as unknown;
40
+ assertFocusSessionStateFile(parsed);
41
+ return { kind: "loaded", activeFocusPresetId: parsed.activeFocusPresetId };
42
+ } catch (error) {
43
+ const code = typeof error === "object" && error && "code" in error ? (error as { code?: string }).code : undefined;
44
+ if (code === "ENOENT") return { kind: "missing" };
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ return { kind: "malformed", error: message };
47
+ }
48
+ }
49
+
50
+ export async function saveFocusSessionState(root: string, activeFocusPresetId: string | null): Promise<void> {
51
+ const filePath = resolveFocusSessionStatePath(root);
52
+ await mkdir(path.dirname(filePath), { recursive: true });
53
+ const payload: FocusSessionStateFile = {
54
+ version: 1,
55
+ activeFocusPresetId,
56
+ };
57
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
58
+ }
package/index.ts CHANGED
@@ -6,13 +6,15 @@ import path from "node:path";
6
6
  import YAML from "yaml";
7
7
  import {
8
8
  type ActiveFocusPresetPosition,
9
+ applyActiveFocusSession,
9
10
  cycleActiveFocusPresetBackward,
10
11
  cycleActiveFocusPresetForward,
11
12
  type FocusPreset,
12
- ensureActiveFocusInitialized,
13
13
  findFocusPresetById,
14
+ getActiveFocusInitSource,
14
15
  getActiveFocusPresetId,
15
16
  getActiveFocusPresetPosition,
17
+ isActiveFocusSessionInitialized,
16
18
  biasMatchesTowardActiveFocus,
17
19
  isTagBasedTargetInference,
18
20
  matchesFocusTarget,
@@ -42,6 +44,11 @@ import {
42
44
  readMonofoldFile,
43
45
  runMonofoldSearch,
44
46
  } from "./monofold-read-ops.js";
47
+ import {
48
+ FOCUS_SESSION_STATE_RELATIVE_PATH,
49
+ loadFocusSessionState,
50
+ saveFocusSessionState,
51
+ } from "./focus-session-state.js";
45
52
  import {
46
53
  clearUnknownPathAllows,
47
54
  loadUnknownPathAllows,
@@ -919,8 +926,16 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
919
926
  sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
920
927
  }
921
928
 
929
+ function formatActiveFocusInitSourceSuffix(): string {
930
+ const source = getActiveFocusInitSource();
931
+ if (source === "restored") return " [restored]";
932
+ if (source === "stale") return " [stale save, using default]";
933
+ if (source === "malformed") return " [using default]";
934
+ return "";
935
+ }
936
+
922
937
  function formatFocusStatus(position: ActiveFocusPresetPosition): string {
923
- const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT} / ${FOCUS_CYCLE_BACKWARD_SHORTCUT}`;
938
+ const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total})${formatActiveFocusInitSourceSuffix()} ${FOCUS_CYCLE_SHORTCUT} / ${FOCUS_CYCLE_BACKWARD_SHORTCUT}`;
924
939
  const route = position.preset.defaultRouteOverride ? ` route:${position.preset.defaultRouteOverride}` : "";
925
940
  const note = position.preset.decisionNoteDestination
926
941
  ? ` note:${position.preset.decisionNoteDestination.path}`
@@ -928,6 +943,38 @@ function formatFocusStatus(position: ActiveFocusPresetPosition): string {
928
943
  return `${base}${route}${note}`;
929
944
  }
930
945
 
946
+ function notifyActiveFocusInitSource(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
947
+ if (!ctx.hasUI) return;
948
+ const source = getActiveFocusInitSource();
949
+ if (source === "malformed") {
950
+ ctx.ui.notify(
951
+ `Ignored malformed focus session state at ${FOCUS_SESSION_STATE_RELATIVE_PATH}; using default Active Focus.`,
952
+ "warning",
953
+ );
954
+ return;
955
+ }
956
+ if (source === "stale") {
957
+ ctx.ui.notify("Saved Active Focus preset is no longer valid; using default.", "warning");
958
+ return;
959
+ }
960
+ if (source === "restored") {
961
+ const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
962
+ if (position) {
963
+ ctx.ui.notify(`Restored Active Focus: ${position.preset.label} (${position.index + 1}/${position.total})`, "info");
964
+ }
965
+ }
966
+ }
967
+
968
+ async function ensureActiveFocusReady(loaded: LoadedConfig): Promise<void> {
969
+ if (isActiveFocusSessionInitialized()) return;
970
+ const persisted = await loadFocusSessionState(loaded.root);
971
+ applyActiveFocusSession(loaded.raw.focusPresets, persisted);
972
+ }
973
+
974
+ async function persistActiveFocusSession(root: string): Promise<void> {
975
+ await saveFocusSessionState(root, getActiveFocusPresetId());
976
+ }
977
+
931
978
  function updateFocusStatus(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
932
979
  if (!ctx.hasUI) return;
933
980
  const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
@@ -969,7 +1016,7 @@ function biasWorkspaceMatchesWithActiveFocus(loaded: LoadedConfig, matches: Reso
969
1016
  }
970
1017
 
971
1018
  async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
972
- ensureActiveFocusInitialized(loaded.raw.focusPresets);
1019
+ await ensureActiveFocusReady(loaded);
973
1020
  let matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
974
1021
  if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
975
1022
  if (matches.length > 1 && isTagBasedTargetInference(target)) {
@@ -1137,12 +1184,14 @@ async function buildManifest(
1137
1184
  loaded: LoadedConfig,
1138
1185
  options?: { skills?: import("@earendil-works/pi-coding-agent").Skill[] },
1139
1186
  ): Promise<string> {
1140
- ensureActiveFocusInitialized(loaded.raw.focusPresets);
1187
+ await ensureActiveFocusReady(loaded);
1141
1188
  const lines = ["Pi Monofold Manifest:"];
1142
1189
  const activePreset = getActiveFocusPreset(loaded);
1143
1190
  const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
1144
1191
  if (activePreset && position) {
1145
- lines.push(`Active Focus: ${activePreset.label} (${activePreset.id}, ${position.index + 1}/${position.total})`);
1192
+ lines.push(
1193
+ `Active Focus: ${activePreset.label} (${activePreset.id}, ${position.index + 1}/${position.total})${formatActiveFocusInitSourceSuffix()}`,
1194
+ );
1146
1195
  if (activePreset.defaultRouteOverride) {
1147
1196
  lines.push(`Default write route override: ${activePreset.defaultRouteOverride} (explicit routeType/--route still wins)`);
1148
1197
  }
@@ -1443,8 +1492,9 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
1443
1492
  pi.on("session_start", async (_event, ctx) => {
1444
1493
  try {
1445
1494
  const loaded = await loadConfig(ctx.cwd);
1446
- ensureActiveFocusInitialized(loaded.raw.focusPresets);
1495
+ await ensureActiveFocusReady(loaded);
1447
1496
  updateFocusStatus(ctx, loaded);
1497
+ notifyActiveFocusInitSource(ctx, loaded);
1448
1498
  const activeId = getActiveFocusPresetId();
1449
1499
  if (!activeId) return;
1450
1500
  const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
@@ -1459,7 +1509,7 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
1459
1509
  pi.on("before_agent_start", async (_event, ctx) => {
1460
1510
  try {
1461
1511
  const loaded = await loadConfig(ctx.cwd);
1462
- ensureActiveFocusInitialized(loaded.raw.focusPresets);
1512
+ await ensureActiveFocusReady(loaded);
1463
1513
  const manifest = await buildManifest(loaded, { skills: _event.systemPromptOptions?.skills });
1464
1514
  const activePreset = getActiveFocusPreset(loaded);
1465
1515
  const focusInjection = activePreset ? await buildFocusContextInjection(loaded, activePreset) : undefined;
@@ -2067,7 +2117,7 @@ ${focusInjection.text}` : ""}
2067
2117
  try {
2068
2118
  const loaded = await loadConfig(ctx.cwd);
2069
2119
  const focusPresets = loaded.raw.focusPresets ?? [];
2070
- ensureActiveFocusInitialized(focusPresets);
2120
+ await ensureActiveFocusReady(loaded);
2071
2121
  updateFocusStatus(ctx, loaded);
2072
2122
 
2073
2123
  if (focusPresets.length === 0) {
@@ -2085,6 +2135,7 @@ ${focusInjection.text}` : ""}
2085
2135
  if (focusPresets.length === 1) {
2086
2136
  const preset = focusPresets[0]!;
2087
2137
  setActiveFocusPresetId(preset.id, focusPresets);
2138
+ await persistActiveFocusSession(ctx.cwd);
2088
2139
  await notifyFocusApplied(ctx, loaded, { preset, index: 0, total: 1 }, "command");
2089
2140
  return;
2090
2141
  }
@@ -2095,6 +2146,7 @@ ${focusInjection.text}` : ""}
2095
2146
  return;
2096
2147
  }
2097
2148
  const position = setActiveFocusPresetByLabel(choice, focusPresets);
2149
+ await persistActiveFocusSession(ctx.cwd);
2098
2150
  await notifyFocusApplied(ctx, loaded, position, "command");
2099
2151
  } catch (error) {
2100
2152
  sendCommandError(pi, "monofold:focus", error, "/monofold:focus");
@@ -2109,8 +2161,10 @@ ${focusInjection.text}` : ""}
2109
2161
  notifyNoFocusPresets(ctx);
2110
2162
  return;
2111
2163
  }
2164
+ await ensureActiveFocusReady(loaded);
2112
2165
  const result = cycleActiveFocusPresetForward(focusPresets);
2113
2166
  if (!result) return;
2167
+ await persistActiveFocusSession(ctx.cwd);
2114
2168
  updateFocusStatus(ctx, loaded);
2115
2169
  await warnActiveFocusPresetIssues(result.preset, loaded, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
2116
2170
  if (focusPresets.length === 1) {
@@ -2132,8 +2186,10 @@ ${focusInjection.text}` : ""}
2132
2186
  notifyNoFocusPresets(ctx);
2133
2187
  return;
2134
2188
  }
2189
+ await ensureActiveFocusReady(loaded);
2135
2190
  const result = cycleActiveFocusPresetBackward(focusPresets);
2136
2191
  if (!result) return;
2192
+ await persistActiveFocusSession(ctx.cwd);
2137
2193
  updateFocusStatus(ctx, loaded);
2138
2194
  await warnActiveFocusPresetIssues(result.preset, loaded, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
2139
2195
  if (focusPresets.length === 1) {
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
14
14
  "type": "module",
15
- "version": "0.10.0",
15
+ "version": "0.11.1",
16
16
  "pi": {
17
17
  "extensions": [
18
18
  "./index.ts"
@@ -29,6 +29,7 @@
29
29
  "focus-route-override.ts",
30
30
  "focus-skills.ts",
31
31
  "focus-decision-note.ts",
32
+ "focus-session-state.ts",
32
33
  "monofold-read-ops.ts",
33
34
  "read-caps.ts",
34
35
  "validation.ts"