@pasko70/pibo 1.10.0 → 1.11.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.
Files changed (72) hide show
  1. package/dist/apps/chat/chat-request-normalizers.js +7 -0
  2. package/dist/apps/chat/data/chat-data-mappers.js +2 -0
  3. package/dist/apps/chat/loop-api.js +11 -6
  4. package/dist/apps/chat/web-app.js +30 -10
  5. package/dist/apps/chat-ui/assets/{dist-wE9nop9V.js → dist-0E9FVJ6k.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-DlATLa-U.js → dist-BvqC0hRM.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-DUlaXAk7.js → dist-C2BRHNXr.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-nOLTkZrJ.js → dist-C57jNmjf.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-Y-AA2omI.js → dist-CBL8UXjd.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-D-cxLQO1.js → dist-CI-LPHjw.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-LHRs1Nhr.js → dist-CMRFkfl5.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-CS7wdk0Z.js → dist-CpZPhD2y.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{dist-BwKObYnX.js → dist-CuCZ-3_K.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-GdEM8UW1.js → dist-CzC5kPWJ.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{dist-CKtT8YGm.js → dist-D9hLnn1T.js} +1 -1
  16. package/dist/apps/chat-ui/assets/index-BEvjPUor.js +173 -0
  17. package/dist/apps/chat-ui/assets/{index-C0x9nEcf.css → index-DNeE4HrG.css} +1 -1
  18. package/dist/apps/chat-ui/index.html +2 -2
  19. package/dist/apps/chat-vscode-web/assets/index-BLZRi4Ka.js +41 -0
  20. package/dist/apps/chat-vscode-web/assets/index-Bf2JvJ9z.css +2 -0
  21. package/dist/apps/chat-vscode-web/index.html +2 -2
  22. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  23. package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.11.0.vsix +0 -0
  24. package/dist/auth/better-auth.js +49 -1
  25. package/dist/auth/cli.js +220 -0
  26. package/dist/auth/machine-keys.js +258 -0
  27. package/dist/auth/machine-session.js +123 -0
  28. package/dist/bin/pibo.js +0 -0
  29. package/dist/bin/rg.js +0 -0
  30. package/dist/cli-session/localSessionSource.js +34 -12
  31. package/dist/cli.js +6 -0
  32. package/dist/compute/cli.js +7 -0
  33. package/dist/compute/resource-health.js +61 -4
  34. package/dist/config/config.js +5 -0
  35. package/dist/core/events.js +6 -1
  36. package/dist/core/routed-session.js +67 -4
  37. package/dist/core/session-router.js +40 -6
  38. package/dist/data/ingest-service.js +3 -1
  39. package/dist/data/schema.js +4 -0
  40. package/dist/debug/index.js +4 -0
  41. package/dist/debug/trace-status.js +25 -0
  42. package/dist/debug/trace.js +51 -17
  43. package/dist/index.js +1 -0
  44. package/dist/loops/accounting.js +19 -0
  45. package/dist/loops/cli.js +22 -6
  46. package/dist/loops/prompts.js +4 -1
  47. package/dist/loops/service.js +97 -8
  48. package/dist/loops/store.js +128 -17
  49. package/dist/loops/tools.js +24 -7
  50. package/dist/reliability/store.js +34 -7
  51. package/dist/resources/cli.js +15 -0
  52. package/dist/resources/lifecycle.js +28 -4
  53. package/dist/resources/reaper.js +1 -0
  54. package/dist/runs/lifecycle.js +59 -0
  55. package/dist/runs/registry.js +47 -1
  56. package/dist/runs/tools.js +31 -13
  57. package/dist/session-ui/terminalRows.js +66 -2
  58. package/dist/shared/trace-async-agent-runs.js +4 -4
  59. package/dist/shared/trace-event-projection.js +59 -19
  60. package/dist/shared/trace-nodes.js +5 -0
  61. package/dist/shared/trace-page-merge.js +15 -0
  62. package/dist/shared/trace-run-notifications.js +3 -1
  63. package/dist/signals/projector.js +16 -3
  64. package/dist/tools/browser-pool.js +50 -0
  65. package/dist/tools/browser-use-leases.js +12 -8
  66. package/dist/tools/guides.js +8 -1
  67. package/dist/tools/index.js +1 -0
  68. package/package.json +2 -1
  69. package/skills/builtin/loop/SKILL.md +12 -3
  70. package/dist/apps/chat-ui/assets/index-8W_yMHQI.js +0 -173
  71. package/dist/apps/chat-vscode-web/assets/index-B5QK07zO.css +0 -2
  72. package/dist/apps/chat-vscode-web/assets/index-BAMxIaI_.js +0 -41
@@ -75,13 +75,37 @@ function resourceMetadataJson(resources) {
75
75
  const normalized = normalizeLoopResourceMetadata(resources);
76
76
  return normalized ? JSON.stringify(normalized) : null;
77
77
  }
78
+ function parseRunAccounting(json) {
79
+ if (!json)
80
+ return undefined;
81
+ try {
82
+ const value = JSON.parse(json);
83
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function runAccountingJson(accounting) { return accounting ? JSON.stringify(accounting) : null; }
90
+ function normalizeJobState(state, mode, enabled, createdAt) {
91
+ if (mode !== 'goal')
92
+ return state;
93
+ const activeTimeSeconds = Math.max(0, Math.floor(state.activeTimeSeconds ?? state.timeUsedSeconds ?? 0));
94
+ const goalStartedAt = state.goalStartedAt ?? (enabled || (state.completedIterations ?? 0) > 0 || (state.tokensUsed ?? 0) > 0 || (state.goalStatus !== undefined && state.goalStatus !== 'paused') ? createdAt : undefined);
95
+ const normalized = { ...state, activeTimeSeconds, ...(goalStartedAt ? { goalStartedAt } : {}) };
96
+ delete normalized.timeUsedSeconds;
97
+ return normalized;
98
+ }
78
99
  function jobFromRow(row) {
79
100
  const resources = parseResourceMetadata(row.resource_json);
80
- return { id: row.id, mode: normalizeLoopMode(row.loop_mode, 'ralph'), name: row.name, description: row.description ?? undefined, enabled: row.enabled === 1, target: parseLoopTarget(row.target_json), profile: row.profile, prompt: row.prompt, maxIterations: row.max_iterations ?? undefined, tokenBudget: row.token_budget ?? undefined, stopPolicy: parseStopPolicy(row.stop_policy_json), ...parseRuntimeOptions(row.runtime_options_json), ...(resources ? { resources } : {}), state: parseJson(row.state_json), createdAt: row.created_at, updatedAt: row.updated_at };
101
+ const mode = normalizeLoopMode(row.loop_mode, 'ralph');
102
+ const enabled = row.enabled === 1;
103
+ return { id: row.id, mode, name: row.name, description: row.description ?? undefined, enabled, target: parseLoopTarget(row.target_json), profile: row.profile, prompt: row.prompt, maxIterations: row.max_iterations ?? undefined, tokenBudget: row.token_budget ?? undefined, tokenReserve: row.token_reserve ?? undefined, stopPolicy: parseStopPolicy(row.stop_policy_json), ...parseRuntimeOptions(row.runtime_options_json), ...(resources ? { resources } : {}), state: normalizeJobState(parseJson(row.state_json), mode, enabled, row.created_at), createdAt: row.created_at, updatedAt: row.updated_at };
81
104
  }
82
105
  function runFromRow(row) {
83
106
  const resources = parseResourceMetadata(row.resource_json);
84
- return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
107
+ const accounting = parseRunAccounting(row.accounting_json);
108
+ return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(accounting ? { accounting } : {}), ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
85
109
  }
86
110
  function mergeResourceMetadata(jobResources, runResources) {
87
111
  if (!jobResources && !runResources)
@@ -106,6 +130,13 @@ function normalizeTokenBudget(value) {
106
130
  throw new Error('tokenBudget must be a positive integer');
107
131
  return value;
108
132
  }
133
+ function normalizeTokenReserve(value) {
134
+ if (value === undefined)
135
+ return undefined;
136
+ if (!Number.isInteger(value) || value < 0)
137
+ throw new Error('tokenReserve must be a non-negative integer');
138
+ return value;
139
+ }
109
140
  function goalStatus(job) {
110
141
  if (job.mode !== 'goal')
111
142
  return undefined;
@@ -213,10 +244,15 @@ function validateJobInput(input) {
213
244
  throw new Error('prompt is required');
214
245
  if (input.target.kind === 'room' && !input.target.roomId.trim())
215
246
  throw new Error('target.roomId is required');
216
- if (input.mode === 'ralph' && input.tokenBudget !== undefined)
217
- throw new Error('tokenBudget is only available for goal mode');
247
+ if (input.mode === 'ralph' && (input.tokenBudget !== undefined || input.tokenReserve !== undefined))
248
+ throw new Error('tokenBudget and tokenReserve are only available for goal mode');
218
249
  normalizeMaxIterations(input.maxIterations);
219
250
  normalizeTokenBudget(input.tokenBudget);
251
+ normalizeTokenReserve(input.tokenReserve);
252
+ if (input.tokenReserve !== undefined && input.tokenBudget === undefined)
253
+ throw new Error('tokenReserve requires tokenBudget');
254
+ if (input.tokenReserve !== undefined && input.tokenBudget !== undefined && input.tokenReserve >= input.tokenBudget)
255
+ throw new Error('tokenReserve must be smaller than tokenBudget');
220
256
  normalizeRuntimeOptions(input);
221
257
  normalizeLoopStopPolicy(input.stopPolicy);
222
258
  normalizeLoopResourceMetadata(input.resources);
@@ -246,10 +282,10 @@ export class PiboLoopStore {
246
282
  const enabled = input.enabled === true;
247
283
  const state = {
248
284
  completedIterations: 0,
249
- ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, timeUsedSeconds: 0 } : {}),
285
+ ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: timestamp } : {}) } : {}),
250
286
  ...(input.initialPiboSessionId?.trim() ? { lastPiboSessionId: input.initialPiboSessionId.trim() } : {}),
251
287
  };
252
- const job = { id: mode === 'ralph' ? `ralph_${randomUUID()}` : `loop_${randomUUID()}`, mode, name: (input.name ?? defaultName(input.prompt)).trim(), description: input.description?.trim() || undefined, enabled, target, profile: input.profile, prompt: input.prompt, maxIterations: normalizeMaxIterations(input.maxIterations), tokenBudget: normalizeTokenBudget(input.tokenBudget), stopPolicy: normalizeLoopStopPolicy(input.stopPolicy), ...runtimeOptions, ...(resources ? { resources } : {}), state, createdAt: timestamp, updatedAt: timestamp };
288
+ const job = { id: mode === 'ralph' ? `ralph_${randomUUID()}` : `loop_${randomUUID()}`, mode, name: (input.name ?? defaultName(input.prompt)).trim(), description: input.description?.trim() || undefined, enabled, target, profile: input.profile, prompt: input.prompt, maxIterations: normalizeMaxIterations(input.maxIterations), tokenBudget: normalizeTokenBudget(input.tokenBudget), tokenReserve: normalizeTokenReserve(input.tokenReserve), stopPolicy: normalizeLoopStopPolicy(input.stopPolicy), ...runtimeOptions, ...(resources ? { resources } : {}), state, createdAt: timestamp, updatedAt: timestamp };
253
289
  this.insertJob(job);
254
290
  return this.getJob(job.id);
255
291
  }
@@ -264,6 +300,10 @@ export class PiboLoopStore {
264
300
  return undefined;
265
301
  const timestamp = nowIso(now);
266
302
  const state = { ...job.state, goalStatus: status, runningAt: job.state.runningAt };
303
+ if (job.state.runningAt)
304
+ delete state.goalEndedAt;
305
+ else
306
+ state.goalEndedAt = timestamp;
267
307
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 0, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, id);
268
308
  return this.getJob(id);
269
309
  }
@@ -272,20 +312,65 @@ export class PiboLoopStore {
272
312
  if (!job || job.mode !== 'goal')
273
313
  return job;
274
314
  const tokens = Math.max(0, Math.floor(input.tokens ?? 0));
275
- const timeUsedSeconds = Math.max(0, Math.floor(input.timeUsedSeconds ?? 0));
315
+ const activeTimeSeconds = Math.max(0, Math.floor(input.activeTimeSeconds ?? 0));
276
316
  const nextTokens = (job.state.tokensUsed ?? 0) + tokens;
277
317
  const currentStatus = goalStatus(job) ?? 'active';
278
318
  const budgetLimited = currentStatus === 'active' && job.tokenBudget !== undefined && nextTokens >= job.tokenBudget;
279
319
  const state = {
280
320
  ...job.state,
281
321
  tokensUsed: nextTokens,
282
- timeUsedSeconds: (job.state.timeUsedSeconds ?? 0) + timeUsedSeconds,
322
+ activeTimeSeconds: (job.state.activeTimeSeconds ?? 0) + activeTimeSeconds,
283
323
  goalStatus: budgetLimited ? 'budget_limited' : currentStatus,
284
324
  };
285
325
  const timestamp = nowIso(now);
326
+ if (budgetLimited && !job.state.runningAt)
327
+ state.goalEndedAt = timestamp;
286
328
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(budgetLimited ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, id);
287
329
  return this.getJob(id);
288
330
  }
331
+ recordGoalTurnUsage(id, runId, tokens, now = new Date()) {
332
+ this.db.exec('BEGIN IMMEDIATE');
333
+ try {
334
+ const job = this.recordGoalProgress(id, { tokens }, now);
335
+ if (job?.mode === 'goal') {
336
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
337
+ if (row) {
338
+ const accounting = parseRunAccounting(row.accounting_json) ?? {};
339
+ const turnTokens = (accounting.tokensUsed ?? 0) + Math.max(0, Math.floor(tokens));
340
+ const budget = accounting.tokenBudget;
341
+ const before = accounting.tokensUsedBefore ?? 0;
342
+ const nextAccounting = { ...accounting, tokensUsed: turnTokens, ...(budget !== undefined ? { overshootTokens: Math.max(0, before + turnTokens - budget) } : {}) };
343
+ this.db.prepare('UPDATE pibo_ralph_runs SET accounting_json = ?, updated_at = ? WHERE id = ?').run(runAccountingJson(nextAccounting), nowIso(now), runId);
344
+ }
345
+ }
346
+ this.db.exec('COMMIT');
347
+ return job;
348
+ }
349
+ catch (error) {
350
+ this.db.exec('ROLLBACK');
351
+ throw error;
352
+ }
353
+ }
354
+ recordGoalRunTime(id, runId, activeTimeSeconds, now = new Date()) {
355
+ const seconds = Math.max(0, Math.floor(activeTimeSeconds));
356
+ this.db.exec('BEGIN IMMEDIATE');
357
+ try {
358
+ const job = this.recordGoalProgress(id, { activeTimeSeconds: seconds }, now);
359
+ if (job?.mode === 'goal') {
360
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
361
+ if (row) {
362
+ const accounting = { ...(parseRunAccounting(row.accounting_json) ?? {}), activeTimeSeconds: seconds };
363
+ this.db.prepare('UPDATE pibo_ralph_runs SET accounting_json = ?, updated_at = ? WHERE id = ?').run(runAccountingJson(accounting), nowIso(now), runId);
364
+ }
365
+ }
366
+ this.db.exec('COMMIT');
367
+ return job;
368
+ }
369
+ catch (error) {
370
+ this.db.exec('ROLLBACK');
371
+ throw error;
372
+ }
373
+ }
289
374
  listJobs(input = {}) {
290
375
  const clauses = [];
291
376
  const values = [];
@@ -309,16 +394,19 @@ export class PiboLoopStore {
309
394
  const enabled = patch.enabled ?? existing.enabled;
310
395
  let state = mode === existing.mode
311
396
  ? { ...existing.state }
312
- : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, timeUsedSeconds: 0 } : {}) };
397
+ : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: nowIso(now) } : {}) } : {}) };
313
398
  if (mode === 'goal' && patch.enabled !== undefined) {
314
399
  const currentGoalStatus = goalStatus({ mode, enabled: existing.enabled, state: existing.state }) ?? 'paused';
315
400
  if (patch.enabled) {
316
401
  if (currentGoalStatus === 'complete')
317
402
  throw new Error('Completed goals cannot be restarted; create a new goal');
318
403
  const nextBudget = hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget;
319
- if (currentGoalStatus === 'budget_limited' && nextBudget !== undefined && (existing.state.tokensUsed ?? 0) >= nextBudget)
320
- throw new Error('Increase or clear the token budget before resuming this goal');
404
+ const nextReserve = hasOwn(patch, 'tokenReserve') ? normalizeTokenReserve(patch.tokenReserve ?? undefined) : existing.tokenReserve;
405
+ if (currentGoalStatus === 'budget_limited' && nextBudget !== undefined && (existing.state.tokensUsed ?? 0) + (nextReserve ?? 0) >= nextBudget)
406
+ throw new Error('Increase or clear the token budget, or lower the token reserve, before resuming this goal');
321
407
  state.goalStatus = 'active';
408
+ state.goalStartedAt ??= nowIso(now);
409
+ delete state.goalEndedAt;
322
410
  state.stopRequestedAt = undefined;
323
411
  state.cancelRequestedAt = undefined;
324
412
  }
@@ -326,7 +414,7 @@ export class PiboLoopStore {
326
414
  state.goalStatus = currentGoalStatus === 'active' ? 'paused' : currentGoalStatus;
327
415
  }
328
416
  }
329
- const next = { ...existing, mode, state, name: patch.name !== undefined ? patch.name.trim() : existing.name, description: patch.description !== undefined ? patch.description?.trim() || undefined : existing.description, enabled, target, profile: patch.profile ?? existing.profile, prompt: patch.prompt ?? existing.prompt, maxIterations: hasOwn(patch, 'maxIterations') ? normalizeMaxIterations(patch.maxIterations ?? undefined) : existing.maxIterations, tokenBudget: mode === 'ralph' ? undefined : hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget, stopPolicy, modelOverride: runtimeOptions.modelOverride, thinkingLevel: runtimeOptions.thinkingLevel, fastMode: runtimeOptions.fastMode, updatedAt: nowIso(now) };
417
+ const next = { ...existing, mode, state, name: patch.name !== undefined ? patch.name.trim() : existing.name, description: patch.description !== undefined ? patch.description?.trim() || undefined : existing.description, enabled, target, profile: patch.profile ?? existing.profile, prompt: patch.prompt ?? existing.prompt, maxIterations: hasOwn(patch, 'maxIterations') ? normalizeMaxIterations(patch.maxIterations ?? undefined) : existing.maxIterations, tokenBudget: mode === 'ralph' ? undefined : hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget, tokenReserve: mode === 'ralph' || (hasOwn(patch, 'tokenBudget') && patch.tokenBudget === null && !hasOwn(patch, 'tokenReserve')) ? undefined : hasOwn(patch, 'tokenReserve') ? normalizeTokenReserve(patch.tokenReserve ?? undefined) : existing.tokenReserve, stopPolicy, modelOverride: runtimeOptions.modelOverride, thinkingLevel: runtimeOptions.thinkingLevel, fastMode: runtimeOptions.fastMode, updatedAt: nowIso(now) };
330
418
  validateJobInput(next);
331
419
  this.writeJob(next);
332
420
  return this.getJob(id);
@@ -418,7 +506,7 @@ export class PiboLoopStore {
418
506
  const reachedMaxIterations = job.maxIterations !== undefined && completedIterations >= job.maxIterations;
419
507
  const terminalGoalStatus = job.mode === 'goal' && ['complete', 'blocked', 'budget_limited'].includes(goalStatus(job) ?? '');
420
508
  const shouldDisable = terminalGoalStatus || reachedMaxIterations || input.stopAfterRun === true || input.stopEvaluation?.finalAction === 'stop-after-run' || input.stopEvaluation?.finalAction === 'cancel-current-run';
421
- const state = { ...job.state, runningAt: undefined, completedIterations, lastRunAt: timestamp, lastRunId: input.runId, lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok', lastError: input.error, lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId, consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0, conditionStates: input.conditionStates ?? job.state.conditionStates, lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation };
509
+ const state = { ...job.state, runningAt: undefined, completedIterations, lastRunAt: timestamp, lastRunId: input.runId, lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok', lastError: input.error, lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId, consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0, conditionStates: input.conditionStates ?? job.state.conditionStates, lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation, ...(terminalGoalStatus ? { goalEndedAt: job.state.goalEndedAt ?? timestamp } : {}) };
422
510
  this.db.prepare('UPDATE pibo_ralph_runs SET status = ?, pibo_session_id = COALESCE(?, pibo_session_id), reason = ?, error = ?, completed_at = ?, updated_at = ? WHERE id = ?').run(input.status, input.piboSessionId ?? null, input.reason ?? input.stopEvaluation?.reason ?? null, input.error ?? null, timestamp, timestamp, input.runId);
423
511
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(shouldDisable ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, job.id);
424
512
  }
@@ -486,6 +574,15 @@ export class PiboLoopStore {
486
574
  this.db.exec('COMMIT');
487
575
  return undefined;
488
576
  }
577
+ if (job.mode === 'goal' && job.tokenBudget !== undefined) {
578
+ const remaining = Math.max(0, job.tokenBudget - (job.state.tokensUsed ?? 0));
579
+ if (remaining <= (job.tokenReserve ?? 0)) {
580
+ const state = { ...job.state, goalStatus: 'budget_limited', goalEndedAt: timestamp };
581
+ this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 0, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, job.id);
582
+ this.db.exec('COMMIT');
583
+ return undefined;
584
+ }
585
+ }
489
586
  const run = this.createRunLocked(job, timestamp);
490
587
  const state = { ...job.state, runningAt: timestamp, lastRunAt: timestamp, lastRunId: run.id };
491
588
  this.updateJobStateLocked(job.id, state, timestamp);
@@ -502,13 +599,15 @@ export class PiboLoopStore {
502
599
  this.ensureJobColumn('loop_mode', "TEXT NOT NULL DEFAULT 'ralph'");
503
600
  this.ensureJobColumn('max_iterations', 'INTEGER');
504
601
  this.ensureJobColumn('token_budget', 'INTEGER');
602
+ this.ensureJobColumn('token_reserve', 'INTEGER');
505
603
  this.ensureJobColumn('runtime_options_json', 'TEXT');
506
604
  this.ensureJobColumn('stop_policy_json', 'TEXT');
507
605
  this.ensureJobColumn('resource_json', 'TEXT');
508
606
  this.ensureRunColumn('resource_json', 'TEXT');
607
+ this.ensureRunColumn('accounting_json', 'TEXT');
509
608
  }
510
609
  createFreshSchema() {
511
- this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
610
+ this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, token_reserve INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, accounting_json TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
512
611
  }
513
612
  ensureJobColumn(name, definition) {
514
613
  const columns = this.tableColumns('pibo_ralph_jobs');
@@ -523,9 +622,21 @@ export class PiboLoopStore {
523
622
  tableColumns(tableName) {
524
623
  return new Set(this.db.prepare(`PRAGMA table_info(${tableName})`).all().map((column) => column.name));
525
624
  }
526
- insertJob(job) { this.db.prepare('INSERT INTO pibo_ralph_jobs (id, loop_mode, name, description, enabled, target_json, profile, prompt, max_iterations, token_budget, runtime_options_json, stop_policy_json, resource_json, state_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(job.id, job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.createdAt, job.updatedAt); }
527
- writeJob(job) { this.db.prepare('UPDATE pibo_ralph_jobs SET loop_mode = ?, name = ?, description = ?, enabled = ?, target_json = ?, profile = ?, prompt = ?, max_iterations = ?, token_budget = ?, runtime_options_json = ?, stop_policy_json = ?, resource_json = ?, state_json = ?, updated_at = ? WHERE id = ?').run(job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.updatedAt, job.id); }
625
+ insertJob(job) { this.db.prepare('INSERT INTO pibo_ralph_jobs (id, loop_mode, name, description, enabled, target_json, profile, prompt, max_iterations, token_budget, token_reserve, runtime_options_json, stop_policy_json, resource_json, state_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(job.id, job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, job.tokenReserve ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.createdAt, job.updatedAt); }
626
+ writeJob(job) { this.db.prepare('UPDATE pibo_ralph_jobs SET loop_mode = ?, name = ?, description = ?, enabled = ?, target_json = ?, profile = ?, prompt = ?, max_iterations = ?, token_budget = ?, token_reserve = ?, runtime_options_json = ?, stop_policy_json = ?, resource_json = ?, state_json = ?, updated_at = ? WHERE id = ?').run(job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, job.tokenReserve ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.updatedAt, job.id); }
528
627
  updateJobStateLocked(id, state, updatedAt) { this.db.prepare('UPDATE pibo_ralph_jobs SET state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), updatedAt, id); }
529
- createRunLocked(job, timestamp) { const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', startedAt: timestamp, ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp }; this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt); return run; }
628
+ createRunLocked(job, timestamp) {
629
+ const tokensUsedBefore = job.state.tokensUsed ?? 0;
630
+ const accounting = job.mode === 'goal' ? {
631
+ ...(job.tokenBudget !== undefined ? { tokenBudget: job.tokenBudget, remainingTokensBefore: Math.max(0, job.tokenBudget - tokensUsedBefore) } : {}),
632
+ ...(job.tokenReserve !== undefined ? { tokenReserve: job.tokenReserve } : {}),
633
+ tokensUsedBefore,
634
+ tokensUsed: 0,
635
+ overshootTokens: 0,
636
+ } : undefined;
637
+ const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', startedAt: timestamp, ...(accounting ? { accounting } : {}), ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp };
638
+ this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, accounting_json, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, runAccountingJson(run.accounting), resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt);
639
+ return run;
640
+ }
530
641
  }
531
642
  export function createDefaultPiboLoopStore(options = {}) { return new PiboLoopStore(options); }
@@ -1,5 +1,6 @@
1
1
  import { StringEnum, Type } from '@earendil-works/pi-ai';
2
2
  import { defineTool } from '@earendil-works/pi-coding-agent';
3
+ import { goalActiveTimeSeconds, goalCanStartNextTurn, goalElapsedWallClockSeconds, goalRemainingTokens } from './accounting.js';
3
4
  import { createDefaultPiboLoopStore } from './store.js';
4
5
  export const PIBO_GOAL_TOOL_NAMES = ['get_goal', 'create_goal', 'update_goal'];
5
6
  let configuredStorePath;
@@ -33,17 +34,30 @@ function positiveInteger(value, field) {
33
34
  throw new Error(`${field} must be a positive integer`);
34
35
  return value;
35
36
  }
37
+ function nonNegativeInteger(value, field) {
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (!Number.isInteger(value) || value < 0)
41
+ throw new Error(`${field} must be a non-negative integer`);
42
+ return value;
43
+ }
36
44
  function goalPayload(job) {
37
- const tokensUsed = job.state.tokensUsed ?? 0;
38
45
  const tokenBudget = job.tokenBudget;
39
46
  return {
40
47
  goalId: job.id,
41
48
  objective: job.prompt,
42
49
  status: effectiveGoalStatus(job),
50
+ budgetType: tokenBudget === undefined ? 'unbounded' : 'soft',
43
51
  tokenBudget: tokenBudget ?? null,
44
- tokensUsed,
45
- remainingTokens: tokenBudget === undefined ? null : Math.max(0, tokenBudget - tokensUsed),
46
- timeUsedSeconds: job.state.timeUsedSeconds ?? 0,
52
+ tokenReserve: job.tokenReserve ?? 0,
53
+ tokensUsed: job.state.tokensUsed ?? 0,
54
+ remainingTokens: goalRemainingTokens(job) ?? null,
55
+ canStartNextTurn: goalCanStartNextTurn(job),
56
+ activeAgentTimeSeconds: goalActiveTimeSeconds(job),
57
+ elapsedWallClockSeconds: goalElapsedWallClockSeconds(job),
58
+ goalStartedAt: job.state.goalStartedAt ?? null,
59
+ goalEndedAt: job.state.goalEndedAt ?? null,
60
+ wallClockIncludesPausedTime: true,
47
61
  };
48
62
  }
49
63
  function effectiveGoalStatus(job) {
@@ -64,7 +78,7 @@ function createGetGoalTool(context, options) {
64
78
  return defineTool({
65
79
  name: 'get_goal',
66
80
  label: 'Get Goal',
67
- description: 'Get the current goal for this Pibo Session, including status, token budget, consumed tokens, remaining tokens, and elapsed active time.',
81
+ description: 'Get the current goal for this Pibo Session, including soft-budget risk, per-turn reserve, active agent time, and wall-clock elapsed time.',
68
82
  promptSnippet: 'Use get_goal when you need the authoritative persisted status or accounting for the current Pibo Session goal.',
69
83
  parameters: Type.Object({}),
70
84
  async execute() {
@@ -89,7 +103,8 @@ function createCreateGoalTool(context, options) {
89
103
  promptSnippet: 'Call create_goal only when the user or system explicitly requests a persistent goal. Do not infer a goal from an ordinary task.',
90
104
  parameters: Type.Object({
91
105
  objective: Type.String({ description: 'Concrete objective to pursue across automatic continuations.' }),
92
- token_budget: Type.Optional(Type.Number({ description: 'Optional positive token budget. Omit unless explicitly requested.' })),
106
+ token_budget: Type.Optional(Type.Number({ description: 'Optional soft token budget. Usage arrives after each model response, so the final turn can overshoot.' })),
107
+ token_reserve: Type.Optional(Type.Number({ description: 'Optional non-negative minimum remaining tokens required before Pibo starts another turn.' })),
93
108
  }),
94
109
  async execute(_toolCallId, params) {
95
110
  try {
@@ -98,6 +113,7 @@ function createCreateGoalTool(context, options) {
98
113
  if (!objective)
99
114
  throw new Error('objective is required');
100
115
  const tokenBudget = positiveInteger(params.token_budget, 'token_budget');
116
+ const tokenReserve = nonNegativeInteger(params.token_reserve, 'token_reserve');
101
117
  return await withStore(options, (store) => {
102
118
  const existing = store.getLatestGoalForSession(session.piboSessionId);
103
119
  if (existing && effectiveGoalStatus(existing) !== 'complete') {
@@ -110,6 +126,7 @@ function createCreateGoalTool(context, options) {
110
126
  profile: session.profileName,
111
127
  prompt: objective,
112
128
  tokenBudget,
129
+ tokenReserve,
113
130
  initialPiboSessionId: session.piboSessionId,
114
131
  });
115
132
  return toolResult({ ok: true, goal: goalPayload(job) });
@@ -147,7 +164,7 @@ function createUpdateGoalTool(context, options) {
147
164
  ok: true,
148
165
  goal: goalPayload(job),
149
166
  ...(status === 'complete' && job.tokenBudget !== undefined
150
- ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed before the current model turn finishes` }
167
+ ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed against a soft budget before the current model turn finishes` }
151
168
  : {}),
152
169
  });
153
170
  });
@@ -30,6 +30,11 @@ function migratePiboRunControllerColumn(db) {
30
30
  db.exec("DROP INDEX IF EXISTS idx_pibo_runs_" + ["o", "wner_updated"].join(""));
31
31
  db.exec(`ALTER TABLE pibo_runs RENAME COLUMN ${legacyColumn} TO controller_pibo_session_id`);
32
32
  }
33
+ function ensurePiboRunColumn(db, name, definition) {
34
+ const columns = sqliteTableColumns(db, "pibo_runs");
35
+ if (!columns.has(name))
36
+ db.exec(`ALTER TABLE pibo_runs ADD COLUMN ${name} ${definition}`);
37
+ }
33
38
  export class PiboReliabilityStore {
34
39
  db;
35
40
  appendEventStatement;
@@ -135,13 +140,21 @@ export class PiboReliabilityStore {
135
140
  completed_at TEXT,
136
141
  job_id TEXT,
137
142
  retryable INTEGER NOT NULL DEFAULT 0,
138
- max_attempts INTEGER NOT NULL DEFAULT 1
143
+ max_attempts INTEGER NOT NULL DEFAULT 1,
144
+ timeout_ms INTEGER,
145
+ timeout_at TEXT,
146
+ timeout_phase TEXT,
147
+ service_warning TEXT
139
148
  );
140
149
  CREATE INDEX IF NOT EXISTS idx_pibo_runs_controller_updated
141
150
  ON pibo_runs(controller_pibo_session_id, updated_at);
142
151
  CREATE INDEX IF NOT EXISTS idx_pibo_runs_status
143
152
  ON pibo_runs(status);
144
153
  `);
154
+ ensurePiboRunColumn(this.db, "timeout_ms", "INTEGER");
155
+ ensurePiboRunColumn(this.db, "timeout_at", "TEXT");
156
+ ensurePiboRunColumn(this.db, "timeout_phase", "TEXT");
157
+ ensurePiboRunColumn(this.db, "service_warning", "TEXT");
145
158
  this.appendEventStatement = this.db.prepare(`
146
159
  INSERT INTO pibo_event_stream (topic, key, event_id, idempotency_key, created_at, retention_class, payload_json)
147
160
  VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -466,6 +479,7 @@ export class PiboReliabilityStore {
466
479
  const timestamp = now();
467
480
  const runId = input.runId ?? `run_${randomUUID()}`;
468
481
  const maxAttempts = Math.max(1, input.maxAttempts ?? 1);
482
+ const timeoutAt = input.timeoutMs === undefined ? undefined : new Date(Date.parse(timestamp) + input.timeoutMs).toISOString();
469
483
  const job = this.enqueue({
470
484
  queue: "runs",
471
485
  payload: {
@@ -473,6 +487,7 @@ export class PiboReliabilityStore {
473
487
  controllerPiboSessionId: input.controllerPiboSessionId,
474
488
  toolName: input.toolName,
475
489
  params: input.params,
490
+ timeoutMs: input.timeoutMs,
476
491
  },
477
492
  maxAttempts,
478
493
  });
@@ -481,10 +496,10 @@ export class PiboReliabilityStore {
481
496
  INSERT INTO pibo_runs (
482
497
  run_id, kind, controller_pibo_session_id, status, completion_policy, consumed, tool_name,
483
498
  summary, result_json, error, notified_status, acknowledged_status, created_at, updated_at,
484
- completed_at, job_id, retryable, max_attempts
485
- ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?)
499
+ completed_at, job_id, retryable, max_attempts, timeout_ms, timeout_at, timeout_phase, service_warning
500
+ ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?)
486
501
  `)
487
- .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts);
502
+ .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts, input.timeoutMs ?? null, timeoutAt ?? null, input.serviceWarning ?? null);
488
503
  this.claimJob(job.jobId, `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
489
504
  return this.requireRun(runId);
490
505
  }
@@ -508,10 +523,14 @@ export class PiboReliabilityStore {
508
523
  completed_at = ?,
509
524
  job_id = ?,
510
525
  retryable = ?,
511
- max_attempts = ?
526
+ max_attempts = ?,
527
+ timeout_ms = ?,
528
+ timeout_at = ?,
529
+ timeout_phase = ?,
530
+ service_warning = ?
512
531
  WHERE run_id = ?
513
532
  `)
514
- .run(next.status, next.completionPolicy, next.consumed ? 1 : 0, next.summary ?? null, next.result ? JSON.stringify(next.result) : null, next.error ?? null, next.notifiedStatus ?? null, next.acknowledgedStatus ?? null, next.updatedAt, next.completedAt ?? null, next.jobId ?? null, next.retryable ? 1 : 0, next.maxAttempts, runId);
533
+ .run(next.status, next.completionPolicy, next.consumed ? 1 : 0, next.summary ?? null, next.result ? JSON.stringify(next.result) : null, next.error ?? null, next.notifiedStatus ?? null, next.acknowledgedStatus ?? null, next.updatedAt, next.completedAt ?? null, next.jobId ?? null, next.retryable ? 1 : 0, next.maxAttempts, next.timeoutMs ?? null, next.timeoutAt ?? null, next.timeoutPhase ?? null, next.serviceWarning ?? null, runId);
515
534
  return this.requireRun(runId);
516
535
  }
517
536
  getRun(runId) {
@@ -535,7 +554,7 @@ export class PiboReliabilityStore {
535
554
  }
536
555
  pruneRuns(input) {
537
556
  const rows = this.db
538
- .prepare("SELECT * FROM pibo_runs WHERE status IN ('completed', 'failed', 'cancelled') AND completed_at IS NOT NULL")
557
+ .prepare("SELECT * FROM pibo_runs WHERE status IN ('completed', 'failed', 'timed_out', 'cancelled') AND completed_at IS NOT NULL")
539
558
  .all();
540
559
  const ids = rows
541
560
  .map(runFromRow)
@@ -760,6 +779,14 @@ function runFromRow(row) {
760
779
  output.completedAt = row.completed_at;
761
780
  if (row.job_id)
762
781
  output.jobId = row.job_id;
782
+ if (row.timeout_ms !== null)
783
+ output.timeoutMs = row.timeout_ms;
784
+ if (row.timeout_at)
785
+ output.timeoutAt = row.timeout_at;
786
+ if (row.timeout_phase === "startup" || row.timeout_phase === "lifetime")
787
+ output.timeoutPhase = row.timeout_phase;
788
+ if (row.service_warning)
789
+ output.serviceWarning = row.service_warning;
763
790
  return output;
764
791
  }
765
792
  function retryDelayMs(attempts, input) {
@@ -1,3 +1,4 @@
1
+ import { isAbsolute, resolve } from "node:path";
1
2
  import { Command } from "commander";
2
3
  import { getComputeResourceHealth } from "../compute/resource-health.js";
3
4
  import { renderComputeResourceHealthText } from "../compute/cli.js";
@@ -17,6 +18,15 @@ function parsePidList(value) {
17
18
  throw new Error("PIDs must be positive integers separated by commas");
18
19
  return [...new Set(pids)];
19
20
  }
21
+ function parseAbsolutePathList(value) {
22
+ const paths = value.split(",").map((item) => item.trim());
23
+ if (paths.some((path) => !path || !isAbsolute(path)))
24
+ throw new Error("Browser user-data directories must be absolute paths separated by commas");
25
+ return [...new Set(paths.map((path) => resolve(path)))];
26
+ }
27
+ function shellQuote(value) {
28
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
29
+ }
20
30
  export function renderResourceLeasesText(leases) {
21
31
  if (leases.length === 0)
22
32
  return "No active managed browser-pool leases.\nNext: pibo resources status";
@@ -53,6 +63,10 @@ export function renderResourceReapText(value) {
53
63
  ];
54
64
  if (plan.options.includeDev)
55
65
  args.push("--include-dev");
66
+ if (plan.options.exemptBrowserPids.length > 0)
67
+ args.push(`--exempt-browser-pids ${plan.options.exemptBrowserPids.join(",")}`);
68
+ if (plan.options.exemptBrowserUserDataDirs.length > 0)
69
+ args.push(`--exempt-browser-user-data-dirs ${shellQuote(plan.options.exemptBrowserUserDataDirs.join(","))}`);
56
70
  if (plan.options.browserPoolRoot)
57
71
  args.push(`--browser-pool-root ${plan.options.browserPoolRoot}`);
58
72
  if (plan.options.browserUseHome)
@@ -118,6 +132,7 @@ export async function runResourcesCli(argv) {
118
132
  .option("--idle-timeout-minutes <n>", "Select browser pools idle for this many minutes", parseNonNegativeNumber, 10)
119
133
  .option("--unmanaged-browser-grace-minutes <n>", "Select unmanaged Chromium older than this many minutes", parseNonNegativeNumber, 10)
120
134
  .option("--exempt-browser-pids <list>", "Comma-separated browser PIDs or process groups to preserve", parsePidList)
135
+ .option("--exempt-browser-user-data-dirs <list>", "Comma-separated absolute browser profile directories to preserve", parseAbsolutePathList)
121
136
  .option("--browser-pool-root <path>", "Browser pool root directory to scan")
122
137
  .option("--browser-use-home <path>", "Browser-use home directory to scan for stale CDP files")
123
138
  .option("--json", "Print machine-readable cleanup plan or result")
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir, readFile, rm } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { applyComputeWorkerReapPlan, buildComputeWorkerReapPlan, planReapWorkers, } from "../compute/docker.js";
7
7
  import { defaultBrowserPoolRoot, defaultBrowserUseHome, getComputeResourceHealth, parseProcessList, } from "../compute/resource-health.js";
@@ -60,9 +60,14 @@ export async function planResourceReap(options = {}) {
60
60
  collectManagedBrowserPools(resolved.browserPoolRoot),
61
61
  planStaleCdpFiles(resolved.browserUseHome),
62
62
  planComputeReapSafely({ includeDev: resolved.includeDev, maxAgeMinutes: resolved.maxAgeMinutes, now }),
63
- getComputeResourceHealth({ now, browserPoolRoot: resolved.browserPoolRoot, browserUseHome: resolved.browserUseHome }),
63
+ getComputeResourceHealth({
64
+ now,
65
+ browserPoolRoot: resolved.browserPoolRoot,
66
+ browserUseHome: resolved.browserUseHome,
67
+ exemptBrowserUserDataDirs: [],
68
+ }),
64
69
  ]);
65
- const unmanagedBrowsers = buildUnmanagedBrowserPlanItems(health.browserProcesses.unassignedMainProcessDetails, resolved.unmanagedBrowserGraceMinutes, new Set(resolved.exemptBrowserPids));
70
+ const unmanagedBrowsers = buildUnmanagedBrowserPlanItems(health.browserProcesses.unassignedMainProcessDetails, resolved.unmanagedBrowserGraceMinutes, new Set(resolved.exemptBrowserPids), new Set(resolved.exemptBrowserUserDataDirs));
66
71
  return buildResourceReapPlan({ now, options: resolved, records, staleFiles, unmanagedBrowsers, compute });
67
72
  }
68
73
  export function buildResourceReapPlan(input) {
@@ -126,9 +131,10 @@ function resolveReapOptions(options) {
126
131
  browserPoolRoot: options.browserPoolRoot ?? defaultBrowserPoolRoot(),
127
132
  browserUseHome: options.browserUseHome ?? defaultBrowserUseHome(),
128
133
  exemptBrowserPids: options.exemptBrowserPids ?? readExemptBrowserPids(),
134
+ exemptBrowserUserDataDirs: normalizeBrowserUserDataDirs(options.exemptBrowserUserDataDirs ?? readExemptBrowserUserDataDirs()),
129
135
  };
130
136
  }
131
- export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPids = new Set()) {
137
+ export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPids = new Set(), exemptUserDataDirs = new Set()) {
132
138
  const graceSeconds = graceMinutes * 60;
133
139
  return processes.map((process) => {
134
140
  let action = "terminate";
@@ -141,6 +147,10 @@ export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPi
141
147
  action = "skip";
142
148
  reason = "explicitly exempted pid or process group";
143
149
  }
150
+ else if (browserUserDataDirIsExempt(process.userDataDir, exemptUserDataDirs)) {
151
+ action = "skip";
152
+ reason = "explicitly exempted browser user-data-dir";
153
+ }
144
154
  else if (process.elapsedSeconds !== undefined && process.elapsedSeconds < graceSeconds) {
145
155
  action = "skip";
146
156
  reason = `process age ${process.elapsedSeconds}s is within ${graceSeconds}s grace period`;
@@ -211,6 +221,20 @@ function readExemptBrowserPids() {
211
221
  .map((value) => Number.parseInt(value.trim(), 10))
212
222
  .filter((value) => Number.isInteger(value) && value > 0);
213
223
  }
224
+ function readExemptBrowserUserDataDirs() {
225
+ return (process.env.PIBO_RESOURCE_REAPER_EXEMPT_BROWSER_USER_DATA_DIRS ?? "").split(",");
226
+ }
227
+ function normalizeBrowserUserDataDirs(values) {
228
+ return [...new Set([...values]
229
+ .map((value) => value.trim())
230
+ .filter((value) => value.length > 0 && isAbsolute(value))
231
+ .map((value) => resolve(value)))];
232
+ }
233
+ function browserUserDataDirIsExempt(userDataDir, exemptions) {
234
+ if (!userDataDir || !isAbsolute(userDataDir))
235
+ return false;
236
+ return exemptions.has(resolve(userDataDir));
237
+ }
214
238
  function buildBrowserReapPlanItem(record, now, idleTimeoutMinutes) {
215
239
  const { state } = record;
216
240
  let action = "skip";
@@ -76,6 +76,7 @@ export class ResourceReaperService {
76
76
  browserPoolRoot: this.options.browserPoolRoot,
77
77
  browserUseHome: this.options.browserUseHome,
78
78
  exemptBrowserPids: this.options.exemptBrowserPids,
79
+ exemptBrowserUserDataDirs: this.options.exemptBrowserUserDataDirs,
79
80
  now: runAt,
80
81
  });
81
82
  result = await this.apply(plan);