@oh-my-pi/pi-coding-agent 17.1.0 → 17.1.2

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 (196) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/cli.js +6325 -4420
  3. package/dist/types/cli/bench-cli.d.ts +1 -0
  4. package/dist/types/config/model-resolver.d.ts +6 -3
  5. package/dist/types/config/settings-schema.d.ts +62 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  7. package/dist/types/launch/broker-list-order.test.d.ts +1 -0
  8. package/dist/types/live/attestation.d.ts +2 -0
  9. package/dist/types/live/controller.d.ts +10 -2
  10. package/dist/types/live/protocol.d.ts +1 -1
  11. package/dist/types/live/transport.d.ts +6 -19
  12. package/dist/types/live/visualizer.d.ts +8 -11
  13. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  14. package/dist/types/modes/components/custom-message.d.ts +1 -1
  15. package/dist/types/modes/components/message-frame.d.ts +8 -4
  16. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  18. package/dist/types/modes/interactive-mode.d.ts +3 -6
  19. package/dist/types/modes/types.d.ts +9 -5
  20. package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
  21. package/dist/types/session/agent-session-types.d.ts +8 -1
  22. package/dist/types/session/agent-session.d.ts +20 -1
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/eval-runner.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +2 -0
  26. package/dist/types/session/session-tools.d.ts +15 -0
  27. package/dist/types/session/streaming-output.d.ts +8 -0
  28. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  29. package/dist/types/stt/index.d.ts +0 -2
  30. package/dist/types/stt/stt-controller.d.ts +7 -0
  31. package/dist/types/task/executor.d.ts +3 -1
  32. package/dist/types/task/structured-subagent.d.ts +3 -0
  33. package/dist/types/task/types.d.ts +11 -11
  34. package/dist/types/thinking.d.ts +13 -0
  35. package/dist/types/tiny/title-client.d.ts +10 -0
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/computer/protocol.d.ts +43 -0
  38. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  39. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  40. package/dist/types/tools/computer/worker.d.ts +15 -0
  41. package/dist/types/tools/computer-renderer.d.ts +22 -0
  42. package/dist/types/tools/computer.d.ts +71 -0
  43. package/dist/types/tools/context.d.ts +2 -0
  44. package/dist/types/tools/default-renderer.d.ts +21 -0
  45. package/dist/types/tools/eval-format/index.d.ts +7 -0
  46. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  47. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  48. package/dist/types/tools/eval-format/python.d.ts +2 -0
  49. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  50. package/dist/types/tools/index.d.ts +2 -0
  51. package/dist/types/tools/resolve.d.ts +7 -0
  52. package/dist/types/tools/todo.d.ts +4 -1
  53. package/dist/types/tts/streaming-player.d.ts +10 -43
  54. package/dist/types/utils/tools-manager.d.ts +1 -2
  55. package/dist/types/web/search/providers/base.d.ts +16 -0
  56. package/dist/types/web/search/providers/brave.d.ts +2 -0
  57. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  58. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  59. package/dist/types/web/search/providers/jina.d.ts +2 -0
  60. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  61. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  62. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  63. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  64. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  65. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  66. package/dist/types/web/search/query.d.ts +190 -0
  67. package/package.json +12 -12
  68. package/src/cli/args.ts +1 -0
  69. package/src/cli/bench-cli.ts +12 -1
  70. package/src/cli/setup-cli.ts +2 -14
  71. package/src/cli/web-search-cli.ts +7 -0
  72. package/src/cli.ts +8 -0
  73. package/src/config/model-registry.ts +6 -0
  74. package/src/config/model-resolver.ts +17 -6
  75. package/src/config/settings-schema.ts +66 -0
  76. package/src/debug/raw-sse-buffer.ts +157 -30
  77. package/src/eval/executor-base.ts +1 -0
  78. package/src/eval/js/executor.ts +2 -0
  79. package/src/exec/bash-executor.ts +1 -0
  80. package/src/export/share.ts +4 -3
  81. package/src/extensibility/extensions/wrapper.ts +68 -12
  82. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  83. package/src/launch/broker-list-order.test.ts +89 -0
  84. package/src/launch/broker.ts +49 -8
  85. package/src/live/attestation.ts +91 -0
  86. package/src/live/controller.ts +76 -23
  87. package/src/live/protocol.test.ts +3 -3
  88. package/src/live/protocol.ts +1 -1
  89. package/src/live/transport.ts +72 -140
  90. package/src/live/visualizer.ts +114 -134
  91. package/src/modes/components/assistant-message.ts +7 -2
  92. package/src/modes/components/custom-message.ts +4 -1
  93. package/src/modes/components/message-frame.ts +14 -8
  94. package/src/modes/components/session-account-selector.ts +62 -0
  95. package/src/modes/components/tool-execution.ts +17 -110
  96. package/src/modes/controllers/input-controller.ts +55 -47
  97. package/src/modes/controllers/live-command-controller.ts +82 -5
  98. package/src/modes/controllers/selector-controller.ts +62 -0
  99. package/src/modes/interactive-mode.ts +79 -5
  100. package/src/modes/types.ts +10 -4
  101. package/src/modes/utils/ui-helpers.ts +7 -8
  102. package/src/prompts/system/computer-safety.md +14 -0
  103. package/src/prompts/system/resolve-device-reminder.md +1 -1
  104. package/src/prompts/system/workflow-notice.md +1 -1
  105. package/src/prompts/tools/ast-edit.md +1 -0
  106. package/src/prompts/tools/bash.md +1 -0
  107. package/src/prompts/tools/computer.md +26 -0
  108. package/src/prompts/tools/eval.md +2 -2
  109. package/src/prompts/tools/task.md +5 -2
  110. package/src/prompts/tools/web-search.md +2 -0
  111. package/src/sdk.ts +13 -4
  112. package/src/session/agent-session-types.ts +9 -0
  113. package/src/session/agent-session.ts +70 -0
  114. package/src/session/auth-storage.ts +1 -0
  115. package/src/session/eval-runner.ts +5 -0
  116. package/src/session/messages.ts +3 -0
  117. package/src/session/queued-messages.ts +7 -1
  118. package/src/session/session-tools.ts +37 -0
  119. package/src/session/streaming-output.ts +52 -5
  120. package/src/slash-commands/builtin-registry.ts +165 -9
  121. package/src/slash-commands/helpers/session-pin.ts +44 -0
  122. package/src/stt/downloader.ts +0 -2
  123. package/src/stt/index.ts +0 -2
  124. package/src/stt/stt-controller.ts +57 -146
  125. package/src/system-prompt.ts +4 -0
  126. package/src/task/executor.ts +17 -9
  127. package/src/task/index.ts +14 -34
  128. package/src/task/structured-subagent.ts +4 -0
  129. package/src/task/types.ts +15 -13
  130. package/src/thinking.ts +27 -0
  131. package/src/tiny/title-client.ts +22 -0
  132. package/src/tools/ast-edit.ts +4 -1
  133. package/src/tools/bash-interactive.ts +90 -86
  134. package/src/tools/bash.ts +13 -0
  135. package/src/tools/browser/render.ts +2 -1
  136. package/src/tools/builtin-names.ts +1 -0
  137. package/src/tools/computer/protocol.ts +28 -0
  138. package/src/tools/computer/supervisor.ts +258 -0
  139. package/src/tools/computer/worker-entry.ts +25 -0
  140. package/src/tools/computer/worker.ts +135 -0
  141. package/src/tools/computer-renderer.ts +108 -0
  142. package/src/tools/computer.ts +433 -0
  143. package/src/tools/context.ts +2 -0
  144. package/src/tools/default-renderer.ts +139 -0
  145. package/src/tools/essential-tools.ts +1 -0
  146. package/src/tools/eval-format/index.ts +24 -0
  147. package/src/tools/eval-format/javascript.ts +952 -0
  148. package/src/tools/eval-format/julia.ts +446 -0
  149. package/src/tools/eval-format/python.ts +544 -0
  150. package/src/tools/eval-format/ruby.ts +380 -0
  151. package/src/tools/eval-render.ts +12 -6
  152. package/src/tools/index.ts +5 -0
  153. package/src/tools/renderers.ts +2 -0
  154. package/src/tools/resolve.ts +10 -1
  155. package/src/tools/todo.ts +58 -6
  156. package/src/tools/xdev.ts +54 -26
  157. package/src/tts/streaming-player.ts +81 -340
  158. package/src/utils/clipboard.ts +1 -30
  159. package/src/utils/mac-file-urls.applescript +37 -0
  160. package/src/utils/tool-choice.ts +14 -0
  161. package/src/utils/tools-manager.ts +1 -19
  162. package/src/web/search/index.ts +28 -5
  163. package/src/web/search/providers/anthropic.ts +62 -4
  164. package/src/web/search/providers/base.ts +16 -0
  165. package/src/web/search/providers/brave.ts +30 -3
  166. package/src/web/search/providers/codex.ts +14 -1
  167. package/src/web/search/providers/duckduckgo.ts +23 -1
  168. package/src/web/search/providers/ecosia.ts +5 -1
  169. package/src/web/search/providers/exa.ts +28 -1
  170. package/src/web/search/providers/firecrawl.ts +37 -3
  171. package/src/web/search/providers/gemini.ts +12 -2
  172. package/src/web/search/providers/google.ts +2 -1
  173. package/src/web/search/providers/jina.ts +31 -8
  174. package/src/web/search/providers/kagi.ts +10 -1
  175. package/src/web/search/providers/kimi.ts +16 -1
  176. package/src/web/search/providers/mojeek.ts +14 -1
  177. package/src/web/search/providers/parallel.ts +48 -2
  178. package/src/web/search/providers/perplexity.ts +94 -6
  179. package/src/web/search/providers/searxng.ts +145 -9
  180. package/src/web/search/providers/startpage.ts +8 -2
  181. package/src/web/search/providers/synthetic.ts +7 -1
  182. package/src/web/search/providers/tavily.ts +52 -3
  183. package/src/web/search/providers/tinyfish.ts +6 -1
  184. package/src/web/search/providers/xai.ts +49 -2
  185. package/src/web/search/providers/zai.ts +19 -1
  186. package/src/web/search/query.ts +850 -0
  187. package/dist/types/stt/recorder.d.ts +0 -30
  188. package/dist/types/stt/transcriber.d.ts +0 -14
  189. package/dist/types/stt/wav.d.ts +0 -29
  190. package/dist/types/tts/player.d.ts +0 -32
  191. package/src/live/audio-worklet.txt +0 -59
  192. package/src/live/browser-runtime.txt +0 -221
  193. package/src/stt/recorder.ts +0 -551
  194. package/src/stt/transcriber.ts +0 -60
  195. package/src/stt/wav.ts +0 -173
  196. package/src/tts/player.ts +0 -137
@@ -3,6 +3,13 @@ import type { Model, ProviderResponseMetadata, RawSseEvent } from "@oh-my-pi/pi-
3
3
  const MAX_RAW_SSE_EVENTS = 1_000;
4
4
  const MAX_RAW_SSE_CHARS = 512_000;
5
5
  const MAX_RAW_SSE_EVENT_CHARS = 64_000;
6
+ // Reserve room for the `: omp-debug-truncated` / `: omp-debug-elided` marker
7
+ // lines so a trimmed event stays within MAX_RAW_SSE_EVENT_CHARS overall.
8
+ const TRIM_MARKER_RESERVE = 200;
9
+ // Caps applied to individual tool entries when compacting a `tools` array
10
+ // inside an oversized `data:` payload.
11
+ const MAX_TOOL_SCHEMA_CHARS = 200;
12
+ const MAX_TOOL_DESCRIPTION_CHARS = 200;
6
13
 
7
14
  export type RawSseDebugRecord =
8
15
  | {
@@ -45,42 +52,162 @@ export interface RawSseDebugSnapshot {
45
52
  // path. The parallel array keeps records as plain monomorphic objects.
46
53
  type TrimResult = { raw: string[]; truncated: boolean; originalChars: number; chars: number };
47
54
 
48
- // Single-pass trim. Returns the final `chars` count using the historical
49
- // formula `reduce(line.length + 1, init = 1)` so the new accounting matches
50
- // the previous `countRecordChars` byte-for-byte (the trailing +1 covers the
51
- // record-level newline that `rawRecordText` appends in `toRawText`).
52
- //
53
- // When the event fits within budget the input `raw` array is returned
54
- // **by reference** see the ownership contract documented at
55
- // `RawSseDebugBuffer.recordEvent` below.
56
- function trimRawLines(raw: string[]): TrimResult {
57
- let originalChars = 0;
58
- for (let i = 0; i < raw.length; i++) originalChars += raw[i].length + 1;
55
+ // `chars` uses the historical formula `reduce(line.length + 1, init = 1)` so
56
+ // the accounting matches the previous `countRecordChars` byte-for-byte (the
57
+ // trailing +1 covers the record-level newline that `rawRecordText` appends in
58
+ // `toRawText`).
59
+ function countLines(lines: readonly string[]): number {
60
+ let chars = 0;
61
+ for (let i = 0; i < lines.length; i++) chars += lines[i].length + 1;
62
+ return chars;
63
+ }
64
+
65
+ function elideText(text: string, max: number): string {
66
+ if (text.length <= max) return text;
67
+ return `${text.slice(0, max)}… (+${text.length - max} chars)`;
68
+ }
69
+
70
+ // Shrinks one tool definition in place: schemas (`parameters` for OpenAI
71
+ // shapes, `input_schema` for Anthropic) become elided JSON strings and long
72
+ // descriptions are cut, while `name`/`type` survive untouched. Chat-completions
73
+ // nests the payload under `function`.
74
+ function compactToolEntry(tool: unknown): boolean {
75
+ if (typeof tool !== "object" || tool === null) return false;
76
+ const obj = tool as Record<string, unknown>;
77
+ let changed = false;
78
+ if (typeof obj.function === "object" && obj.function !== null) {
79
+ changed = compactToolEntry(obj.function);
80
+ }
81
+ for (const key of ["parameters", "input_schema"]) {
82
+ const schema = obj[key];
83
+ if (schema === undefined || schema === null) continue;
84
+ const text = typeof schema === "string" ? schema : JSON.stringify(schema);
85
+ if (text.length <= MAX_TOOL_SCHEMA_CHARS) continue;
86
+ obj[key] = elideText(text, MAX_TOOL_SCHEMA_CHARS);
87
+ changed = true;
88
+ }
89
+ if (typeof obj.description === "string" && obj.description.length > MAX_TOOL_DESCRIPTION_CHARS) {
90
+ obj.description = elideText(obj.description, MAX_TOOL_DESCRIPTION_CHARS);
91
+ changed = true;
92
+ }
93
+ return changed;
94
+ }
95
+
96
+ // Walks a parsed SSE payload and compacts every `tools` array it finds
97
+ // (e.g. `response.tools` echoed back by the Responses API). Mutates `node`.
98
+ function compactToolsDeep(node: unknown): boolean {
99
+ if (Array.isArray(node)) {
100
+ let changed = false;
101
+ for (const item of node) changed = compactToolsDeep(item) || changed;
102
+ return changed;
103
+ }
104
+ if (typeof node !== "object" || node === null) return false;
105
+ let changed = false;
106
+ const obj = node as Record<string, unknown>;
107
+ for (const key in obj) {
108
+ const value = obj[key];
109
+ if (key === "tools" && Array.isArray(value)) {
110
+ for (const tool of value) changed = compactToolEntry(tool) || changed;
111
+ } else {
112
+ changed = compactToolsDeep(value) || changed;
113
+ }
114
+ }
115
+ return changed;
116
+ }
59
117
 
118
+ // Rewrites oversized `data:` lines with tool schemas compacted. Returns null
119
+ // when nothing changed (unparseable payloads or no tools to shrink). Only
120
+ // invoked on events that already blew the budget, so the JSON round-trip is
121
+ // off the streaming hot path.
122
+ function compactToolLines(raw: readonly string[]): string[] | null {
123
+ let changed = false;
124
+ const out = raw.map(line => {
125
+ if (!line.startsWith("data:") || line.length <= MAX_TOOL_SCHEMA_CHARS) return line;
126
+ const start = line.charCodeAt(5) === 32 ? 6 : 5;
127
+ try {
128
+ const parsed = JSON.parse(line.slice(start));
129
+ if (!compactToolsDeep(parsed)) return line;
130
+ changed = true;
131
+ return `data: ${JSON.stringify(parsed)}`;
132
+ } catch {
133
+ return line;
134
+ }
135
+ });
136
+ return changed ? out : null;
137
+ }
138
+
139
+ // Keeps the first and last portions of an over-budget event and drops the
140
+ // middle, so leading fields (id/model/status) AND trailing fields
141
+ // (usage/finish_reason) both stay visible. A `: omp-debug-elided` comment
142
+ // marks the cut; split lines carry `…` at the cut edge.
143
+ function headTailTrim(lines: string[], budget: number, elidedTotal: number): string[] {
144
+ const headBudget = budget >> 1;
145
+ const tailBudget = budget - headBudget;
146
+
147
+ let i = 0;
148
+ let headRemaining = headBudget;
149
+ const out: string[] = [];
150
+ while (i < lines.length && lines[i].length + 1 <= headRemaining) {
151
+ headRemaining -= lines[i].length + 1;
152
+ out.push(lines[i]);
153
+ i++;
154
+ }
155
+
156
+ let j = lines.length - 1;
157
+ let tailRemaining = tailBudget;
158
+ const tail: string[] = [];
159
+ while (j >= i && lines[j].length + 1 <= tailRemaining) {
160
+ tailRemaining -= lines[j].length + 1;
161
+ tail.push(lines[j]);
162
+ j--;
163
+ }
164
+ tail.reverse();
165
+
166
+ let elided = elidedTotal - countLines(out) - countLines(tail);
167
+ if (i <= j) {
168
+ // lines[i..j] straddle the cut: keep a head slice of the first and a
169
+ // tail slice of the last (the same line when i === j).
170
+ const headSlice = lines[i].slice(0, Math.max(0, headRemaining - 2));
171
+ const tailStart =
172
+ i === j
173
+ ? Math.max(headSlice.length, lines[j].length - tailRemaining + 2)
174
+ : Math.max(0, lines[j].length - tailRemaining + 2);
175
+ const tailSlice = lines[j].slice(tailStart);
176
+ elided -= headSlice.length + tailSlice.length;
177
+ if (headSlice.length > 0) out.push(`${headSlice}…`);
178
+ out.push(`: omp-debug-elided chars=${Math.max(0, elided)}`);
179
+ if (tailSlice.length > 0) out.push(`…${tailSlice}`);
180
+ } else if (elided > 0) {
181
+ out.push(`: omp-debug-elided chars=${elided}`);
182
+ }
183
+ out.push(...tail);
184
+ return out;
185
+ }
186
+
187
+ // Trim pipeline for one SSE event:
188
+ // 1. fits → return `raw` **by reference** (ownership contract at
189
+ // `RawSseDebugBuffer.recordEvent` below).
190
+ // 2. over budget → compact tool schemas inside `data:` JSON payloads;
191
+ // if that alone fits, the payload stays parseable JSON.
192
+ // 3. still over → head+tail trim (middle elided).
193
+ // Any trimmed result ends with the `: omp-debug-truncated` marker carrying
194
+ // the original size.
195
+ function trimRawLines(raw: string[]): TrimResult {
196
+ const originalChars = countLines(raw);
60
197
  if (originalChars <= MAX_RAW_SSE_EVENT_CHARS) {
61
198
  return { raw, truncated: false, originalChars, chars: originalChars + 1 };
62
199
  }
63
200
 
64
- const trimmed: string[] = [];
65
- let remaining = MAX_RAW_SSE_EVENT_CHARS;
66
- let chars = 1; // matches reduce(.., init = 1)
67
- for (const line of raw) {
68
- if (remaining <= 0) break;
69
- if (line.length + 1 <= remaining) {
70
- trimmed.push(line);
71
- chars += line.length + 1;
72
- remaining -= line.length + 1;
73
- continue;
74
- }
75
- const slice = line.slice(0, Math.max(0, remaining));
76
- trimmed.push(slice);
77
- chars += slice.length + 1;
78
- remaining = 0;
201
+ const budget = MAX_RAW_SSE_EVENT_CHARS - TRIM_MARKER_RESERVE;
202
+ let lines = compactToolLines(raw) ?? raw;
203
+ const compactedChars = lines === raw ? originalChars : countLines(lines);
204
+ if (compactedChars > budget) {
205
+ lines = headTailTrim(lines, budget, compactedChars);
206
+ } else if (lines === raw) {
207
+ lines = raw.slice();
79
208
  }
80
- const tail = `: omp-debug-truncated originalChars=${originalChars}`;
81
- trimmed.push(tail);
82
- chars += tail.length + 1;
83
- return { raw: trimmed, truncated: true, originalChars, chars };
209
+ lines.push(`: omp-debug-truncated originalChars=${originalChars}`);
210
+ return { raw: lines, truncated: true, originalChars, chars: countLines(lines) + 1 };
84
211
  }
85
212
 
86
213
  export function formatRawSseIsoTime(timestamp: number): string {
@@ -497,6 +497,7 @@ export async function executeWithKernelBase<
497
497
  logger.error(`${errorLogLabel} execution failed`, { error: error.message });
498
498
  throw error;
499
499
  } finally {
500
+ await sink.dispose();
500
501
  unregisterBridge?.();
501
502
  abortShield.dispose?.();
502
503
  }
@@ -169,5 +169,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
169
169
  outputBytes: summary.outputBytes,
170
170
  displayOutputs,
171
171
  };
172
+ } finally {
173
+ await outputSink.dispose();
172
174
  }
173
175
  }
@@ -581,6 +581,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
581
581
  resetSession = true;
582
582
  throw err;
583
583
  } finally {
584
+ await sink.dispose();
584
585
  if (timeoutTimer) {
585
586
  clearTimeout(timeoutTimer);
586
587
  }
@@ -400,8 +400,9 @@ function redactShareMessage(
400
400
  };
401
401
  case "assistant":
402
402
  // Drop opaque provider-replay state (encrypted reasoning / native history) the viewer
403
- // never reads and we cannot redact field-by-field: `providerPayload` and any
404
- // `redactedThinking` blocks.
403
+ // never reads and we cannot redact field-by-field: `providerPayload`, any
404
+ // `redactedThinking` blocks, and native Anthropic server-tool blocks
405
+ // (`server_tool_use` input / `web_search_tool_result` encrypted_content).
405
406
  return {
406
407
  ...message,
407
408
  providerPayload: undefined,
@@ -410,7 +411,7 @@ function redactShareMessage(
410
411
  ? undefined
411
412
  : o.obfuscate(message.errorMessage, sharedRegexSecretValues),
412
413
  content: message.content.flatMap((block): AssistantMessage["content"] => {
413
- if (block.type === "redactedThinking") return [];
414
+ if (block.type === "redactedThinking" || block.type === "anthropicServerTool") return [];
414
415
  if (block.type === "text") return [{ ...block, text: o.obfuscate(block.text, sharedRegexSecretValues) }];
415
416
  if (block.type === "thinking") {
416
417
  return [{ ...block, thinking: o.obfuscate(block.thinking, sharedRegexSecretValues) }];
@@ -8,10 +8,11 @@ import type {
8
8
  AgentToolUpdateCallback,
9
9
  ToolLoadMode,
10
10
  } from "@oh-my-pi/pi-agent-core";
11
- import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
11
+ import type { ComputerSafetyCheck, ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
12
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
12
13
  import type { Settings } from "../../config/settings";
13
14
  import type { Theme } from "../../modes/theme/theme";
14
- import { type ApprovalMode, formatApprovalPrompt, resolveApproval } from "../../tools/approval";
15
+ import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
15
16
  import { defaultLoadModeForToolName } from "../../tools/essential-tools";
16
17
  import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
17
18
  import { applyToolProxy } from "../tool-proxy";
@@ -83,6 +84,42 @@ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: E
83
84
  return registeredTools.map(rt => wrapRegisteredTool(rt, runner));
84
85
  }
85
86
 
87
+ function computerSafetyChecks(context: AgentToolContext | undefined): ComputerSafetyCheck[] {
88
+ const metadata = context?.toolCall?.providerMetadata;
89
+ return metadata?.type === "computer" ? metadata.pendingSafetyChecks : [];
90
+ }
91
+
92
+ function approvalArgs(params: unknown, context: AgentToolContext | undefined): unknown {
93
+ const metadata = context?.toolCall?.providerMetadata;
94
+ return metadata?.type === "computer" ? { actions: metadata.actions } : params;
95
+ }
96
+
97
+ function toolEventArgs(params: unknown, context: AgentToolContext | undefined): Record<string, unknown> {
98
+ const metadata = context?.toolCall?.providerMetadata;
99
+ if (metadata?.type === "computer") {
100
+ return {
101
+ actions: metadata.actions,
102
+ pendingSafetyChecks: metadata.pendingSafetyChecks,
103
+ };
104
+ }
105
+ return params as Record<string, unknown>;
106
+ }
107
+
108
+ function approvalData(value: string): string {
109
+ const sanitized = sanitizeText(value)
110
+ .replace(/[\r\n\t]+/g, " ")
111
+ .trim();
112
+ const truncated = truncateForPrompt(sanitized, 500);
113
+ return truncated.replace(/([\\`*_{}[\]()<>#+\-.!|])/g, "\\$1");
114
+ }
115
+
116
+ function safetyCheckLines(checks: readonly ComputerSafetyCheck[]): string[] {
117
+ return checks.map((check, index) => {
118
+ const value = check.message || check.code || check.id;
119
+ return `${index + 1}. ${approvalData(value)}`;
120
+ });
121
+ }
122
+
86
123
  /**
87
124
  * Wraps a tool with extension callbacks for interception.
88
125
  * - Emits tool_call event before execution (can block)
@@ -128,19 +165,25 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
128
165
  const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
129
166
  const approvalMode: ApprovalMode = cliAutoApprove ? "yolo" : configuredMode;
130
167
  const userPolicies = (settings?.get("tools.approval") ?? {}) as Record<string, unknown>;
131
- const resolved = resolveApproval(this.tool, params, approvalMode, userPolicies);
168
+ const resolvedArgs = approvalArgs(params, context);
169
+ const resolved = resolveApproval(this.tool, resolvedArgs, approvalMode, userPolicies);
132
170
  if (resolved.policy === "deny") {
133
171
  throw new Error(
134
172
  `Tool "${this.tool.name}" is blocked by user policy.\n` +
135
173
  `To allow: remove "tools.approval.${this.tool.name}: deny" from config.`,
136
174
  );
137
175
  }
176
+ const pendingSafetyChecks = computerSafetyChecks(context);
138
177
  // An xd:// device dispatch already cleared the write tool's outer gate at
139
178
  // this tool's tier — re-prompting would double-ask for one action. Explicit
140
179
  // per-tool "prompt" policies and tool-demanded overrides still prompt.
180
+ // Provider safety checks are stronger: yolo, per-tool allow, and xdev approval
181
+ // never acknowledge them on the user's behalf.
141
182
  const explicitPrompt = resolved.override || Object.hasOwn(userPolicies, this.tool.name);
142
183
  const approvalCheck = {
143
- required: resolved.policy === "prompt" && (explicitPrompt || context?.xdevApproved !== true),
184
+ required:
185
+ pendingSafetyChecks.length > 0 ||
186
+ (resolved.policy === "prompt" && (explicitPrompt || context?.xdevApproved !== true)),
144
187
  reason: resolved.reason,
145
188
  };
146
189
 
@@ -171,10 +214,16 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
171
214
  });
172
215
  };
173
216
 
174
- // Check if UI is available
217
+ // Provider safety checks fail closed without an interactive prompt. Unlike
218
+ // ordinary tier approval, no setting or yolo mode may bypass this gate.
175
219
  if (!this.runner.hasUI()) {
176
220
  const reason = "no interactive UI available";
177
221
  await resolveApproval(false, reason);
222
+ if (pendingSafetyChecks.length > 0) {
223
+ throw new Error(
224
+ `Tool "${this.tool.name}" has pending provider safety checks but no interactive UI is available.`,
225
+ );
226
+ }
178
227
  throw new Error(
179
228
  `Tool "${this.tool.name}" requires approval but no interactive UI available.\n` +
180
229
  `Options:\n` +
@@ -185,12 +234,14 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
185
234
  }
186
235
 
187
236
  const uiContext = this.runner.getUIContext();
237
+ const basePrompt = formatApprovalPrompt(this.tool, resolvedArgs, approvalCheck.reason);
238
+ const safetyPrompt =
239
+ pendingSafetyChecks.length > 0
240
+ ? `${basePrompt}\nProvider safety checks:\n${safetyCheckLines(pendingSafetyChecks).join("\n")}`
241
+ : basePrompt;
188
242
  let choice: string | undefined;
189
243
  try {
190
- choice = await uiContext.select(formatApprovalPrompt(this.tool, params, approvalCheck.reason), [
191
- "Approve",
192
- "Deny",
193
- ]);
244
+ choice = await uiContext.select(safetyPrompt, ["Approve", "Deny"]);
194
245
  } catch (err) {
195
246
  await resolveApproval(false, err instanceof Error ? err.message : "approval aborted");
196
247
  throw err;
@@ -200,6 +251,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
200
251
  if (!approved) {
201
252
  throw new Error(`Tool call denied by user: ${this.tool.name}`);
202
253
  }
254
+ if (pendingSafetyChecks.length > 0) {
255
+ if (!context) throw new Error("Provider safety approval context is unavailable");
256
+ context.providerSafetyApproved = true;
257
+ }
203
258
  }
204
259
 
205
260
  // 2. Emit tool_call event - extensions can block execution
@@ -211,7 +266,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
211
266
  toolCallId,
212
267
  input: normalizeToolEventInput(
213
268
  this.tool.name,
214
- resolveToolEventInput(this.tool, params as Record<string, unknown>),
269
+ resolveToolEventInput(this.tool, toolEventArgs(params, context)),
215
270
  ),
216
271
  })) as ToolCallEventResult | undefined;
217
272
 
@@ -228,7 +283,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
228
283
  }
229
284
 
230
285
  // Execute the actual tool
231
- let result: { content: any; details?: TDetails };
286
+ let result: AgentToolResult<TDetails, TParameters>;
232
287
  let executionError: Error | undefined;
233
288
 
234
289
  try {
@@ -249,7 +304,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
249
304
  toolCallId,
250
305
  input: normalizeToolEventInput(
251
306
  this.tool.name,
252
- resolveToolEventInput(this.tool, params as Record<string, unknown>),
307
+ resolveToolEventInput(this.tool, toolEventArgs(params, context)),
253
308
  ),
254
309
  content: result.content,
255
310
  details: result.details,
@@ -275,6 +330,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
275
330
  return {
276
331
  content: modifiedContent,
277
332
  details: modifiedDetails,
333
+ providerMetadata: result.providerMetadata,
278
334
  ...(effectiveError ? { isError: true } : {}),
279
335
  };
280
336
  }
@@ -17,7 +17,7 @@ import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import { type AuthCredential, SqliteAuthCredentialStore, type TSchema } from "@oh-my-pi/pi-ai";
20
- import { Text } from "@oh-my-pi/pi-tui";
20
+ import { getKeybindings, type Keybinding, Text } from "@oh-my-pi/pi-tui";
21
21
  import {
22
22
  getAgentDbPath,
23
23
  getAgentDir,
@@ -26,6 +26,7 @@ import {
26
26
  parseFrontmatter as parseOmpFrontmatter,
27
27
  } from "@oh-my-pi/pi-utils";
28
28
  import { getPackageDir as getOmpPackageDir } from "../config";
29
+ import { formatKeyHints } from "../config/keybindings";
29
30
  import type { PromptTemplate } from "../config/prompt-templates";
30
31
  import { type SettingPath, Settings } from "../config/settings";
31
32
  import { EditTool } from "../edit";
@@ -373,6 +374,11 @@ async function executeLegacyBashOperations(
373
374
  }
374
375
  }
375
376
 
377
+ /** Format the active shortcut for legacy extensions that render keybinding hints. */
378
+ export function keyText(action: Keybinding): string {
379
+ return formatKeyHints(getKeybindings().getKeys(action));
380
+ }
381
+
376
382
  /** Parse frontmatter using the historical Pi package-root helper. */
377
383
  export interface ParsedFrontmatter<T extends Record<string, unknown> = Record<string, unknown>> {
378
384
  frontmatter: T;
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { TempDir } from "@oh-my-pi/pi-utils";
5
+ import { createDaemonBrokerClient, type DaemonBrokerClient } from "./client";
6
+ import type { DaemonSnapshot, DaemonSpec } from "./protocol";
7
+
8
+ const TERMINAL_HISTORY_LIMIT = 10;
9
+
10
+ function spec(name: string, cwd: string): DaemonSpec {
11
+ return {
12
+ name,
13
+ application: process.execPath,
14
+ args: [],
15
+ env: {},
16
+ cwd,
17
+ pty: false,
18
+ restart: "no",
19
+ persist: false,
20
+ detached: false,
21
+ };
22
+ }
23
+
24
+ function terminalSnapshot(index: number): DaemonSnapshot {
25
+ const name = `exited-${index}`;
26
+ return {
27
+ name,
28
+ id: name,
29
+ state: index % 2 === 0 ? "exited" : "failed",
30
+ createdAt: index * 10,
31
+ startedAt: index * 10,
32
+ exitedAt: index * 10 + 1,
33
+ exitReason: `historical exit ${index}`,
34
+ restartCount: 0,
35
+ outputBytes: 0,
36
+ persist: false,
37
+ detached: false,
38
+ };
39
+ }
40
+
41
+ async function seedTerminalRecord(runtimeDir: string, cwd: string, snapshot: DaemonSnapshot): Promise<void> {
42
+ const metaPath = path.join(runtimeDir, "daemons", snapshot.name, "meta.json");
43
+ await Bun.write(metaPath, JSON.stringify({ daemon: snapshot, spec: spec(snapshot.name, cwd) }));
44
+ }
45
+
46
+ async function shutdown(client: DaemonBrokerClient, activeName: string): Promise<void> {
47
+ await client.request({ op: "stop", name: activeName, timeoutMs: 2_000 }).catch(() => undefined);
48
+ await client.request({ op: "shutdown" }).catch(() => undefined);
49
+ client.close();
50
+ }
51
+
52
+ describe("broker list", () => {
53
+ it("returns active daemons first and caps recovered terminal history by real exit time", async () => {
54
+ using tempDir = TempDir.createSync("@omp-launch-list-");
55
+ const projectDir = path.join(tempDir.path(), "project");
56
+ const runtimeDir = path.join(tempDir.path(), "runtime");
57
+ await fs.mkdir(projectDir);
58
+
59
+ for (let index = 0; index < TERMINAL_HISTORY_LIMIT + 5; index++) {
60
+ await seedTerminalRecord(runtimeDir, projectDir, terminalSnapshot(index));
61
+ }
62
+
63
+ const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
64
+ const activeName = "active-server";
65
+ try {
66
+ const started = await client.request({
67
+ op: "start",
68
+ spec: {
69
+ ...spec(activeName, projectDir),
70
+ args: ["-e", "process.stdin.resume()"],
71
+ },
72
+ });
73
+ expect(started.op).toBe("start");
74
+
75
+ const listed = await client.request({ op: "list" });
76
+ expect(listed.op).toBe("list");
77
+ if (listed.op !== "list") throw new Error(`Unexpected broker result: ${listed.op}`);
78
+
79
+ expect(listed.daemons.map(daemon => daemon.name)).toEqual([
80
+ activeName,
81
+ ...Array.from({ length: TERMINAL_HISTORY_LIMIT }, (_, offset) => `exited-${14 - offset}`),
82
+ ]);
83
+ expect(listed.daemons[0]?.state).toBe("running");
84
+ expect(listed.daemons.at(-1)?.exitedAt).toBe(51);
85
+ } finally {
86
+ await shutdown(client, activeName);
87
+ }
88
+ }, 20_000);
89
+ });
@@ -33,6 +33,12 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024;
33
33
  const LOG_READ_BYTES = 2 * 1024 * 1024;
34
34
  const READINESS_BUFFER_CHARS = 64 * 1024;
35
35
  const RESTART_MAX_DELAY_MS = 30_000;
36
+ /**
37
+ * Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons
38
+ * are always shown in full; older history is truncated so the response stays
39
+ * bounded over a long-lived project (issue #6517).
40
+ */
41
+ const MAX_TERMINAL_DAEMONS_LISTED = 10;
36
42
  const TOKEN_FILE = "broker.token";
37
43
  const PID_FILE = "broker.pid";
38
44
  const META_FILE = "meta.json";
@@ -95,6 +101,41 @@ function terminalState(state: DaemonSnapshot["state"]): boolean {
95
101
  return state === "exited" || state === "failed";
96
102
  }
97
103
 
104
+ /**
105
+ * Order daemons for the `list` response: non-terminal (active) daemons first,
106
+ * oldest to newest, so the process the user is acting on is immediately visible
107
+ * instead of buried behind exited history; then the most recently exited/failed
108
+ * ones, capped at {@link MAX_TERMINAL_DAEMONS_LISTED} to keep the response from
109
+ * growing without bound. Truncated terminal records stay addressable by name
110
+ * via `describe`/`logs`/`restart`.
111
+ */
112
+ function orderDaemonsForListing(snapshots: DaemonSnapshot[]): DaemonSnapshot[] {
113
+ const active: DaemonSnapshot[] = [];
114
+ const terminal: DaemonSnapshot[] = [];
115
+ for (const snapshot of snapshots) {
116
+ (terminalState(snapshot.state) ? terminal : active).push(snapshot);
117
+ }
118
+ active.sort((left, right) => left.createdAt - right.createdAt);
119
+ terminal.sort((left, right) => (right.exitedAt ?? right.createdAt) - (left.exitedAt ?? left.createdAt));
120
+ return [...active, ...terminal.slice(0, MAX_TERMINAL_DAEMONS_LISTED)];
121
+ }
122
+
123
+ /**
124
+ * Reap a recovered non-detached daemon snapshot in place. Already-terminal
125
+ * records are left untouched so `list` keeps their real {@link DaemonSnapshot.exitedAt}
126
+ * for recency ranking; records that were still alive when the previous broker
127
+ * exited are marked `exited` at `now`, since their process died with that broker
128
+ * (issue #6517). Returns whether the record was reaped.
129
+ */
130
+ function reapRecoveredSnapshot(snapshot: DaemonSnapshot, now: number): boolean {
131
+ if (terminalState(snapshot.state)) return false;
132
+ snapshot.pid = undefined;
133
+ snapshot.state = "exited";
134
+ snapshot.exitedAt = now;
135
+ snapshot.exitReason = "previous broker exited";
136
+ return true;
137
+ }
138
+
98
139
  /** Mirror per-condition readiness progress into the snapshot so clients can see which condition is unmet. */
99
140
  function syncReadyPending(record: ManagedDaemon): void {
100
141
  if (record.snapshot.state !== "starting") {
@@ -402,9 +443,7 @@ class DaemonBroker {
402
443
  await Promise.all([...this.#records.values()].map(record => this.#refreshDetached(record)));
403
444
  return {
404
445
  op: "list",
405
- daemons: [...this.#records.values()]
406
- .sort((left, right) => left.snapshot.createdAt - right.snapshot.createdAt)
407
- .map(record => record.snapshot),
446
+ daemons: orderDaemonsForListing([...this.#records.values()].map(record => record.snapshot)),
408
447
  };
409
448
  }
410
449
  case "logs":
@@ -973,11 +1012,13 @@ class DaemonBroker {
973
1012
  snapshot.state !== "stopping" &&
974
1013
  processRef?.status() === "running";
975
1014
  if (!detached) {
976
- if (processRef) await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 });
977
- snapshot.pid = undefined;
978
- snapshot.state = "exited";
979
- snapshot.exitedAt = Date.now();
980
- snapshot.exitReason = "previous broker exited";
1015
+ // Reap only records that were still alive when the previous broker
1016
+ // exited; already-terminal records keep their real exit time so
1017
+ // `list` ranks exited history by true recency (issue #6517).
1018
+ if (!terminalState(snapshot.state) && processRef) {
1019
+ await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 });
1020
+ }
1021
+ reapRecoveredSnapshot(snapshot, Date.now());
981
1022
  } else if (snapshot.state === "restarting") {
982
1023
  snapshot.state = spec.ready ? "starting" : "running";
983
1024
  }