@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

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 (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
@@ -8,7 +8,7 @@ import {
8
8
  SqliteAuthCredentialStore,
9
9
  type StoredAuthCredential,
10
10
  } from "@oh-my-pi/pi-ai";
11
- import { getAgentDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
11
+ import { AsyncDrain, getAgentDbPath, getStatsDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
12
12
  import type { RawSettings as Settings } from "../config/settings";
13
13
 
14
14
  /** Row shape for settings table queries */
@@ -23,8 +23,100 @@ type ModelUsageRow = {
23
23
  last_used_at: number;
24
24
  };
25
25
 
26
- /** Bump when schema changes require migration */
27
- const SCHEMA_VERSION = 5;
26
+ /** Row shape for model_perf table queries */
27
+ type ModelPerfRow = {
28
+ model_key: string;
29
+ samples: number;
30
+ output_tokens: number;
31
+ gen_ms: number;
32
+ ttft_samples: number;
33
+ ttft_ms: number;
34
+ };
35
+
36
+ /** Row shape read from an `omp stats` messages table during backfill. */
37
+ type StatsMessageRow = {
38
+ rowid: number;
39
+ timestamp: number;
40
+ provider: string;
41
+ model: string;
42
+ output_tokens: number;
43
+ duration: number;
44
+ ttft: number | null;
45
+ };
46
+
47
+ /** Per-model running sums accumulated during a backfill walk. */
48
+ type PerfAccum = {
49
+ samples: number;
50
+ outputTokens: number;
51
+ genMs: number;
52
+ ttftSamples: number;
53
+ ttftMs: number;
54
+ };
55
+
56
+ /** One completed request's timing, folded into the per-model aggregates. */
57
+ export interface ModelPerfSample {
58
+ /** Output tokens the provider reported for the turn. */
59
+ outputTokens: number;
60
+ /** Total request duration in milliseconds. */
61
+ durationMs: number;
62
+ /** Time to first token in milliseconds; omit when the provider did not report one. */
63
+ ttftMs?: number;
64
+ }
65
+
66
+ /** Validated, insert-ready model_perf sample (see {@link normalizeModelPerfSample}). */
67
+ type ModelPerfInsert = {
68
+ modelKey: string;
69
+ outputTokens: number;
70
+ durationMs: number;
71
+ ttftSamples: 0 | 1;
72
+ ttftMs: number;
73
+ };
74
+
75
+ /** Recency-weighted per-model performance averages. */
76
+ export interface ModelPerfStats {
77
+ /** Decayed sample count backing the averages. */
78
+ samples: number;
79
+ /** Average output tokens/sec over the total request duration. */
80
+ tps: number;
81
+ /** Average time-to-first-token in milliseconds; null when no sample reported one. */
82
+ ttftMs: number | null;
83
+ }
84
+
85
+ /**
86
+ * Decay threshold for model_perf running sums: once a model accumulates this
87
+ * many samples, each new sample first halves every aggregate, turning the
88
+ * plain average into a recency-weighted one (provider speeds drift over time).
89
+ */
90
+ const MODEL_PERF_DECAY_AT = 256;
91
+ /** meta-table marker set once historical stats.db rows have been imported into model_perf. */
92
+ const MODEL_PERF_BACKFILL_KEY = "model_perf_backfill";
93
+ /** Batch window for deferred model_perf writes; matches prompt-history's drain cadence. */
94
+ const MODEL_PERF_FLUSH_DELAY_MS = 100;
95
+ /** Backfill ignores stats.db history older than this; decay makes stale provider speeds worthless anyway. */
96
+ const MODEL_PERF_BACKFILL_MAX_AGE_MS = 90 * 86_400_000;
97
+ /** Rows fetched per synchronous backfill chunk — keeps per-chunk event-loop blocking under ~20ms even on cold I/O. */
98
+ const MODEL_PERF_BACKFILL_CHUNK = 2048;
99
+ /** Hard ceiling on rows scanned per backfill run, whatever the age cutoff admits — bounds total CPU on very high-volume databases (models only seen earlier than the newest N measurable rows get no backfill). */
100
+ const MODEL_PERF_BACKFILL_MAX_ROWS = 250_000;
101
+
102
+ /**
103
+ * Validates one request timing and shapes it for the model_perf upsert.
104
+ * Returns null for unmeasurable samples (no tokens, no duration). Out-of-range
105
+ * TTFT (>= duration) is bogus latency data; the sample still measures throughput.
106
+ */
107
+ function normalizeModelPerfSample(modelKey: string, sample: ModelPerfSample): ModelPerfInsert | null {
108
+ const { outputTokens, durationMs } = sample;
109
+ if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null;
110
+ if (!Number.isFinite(durationMs) || durationMs <= 0) return null;
111
+ const ttftMs =
112
+ sample.ttftMs !== undefined && Number.isFinite(sample.ttftMs) && sample.ttftMs > 0 && sample.ttftMs < durationMs
113
+ ? sample.ttftMs
114
+ : undefined;
115
+ return { modelKey, outputTokens, durationMs, ttftSamples: ttftMs !== undefined ? 1 : 0, ttftMs: ttftMs ?? 0 };
116
+ }
117
+
118
+ /** Current agent.db schema version; bump when schema changes require migration. */
119
+ export const SCHEMA_VERSION = 6;
28
120
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
29
121
 
30
122
  /** Singleton instances per database path */
@@ -42,9 +134,18 @@ export class AgentStorage {
42
134
  #listSettingsStmt: Statement;
43
135
  #upsertModelUsageStmt: Statement;
44
136
  #listModelUsageStmt: Statement;
137
+ #upsertModelPerfStmt: Statement;
138
+ #listModelPerfStmt: Statement;
45
139
  #modelUsageCache: string[] | null = null;
140
+ /** Only the real user db auto-imports stats.db history; custom paths (tests, embedding) opt in explicitly. */
141
+ #autoPerfBackfill: boolean;
142
+ /** One backfill *check* per process; the persistent gate is the meta marker. */
143
+ #perfBackfillChecked = false;
144
+ /** Coalesces per-turn perf samples into one deferred transaction off the turn's hot path. */
145
+ #perfDrain = new AsyncDrain<ModelPerfInsert>(MODEL_PERF_FLUSH_DELAY_MS);
46
146
 
47
147
  private constructor(dbPath: string) {
148
+ this.#autoPerfBackfill = dbPath === getAgentDbPath();
48
149
  this.#ensureDir(dbPath);
49
150
  try {
50
151
  this.#db = new Database(dbPath);
@@ -72,6 +173,22 @@ export class AgentStorage {
72
173
  this.#listModelUsageStmt = this.#db.prepare(
73
174
  "SELECT model_key, last_used_at FROM model_usage ORDER BY last_used_at DESC",
74
175
  );
176
+ // Recency-weighted upsert: past MODEL_PERF_DECAY_AT samples, every new
177
+ // sample first halves the aggregates so old measurements fade out.
178
+ this.#upsertModelPerfStmt = this.#db.prepare(
179
+ `INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at)
180
+ VALUES (?1, 1, ?2, ?3, ?4, ?5, ${SQLITE_NOW_EPOCH})
181
+ ON CONFLICT(model_key) DO UPDATE SET
182
+ samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.samples / 2 ELSE model_perf.samples END) + 1,
183
+ output_tokens = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.output_tokens * 0.5 ELSE model_perf.output_tokens END) + excluded.output_tokens,
184
+ gen_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.gen_ms * 0.5 ELSE model_perf.gen_ms END) + excluded.gen_ms,
185
+ ttft_samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_samples * 0.5 ELSE model_perf.ttft_samples END) + excluded.ttft_samples,
186
+ ttft_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_ms * 0.5 ELSE model_perf.ttft_ms END) + excluded.ttft_ms,
187
+ updated_at = ${SQLITE_NOW_EPOCH}`,
188
+ );
189
+ this.#listModelPerfStmt = this.#db.prepare(
190
+ "SELECT model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms FROM model_perf",
191
+ );
75
192
  }
76
193
 
77
194
  /**
@@ -93,6 +210,21 @@ CREATE TABLE IF NOT EXISTS model_usage (
93
210
  last_used_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
94
211
  );
95
212
 
213
+ CREATE TABLE IF NOT EXISTS model_perf (
214
+ model_key TEXT PRIMARY KEY,
215
+ samples REAL NOT NULL DEFAULT 0,
216
+ output_tokens REAL NOT NULL DEFAULT 0,
217
+ gen_ms REAL NOT NULL DEFAULT 0,
218
+ ttft_samples REAL NOT NULL DEFAULT 0,
219
+ ttft_ms REAL NOT NULL DEFAULT 0,
220
+ updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
221
+ );
222
+
223
+ CREATE TABLE IF NOT EXISTS meta (
224
+ key TEXT PRIMARY KEY,
225
+ value TEXT NOT NULL
226
+ );
227
+
96
228
  CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
97
229
  `);
98
230
 
@@ -175,6 +307,14 @@ CREATE TABLE settings (
175
307
  if (fromVersion < 5) {
176
308
  this.#migrateSchemaV4ToV5();
177
309
  }
310
+ if (fromVersion < 6) {
311
+ // v5 → v6: TPS switched from the post-TTFT decode window to total
312
+ // request duration (hidden reasoning made decode-window rates bogus).
313
+ // Purge the old aggregates and re-arm the stats.db backfill so
314
+ // history is re-imported through the corrected fold.
315
+ this.#db.run("DELETE FROM model_perf");
316
+ this.#db.prepare("DELETE FROM meta WHERE key = ?").run(MODEL_PERF_BACKFILL_KEY);
317
+ }
178
318
  }
179
319
 
180
320
  #migrateSchemaV4ToV5(): void {
@@ -257,6 +397,8 @@ FROM model_usage_legacy
257
397
  this.#listSettingsStmt.finalize();
258
398
  this.#upsertModelUsageStmt.finalize();
259
399
  this.#listModelUsageStmt.finalize();
400
+ this.#upsertModelPerfStmt.finalize();
401
+ this.#listModelPerfStmt.finalize();
260
402
  // SqliteAuthCredentialStore.close() finalizes its own statements and
261
403
  // closes the shared #db handle — must run after our statements finalize.
262
404
  this.#authStore.close();
@@ -317,6 +459,191 @@ FROM model_usage_legacy
317
459
  }
318
460
  }
319
461
 
462
+ /**
463
+ * Folds one completed request's timing into the model's perf aggregates.
464
+ * TPS is measured over the total request duration — not the post-TTFT
465
+ * decode window, which undercounts generation time (and so inflates the
466
+ * rate) when reasoning tokens are generated before the first visible
467
+ * token. Invalid samples (no tokens, no duration) are dropped.
468
+ *
469
+ * Deferred like prompt history: samples are batched and written in one
470
+ * transaction after {@link MODEL_PERF_FLUSH_DELAY_MS}, keeping SQLite off
471
+ * the turn-completion hot path. Fire-and-forget safe — flush failures are
472
+ * logged, never thrown; await the returned promise only to observe the flush.
473
+ * @param modelKey - Model key in "provider/modelId" format
474
+ */
475
+ recordModelPerf(modelKey: string, sample: ModelPerfSample): Promise<void> {
476
+ const row = normalizeModelPerfSample(modelKey, sample);
477
+ if (!row) return Promise.resolve();
478
+ return this.#perfDrain.push(row, rows => this.#flushModelPerf(rows));
479
+ }
480
+
481
+ #flushModelPerf(rows: ModelPerfInsert[]): void {
482
+ // Kick the one-time history import too, so aggregates populate even if
483
+ // the user never opens /models. Additive merge makes ordering with live
484
+ // samples irrelevant.
485
+ this.#kickModelPerfBackfill();
486
+ try {
487
+ this.#db.transaction((batch: ModelPerfInsert[]) => {
488
+ for (const row of batch) this.#foldModelPerf(row);
489
+ })(rows);
490
+ } catch (error) {
491
+ logger.warn("AgentStorage failed to record model perf", { error: String(error) });
492
+ }
493
+ }
494
+
495
+ #foldModelPerf(row: ModelPerfInsert): void {
496
+ this.#upsertModelPerfStmt.run(row.modelKey, row.outputTokens, row.durationMs, row.ttftSamples, row.ttftMs);
497
+ }
498
+
499
+ /**
500
+ * Returns recency-weighted TPS/TTFT averages for every model with recorded
501
+ * requests, keyed by "provider/modelId". Read by the /models browser.
502
+ * Also kicks the one-time background stats.db import; until it completes,
503
+ * models without live samples are simply absent.
504
+ */
505
+ getModelPerf(): Map<string, ModelPerfStats> {
506
+ this.#kickModelPerfBackfill();
507
+ const stats = new Map<string, ModelPerfStats>();
508
+ try {
509
+ for (const row of this.#listModelPerfStmt.all() as ModelPerfRow[]) {
510
+ if (row.gen_ms <= 0 || row.output_tokens <= 0) continue;
511
+ stats.set(row.model_key, {
512
+ samples: row.samples,
513
+ tps: (row.output_tokens * 1000) / row.gen_ms,
514
+ ttftMs: row.ttft_samples > 0 ? row.ttft_ms / row.ttft_samples : null,
515
+ });
516
+ }
517
+ } catch (error) {
518
+ logger.warn("AgentStorage failed to read model perf", { error: String(error) });
519
+ }
520
+ return stats;
521
+ }
522
+
523
+ /**
524
+ * One-time, non-blocking import of historical request timings from the
525
+ * `omp stats` database (`~/.omp/stats.db`) into model_perf. Fire-and-forget:
526
+ * the walk runs in bounded chunks with event-loop yields between them
527
+ * (bun:sqlite is synchronous — an unbounded scan here froze the TUI for
528
+ * ~30s on multi-million-row stats databases), and the persistent meta
529
+ * marker is only set on success so a crash or error retries next process.
530
+ * A missing stats.db leaves the marker unset so a later `omp stats` run
531
+ * still gets imported. No-op for non-default db paths.
532
+ */
533
+ #kickModelPerfBackfill(): void {
534
+ if (!this.#autoPerfBackfill || this.#perfBackfillChecked) return;
535
+ this.#perfBackfillChecked = true;
536
+ try {
537
+ const marker = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(MODEL_PERF_BACKFILL_KEY);
538
+ if (marker) return;
539
+ const statsDbPath = getStatsDbPath();
540
+ if (!fs.existsSync(statsDbPath)) return;
541
+ void this.backfillModelPerfFromStats(statsDbPath)
542
+ .then(imported => {
543
+ this.#db
544
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
545
+ .run(MODEL_PERF_BACKFILL_KEY, "complete");
546
+ logger.info("AgentStorage imported model perf history from stats.db", { imported });
547
+ })
548
+ .catch(error => {
549
+ logger.warn("AgentStorage model perf backfill failed", { error: String(error) });
550
+ });
551
+ } catch (error) {
552
+ logger.warn("AgentStorage model perf backfill failed", { error: String(error) });
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Imports recent measurable request rows from an `omp stats` database
558
+ * (`messages` table) into the model_perf aggregates. Walks newest-first
559
+ * over the timestamp index in {@link MODEL_PERF_BACKFILL_CHUNK}-row chunks,
560
+ * yielding to the event loop between chunks, and keeps at most
561
+ * {@link MODEL_PERF_DECAY_AT} rows per model within the
562
+ * {@link MODEL_PERF_BACKFILL_MAX_AGE_MS} window — beyond either bound the
563
+ * live decay would erase the contribution anyway. Errored turns are
564
+ * excluded; aborted turns with reported usage count, matching live capture.
565
+ * Sums land in one additive transaction at the end, so concurrent live
566
+ * samples merge correctly regardless of order.
567
+ * @param statsDbPath - Path to a stats.db file; opened read-only
568
+ * @returns Number of rows folded in
569
+ * @throws When the stats db cannot be opened or queried
570
+ */
571
+ async backfillModelPerfFromStats(statsDbPath: string): Promise<number> {
572
+ const statsDb = new Database(statsDbPath, { readonly: true });
573
+ try {
574
+ statsDb.run("PRAGMA busy_timeout = 5000");
575
+ const select = statsDb.prepare(
576
+ `SELECT rowid, timestamp, provider, model, output_tokens, duration, ttft
577
+ FROM messages
578
+ WHERE (timestamp < ?1 OR (timestamp = ?1 AND rowid < ?2))
579
+ AND timestamp >= ?3
580
+ AND duration > 0 AND output_tokens > 0 AND stop_reason != 'error'
581
+ ORDER BY timestamp DESC, rowid DESC
582
+ LIMIT ?4`,
583
+ );
584
+ const cutoff = Date.now() - MODEL_PERF_BACKFILL_MAX_AGE_MS;
585
+ const sums = new Map<string, PerfAccum>();
586
+ let cursorTimestamp = Number.MAX_SAFE_INTEGER;
587
+ let cursorRowid = Number.MAX_SAFE_INTEGER;
588
+ let scanned = 0;
589
+ let imported = 0;
590
+ while (scanned < MODEL_PERF_BACKFILL_MAX_ROWS) {
591
+ const chunk = Math.min(MODEL_PERF_BACKFILL_CHUNK, MODEL_PERF_BACKFILL_MAX_ROWS - scanned);
592
+ const rows = select.all(cursorTimestamp, cursorRowid, cutoff, chunk) as StatsMessageRow[];
593
+ if (rows.length === 0) break;
594
+ scanned += rows.length;
595
+ const last = rows[rows.length - 1];
596
+ cursorTimestamp = last.timestamp;
597
+ cursorRowid = last.rowid;
598
+ for (const row of rows) {
599
+ const key = `${row.provider}/${row.model}`;
600
+ let accum = sums.get(key);
601
+ if (accum && accum.samples >= MODEL_PERF_DECAY_AT) continue;
602
+ const normalized = normalizeModelPerfSample(key, {
603
+ outputTokens: row.output_tokens,
604
+ durationMs: row.duration,
605
+ ttftMs: row.ttft ?? undefined,
606
+ });
607
+ if (!normalized) continue;
608
+ if (!accum) {
609
+ accum = { samples: 0, outputTokens: 0, genMs: 0, ttftSamples: 0, ttftMs: 0 };
610
+ sums.set(key, accum);
611
+ }
612
+ accum.samples += 1;
613
+ accum.outputTokens += normalized.outputTokens;
614
+ accum.genMs += normalized.durationMs;
615
+ accum.ttftSamples += normalized.ttftSamples;
616
+ accum.ttftMs += normalized.ttftMs;
617
+ imported++;
618
+ }
619
+ if (rows.length < chunk) break;
620
+ // Yield so a chunked walk never freezes the TUI (bun:sqlite is sync).
621
+ await Bun.sleep(0);
622
+ }
623
+ if (sums.size > 0) {
624
+ const upsert = this.#db.prepare(
625
+ `INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at)
626
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ${SQLITE_NOW_EPOCH})
627
+ ON CONFLICT(model_key) DO UPDATE SET
628
+ samples = model_perf.samples + excluded.samples,
629
+ output_tokens = model_perf.output_tokens + excluded.output_tokens,
630
+ gen_ms = model_perf.gen_ms + excluded.gen_ms,
631
+ ttft_samples = model_perf.ttft_samples + excluded.ttft_samples,
632
+ ttft_ms = model_perf.ttft_ms + excluded.ttft_ms,
633
+ updated_at = ${SQLITE_NOW_EPOCH}`,
634
+ );
635
+ this.#db.transaction(() => {
636
+ for (const [key, accum] of sums) {
637
+ upsert.run(key, accum.samples, accum.outputTokens, accum.genMs, accum.ttftSamples, accum.ttftMs);
638
+ }
639
+ })();
640
+ }
641
+ return imported;
642
+ } finally {
643
+ statsDb.close();
644
+ }
645
+ }
646
+
320
647
  /**
321
648
  * Checks if any auth credentials exist in storage.
322
649
  * @returns True if at least one credential is stored
@@ -1,7 +1,7 @@
1
1
  import { Database, type Statement } from "bun:sqlite";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
4
+ import { AsyncDrain, getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  export interface HistoryEntry {
7
7
  id: number;
@@ -28,39 +28,6 @@ function escapeLikePattern(text: string): string {
28
28
  return text.replace(/[\\%_]/g, "\\$&");
29
29
  }
30
30
 
31
- class AsyncDrain<T> {
32
- #queue?: T[];
33
- #promise = Promise.resolve();
34
-
35
- constructor(readonly delayMs: number = 0) {}
36
-
37
- push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void> {
38
- let queue = this.#queue;
39
- if (!queue) {
40
- this.#queue = queue = [];
41
- const { promise, resolve, reject } = Promise.withResolvers<void>();
42
- const exec = (): void => {
43
- try {
44
- if (this.#queue === queue) {
45
- this.#queue = undefined;
46
- }
47
- resolve(hnd(queue!));
48
- } catch (error) {
49
- reject(error);
50
- }
51
- };
52
- if (this.delayMs > 0) {
53
- setTimeout(exec, this.delayMs);
54
- } else {
55
- queueMicrotask(exec);
56
- }
57
- this.#promise = promise;
58
- }
59
- queue.push(value);
60
- return this.#promise;
61
- }
62
- }
63
-
64
31
  export class HistoryStorage {
65
32
  #db: Database;
66
33
  static #instance?: HistoryStorage;
@@ -81,3 +81,76 @@ describe("buildSessionContext snapcompact archives", () => {
81
81
  expect(summary.blocks?.map(block => block.type)).toEqual(["text", "image", "text"]);
82
82
  });
83
83
  });
84
+
85
+ // A turn whose tool is still executing at rebuild time: the assistant message
86
+ // (with its toolCall) is persisted at message_end, the toolResult is not.
87
+ const danglingToolCallEntries = [
88
+ {
89
+ type: "message",
90
+ id: "m1",
91
+ parentId: null,
92
+ timestamp,
93
+ message: { role: "user", content: [{ type: "text", text: "run it" }], timestamp: 1 },
94
+ },
95
+ {
96
+ type: "message",
97
+ id: "m2",
98
+ parentId: "m1",
99
+ timestamp,
100
+ message: {
101
+ role: "assistant",
102
+ content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "sleep 60" } }],
103
+ api: "anthropic-messages",
104
+ provider: "anthropic",
105
+ model: "claude-sonnet-4-5",
106
+ usage: {
107
+ input: 1,
108
+ output: 1,
109
+ cacheRead: 0,
110
+ cacheWrite: 0,
111
+ totalTokens: 2,
112
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
113
+ },
114
+ stopReason: "toolUse",
115
+ timestamp: 2,
116
+ },
117
+ },
118
+ ] satisfies SessionEntry[];
119
+
120
+ function danglingCallIds(messages: AgentMessage[]): string[] {
121
+ const ids: string[] = [];
122
+ for (const message of messages) {
123
+ if (message.role !== "assistant") continue;
124
+ for (const block of message.content) {
125
+ if (block.type === "toolCall") ids.push(block.id);
126
+ }
127
+ }
128
+ return ids;
129
+ }
130
+
131
+ describe("buildSessionContext dangling toolCalls", () => {
132
+ it("strips a dangling toolCall-only assistant turn from the transcript by default", () => {
133
+ const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { transcript: true });
134
+
135
+ expect(danglingCallIds(context.messages)).toEqual([]);
136
+ // The turn had nothing but the dangling call, so the whole message drops.
137
+ expect(context.messages.some(message => message.role === "assistant")).toBe(false);
138
+ });
139
+
140
+ it("keeps a dangling toolCall in transcript mode with keepDanglingToolCalls", () => {
141
+ const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, {
142
+ transcript: true,
143
+ keepDanglingToolCalls: true,
144
+ });
145
+
146
+ expect(danglingCallIds(context.messages)).toEqual(["call-1"]);
147
+ });
148
+
149
+ it("always strips dangling toolCalls from the LLM context", () => {
150
+ const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, {
151
+ keepDanglingToolCalls: true,
152
+ });
153
+
154
+ expect(danglingCallIds(context.messages)).toEqual([]);
155
+ });
156
+ });
@@ -120,6 +120,15 @@ export interface BuildSessionContextOptions {
120
120
  transcript?: boolean;
121
121
  /** In transcript mode, elide entries replaced by the latest compaction. */
122
122
  collapseCompactedHistory?: boolean;
123
+ /**
124
+ * Transcript mode only: keep `toolCall` blocks that have no matching
125
+ * `toolResult` on the path instead of stripping them. Pass this when the
126
+ * session is mid-turn (a tool is still executing, its result not yet
127
+ * persisted) so the rebuilt transcript renders the in-flight call as
128
+ * pending; without it a focus/unfocus or overlay-close rebuild silently
129
+ * hides the call the agent is still waiting on.
130
+ */
131
+ keepDanglingToolCalls?: boolean;
123
132
  }
124
133
 
125
134
  /**
@@ -446,34 +455,42 @@ export function buildSessionContext(
446
455
  // plaintext to keep) and clear `thinking` signatures so the provider encoder
447
456
  // downgrades them to plain text (verified accepted by the live API), preserving the
448
457
  // visible reasoning while removing the immutability/invalid-signature hazard. Drop a
449
- // turn left with no content. (Live turns never qualify: their results are persisted
450
- // on the same path before any context rebuild.)
451
- const pairedToolResultIds = new Set<string>();
452
- for (const message of messages) {
453
- if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
454
- }
455
- for (let i = messages.length - 1; i >= 0; i--) {
456
- const message = messages[i];
457
- if (message.role !== "assistant") continue;
458
- const hasDangling = message.content.some(
459
- block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
460
- );
461
- if (!hasDangling) continue;
462
- const normalized = message.content
463
- .filter(
464
- block =>
465
- !(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
466
- )
467
- .map(block =>
468
- block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
458
+ // turn left with no content. (Live turns only qualify mid-turn: a transcript rebuild
459
+ // while the tool still executes sees the persisted assistant turn without its result.
460
+ // Those callers pass `keepDanglingToolCalls` so the in-flight call stays visible as
461
+ // a pending block instead of vanishing from the chat.)
462
+ const keepDangling = options?.transcript === true && options.keepDanglingToolCalls === true;
463
+ if (!keepDangling) {
464
+ const pairedToolResultIds = new Set<string>();
465
+ for (const message of messages) {
466
+ if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
467
+ }
468
+ for (let i = messages.length - 1; i >= 0; i--) {
469
+ const message = messages[i];
470
+ if (message.role !== "assistant") continue;
471
+ const hasDangling = message.content.some(
472
+ block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
469
473
  );
470
- if (normalized.length === 0) {
471
- messages.splice(i, 1);
472
- if (options?.transcript) {
473
- cacheMissExplainedAt.splice(i, 1);
474
+ if (!hasDangling) continue;
475
+ const normalized = message.content
476
+ .filter(
477
+ block =>
478
+ !(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) &&
479
+ block.type !== "redactedThinking",
480
+ )
481
+ .map(block =>
482
+ block.type === "thinking" && block.thinkingSignature
483
+ ? { ...block, thinkingSignature: undefined }
484
+ : block,
485
+ );
486
+ if (normalized.length === 0) {
487
+ messages.splice(i, 1);
488
+ if (options?.transcript) {
489
+ cacheMissExplainedAt.splice(i, 1);
490
+ }
491
+ } else {
492
+ messages[i] = { ...message, content: normalized };
474
493
  }
475
- } else {
476
- messages[i] = { ...message, content: normalized };
477
494
  }
478
495
  }
479
496
 
@@ -24,6 +24,7 @@ import {
24
24
  MarketplaceManager,
25
25
  } from "../extensibility/plugins/marketplace";
26
26
  import { resolveMemoryBackend } from "../memory-backend";
27
+ import { runPauseScreen } from "../modes/components/pause-screen";
27
28
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
28
29
  import { theme } from "../modes/theme/theme";
29
30
  import type { InteractiveModeContext } from "../modes/types";
@@ -325,6 +326,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
325
326
  if (prompt) return { prompt };
326
327
  },
327
328
  },
329
+ {
330
+ name: "queue",
331
+ description: "Queue a message for after the agent yields",
332
+ inlineHint: "<message>",
333
+ allowArgs: true,
334
+ handleTui: async (command, runtime) => {
335
+ await runtime.ctx.handleQueueCommand(command.args);
336
+ },
337
+ },
328
338
  {
329
339
  name: "model",
330
340
  aliases: ["models"],
@@ -2269,6 +2279,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
2269
2279
  if (prompt) return { prompt };
2270
2280
  },
2271
2281
  },
2282
+ {
2283
+ name: "pause",
2284
+ description: "Freeze all agents (main, subagents, advisor) until resumed",
2285
+ handleTui: async (_command, runtime) => {
2286
+ runtime.ctx.editor.setText("");
2287
+ await runPauseScreen(runtime.ctx);
2288
+ },
2289
+ },
2272
2290
  {
2273
2291
  name: "quit",
2274
2292
  description: "Quit the application",
@@ -13,6 +13,7 @@ import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
13
13
  import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
14
14
  import scoutMd from "../prompts/agents/scout.md" with { type: "text" };
15
15
  import taskMd from "../prompts/agents/task.md" with { type: "text" };
16
+ import { AUTO_THINKING } from "../thinking";
16
17
 
17
18
  import type { AgentDefinition, AgentSource } from "./types";
18
19
 
@@ -50,6 +51,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
50
51
  description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
51
52
  spawns: "*",
52
53
  model: "pi/task",
54
+ thinkingLevel: AUTO_THINKING,
53
55
  },
54
56
  template: taskMd,
55
57
  },