@hank-warren/pi-plan-mode 1.4.0 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d0c46a5: Drop two legacy paths that were past their delete-by date:
8
+
9
+ - The one-shot repair of a thinking level left raised by a pre-1.3.0 session, and the "thinkingLevel is no longer used" row in the settings menu. An unknown `thinkingLevel` key in `pi-plan-mode.json` is still preserved verbatim on save.
10
+ - The `plan-mode.json` settings fallback and its "Using legacy…" / "ignored because…" notices. Only `$PI_CODING_AGENT_DIR/pi-plan-mode.json` is read now; a host still on the old filename gets defaults and should rename the file.
11
+
12
+ Internally, the settings watcher and the menu/workflow lifecycle moved into their own modules and the state transitions share one helper; nothing else about `/plan` changed. `engines.node` now states Pi's own floor, `>=22.19.0`.
13
+
14
+ ## 1.5.0
15
+
16
+ ### Minor Changes
17
+
18
+ - c079c51: Keep the completed plan out of model context, stage the Plan tools, and fail honestly when a plan cannot be saved.
19
+
20
+ The completed-plan card is a display-only session entry rendered through `registerEntryRenderer` instead of a message, so the plan stays visible and restorable in the transcript while never entering model context or compaction. `plan_mode_complete` returns a one-line `Plan saved to <path>.` pointer; the durable file remains the handoff.
21
+
22
+ `plan_mode_complete` now writes the file first and throws when the write fails, rather than reporting success and returning `undefined`. Prior state stays intact and the call is retryable.
23
+
24
+ `plan_mode_complete` and the `plan_mode_question` fallback activate when Plan mode is entered or restored, so a session that never plans does not carry their schemas. Ownership of `ask_user_question` is resolved by package directory and read back from the host rather than assumed from the write, and every reconcile is announced on an event, so the fallback no longer depends on hook order between packages. A headless run has no legitimate question tool, so the prompt and the finalize steer switch to a plain-text variant instead of naming a tool that both packages strip.
25
+
26
+ `AbortSignal` is wired through the question tool with exactly-once cleanup.
27
+
3
28
  ## 1.4.0
4
29
 
5
30
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-plan-mode#readme",
24
24
  "engines": {
25
- "node": ">=18.0.0"
25
+ "node": ">=22.19.0"
26
26
  },
27
27
  "pi": {
28
28
  "extensions": [
package/src/command.ts CHANGED
@@ -1,4 +1,4 @@
1
- export interface CommandArgumentCompletion {
1
+ interface CommandArgumentCompletion {
2
2
  value: string;
3
3
  label: string;
4
4
  description?: string;
@@ -2,10 +2,10 @@ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
2
  import { Markdown } from "@earendil-works/pi-tui";
3
3
 
4
4
  export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
5
- export const PLAN_MODE_COMPLETE_VERSION = 1;
6
- export const PLAN_MODE_MAX_CHARS = 50_000;
5
+ const PLAN_MODE_COMPLETE_VERSION = 1;
6
+ const PLAN_MODE_MAX_CHARS = 50_000;
7
7
 
8
- export type PlanModeCompletionDetails = {
8
+ type PlanModeCompletionDetails = {
9
9
  version: typeof PLAN_MODE_COMPLETE_VERSION;
10
10
  source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
11
11
  plan: string;
@@ -43,7 +43,7 @@ export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCo
43
43
  return { ok: true, plan };
44
44
  }
45
45
 
46
- export function planFromCompletionDetails(value: unknown) {
46
+ function planFromCompletionDetails(value: unknown) {
47
47
  if (!isRecord(value)) return undefined;
48
48
  if (
49
49
  value.version !== PLAN_MODE_COMPLETE_VERSION ||
@@ -57,7 +57,12 @@ export function planFromCompletionDetails(value: unknown) {
57
57
 
58
58
  export function planModeCompleted(plan: string, planPath?: string) {
59
59
  return {
60
- content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
60
+ content: [
61
+ {
62
+ type: "text" as const,
63
+ text: planPath ? `Plan saved to ${planPath}.` : "Plan saved.",
64
+ },
65
+ ],
61
66
  details: {
62
67
  version: PLAN_MODE_COMPLETE_VERSION,
63
68
  source: PLAN_MODE_COMPLETE_TOOL_NAME,
@@ -73,7 +78,7 @@ type PlanModeCompletionRenderResult = {
73
78
  details?: unknown;
74
79
  };
75
80
 
76
- export function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
81
+ function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
77
82
  const content = result.content
78
83
  .filter((block) => block.type === "text" && typeof block.text === "string")
79
84
  .map((block) => block.text)
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { LegacyThinkingCapture } from "./state.js";
3
2
 
4
3
  type AgentSettledHandler = (event: unknown, ctx: ExtensionContext) => unknown;
5
4
 
@@ -11,16 +10,6 @@ export function onAgentSettled(pi: ExtensionAPI, handler: AgentSettledHandler) {
11
10
  ).on("agent_settled", handler);
12
11
  }
13
12
 
14
- /**
15
- * Only the one-shot migration that undoes a pre-1.3.0 thinking-level change
16
- * still calls this. Plan mode never sets the thinking level otherwise.
17
- *
18
- * legacy: delete in 1.4.0
19
- */
20
- export function setPlanThinkingLevel(pi: ExtensionAPI, level: LegacyThinkingCapture["previous"]) {
21
- (pi.setThinkingLevel as unknown as (level: string) => void)(level);
22
- }
23
-
24
13
  export function isStaleExtensionContextError(error: unknown) {
25
14
  return (
26
15
  error instanceof Error &&
@@ -5,7 +5,7 @@ import type { PlanModeState } from "./state.js";
5
5
  type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
6
6
  type ReplacementContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
7
7
 
8
- export interface FreshImplementationRequest {
8
+ interface FreshImplementationRequest {
9
9
  plan: string;
10
10
  planPath: string;
11
11
  stateEntryType: string;
@@ -18,7 +18,7 @@ interface FreshImplementationFromStateOptions {
18
18
  stateEntryType: string;
19
19
  }
20
20
 
21
- export type FreshImplementationResult =
21
+ type FreshImplementationResult =
22
22
  | { kind: "started" }
23
23
  | { kind: "cancelled" }
24
24
  | { kind: "partial" }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A scope captured when deferred work starts: the signal that work should race
3
+ * against, and the question "is what I was started for still the current
4
+ * thing?".
5
+ */
6
+ export interface LifecycleScope {
7
+ readonly signal: AbortSignal;
8
+ isCurrent(): boolean;
9
+ }
10
+
11
+ /**
12
+ * Two nested generations decide whether deferred Plan-mode work may still act.
13
+ *
14
+ * The session generation moves when Pi replaces or shuts down the session: a
15
+ * menu, a settings reload, or a question left waiting from the previous session
16
+ * must never write to the new one. The workflow generation moves on every
17
+ * enter/exit/implement, so a menu opened against one plan cannot act after the
18
+ * user has moved on — while a settings reload, which belongs to the session
19
+ * rather than to a plan, is deliberately left alone by it.
20
+ *
21
+ * The abort signal is the second half of the same rule: it stops work that is
22
+ * already blocked on the UI, where a generation check would never be reached.
23
+ */
24
+ export function createLifecycle() {
25
+ let sessionGeneration = 0;
26
+ let workflowGeneration = 0;
27
+ let controller = new AbortController();
28
+
29
+ const sessionScope = (): LifecycleScope => {
30
+ const session = sessionGeneration;
31
+ const active = controller;
32
+ return {
33
+ signal: active.signal,
34
+ isCurrent: () => session === sessionGeneration && !active.signal.aborted,
35
+ };
36
+ };
37
+
38
+ /**
39
+ * Ends the current session: everything captured before this call goes stale
40
+ * and everything waiting on the signal is aborted with `reason`. The aborted
41
+ * signal stays in place, so anything captured *after* it is stale too —
42
+ * which is what a shut-down session wants: there is no next session to be
43
+ * current for, and a menu opened in that window must refuse to run.
44
+ */
45
+ const endSession = (reason: string) => {
46
+ sessionGeneration += 1;
47
+ controller.abort(new DOMException(reason, "AbortError"));
48
+ };
49
+
50
+ return {
51
+ /** The live session signal, for composing with a caller's own. */
52
+ get signal() {
53
+ return controller.signal;
54
+ },
55
+ endSession,
56
+ /**
57
+ * Ends the current session and opens the next one, whose scope is
58
+ * returned: work started from here races against a fresh signal.
59
+ */
60
+ nextSession(reason: string): LifecycleScope {
61
+ endSession(reason);
62
+ controller = new AbortController();
63
+ return sessionScope();
64
+ },
65
+ /** Supersedes menus and prompts opened against the previous plan state. */
66
+ nextWorkflow() {
67
+ workflowGeneration += 1;
68
+ },
69
+ /** The scope for menu-scale work: stale as soon as either generation moves. */
70
+ capture(): LifecycleScope {
71
+ const session = sessionScope();
72
+ const workflow = workflowGeneration;
73
+ return {
74
+ signal: session.signal,
75
+ isCurrent: () => session.isCurrent() && workflow === workflowGeneration,
76
+ };
77
+ },
78
+ };
79
+ }
@@ -8,7 +8,7 @@ import type { PlanModeState } from "./state.js";
8
8
 
9
9
  export { DEFAULT_PLAN_EXPORT_PATH };
10
10
 
11
- export interface PlanExportResult {
11
+ interface PlanExportResult {
12
12
  path: string;
13
13
  }
14
14
 
@@ -17,7 +17,7 @@ export interface PlanExportDestination {
17
17
  resolvedPath: string;
18
18
  }
19
19
 
20
- export interface PlanExportLifecycle {
20
+ interface PlanExportLifecycle {
21
21
  signal: AbortSignal;
22
22
  isCurrent(): boolean;
23
23
  getState?(): PlanModeState;
@@ -74,7 +74,7 @@ export async function exportStoredPlan(
74
74
  return true;
75
75
  }
76
76
 
77
- export async function exportPlanToFile(
77
+ async function exportPlanToFile(
78
78
  plan: string,
79
79
  requestedPath: string | undefined,
80
80
  cwd: string,
@@ -109,7 +109,7 @@ export function planExportDestination(defaultPath: string, cwd: string): PlanExp
109
109
  };
110
110
  }
111
111
 
112
- export function resolvePlanExportPath(
112
+ function resolvePlanExportPath(
113
113
  requestedPath: string | undefined,
114
114
  cwd: string,
115
115
  defaultPath = DEFAULT_PLAN_EXPORT_PATH,
package/src/plan-file.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdir, open, rename, rm, unlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export const PLANS_DIRECTORY = "plans";
7
+ const PLANS_DIRECTORY = "plans";
8
8
  const MAX_PLAN_BYTES = 1024 * 1024;
9
9
 
10
10
  /**
@@ -79,7 +79,3 @@ export async function readPlanFile(path: string): Promise<string | undefined> {
79
79
  export async function deletePlanFile(path: string): Promise<void> {
80
80
  await unlink(path).catch(() => undefined);
81
81
  }
82
-
83
- export async function planFileExists(path: string): Promise<boolean> {
84
- return (await readPlanFile(path)) !== undefined;
85
- }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
 
4
- export interface PlanLaunchMenuOptions {
4
+ interface PlanLaunchMenuOptions {
5
5
  statusText: string;
6
6
  signal: AbortSignal;
7
7
  isCurrent(): boolean;