@oh-my-pi/pi-coding-agent 15.12.2 → 15.12.4

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 (231) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli.js +1121 -871
  3. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  4. package/dist/types/autoresearch/tools/log-experiment.d.ts +1 -1
  5. package/dist/types/autoresearch/tools/run-experiment.d.ts +1 -1
  6. package/dist/types/autoresearch/tools/update-notes.d.ts +1 -1
  7. package/dist/types/cli/args.d.ts +0 -1
  8. package/dist/types/cli/models-cli.d.ts +49 -0
  9. package/dist/types/commands/launch.d.ts +0 -3
  10. package/dist/types/commands/models.d.ts +33 -0
  11. package/dist/types/commands/token.d.ts +25 -0
  12. package/dist/types/commit/agentic/tools/analyze-file.d.ts +1 -1
  13. package/dist/types/commit/agentic/tools/git-file-diff.d.ts +1 -1
  14. package/dist/types/commit/agentic/tools/git-hunk.d.ts +1 -1
  15. package/dist/types/commit/agentic/tools/git-overview.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +1 -1
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +1 -1
  18. package/dist/types/commit/agentic/tools/recent-commits.d.ts +1 -1
  19. package/dist/types/commit/agentic/tools/schemas.d.ts +1 -1
  20. package/dist/types/commit/agentic/tools/split-commit.d.ts +1 -1
  21. package/dist/types/commit/changelog/generate.d.ts +1 -1
  22. package/dist/types/commit/shared-llm.d.ts +1 -1
  23. package/dist/types/config/model-registry.d.ts +7 -0
  24. package/dist/types/config/models-config-schema.d.ts +1 -1
  25. package/dist/types/config/settings-schema.d.ts +21 -1
  26. package/dist/types/edit/hashline/params.d.ts +1 -1
  27. package/dist/types/edit/modes/apply-patch.d.ts +1 -1
  28. package/dist/types/edit/modes/patch.d.ts +1 -1
  29. package/dist/types/edit/modes/replace.d.ts +1 -1
  30. package/dist/types/extensibility/custom-commands/types.d.ts +2 -2
  31. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  32. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  33. package/dist/types/extensibility/hooks/types.d.ts +2 -2
  34. package/dist/types/goals/tools/goal-tool.d.ts +1 -1
  35. package/dist/types/lsp/types.d.ts +1 -1
  36. package/dist/types/mcp/manager.d.ts +8 -0
  37. package/dist/types/mnemopi/config.d.ts +28 -0
  38. package/dist/types/modes/acp/acp-agent.d.ts +1 -2
  39. package/dist/types/modes/components/index.d.ts +1 -0
  40. package/dist/types/modes/components/logout-account-selector.d.ts +8 -0
  41. package/dist/types/modes/components/status-line/component.d.ts +9 -5
  42. package/dist/types/modes/components/status-line/types.d.ts +2 -1
  43. package/dist/types/modes/controllers/event-controller.d.ts +0 -17
  44. package/dist/types/modes/interactive-mode.d.ts +0 -3
  45. package/dist/types/modes/types.d.ts +0 -5
  46. package/dist/types/session/agent-session.d.ts +14 -33
  47. package/dist/types/session/agent-storage.d.ts +2 -1
  48. package/dist/types/session/indexed-session-storage.d.ts +1 -0
  49. package/dist/types/session/messages.d.ts +8 -10
  50. package/dist/types/session/session-manager.d.ts +15 -0
  51. package/dist/types/session/session-storage.d.ts +5 -0
  52. package/dist/types/slash-commands/helpers/logout.d.ts +15 -0
  53. package/dist/types/task/types.d.ts +1 -1
  54. package/dist/types/tools/ask.d.ts +1 -1
  55. package/dist/types/tools/ast-edit.d.ts +1 -1
  56. package/dist/types/tools/ast-grep.d.ts +1 -1
  57. package/dist/types/tools/bash.d.ts +1 -1
  58. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +202 -0
  59. package/dist/types/tools/browser/cmux/rpc.d.ts +70 -0
  60. package/dist/types/tools/browser/cmux/socket-client.d.ts +19 -0
  61. package/dist/types/tools/browser/registry.d.ts +16 -3
  62. package/dist/types/tools/browser/render.d.ts +2 -0
  63. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  64. package/dist/types/tools/browser/tab-supervisor.d.ts +16 -4
  65. package/dist/types/tools/browser.d.ts +3 -1
  66. package/dist/types/tools/checkpoint.d.ts +1 -1
  67. package/dist/types/tools/debug.d.ts +1 -1
  68. package/dist/types/tools/eval.d.ts +1 -1
  69. package/dist/types/tools/find.d.ts +1 -1
  70. package/dist/types/tools/gh.d.ts +1 -1
  71. package/dist/types/tools/image-gen.d.ts +1 -1
  72. package/dist/types/tools/index.d.ts +3 -1
  73. package/dist/types/tools/inspect-image.d.ts +1 -1
  74. package/dist/types/tools/irc.d.ts +1 -1
  75. package/dist/types/tools/job.d.ts +1 -1
  76. package/dist/types/tools/memory-edit.d.ts +1 -1
  77. package/dist/types/tools/memory-recall.d.ts +1 -1
  78. package/dist/types/tools/memory-reflect.d.ts +1 -1
  79. package/dist/types/tools/memory-retain.d.ts +1 -1
  80. package/dist/types/tools/read.d.ts +1 -1
  81. package/dist/types/tools/render-mermaid.d.ts +1 -1
  82. package/dist/types/tools/resolve.d.ts +1 -1
  83. package/dist/types/tools/review.d.ts +1 -1
  84. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  85. package/dist/types/tools/search.d.ts +1 -1
  86. package/dist/types/tools/ssh.d.ts +1 -1
  87. package/dist/types/tools/todo.d.ts +1 -1
  88. package/dist/types/tools/tts.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +1 -1
  90. package/dist/types/utils/clipboard.d.ts +4 -3
  91. package/dist/types/utils/image-loading.d.ts +18 -1
  92. package/dist/types/utils/thinking-display.d.ts +17 -0
  93. package/dist/types/web/search/index.d.ts +1 -1
  94. package/package.json +14 -14
  95. package/src/autoresearch/storage.ts +2 -1
  96. package/src/autoresearch/tools/init-experiment.ts +1 -1
  97. package/src/autoresearch/tools/log-experiment.ts +1 -1
  98. package/src/autoresearch/tools/run-experiment.ts +1 -1
  99. package/src/autoresearch/tools/update-notes.ts +1 -1
  100. package/src/cli/args.ts +0 -8
  101. package/src/cli/auth-gateway-cli.ts +1 -1
  102. package/src/cli/bench-cli.ts +1 -1
  103. package/src/cli/dry-balance-cli.ts +1 -1
  104. package/src/cli/models-cli.ts +427 -0
  105. package/src/cli-commands.ts +2 -0
  106. package/src/collab/host.ts +9 -12
  107. package/src/commands/launch.ts +0 -3
  108. package/src/commands/models.ts +61 -0
  109. package/src/commands/token.ts +89 -0
  110. package/src/commit/agentic/tools/analyze-file.ts +1 -1
  111. package/src/commit/agentic/tools/git-file-diff.ts +1 -1
  112. package/src/commit/agentic/tools/git-hunk.ts +1 -1
  113. package/src/commit/agentic/tools/git-overview.ts +1 -1
  114. package/src/commit/agentic/tools/propose-changelog.ts +1 -1
  115. package/src/commit/agentic/tools/propose-commit.ts +1 -1
  116. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  117. package/src/commit/agentic/tools/schemas.ts +1 -1
  118. package/src/commit/agentic/tools/split-commit.ts +1 -1
  119. package/src/commit/analysis/summary.ts +1 -1
  120. package/src/commit/changelog/generate.ts +1 -1
  121. package/src/commit/shared-llm.ts +1 -1
  122. package/src/config/model-registry.ts +15 -12
  123. package/src/config/model-resolver.ts +2 -2
  124. package/src/config/models-config-schema.ts +1 -1
  125. package/src/config/settings-schema.ts +19 -1
  126. package/src/edit/hashline/params.ts +1 -1
  127. package/src/edit/modes/apply-patch.ts +1 -1
  128. package/src/edit/modes/patch.ts +1 -1
  129. package/src/edit/modes/replace.ts +1 -1
  130. package/src/eval/agent-bridge.ts +1 -1
  131. package/src/eval/completion-bridge.ts +1 -1
  132. package/src/export/html/template.js +24 -2
  133. package/src/export/html/tool-views.generated.js +2 -2
  134. package/src/extensibility/custom-commands/loader.ts +1 -1
  135. package/src/extensibility/custom-commands/types.ts +2 -2
  136. package/src/extensibility/custom-tools/loader.ts +1 -1
  137. package/src/extensibility/custom-tools/types.ts +2 -2
  138. package/src/extensibility/extensions/loader.ts +2 -2
  139. package/src/extensibility/extensions/types.ts +2 -2
  140. package/src/extensibility/hooks/loader.ts +1 -1
  141. package/src/extensibility/hooks/types.ts +2 -2
  142. package/src/extensibility/skills.ts +18 -3
  143. package/src/goals/tools/goal-tool.ts +1 -1
  144. package/src/internal-urls/docs-index.generated.ts +6 -3
  145. package/src/lsp/types.ts +1 -1
  146. package/src/main.ts +0 -25
  147. package/src/mcp/config-writer.ts +7 -3
  148. package/src/mcp/manager.ts +11 -0
  149. package/src/memories/index.ts +3 -1
  150. package/src/memories/storage.ts +2 -1
  151. package/src/mnemopi/config.ts +95 -11
  152. package/src/modes/acp/acp-agent.ts +5 -48
  153. package/src/modes/acp/acp-event-mapper.ts +5 -1
  154. package/src/modes/components/agent-hub.ts +2 -1
  155. package/src/modes/components/assistant-message.ts +8 -7
  156. package/src/modes/components/index.ts +1 -0
  157. package/src/modes/components/logout-account-selector.ts +130 -0
  158. package/src/modes/components/mcp-add-wizard.ts +1 -1
  159. package/src/modes/components/model-selector.ts +2 -2
  160. package/src/modes/components/status-line/component.ts +54 -157
  161. package/src/modes/components/status-line/segments.ts +1 -1
  162. package/src/modes/components/status-line/types.ts +2 -1
  163. package/src/modes/controllers/command-controller.ts +0 -12
  164. package/src/modes/controllers/event-controller.ts +23 -62
  165. package/src/modes/controllers/input-controller.ts +60 -31
  166. package/src/modes/controllers/mcp-command-controller.ts +44 -3
  167. package/src/modes/controllers/selector-controller.ts +56 -10
  168. package/src/modes/controllers/streaming-reveal.ts +4 -3
  169. package/src/modes/interactive-mode.ts +2 -8
  170. package/src/modes/theme/theme.ts +1 -1
  171. package/src/modes/types.ts +0 -5
  172. package/src/modes/utils/ui-helpers.ts +2 -1
  173. package/src/prompts/system/empty-stop-retry.md +4 -6
  174. package/src/sdk.ts +15 -19
  175. package/src/session/agent-session.ts +125 -234
  176. package/src/session/agent-storage.ts +18 -9
  177. package/src/session/history-storage.ts +2 -1
  178. package/src/session/indexed-session-storage.ts +7 -0
  179. package/src/session/messages.ts +9 -11
  180. package/src/session/session-dump-format.ts +4 -2
  181. package/src/session/session-manager.ts +116 -0
  182. package/src/session/session-storage.ts +20 -0
  183. package/src/slash-commands/builtin-registry.ts +15 -1
  184. package/src/slash-commands/helpers/logout.ts +88 -0
  185. package/src/task/types.ts +1 -1
  186. package/src/tools/ask.ts +1 -1
  187. package/src/tools/ast-edit.ts +13 -4
  188. package/src/tools/ast-grep.ts +1 -1
  189. package/src/tools/bash.ts +1 -1
  190. package/src/tools/browser/cmux/cmux-tab.ts +1264 -0
  191. package/src/tools/browser/cmux/rpc.ts +156 -0
  192. package/src/tools/browser/cmux/socket-client.ts +309 -0
  193. package/src/tools/browser/registry.ts +37 -3
  194. package/src/tools/browser/render.ts +6 -1
  195. package/src/tools/browser/tab-protocol.ts +2 -0
  196. package/src/tools/browser/tab-supervisor.ts +189 -18
  197. package/src/tools/browser/tab-worker.ts +1 -1
  198. package/src/tools/browser.ts +16 -1
  199. package/src/tools/checkpoint.ts +1 -1
  200. package/src/tools/debug.ts +1 -1
  201. package/src/tools/eval.ts +11 -6
  202. package/src/tools/fetch.ts +13 -2
  203. package/src/tools/find.ts +1 -1
  204. package/src/tools/gh.ts +1 -1
  205. package/src/tools/github-cache.ts +2 -1
  206. package/src/tools/image-gen.ts +1 -1
  207. package/src/tools/index.ts +3 -1
  208. package/src/tools/inspect-image.ts +3 -1
  209. package/src/tools/irc.ts +1 -1
  210. package/src/tools/job.ts +1 -1
  211. package/src/tools/memory-edit.ts +1 -1
  212. package/src/tools/memory-recall.ts +1 -1
  213. package/src/tools/memory-reflect.ts +1 -1
  214. package/src/tools/memory-retain.ts +1 -1
  215. package/src/tools/read.ts +8 -2
  216. package/src/tools/render-mermaid.ts +1 -1
  217. package/src/tools/report-tool-issue.ts +3 -2
  218. package/src/tools/resolve.ts +1 -1
  219. package/src/tools/review.ts +1 -1
  220. package/src/tools/search-tool-bm25.ts +1 -1
  221. package/src/tools/search.ts +1 -1
  222. package/src/tools/ssh.ts +1 -1
  223. package/src/tools/todo.ts +1 -1
  224. package/src/tools/tts.ts +1 -1
  225. package/src/tools/write.ts +1 -1
  226. package/src/utils/clipboard.ts +35 -18
  227. package/src/utils/image-loading.ts +35 -4
  228. package/src/utils/thinking-display.ts +37 -0
  229. package/src/web/search/index.ts +1 -1
  230. package/dist/types/cli/list-models.d.ts +0 -30
  231. package/src/cli/list-models.ts +0 -194
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import {
5
5
  type AuthCredential,
6
6
  type AuthCredentialStore,
7
+ isSqliteBusyError,
7
8
  SqliteAuthCredentialStore,
8
9
  type StoredAuthCredential,
9
10
  } from "@oh-my-pi/pi-ai";
@@ -78,10 +79,14 @@ export class AgentStorage {
78
79
  * AuthCredentialStore handles auth_credentials and cache tables.
79
80
  */
80
81
  #initializeSchema(): void {
82
+ // Install the busy handler BEFORE any lock-taking statement (incl.
83
+ // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
84
+ // recovery). Without this, concurrent omp startups can crash here with
85
+ // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. See issue #2421.
86
+ this.#db.run("PRAGMA busy_timeout = 5000");
81
87
  this.#db.run(`
82
88
  PRAGMA journal_mode=WAL;
83
89
  PRAGMA synchronous=NORMAL;
84
- PRAGMA busy_timeout=5000;
85
90
 
86
91
  CREATE TABLE IF NOT EXISTS model_usage (
87
92
  model_key TEXT PRIMARY KEY,
@@ -208,7 +213,8 @@ FROM model_usage_legacy
208
213
 
209
214
  /**
210
215
  * Returns singleton instance for the given database path, creating if needed.
211
- * Retries on SQLITE_BUSY with exponential backoff.
216
+ * Retries on the `SQLITE_BUSY` family (including `SQLITE_BUSY_RECOVERY`) with
217
+ * exponential backoff. See issue #2421.
212
218
  * @param dbPath - Path to the SQLite database file (defaults to config path)
213
219
  * @returns AgentStorage instance for the given path
214
220
  */
@@ -216,7 +222,7 @@ FROM model_usage_legacy
216
222
  const existing = instances.get(dbPath);
217
223
  if (existing) return existing;
218
224
 
219
- const maxRetries = 3;
225
+ const maxRetries = 4;
220
226
  const baseDelayMs = 100;
221
227
  let lastError: Error | undefined;
222
228
 
@@ -226,17 +232,20 @@ FROM model_usage_legacy
226
232
  instances.set(dbPath, storage);
227
233
  return storage;
228
234
  } catch (err) {
229
- const isSqliteBusy = err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_BUSY";
230
- if (!isSqliteBusy) {
235
+ if (!isSqliteBusyError(err)) {
231
236
  throw err;
232
237
  }
233
- lastError = err as Error;
234
- const delayMs = baseDelayMs * 2 ** attempt;
235
- await Bun.sleep(delayMs);
238
+ lastError = err instanceof Error ? err : new Error(String(err));
239
+ if (attempt < maxRetries - 1) {
240
+ await Bun.sleep(baseDelayMs * 2 ** attempt);
241
+ }
236
242
  }
237
243
  }
238
244
 
239
- throw lastError ?? new Error("Failed to open database after retries");
245
+ throw new Error(
246
+ `Failed to open agent database at '${dbPath}' after ${maxRetries} attempts: ${lastError?.message}`,
247
+ { cause: lastError },
248
+ );
240
249
  }
241
250
 
242
251
  /**
@@ -86,10 +86,11 @@ export class HistoryStorage {
86
86
 
87
87
  const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get();
88
88
 
89
+ // Install the busy handler BEFORE any lock-taking statement. See #2421.
90
+ this.#db.run("PRAGMA busy_timeout = 5000");
89
91
  this.#db.run(`
90
92
  PRAGMA journal_mode=WAL;
91
93
  PRAGMA synchronous=NORMAL;
92
- PRAGMA busy_timeout=5000;
93
94
 
94
95
  CREATE TABLE IF NOT EXISTS history (
95
96
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -414,6 +414,13 @@ class IndexedSessionStorageWriter implements SessionStorageWriter {
414
414
  await this.flush();
415
415
  }
416
416
 
417
+ fsyncSync(): void {
418
+ // Indexed storage has no real fd to fsync; drain the pending chain
419
+ // synchronously is not possible, so this is a no-op. The async flush()
420
+ // above already ensures durability for the indexed backend.
421
+ if (this.#error) throw this.#error;
422
+ }
423
+
417
424
  async close(): Promise<void> {
418
425
  if (this.#closed) return;
419
426
  this.#closed = true;
@@ -40,13 +40,11 @@ export interface SkillPromptDetails {
40
40
  path: string;
41
41
  args?: string;
42
42
  lineCount: number;
43
- /** Internal: tag used by AgentSession to remove the pending-display chip
44
- * from `#steeringMessages` / `#followUpMessages` when the agent consumes
45
- * this message. Not surfaced to renderers; the `__` prefix signals
46
- * "private". Optional — non-streaming skill prompts never set it. Stripped
47
- * from persisted `details` by `SessionManager.appendCustomMessageEntry`
48
- * via the `INTERNAL_DETAILS_FIELDS` allowlist below. */
49
- __pendingDisplayTag?: string;
43
+ /** Internal: compact label shown for a queued custom message. Optional —
44
+ * non-streaming skill prompts never set it. Stripped from persisted
45
+ * `details` by `SessionManager.appendCustomMessageEntry` via the
46
+ * `INTERNAL_DETAILS_FIELDS` allowlist below. */
47
+ __queueChipText?: string;
50
48
  }
51
49
 
52
50
  /** Sentinel value for `AssistantMessage.errorMessage` indicating that the abort
@@ -104,12 +102,12 @@ export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt
104
102
  return "Operation aborted";
105
103
  }
106
104
 
107
- /** Extract the optional `__pendingDisplayTag` field from a CustomMessage's
105
+ /** Extract the optional `__queueChipText` field from a CustomMessage's
108
106
  * `details` blob. Safe over `unknown`; returns undefined when the field is
109
107
  * absent or non-string. */
110
- export function readPendingDisplayTag(details: unknown): string | undefined {
108
+ export function readQueueChipText(details: unknown): string | undefined {
111
109
  if (typeof details !== "object" || details === null) return undefined;
112
- const candidate = (details as { __pendingDisplayTag?: unknown }).__pendingDisplayTag;
110
+ const candidate = (details as { __queueChipText?: unknown }).__queueChipText;
113
111
  return typeof candidate === "string" ? candidate : undefined;
114
112
  }
115
113
 
@@ -118,7 +116,7 @@ export function readPendingDisplayTag(details: unknown): string | undefined {
118
116
  * the CustomMessageEntry to disk. Scoped intentionally narrow: only fields
119
117
  * declared here are stripped. Adding a new entry is a deliberate, reviewed
120
118
  * change — unrelated future payload fields are never silently dropped. */
121
- export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag"] as const;
119
+ export const INTERNAL_DETAILS_FIELDS = ["__queueChipText"] as const;
122
120
 
123
121
  /** Return a `details` copy with every key in `INTERNAL_DETAILS_FIELDS`
124
122
  * removed. Returns the input unchanged when there is nothing to strip
@@ -5,6 +5,7 @@ import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
6
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
7
7
  import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
8
+ import { getVisibleThinkingText } from "../utils/thinking-display";
8
9
  import {
9
10
  type BashExecutionMessage,
10
11
  type BranchSummaryMessage,
@@ -126,9 +127,10 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
126
127
  if (c.type === "text") {
127
128
  lines.push(c.text);
128
129
  } else if (c.type === "thinking") {
129
- if (c.thinking.trim().length === 0) continue;
130
+ const thinking = getVisibleThinkingText(c);
131
+ if (thinking.length === 0) continue;
130
132
  lines.push("<thinking>");
131
- lines.push(c.thinking);
133
+ lines.push(thinking);
132
134
  lines.push("</thinking>\n");
133
135
  } else if (c.type === "toolCall") {
134
136
  lines.push(`<invoke name="${c.name}">`);
@@ -1527,6 +1527,17 @@ class NdjsonFileWriter {
1527
1527
  }
1528
1528
  }
1529
1529
 
1530
+ /** Synchronously fsync the underlying file descriptor to physical disk. */
1531
+ fsyncSync(): void {
1532
+ if (this.#closed) return;
1533
+ if (this.#error) throw this.#error;
1534
+ try {
1535
+ this.#writer.fsyncSync();
1536
+ } catch (err) {
1537
+ throw this.#recordError(err);
1538
+ }
1539
+ }
1540
+
1530
1541
  /** Close the writer, flushing all data. */
1531
1542
  async close(): Promise<void> {
1532
1543
  if (this.#closed || this.#closing) return;
@@ -2592,6 +2603,111 @@ export class SessionManager {
2592
2603
  if (this.#persistError) throw this.#persistError;
2593
2604
  }
2594
2605
 
2606
+ /**
2607
+ * Synchronously flush all in-memory entries to disk and fsync.
2608
+ * Use when the process may exit before an async flush settles (e.g. Ctrl+C
2609
+ * in the TUI, where raw mode consumes the keystroke so postmortem's SIGINT
2610
+ * handler never fires).
2611
+ *
2612
+ * Hot path: the persist writer is open and flushed, so a single fsyncSync
2613
+ * pushes the page-cache data to physical disk.
2614
+ *
2615
+ * Cold path: entries are only in memory (session just started, or a rewrite
2616
+ * is pending). Writes all entries to a temp file, fsyncs, and atomically
2617
+ * renames over the session file — then re-opens an append writer so the
2618
+ * hot path resumes on subsequent `_persist` calls.
2619
+ */
2620
+ flushSync(): void {
2621
+ if (!this.persist || !this.#sessionFile) return;
2622
+ if (this.#persistError) throw this.#persistError;
2623
+
2624
+ // Hot path: writer is open and all entries have been written via writeSync.
2625
+ // Just fsync the fd — the data is already in the kernel page cache.
2626
+ if (this.#persistWriter?.isOpen() && this.#flushed && !this.#needsFullRewriteOnNextPersist) {
2627
+ this.#persistWriter.fsyncSync();
2628
+ return;
2629
+ }
2630
+
2631
+ // Cold path: write all in-memory entries to a temp file and atomically
2632
+ // replace the session file. This is safe to run even when an async
2633
+ // rewrite is queued on #persistChain: the async task won't progress
2634
+ // while we're on the sync call stack, and the file we produce is a
2635
+ // superset of whatever the async rewrite would write.
2636
+ const dir = path.resolve(this.#sessionFile, "..");
2637
+ const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
2638
+ const fd = fs.openSync(tempPath, "w");
2639
+ try {
2640
+ for (const entry of this.#fileEntries) {
2641
+ const persisted = prepareEntryForPersistenceSync(entry, this.#blobStore);
2642
+ const line = `${JSON.stringify(persisted)}\n`;
2643
+ fs.writeSync(fd, line);
2644
+ }
2645
+ fs.fsyncSync(fd);
2646
+ } finally {
2647
+ fs.closeSync(fd);
2648
+ }
2649
+
2650
+ // Atomic replace (with EPERM retry for Windows)
2651
+ try {
2652
+ fs.renameSync(tempPath, this.#sessionFile);
2653
+ } catch (err) {
2654
+ if (!hasFsCode(err, "EPERM")) {
2655
+ try {
2656
+ fs.unlinkSync(tempPath);
2657
+ } catch {
2658
+ /* best effort */
2659
+ }
2660
+ throw toError(err);
2661
+ }
2662
+ // Windows: move the old file aside, then rename
2663
+ const backupPath = path.join(dir, `${path.basename(this.#sessionFile)}.${Snowflake.next()}.bak`);
2664
+ try {
2665
+ fs.renameSync(this.#sessionFile, backupPath);
2666
+ } catch (moveAsideErr) {
2667
+ if (isEnoent(moveAsideErr)) {
2668
+ fs.renameSync(tempPath, this.#sessionFile);
2669
+ return;
2670
+ }
2671
+ try {
2672
+ fs.unlinkSync(tempPath);
2673
+ } catch {
2674
+ /* best effort */
2675
+ }
2676
+ throw toError(err);
2677
+ }
2678
+ try {
2679
+ fs.renameSync(tempPath, this.#sessionFile);
2680
+ } catch (replaceErr) {
2681
+ // Roll back
2682
+ try {
2683
+ fs.renameSync(backupPath, this.#sessionFile);
2684
+ } catch {
2685
+ /* best effort */
2686
+ }
2687
+ throw toError(replaceErr);
2688
+ }
2689
+ try {
2690
+ fs.unlinkSync(backupPath);
2691
+ } catch {
2692
+ /* best effort */
2693
+ }
2694
+ }
2695
+
2696
+ // Re-open the persist writer in append mode so the hot path resumes.
2697
+ if (this.#persistWriter) {
2698
+ // The old writer is stale (pointed at the pre-rewrite file or was
2699
+ // mid-close). Close it asynchronously — it's a no-op if already
2700
+ // closed, and we don't want to block on draining its queue.
2701
+ void this.#persistWriter.close().catch(() => {});
2702
+ }
2703
+ this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
2704
+ onError: err => this.#recordPersistError(err),
2705
+ });
2706
+ this.#persistWriterPath = this.#sessionFile;
2707
+ this.#flushed = true;
2708
+ this.#needsFullRewriteOnNextPersist = false;
2709
+ }
2710
+
2595
2711
  /** Close the persistent writer after flushing all pending data. */
2596
2712
  async close(): Promise<void> {
2597
2713
  if (!this.#persistWriter) return;
@@ -23,6 +23,11 @@ export interface SessionStorageWriter {
23
23
  writeLineSync(line: string): void;
24
24
  flush(): Promise<void>;
25
25
  fsync(): Promise<void>;
26
+ /**
27
+ * Synchronously fsync the underlying file descriptor. Returns once the data
28
+ * is on the physical disk. Throws synchronously on I/O error.
29
+ */
30
+ fsyncSync(): void;
26
31
  close(): Promise<void>;
27
32
  getError(): Error | undefined;
28
33
  }
@@ -118,6 +123,16 @@ class FileSessionStorageWriter implements SessionStorageWriter {
118
123
  }
119
124
  }
120
125
 
126
+ fsyncSync(): void {
127
+ if (this.#closed) throw new Error("Writer closed");
128
+ if (this.#error) throw this.#error;
129
+ try {
130
+ fs.fsyncSync(this.#fd);
131
+ } catch (err) {
132
+ throw this.#recordError(err);
133
+ }
134
+ }
135
+
121
136
  async close(): Promise<void> {
122
137
  if (this.#closed) return;
123
138
  this.#closed = true;
@@ -291,6 +306,11 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
291
306
  if (this.#error) throw this.#error;
292
307
  }
293
308
 
309
+ fsyncSync(): void {
310
+ // No-op for in-memory storage
311
+ if (this.#error) throw this.#error;
312
+ }
313
+
294
314
  async close(): Promise<void> {
295
315
  if (this.#closed) return;
296
316
  this.#closed = true;
@@ -1005,7 +1005,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1005
1005
  {
1006
1006
  name: "logout",
1007
1007
  description: "Logout from OAuth provider",
1008
- handleTui: (_command, runtime) => {
1008
+ inlineHint: "[provider]",
1009
+ allowArgs: true,
1010
+ handleTui: (command, runtime) => {
1011
+ const providerId = command.args.trim();
1012
+ if (providerId) {
1013
+ const matchedProvider = getOAuthProviders().find(provider => provider.id === providerId);
1014
+ if (!matchedProvider) {
1015
+ runtime.ctx.showWarning(`Unknown OAuth provider: ${providerId}`);
1016
+ runtime.ctx.editor.setText("");
1017
+ return;
1018
+ }
1019
+ void runtime.ctx.showOAuthSelector("logout", matchedProvider.id);
1020
+ runtime.ctx.editor.setText("");
1021
+ return;
1022
+ }
1009
1023
  void runtime.ctx.showOAuthSelector("logout");
1010
1024
  runtime.ctx.editor.setText("");
1011
1025
  },
@@ -0,0 +1,88 @@
1
+ import type { OAuthAccountIdentity, StoredAuthCredential } from "../../session/auth-storage";
2
+
3
+ export interface LogoutAccount {
4
+ credentialId: number;
5
+ provider: string;
6
+ label: string;
7
+ detail: string;
8
+ type: "api_key" | "oauth";
9
+ active: boolean;
10
+ }
11
+
12
+ interface LogoutAccountOptions {
13
+ activeIdentity?: OAuthAccountIdentity;
14
+ activeApiKey?: boolean;
15
+ }
16
+
17
+ function nonEmpty(value: string | undefined): string | undefined {
18
+ const trimmed = value?.trim();
19
+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
20
+ }
21
+
22
+ function oauthLabel(row: StoredAuthCredential): string {
23
+ const credential = row.credential;
24
+ if (credential.type !== "oauth") return `API key #${row.id}`;
25
+ return (
26
+ nonEmpty(credential.email) ??
27
+ nonEmpty(credential.accountId) ??
28
+ nonEmpty(credential.projectId) ??
29
+ nonEmpty(credential.enterpriseUrl) ??
30
+ `OAuth credential #${row.id}`
31
+ );
32
+ }
33
+
34
+ function oauthDetail(row: StoredAuthCredential, label: string): string {
35
+ const credential = row.credential;
36
+ if (credential.type === "api_key") return `stored API key #${row.id}`;
37
+ const parts: string[] = [];
38
+ const email = nonEmpty(credential.email);
39
+ const accountId = nonEmpty(credential.accountId);
40
+ const projectId = nonEmpty(credential.projectId);
41
+ const enterpriseUrl = nonEmpty(credential.enterpriseUrl);
42
+ if (email && email !== label) parts.push(email);
43
+ if (accountId && accountId !== label) parts.push(`account ${accountId}`);
44
+ if (projectId && projectId !== label) parts.push(`project ${projectId}`);
45
+ if (enterpriseUrl && enterpriseUrl !== label) parts.push(enterpriseUrl);
46
+ parts.push(`oauth #${row.id}`);
47
+ return parts.join(" · ");
48
+ }
49
+
50
+ function oauthMatchesActiveIdentity(
51
+ row: StoredAuthCredential,
52
+ activeIdentity: OAuthAccountIdentity | undefined,
53
+ ): boolean {
54
+ if (!activeIdentity || row.credential.type !== "oauth") return false;
55
+ const credential = row.credential;
56
+ return (
57
+ (activeIdentity.accountId !== undefined && credential.accountId === activeIdentity.accountId) ||
58
+ (activeIdentity.email !== undefined && credential.email === activeIdentity.email) ||
59
+ (activeIdentity.projectId !== undefined && credential.projectId === activeIdentity.projectId)
60
+ );
61
+ }
62
+
63
+ export function toLogoutAccounts(
64
+ provider: string,
65
+ credentials: StoredAuthCredential[],
66
+ options: LogoutAccountOptions = {},
67
+ ): LogoutAccount[] {
68
+ return credentials
69
+ .map(row => {
70
+ const label = oauthLabel(row);
71
+ const active =
72
+ row.credential.type === "oauth"
73
+ ? oauthMatchesActiveIdentity(row, options.activeIdentity)
74
+ : options.activeApiKey === true;
75
+ return {
76
+ credentialId: row.id,
77
+ provider,
78
+ label,
79
+ detail: oauthDetail(row, label),
80
+ type: row.credential.type,
81
+ active,
82
+ } satisfies LogoutAccount;
83
+ })
84
+ .sort((left, right) => {
85
+ if (left.active !== right.active) return left.active ? -1 : 1;
86
+ return left.label.localeCompare(right.label) || left.credentialId - right.credentialId;
87
+ });
88
+ }
package/src/task/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
- import * as z from "zod/v4";
4
+ import { z } from "zod/v4";
5
5
  import type { AgentSessionEvent } from "../session/agent-session";
6
6
  import type { NestedRepoPatch } from "./worktree";
7
7
 
package/src/tools/ask.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import { type Component, Markdown, type MarkdownTheme, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
20
20
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
21
- import * as z from "zod/v4";
21
+ import { z } from "zod/v4";
22
22
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
23
23
  import type { ExtensionUISelectItem } from "../extensibility/extensions";
24
24
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
@@ -5,7 +5,7 @@ import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-m
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { replaceTabs, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
- import * as z from "zod/v4";
8
+ import { z } from "zod/v4";
9
9
  import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
10
10
  import { normalizeToLF } from "../edit/normalize";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -512,6 +512,14 @@ function buildChangeBody(groups: string[][], expanded: boolean, budget: number,
512
512
  return lines;
513
513
  }
514
514
 
515
+ /** One-line header preview of an AST pattern. `renderStatusLine` only flattens
516
+ * CR/LF, so a multi-line tab-indented pattern would otherwise punch raw tabs
517
+ * into the status line; collapse all whitespace runs to single spaces. */
518
+ function patternPreview(pat: string | undefined): string | undefined {
519
+ const collapsed = pat?.replace(/\s+/g, " ").trim();
520
+ return collapsed || undefined;
521
+ }
522
+
515
523
  export const astEditToolRenderer = {
516
524
  inline: true,
517
525
  renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -520,7 +528,8 @@ export const astEditToolRenderer = {
520
528
  const rewriteCount = args.ops?.length ?? 0;
521
529
  if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
522
530
 
523
- const description = rewriteCount === 1 ? args.ops?.[0]?.pat : rewriteCount ? `${rewriteCount} rewrites` : "?";
531
+ const description =
532
+ rewriteCount === 1 ? patternPreview(args.ops?.[0]?.pat) : rewriteCount ? `${rewriteCount} rewrites` : "?";
524
533
  const header = renderStatusLine({ icon: "pending", title: "AST Edit", description, meta }, uiTheme);
525
534
  // Pending call has no body yet — a lone status line is sleeker than an empty frame.
526
535
  return new Text(header, 0, 0);
@@ -553,7 +562,7 @@ export const astEditToolRenderer = {
553
562
 
554
563
  if (totalReplacements === 0) {
555
564
  const rewriteCount = args?.ops?.length ?? 0;
556
- const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
565
+ const description = rewriteCount === 1 ? patternPreview(args?.ops?.[0]?.pat) : undefined;
557
566
  const meta = ["0 replacements"];
558
567
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
559
568
  if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
@@ -578,7 +587,7 @@ export const astEditToolRenderer = {
578
587
  meta.push(`searched ${filesSearched}`);
579
588
  if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
580
589
  const rewriteCount = args?.ops?.length ?? 0;
581
- const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
590
+ const description = rewriteCount === 1 ? patternPreview(args?.ops?.[0]?.pat) : undefined;
582
591
 
583
592
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
584
593
  const allLines = textContent.split("\n");
@@ -5,7 +5,7 @@ import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
- import * as z from "zod/v4";
8
+ import { z } from "zod/v4";
9
9
  import { recordFileSnapshot } from "../edit/file-snapshot-store";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
package/src/tools/bash.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
11
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
12
- import * as z from "zod/v4";
12
+ import { z } from "zod/v4";
13
13
  import { type BashResult, executeBash } from "../exec/bash-executor";
14
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
15
  import { InternalUrlRouter } from "../internal-urls";