@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,561 @@
1
+ import * as fs from "node:fs";
2
+ import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils";
3
+ import type { BunFile } from "bun";
4
+ import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client";
5
+ import { getServersForFile } from "./config";
6
+ import {
7
+ captureDiagnosticVersions,
8
+ captureOpenFileVersions,
9
+ DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
10
+ type FileDiagnosticsResult,
11
+ FileFormatResult,
12
+ formatContent,
13
+ getDiagnosticsForFile,
14
+ INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
15
+ limitDiagnosticMessages,
16
+ type ServerVersionMap,
17
+ } from "./diagnostics";
18
+ import { getConfig, notifyFileSaved, splitServers, syncFileContent } from "./servers";
19
+ import type { ServerConfig } from "./types";
20
+ import { summarizeDiagnosticMessages } from "./utils";
21
+
22
+ /** Options for creating the LSP writethrough callback */
23
+ export interface WritethroughOptions {
24
+ /** Whether to format the file using LSP after writing */
25
+ enableFormat?: boolean;
26
+ /** Whether to get LSP diagnostics after writing */
27
+ enableDiagnostics?: boolean;
28
+ /** Called when diagnostics arrive after the main timeout. */
29
+ onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
30
+ /** Signal to cancel a pending deferred diagnostics fetch. */
31
+ deferredSignal?: AbortSignal;
32
+ /** Transform diagnostics before surfacing them after a successful fetch. */
33
+ transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
34
+ }
35
+
36
+ /** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
37
+ type ResolvedWritethroughOptions = {
38
+ enableFormat: boolean;
39
+ enableDiagnostics: boolean;
40
+ transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
41
+ };
42
+
43
+ /** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
44
+ export type WritethroughDeferredHandle = {
45
+ onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
46
+ signal: AbortSignal;
47
+ finalize: (diagnostics: FileDiagnosticsResult | undefined) => void;
48
+ };
49
+
50
+ /** Callback type for the LSP writethrough */
51
+ export type WritethroughCallback = (
52
+ dst: string,
53
+ content: string,
54
+ signal?: AbortSignal,
55
+ file?: BunFile,
56
+ batch?: LspWritethroughBatchRequest,
57
+ getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
58
+ ) => Promise<FileDiagnosticsResult | undefined>;
59
+
60
+ /** No-op writethrough callback */
61
+ export async function writethroughNoop(
62
+ dst: string,
63
+ content: string,
64
+ _signal?: AbortSignal,
65
+ file?: BunFile,
66
+ _batch?: LspWritethroughBatchRequest,
67
+ _getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
68
+ ): Promise<FileDiagnosticsResult | undefined> {
69
+ if (file) {
70
+ await file.write(content);
71
+ } else {
72
+ await Bun.write(dst, content);
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ interface PendingWritethrough {
78
+ dst: string;
79
+ file?: BunFile;
80
+ changeType: FileChangeType;
81
+ }
82
+
83
+ interface RunLspWritethroughOptions {
84
+ contentAlreadyWritten?: boolean;
85
+ }
86
+
87
+ interface LspWritethroughBatchRequest {
88
+ id: string;
89
+ flush: boolean;
90
+ }
91
+
92
+ interface LspWritethroughBatchState {
93
+ entries: Map<string, PendingWritethrough>;
94
+ options: ResolvedWritethroughOptions;
95
+ }
96
+
97
+ const writethroughBatches = new Map<string, LspWritethroughBatchState>();
98
+
99
+ function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughOptions): LspWritethroughBatchState {
100
+ const existing = writethroughBatches.get(id);
101
+ if (existing) {
102
+ existing.options.enableFormat ||= options.enableFormat;
103
+ existing.options.enableDiagnostics ||= options.enableDiagnostics;
104
+ existing.options.transformDiagnostics ??= options.transformDiagnostics;
105
+ return existing;
106
+ }
107
+ const batch: LspWritethroughBatchState = {
108
+ entries: new Map<string, PendingWritethrough>(),
109
+ options: { ...options },
110
+ };
111
+ writethroughBatches.set(id, batch);
112
+ return batch;
113
+ }
114
+
115
+ export async function flushLspWritethroughBatch(
116
+ id: string,
117
+ cwd: string,
118
+ signal?: AbortSignal,
119
+ ): Promise<FileDiagnosticsResult | undefined> {
120
+ const state = writethroughBatches.get(id);
121
+ if (!state) {
122
+ return undefined;
123
+ }
124
+ writethroughBatches.delete(id);
125
+ return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
126
+ }
127
+
128
+ function mergeDiagnostics(
129
+ results: Array<FileDiagnosticsResult | undefined>,
130
+ options: ResolvedWritethroughOptions,
131
+ ): FileDiagnosticsResult | undefined {
132
+ const messages: string[] = [];
133
+ const servers = new Set<string>();
134
+ let hasResults = false;
135
+ let hasFormatter = false;
136
+ let formatted = false;
137
+
138
+ for (const result of results) {
139
+ if (!result) continue;
140
+ hasResults = true;
141
+ if (result.server) {
142
+ for (const server of result.server.split(",")) {
143
+ const trimmed = server.trim();
144
+ if (trimmed) {
145
+ servers.add(trimmed);
146
+ }
147
+ }
148
+ }
149
+ if (result.messages.length > 0) {
150
+ messages.push(...result.messages);
151
+ }
152
+ if (result.formatter !== undefined) {
153
+ hasFormatter = true;
154
+ if (result.formatter === FileFormatResult.FORMATTED) {
155
+ formatted = true;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (!hasResults && !hasFormatter) {
161
+ return undefined;
162
+ }
163
+
164
+ let summary = options.enableDiagnostics ? "no issues" : "OK";
165
+ let errored = false;
166
+ let limitedMessages = messages;
167
+ if (messages.length > 0) {
168
+ const summaryInfo = summarizeDiagnosticMessages(messages);
169
+ summary = summaryInfo.summary;
170
+ errored = summaryInfo.errored;
171
+ limitedMessages = limitDiagnosticMessages(messages);
172
+ }
173
+ const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
174
+
175
+ return {
176
+ server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
177
+ messages: limitedMessages,
178
+ summary,
179
+ errored,
180
+ formatter,
181
+ };
182
+ }
183
+
184
+ async function scheduleDeferredDiagnosticsFetch(args: {
185
+ dst: string;
186
+ cwd: string;
187
+ servers: Array<[string, ServerConfig]>;
188
+ minVersions: ServerVersionMap | undefined;
189
+ expectedDocumentVersions: ServerVersionMap | undefined;
190
+ signal: AbortSignal;
191
+ callback: (diagnostics: FileDiagnosticsResult) => void;
192
+ }): Promise<void> {
193
+ try {
194
+ const deferredTimeout = AbortSignal.timeout(25_000);
195
+ const combined = AbortSignal.any([args.signal, deferredTimeout]);
196
+ const diagnostics = await getDiagnosticsForFile(args.dst, args.cwd, args.servers, {
197
+ signal: combined,
198
+ minVersions: args.minVersions,
199
+ expectedDocumentVersions: args.expectedDocumentVersions,
200
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
201
+ });
202
+ if (args.signal.aborted || diagnostics === undefined) return;
203
+ args.callback(diagnostics);
204
+ } catch {
205
+ // Cancelled or LSP gave up; silently discard.
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Fetch post-write diagnostics without making the edit/write block on a slow
211
+ * language server.
212
+ *
213
+ * Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
214
+ * fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
215
+ * exact document-version matches return immediately, and unversioned/mismatched
216
+ * publishes must settle with no newer publish before inline acceptance. If
217
+ * nothing fresh arrives in the inline window and a deferred
218
+ * channel is available, the in-flight fetch is handed off to deliver late via
219
+ * `onDeferredDiagnostics`, and this returns `undefined` so the tool result
220
+ * lands immediately. Without a deferred channel (direct/CI callers) it blocks
221
+ * for the standard budget so the result is still returned inline.
222
+ */
223
+ async function fetchDiagnosticsWithDeferral(args: {
224
+ dst: string;
225
+ cwd: string;
226
+ servers: Array<[string, ServerConfig]>;
227
+ minVersions: ServerVersionMap | undefined;
228
+ expectedDocumentVersions: ServerVersionMap | undefined;
229
+ transformDiagnostics?: ResolvedWritethroughOptions["transformDiagnostics"];
230
+ deferred?: { onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; signal: AbortSignal };
231
+ signal?: AbortSignal;
232
+ }): Promise<FileDiagnosticsResult | undefined> {
233
+ const { dst, cwd, servers, minVersions, expectedDocumentVersions, transformDiagnostics, deferred, signal } = args;
234
+ const apply = (d: FileDiagnosticsResult | undefined) =>
235
+ d && transformDiagnostics ? transformDiagnostics(dst, d) : d;
236
+
237
+ if (!deferred) {
238
+ // No late-injection channel: block for the standard budget and return inline.
239
+ return apply(
240
+ await getDiagnosticsForFile(dst, cwd, servers, {
241
+ signal,
242
+ minVersions,
243
+ expectedDocumentVersions,
244
+ }),
245
+ );
246
+ }
247
+
248
+ // One background fetch with a generous inner budget; await it only briefly inline.
249
+ const fetchPromise = getDiagnosticsForFile(dst, cwd, servers, {
250
+ signal: deferred.signal,
251
+ minVersions,
252
+ expectedDocumentVersions,
253
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
254
+ });
255
+ const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
256
+ const raced = await Promise.race([
257
+ fetchPromise,
258
+ Bun.sleep(INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS).then(() => INLINE_TIMEOUT),
259
+ ]);
260
+ if (raced !== INLINE_TIMEOUT) {
261
+ return apply(raced as FileDiagnosticsResult | undefined);
262
+ }
263
+ // Slow server: deliver late via the deferred channel; nothing inline. The
264
+ // deferred sink (edit tool) applies its own dedup, so pass the raw result.
265
+ void fetchPromise
266
+ .then(diagnostics => {
267
+ if (diagnostics && !deferred.signal.aborted) deferred.onDeferredDiagnostics(diagnostics);
268
+ })
269
+ .catch(() => {});
270
+ return undefined;
271
+ }
272
+
273
+ async function runLspWritethrough(
274
+ dst: string,
275
+ content: string,
276
+ cwd: string,
277
+ options: ResolvedWritethroughOptions,
278
+ changeType: FileChangeType,
279
+ signal?: AbortSignal,
280
+ file?: BunFile,
281
+ deferred?: {
282
+ onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
283
+ signal: AbortSignal;
284
+ },
285
+ runOptions?: RunLspWritethroughOptions,
286
+ ): Promise<FileDiagnosticsResult | undefined> {
287
+ const { enableFormat, enableDiagnostics } = options;
288
+ const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
289
+
290
+ let finalContent = content;
291
+ const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
292
+ const getWritePromise = once(() =>
293
+ contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
294
+ );
295
+ let writeNotified = false;
296
+ const notifyWriteCommitted = async (notifySignal: AbortSignal | undefined = signal) => {
297
+ if (writeNotified) return;
298
+ writeNotified = true;
299
+ try {
300
+ await notifyWorkspaceWatchedFiles(cwd, [{ filePath: dst, type: changeType }], notifySignal);
301
+ } catch (error) {
302
+ if (notifySignal?.aborted && !signal?.aborted) {
303
+ // The operation budget died mid-notify while the caller is still
304
+ // live: allow the post-write retry below to re-announce with the
305
+ // caller's signal (didChangeWatchedFiles is idempotent).
306
+ writeNotified = false;
307
+ return;
308
+ }
309
+ throw error;
310
+ }
311
+ };
312
+ if (!enableFormat && !enableDiagnostics) {
313
+ await getWritePromise();
314
+ await notifyWriteCommitted();
315
+ return undefined;
316
+ }
317
+
318
+ const config = getConfig(cwd);
319
+ const servers = getServersForFile(config, dst);
320
+
321
+ if (servers.length === 0) {
322
+ await getWritePromise();
323
+ await notifyWriteCommitted();
324
+ return undefined;
325
+ }
326
+ const { lspServers, customLinterServers } = splitServers(servers);
327
+ const useCustomFormatter = enableFormat && customLinterServers.length > 0;
328
+
329
+ // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
330
+ // Bound client creation by the writethrough budget: a hung/broken server
331
+ // must not add its full init wait (30s default) to every edit.
332
+ const minVersionsPromise = enableDiagnostics ? captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
333
+ let minVersions = useCustomFormatter ? undefined : await minVersionsPromise;
334
+ let expectedDocumentVersions: ServerVersionMap | undefined;
335
+
336
+ let formatter: FileFormatResult | undefined;
337
+ let diagnostics: FileDiagnosticsResult | undefined;
338
+ let timedOut = false;
339
+ let synced = false;
340
+ let operationSignal: AbortSignal | undefined;
341
+ try {
342
+ const timeoutSignal = AbortSignal.timeout(5_000);
343
+ timeoutSignal.addEventListener(
344
+ "abort",
345
+ () => {
346
+ timedOut = true;
347
+ },
348
+ { once: true },
349
+ );
350
+ operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
351
+ await untilAborted(operationSignal, async () => {
352
+ if (useCustomFormatter) {
353
+ // Custom linters operate on on-disk input; the shared pre-write also
354
+ // supports implementations that inspect the file before formatting.
355
+ if (!contentAlreadyWritten) await writeContent(content);
356
+ const [formattedContent, capturedVersions] = await Promise.all([
357
+ formatContent(dst, content, cwd, customLinterServers, operationSignal),
358
+ minVersionsPromise,
359
+ ]);
360
+ finalContent = formattedContent;
361
+ minVersions = capturedVersions;
362
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
363
+ if (!contentAlreadyWritten || finalContent !== content) await writeContent(finalContent);
364
+ await notifyWriteCommitted(operationSignal);
365
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
366
+ } else {
367
+ // 1. Sync original content to LSP servers
368
+ await syncFileContent(dst, content, cwd, lspServers, operationSignal);
369
+
370
+ // 2. Format in-memory via LSP
371
+ if (enableFormat) {
372
+ finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
373
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
374
+ }
375
+
376
+ // 3. If formatted, sync formatted content to LSP servers
377
+ if (finalContent !== content) {
378
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
379
+ }
380
+
381
+ // 4. Write to disk
382
+ await getWritePromise();
383
+ await notifyWriteCommitted(operationSignal);
384
+ }
385
+
386
+ if (enableDiagnostics) {
387
+ expectedDocumentVersions = await captureOpenFileVersions(dst, cwd, lspServers, operationSignal);
388
+ }
389
+
390
+ // 5. Notify saved to LSP servers
391
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal, !useCustomFormatter || enableDiagnostics);
392
+ });
393
+ synced = true;
394
+ } catch {
395
+ if (timedOut) {
396
+ formatter = undefined;
397
+ diagnostics = undefined;
398
+ // Schedule background diagnostic fetch if caller wants deferred results
399
+ if (deferred && !deferred.signal.aborted && enableDiagnostics) {
400
+ void scheduleDeferredDiagnosticsFetch({
401
+ dst,
402
+ cwd,
403
+ servers,
404
+ minVersions,
405
+ expectedDocumentVersions,
406
+ signal: deferred.signal,
407
+ callback: deferred.onDeferredDiagnostics,
408
+ });
409
+ }
410
+ }
411
+ await getWritePromise();
412
+ // The write above committed even though the operation budget elapsed:
413
+ // announce it on the caller's signal — the dead `operationSignal` would
414
+ // abort the notify before it ever reaches the server.
415
+ await notifyWriteCommitted();
416
+ }
417
+
418
+ if (synced && enableDiagnostics) {
419
+ diagnostics = await fetchDiagnosticsWithDeferral({
420
+ dst,
421
+ cwd,
422
+ servers,
423
+ minVersions,
424
+ expectedDocumentVersions,
425
+ transformDiagnostics: options.transformDiagnostics,
426
+ deferred,
427
+ signal,
428
+ });
429
+ }
430
+
431
+ if (formatter !== undefined) {
432
+ diagnostics ??= {
433
+ server: servers.map(([name]) => name).join(", "),
434
+ messages: [],
435
+ summary: "OK",
436
+ errored: false,
437
+ };
438
+ diagnostics.formatter = formatter;
439
+ }
440
+
441
+ return diagnostics;
442
+ }
443
+
444
+ async function flushWritethroughBatch(
445
+ batch: PendingWritethrough[],
446
+ cwd: string,
447
+ options: ResolvedWritethroughOptions,
448
+ signal?: AbortSignal,
449
+ getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
450
+ ): Promise<FileDiagnosticsResult | undefined> {
451
+ if (batch.length === 0) {
452
+ return undefined;
453
+ }
454
+ const results: Array<FileDiagnosticsResult | undefined> = [];
455
+ for (const entry of batch) {
456
+ const bundle = getDeferred?.(entry.dst);
457
+ let content: string;
458
+ try {
459
+ content = await fs.promises.readFile(entry.dst, "utf8");
460
+ } catch (error) {
461
+ if (!isEnoent(error)) throw error;
462
+ bundle?.finalize(undefined);
463
+ continue;
464
+ }
465
+ const deferredInner =
466
+ bundle &&
467
+ ({
468
+ onDeferredDiagnostics: bundle.onDeferredDiagnostics,
469
+ signal: bundle.signal,
470
+ } as const);
471
+ const diag = await runLspWritethrough(
472
+ entry.dst,
473
+ content,
474
+ cwd,
475
+ options,
476
+ entry.changeType,
477
+ signal,
478
+ entry.file,
479
+ deferredInner,
480
+ { contentAlreadyWritten: true },
481
+ );
482
+ bundle?.finalize(diag);
483
+ results.push(diag);
484
+ }
485
+ return mergeDiagnostics(results, options);
486
+ }
487
+
488
+ /** Create a writethrough callback for LSP aware write operations */
489
+ export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
490
+ const resolvedOptions: ResolvedWritethroughOptions = {
491
+ enableFormat: options?.enableFormat ?? false,
492
+ enableDiagnostics: options?.enableDiagnostics ?? false,
493
+ transformDiagnostics: options?.transformDiagnostics,
494
+ };
495
+ return async (
496
+ dst: string,
497
+ content: string,
498
+ signal?: AbortSignal,
499
+ file?: BunFile,
500
+ batch?: LspWritethroughBatchRequest,
501
+ getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
502
+ ) => {
503
+ const changeType = (await Bun.file(dst).exists()) ? FileChangeType.Changed : FileChangeType.Created;
504
+ if (!batch) {
505
+ const bundle = getDeferred?.(dst);
506
+ const deferredInner =
507
+ bundle &&
508
+ ({
509
+ onDeferredDiagnostics: bundle.onDeferredDiagnostics,
510
+ signal: bundle.signal,
511
+ } as const);
512
+ const diagnostics = await runLspWritethrough(
513
+ dst,
514
+ content,
515
+ cwd,
516
+ resolvedOptions,
517
+ changeType,
518
+ signal,
519
+ file,
520
+ deferredInner,
521
+ );
522
+ bundle?.finalize(diagnostics);
523
+ return diagnostics;
524
+ }
525
+
526
+ // File commits are never deferred: the batch owns only LSP post-processing,
527
+ // so a later flush cannot replay an obsolete whole-file snapshot.
528
+ try {
529
+ await writethroughNoop(dst, content, signal, file);
530
+ } catch (error) {
531
+ if (batch.flush) {
532
+ const pending = writethroughBatches.get(batch.id);
533
+ if (pending) {
534
+ writethroughBatches.delete(batch.id);
535
+ try {
536
+ await flushWritethroughBatch(
537
+ Array.from(pending.entries.values()),
538
+ cwd,
539
+ pending.options,
540
+ signal,
541
+ getDeferred,
542
+ );
543
+ } catch (flushError) {
544
+ logger.warn("Failed to flush pending LSP batch after final write failure", {
545
+ batchId: batch.id,
546
+ error: flushError instanceof Error ? flushError.message : String(flushError),
547
+ });
548
+ }
549
+ }
550
+ }
551
+ throw error;
552
+ }
553
+
554
+ const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
555
+ state.entries.set(dst, { dst, file, changeType });
556
+ if (!batch.flush) return undefined;
557
+
558
+ writethroughBatches.delete(batch.id);
559
+ return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
560
+ };
561
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  /** Header buckets for one MCP HTTP request. */
17
- export interface MCPHeaderSources {
17
+ interface MCPHeaderSources {
18
18
  /** Client-generated HTTP/MCP/authorization headers; win case-insensitively. */
19
19
  generated: Record<string, string>;
20
20
  /** Configured headers from the server entry (package or user config). */
@@ -10,7 +10,7 @@ export interface AggregateMetrics extends AgentMetrics {
10
10
  activeDurationAgents: number;
11
11
  }
12
12
 
13
- export interface AgentTreeProjection {
13
+ interface AgentTreeProjection {
14
14
  rows: AgentRef[];
15
15
  depthById: Map<string, number>;
16
16
  parentById: Map<string, string>;
@@ -65,7 +65,7 @@ export function progressMetrics(observed: ObservableSession | undefined): AgentM
65
65
  * usage embedded in completed `task` tool results, so using it for a parent
66
66
  * row would double-count child rows in the aggregate.
67
67
  */
68
- export function readSessionMetrics(session: NonNullable<AgentRef["session"]>): AgentMetrics | undefined {
68
+ function readSessionMetrics(session: NonNullable<AgentRef["session"]>): AgentMetrics | undefined {
69
69
  try {
70
70
  const stats = session.getSessionStats();
71
71
  const messages = session.agent?.state?.messages;
@@ -16,7 +16,7 @@ export interface RosterRender {
16
16
  }
17
17
 
18
18
  /** Legacy progress snapshots may omit counters; snapshot absence remains distinct. */
19
- export function metricNumber(value: number | undefined): number {
19
+ function metricNumber(value: number | undefined): number {
20
20
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
21
21
  }
22
22
 
@@ -67,7 +67,7 @@ export function statusText(status: AgentRef["status"], text: string): string {
67
67
  }
68
68
 
69
69
  /** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
70
- export function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
70
+ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
71
71
  const model = theme.fg("muted", sanitizeDisplayText(modelId));
72
72
  if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
73
73
  const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
@@ -81,11 +81,7 @@ export function formatRoleBadge(role: string, settings: Settings): string {
81
81
  }
82
82
 
83
83
  /** Format a resolved selector, preserving provider identity when requested. */
84
- export function formatResolvedModelBadge(
85
- resolved: string,
86
- preserveProvider = false,
87
- fallbackLevel?: ThinkingLevel,
88
- ): string {
84
+ function formatResolvedModelBadge(resolved: string, preserveProvider = false, fallbackLevel?: ThinkingLevel): string {
89
85
  const cleanResolved = sanitizeDisplayText(resolved);
90
86
  // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
91
87
  // suffix as a thinking level when it parses as one.
@@ -11,7 +11,7 @@ import {
11
11
  type UsageReport,
12
12
  } from "@oh-my-pi/pi-ai";
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
14
- import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
14
+ import { formatDuration, logger, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
15
15
  import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
16
16
  import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
17
17
  import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
@@ -1442,6 +1442,9 @@ export class CommandController {
1442
1442
  if (message === "Handoff cancelled") {
1443
1443
  this.ctx.showError("Handoff cancelled");
1444
1444
  } else {
1445
+ // Persist the real failure so it is debuggable after the transient
1446
+ // TUI error clears (#7993).
1447
+ logger.error("Handoff failed", { error: message });
1445
1448
  this.ctx.showError(`Handoff failed: ${message}`);
1446
1449
  }
1447
1450
  } finally {