@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -0,0 +1,86 @@
1
+ import type {
2
+ Context,
3
+ DeveloperMessage,
4
+ ImageContent,
5
+ Model,
6
+ TextContent,
7
+ ToolResultMessage,
8
+ UserMessage,
9
+ } from "@oh-my-pi/pi-ai";
10
+ import { providerImageBudget } from "@oh-my-pi/snapcompact";
11
+
12
+ const TOOL_RESULT_IMAGE_OMISSION: TextContent = {
13
+ type: "text",
14
+ text: "[image omitted: provider image limit]",
15
+ };
16
+
17
+ function countImages(context: Context): number {
18
+ let count = 0;
19
+ for (const message of context.messages) {
20
+ if (!Array.isArray(message.content)) continue;
21
+ for (const part of message.content) {
22
+ if (part.type === "image") count++;
23
+ }
24
+ }
25
+ return count;
26
+ }
27
+
28
+ function clampContent(
29
+ content: readonly (TextContent | ImageContent)[],
30
+ state: { remainingDrops: number },
31
+ ): (TextContent | ImageContent)[] | undefined {
32
+ let changed = false;
33
+ const clamped: (TextContent | ImageContent)[] = [];
34
+ for (const part of content) {
35
+ if (part.type === "image" && state.remainingDrops > 0) {
36
+ state.remainingDrops--;
37
+ changed = true;
38
+ continue;
39
+ }
40
+ clamped.push(part);
41
+ }
42
+ return changed ? clamped : undefined;
43
+ }
44
+
45
+ function clampUserMessage(message: UserMessage, state: { remainingDrops: number }): UserMessage {
46
+ if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
47
+ const content = clampContent(message.content, state);
48
+ return content ? { ...message, content } : message;
49
+ }
50
+
51
+ function clampDeveloperMessage(message: DeveloperMessage, state: { remainingDrops: number }): DeveloperMessage {
52
+ if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
53
+ const content = clampContent(message.content, state);
54
+ return content ? { ...message, content } : message;
55
+ }
56
+
57
+ function clampToolResultMessage(message: ToolResultMessage, state: { remainingDrops: number }): ToolResultMessage {
58
+ if (state.remainingDrops <= 0) return message;
59
+ const content = clampContent(message.content, state);
60
+ if (!content) return message;
61
+ return { ...message, content: content.length > 0 ? content : [TOOL_RESULT_IMAGE_OMISSION] };
62
+ }
63
+
64
+ /** Drops oldest transient image blocks so outgoing vision requests fit the active provider's image cap. */
65
+ export function clampProviderContextImages(context: Context, model: Model): Context {
66
+ if (!model.input.includes("image")) return context;
67
+ const limit = providerImageBudget(model.provider);
68
+ const totalImages = countImages(context);
69
+ if (totalImages <= limit) return context;
70
+
71
+ const state = { remainingDrops: totalImages - limit };
72
+ const messages = context.messages.map(message => {
73
+ switch (message.role) {
74
+ case "user":
75
+ return clampUserMessage(message, state);
76
+ case "developer":
77
+ return clampDeveloperMessage(message, state);
78
+ case "toolResult":
79
+ return clampToolResultMessage(message, state);
80
+ case "assistant":
81
+ return message;
82
+ }
83
+ return message;
84
+ });
85
+ return { ...context, messages };
86
+ }
@@ -62,13 +62,14 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
62
62
 
63
63
  export interface BuildSessionContextOptions {
64
64
  /**
65
- * Build the full-history display transcript instead of the LLM context:
66
- * every path entry in chronological order, with each compaction emitted
67
- * inline as a `compactionSummary` message at the position it fired rather
68
- * than replacing the history before it. Display-only — never send the
69
- * result to a provider.
65
+ * Build the display transcript instead of the LLM context. By default this
66
+ * preserves every path entry with compactions inline; set
67
+ * `collapseCompactedHistory` for the live TUI surface to render only the
68
+ * latest compacted tail.
70
69
  */
71
70
  transcript?: boolean;
71
+ /** In transcript mode, elide entries replaced by the latest compaction. */
72
+ collapseCompactedHistory?: boolean;
72
73
  }
73
74
 
74
75
  /**
@@ -255,7 +256,7 @@ export function buildSessionContext(
255
256
  }
256
257
  };
257
258
 
258
- if (options?.transcript) {
259
+ if (options?.transcript && !options.collapseCompactedHistory) {
259
260
  // Display transcript: every entry in chronological order. Compactions do
260
261
  // not erase prior history here — each renders inline (as a divider in the
261
262
  // TUI) at the point it fired, with any snapcompact frames re-attached so
@@ -294,6 +295,7 @@ export function buildSessionContext(
294
295
  })();
295
296
  const remoteReplacementHistory = providerPayload?.items;
296
297
 
298
+ if (options?.transcript) handleEntryResetTracking(compaction);
297
299
  // Emit summary first; re-attach any archived snapcompact frames so the
298
300
  // model can keep reading the archived history after every context rebuild.
299
301
  const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
@@ -312,7 +314,12 @@ export function buildSessionContext(
312
314
  // Find compaction index in path
313
315
  const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
314
316
 
315
- if (!remoteReplacementHistory) {
317
+ // The remote replacement payload (OpenAI remote compaction) carries the
318
+ // kept turns for the LLM context only; it is not rendered as visible
319
+ // messages. The collapsed display transcript must still emit the kept
320
+ // SessionEntry rows so a remotely-compacted session keeps its recent
321
+ // turns visible instead of showing only the summary and post-compaction.
322
+ if (!remoteReplacementHistory || options?.transcript) {
316
323
  // Emit kept messages (before compaction, starting from firstKeptEntryId)
317
324
  let foundFirstKept = false;
318
325
  for (let i = 0; i < compactionIdx; i++) {
@@ -137,8 +137,30 @@ export class FileSessionStorage implements SessionStorage {
137
137
  }
138
138
 
139
139
  writeTextSync(fpath: string, content: string): void {
140
- this.ensureDirSync(path.dirname(fpath));
141
- fs.writeFileSync(fpath, content);
140
+ const dir = path.dirname(fpath);
141
+ this.ensureDirSync(dir);
142
+ const tempPath = path.join(dir, `.${path.basename(fpath)}.${Snowflake.next()}.tmp`);
143
+ try {
144
+ fs.writeFileSync(tempPath, content);
145
+ fs.renameSync(tempPath, fpath);
146
+ } catch (err) {
147
+ try {
148
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
149
+ } catch (cleanupErr) {
150
+ if (!isEnoent(cleanupErr)) {
151
+ logger.warn("Failed to remove session rewrite temp file", {
152
+ sessionFile: fpath,
153
+ tempPath,
154
+ error: toError(cleanupErr).message,
155
+ });
156
+ }
157
+ }
158
+ if (hasFsCode(err, "EPERM")) {
159
+ fs.writeFileSync(fpath, content);
160
+ return;
161
+ }
162
+ throw toError(err);
163
+ }
142
164
  }
143
165
 
144
166
  statSync(path: string): SessionStorageStat {
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { countTokens } from "@oh-my-pi/pi-agent-core";
18
18
  import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
19
+ import { isPersonalGitHubCopilotBaseUrl } from "@oh-my-pi/pi-catalog/wire/github-copilot";
19
20
  import * as snapcompact from "@oh-my-pi/snapcompact";
20
21
  import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
21
22
  import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -77,6 +78,19 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
77
78
  return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
78
79
  }
79
80
 
81
+ /**
82
+ * The model is vision-capable for the endpoint we're actually about to hit.
83
+ * GitHub Copilot business and enterprise hosts respond `400 vision is not
84
+ * supported` on image inputs (issue #3387), so even if a stale cached spec
85
+ * still advertises `["text","image"]` we MUST not rasterize transcripts when
86
+ * the resolved `baseUrl` is non-personal.
87
+ */
88
+ function canSendImages(model: Model): boolean {
89
+ if (!model.input.includes("image")) return false;
90
+ if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
91
+ return true;
92
+ }
93
+
80
94
  interface SystemPromptImageTarget {
81
95
  scope: Exclude<SnapcompactSystemPromptMode, "none">;
82
96
  text: string;
@@ -277,7 +291,7 @@ export function estimateInlineSavings(input: {
277
291
  messages: readonly InlineMessageView[];
278
292
  }): SnapcompactSavingsEstimate {
279
293
  const { options, model } = input;
280
- if (!model?.input.includes("image")) {
294
+ if (!model || !canSendImages(model)) {
281
295
  return { visionCapable: false, savedTokens: 0 };
282
296
  }
283
297
 
@@ -416,8 +430,10 @@ export class SnapcompactInlineTransformer {
416
430
 
417
431
  async transform(context: Context, model: Model): Promise<Context> {
418
432
  // Vision gate: providers silently DROP images on text-only models —
419
- // rendering would lose the content entirely.
420
- if (!model.input.includes("image")) return context;
433
+ // rendering would lose the content entirely. Also short-circuits when the
434
+ // resolved endpoint rejects vision regardless of the model's input list
435
+ // (issue #3387: Copilot business endpoint).
436
+ if (!canSendImages(model)) return context;
421
437
 
422
438
  const shape = snapcompact.resolveShape(model, this.options.shape);
423
439
  const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);
@@ -239,11 +239,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
239
239
  return "Plan: off";
240
240
  },
241
241
  handleTui: async (command, runtime) => {
242
- const hadArgs = !!command.args;
243
242
  await runtime.ctx.handlePlanModeCommand(command.args || undefined);
244
- if (hadArgs) {
245
- runtime.ctx.editor.addToHistory(command.text);
246
- }
247
243
  runtime.ctx.editor.setText("");
248
244
  },
249
245
  },
@@ -277,11 +273,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
277
273
  return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off";
278
274
  },
279
275
  handleTui: async (command, runtime) => {
280
- const hadArgs = !!command.args;
281
276
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
282
- if (hadArgs) {
283
- runtime.ctx.editor.addToHistory(command.text);
284
- }
285
277
  runtime.ctx.editor.setText("");
286
278
  },
287
279
  },
@@ -1270,7 +1262,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1270
1262
  allowArgs: true,
1271
1263
  handle: handleMcpAcp,
1272
1264
  handleTui: async (command, runtime) => {
1273
- runtime.ctx.editor.addToHistory(command.text);
1274
1265
  runtime.ctx.editor.setText("");
1275
1266
  await runtime.ctx.handleMCPCommand(command.text);
1276
1267
  },
@@ -1293,7 +1284,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1293
1284
  allowArgs: true,
1294
1285
  handle: handleSshAcp,
1295
1286
  handleTui: async (command, runtime) => {
1296
- runtime.ctx.editor.addToHistory(command.text);
1297
1287
  runtime.ctx.editor.setText("");
1298
1288
  await runtime.ctx.handleSSHCommand(command.text);
1299
1289
  },
@@ -1436,9 +1426,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1436
1426
  allowArgs: true,
1437
1427
  handleTui: async (command, runtime) => {
1438
1428
  const question = command.text.slice(`/${command.name}`.length).trim();
1439
- if (question) {
1440
- runtime.ctx.editor.addToHistory(command.text);
1441
- }
1442
1429
  runtime.ctx.editor.setText("");
1443
1430
  await runtime.ctx.handleBtwCommand(question);
1444
1431
  },
@@ -1450,9 +1437,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1450
1437
  allowArgs: true,
1451
1438
  handleTui: async (command, runtime) => {
1452
1439
  const work = command.text.slice(`/${command.name}`.length).trim();
1453
- if (work) {
1454
- runtime.ctx.editor.addToHistory(command.text);
1455
- }
1456
1440
  runtime.ctx.editor.setText("");
1457
1441
  await runtime.ctx.handleTanCommand(work);
1458
1442
  },
@@ -1464,9 +1448,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1464
1448
  allowArgs: true,
1465
1449
  handleTui: async (command, runtime) => {
1466
1450
  const complaint = command.text.slice(`/${command.name}`.length).trim();
1467
- if (complaint) {
1468
- runtime.ctx.editor.addToHistory(command.text);
1469
- }
1470
1451
  runtime.ctx.editor.setText("");
1471
1452
  await runtime.ctx.handleOmfgCommand(complaint);
1472
1453
  },
@@ -1558,7 +1539,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1558
1539
  }
1559
1540
  },
1560
1541
  handleTui: async (command, runtime) => {
1561
- runtime.ctx.editor.addToHistory(command.text);
1562
1542
  runtime.ctx.editor.setText("");
1563
1543
  await runtime.ctx.handleMemoryCommand(command.text);
1564
1544
  },
@@ -1586,7 +1566,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1586
1566
  runtime.ctx.editor.setText("");
1587
1567
  return;
1588
1568
  }
1589
- runtime.ctx.editor.addToHistory(command.text);
1590
1569
  runtime.ctx.editor.setText("");
1591
1570
  await runtime.ctx.handleRenameCommand(title);
1592
1571
  },
@@ -1629,7 +1608,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1629
1608
  runtime.ctx.editor.setText("");
1630
1609
  return;
1631
1610
  }
1632
- runtime.ctx.editor.addToHistory(command.text);
1633
1611
  runtime.ctx.editor.setText("");
1634
1612
  await runtime.ctx.handleMoveCommand(targetPath);
1635
1613
  },
@@ -1,4 +1,5 @@
1
1
  import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
2
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
2
3
  import type { OAuthAccountIdentity } from "../../session/auth-storage";
3
4
  import type { SlashCommandRuntime } from "../types";
4
5
  import { reportMatchesActiveAccount } from "./active-oauth-account";
@@ -52,6 +53,10 @@ function renderUsageReports(
52
53
  )) {
53
54
  lines.push("", formatProviderName(provider));
54
55
  const activeAccount = resolveActiveAccount?.(provider);
56
+ // Provider-wide disclaimers render once per provider, not per limit.
57
+ const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
58
+ for (const note of providerNotes)
59
+ lines.push(` ${sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))}`);
55
60
  for (const report of providerReports) {
56
61
  const inUse = reportMatchesActiveAccount(report, activeAccount);
57
62
  const savedResets = report.resetCredits?.availableCount ?? 0;
@@ -83,7 +88,10 @@ function renderUsageReports(
83
88
  if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
84
89
  lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
85
90
  }
86
- if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
91
+ if (limit.notes && limit.notes.length > 0)
92
+ lines.push(
93
+ ` ${limit.notes.map(n => sanitizeText(n.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))).join(" • ")}`,
94
+ );
87
95
  }
88
96
  }
89
97
  }
package/src/task/index.ts CHANGED
@@ -47,29 +47,22 @@ import type { AsyncJobManager } from "../async";
47
47
  import type { LocalProtocolOptions } from "../internal-urls";
48
48
  import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
49
49
  import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
50
- import { generateCommitMessage } from "../utils/commit-message-generator";
51
- import * as git from "../utils/git";
52
50
  import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
53
51
  import { runSubprocess } from "./executor";
52
+ import {
53
+ applyEligibleNestedPatches,
54
+ type IsolationContext,
55
+ makeIsolationCommitMessage,
56
+ mergeIsolatedChanges,
57
+ prepareIsolationContext,
58
+ runIsolatedSubprocess,
59
+ } from "./isolation-runner";
54
60
  import { generateTaskName } from "./name-generator";
55
61
  import { AgentOutputManager } from "./output-manager";
56
62
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
57
63
  import { renderResult, renderCall as renderTaskCall } from "./render";
58
64
  import { repairTaskParams } from "./repair-args";
59
- import {
60
- applyNestedPatches,
61
- captureBaseline,
62
- captureDeltaPatch,
63
- cleanupIsolation,
64
- cleanupTaskBranches,
65
- commitToBranch,
66
- ensureIsolation,
67
- getRepoRoot,
68
- type IsolationHandle,
69
- mergeTaskBranches,
70
- parseIsolationMode,
71
- type WorktreeBaseline,
72
- } from "./worktree";
65
+ import { parseIsolationMode } from "./worktree";
73
66
 
74
67
  function renderSubagentUserPrompt(assignment: string): string {
75
68
  return prompt.render(subagentUserPromptTemplate, {
@@ -1047,7 +1040,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1047
1040
  const isolationRequested = "isolated" in params ? params.isolated === true : false;
1048
1041
  const isIsolated = isolationMode !== "none" && isolationRequested;
1049
1042
  const mergeMode = this.session.settings.get("task.isolation.merge");
1050
- const commitStyle = this.session.settings.get("task.isolation.commits");
1051
1043
  const taskDepth = this.session.taskDepth ?? 0;
1052
1044
  const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
1053
1045
 
@@ -1118,12 +1110,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1118
1110
  // structured output go through eval agent(prompt, schema).
1119
1111
  const effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema;
1120
1112
 
1121
- let repoRoot: string | null = null;
1122
- let baseline: WorktreeBaseline | null = null;
1113
+ let isolationContext: IsolationContext | null = null;
1123
1114
  if (isIsolated) {
1124
1115
  try {
1125
- repoRoot = await getRepoRoot(this.session.cwd);
1126
- baseline = await captureBaseline(repoRoot);
1116
+ isolationContext = await prepareIsolationContext(this.session.cwd);
1127
1117
  } catch (err) {
1128
1118
  const message = err instanceof Error ? err.message : String(err);
1129
1119
  return {
@@ -1132,6 +1122,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1132
1122
  };
1133
1123
  }
1134
1124
  }
1125
+ const repoRoot = isolationContext?.repoRoot ?? null;
1135
1126
 
1136
1127
  const preferredIsolationBackend = parseIsolationMode(isolationMode);
1137
1128
 
@@ -1251,17 +1242,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1251
1242
  };
1252
1243
  emitProgress();
1253
1244
 
1254
- const buildCommitMessageFn = () =>
1255
- commitStyle === "ai" && this.session.modelRegistry
1256
- ? async (diff: string) => {
1257
- return generateCommitMessage(
1258
- diff,
1259
- this.session.modelRegistry!,
1260
- this.session.settings,
1261
- this.session.getSessionId?.() ?? undefined,
1262
- );
1263
- }
1264
- : undefined;
1245
+ const buildCommitMessageFn = makeIsolationCommitMessage(this.session);
1265
1246
 
1266
1247
  const sharedRunOptions = {
1267
1248
  cwd: this.session.cwd,
@@ -1321,192 +1302,65 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1321
1302
  if (!isIsolated) {
1322
1303
  return runSubprocess(sharedRunOptions);
1323
1304
  }
1324
-
1325
- const taskStart = Date.now();
1326
- let isolationHandle: IsolationHandle | undefined;
1327
- try {
1328
- if (!repoRoot || !baseline) {
1329
- throw new Error("Isolated task execution not initialized.");
1330
- }
1331
- const taskBaseline = structuredClone(baseline);
1332
-
1333
- isolationHandle = await ensureIsolation(repoRoot, agentId, preferredIsolationBackend);
1334
- const isolationDir = isolationHandle.mergedDir;
1335
-
1336
- // Isolated runs re-discover extensions/custom tools inside the
1337
- // worktree instead of reusing the parent's source paths.
1338
- const result = await runSubprocess({
1339
- ...sharedRunOptions,
1340
- worktree: isolationDir,
1341
- preloadedExtensionPaths: undefined,
1342
- preloadedCustomToolPaths: undefined,
1343
- });
1344
- if (mergeMode === "branch" && result.exitCode === 0) {
1345
- try {
1346
- const commitResult = await commitToBranch(
1347
- isolationDir,
1348
- taskBaseline,
1349
- agentId,
1350
- params.description,
1351
- buildCommitMessageFn(),
1352
- );
1353
- return {
1354
- ...result,
1355
- branchName: commitResult?.branchName,
1356
- nestedPatches: commitResult?.nestedPatches,
1357
- };
1358
- } catch (mergeErr) {
1359
- // Agent succeeded but branch commit failed — clean up stale branch
1360
- const branchName = `omp/task/${agentId}`;
1361
- await git.branch.tryDelete(repoRoot, branchName);
1362
- const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1363
- return { ...result, error: `Merge failed: ${msg}` };
1364
- }
1365
- }
1366
- if (result.exitCode === 0) {
1367
- try {
1368
- const delta = await captureDeltaPatch(isolationDir, taskBaseline);
1369
- const patchPath = path.join(effectiveArtifactsDir, `${agentId}.patch`);
1370
- await Bun.write(patchPath, delta.rootPatch);
1371
- return {
1372
- ...result,
1373
- patchPath,
1374
- nestedPatches: delta.nestedPatches,
1375
- };
1376
- } catch (patchErr) {
1377
- const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
1378
- return { ...result, error: `Patch capture failed: ${msg}` };
1379
- }
1380
- }
1381
- return result;
1382
- } catch (err) {
1383
- const message = err instanceof Error ? err.message : String(err);
1384
- return {
1385
- index: spawnIndex,
1386
- id: agentId,
1387
- agent: agent.name,
1388
- agentSource: agent.source,
1389
- task: renderSubagentUserPrompt(assignment),
1390
- assignment,
1391
- description: params.description,
1392
- exitCode: 1,
1393
- output: "",
1394
- stderr: message,
1395
- truncated: false,
1396
- durationMs: Date.now() - taskStart,
1397
- tokens: 0,
1398
- requests: 0,
1399
- modelOverride,
1400
- error: message,
1401
- };
1402
- } finally {
1403
- if (isolationHandle) {
1404
- await cleanupIsolation(isolationHandle);
1405
- }
1305
+ if (!isolationContext) {
1306
+ throw new Error("Isolated task execution not initialized.");
1406
1307
  }
1308
+ const taskStart = Date.now();
1309
+ return runIsolatedSubprocess({
1310
+ baseOptions: sharedRunOptions,
1311
+ context: isolationContext,
1312
+ preferredBackend: preferredIsolationBackend,
1313
+ agentId,
1314
+ mergeMode,
1315
+ artifactsDir: effectiveArtifactsDir,
1316
+ description: params.description,
1317
+ buildCommitMessage: buildCommitMessageFn,
1318
+ buildFailureResult: err => {
1319
+ const message = err instanceof Error ? err.message : String(err);
1320
+ return {
1321
+ index: spawnIndex,
1322
+ id: agentId,
1323
+ agent: agent.name,
1324
+ agentSource: agent.source,
1325
+ task: renderSubagentUserPrompt(assignment),
1326
+ assignment,
1327
+ description: params.description,
1328
+ exitCode: 1,
1329
+ output: "",
1330
+ stderr: message,
1331
+ truncated: false,
1332
+ durationMs: Date.now() - taskStart,
1333
+ tokens: 0,
1334
+ requests: 0,
1335
+ modelOverride,
1336
+ error: message,
1337
+ };
1338
+ },
1339
+ });
1407
1340
  };
1408
1341
 
1409
1342
  const result = await runTask();
1410
1343
 
1411
1344
  let mergeSummary = "";
1412
1345
  let changesApplied: boolean | null = null;
1413
- let hadAnyChanges = false;
1414
1346
  let mergedBranchForNestedPatches = false;
1415
1347
  if (isIsolated && repoRoot) {
1416
- try {
1417
- if (mergeMode === "branch") {
1418
- if (!result.branchName || result.exitCode !== 0 || result.aborted) {
1419
- changesApplied = true;
1420
- mergeSummary = "\n\nNo changes to apply.";
1421
- } else {
1422
- const mergeResult = await mergeTaskBranches(repoRoot, [
1423
- { branchName: result.branchName, taskId: result.id, description: result.description },
1424
- ]);
1425
- mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
1426
- changesApplied = mergeResult.failed.length === 0;
1427
- hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
1428
-
1429
- if (changesApplied) {
1430
- mergeSummary = hadAnyChanges
1431
- ? `\n\nMerged branch: ${result.branchName}`
1432
- : "\n\nNo changes to apply.";
1433
- } else {
1434
- const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1435
- mergeSummary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
1436
- }
1437
- if (mergeResult.stashConflict) {
1438
- mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
1439
- }
1440
-
1441
- // Clean up the merged branch (keep failed ones for manual resolution)
1442
- if (changesApplied) {
1443
- await cleanupTaskBranches(repoRoot, [result.branchName]);
1444
- }
1445
- }
1446
- } else {
1447
- // Patch mode: apply the patch from a successful run. A failed or
1448
- // aborted run has nothing to apply and must not block the result.
1449
- const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
1450
- if (!succeeded) {
1451
- changesApplied = true;
1452
- hadAnyChanges = false;
1453
- } else if (!result.patchPath) {
1454
- changesApplied = false;
1455
- hadAnyChanges = false;
1456
- } else {
1457
- const patchText = await Bun.file(result.patchPath).text();
1458
- if (!patchText.trim()) {
1459
- changesApplied = true;
1460
- hadAnyChanges = false;
1461
- } else {
1462
- const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
1463
- changesApplied = await git.patch.canApplyText(repoRoot, normalized);
1464
- if (changesApplied) {
1465
- try {
1466
- await git.patch.applyText(repoRoot, normalized);
1467
- hadAnyChanges = true;
1468
- } catch {
1469
- changesApplied = false;
1470
- hadAnyChanges = false;
1471
- }
1472
- }
1473
- }
1474
- }
1475
-
1476
- if (changesApplied) {
1477
- mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
1478
- } else {
1479
- const notification =
1480
- "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
1481
- const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
1482
- mergeSummary = `\n\n${notification}${patchList}`;
1483
- }
1484
- }
1485
- } catch (mergeErr) {
1486
- const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1487
- changesApplied = false;
1488
- hadAnyChanges = false;
1489
- mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
1490
- }
1348
+ const outcome = await mergeIsolatedChanges({ result, repoRoot, mergeMode });
1349
+ mergeSummary = outcome.summary;
1350
+ changesApplied = outcome.changesApplied;
1351
+ mergedBranchForNestedPatches = outcome.mergedBranchForNestedPatches;
1491
1352
  }
1492
1353
 
1493
- // Apply nested repo patches (separate from parent git)
1494
- if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
1495
- const nestedPatches = result.nestedPatches ?? [];
1496
- const eligible =
1497
- nestedPatches.length > 0 &&
1498
- result.exitCode === 0 &&
1499
- !result.aborted &&
1500
- (mergeMode !== "branch" || mergedBranchForNestedPatches);
1501
- if (eligible) {
1502
- try {
1503
- await applyNestedPatches(repoRoot, nestedPatches, buildCommitMessageFn());
1504
- } catch {
1505
- // Nested patch failures are non-fatal to the parent merge
1506
- mergeSummary +=
1507
- "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
1508
- }
1509
- }
1354
+ // Apply nested repo patches (separate from parent git).
1355
+ if (isIsolated && repoRoot) {
1356
+ mergeSummary += await applyEligibleNestedPatches({
1357
+ result,
1358
+ repoRoot,
1359
+ mergeMode,
1360
+ changesApplied,
1361
+ mergedBranchForNestedPatches,
1362
+ commitMessage: buildCommitMessageFn(),
1363
+ });
1510
1364
  }
1511
1365
 
1512
1366
  // Cleanup temp directory if used