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
@@ -9,12 +9,13 @@ import {
9
9
  getSubagentRegistryPath,
10
10
  isBlindModelRef,
11
11
  loadSubagentConfig,
12
- listRunDirs,
12
+ listSubagentSessionRecords,
13
13
  loadSubagentRegistry,
14
14
  removeSubagentRunsFromRegistry,
15
15
  stopAgents,
16
16
  type AgentCompletionHandler,
17
17
  type StopSignal,
18
+ type SubagentSessionRecord,
18
19
  } from "./lib.js";
19
20
  import { buildUltraworkPrompt, isUltraworkEnvEnabled, registerCommands } from "./commands.js";
20
21
  import { agentStrategyPrompt, appendAgentStrategyPrompt } from "./core/agent-strategy.js";
@@ -46,6 +47,11 @@ interface ShutdownTarget {
46
47
  agentIds?: string[];
47
48
  }
48
49
 
50
+ interface ShutdownPlan {
51
+ targets: ShutdownTarget[];
52
+ runDirsToDelete: string[];
53
+ }
54
+
49
55
  function createLiveStatePayload(
50
56
  liveAgents: Map<string, Map<string, LiveAgent>>,
51
57
  sessionFile: string | undefined,
@@ -81,6 +87,14 @@ function agentMatchesSession(agent: LiveAgent, sessionFile: string | undefined):
81
87
  return pathsEqual(sessionFile, agent.parentSession);
82
88
  }
83
89
 
90
+ function isStaleExtensionContextError(error: unknown): boolean {
91
+ return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
92
+ }
93
+
94
+ function ignoreStaleExtensionContextError(error: unknown): void {
95
+ if (!isStaleExtensionContextError(error)) throw error;
96
+ }
97
+
84
98
  export default function (pi: ExtensionAPI) {
85
99
  const liveAgents = new Map<string, Map<string, LiveAgent>>();
86
100
  const subagentOverlay = new SubagentOverlay(liveAgents);
@@ -90,11 +104,15 @@ export default function (pi: ExtensionAPI) {
90
104
  publishSubagentPresetsStartupSection();
91
105
 
92
106
  function refreshSubagentOverlay(): void {
93
- reconcileLiveAgentCompletions();
94
- const liveState = createLiveStatePayload(liveAgents, currentSessionFile);
95
- pi.events?.emit?.(SUBAGENTS_LIVE_COUNT_EVENT, { count: liveState.count });
96
- pi.events?.emit?.(SUBAGENTS_LIVE_STATE_EVENT, liveState);
97
- updateCompletionWatcher();
107
+ try {
108
+ reconcileLiveAgentCompletions();
109
+ const liveState = createLiveStatePayload(liveAgents, currentSessionFile);
110
+ pi.events?.emit?.(SUBAGENTS_LIVE_COUNT_EVENT, { count: liveState.count });
111
+ pi.events?.emit?.(SUBAGENTS_LIVE_STATE_EVENT, liveState);
112
+ updateCompletionWatcher();
113
+ } catch (error) {
114
+ ignoreStaleExtensionContextError(error);
115
+ }
98
116
  }
99
117
 
100
118
  function removeLiveAgent(runDir: string, agentId: string): void {
@@ -147,10 +165,14 @@ export default function (pi: ExtensionAPI) {
147
165
  registerCommands(pi);
148
166
 
149
167
  pi.on("session_start", async (_event, ctx) => {
150
- sawAutoUltraworkCandidate = false;
151
- currentSessionFile = sessionFileFromContext(ctx);
152
- subagentOverlay.restoreRunningAgents(ctx.cwd, currentSessionFile);
153
- refreshSubagentOverlay();
168
+ try {
169
+ sawAutoUltraworkCandidate = false;
170
+ currentSessionFile = sessionFileFromContext(ctx);
171
+ subagentOverlay.restoreRunningAgents(ctx.cwd, currentSessionFile);
172
+ refreshSubagentOverlay();
173
+ } catch (error) {
174
+ ignoreStaleExtensionContextError(error);
175
+ }
154
176
  });
155
177
 
156
178
  pi.on("tool_execution_end", async (event) => {
@@ -207,18 +229,23 @@ export default function (pi: ExtensionAPI) {
207
229
  });
208
230
 
209
231
  pi.on("session_shutdown", async (event, ctx) => {
210
- subagentOverlay.dispose();
211
- if (completionWatchTimer) {
212
- clearInterval(completionWatchTimer);
213
- completionWatchTimer = undefined;
214
- }
215
- if (event?.reason === "reload" || event?.reason === "fork") return;
216
232
  try {
217
- await cleanupProjectSubagentState(ctx.cwd, liveAgents);
218
- liveAgents.clear();
219
- refreshSubagentOverlay();
220
- } catch {
221
- // Shutdown cleanup is best-effort and must never block the main session from closing.
233
+ subagentOverlay.dispose();
234
+ if (completionWatchTimer) {
235
+ clearInterval(completionWatchTimer);
236
+ completionWatchTimer = undefined;
237
+ }
238
+ if (event?.reason === "reload" || event?.reason === "fork") return;
239
+ try {
240
+ const shutdownSessionFile = sessionFileFromContext(ctx) ?? currentSessionFile;
241
+ await cleanupProjectSubagentState(ctx.cwd, liveAgents, { parentSession: shutdownSessionFile });
242
+ liveAgents.clear();
243
+ refreshSubagentOverlay();
244
+ } catch {
245
+ // Shutdown cleanup is best-effort and must never block the main session from closing.
246
+ }
247
+ } catch (error) {
248
+ ignoreStaleExtensionContextError(error);
222
249
  }
223
250
  });
224
251
  }
@@ -394,31 +421,100 @@ function selectedToolsInclude(event: unknown, toolName: string): boolean {
394
421
  return !Array.isArray(selectedTools) || selectedTools.includes(toolName);
395
422
  }
396
423
 
397
- async function cleanupProjectSubagentState(cwd: string, liveAgents: Map<string, Map<string, LiveAgent>>): Promise<void> {
398
- const shutdownTargets = collectShutdownTargets(cwd, liveAgents);
399
- const signaled = signalShutdownTargets(shutdownTargets, "SIGTERM");
424
+ async function cleanupProjectSubagentState(
425
+ cwd: string,
426
+ liveAgents: Map<string, Map<string, LiveAgent>>,
427
+ options: { parentSession?: string } = {},
428
+ ): Promise<void> {
429
+ const shutdownPlan = collectShutdownPlan(cwd, liveAgents, options.parentSession);
430
+ const signaled = signalShutdownTargets(shutdownPlan.targets, "SIGTERM");
400
431
  if (signaled > 0) await sleep(SESSION_SHUTDOWN_KILL_GRACE_MS);
401
- signalShutdownTargets(shutdownTargets, "SIGKILL");
432
+ signalShutdownTargets(shutdownPlan.targets, "SIGKILL");
402
433
 
403
- const runDirs = listRunDirs(cwd);
404
- for (const runDir of runDirs) stopRunBestEffort(runDir, undefined, "SIGKILL");
405
- deleteRunDirs(runDirs);
406
- removeSubagentRunsFromRegistry(cwd, runDirs);
434
+ for (const target of shutdownPlan.targets) stopRunBestEffort(target.runDir, target.agentIds, "SIGKILL");
435
+ deleteRunDirs(shutdownPlan.runDirsToDelete);
436
+ removeSubagentRunsFromRegistry(cwd, shutdownPlan.runDirsToDelete);
407
437
  removeEmptySubagentState(cwd);
408
438
  }
409
439
 
410
- function collectShutdownTargets(cwd: string, liveAgents: Map<string, Map<string, LiveAgent>>): ShutdownTarget[] {
411
- const targets = new Map<string, Set<string> | undefined>();
412
- for (const runDir of listRunDirs(cwd)) targets.set(runDir, undefined);
440
+
441
+ function collectShutdownPlan(
442
+ cwd: string,
443
+ liveAgents: Map<string, Map<string, LiveAgent>>,
444
+ parentSession: string | undefined,
445
+ ): ShutdownPlan {
446
+ if (!parentSession) return collectLiveShutdownPlan(liveAgents);
447
+
448
+ const targets = new Map<string, Set<string>>();
449
+ const runDirsToDelete = new Set<string>();
450
+ const recordsByRun = groupRecordsByRun(listSubagentSessionRecords(cwd));
451
+
452
+ for (const [runDir, records] of recordsByRun) {
453
+ const matchingRecords = records.filter((record) => recordMatchesSession(record, parentSession));
454
+ if (matchingRecords.length === 0) continue;
455
+ mergeTargetIds(targets, runDir, matchingRecords.map((record) => record.agentId));
456
+ const liveRun = liveAgents.get(runDir);
457
+ if (records.every((record) => recordMatchesSession(record, parentSession)) && liveRunMatchesSession(liveRun, parentSession)) {
458
+ runDirsToDelete.add(runDir);
459
+ }
460
+ }
461
+
413
462
  for (const [runDir, liveRun] of liveAgents) {
414
- const existing = targets.get(runDir);
415
- if (existing === undefined && targets.has(runDir)) continue;
416
- targets.set(runDir, new Set(liveRun.keys()));
463
+ const matchingIds = [...liveRun.values()]
464
+ .filter((agent) => liveAgentMatchesSession(agent, parentSession))
465
+ .map((agent) => agent.agentId);
466
+ if (matchingIds.length === 0) continue;
467
+ mergeTargetIds(targets, runDir, matchingIds);
468
+ const records = recordsByRun.get(runDir) ?? [];
469
+ if (records.every((record) => recordMatchesSession(record, parentSession)) && liveRunMatchesSession(liveRun, parentSession)) {
470
+ runDirsToDelete.add(runDir);
471
+ }
417
472
  }
418
- return [...targets].map(([runDir, agentIds]) => ({
473
+
474
+ return {
475
+ targets: targetMapToTargets(targets),
476
+ runDirsToDelete: [...runDirsToDelete],
477
+ };
478
+ }
479
+
480
+ function collectLiveShutdownPlan(liveAgents: Map<string, Map<string, LiveAgent>>): ShutdownPlan {
481
+ const targets = [...liveAgents.entries()].map(([runDir, liveRun]) => ({
419
482
  runDir,
420
- ...(agentIds ? { agentIds: [...agentIds] } : {}),
483
+ agentIds: [...liveRun.keys()],
421
484
  }));
485
+ return { targets, runDirsToDelete: targets.map((target) => target.runDir) };
486
+ }
487
+
488
+ function groupRecordsByRun(records: SubagentSessionRecord[]): Map<string, SubagentSessionRecord[]> {
489
+ const grouped = new Map<string, SubagentSessionRecord[]>();
490
+ for (const record of records) {
491
+ const existing = grouped.get(record.runDir) ?? [];
492
+ existing.push(record);
493
+ grouped.set(record.runDir, existing);
494
+ }
495
+ return grouped;
496
+ }
497
+
498
+ function recordMatchesSession(record: SubagentSessionRecord, sessionFile: string): boolean {
499
+ return Boolean(record.parentSession && pathsEqual(record.parentSession, sessionFile));
500
+ }
501
+
502
+ function liveAgentMatchesSession(agent: LiveAgent, sessionFile: string): boolean {
503
+ return Boolean(agent.parentSession && pathsEqual(agent.parentSession, sessionFile));
504
+ }
505
+
506
+ function liveRunMatchesSession(liveRun: Map<string, LiveAgent> | undefined, sessionFile: string): boolean {
507
+ return !liveRun || [...liveRun.values()].every((agent) => liveAgentMatchesSession(agent, sessionFile));
508
+ }
509
+
510
+ function mergeTargetIds(targets: Map<string, Set<string>>, runDir: string, agentIds: string[]): void {
511
+ const target = targets.get(runDir) ?? new Set<string>();
512
+ for (const agentId of agentIds) target.add(agentId);
513
+ targets.set(runDir, target);
514
+ }
515
+
516
+ function targetMapToTargets(targets: Map<string, Set<string>>): ShutdownTarget[] {
517
+ return [...targets.entries()].map(([runDir, agentIds]) => ({ runDir, agentIds: [...agentIds] }));
422
518
  }
423
519
 
424
520
  function signalShutdownTargets(targets: ShutdownTarget[], signal: StopSignal): number {
@@ -10,13 +10,18 @@ export interface ContextUsageProvider {
10
10
 
11
11
  export function isStaleExtensionContextError(error: unknown): boolean {
12
12
  if (!(error instanceof Error)) return false
13
- return /ctx is stale|stale after session replacement|stale after.*reload/i.test(error.message)
13
+ return /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message)
14
14
  }
15
15
 
16
16
  export function ignoreStaleExtensionContextError(error: unknown): void {
17
17
  if (!isStaleExtensionContextError(error)) throw error
18
18
  }
19
19
 
20
+ export function isAgentBusyRaceError(error: unknown): boolean {
21
+ if (!(error instanceof Error)) return false
22
+ return /Agent is already processing(?: a prompt)?\.|Wait for completion before continuing|Specify streamingBehavior/i.test(error.message)
23
+ }
24
+
20
25
  // Fork/newSession/switchSession/reload can invalidate a runner while late UI or
21
26
  // context events from the old runner are still unwinding. In that race,
22
27
  // ctx.getContextUsage() throws the Pi stale-ctx guard; treat it as unavailable
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui"
3
3
  import type { DcpState } from "./state.js"
4
- import type { DcpConfig } from "./config.js"
4
+ import { modelKeysFromContext, resolveModelConfig, type DcpConfig } from "./config.js"
5
5
  import type { DcpNudgeType } from "./pruner-types.js"
6
6
  import { isToolRecordProtected, markToolPruned } from "./pruner.js"
7
7
  import { safeGetContextUsage } from "../context-usage.js"
@@ -540,6 +540,7 @@ export function registerCommands(
540
540
  async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
541
541
  const parts = args.trim().split(/\s+/).filter(Boolean)
542
542
  const sub = parts[0] ?? ""
543
+ const effectiveConfig = resolveModelConfig(config, modelKeysFromContext(ctx))
543
544
 
544
545
  try {
545
546
  switch (sub) {
@@ -559,7 +560,7 @@ export function registerCommands(
559
560
  case "sweep": {
560
561
  const rawN = parts[1] !== undefined ? parseInt(parts[1], 10) : 0
561
562
  const n = isNaN(rawN) || rawN < 0 ? 0 : rawN
562
- await handleSweep(ctx, state, config, n)
563
+ await handleSweep(ctx, state, effectiveConfig, n)
563
564
  break
564
565
  }
565
566
 
@@ -5,7 +5,7 @@
5
5
  import { Type } from "typebox"
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
7
7
  import type { DcpState } from "./state.js"
8
- import type { DcpConfig } from "./config.js"
8
+ import { modelKeysFromContext, resolveModelConfig, type DcpConfig } from "./config.js"
9
9
  import { clearDcpNudgeAnchors } from "./pruner.js"
10
10
  import type { DcpCompressionVisualDetails } from "./ui.js"
11
11
  import { normalizeDcpContextUsage } from "./ui.js"
@@ -149,6 +149,11 @@ export function registerCompressTool(
149
149
  }),
150
150
 
151
151
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
152
+ const effectiveConfig = resolveModelConfig(config, modelKeysFromContext(ctx))
153
+ if (!effectiveConfig.enabled) {
154
+ throw new Error("DCP is disabled for the active model")
155
+ }
156
+
152
157
  const newBlockIds: number[] = []
153
158
  const ranges = Array.isArray(params.ranges) ? params.ranges : []
154
159
  const messages = Array.isArray(params.messages) ? params.messages : []
@@ -215,7 +220,7 @@ export function registerCompressTool(
215
220
  anchorMessageId: anchor.stableId,
216
221
  createdByToolCallId: _toolCallId,
217
222
  state,
218
- config,
223
+ config: effectiveConfig,
219
224
  mode: "range",
220
225
  })
221
226
  const block = created.block
@@ -260,7 +265,7 @@ export function registerCompressTool(
260
265
  skippedMessageIssues.push({ kind: "non-finite", messageId })
261
266
  continue
262
267
  }
263
- if (config.compress.protectUserMessages && meta.role === "user") {
268
+ if (effectiveConfig.compress.protectUserMessages && meta.role === "user") {
264
269
  skippedMessageIssues.push({ kind: "protected-user", messageId })
265
270
  continue
266
271
  }
@@ -291,7 +296,7 @@ export function registerCompressTool(
291
296
  anchorMessageId: anchor.stableId,
292
297
  createdByToolCallId: _toolCallId,
293
298
  state,
294
- config,
299
+ config: effectiveConfig,
295
300
  mode: "message",
296
301
  validatePlaceholders: false,
297
302
  expandPlaceholders: false,
@@ -25,7 +25,7 @@ export interface DcpConfig {
25
25
  modelMinContextLimits?: Record<string, number | string> // same formats as modelMinContextPercent; checked before modelMinContextPercent
26
26
  summaryBuffer: boolean
27
27
  nudgeFrequency: number // inject nudge every N context events (default: 2)
28
- iterationNudgeThreshold: number // nudge after N tool calls since last user msg (default: 8)
28
+ iterationNudgeThreshold: number // nudge after N tool calls since last user msg (default: 4)
29
29
  nudgeForce: "strong" | "soft"
30
30
  protectedTools: string[] // these tool outputs always protected from pruning
31
31
  protectTags: boolean
@@ -67,8 +67,17 @@ export interface DcpConfig {
67
67
  }
68
68
  protectedFilePatterns: string[]
69
69
  pruneNotification: "off" | "minimal" | "detailed"
70
+ modelOverrides: Record<string, DcpConfigOverride>
70
71
  }
71
72
 
73
+ export type DcpConfigOverride = DeepPartial<Omit<DcpConfig, "modelOverrides">>
74
+
75
+ type DeepPartial<T> = T extends Array<infer U>
76
+ ? Array<DeepPartial<U>>
77
+ : T extends object
78
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
79
+ : T
80
+
72
81
  // ---------------------------------------------------------------------------
73
82
  // Defaults
74
83
  // ---------------------------------------------------------------------------
@@ -87,7 +96,7 @@ const DEFAULT_CONFIG: DcpConfig = {
87
96
  modelMinContextPercent: {},
88
97
  summaryBuffer: true,
89
98
  nudgeFrequency: 1,
90
- iterationNudgeThreshold: 6,
99
+ iterationNudgeThreshold: 4,
91
100
  nudgeForce: "soft",
92
101
  protectedTools: ["compress", "write", "edit"],
93
102
  protectTags: false,
@@ -95,14 +104,14 @@ const DEFAULT_CONFIG: DcpConfig = {
95
104
  autoCandidates: {
96
105
  enabled: true,
97
106
  minContextPercent: 0.20,
98
- keepRecentTurns: 2,
107
+ keepRecentTurns: 1,
99
108
  minMessages: 6,
100
109
  minTokens: 1500,
101
110
  },
102
111
  messageMode: {
103
112
  enabled: true,
104
113
  minContextPercent: 0.20,
105
- keepRecentTurns: 2,
114
+ keepRecentTurns: 1,
106
115
  mediumTokens: 500,
107
116
  highTokens: 5000,
108
117
  maxSuggestions: 5,
@@ -120,13 +129,17 @@ const DEFAULT_CONFIG: DcpConfig = {
120
129
  },
121
130
  autoToolPruning: {
122
131
  enabled: true,
123
- maxOutputTokens: 2000,
124
- keepRecentTurns: 2,
132
+ maxOutputTokens: 1200,
133
+ keepRecentTurns: 1,
125
134
  readLikeTools: [
126
135
  "read",
136
+ "shell",
137
+ "bash",
127
138
  "grep",
128
139
  "find",
129
140
  "ls",
141
+ "web_search",
142
+ "web_fetch",
130
143
  "repo_architecture",
131
144
  "repo_structure",
132
145
  "repo_ast",
@@ -141,6 +154,7 @@ const DEFAULT_CONFIG: DcpConfig = {
141
154
  },
142
155
  protectedFilePatterns: [],
143
156
  pruneNotification: "detailed",
157
+ modelOverrides: {},
144
158
  }
145
159
 
146
160
  // ---------------------------------------------------------------------------
@@ -226,6 +240,128 @@ function mergeSuiteDcpConfig(config: DcpConfig, filePath: string): DcpConfig {
226
240
  return deepMerge(config, raw as Partial<DcpConfig>)
227
241
  }
228
242
 
243
+ function normalizeModelKey(key: string | undefined): string | undefined {
244
+ if (typeof key !== "string") return undefined
245
+ const trimmed = key.trim()
246
+ return trimmed.length > 0 ? trimmed : undefined
247
+ }
248
+
249
+ function escapeRegExp(text: string): string {
250
+ return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
251
+ }
252
+
253
+ function globToRegExp(pattern: string): RegExp {
254
+ let source = "^"
255
+ for (let i = 0; i < pattern.length; i++) {
256
+ const char = pattern[i]!
257
+ if (char === "*") {
258
+ source += ".*"
259
+ } else if (char === "?") {
260
+ source += "."
261
+ } else {
262
+ source += escapeRegExp(char)
263
+ }
264
+ }
265
+ source += "$"
266
+ return new RegExp(source)
267
+ }
268
+
269
+ function isWildcardPattern(pattern: string): boolean {
270
+ return pattern.includes("*") || pattern.includes("?")
271
+ }
272
+
273
+ function modelPatternMatches(pattern: string, candidate: string): boolean {
274
+ return globToRegExp(pattern).test(candidate)
275
+ }
276
+
277
+ function uniqueModelCandidates(modelKeys: Array<string | undefined>): string[] {
278
+ return modelKeys
279
+ .map((key) => normalizeModelKey(key))
280
+ .filter((key, index, array): key is string => typeof key === "string" && array.indexOf(key) === index)
281
+ }
282
+
283
+ export function matchingModelEntries<T>(
284
+ record: Record<string, T> | undefined,
285
+ modelKeys: Array<string | undefined> = [],
286
+ ): Array<[string, T]> {
287
+ if (!record || Object.keys(record).length === 0) return []
288
+
289
+ const candidates = uniqueModelCandidates(modelKeys)
290
+ if (candidates.length === 0) return []
291
+
292
+ const exactEntries = new Map<string, T>()
293
+ const wildcardEntries: Array<[string, T]> = []
294
+
295
+ for (const [rawKey, value] of Object.entries(record)) {
296
+ const key = normalizeModelKey(rawKey)
297
+ if (!key) continue
298
+ if (isWildcardPattern(key)) wildcardEntries.push([key, value])
299
+ else exactEntries.set(key, value)
300
+ }
301
+
302
+ const fullCandidates = candidates.filter((candidate) => candidate.includes("/"))
303
+ const bareCandidates = candidates.filter((candidate) => !candidate.includes("/"))
304
+ const matches: Array<[string, T]> = []
305
+
306
+ for (const candidate of bareCandidates) {
307
+ for (const entry of wildcardEntries) {
308
+ if (entry[0].includes("/")) continue
309
+ if (modelPatternMatches(entry[0], candidate)) matches.push(entry)
310
+ }
311
+ }
312
+
313
+ for (const candidate of bareCandidates) {
314
+ const value = exactEntries.get(candidate)
315
+ if (value !== undefined) matches.push([candidate, value])
316
+ }
317
+
318
+ for (const candidate of fullCandidates) {
319
+ for (const entry of wildcardEntries) {
320
+ if (!entry[0].includes("/")) continue
321
+ if (modelPatternMatches(entry[0], candidate)) matches.push(entry)
322
+ }
323
+ }
324
+
325
+ for (const candidate of fullCandidates) {
326
+ const value = exactEntries.get(candidate)
327
+ if (value !== undefined) matches.push([candidate, value])
328
+ }
329
+
330
+ return matches
331
+ }
332
+
333
+ export function modelKeysFromContext(ctx: unknown): string[] {
334
+ const ctxModel = (ctx as any)?.model
335
+ const provider = normalizeModelKey(
336
+ ctxModel?.provider ?? ctxModel?.providerId ?? ctxModel?.providerID,
337
+ )
338
+ const model = normalizeModelKey(
339
+ ctxModel?.id ?? ctxModel?.model ?? ctxModel?.modelId ?? ctxModel?.modelID,
340
+ )
341
+
342
+ return [provider && model ? `${provider}/${model}` : undefined, model].filter(
343
+ (key): key is string => typeof key === "string",
344
+ )
345
+ }
346
+
347
+ export function resolveModelConfig(
348
+ config: DcpConfig,
349
+ modelKeys: Array<string | undefined> = [],
350
+ ): DcpConfig {
351
+ const overrides = config.modelOverrides
352
+ if (!overrides || Object.keys(overrides).length === 0) return config
353
+
354
+ const matches = matchingModelEntries(overrides, modelKeys)
355
+ if (matches.length === 0) return config
356
+
357
+ let resolved = deepMerge(config, {})
358
+ for (const [, override] of matches) {
359
+ resolved = deepMerge(resolved, override as Partial<DcpConfig>)
360
+ }
361
+
362
+ return resolved
363
+ }
364
+
229
365
  // ---------------------------------------------------------------------------
230
366
  // Public API
231
367
  // ---------------------------------------------------------------------------
@@ -3,7 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
6
- import { loadConfig } from "./config.js"
6
+ import { loadConfig, modelKeysFromContext, resolveModelConfig } from "./config.js"
7
7
  import {
8
8
  createState,
9
9
  resetState,
@@ -102,8 +102,12 @@ function isDcpControlPlaneMessage(message: any): boolean {
102
102
  export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
103
103
  // ── 1. Load config ────────────────────────────────────────────────────────
104
104
  const config = loadConfig()
105
+ const configForContext = (ctx: unknown) => resolveModelConfig(config, modelKeysFromContext(ctx))
106
+ const hasEnabledModelOverride = Object.values(config.modelOverrides).some(
107
+ (override) => override.enabled === true,
108
+ )
105
109
 
106
- if (!config.enabled) return
110
+ if (!config.enabled && !hasEnabledModelOverride) return
107
111
 
108
112
  // ── 2. Create state ───────────────────────────────────────────────────────
109
113
  const state = createState()
@@ -175,6 +179,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
175
179
 
176
180
  // ── 7. before_agent_start: inject system prompt ───────────────────────────
177
181
  pi.on("before_agent_start", async (event, _ctx) => {
182
+ const effectiveConfig = configForContext(_ctx)
183
+ if (!effectiveConfig.enabled) return { systemPrompt: event.systemPrompt }
184
+
178
185
  const promptAddition = state.manualMode
179
186
  ? MANUAL_MODE_SYSTEM_PROMPT
180
187
  : SYSTEM_PROMPT
@@ -239,11 +246,15 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
239
246
 
240
247
  // ── 10. context: apply pruning and inject nudges ──────────────────────────
241
248
  pi.on("context", async (event, ctx) => {
249
+ const effectiveConfig = configForContext(ctx)
242
250
  const contextMessages = event.messages.filter((message: any) =>
243
251
  !isUserVisibleOnlyMessage(message) && !isDcpControlPlaneMessage(message)
244
252
  )
253
+ if (!effectiveConfig.enabled) {
254
+ return { messages: contextMessages }
255
+ }
245
256
  annotateMessagesWithBranchEntryIds(contextMessages, ctx)
246
- let prunedMessages = applyPruning(contextMessages, state, config)
257
+ let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
247
258
  let candidate = null as ReturnType<typeof detectCompressionCandidate>
248
259
  let messageCandidates = [] as ReturnType<typeof detectMessageCompressionCandidates>
249
260
 
@@ -273,11 +284,11 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
273
284
  const ctxModel = (ctx as any).model
274
285
  const provider = ctxModel?.provider ?? ctxModel?.providerId ?? ctxModel?.providerID
275
286
  const model = ctxModel?.id ?? ctxModel?.model ?? ctxModel?.modelId ?? ctxModel?.modelID
276
- const thresholds = resolveContextThresholds(config, [
287
+ const thresholds = resolveContextThresholds(effectiveConfig, [
277
288
  provider && model ? `${provider}/${model}` : undefined,
278
289
  model,
279
290
  ], usage.contextWindow)
280
- if (config.compress.summaryBuffer) {
291
+ if (effectiveConfig.compress.summaryBuffer) {
281
292
  const summaryBonus = getActiveSummaryTokenEstimate(state) / usage.contextWindow
282
293
  thresholds.maxContextPercent += Math.min(summaryBonus, SUMMARY_BUFFER_MAX_CONTEXT_BONUS)
283
294
  }
@@ -292,7 +303,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
292
303
  const nudgeType = getNudgeType(
293
304
  contextPercent,
294
305
  state,
295
- config,
306
+ effectiveConfig,
296
307
  toolCallsSinceLastUser,
297
308
  thresholds,
298
309
  )
@@ -304,12 +315,13 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
304
315
  candidate = detectCompressionCandidate(
305
316
  prunedMessages,
306
317
  state,
307
- config,
318
+ effectiveConfig,
308
319
  contextPercent,
309
320
  )
310
321
  messageCandidates = detectMessageCompressionCandidates(
311
322
  prunedMessages,
312
- config,
323
+ state,
324
+ effectiveConfig,
313
325
  contextPercent,
314
326
  )
315
327