pi-ui-extend 0.1.34 → 0.1.36

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 (73) hide show
  1. package/README.md +20 -0
  2. package/dist/app/app.d.ts +1 -0
  3. package/dist/app/app.js +12 -2
  4. package/dist/app/commands/command-host.d.ts +1 -0
  5. package/dist/app/commands/command-model-actions.d.ts +1 -0
  6. package/dist/app/commands/command-model-actions.js +32 -0
  7. package/dist/app/commands/command-navigation-actions.js +3 -0
  8. package/dist/app/commands/command-session-actions.js +2 -0
  9. package/dist/app/constants.d.ts +2 -1
  10. package/dist/app/constants.js +6 -1
  11. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  12. package/dist/app/extensions/extension-actions-controller.js +4 -0
  13. package/dist/app/input/input-controller.d.ts +5 -1
  14. package/dist/app/input/input-controller.js +122 -16
  15. package/dist/app/input/input-paste-handler.js +3 -1
  16. package/dist/app/input/terminal-edit-shortcuts.d.ts +21 -0
  17. package/dist/app/input/terminal-edit-shortcuts.js +92 -16
  18. package/dist/app/popup/popup-action-controller.d.ts +1 -0
  19. package/dist/app/popup/popup-action-controller.js +1 -0
  20. package/dist/app/rendering/conversation-entry-renderer.d.ts +1 -0
  21. package/dist/app/rendering/conversation-entry-renderer.js +1 -1
  22. package/dist/app/rendering/conversation-tool-renderer.d.ts +1 -0
  23. package/dist/app/rendering/conversation-tool-renderer.js +21 -0
  24. package/dist/app/rendering/conversation-viewport.d.ts +3 -0
  25. package/dist/app/rendering/conversation-viewport.js +41 -5
  26. package/dist/app/rendering/editor-layout-renderer.js +3 -2
  27. package/dist/app/rendering/editor-panels.js +27 -10
  28. package/dist/app/runtime.d.ts +1 -0
  29. package/dist/app/runtime.js +33 -14
  30. package/dist/app/session/session-event-controller.d.ts +7 -0
  31. package/dist/app/session/session-event-controller.js +78 -0
  32. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  33. package/dist/app/session/session-lifecycle-controller.js +7 -0
  34. package/dist/app/session/tabs-controller.d.ts +1 -0
  35. package/dist/app/session/tabs-controller.js +4 -1
  36. package/dist/app/subagents/subagents-widget-controller.d.ts +10 -2
  37. package/dist/app/subagents/subagents-widget-controller.js +141 -70
  38. package/dist/app/terminal/terminal-controller.d.ts +10 -0
  39. package/dist/app/terminal/terminal-controller.js +91 -2
  40. package/dist/app/todo/todo-model.js +2 -0
  41. package/dist/app/todo/todo-widget-controller.d.ts +2 -0
  42. package/dist/app/todo/todo-widget-controller.js +17 -7
  43. package/dist/app/types.d.ts +4 -0
  44. package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
  45. package/dist/app/workspace/workspace-actions-controller.js +1 -0
  46. package/dist/bundled-extensions/question/tui.js +8 -1
  47. package/dist/bundled-extensions/session-title/index.js +65 -14
  48. package/dist/input-editor-files.js +23 -4
  49. package/dist/markdown-format.d.ts +4 -1
  50. package/dist/markdown-format.js +76 -9
  51. package/external/pi-tools-suite/README.md +71 -1
  52. package/external/pi-tools-suite/package.json +5 -5
  53. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -6
  54. package/external/pi-tools-suite/src/async-subagents/index.ts +133 -37
  55. package/external/pi-tools-suite/src/context-usage.ts +6 -1
  56. package/external/pi-tools-suite/src/dcp/commands.ts +3 -2
  57. package/external/pi-tools-suite/src/dcp/compress-tool.ts +9 -4
  58. package/external/pi-tools-suite/src/dcp/config.ts +142 -6
  59. package/external/pi-tools-suite/src/dcp/index.ts +20 -8
  60. package/external/pi-tools-suite/src/dcp/prompts.ts +17 -9
  61. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +59 -15
  62. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +6 -8
  63. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  64. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +51 -1
  65. package/external/pi-tools-suite/src/glm-coding-discipline/index.ts +16 -11
  66. package/external/pi-tools-suite/src/model-tools/index.ts +24 -12
  67. package/external/pi-tools-suite/src/prompt-commands/index.ts +11 -2
  68. package/external/pi-tools-suite/src/telegram-mirror/index.ts +66 -27
  69. package/external/pi-tools-suite/src/todo/index.ts +87 -16
  70. package/external/pi-tools-suite/src/todo/state/store.ts +41 -10
  71. package/external/pi-tools-suite/src/todo/todo.ts +49 -6
  72. package/external/pi-tools-suite/src/tool-descriptions.ts +4 -4
  73. package/package.json +7 -5
@@ -5,6 +5,7 @@ import type { Api, AssistantMessage, ImageContent, Model, TextContent } from "@e
5
5
  import { Type } from "typebox";
6
6
 
7
7
  import { loadPiToolsSuiteConfig } from "../config.js";
8
+ import { ignoreStaleExtensionContextError } from "../context-usage.js";
8
9
 
9
10
  type ExtensionAPI = any;
10
11
 
@@ -189,20 +190,24 @@ export default function glmCodingDiscipline(pi: ExtensionAPI) {
189
190
  }
190
191
 
191
192
  function syncLookupToolAvailability(modelRef: string | undefined, cwd?: string): void {
192
- const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined;
193
- if (!Array.isArray(activeTools)) return;
193
+ try {
194
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined;
195
+ if (!Array.isArray(activeTools)) return;
194
196
 
195
- const lookupEnabled = Boolean(lookupModelFromConfig(cwd));
196
- const shouldExposeLookup = lookupEnabled && isGlmModel(modelRef);
197
- const hasLookup = activeTools.includes(LOOKUP_TOOL_NAME);
197
+ const lookupEnabled = Boolean(lookupModelFromConfig(cwd));
198
+ const shouldExposeLookup = lookupEnabled && isGlmModel(modelRef);
199
+ const hasLookup = activeTools.includes(LOOKUP_TOOL_NAME);
198
200
 
199
- if (shouldExposeLookup === hasLookup) return;
200
- if (typeof pi.setActiveTools !== "function") return;
201
+ if (shouldExposeLookup === hasLookup) return;
202
+ if (typeof pi.setActiveTools !== "function") return;
201
203
 
202
- const nextTools = shouldExposeLookup
203
- ? [...activeTools, LOOKUP_TOOL_NAME]
204
- : activeTools.filter((tool: unknown) => tool !== LOOKUP_TOOL_NAME);
205
- pi.setActiveTools([...new Set(nextTools)]);
204
+ const nextTools = shouldExposeLookup
205
+ ? [...activeTools, LOOKUP_TOOL_NAME]
206
+ : activeTools.filter((tool: unknown) => tool !== LOOKUP_TOOL_NAME);
207
+ pi.setActiveTools([...new Set(nextTools)]);
208
+ } catch (error) {
209
+ ignoreStaleExtensionContextError(error);
210
+ }
206
211
  }
207
212
 
208
213
  maybeRegisterLookupTool(process.cwd());
@@ -40,6 +40,14 @@ const MANAGED_TOOLS = new Set([...CLAUDE_ALIAS_TOOLS, ...CODEX_ALIAS_TOOLS, ...B
40
40
  const REPO_DISCOVERY_TOOL_NAME_SET = new Set(REPO_DISCOVERY_TOOL_NAMES);
41
41
  const MAX_BUILTIN_DEFINITIONS = 64;
42
42
 
43
+ function isStaleExtensionContextError(error: unknown): boolean {
44
+ return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
45
+ }
46
+
47
+ function ignoreStaleExtensionContextError(error: unknown): void {
48
+ if (!isStaleExtensionContextError(error)) throw error;
49
+ }
50
+
43
51
  type ShellAliasInput = {
44
52
  command?: string;
45
53
  description?: string;
@@ -391,18 +399,22 @@ function sameTools(left: string[], right: string[]): boolean {
391
399
  }
392
400
 
393
401
  function applyToolProfile(pi: ExtensionAPI, model: unknown, baseTools: string[]): void {
394
- const targetTools = toolsForProfile(detectModelProfile(model));
395
- const preserveSelection = shouldPreserveSelection();
396
- const active = pi.getActiveTools();
397
- const repoTools = activeRepoDiscoveryTools(active, baseTools);
398
- const preserved = active.filter((tool) => !MANAGED_TOOLS.has(tool) && !REPO_DISCOVERY_TOOL_NAME_SET.has(tool));
399
- const baseWithoutRepoTools = baseTools.filter((tool) => !REPO_DISCOVERY_TOOL_NAME_SET.has(tool));
400
- const selectedTargetTools = preserveSelection
401
- ? selectSuitableToolsForModel(model, active.filter((tool) => MANAGED_TOOLS.has(tool)))
402
- : targetTools;
403
- const next = selectedTargetTools ? [...repoTools, ...preserved, ...selectedTargetTools] : [...repoTools, ...preserved, ...baseWithoutRepoTools];
404
- const nextTools = [...new Set(next)];
405
- if (!sameTools(active, nextTools)) pi.setActiveTools(nextTools);
402
+ try {
403
+ const targetTools = toolsForProfile(detectModelProfile(model));
404
+ const preserveSelection = shouldPreserveSelection();
405
+ const active = pi.getActiveTools();
406
+ const repoTools = activeRepoDiscoveryTools(active, baseTools);
407
+ const preserved = active.filter((tool) => !MANAGED_TOOLS.has(tool) && !REPO_DISCOVERY_TOOL_NAME_SET.has(tool));
408
+ const baseWithoutRepoTools = baseTools.filter((tool) => !REPO_DISCOVERY_TOOL_NAME_SET.has(tool));
409
+ const selectedTargetTools = preserveSelection
410
+ ? selectSuitableToolsForModel(model, active.filter((tool) => MANAGED_TOOLS.has(tool)))
411
+ : targetTools;
412
+ const next = selectedTargetTools ? [...repoTools, ...preserved, ...selectedTargetTools] : [...repoTools, ...preserved, ...baseWithoutRepoTools];
413
+ const nextTools = [...new Set(next)];
414
+ if (!sameTools(active, nextTools)) pi.setActiveTools(nextTools);
415
+ } catch (error) {
416
+ ignoreStaleExtensionContextError(error);
417
+ }
406
418
  }
407
419
 
408
420
  function shouldPreserveSelection(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
5
5
  import { ensurePiToolsSuiteUserConfig, getPiToolsSuiteUserConfigPath } from "../config";
6
+ import { ignoreStaleExtensionContextError } from "../context-usage";
6
7
  import { publishStartupSection } from "../startup-section";
7
8
 
8
9
  type PromptCommand = {
@@ -166,12 +167,20 @@ async function runPromptCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext,
166
167
  if (!prompt) return notify(ctx, `/${name} has an empty prompt.`, "error");
167
168
 
168
169
  await ctx.waitForIdle();
169
- pi.sendUserMessage(prompt);
170
+ try {
171
+ pi.sendUserMessage(prompt);
172
+ } catch (error) {
173
+ ignoreStaleExtensionContextError(error);
174
+ }
170
175
  }
171
176
 
172
177
  async function reloadAfterConfigChange(ctx: ExtensionCommandContext, message: string): Promise<void> {
173
178
  notify(ctx, `${message}\nReloading commands from ${getConfigPath()}…`);
174
- await ctx.reload();
179
+ try {
180
+ await ctx.reload();
181
+ } catch (error) {
182
+ ignoreStaleExtensionContextError(error);
183
+ }
175
184
  }
176
185
 
177
186
  async function selectCommand(ctx: ExtensionContext, title: string, commands: Record<string, PromptCommand>): Promise<string | undefined> {
@@ -35,6 +35,7 @@
35
35
 
36
36
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
37
  import { loadTelegramMirrorConfig } from "../config.js";
38
+ import { ignoreStaleExtensionContextError, isAgentBusyRaceError } from "../context-usage.js";
38
39
  import { TelegramBot } from "./bot.js";
39
40
  import { captureAbortableContext, registerPixEventHandlers, type PixMirrorHooks, type RendererSink } from "./events.js";
40
41
  import { TurnRenderer, type RendererEvent } from "./renderer.js";
@@ -104,11 +105,32 @@ export default function telegramMirror(pi: ExtensionAPI): void {
104
105
  console.error(`[telegram-mirror] ${message}`);
105
106
  };
106
107
 
108
+ function staleSafe<T>(callback: () => T, fallback?: T): T | undefined {
109
+ try {
110
+ return callback();
111
+ } catch (error) {
112
+ ignoreStaleExtensionContextError(error);
113
+ return fallback;
114
+ }
115
+ }
116
+
117
+ function sendUserMessageSafely(text: string): void {
118
+ try {
119
+ pi.sendUserMessage(text);
120
+ } catch (error) {
121
+ if (isAgentBusyRaceError(error)) {
122
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
123
+ return;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
107
129
  // Dispatch the leader uses to execute commands on its own pi session.
108
- const localDispatch: LocalDispatch = {
109
- sendUserMessage(text) {
110
- pi.sendUserMessage(text);
111
- },
130
+ const localDispatch: LocalDispatch = {
131
+ sendUserMessage(text) {
132
+ staleSafe(() => sendUserMessageSafely(text));
133
+ },
112
134
  currentDialog() {
113
135
  return mirrorCtx?.currentDialog();
114
136
  },
@@ -119,8 +141,12 @@ export default function telegramMirror(pi: ExtensionAPI): void {
119
141
  mirrorCtx?.compact();
120
142
  },
121
143
  status() {
122
- if (!mirrorCtx) return undefined;
123
- return { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() };
144
+ const ctx = mirrorCtx;
145
+ if (!ctx) return undefined;
146
+ return {
147
+ idle: staleSafe(() => ctx.isIdle(), true) ?? true,
148
+ hasPending: staleSafe(() => ctx.hasPendingMessages(), false) ?? false,
149
+ };
124
150
  },
125
151
  };
126
152
 
@@ -336,7 +362,7 @@ export default function telegramMirror(pi: ExtensionAPI): void {
336
362
  try {
337
363
  switch (command) {
338
364
  case "sendUserMessage":
339
- pi.sendUserMessage(((args as { text?: string } | undefined)?.text ?? ""));
365
+ sendUserMessageSafely(((args as { text?: string } | undefined)?.text ?? ""));
340
366
  break;
341
367
  case "abort":
342
368
  mirrorCtx?.abort();
@@ -477,22 +503,30 @@ export default function telegramMirror(pi: ExtensionAPI): void {
477
503
  function refreshCtx(ctx: ExtensionContext | undefined): void {
478
504
  if (!ctx) return;
479
505
  if (!mirrorCtx) {
480
- mirrorCtx = makeCtx({
481
- abort: () => ctx.abort(),
482
- isIdle: () => ctx.isIdle(),
483
- hasPendingMessages: () => ctx.hasPendingMessages(),
484
- compact: () => ctx.compact(),
506
+ mirrorCtx = makeCtx({
507
+ abort: () => {
508
+ staleSafe(() => ctx.abort());
509
+ },
510
+ isIdle: () => staleSafe(() => ctx.isIdle(), true) ?? true,
511
+ hasPendingMessages: () => staleSafe(() => ctx.hasPendingMessages(), false) ?? false,
512
+ compact: () => {
513
+ staleSafe(() => ctx.compact());
514
+ },
485
515
  currentDialog: () => currentDialogFromContext(ctx),
486
516
  });
487
- return;
488
- }
489
- const m = mirrorCtx as Mutable<MirrorContext>;
490
- m.abort = () => ctx.abort();
491
- m.isIdle = () => ctx.isIdle();
492
- m.hasPendingMessages = () => ctx.hasPendingMessages();
493
- m.compact = () => ctx.compact();
494
- m.currentDialog = () => currentDialogFromContext(ctx);
517
+ return;
495
518
  }
519
+ const m = mirrorCtx as Mutable<MirrorContext>;
520
+ m.abort = () => {
521
+ staleSafe(() => ctx.abort());
522
+ };
523
+ m.isIdle = () => staleSafe(() => ctx.isIdle(), true) ?? true;
524
+ m.hasPendingMessages = () => staleSafe(() => ctx.hasPendingMessages(), false) ?? false;
525
+ m.compact = () => {
526
+ staleSafe(() => ctx.compact());
527
+ };
528
+ m.currentDialog = () => currentDialogFromContext(ctx);
529
+ }
496
530
 
497
531
  function refreshSelfInfo(ctx: ExtensionContext | undefined): void {
498
532
  const snapshot = sessionSnapshot(ctx);
@@ -611,13 +645,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
611
645
 
612
646
  function sessionSnapshot(ctx: ExtensionContext | undefined): SessionSnapshot | undefined {
613
647
  if (!ctx) return undefined;
614
- const manager = ctx.sessionManager;
615
- return {
616
- cwd: manager.getCwd?.() ?? ctx.cwd,
617
- ...(manager.getSessionId?.() ? { sessionId: manager.getSessionId() } : {}),
618
- ...(manager.getSessionFile?.() ? { sessionFile: manager.getSessionFile() } : {}),
619
- ...(manager.getSessionName?.() ? { sessionName: manager.getSessionName() } : {}),
620
- };
648
+ try {
649
+ const manager = ctx.sessionManager;
650
+ return {
651
+ cwd: manager.getCwd?.() ?? ctx.cwd,
652
+ ...(manager.getSessionId?.() ? { sessionId: manager.getSessionId() } : {}),
653
+ ...(manager.getSessionFile?.() ? { sessionFile: manager.getSessionFile() } : {}),
654
+ ...(manager.getSessionName?.() ? { sessionName: manager.getSessionName() } : {}),
655
+ };
656
+ } catch (error) {
657
+ ignoreStaleExtensionContextError(error);
658
+ return undefined;
659
+ }
621
660
  }
622
661
 
623
662
  function notify(ctx: { hasUI?: boolean; ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }, message: string, type: "info" | "warning" | "error" = "info"): void {
@@ -1,12 +1,13 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { loadPiToolsSuiteConfig } from "../config.js";
3
+ import { isAgentBusyRaceError } from "../context-usage.js";
3
4
  import { autoClearCompletedTodos } from "./state/auto-clear.js";
4
5
  import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js";
5
6
  import { replayFromBranch } from "./state/replay.js";
6
7
  import { ACTIVE_STATUSES, isTaskBlocked, selectVisibleTasks } from "./state/selectors.js";
7
8
  import { applyTaskMutation } from "./state/state-reducer.js";
8
9
  import { getState, replaceState } from "./state/store.js";
9
- import { DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
10
+ import { activateTodoStateScope, DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
10
11
  import type { Task, TaskMutationParams } from "./tool/types.js";
11
12
 
12
13
  type AgentMessageLike = { role?: unknown; stopReason?: unknown; content?: unknown };
@@ -17,6 +18,11 @@ const TODO_NUDGE_IDLE_RETRY_DELAY_MS = 100;
17
18
  const TODO_NUDGE_MAX_IDLE_ATTEMPTS = 40;
18
19
  const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
19
20
  const TODO_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
21
+ const TODO_THINKING_RESTORE_METADATA_KEY = "__piTodoRestoreThinking";
22
+
23
+ function isStaleExtensionContextError(error: unknown): boolean {
24
+ return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
25
+ }
20
26
 
21
27
  type TodoThinkingLevel = (typeof TODO_THINKING_LEVELS)[number];
22
28
  type ModelLike = { reasoning?: boolean; thinkingLevelMap?: Partial<Record<TodoThinkingLevel, unknown | null>> };
@@ -108,7 +114,12 @@ function getPersistedPlanPrompt(path: string): string | undefined {
108
114
 
109
115
  function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
110
116
  if (ctx.hasUI) {
111
- pi.sendUserMessage(prompt);
117
+ try {
118
+ pi.sendUserMessage(prompt);
119
+ } catch (error) {
120
+ if (isStaleExtensionContextError(error)) return;
121
+ throw error;
122
+ }
112
123
  return;
113
124
  }
114
125
  console.log(prompt);
@@ -128,10 +139,21 @@ export default function (pi: ExtensionAPI) {
128
139
  const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {};
129
140
  registerTodoTool(pi, {
130
141
  ...thinkingPrompt,
142
+ prepareMutation: (state, _ctx, info) => {
143
+ if (!todoThinkingEnabled) return info.params;
144
+ if (info.action === "update") return prepareTodoThinkingMutation(state, info.params);
145
+ if (info.action === "batch_update") {
146
+ return {
147
+ ...info.params,
148
+ items: (info.params.items ?? []).map((item) => prepareTodoThinkingMutation(state, item)),
149
+ };
150
+ }
151
+ return info.params;
152
+ },
131
153
  afterCommit: async (state, ctx, info) => {
132
154
  if (todoThinkingEnabled) applyTodoThinkingAfterCommit(state, info);
133
155
  try {
134
- const sync = syncPersistedPlan(ctx.cwd, state);
156
+ const sync = syncPersistedPlan(ctx.cwd, info.committedState);
135
157
  if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`);
136
158
  } catch (err) {
137
159
  console.warn(`rpiv-todo: failed to sync persisted plan — ${(err as Error).message}`);
@@ -144,7 +166,7 @@ export default function (pi: ExtensionAPI) {
144
166
  const mutations = getTodoThinkingMutations(info.action, info.params);
145
167
  for (const mutation of mutations) {
146
168
  if (mutation.id === undefined || mutation.status === "in_progress") continue;
147
- restoreTaskThinking(mutation.id);
169
+ restoreTaskThinking(mutation.id, state);
148
170
  }
149
171
  restoreInactiveTodoThinking(state);
150
172
  for (const mutation of mutations) {
@@ -167,6 +189,34 @@ export default function (pi: ExtensionAPI) {
167
189
  return isTodoThinkingLevel(level) ? level : undefined;
168
190
  }
169
191
 
192
+ function prepareTodoThinkingMutation(state: ReturnType<typeof getState>, params: TaskMutationParams): TaskMutationParams {
193
+ if (params.id === undefined) return params;
194
+ const current = state.tasks.find((task) => task.id === params.id);
195
+ if (!current) return params;
196
+ const nextStatus = params.status ?? current.status;
197
+ const nextThinking = params.thinking ?? current.thinking;
198
+ const shouldCapturePreviousThinking =
199
+ nextStatus === "in_progress" && nextThinking !== undefined && (current.status !== "in_progress" || params.thinking !== undefined);
200
+ if (!shouldCapturePreviousThinking) return params;
201
+ const currentThinking = getCurrentThinkingLevel();
202
+ if (!currentThinking) return params;
203
+ return {
204
+ ...params,
205
+ metadata: {
206
+ ...(params.metadata ?? {}),
207
+ [TODO_THINKING_RESTORE_METADATA_KEY]: currentThinking,
208
+ },
209
+ };
210
+ }
211
+
212
+ function getRememberedThinking(taskId: number, state: ReturnType<typeof getState>): TodoThinkingLevel | undefined {
213
+ const remembered = rememberedThinkingByTaskId.get(taskId);
214
+ if (remembered) return remembered;
215
+ const task = state.tasks.find((item) => item.id === taskId);
216
+ const stored = task?.metadata?.[TODO_THINKING_RESTORE_METADATA_KEY];
217
+ return isTodoThinkingLevel(stored) ? stored : undefined;
218
+ }
219
+
170
220
  function switchToTaskThinking(taskId: number, level: TodoThinkingLevel): void {
171
221
  if (!getAvailableTodoThinkingLevels(currentModel).includes(level)) return;
172
222
  const current = getCurrentThinkingLevel();
@@ -175,8 +225,8 @@ export default function (pi: ExtensionAPI) {
175
225
  if (current !== level) setTodoThinkingLevel(level);
176
226
  }
177
227
 
178
- function restoreTaskThinking(taskId: number): void {
179
- const previous = rememberedThinkingByTaskId.get(taskId);
228
+ function restoreTaskThinking(taskId: number, state: ReturnType<typeof getState>): void {
229
+ const previous = getRememberedThinking(taskId, state);
180
230
  if (!previous) return;
181
231
  rememberedThinkingByTaskId.delete(taskId);
182
232
  if (getCurrentThinkingLevel() !== previous) setTodoThinkingLevel(previous);
@@ -186,7 +236,7 @@ export default function (pi: ExtensionAPI) {
186
236
  for (const taskId of [...rememberedThinkingByTaskId.keys()]) {
187
237
  const task = state.tasks.find((item) => item.id === taskId);
188
238
  if (task?.status === "in_progress") continue;
189
- restoreTaskThinking(taskId);
239
+ restoreTaskThinking(taskId, state);
190
240
  }
191
241
  }
192
242
 
@@ -197,13 +247,20 @@ export default function (pi: ExtensionAPI) {
197
247
  setter.call(pi, level);
198
248
  }
199
249
 
200
- function isCompletedAssistantReply(message: AgentMessageLike | undefined): boolean {
201
- if (message?.role !== "assistant") return false;
202
- if (message.stopReason === "aborted" || message.stopReason === "error" || message.stopReason === "length") return false;
203
- if (typeof message.content === "string") return message.content.trim().length > 0;
204
- if (!Array.isArray(message.content)) return false;
205
- return message.content.some((block) => typeof (block as { type?: unknown }).type === "string");
206
- }
250
+ function hasVisibleAssistantContent(content: unknown): boolean {
251
+ if (typeof content === "string") return content.trim().length > 0;
252
+ if (!Array.isArray(content)) return false;
253
+ return content.some((block) => {
254
+ const candidate = block as { type?: unknown; text?: unknown };
255
+ return candidate.type === "text" && typeof candidate.text === "string" && candidate.text.trim().length > 0;
256
+ });
257
+ }
258
+
259
+ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boolean {
260
+ if (message?.role !== "assistant") return false;
261
+ if (message.stopReason === "aborted" || message.stopReason === "error" || message.stopReason === "length" || message.stopReason === "toolUse") return false;
262
+ return hasVisibleAssistantContent(message.content);
263
+ }
207
264
 
208
265
  function hasCompletedAssistantReply(messages: readonly unknown[] | undefined): boolean {
209
266
  if (!Array.isArray(messages)) return false;
@@ -227,6 +284,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
227
284
  }
228
285
 
229
286
  function applyInternalTodoMutation(action: "update", params: TaskMutationParams, ctx: ExtensionContext): boolean {
287
+ activateTodoStateScope(ctx);
230
288
  const result = applyTaskMutation(getState(), action, params);
231
289
  if (result.op.kind === "error") {
232
290
  console.warn(`rpiv-todo: failed internal ${action} mutation — ${result.op.message}`);
@@ -235,7 +293,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
235
293
  const autoClear = autoClearCompletedTodos(result.state);
236
294
  replaceState(autoClear.state);
237
295
  publishTodoState(pi as any, ctx, action, params as Record<string, unknown>);
238
- if (todoThinkingEnabled) applyTodoThinkingAfterCommit(autoClear.state, { action, params });
296
+ if (todoThinkingEnabled) applyTodoThinkingAfterCommit(result.state, { action, params });
239
297
  try {
240
298
  const sync = syncPersistedPlan(ctx.cwd, autoClear.state);
241
299
  if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`);
@@ -246,6 +304,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
246
304
  }
247
305
 
248
306
  function maybeRecoverCompletedCurrentTask(messages: readonly unknown[] | undefined, ctx: ExtensionContext): boolean {
307
+ activateTodoStateScope(ctx);
249
308
  if (!hasCompletedAssistantReply(messages)) return false;
250
309
  const task = findOptimisticallyCompletableTask(getState().tasks);
251
310
  if (!task) return false;
@@ -264,6 +323,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
264
323
  nudgeTimer = setTimeout(() => {
265
324
  nudgeTimer = undefined;
266
325
  try {
326
+ activateTodoStateScope(ctx);
267
327
  if (!ctx.isIdle()) {
268
328
  if (attempt < TODO_NUDGE_MAX_IDLE_ATTEMPTS) scheduleTodoNudge(ctx, attempt + 1);
269
329
  return;
@@ -286,6 +346,11 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
286
346
  // queueing followUp from inside agent_end can be too late to be drained.
287
347
  pi.sendUserMessage(nudge.message);
288
348
  } catch (err) {
349
+ if (isAgentBusyRaceError(err)) {
350
+ if (attempt < TODO_NUDGE_MAX_IDLE_ATTEMPTS) scheduleTodoNudge(ctx, attempt + 1);
351
+ return;
352
+ }
353
+ if (isStaleExtensionContextError(err)) return;
289
354
  console.warn(`rpiv-todo: failed to auto-nudge unfinished todos — ${(err as Error).message}`);
290
355
  }
291
356
  }, delayMs);
@@ -295,6 +360,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
295
360
  registerTodosCommand(pi);
296
361
 
297
362
  pi.on("session_start", async (_event, ctx) => {
363
+ activateTodoStateScope(ctx);
298
364
  currentModel = ctx.model;
299
365
  registerTodoToolWithCurrentPrompt();
300
366
  const persisted = loadPersistedPlan(ctx.cwd);
@@ -313,12 +379,14 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
313
379
  });
314
380
 
315
381
  pi.on("session_compact", async (_event, ctx) => {
382
+ activateTodoStateScope(ctx);
316
383
  replaceState(autoClearCompletedTodos(loadPersistedPlan(ctx.cwd)?.state ?? replayFromBranch(ctx)).state);
317
384
  publishTodoState(pi as any, ctx);
318
385
  lastNudgedSignature = undefined;
319
386
  });
320
387
 
321
388
  pi.on("session_tree", async (_event, ctx) => {
389
+ activateTodoStateScope(ctx);
322
390
  replaceState(autoClearCompletedTodos(loadPersistedPlan(ctx.cwd)?.state ?? replayFromBranch(ctx)).state);
323
391
  publishTodoState(pi as any, ctx);
324
392
  lastNudgedSignature = undefined;
@@ -345,12 +413,14 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
345
413
  if (isAskUserToolName(event.toolName)) pendingAskUserToolCallIds.delete(event.toolCallId);
346
414
  });
347
415
 
348
- pi.on("agent_start", async () => {
416
+ pi.on("agent_start", async (_event, ctx) => {
417
+ activateTodoStateScope(ctx);
349
418
  pendingAskUserToolCallIds.clear();
350
419
  inProgressAtAgentStart = new Set(selectVisibleTasks(getState()).filter((task) => task.status === "in_progress").map((task) => task.id));
351
420
  });
352
421
 
353
422
  pi.on("message_end", async (event, ctx) => {
423
+ activateTodoStateScope(ctx);
354
424
  if (!isCompletedAssistantReply((event as { message?: AgentMessageLike } | undefined)?.message)) return;
355
425
  if (maybeRecoverCompletedCurrentTask([(event as { message?: AgentMessageLike }).message], ctx)) {
356
426
  lastNudgedSignature = undefined;
@@ -359,6 +429,7 @@ function isCompletedAssistantReply(message: AgentMessageLike | undefined): boole
359
429
  });
360
430
 
361
431
  pi.on("agent_end", async (event, ctx) => {
432
+ activateTodoStateScope(ctx);
362
433
  const completedAssistantReply = hasCompletedAssistantReply((event as { messages?: readonly unknown[] } | undefined)?.messages);
363
434
 
364
435
  if (suppressNextNudgeForThinkingSwitch) {
@@ -1,28 +1,57 @@
1
1
  import type { Task } from "../tool/types.js";
2
2
  import { EMPTY_STATE, type TaskState } from "./state.js";
3
3
 
4
+ const DEFAULT_SCOPE_KEY = "__default__";
5
+
4
6
  /**
5
- * Module-level live state cell. Pre-refactor this lived as bare `tasks` /
6
- * `nextId` consts in `todo.ts`; centralizing here keeps the store as the
7
- * single mutation seam and lets the reducer remain pure.
7
+ * Module-level live state cells, keyed by the active Pi session identity.
8
+ * Pi can keep multiple SDK runtimes/tabs alive in one Node process, so a
9
+ * single module-level todo cell leaks tasks across sessions. Keep the legacy
10
+ * getState()/commitState() API, but make it resolve through the current scope.
8
11
  */
9
- let state: TaskState = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
12
+ let activeScopeKey = DEFAULT_SCOPE_KEY;
13
+ const statesByScopeKey = new Map<string, TaskState>([
14
+ [DEFAULT_SCOPE_KEY, { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId }],
15
+ ]);
16
+
17
+ function emptyState(): TaskState {
18
+ return { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
19
+ }
20
+
21
+ function normalizedScopeKey(scopeKey: string | undefined): string {
22
+ const trimmed = scopeKey?.trim();
23
+ return trimmed ? trimmed : DEFAULT_SCOPE_KEY;
24
+ }
25
+
26
+ function activeState(): TaskState {
27
+ let state = statesByScopeKey.get(activeScopeKey);
28
+ if (!state) {
29
+ state = emptyState();
30
+ statesByScopeKey.set(activeScopeKey, state);
31
+ }
32
+ return state;
33
+ }
34
+
35
+ export function activateStateScope(scopeKey: string | undefined): void {
36
+ activeScopeKey = normalizedScopeKey(scopeKey);
37
+ activeState();
38
+ }
10
39
 
11
40
  /**
12
41
  * Live tasks accessor. Returned `readonly Task[]` so callers cannot mutate the
13
42
  * live cell. Consumers must not cast back.
14
43
  */
15
44
  export function getTodos(): readonly Task[] {
16
- return state.tasks;
45
+ return activeState().tasks;
17
46
  }
18
47
 
19
48
  export function getNextId(): number {
20
- return state.nextId;
49
+ return activeState().nextId;
21
50
  }
22
51
 
23
52
  /** Snapshot accessor used by reducer callers to pass canonical state in. */
24
53
  export function getState(): TaskState {
25
- return state;
54
+ return activeState();
26
55
  }
27
56
 
28
57
  /**
@@ -31,7 +60,7 @@ export function getState(): TaskState {
31
60
  * `replayFromBranch` decodes the latest snapshot.
32
61
  */
33
62
  export function replaceState(next: TaskState): void {
34
- state = next;
63
+ statesByScopeKey.set(activeScopeKey, next);
35
64
  }
36
65
 
37
66
  /**
@@ -39,7 +68,7 @@ export function replaceState(next: TaskState): void {
39
68
  * `state` output to publish the new canonical state to live readers.
40
69
  */
41
70
  export function commitState(next: TaskState): void {
42
- state = next;
71
+ statesByScopeKey.set(activeScopeKey, next);
43
72
  }
44
73
 
45
74
  /**
@@ -48,5 +77,7 @@ export function commitState(next: TaskState): void {
48
77
  * Plan §Decisions §Decision 7.
49
78
  */
50
79
  export function __resetState(): void {
51
- state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
80
+ activeScopeKey = DEFAULT_SCOPE_KEY;
81
+ statesByScopeKey.clear();
82
+ statesByScopeKey.set(DEFAULT_SCOPE_KEY, emptyState());
52
83
  }