pi-ui-extend 0.1.34 → 0.1.35

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 (58) hide show
  1. package/README.md +20 -0
  2. package/dist/app/app.js +4 -2
  3. package/dist/app/constants.d.ts +2 -1
  4. package/dist/app/constants.js +6 -1
  5. package/dist/app/input/input-controller.d.ts +4 -1
  6. package/dist/app/input/input-controller.js +95 -16
  7. package/dist/app/input/input-paste-handler.js +3 -1
  8. package/dist/app/input/terminal-edit-shortcuts.d.ts +20 -0
  9. package/dist/app/input/terminal-edit-shortcuts.js +50 -16
  10. package/dist/app/rendering/conversation-entry-renderer.d.ts +1 -0
  11. package/dist/app/rendering/conversation-entry-renderer.js +1 -1
  12. package/dist/app/rendering/conversation-tool-renderer.d.ts +1 -0
  13. package/dist/app/rendering/conversation-tool-renderer.js +21 -0
  14. package/dist/app/rendering/conversation-viewport.d.ts +3 -0
  15. package/dist/app/rendering/conversation-viewport.js +41 -5
  16. package/dist/app/rendering/editor-layout-renderer.js +3 -2
  17. package/dist/app/rendering/editor-panels.js +27 -10
  18. package/dist/app/runtime.d.ts +1 -0
  19. package/dist/app/runtime.js +33 -14
  20. package/dist/app/session/session-event-controller.d.ts +7 -0
  21. package/dist/app/session/session-event-controller.js +78 -0
  22. package/dist/app/session/tabs-controller.js +3 -1
  23. package/dist/app/subagents/subagents-widget-controller.d.ts +10 -2
  24. package/dist/app/subagents/subagents-widget-controller.js +141 -70
  25. package/dist/app/terminal/terminal-controller.d.ts +10 -0
  26. package/dist/app/terminal/terminal-controller.js +91 -2
  27. package/dist/app/todo/todo-model.js +2 -0
  28. package/dist/app/todo/todo-widget-controller.d.ts +2 -0
  29. package/dist/app/todo/todo-widget-controller.js +17 -7
  30. package/dist/app/types.d.ts +4 -0
  31. package/dist/bundled-extensions/question/tui.js +8 -1
  32. package/dist/bundled-extensions/session-title/index.js +65 -14
  33. package/dist/input-editor-files.js +23 -4
  34. package/dist/markdown-format.d.ts +4 -1
  35. package/dist/markdown-format.js +76 -9
  36. package/external/pi-tools-suite/README.md +71 -1
  37. package/external/pi-tools-suite/package.json +3 -3
  38. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -6
  39. package/external/pi-tools-suite/src/async-subagents/index.ts +133 -37
  40. package/external/pi-tools-suite/src/context-usage.ts +6 -1
  41. package/external/pi-tools-suite/src/dcp/commands.ts +3 -2
  42. package/external/pi-tools-suite/src/dcp/compress-tool.ts +9 -4
  43. package/external/pi-tools-suite/src/dcp/config.ts +142 -6
  44. package/external/pi-tools-suite/src/dcp/index.ts +20 -8
  45. package/external/pi-tools-suite/src/dcp/prompts.ts +17 -9
  46. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +59 -15
  47. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +6 -8
  48. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  49. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +51 -1
  50. package/external/pi-tools-suite/src/glm-coding-discipline/index.ts +16 -11
  51. package/external/pi-tools-suite/src/model-tools/index.ts +24 -12
  52. package/external/pi-tools-suite/src/prompt-commands/index.ts +11 -2
  53. package/external/pi-tools-suite/src/telegram-mirror/index.ts +66 -27
  54. package/external/pi-tools-suite/src/todo/index.ts +87 -16
  55. package/external/pi-tools-suite/src/todo/state/store.ts +41 -10
  56. package/external/pi-tools-suite/src/todo/todo.ts +49 -6
  57. package/external/pi-tools-suite/src/tool-descriptions.ts +4 -4
  58. package/package.json +7 -5
@@ -26,7 +26,9 @@ When multiple independent stale sections exist, prefer several focused compressi
26
26
  When one older message is huge but the surrounding context is still useful, use message-mode compression for that single message instead of compressing a broad range.
27
27
  Summaries should be proportional to future usefulness, not proportional to the amount of text being removed.
28
28
 
29
- Use \`compress\` as steady housekeeping while you work. After any completed implementation + verification slice, compress that closed slice before replying or starting a new task unless the next step needs exact raw logs/diffs. Do not carry large stale tool outputs across task boundaries.
29
+ Use \`compress\` as steady housekeeping while you work. Closed slice => compress now. Do not start a new search, file-read batch, test, verification run, or web lookup while older completed work remains raw.
30
+
31
+ A closed slice is any finished implementation, verification, config edit, answered exploration, dead-end debugging branch, or test/log inspection. Passing logs are summary-only: preserve command, pass/fail, key failures if any, and whether follow-up is needed; never keep a full passing log in live context. Treat large shell/read/repo/web outputs as disposable evidence once their facts are extracted.
30
32
 
31
33
  Before compressing while work is unfinished, ensure one \`todo in_progress\` captures the active objective and next step.
32
34
 
@@ -38,6 +40,7 @@ CADENCE, SIGNALS, AND LATENCY
38
40
  - Prioritize closedness and independence over raw size
39
41
  - Prefer smaller, regular compressions over infrequent massive compressions for better latency and summary quality
40
42
  - When multiple independent stale sections are ready, batch compressions in parallel
43
+ - Before more exploration, ask whether an older completed slice can become summary-only; if yes, compress first
41
44
 
42
45
  COMPRESS WHEN
43
46
 
@@ -45,6 +48,8 @@ A section is genuinely closed and the raw conversation has served its purpose:
45
48
 
46
49
  - Research concluded and findings are clear
47
50
  - Implementation finished and verified
51
+ - Config/doc edit finished
52
+ - Test, lint, or CI output has been understood, especially passing logs
48
53
  - Exploration exhausted and patterns understood
49
54
  - Dead-end noise can be discarded without waiting for a whole chapter to close
50
55
 
@@ -77,7 +82,10 @@ It is your responsibility to keep a sharp, high-quality context window for optim
77
82
  export const COMPRESS_RANGE_DESCRIPTION = `Collapse one or more ranges of the conversation into detailed summaries.
78
83
 
79
84
  AUTONOMOUS HOUSEKEEPING
80
- Do not wait for context emergencies. After a completed implementation + verification slice, compress that closed slice before replying or starting a new task unless the next step needs exact raw logs/diffs. Do not carry large stale tool outputs across task boundaries.
85
+ Do not wait for context emergencies. Closed slice => compress now. A closed slice includes completed implementation, verification, config/doc edit, answered exploration, dead-end debugging, or finished test/log inspection. Compress before starting the next search/read/test/web lookup unless exact raw text is still needed.
86
+
87
+ PASSING LOGS AND LARGE OUTPUTS
88
+ Passing check/test/lint/tsc logs are summary-only after you know the result. Preserve command, pass/fail, key failures if any, and follow-up status; drop full passing output. Treat large shell/read/repo/web outputs as disposable evidence once important facts are extracted.
81
89
 
82
90
  DCP REMINDERS
83
91
  If a \`<dcp-system-reminder>\` is present in context, treat it as a direct instruction to evaluate compression immediately. If a safe closed range exists, call this tool before further exploration. Skipping compression is acceptable only when the newest raw context is still needed for the next concrete edit, test, or answer.
@@ -87,7 +95,7 @@ Your summary must be COMPLETE FOR CONTINUATION, not a transcript rewrite. Preser
87
95
 
88
96
  If active unfinished work exists, start with \`Active objective\` and \`Next step\`.
89
97
 
90
- Default to a compact structured summary (roughly 5-15 bullets for a normal completed work slice). Grow beyond that only when the compressed range contains multiple independent decisions, unresolved blockers, or precise state that is genuinely required to continue.
98
+ Default to a compact structured summary (roughly 4-10 bullets for a normal completed work slice). Grow beyond that only when the compressed range contains multiple independent decisions, unresolved blockers, or precise state that is genuinely required to continue.
91
99
 
92
100
  Do not copy long raw code, JSON, diffs, logs, or tool output into summaries. Prefer semantic descriptions such as “updated foo.json so scene_assets_1.zai-svg has maxConcurrentRuns set to 5.” Include exact snippets only when the literal text is required for safe continuation, and keep them short and single-line.
93
101
 
@@ -98,7 +106,7 @@ USER INTENT FIDELITY
98
106
  When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
99
107
  Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning.
100
108
 
101
- Be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration, and incidental line-by-line edit history. What remains should be pure signal with enough detail to resume work confidently.
109
+ Be LEAN. Strip away full logs, repeated search/read output, duplicate summaries, incidental failed attempts, and line-by-line edit history. What remains should be pure signal with enough detail to resume work confidently.
102
110
 
103
111
  TWO COMPRESSION MODES
104
112
  You may use either or both modes in one call:
@@ -174,7 +182,7 @@ You are at or beyond the configured max context threshold. This is an emergency
174
182
  You MUST use the \`compress\` tool now. Do not continue normal exploration until compression is handled.
175
183
 
176
184
  If you are in the middle of a critical atomic operation, finish that atomic step first, then compress immediately.
177
- If a completed implementation+verification slice exists, compress it before replying or starting another task.
185
+ If any closed slice exists (finished implementation, verification, config/doc edit, answered exploration, dead end, or test/log inspection), compress it before replying or starting another task. Passing logs should become command + pass/fail + follow-up status only.
178
186
 
179
187
  RANGE STRATEGY (MANDATORY)
180
188
  Prioritize one large, closed, high-yield compression range first.
@@ -201,9 +209,9 @@ ACTION REQUIRED: Context usage is high.
201
209
 
202
210
  Before doing more exploration, look for a closed, self-contained range that no longer needs to stay raw and compress it now.
203
211
 
204
- Do not treat this as optional housekeeping. If any completed research, implementation, verification, CI-log inspection, or dead-end debugging slice is present, call the \`compress\` tool before continuing normal work.
212
+ Do not treat this as optional housekeeping. If any completed research, implementation, verification, config/doc edit, CI-log inspection, or dead-end debugging slice is present, call the \`compress\` tool before continuing normal work.
205
213
  If a completed implementation+verification slice exists, compress it before replying or starting another task.
206
- High-priority stale tool outputs must be compressed once no exact raw text is needed.
214
+ High-priority stale shell/read/repo/web outputs must be compressed once no exact raw text is needed. Passing logs should not remain raw after they are understood.
207
215
 
208
216
  RANGE SELECTION
209
217
  Prefer older, resolved history. Avoid the newest active working slice unless it is clearly done.
@@ -225,7 +233,7 @@ If any range is cleanly closed and unlikely to be needed again, use the \`compre
225
233
  If direction has shifted, compress earlier ranges that are now less relevant.
226
234
 
227
235
  Do not defer this across another batch of searches, reads, CI log fetches, or tests. The next safe action should be compression whenever a closed slice exists.
228
- High-priority stale tool outputs must be compressed once no exact raw text is needed.
236
+ High-priority stale shell/read/repo/web outputs and understood passing logs must be compressed once no exact raw text is needed.
229
237
 
230
238
  Prefer small, closed-range compressions over one broad compression.
231
239
  Use message-mode compression for isolated large stale messages.
@@ -239,7 +247,7 @@ Keep active context uncompressed.
239
247
  export const ITERATION_NUDGE = `<dcp-system-reminder>
240
248
  ACTION REQUIRED: You've been iterating for a while after the last user message.
241
249
 
242
- Pause before the next non-atomic tool call. If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation, completed CI-log triage, a verified fix, or a dead-end investigation), use the \`compress\` tool on it now.
250
+ Pause before the next non-atomic tool call. If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation, completed config edit, completed CI-log triage, a verified fix, or a dead-end investigation), use the \`compress\` tool on it now.
243
251
 
244
252
  Do not keep accumulating tool outputs while a completed slice remains raw. If a range is closed, compression is the next safe action.
245
253
  If a completed implementation+verification slice exists, compress it before replying or starting another task.
@@ -17,6 +17,55 @@ interface CandidateBoundary {
17
17
  isSystemReminder: boolean;
18
18
  }
19
19
 
20
+ function hasAddressableSnapshot(state: DcpState): boolean {
21
+ return state.messageMetaSnapshot.size > 0 || state.messageIdSnapshot.size > 0;
22
+ }
23
+
24
+ function isActiveBlockId(blockId: number, state: DcpState): boolean {
25
+ return state.compressionBlocks.some((block) => block.id === blockId && block.active);
26
+ }
27
+
28
+ function findCurrentMessageId(msg: any, state: DcpState): string | undefined {
29
+ const role = msg?.role ?? "";
30
+ const timestamp = msg?.timestamp;
31
+ if (!Number.isFinite(timestamp)) return undefined;
32
+
33
+ for (const [id, meta] of state.messageMetaSnapshot) {
34
+ if (meta.timestamp === timestamp && meta.role === role && meta.blockId === undefined) return id;
35
+ }
36
+
37
+ for (const [id, ts] of state.messageIdSnapshot) {
38
+ if (ts === timestamp) return id;
39
+ }
40
+
41
+ return undefined;
42
+ }
43
+
44
+ function resolveAddressableBoundaryId(
45
+ msg: any,
46
+ state: DcpState,
47
+ options: { allowBlocks: boolean },
48
+ ): { id: string; blockId?: number; text: string } | null {
49
+ const text = messageText(msg);
50
+ const blockId = extractBlockId(text);
51
+ if (blockId !== undefined) {
52
+ if (options.allowBlocks && isActiveBlockId(blockId, state)) return { id: `b${blockId}`, blockId, text };
53
+ if (!hasAddressableSnapshot(state) && options.allowBlocks) return { id: `b${blockId}`, blockId, text };
54
+ return null;
55
+ }
56
+
57
+ const messageId = extractMessageId(text);
58
+ if (!messageId) return null;
59
+ if (!hasAddressableSnapshot(state)) return { id: messageId, text };
60
+
61
+ if (state.messageMetaSnapshot.has(messageId) || state.messageIdSnapshot.has(messageId)) {
62
+ return { id: messageId, text };
63
+ }
64
+
65
+ const currentId = findCurrentMessageId(msg, state);
66
+ return currentId ? { id: currentId, text } : null;
67
+ }
68
+
20
69
  export function detectCompressionCandidate(
21
70
  messages: any[],
22
71
  _state: DcpState,
@@ -29,19 +78,16 @@ export function detectCompressionCandidate(
29
78
 
30
79
  const boundaries: CandidateBoundary[] = [];
31
80
  for (const msg of messages) {
32
- const text = messageText(msg);
33
- const blockId = extractBlockId(text);
34
- const messageId = extractMessageId(text);
35
- const id = blockId !== undefined ? `b${blockId}` : messageId;
36
- if (!id) continue;
81
+ const boundary = resolveAddressableBoundaryId(msg, _state, { allowBlocks: true });
82
+ if (!boundary) continue;
37
83
  if (!Number.isFinite(msg.timestamp)) continue;
38
84
  boundaries.push({
39
- id,
85
+ id: boundary.id,
40
86
  role: msg.role ?? "",
41
87
  timestamp: msg.timestamp,
42
88
  tokenEstimate: estimateMessageTokens(msg),
43
- blockId,
44
- isSystemReminder: text.includes("<dcp-system-reminder>"),
89
+ blockId: boundary.blockId,
90
+ isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
45
91
  });
46
92
  }
47
93
 
@@ -102,6 +148,7 @@ export function formatCompressionCandidateHint(candidate: CompressionCandidate):
102
148
 
103
149
  export function detectMessageCompressionCandidates(
104
150
  messages: any[],
151
+ state: DcpState,
105
152
  config: DcpConfig,
106
153
  contextPercent: number,
107
154
  ): MessageCompressionCandidate[] {
@@ -111,18 +158,15 @@ export function detectMessageCompressionCandidates(
111
158
 
112
159
  const boundaries: CandidateBoundary[] = [];
113
160
  for (const msg of messages) {
114
- const text = messageText(msg);
115
- const blockId = extractBlockId(text);
116
- const messageId = extractMessageId(text);
117
- if (!messageId || blockId !== undefined) continue;
161
+ const boundary = resolveAddressableBoundaryId(msg, state, { allowBlocks: false });
162
+ if (!boundary || boundary.blockId !== undefined) continue;
118
163
  if (!Number.isFinite(msg.timestamp)) continue;
119
164
  boundaries.push({
120
- id: messageId,
165
+ id: boundary.id,
121
166
  role: msg.role ?? "",
122
167
  timestamp: msg.timestamp,
123
168
  tokenEstimate: estimateMessageTokens(msg),
124
- blockId,
125
- isSystemReminder: text.includes("<dcp-system-reminder>"),
169
+ isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
126
170
  });
127
171
  }
128
172
 
@@ -1,4 +1,4 @@
1
- import type { DcpConfig } from "./config.js";
1
+ import { matchingModelEntries, type DcpConfig } from "./config.js";
2
2
  import type { DcpState } from "./state.js";
3
3
  import type { NudgeThresholds } from "./pruner-types.js";
4
4
  import { createRequire } from "node:module";
@@ -107,8 +107,6 @@ export function resolveContextThresholds(
107
107
  modelKeys: Array<string | undefined> = [],
108
108
  contextWindow?: number,
109
109
  ): Required<NudgeThresholds> {
110
- const candidates = modelKeys.filter((key): key is string => typeof key === "string" && key.length > 0);
111
-
112
110
  const resolveThresholdValue = (value: number | string | undefined): number | undefined => {
113
111
  if (typeof value === "string") {
114
112
  const trimmed = value.trim();
@@ -130,12 +128,12 @@ export function resolveContextThresholds(
130
128
  };
131
129
 
132
130
  const resolveOverride = (map: Record<string, number | string> | undefined): number | undefined => {
133
- if (!map) return undefined;
134
- for (const key of candidates) {
135
- const value = resolveThresholdValue(map[key]);
136
- if (value !== undefined) return value;
131
+ let resolved: number | undefined;
132
+ for (const [, rawValue] of matchingModelEntries(map, modelKeys)) {
133
+ const value = resolveThresholdValue(rawValue);
134
+ if (value !== undefined) resolved = value;
137
135
  }
138
- return undefined;
136
+ return resolved;
139
137
  };
140
138
 
141
139
  const min =
@@ -157,7 +157,7 @@ function formatCandidateActions(
157
157
 
158
158
  if (candidate) {
159
159
  parts.push(
160
- `Recommended range candidate: ${candidate.startId}..${candidate.endId} (${candidate.messageCount} messages, ~${candidate.estimatedTokens} tokens, ${candidate.reason}). Compress this before the next search/read/test if it is closed.`,
160
+ `Recommended range candidate: ${candidate.startId}..${candidate.endId} (${candidate.messageCount} messages, ~${candidate.estimatedTokens} tokens, ${candidate.reason}). Compress this before the next search/read/test/web lookup if it is closed.`,
161
161
  );
162
162
  if (candidate.includedBlockIds.length > 0) {
163
163
  parts.push(
@@ -172,7 +172,7 @@ function formatCandidateActions(
172
172
  parts.push(
173
173
  `Recommended message candidates: ${listedMessages
174
174
  .map((item) => `${item.messageId} (${item.priority}, ${item.role}, ~${item.estimatedTokens} tokens)`)
175
- .join(", ")}. High-priority stale messages MUST be compressed once their full text is no longer needed. Batch multiple messages in one compress call when possible.`,
175
+ .join(", ")}. High-priority stale messages MUST be compressed once their full text is no longer needed; passing logs should become command + pass/fail + follow-up status only. Batch multiple messages in one compress call when possible.`,
176
176
  );
177
177
  }
178
178
 
@@ -180,7 +180,7 @@ function formatCandidateActions(
180
180
  if (activeBlocks) parts.push(activeBlocks);
181
181
 
182
182
  if (parts.length === 0) {
183
- parts.push("No automatic candidate is certain; scan the older closed context now and compress any completed research, implementation, verification, CI-log inspection, or dead-end debugging slice before accumulating more tool output.");
183
+ parts.push("No automatic candidate is certain; scan the older closed context now and compress any completed research, implementation, config/doc edit, verification, CI-log inspection, or dead-end debugging slice before accumulating more tool output.");
184
184
  }
185
185
 
186
186
  return [`CONCRETE NEXT ACTION`, ...parts].join("\n");
@@ -19,11 +19,61 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
19
19
  "dcp": {
20
20
  "enabled": true,
21
21
  "manualMode": { "enabled": false, "automaticStrategies": true },
22
+ "modelOverrides": {
23
+ "openai-codex/gpt-5.5": {
24
+ "compress": {
25
+ "minContextPercent": "28%",
26
+ "maxContextPercent": "48%"
27
+ }
28
+ },
29
+ "openai-codex/gpt-5.4-mini": {
30
+ "compress": {
31
+ "minContextPercent": "20%",
32
+ "maxContextPercent": "38%"
33
+ }
34
+ },
35
+ "zai/*": {
36
+ "compress": {
37
+ "minContextPercent": "16%",
38
+ "maxContextPercent": "30%"
39
+ }
40
+ },
41
+ "antigravity/*sonnet*": {
42
+ "compress": {
43
+ "minContextPercent": "22%",
44
+ "maxContextPercent": "40%"
45
+ }
46
+ },
47
+ "antigravity/gemini-3.1-pro*": {
48
+ "compress": {
49
+ "minContextPercent": "24%",
50
+ "maxContextPercent": "42%"
51
+ }
52
+ },
53
+ "antigravity/gemini-3-flash*": {
54
+ "compress": {
55
+ "minContextPercent": "18%",
56
+ "maxContextPercent": "34%"
57
+ }
58
+ },
59
+ "antigravity/gemini-2.5-flash*": {
60
+ "compress": {
61
+ "minContextPercent": "18%",
62
+ "maxContextPercent": "32%"
63
+ }
64
+ },
65
+ "antigravity/antigravity-claude-opus-4-6-thinking": {
66
+ "compress": {
67
+ "minContextPercent": "26%",
68
+ "maxContextPercent": "44%"
69
+ }
70
+ }
71
+ },
22
72
  "compress": {
23
73
  "minContextPercent": "20%",
24
74
  "maxContextPercent": "55%",
25
75
  "nudgeFrequency": 1,
26
- "iterationNudgeThreshold": 6,
76
+ "iterationNudgeThreshold": 4,
27
77
  "autoCandidates": { "minContextPercent": 0.2 },
28
78
  "messageMode": { "minContextPercent": 0.2 }
29
79
  }
@@ -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 {