@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-be1f2t8h.md} +92 -0
  3. package/dist/cli.js +9986 -9776
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/settings-schema.d.ts +63 -8
  13. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  14. package/dist/types/cursor.d.ts +193 -8
  15. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  16. package/dist/types/edit/hashline/diff.d.ts +9 -1
  17. package/dist/types/edit/index.d.ts +8 -1
  18. package/dist/types/edit/streaming.d.ts +8 -1
  19. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  20. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  21. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  22. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  23. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  24. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  25. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  26. package/dist/types/lsp/client.d.ts +2 -0
  27. package/dist/types/main.d.ts +2 -0
  28. package/dist/types/mcp/manager.d.ts +33 -4
  29. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  30. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  31. package/dist/types/modes/components/session-selector.d.ts +6 -0
  32. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  33. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  34. package/dist/types/modes/components/usage-row.d.ts +2 -0
  35. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  36. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  37. package/dist/types/modes/interactive-mode.d.ts +4 -2
  38. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  39. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  40. package/dist/types/modes/theme/theme.d.ts +12 -1
  41. package/dist/types/modes/types.d.ts +2 -1
  42. package/dist/types/sdk.d.ts +1 -0
  43. package/dist/types/session/agent-session-events.d.ts +2 -0
  44. package/dist/types/session/agent-session-types.d.ts +36 -1
  45. package/dist/types/session/agent-session.d.ts +4 -2
  46. package/dist/types/session/claude-session-store.d.ts +13 -0
  47. package/dist/types/session/codex-session-store.d.ts +14 -0
  48. package/dist/types/session/credential-pin.d.ts +58 -0
  49. package/dist/types/session/foreign-session-import.d.ts +15 -0
  50. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  51. package/dist/types/session/foreign-session-store.d.ts +23 -0
  52. package/dist/types/session/session-advisors.d.ts +47 -2
  53. package/dist/types/session/session-entries.d.ts +19 -1
  54. package/dist/types/session/session-handoff.d.ts +4 -1
  55. package/dist/types/session/session-loader.d.ts +2 -0
  56. package/dist/types/session/session-maintenance.d.ts +1 -1
  57. package/dist/types/session/session-manager.d.ts +21 -0
  58. package/dist/types/session/session-tools.d.ts +25 -1
  59. package/dist/types/session/turn-recovery.d.ts +10 -4
  60. package/dist/types/system-prompt.d.ts +2 -1
  61. package/dist/types/task/executor.d.ts +11 -2
  62. package/dist/types/task/render.d.ts +7 -0
  63. package/dist/types/thinking.d.ts +18 -6
  64. package/dist/types/tools/acp-bridge.d.ts +28 -5
  65. package/dist/types/tools/grep.d.ts +17 -1
  66. package/dist/types/tools/hub/launch.d.ts +5 -1
  67. package/dist/types/tools/index.d.ts +4 -1
  68. package/dist/types/tools/path-utils.d.ts +19 -0
  69. package/dist/types/tools/read.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +2 -1
  71. package/dist/types/utils/changelog.d.ts +19 -0
  72. package/dist/types/utils/git.d.ts +10 -0
  73. package/dist/types/utils/jj.d.ts +11 -4
  74. package/dist/types/web/search/types.d.ts +2 -2
  75. package/package.json +12 -12
  76. package/src/advisor/runtime.ts +138 -12
  77. package/src/advisor/transcript-recorder.ts +56 -0
  78. package/src/auto-thinking/classifier.ts +48 -10
  79. package/src/cli/args.ts +6 -0
  80. package/src/cli/flag-tables.ts +2 -0
  81. package/src/cli/gallery-fixtures/fs.ts +25 -0
  82. package/src/cli/session-picker.ts +34 -17
  83. package/src/cli/ttsr-cli.ts +19 -1
  84. package/src/cli.ts +9 -0
  85. package/src/collab/guest.ts +37 -4
  86. package/src/collab/host.ts +1 -0
  87. package/src/commands/launch.ts +6 -0
  88. package/src/config/model-registry.ts +103 -25
  89. package/src/config/settings-schema.ts +65 -6
  90. package/src/config/settings.ts +25 -0
  91. package/src/cursor-bridge-tools.ts +81 -0
  92. package/src/cursor.ts +463 -12
  93. package/src/edit/edit-clipboard.ts +23 -0
  94. package/src/edit/hashline/diff.ts +49 -10
  95. package/src/edit/hashline/execute.ts +38 -6
  96. package/src/edit/hashline/filesystem.ts +27 -3
  97. package/src/edit/index.ts +12 -2
  98. package/src/edit/renderer.ts +4 -4
  99. package/src/edit/streaming.ts +17 -2
  100. package/src/export/html/template.js +1 -1
  101. package/src/extensibility/extensions/runner.ts +70 -0
  102. package/src/extensibility/extensions/types.ts +30 -0
  103. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  104. package/src/internal-urls/local-protocol.ts +3 -2
  105. package/src/internal-urls/mcp-protocol.ts +4 -1
  106. package/src/launch/terminal-output-worker-client.ts +53 -0
  107. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  108. package/src/launch/terminal-output-worker.ts +23 -0
  109. package/src/lsp/client.ts +18 -3
  110. package/src/lsp/index.ts +4 -0
  111. package/src/lsp/render.ts +1 -1
  112. package/src/main.ts +127 -56
  113. package/src/mcp/manager.ts +120 -18
  114. package/src/modes/acp/acp-agent.ts +15 -9
  115. package/src/modes/components/chat-transcript-builder.ts +30 -11
  116. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  117. package/src/modes/components/read-tool-group.ts +159 -17
  118. package/src/modes/components/session-selector.ts +19 -3
  119. package/src/modes/components/status-line/component.ts +381 -54
  120. package/src/modes/components/tool-execution.ts +78 -31
  121. package/src/modes/components/usage-row.ts +10 -5
  122. package/src/modes/controllers/event-controller.ts +26 -4
  123. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  124. package/src/modes/controllers/selector-controller.ts +94 -36
  125. package/src/modes/controllers/tan-command-controller.ts +14 -0
  126. package/src/modes/interactive-mode.ts +31 -17
  127. package/src/modes/rpc/rpc-client.ts +18 -0
  128. package/src/modes/rpc/rpc-mode.ts +16 -1
  129. package/src/modes/rpc/rpc-types.ts +11 -0
  130. package/src/modes/theme/theme.ts +151 -6
  131. package/src/modes/types.ts +2 -1
  132. package/src/modes/utils/ui-helpers.ts +38 -21
  133. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  134. package/src/prompts/system/plan-mode-active.md +2 -2
  135. package/src/prompts/system/system-prompt.md +3 -0
  136. package/src/prompts/system/xdev-mount-notice.md +1 -1
  137. package/src/prompts/tools/bash.md +15 -17
  138. package/src/prompts/tools/checkpoint.md +1 -1
  139. package/src/prompts/tools/glob.md +7 -6
  140. package/src/prompts/tools/grep.md +7 -6
  141. package/src/sdk.ts +178 -15
  142. package/src/session/agent-session-events.ts +1 -0
  143. package/src/session/agent-session-types.ts +40 -1
  144. package/src/session/agent-session.ts +302 -175
  145. package/src/session/claude-session-store.ts +426 -0
  146. package/src/session/codex-session-store.ts +673 -0
  147. package/src/session/credential-pin.ts +93 -0
  148. package/src/session/foreign-session-import.ts +52 -0
  149. package/src/session/foreign-session-jsonl.ts +29 -0
  150. package/src/session/foreign-session-store.ts +26 -0
  151. package/src/session/model-controls.ts +11 -3
  152. package/src/session/session-advisors.ts +140 -16
  153. package/src/session/session-entries.ts +21 -1
  154. package/src/session/session-handoff.ts +20 -3
  155. package/src/session/session-loader.ts +25 -9
  156. package/src/session/session-maintenance.ts +59 -11
  157. package/src/session/session-manager.ts +61 -0
  158. package/src/session/session-tools.ts +107 -5
  159. package/src/session/settings-stream-fn.ts +1 -0
  160. package/src/session/turn-recovery.ts +97 -51
  161. package/src/slash-commands/builtin-registry.ts +131 -3
  162. package/src/system-prompt.ts +3 -2
  163. package/src/task/executor.ts +17 -4
  164. package/src/task/render.ts +20 -4
  165. package/src/thinking.ts +29 -10
  166. package/src/tools/acp-bridge.ts +52 -8
  167. package/src/tools/browser.ts +5 -0
  168. package/src/tools/checkpoint.ts +0 -13
  169. package/src/tools/grep.ts +64 -8
  170. package/src/tools/hub/launch.ts +10 -15
  171. package/src/tools/index.ts +28 -4
  172. package/src/tools/path-utils.ts +88 -0
  173. package/src/tools/read.ts +3 -0
  174. package/src/tools/write.ts +9 -3
  175. package/src/tools/xdev.ts +39 -14
  176. package/src/utils/changelog.ts +144 -4
  177. package/src/utils/git.ts +61 -14
  178. package/src/utils/jj.ts +19 -13
  179. package/src/web/search/providers/codex.ts +34 -0
  180. package/src/web/search/types.ts +1 -1
@@ -40,15 +40,39 @@ export function shouldRouteWriteThroughBridge(
40
40
  return absolutePath !== resolvePlanPath(session, state.planFilePath);
41
41
  }
42
42
 
43
+ /**
44
+ * Result of a bridge-routed write: the content actually verified on disk
45
+ * after the client processed the write, plus whether that content diverges
46
+ * from what the tool asked to persist.
47
+ *
48
+ * ACP's `fs/write_text_file` has no "verbatim, no side effects" guarantee —
49
+ * a client (e.g. Zed with `format_on_save: on`) may reformat the buffer as
50
+ * part of handling the write before it settles on disk. Silently trusting
51
+ * the requested `content` as "what's now on disk" lets that drift poison
52
+ * every snapshot/tag/hash a caller derives from the write, which then reads
53
+ * back as unrelated whole-file corruption on the *next* edit. Reading the
54
+ * file back and reporting what's actually there keeps callers honest.
55
+ */
56
+ export interface BridgeWriteResult {
57
+ /** Content actually present on disk immediately after the bridge write. */
58
+ text: string;
59
+ /** `true` when `text` differs from the content the tool asked to write. */
60
+ driftedFromRequest: boolean;
61
+ }
62
+
43
63
  /**
44
64
  * Try to route a file write through the ACP client bridge.
45
65
  *
46
66
  * Performs the full guard check, bridge call (wrapped in {@link ToolError}),
47
- * FS-scan cache invalidation, and session mutation-version bump.
67
+ * a post-write read-back to detect client-side transformation (e.g.
68
+ * format-on-save), FS-scan cache invalidation, and session mutation-version
69
+ * bump.
48
70
  *
49
- * Returns `true` when the bridge was used and the caller must skip the
50
- * writethrough path. Returns `false` when the bridge is unavailable or the
51
- * path should not be routed through it.
71
+ * Returns `undefined` when the bridge is unavailable or the path should not
72
+ * be routed through it the caller must fall back to the writethrough path.
73
+ * Returns a {@link BridgeWriteResult} when the bridge was used; callers MUST
74
+ * use `result.text` (not the content they requested) for any snapshot, hash,
75
+ * or tag derived from this write.
52
76
  */
53
77
  export async function routeWriteThroughBridge(
54
78
  session: ToolSession,
@@ -56,11 +80,11 @@ export async function routeWriteThroughBridge(
56
80
  absolutePath: string,
57
81
  content: string,
58
82
  signal?: AbortSignal,
59
- ): Promise<boolean> {
60
- if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return false;
83
+ ): Promise<BridgeWriteResult | undefined> {
84
+ if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return undefined;
61
85
 
62
86
  const bridge = session.getClientBridge?.();
63
- if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return false;
87
+ if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined;
64
88
 
65
89
  const changeType = (await Bun.file(absolutePath).exists()) ? FileChangeType.Changed : FileChangeType.Created;
66
90
  // The ACP protocol has no cancellation for fs writes; the most we can do is
@@ -77,5 +101,25 @@ export async function routeWriteThroughBridge(
77
101
  }
78
102
  invalidateFsScanAfterWrite(absolutePath);
79
103
  session.bumpFileMutationVersion?.(absolutePath);
80
- return true;
104
+
105
+ // Best-effort verification: the client already flushed the write (that's
106
+ // the whole point of `fs/write_text_file`), so the file on disk reflects
107
+ // whatever the client actually persisted, formatter and all. If the
108
+ // read-back itself fails, fall back to trusting `content` rather than
109
+ // failing an otherwise-successful write. This is a best-effort signal,
110
+ // not a guarantee: ACP defines no ordering between a client acking the
111
+ // write and its own async format-on-save settling, so a client that acks
112
+ // before its formatter runs will look verbatim here. That degrades to
113
+ // the pre-fix behavior for THIS write, but never corrupts state: the
114
+ // next `readText` still observes whatever the client eventually settles
115
+ // on, and a real desync there surfaces as an honest stale-tag error
116
+ // instead of a silently wrong tag.
117
+ let actualText = content;
118
+ try {
119
+ actualText = await Bun.file(absolutePath).text();
120
+ } catch {
121
+ // Unreadable right after a reported-successful write; nothing more we
122
+ // can verify here.
123
+ }
124
+ return { text: actualText, driftedFromRequest: actualText !== content };
81
125
  }
@@ -95,6 +95,11 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
95
95
  const exe = resolveToCwd(app.path, session.cwd);
96
96
  return { kind: "spawned", path: exe };
97
97
  }
98
+ // A configured endpoint is a default, not an override: explicit app options win.
99
+ const configuredCdpUrl = (session.settings.get("browser.cdpUrl") as string | undefined)?.trim();
100
+ if (configuredCdpUrl) {
101
+ return { kind: "connected", cdpUrl: configuredCdpUrl.replace(/\/+$/, "") };
102
+ }
98
103
  const cmuxKind = resolveCmuxKind({
99
104
  settingEnabled: session.settings.get("browser.cmux") as boolean | undefined,
100
105
  });
@@ -50,11 +50,6 @@ export interface RewindToolDetails {
50
50
  meta?: OutputMeta;
51
51
  }
52
52
 
53
- function isTopLevelSession(session: ToolSession): boolean {
54
- const depth = session.taskDepth;
55
- return depth === undefined || depth === 0;
56
- }
57
-
58
53
  export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
59
54
  readonly name = "checkpoint";
60
55
  readonly approval = "read" as const;
@@ -71,7 +66,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
71
66
  }
72
67
 
73
68
  static createIf(session: ToolSession): CheckpointTool | null {
74
- if (!isTopLevelSession(session)) return null;
75
69
  return new CheckpointTool(session);
76
70
  }
77
71
 
@@ -82,9 +76,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
82
76
  _onUpdate?: AgentToolUpdateCallback<CheckpointToolDetails>,
83
77
  _context?: AgentToolContext,
84
78
  ): Promise<AgentToolResult<CheckpointToolDetails>> {
85
- if (!isTopLevelSession(this.session)) {
86
- throw new ToolError("Checkpoint not available in subagents.");
87
- }
88
79
  if (this.session.getCheckpointState?.()) {
89
80
  throw new ToolError("Checkpoint already active.");
90
81
  }
@@ -117,7 +108,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
117
108
  }
118
109
 
119
110
  static createIf(session: ToolSession): RewindTool | null {
120
- if (!isTopLevelSession(session)) return null;
121
111
  return new RewindTool(session);
122
112
  }
123
113
 
@@ -128,9 +118,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
128
118
  _onUpdate?: AgentToolUpdateCallback<RewindToolDetails>,
129
119
  _context?: AgentToolContext,
130
120
  ): Promise<AgentToolResult<RewindToolDetails>> {
131
- if (!isTopLevelSession(this.session)) {
132
- throw new ToolError("Checkpoint not available in subagents.");
133
- }
134
121
  if (!this.session.getCheckpointState?.()) {
135
122
  if (this.session.getLastCompletedRewind?.()) {
136
123
  throw new ToolError(
package/src/tools/grep.ts CHANGED
@@ -884,6 +884,22 @@ export interface GrepToolDetails {
884
884
 
885
885
  type SearchParams = typeof searchSchema.infer;
886
886
 
887
+ /**
888
+ * Construction-time overrides for callers that are not the model.
889
+ *
890
+ * The model-facing schema deliberately does not grow these: they exist for
891
+ * wire bridges (the Cursor `pi_grep` frame) whose protocol carries an explicit
892
+ * context width and total match cap, and which would otherwise have to drop
893
+ * them. Unset means "use the session settings / built-in caps" — the behavior
894
+ * every model-issued call keeps.
895
+ */
896
+ export interface GrepToolOptions {
897
+ /** Overrides `grep.contextBefore`/`grep.contextAfter` for every call on this instance. */
898
+ context?: number;
899
+ /** Caps total surfaced matches. Applied on top of the built-in per-file and file-window caps, never above them. */
900
+ totalMatchLimit?: number;
901
+ }
902
+
887
903
  export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails> {
888
904
  readonly name = "grep";
889
905
  readonly approval = (args: unknown): ToolTier => {
@@ -897,7 +913,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
913
  readonly parameters = searchSchema;
898
914
  readonly strict = true;
899
915
 
900
- constructor(private readonly session: ToolSession) {
916
+ readonly #contextOverride?: number;
917
+ readonly #totalMatchLimit?: number;
918
+
919
+ constructor(
920
+ private readonly session: ToolSession,
921
+ options?: GrepToolOptions,
922
+ ) {
923
+ const context = options?.context;
924
+ this.#contextOverride = context !== undefined ? Math.max(0, Math.floor(context)) : undefined;
925
+ const total = options?.totalMatchLimit;
926
+ this.#totalMatchLimit = total !== undefined ? Math.max(1, Math.floor(total)) : undefined;
901
927
  const displayMode = resolveFileDisplayMode(session);
902
928
  this.description = prompt.render(grepDescription, {
903
929
  IS_HL_MODE: displayMode.hashLines,
@@ -978,8 +1004,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
978
1004
  `or pass a UTF-8 text member.`,
979
1005
  );
980
1006
  }
981
- const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
982
- const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
1007
+ const normalizedContextBefore = this.#contextOverride ?? this.session.settings.get("grep.contextBefore");
1008
+ const normalizedContextAfter = this.#contextOverride ?? this.session.settings.get("grep.contextAfter");
983
1009
  const ignoreCase = !(caseSensitive ?? true);
984
1010
  const useGitignore = gitignore ?? true;
985
1011
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
@@ -1297,9 +1323,22 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1297
1323
  // Single-file scopes can't paginate — there is one file by definition.
1298
1324
  const canPaginate = isMultiScope;
1299
1325
  const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
1300
- const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
1301
- const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
1326
+ // A caller with a total match cap is not paginating: the cap bounds the
1327
+ // output, and the only consumer that sets one (`pi_grep`) has no `skip`
1328
+ // field to follow a "use skip=N" suggestion with. Windowing it to the
1329
+ // first 20 files would silently return fewer matches than it asked for
1330
+ // while reporting the cap as unreached.
1331
+ //
1332
+ // The window is cap+1 files, not cap: with one match per file, a cap
1333
+ // of N over exactly N files is complete, while over N+1 files it is
1334
+ // clipped — and only reading that extra file distinguishes the two.
1335
+ // The cap below then does the trimming and records that it bit, so
1336
+ // `match_limit_reached` reaches the frame set.
1337
+ const fileWindow = this.#totalMatchLimit !== undefined ? this.#totalMatchLimit + 1 : DEFAULT_FILE_LIMIT;
1338
+ const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + fileWindow) : fileOrder;
1339
+ const fileLimitReached = canPaginate && totalFiles > skipFiles + fileWindow;
1302
1340
  const selectedMatches: GrepMatch[] = [];
1341
+ let totalMatchLimitReached = false;
1303
1342
  if (windowFiles.length > 0) {
1304
1343
  const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
1305
1344
  const cursors = new Array<number>(lists.length).fill(0);
@@ -1313,6 +1352,14 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1313
1352
  }
1314
1353
  }
1315
1354
  }
1355
+ // Round-robin above interleaves files for diversity, so the cap is
1356
+ // applied after selection rather than as a per-list bound: trimming
1357
+ // mid-rotation would silently favour whichever files sort first.
1358
+ const cap = this.#totalMatchLimit;
1359
+ if (cap !== undefined && selectedMatches.length > cap) {
1360
+ selectedMatches.length = cap;
1361
+ totalMatchLimitReached = true;
1362
+ }
1316
1363
  }
1317
1364
  const nextSkip = skipFiles + windowFiles.length;
1318
1365
  const limitMessage = fileLimitReached
@@ -1503,7 +1550,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1503
1550
  const output = truncation.content;
1504
1551
  const displayText = displayLines.join("\n");
1505
1552
  const truncated = Boolean(
1506
- fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
1553
+ fileLimitReached ||
1554
+ perFileLimitReached ||
1555
+ totalMatchLimitReached ||
1556
+ result.limitReached ||
1557
+ truncation.truncated ||
1558
+ linesTruncated,
1507
1559
  );
1508
1560
  const details: GrepToolDetails = {
1509
1561
  scopePath,
@@ -1517,8 +1569,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1517
1569
  count: fileMatchCounts.get(path) ?? 0,
1518
1570
  })),
1519
1571
  truncated,
1520
- fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
1521
- perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
1572
+ fileLimitReached: fileLimitReached ? fileWindow : undefined,
1573
+ perFileLimitReached: totalMatchLimitReached
1574
+ ? this.#totalMatchLimit
1575
+ : perFileLimitReached
1576
+ ? perFileMatchCap
1577
+ : undefined,
1522
1578
  displayContent: displayText,
1523
1579
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
1524
1580
  };
@@ -12,7 +12,7 @@ import { sanitizeText } from "@oh-my-pi/pi-utils";
12
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import { daemonClientForProject } from "../../launch/client";
14
14
  import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../../launch/protocol";
15
- import type { TerminalOutputOptions } from "../../launch/terminal-output";
15
+ import { renderTerminalOutputIsolated } from "../../launch/terminal-output-worker-client";
16
16
  import type { Theme, ThemeColor } from "../../modes/theme/theme";
17
17
  import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../../tui";
18
18
  import type { ToolSession } from "..";
@@ -271,15 +271,14 @@ function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
271
271
  }
272
272
  }
273
273
 
274
- interface TerminalOutputRuntime {
275
- renderTerminalOutput(output: string, options: TerminalOutputOptions): Promise<string[] | undefined>;
276
- }
277
-
278
- async function renderLegacyTerminalOutput(terminalText: string, params: LaunchParams): Promise<string[] | undefined> {
279
- // `require` keeps xterm out of normal main/Hub startup while retaining display
280
- // compatibility with a legacy broker that was already running during upgrade.
281
- const runtime = require("../../launch/terminal-output") as TerminalOutputRuntime;
282
- return runtime.renderTerminalOutput(terminalText, {
274
+ /** Resolve display rows while keeping legacy raw replay outside the client process. */
275
+ export async function renderLaunchLogTerminalRows(
276
+ result: Extract<DaemonRpcResult, { op: "logs" }>,
277
+ params: Pick<LaunchParams, "head" | "lines">,
278
+ ): Promise<string[] | undefined> {
279
+ if (result.terminalRows !== undefined) return result.terminalRows;
280
+ if (result.terminalText === undefined) return undefined;
281
+ return renderTerminalOutputIsolated(result.terminalText, {
283
282
  head: params.head ?? false,
284
283
  maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)),
285
284
  });
@@ -297,11 +296,7 @@ async function toolDetails(result: DaemonRpcResult, params: LaunchParams): Promi
297
296
  cursor: result.cursor,
298
297
  timedOut: result.timedOut,
299
298
  state: result.state,
300
- terminalRows:
301
- result.terminalRows ??
302
- (result.terminalText === undefined
303
- ? undefined
304
- : await renderLegacyTerminalOutput(result.terminalText, params)),
299
+ terminalRows: await renderLaunchLogTerminalRows(result, params).catch(() => undefined),
305
300
  };
306
301
  case "wait":
307
302
  return { op: "wait", daemon: result.daemon, timedOut: result.timedOut, matched: result.matched };
@@ -1,4 +1,4 @@
1
- import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
1
+ import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
@@ -351,6 +351,10 @@ export interface ToolSession {
351
351
  * file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
352
352
  fileSnapshotStore?: InMemorySnapshotStore;
353
353
 
354
+ /** Per-session `CUT`/`PASTE` clipboard register shared across edit
355
+ * calls. Lazily initialized by `getEditClipboard`. */
356
+ editClipboard?: Clipboard;
357
+
354
358
  /** Per-session log of unresolved git merge conflict regions surfaced by
355
359
  * `read`. Each entry gets a stable id N referenced by `write conflict://N`
356
360
  * to splice the recorded region with replacement content. Lazily initialized
@@ -502,6 +506,18 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
502
506
  // unreachable, in which case eval dispatches exclusively to the others.
503
507
  const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed;
504
508
 
509
+ // Checkpoint and rewind are a pair: listing one without the other strands
510
+ // the agent (it can checkpoint but not rewind, or vice versa). Auto-include
511
+ // the sister tool so a one-sided frontmatter `tools:` entry still works.
512
+ // Unlike the AST/auto-learn convenience auto-includes below, this is a
513
+ // safety pairing — it applies to restricted sessions too.
514
+ if (requestedTools && session.settings.get("checkpoint.enabled")) {
515
+ if (requestedTools.includes("checkpoint") && !requestedTools.includes("rewind")) {
516
+ requestedTools.push("rewind");
517
+ } else if (requestedTools.includes("rewind") && !requestedTools.includes("checkpoint")) {
518
+ requestedTools.push("checkpoint");
519
+ }
520
+ }
505
521
  // Auto-include AST counterparts when their text-based sibling is present.
506
522
  // Restricted callers own the active list and must not have it widened.
507
523
  if (requestedTools && !restrictToolNames) {
@@ -574,7 +590,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
574
590
  if (name === "ask") return session.settings.get("ask.enabled");
575
591
  if (name === "browser") return session.settings.get("browser.enabled");
576
592
  if (name === "computer") return session.settings.get("computer.enabled");
577
- if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
593
+ if (name === "checkpoint" || name === "rewind")
594
+ return (
595
+ session.settings.get("checkpoint.enabled") &&
596
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
597
+ );
578
598
  if (name === "hub") {
579
599
  return (
580
600
  !restrictToolNames && session.enableIrc !== false && isIrcEnabled(session.settings, session.taskDepth ?? 0)
@@ -584,11 +604,15 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
584
604
  return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
585
605
  }
586
606
  if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
587
- if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
607
+ if (name === "manage_skill")
608
+ return (
609
+ session.settings.get("autolearn.enabled") &&
610
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
611
+ );
588
612
  if (name === "learn") {
589
613
  return (
590
614
  session.settings.get("autolearn.enabled") &&
591
- (session.taskDepth ?? 0) === 0 &&
615
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined) &&
592
616
  ["hindsight", "mnemopi", "local"].includes(session.settings.get("memory.backend") ?? "")
593
617
  );
594
618
  }
@@ -520,6 +520,94 @@ export function resolveToCwd(filePath: string, cwd: string): string {
520
520
  return path.resolve(cwd, expanded);
521
521
  }
522
522
 
523
+ /**
524
+ * Resolve a path that MUST stay inside `cwd`, or `null` when it would escape.
525
+ *
526
+ * {@link resolveToCwd} deliberately honors absolute paths, `~`, and `..` —
527
+ * correct for a path a user typed, wrong for one a remote peer supplied.
528
+ * Callers handling untrusted input (Cursor's `download_path`) use this instead:
529
+ * only a non-empty relative path landing under the live cwd is accepted, so
530
+ * neither `/etc/passwd` nor `../../escape` can be written through.
531
+ *
532
+ * The lexical check alone is not containment: a symlink inside the workspace
533
+ * can point anywhere, so `out/config` under a `ws/out -> /elsewhere` link is
534
+ * relative, `..`-free, and still writes outside. Both the target and its
535
+ * deepest existing ancestor are therefore realpath-resolved — the ancestor
536
+ * because a download names a file that does not exist yet, so the link in its
537
+ * path is the only thing that can be resolved before the write.
538
+ *
539
+ * The cwd itself is rejected: a download names a file, never the directory.
540
+ */
541
+ export function confineToWorkspace(filePath: string, cwd: string): string | null {
542
+ if (!filePath || path.isAbsolute(filePath)) return null;
543
+ // `~` expands to an absolute path, and an internal URL is not a filesystem
544
+ // target at all; neither is a relative workspace path.
545
+ if (filePath.startsWith("~") || isInternalUrlPath(filePath)) return null;
546
+ const root = path.resolve(cwd);
547
+ const resolved = path.resolve(root, filePath);
548
+ if (!isUnderRootLexical(resolved, root)) return null;
549
+
550
+ // A workspace reached through a link of its own is legitimate (/tmp on
551
+ // macOS), so the real root is the comparison basis. An unresolvable root is
552
+ // not a workspace to contain anything in.
553
+ const realRoot = tryRealpath(root);
554
+ if (!realRoot) return null;
555
+
556
+ // An existing target is authoritative: resolve it outright.
557
+ const realTarget = tryRealpath(resolved);
558
+ if (realTarget) return isUnderRootLexical(realTarget, realRoot) ? resolved : null;
559
+
560
+ // `realpath` also fails on a *dangling* link, and a write follows that link
561
+ // to wherever it points. Chasing the chain to decide would mean
562
+ // reimplementing symlink resolution (multi-hop, relative hops, loops, and
563
+ // a TOCTOU window against a link that can be re-pointed between the check
564
+ // and the write). A download names a file to create, so a path that is
565
+ // already an unresolvable link is refused outright — the one shape where
566
+ // "cannot tell where this lands" is the whole answer.
567
+ if (isSymlink(resolved)) return null;
568
+
569
+ // Otherwise walk up to the deepest ancestor that does exist and check that,
570
+ // then re-apply the segments below it. Those segments are `..`-free by the
571
+ // lexical check above, so they cannot climb back out.
572
+ let ancestor = path.dirname(resolved);
573
+ const tail: string[] = [path.basename(resolved)];
574
+ for (;;) {
575
+ const real = tryRealpath(ancestor);
576
+ if (real) {
577
+ return isUnderRootLexical(path.join(real, ...tail.reverse()), realRoot) ? resolved : null;
578
+ }
579
+ const parent = path.dirname(ancestor);
580
+ // Ran past the root without finding anything real: the workspace itself
581
+ // resolved above, so this cannot happen unless it vanished mid-check.
582
+ if (parent === ancestor || !isUnderRootLexical(ancestor, root)) return null;
583
+ tail.push(path.basename(ancestor));
584
+ ancestor = parent;
585
+ }
586
+ }
587
+
588
+ /** Whether `target` is a strict descendant of `root`, ignoring symlinks. */
589
+ function isUnderRootLexical(target: string, root: string): boolean {
590
+ const relative = path.relative(root, target);
591
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
592
+ }
593
+
594
+ function tryRealpath(target: string): string | null {
595
+ try {
596
+ return fs.realpathSync.native(target);
597
+ } catch {
598
+ return null;
599
+ }
600
+ }
601
+
602
+ /** Whether the path itself is a symlink, without following it. */
603
+ function isSymlink(target: string): boolean {
604
+ try {
605
+ return fs.lstatSync(target).isSymbolicLink();
606
+ } catch {
607
+ return false;
608
+ }
609
+ }
610
+
523
611
  export function formatPathRelativeToCwd(
524
612
  filePath: string,
525
613
  cwd: string,
package/src/tools/read.ts CHANGED
@@ -735,6 +735,8 @@ export interface ReadToolDetails {
735
735
  method?: string;
736
736
  notes?: string[];
737
737
  meta?: OutputMeta;
738
+ /** Full on-disk byte size recorded before applying a file range. */
739
+ fileSize?: number;
738
740
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
739
741
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
740
742
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -2959,6 +2961,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2959
2961
  }
2960
2962
  }
2961
2963
 
2964
+ details.fileSize = fileSize;
2962
2965
  this.#markMarkdownContentType(details, absolutePath);
2963
2966
  if (suffixResolution) {
2964
2967
  details.suffixResolution = suffixResolution;
@@ -1267,9 +1267,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1267
1267
 
1268
1268
  // Try ACP bridge first for editor-visible filesystem paths. Internal
1269
1269
  // artifacts such as local:// plans are owned by OMP, not the editor.
1270
- if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal)) {
1271
- const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
1272
- const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
1270
+ const bridgeWrite = await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal);
1271
+ if (bridgeWrite) {
1272
+ // `write` always replaces the whole file, so (unlike hashline's
1273
+ // hunk-scoped diff) there's no size cost to keying the header/
1274
+ // executable-bit check on the verified post-write content —
1275
+ // use it so a drifted write (e.g. client format-on-save) still
1276
+ // hands back a tag that matches what's actually on disk.
1277
+ const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, bridgeWrite.text);
1278
+ const header = maybeWriteSnapshotHeader(this.session, absolutePath, bridgeWrite.text);
1273
1279
  const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
1274
1280
  let resultText = header ? `${header}\n${writeLine}` : writeLine;
1275
1281
  if (stripped) {
package/src/tools/xdev.ts CHANGED
@@ -35,6 +35,7 @@ import { parseStreamingJson } from "@oh-my-pi/pi-utils";
35
35
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
36
36
  import { XD_URL_PREFIX } from "../internal-urls/xd-protocol";
37
37
  import type { Theme } from "../modes/theme/theme";
38
+ import { truncateHeadBytes } from "../session/streaming-output";
38
39
  import { renderDefaultToolExecution } from "./default-renderer";
39
40
  import type { Tool } from "./index";
40
41
  import { replaceTabs } from "./render-utils";
@@ -178,14 +179,34 @@ function toolSummary(inst: Tool): string {
178
179
  return firstLine?.trim() ?? inst.label ?? inst.name;
179
180
  }
180
181
 
181
- function promptCatalogSummary(inst: Tool, maxLength?: number): string {
182
+ /** C0/C1 controls and Unicode line/paragraph separators; summaries must remain one line. */
183
+ const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
184
+ const SUMMARY_ELLIPSIS = "…";
185
+ const SUMMARY_ELLIPSIS_BYTES = Buffer.byteLength(SUMMARY_ELLIPSIS, "utf-8");
186
+
187
+ /**
188
+ * Bound a catalog summary for prompt rendering. External summaries are
189
+ * third-party metadata inlined verbatim, so control characters are stripped
190
+ * first, then the result is bounded in UTF-8 BYTES rather than characters (a
191
+ * character bound is not a byte bound for multi-byte scripts). The cut lands
192
+ * on a code point boundary, so the prompt never carries a partial code point.
193
+ */
194
+ function sanitizeCatalogSummary(summary: string, maxBytes?: number): string {
195
+ const cleaned = summary.replace(SUMMARY_CONTROL_CHARS, " ").trim();
196
+ if (maxBytes === undefined || Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned;
197
+ if (maxBytes <= 0) return "";
198
+ if (maxBytes < SUMMARY_ELLIPSIS_BYTES) return truncateHeadBytes(cleaned, maxBytes).text;
199
+ const body = truncateHeadBytes(cleaned, maxBytes - SUMMARY_ELLIPSIS_BYTES).text.trimEnd();
200
+ return `${body}${SUMMARY_ELLIPSIS}`;
201
+ }
202
+
203
+ function promptCatalogSummary(inst: Tool, maxBytes?: number): string {
182
204
  const summary =
183
205
  toolSummary(inst)
184
206
  .split("\n")
185
207
  .find(line => line.trim().length > 0)
186
208
  ?.trim() ?? inst.name;
187
- if (maxLength === undefined || summary.length <= maxLength) return summary;
188
- return `${summary.slice(0, maxLength).trimEnd()}…`;
209
+ return sanitizeCatalogSummary(summary, maxBytes) || inst.name;
189
210
  }
190
211
 
191
212
  /** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
@@ -257,15 +278,19 @@ export function listXdevTools(state: XdevState): Tool[] {
257
278
  });
258
279
  }
259
280
 
260
- /** `{name, summary}` pairs for prompt templates and `/tools` display. */
261
- export function xdevEntries(state: XdevState): Array<{ name: string; summary: string }> {
262
- return listXdevTools(state).map(tool => ({
263
- name: tool.name,
264
- summary: promptCatalogSummary(
265
- tool,
266
- state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP,
267
- ),
268
- }));
281
+ /** `{name, summary, dynamic}` triples for prompt templates and `/tools` display. */
282
+ export function xdevEntries(state: XdevState): Array<{ name: string; summary: string; dynamic: boolean }> {
283
+ return listXdevTools(state).map(tool => {
284
+ // Built-ins are first-party; anything else carries third-party metadata. One
285
+ // boolean drives both the description cap and the flag callers present, so
286
+ // the two can never disagree about which summaries are untrusted.
287
+ const dynamic = !state.builtInNames.has(tool.name);
288
+ return {
289
+ name: tool.name,
290
+ summary: promptCatalogSummary(tool, dynamic ? XDEV_EXTERNAL_DESCRIPTION_CAP : undefined),
291
+ dynamic,
292
+ };
293
+ });
269
294
  }
270
295
 
271
296
  /** `read xd://` listing with one device per line. */
@@ -313,8 +338,8 @@ export function xdevDocsAll(
313
338
  [
314
339
  "## Additional devices (docs on demand)",
315
340
  ...overflow.map(tool => {
316
- const maxLength = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
317
- return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
341
+ const maxBytes = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
342
+ return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxBytes)}`;
318
343
  }),
319
344
  "",
320
345
  `Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,