@oh-my-pi/pi-coding-agent 12.2.1 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ export interface MemoryThread {
4
+ id: string;
5
+ updatedAt: number;
6
+ rolloutPath: string;
7
+ cwd: string;
8
+ sourceKind: string;
9
+ }
10
+
11
+ export interface Stage1OutputRow {
12
+ threadId: string;
13
+ sourceUpdatedAt: number;
14
+ rawMemory: string;
15
+ rolloutSummary: string;
16
+ rolloutSlug: string | null;
17
+ generatedAt: number;
18
+ cwd: string;
19
+ }
20
+
21
+ export interface Stage1Claim {
22
+ threadId: string;
23
+ ownershipToken: string;
24
+ inputWatermark: number;
25
+ sourceUpdatedAt: number;
26
+ rolloutPath: string;
27
+ cwd: string;
28
+ }
29
+
30
+ export interface GlobalClaim {
31
+ ownershipToken: string;
32
+ inputWatermark: number;
33
+ }
34
+
35
+ const STAGE1_KIND = "memory_stage1";
36
+ const GLOBAL_KIND = "memory_consolidate_global";
37
+ const GLOBAL_KEY = "global";
38
+ const DEFAULT_RETRY_REMAINING = 3;
39
+
40
+ export function openMemoryDb(dbPath: string): Database {
41
+ const db = new Database(dbPath);
42
+ db.exec(`
43
+ PRAGMA journal_mode=WAL;
44
+ PRAGMA synchronous=NORMAL;
45
+ PRAGMA busy_timeout=5000;
46
+
47
+ CREATE TABLE IF NOT EXISTS threads (
48
+ id TEXT PRIMARY KEY,
49
+ updated_at INTEGER NOT NULL,
50
+ rollout_path TEXT NOT NULL,
51
+ cwd TEXT NOT NULL,
52
+ source_kind TEXT NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS stage1_outputs (
56
+ thread_id TEXT PRIMARY KEY,
57
+ source_updated_at INTEGER NOT NULL,
58
+ raw_memory TEXT NOT NULL,
59
+ rollout_summary TEXT NOT NULL,
60
+ rollout_slug TEXT,
61
+ generated_at INTEGER NOT NULL
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS jobs (
65
+ kind TEXT NOT NULL,
66
+ job_key TEXT NOT NULL,
67
+ status TEXT NOT NULL,
68
+ worker_id TEXT,
69
+ ownership_token TEXT,
70
+ started_at INTEGER,
71
+ finished_at INTEGER,
72
+ lease_until INTEGER,
73
+ retry_at INTEGER,
74
+ retry_remaining INTEGER NOT NULL,
75
+ last_error TEXT,
76
+ input_watermark INTEGER,
77
+ last_success_watermark INTEGER,
78
+ PRIMARY KEY (kind, job_key)
79
+ );
80
+ `);
81
+ return db;
82
+ }
83
+
84
+ export function closeMemoryDb(db: Database): void {
85
+ db.close();
86
+ }
87
+
88
+ export function clearMemoryData(db: Database): void {
89
+ db.exec(`
90
+ DELETE FROM stage1_outputs;
91
+ DELETE FROM threads;
92
+ DELETE FROM jobs WHERE kind IN ('memory_stage1', 'memory_consolidate_global');
93
+ `);
94
+ }
95
+
96
+ export function upsertThreads(db: Database, threads: MemoryThread[]): void {
97
+ if (threads.length === 0) return;
98
+ const stmt = db.prepare(`
99
+ INSERT INTO threads (id, updated_at, rollout_path, cwd, source_kind)
100
+ VALUES (?, ?, ?, ?, ?)
101
+ ON CONFLICT(id) DO UPDATE SET
102
+ updated_at = excluded.updated_at,
103
+ rollout_path = excluded.rollout_path,
104
+ cwd = excluded.cwd,
105
+ source_kind = excluded.source_kind
106
+ `);
107
+ const tx = db.transaction((rows: MemoryThread[]) => {
108
+ for (const row of rows) {
109
+ stmt.run(row.id, row.updatedAt, row.rolloutPath, row.cwd, row.sourceKind);
110
+ }
111
+ });
112
+ tx(threads);
113
+ }
114
+
115
+ function ensureStage1Job(db: Database, threadId: string): void {
116
+ db.prepare(`
117
+ INSERT OR IGNORE INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
118
+ VALUES (?, ?, 'pending', ?, 0, 0)
119
+ `).run(STAGE1_KIND, threadId, DEFAULT_RETRY_REMAINING);
120
+ }
121
+
122
+ function ensureGlobalJob(db: Database): void {
123
+ db.prepare(`
124
+ INSERT OR IGNORE INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
125
+ VALUES (?, ?, 'pending', ?, 0, 0)
126
+ `).run(GLOBAL_KIND, GLOBAL_KEY, DEFAULT_RETRY_REMAINING);
127
+ }
128
+
129
+ export function claimStage1Jobs(
130
+ db: Database,
131
+ params: {
132
+ nowSec: number;
133
+ threadScanLimit: number;
134
+ maxRolloutsPerStartup: number;
135
+ maxRolloutAgeDays: number;
136
+ minRolloutIdleHours: number;
137
+ leaseSeconds: number;
138
+ runningConcurrencyCap: number;
139
+ workerId: string;
140
+ excludeThreadIds?: string[];
141
+ },
142
+ ): Stage1Claim[] {
143
+ const {
144
+ nowSec,
145
+ threadScanLimit,
146
+ maxRolloutsPerStartup,
147
+ maxRolloutAgeDays,
148
+ minRolloutIdleHours,
149
+ leaseSeconds,
150
+ runningConcurrencyCap,
151
+ workerId,
152
+ excludeThreadIds = [],
153
+ } = params;
154
+ const maxAgeSec = maxRolloutAgeDays * 24 * 60 * 60;
155
+ const minIdleSec = minRolloutIdleHours * 60 * 60;
156
+ const runningCountRow = db
157
+ .prepare(
158
+ "SELECT COUNT(*) AS count FROM jobs WHERE kind = ? AND status = 'running' AND lease_until IS NOT NULL AND lease_until > ?",
159
+ )
160
+ .get(STAGE1_KIND, nowSec) as { count?: number } | undefined;
161
+ let runningCount = runningCountRow?.count ?? 0;
162
+ if (runningCount >= runningConcurrencyCap) return [];
163
+ const candidateRows = db
164
+ .prepare("SELECT id, updated_at, rollout_path, cwd, source_kind FROM threads ORDER BY updated_at DESC LIMIT ?")
165
+ .all(threadScanLimit) as Array<{
166
+ id: string;
167
+ updated_at: number;
168
+ rollout_path: string;
169
+ cwd: string;
170
+ source_kind: string;
171
+ }>;
172
+ const claims: Stage1Claim[] = [];
173
+ const excluded = new Set(excludeThreadIds);
174
+ for (const row of candidateRows) {
175
+ if (claims.length >= maxRolloutsPerStartup) break;
176
+ if (excluded.has(row.id)) continue;
177
+ if (row.source_kind !== "cli" && row.source_kind !== "app") continue;
178
+ if (nowSec - row.updated_at > maxAgeSec) continue;
179
+ if (nowSec - row.updated_at < minIdleSec) continue;
180
+ if (runningCount >= runningConcurrencyCap) break;
181
+ const stage1 = db.prepare("SELECT source_updated_at FROM stage1_outputs WHERE thread_id = ?").get(row.id) as
182
+ | { source_updated_at?: number }
183
+ | undefined;
184
+ if ((stage1?.source_updated_at ?? 0) >= row.updated_at) continue;
185
+ ensureStage1Job(db, row.id);
186
+ const ownershipToken = crypto.randomUUID();
187
+ const leaseUntil = nowSec + leaseSeconds;
188
+ const claimed = db
189
+ .prepare(`
190
+ UPDATE jobs
191
+ SET status = 'running', worker_id = ?, ownership_token = ?, started_at = ?, finished_at = NULL,
192
+ lease_until = ?, retry_at = NULL, last_error = NULL, input_watermark = ?,
193
+ retry_remaining = CASE
194
+ WHEN input_watermark IS NULL THEN ?
195
+ WHEN input_watermark < ? THEN ?
196
+ ELSE retry_remaining
197
+ END
198
+ WHERE kind = ? AND job_key = ?
199
+ AND (last_success_watermark IS NULL OR last_success_watermark < ?)
200
+ AND NOT (status = 'running' AND lease_until IS NOT NULL AND lease_until > ?)
201
+ AND (
202
+ input_watermark IS NULL
203
+ OR input_watermark < ?
204
+ OR (
205
+ retry_remaining > 0
206
+ AND (retry_at IS NULL OR retry_at <= ?)
207
+ )
208
+ )
209
+ `)
210
+ .run(
211
+ workerId,
212
+ ownershipToken,
213
+ nowSec,
214
+ leaseUntil,
215
+ row.updated_at,
216
+ DEFAULT_RETRY_REMAINING,
217
+ row.updated_at,
218
+ DEFAULT_RETRY_REMAINING,
219
+ STAGE1_KIND,
220
+ row.id,
221
+ row.updated_at,
222
+ nowSec,
223
+ row.updated_at,
224
+ nowSec,
225
+ );
226
+ if (Number(claimed.changes ?? 0) <= 0) continue;
227
+ claims.push({
228
+ threadId: row.id,
229
+ ownershipToken,
230
+ inputWatermark: row.updated_at,
231
+ sourceUpdatedAt: row.updated_at,
232
+ rolloutPath: row.rollout_path,
233
+ cwd: row.cwd,
234
+ });
235
+ runningCount += 1;
236
+ }
237
+ return claims;
238
+ }
239
+
240
+ export function enqueueGlobalWatermark(
241
+ db: Database,
242
+ sourceUpdatedAt: number,
243
+ params?: { forceDirtyWhenNotAdvanced?: boolean },
244
+ ): void {
245
+ const forceDirtyWhenNotAdvanced = params?.forceDirtyWhenNotAdvanced ?? false;
246
+ ensureGlobalJob(db);
247
+ db.prepare(`
248
+ UPDATE jobs
249
+ SET
250
+ input_watermark = CASE
251
+ WHEN input_watermark IS NULL THEN ?
252
+ WHEN input_watermark < ? THEN ?
253
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN
254
+ CASE
255
+ WHEN last_success_watermark IS NULL THEN input_watermark + 1
256
+ ELSE last_success_watermark + 1
257
+ END
258
+ ELSE input_watermark
259
+ END,
260
+ retry_remaining = CASE
261
+ WHEN input_watermark IS NULL THEN ?
262
+ WHEN input_watermark < ? THEN ?
263
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN ?
264
+ ELSE retry_remaining
265
+ END,
266
+ retry_at = CASE
267
+ WHEN input_watermark IS NULL THEN NULL
268
+ WHEN input_watermark < ? THEN NULL
269
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN NULL
270
+ ELSE retry_at
271
+ END
272
+ WHERE kind = ? AND job_key = ?
273
+ `).run(
274
+ sourceUpdatedAt,
275
+ sourceUpdatedAt,
276
+ sourceUpdatedAt,
277
+ forceDirtyWhenNotAdvanced ? 1 : 0,
278
+ DEFAULT_RETRY_REMAINING,
279
+ sourceUpdatedAt,
280
+ DEFAULT_RETRY_REMAINING,
281
+ forceDirtyWhenNotAdvanced ? 1 : 0,
282
+ DEFAULT_RETRY_REMAINING,
283
+ sourceUpdatedAt,
284
+ forceDirtyWhenNotAdvanced ? 1 : 0,
285
+ GLOBAL_KIND,
286
+ GLOBAL_KEY,
287
+ );
288
+ }
289
+
290
+ export function markStage1SucceededWithOutput(
291
+ db: Database,
292
+ params: {
293
+ threadId: string;
294
+ ownershipToken: string;
295
+ sourceUpdatedAt: number;
296
+ rawMemory: string;
297
+ rolloutSummary: string;
298
+ rolloutSlug: string | null;
299
+ nowSec: number;
300
+ },
301
+ ): boolean {
302
+ const { threadId, ownershipToken, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec } = params;
303
+ const tx = db.transaction(() => {
304
+ const matched = db
305
+ .prepare(
306
+ "SELECT 1 AS ok FROM jobs WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?",
307
+ )
308
+ .get(STAGE1_KIND, threadId, ownershipToken) as { ok?: number } | undefined;
309
+ if (!matched?.ok) return false;
310
+
311
+ db.prepare(`
312
+ UPDATE jobs
313
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
314
+ last_error = NULL, last_success_watermark = input_watermark
315
+ WHERE kind = ? AND job_key = ? AND ownership_token = ?
316
+ `).run(nowSec, STAGE1_KIND, threadId, ownershipToken);
317
+
318
+ db.prepare(`
319
+ INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, rollout_slug, generated_at)
320
+ VALUES (?, ?, ?, ?, ?, ?)
321
+ ON CONFLICT(thread_id) DO UPDATE SET
322
+ source_updated_at = excluded.source_updated_at,
323
+ raw_memory = excluded.raw_memory,
324
+ rollout_summary = excluded.rollout_summary,
325
+ rollout_slug = excluded.rollout_slug,
326
+ generated_at = excluded.generated_at
327
+ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
328
+ `).run(threadId, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec);
329
+
330
+ enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
331
+ return true;
332
+ });
333
+ return tx() as boolean;
334
+ }
335
+
336
+ export function markStage1SucceededNoOutput(
337
+ db: Database,
338
+ params: { threadId: string; ownershipToken: string; sourceUpdatedAt: number; nowSec: number },
339
+ ): boolean {
340
+ const { threadId, ownershipToken, sourceUpdatedAt, nowSec } = params;
341
+ const tx = db.transaction(() => {
342
+ const matched = db
343
+ .prepare(
344
+ "SELECT 1 AS ok FROM jobs WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?",
345
+ )
346
+ .get(STAGE1_KIND, threadId, ownershipToken) as { ok?: number } | undefined;
347
+ if (!matched?.ok) return false;
348
+
349
+ db.prepare(`
350
+ UPDATE jobs
351
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
352
+ last_error = NULL, last_success_watermark = input_watermark
353
+ WHERE kind = ? AND job_key = ? AND ownership_token = ?
354
+ `).run(nowSec, STAGE1_KIND, threadId, ownershipToken);
355
+
356
+ db.prepare("DELETE FROM stage1_outputs WHERE thread_id = ?").run(threadId);
357
+ enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
358
+ return true;
359
+ });
360
+ return tx() as boolean;
361
+ }
362
+
363
+ export function markStage1Failed(
364
+ db: Database,
365
+ params: { threadId: string; ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number },
366
+ ): boolean {
367
+ const { threadId, ownershipToken, retryDelaySeconds, reason, nowSec } = params;
368
+ const result = db
369
+ .prepare(`
370
+ UPDATE jobs
371
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
372
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
373
+ last_error = ?
374
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
375
+ `)
376
+ .run(nowSec, nowSec + retryDelaySeconds, reason, STAGE1_KIND, threadId, ownershipToken);
377
+ return Number(result.changes ?? 0) > 0;
378
+ }
379
+
380
+ export function tryClaimGlobalPhase2Job(
381
+ db: Database,
382
+ params: { workerId: string; leaseSeconds: number; nowSec: number },
383
+ ): { kind: "claimed"; claim: GlobalClaim } | { kind: "skipped_not_dirty" } | { kind: "skipped_running" } {
384
+ const { workerId, leaseSeconds, nowSec } = params;
385
+ ensureGlobalJob(db);
386
+ const pre = db
387
+ .prepare(
388
+ "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
389
+ )
390
+ .get(GLOBAL_KIND, GLOBAL_KEY) as
391
+ | {
392
+ status: string;
393
+ lease_until: number | null;
394
+ input_watermark: number | null;
395
+ last_success_watermark: number | null;
396
+ retry_at: number | null;
397
+ retry_remaining: number;
398
+ }
399
+ | undefined;
400
+ if (!pre) return { kind: "skipped_not_dirty" };
401
+ const ownershipToken = crypto.randomUUID();
402
+ const claimed = db
403
+ .prepare(`
404
+ UPDATE jobs
405
+ SET status = 'running', worker_id = ?, ownership_token = ?, started_at = ?, finished_at = NULL,
406
+ lease_until = ?, retry_at = NULL, last_error = NULL
407
+ WHERE kind = ? AND job_key = ?
408
+ AND NOT (status = 'running' AND lease_until IS NOT NULL AND lease_until > ?)
409
+ AND (input_watermark IS NOT NULL AND (last_success_watermark IS NULL OR input_watermark > last_success_watermark))
410
+ AND retry_remaining > 0
411
+ AND (retry_at IS NULL OR retry_at <= ?)
412
+ `)
413
+ .run(workerId, ownershipToken, nowSec, nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, nowSec, nowSec);
414
+ if (Number(claimed.changes ?? 0) > 0) {
415
+ const row = db
416
+ .prepare("SELECT input_watermark FROM jobs WHERE kind = ? AND job_key = ? AND ownership_token = ?")
417
+ .get(GLOBAL_KIND, GLOBAL_KEY, ownershipToken) as { input_watermark: number | null } | undefined;
418
+ return {
419
+ kind: "claimed",
420
+ claim: {
421
+ ownershipToken,
422
+ inputWatermark: row?.input_watermark ?? 0,
423
+ },
424
+ };
425
+ }
426
+
427
+ if (pre.status === "running" && pre.lease_until !== null && pre.lease_until > nowSec) {
428
+ return { kind: "skipped_running" };
429
+ }
430
+ const preInput = pre.input_watermark ?? 0;
431
+ const preSuccess = pre.last_success_watermark ?? 0;
432
+ if (preInput <= preSuccess) {
433
+ return { kind: "skipped_not_dirty" };
434
+ }
435
+ if (pre.retry_remaining <= 0) {
436
+ return { kind: "skipped_not_dirty" };
437
+ }
438
+ if (pre.retry_at !== null && pre.retry_at > nowSec) {
439
+ return { kind: "skipped_not_dirty" };
440
+ }
441
+
442
+ const post = db
443
+ .prepare(
444
+ "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
445
+ )
446
+ .get(GLOBAL_KIND, GLOBAL_KEY) as
447
+ | {
448
+ status: string;
449
+ lease_until: number | null;
450
+ input_watermark: number | null;
451
+ last_success_watermark: number | null;
452
+ retry_at: number | null;
453
+ retry_remaining: number;
454
+ }
455
+ | undefined;
456
+ if (!post) return { kind: "skipped_not_dirty" };
457
+ if (post.status === "running" && post.lease_until !== null && post.lease_until > nowSec) {
458
+ return { kind: "skipped_running" };
459
+ }
460
+
461
+ return { kind: "skipped_not_dirty" };
462
+ }
463
+
464
+ export function heartbeatGlobalJob(
465
+ db: Database,
466
+ params: { ownershipToken: string; leaseSeconds: number; nowSec: number },
467
+ ): boolean {
468
+ const { ownershipToken, leaseSeconds, nowSec } = params;
469
+ const result = db
470
+ .prepare(`
471
+ UPDATE jobs
472
+ SET lease_until = ?
473
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
474
+ `)
475
+ .run(nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
476
+ return Number(result.changes ?? 0) > 0;
477
+ }
478
+
479
+ export function listStage1OutputsForGlobal(db: Database, limit: number): Stage1OutputRow[] {
480
+ const rows = db
481
+ .prepare(`
482
+ SELECT o.thread_id, o.source_updated_at, o.raw_memory, o.rollout_summary, o.rollout_slug, o.generated_at, t.cwd
483
+ FROM stage1_outputs o
484
+ LEFT JOIN threads t ON t.id = o.thread_id
485
+ WHERE TRIM(COALESCE(o.raw_memory, '')) != '' OR TRIM(COALESCE(o.rollout_summary, '')) != ''
486
+ ORDER BY o.source_updated_at DESC
487
+ LIMIT ?
488
+ `)
489
+ .all(limit) as Array<{
490
+ thread_id: string;
491
+ source_updated_at: number;
492
+ raw_memory: string;
493
+ rollout_summary: string;
494
+ rollout_slug: string | null;
495
+ generated_at: number;
496
+ cwd: string | null;
497
+ }>;
498
+ return rows.map(row => ({
499
+ threadId: row.thread_id,
500
+ sourceUpdatedAt: row.source_updated_at,
501
+ rawMemory: row.raw_memory,
502
+ rolloutSummary: row.rollout_summary,
503
+ rolloutSlug: row.rollout_slug,
504
+ generatedAt: row.generated_at,
505
+ cwd: row.cwd ?? "",
506
+ }));
507
+ }
508
+
509
+ export function markGlobalPhase2Succeeded(
510
+ db: Database,
511
+ params: { ownershipToken: string; newWatermark: number; nowSec: number },
512
+ ): boolean {
513
+ const { ownershipToken, newWatermark, nowSec } = params;
514
+ const result = db
515
+ .prepare(`
516
+ UPDATE jobs
517
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
518
+ last_error = NULL,
519
+ last_success_watermark = CASE
520
+ WHEN last_success_watermark IS NULL THEN ?
521
+ WHEN last_success_watermark < ? THEN ?
522
+ ELSE last_success_watermark
523
+ END
524
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
525
+ `)
526
+ .run(nowSec, newWatermark, newWatermark, newWatermark, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
527
+ return Number(result.changes ?? 0) > 0;
528
+ }
529
+
530
+ export function markGlobalPhase2Failed(
531
+ db: Database,
532
+ params: { ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number },
533
+ ): boolean {
534
+ const { ownershipToken, retryDelaySeconds, reason, nowSec } = params;
535
+ const result = db
536
+ .prepare(`
537
+ UPDATE jobs
538
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
539
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
540
+ last_error = ?
541
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
542
+ `)
543
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
544
+ return Number(result.changes ?? 0) > 0;
545
+ }
546
+
547
+ export function markGlobalPhase2FailedUnowned(
548
+ db: Database,
549
+ params: { retryDelaySeconds: number; reason: string; nowSec: number },
550
+ ): boolean {
551
+ const { retryDelaySeconds, reason, nowSec } = params;
552
+ const result = db
553
+ .prepare(`
554
+ UPDATE jobs
555
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
556
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
557
+ last_error = ?
558
+ WHERE kind = ? AND job_key = ? AND status = 'running'
559
+ AND (ownership_token IS NULL OR lease_until IS NULL OR lease_until <= ?)
560
+ `)
561
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, nowSec);
562
+ return Number(result.changes ?? 0) > 0;
563
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Component for displaying bash command execution with streaming output.
3
3
  */
4
+
5
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
4
6
  import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
6
8
  import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { truncateToVisualLines } from "./visual-truncate";
10
12
 
11
13
  // Preview line limit when not expanded (matches tool execution behavior)
12
14
  const PREVIEW_LINES = 20;
15
+ const MAX_DISPLAY_LINE_CHARS = 4000;
13
16
 
14
17
  export class BashExecutionComponent extends Container {
15
18
  #outputLines: string[] = [];
@@ -73,13 +76,15 @@ export class BashExecutionComponent extends Container {
73
76
  }
74
77
 
75
78
  appendOutput(chunk: string): void {
76
- const clean = this.#normalizeOutput(chunk);
79
+ const clean = sanitizeText(chunk);
77
80
 
78
81
  // Append to output lines
79
- const newLines = clean.split("\n");
82
+ const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
80
83
  if (this.#outputLines.length > 0 && newLines.length > 0) {
81
84
  // Append first chunk to last line (incomplete line continuation)
82
- this.#outputLines[this.#outputLines.length - 1] += newLines[0];
85
+ this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
86
+ `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
87
+ );
83
88
  this.#outputLines.push(...newLines.slice(1));
84
89
  } else {
85
90
  this.#outputLines.push(...newLines);
@@ -184,15 +189,17 @@ export class BashExecutionComponent extends Container {
184
189
  }
185
190
  }
186
191
 
187
- #normalizeOutput(text: string): string {
188
- // Strip ANSI codes and normalize line endings
189
- // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
190
- return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
192
+ #clampDisplayLine(line: string): string {
193
+ if (line.length <= MAX_DISPLAY_LINE_CHARS) {
194
+ return line;
195
+ }
196
+ const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
197
+ return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
191
198
  }
192
199
 
193
200
  #setOutput(output: string): void {
194
- const clean = this.#normalizeOutput(output);
195
- this.#outputLines = clean ? clean.split("\n") : [];
201
+ const clean = sanitizeText(output);
202
+ this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
196
203
  }
197
204
 
198
205
  /**
@@ -2,6 +2,8 @@
2
2
  * Component for displaying user-initiated Python execution with streaming output.
3
3
  * Shares the same kernel session as the agent's Python tool.
4
4
  */
5
+
6
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
5
7
  import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
8
  import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
7
9
  import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { DynamicBorder } from "./dynamic-border";
10
12
  import { truncateToVisualLines } from "./visual-truncate";
11
13
 
12
14
  const PREVIEW_LINES = 20;
15
+ const MAX_DISPLAY_LINE_CHARS = 4000;
13
16
 
14
17
  export class PythonExecutionComponent extends Container {
15
18
  #outputLines: string[] = [];
@@ -70,11 +73,13 @@ export class PythonExecutionComponent extends Container {
70
73
  }
71
74
 
72
75
  appendOutput(chunk: string): void {
73
- const clean = this.#normalizeOutput(chunk);
76
+ const clean = sanitizeText(chunk);
74
77
 
75
- const newLines = clean.split("\n");
78
+ const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
76
79
  if (this.#outputLines.length > 0 && newLines.length > 0) {
77
- this.#outputLines[this.#outputLines.length - 1] += newLines[0];
80
+ this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
81
+ `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
82
+ );
78
83
  this.#outputLines.push(...newLines.slice(1));
79
84
  } else {
80
85
  this.#outputLines.push(...newLines);
@@ -168,13 +173,17 @@ export class PythonExecutionComponent extends Container {
168
173
  }
169
174
  }
170
175
 
171
- #normalizeOutput(text: string): string {
172
- return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
176
+ #clampDisplayLine(line: string): string {
177
+ if (line.length <= MAX_DISPLAY_LINE_CHARS) {
178
+ return line;
179
+ }
180
+ const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
181
+ return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
173
182
  }
174
183
 
175
184
  #setOutput(output: string): void {
176
- const clean = this.#normalizeOutput(output);
177
- this.#outputLines = clean ? clean.split("\n") : [];
185
+ const clean = sanitizeText(output);
186
+ this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
178
187
  }
179
188
 
180
189
  getOutput(): string {
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
3
  import {
3
4
  Box,
4
5
  type Component,
@@ -12,7 +13,7 @@ import {
12
13
  Text,
13
14
  type TUI,
14
15
  } from "@oh-my-pi/pi-tui";
15
- import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
16
+ import { logger } from "@oh-my-pi/pi-utils";
16
17
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
17
18
  import type { Theme } from "../../modes/theme/theme";
18
19
  import { theme } from "../../modes/theme/theme";