@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
@@ -0,0 +1,25 @@
1
+ export function resolveDebugTraceSessionStatus(sessionStatus, eventTypes) {
2
+ for (let index = eventTypes.length - 1; index >= 0; index -= 1) {
3
+ switch (eventTypes[index]) {
4
+ case "session_error":
5
+ return { status: "error", source: "event-log" };
6
+ case "message_started":
7
+ return { status: "running", source: "event-log" };
8
+ case "message_finished":
9
+ return { status: "idle", source: "event-log" };
10
+ }
11
+ }
12
+ return {
13
+ status: sessionStatus === "running" || sessionStatus === "error" ? sessionStatus : "idle",
14
+ source: "session-store",
15
+ };
16
+ }
17
+ export function summarizeDebugTraceStatus(sessionStatus, nodeStatuses) {
18
+ const errorNodeCount = nodeStatuses.filter((status) => status === "error").length;
19
+ if (sessionStatus === "error")
20
+ return { status: "error", errorNodeCount };
21
+ if (sessionStatus === "running" || nodeStatuses.some((status) => status === "running")) {
22
+ return { status: "running", errorNodeCount };
23
+ }
24
+ return { status: "done", errorNodeCount };
25
+ }
@@ -1,8 +1,9 @@
1
1
  import { buildTraceView } from "../apps/chat/trace.js";
2
2
  import { normalizeSessionErrorDetails } from "../core/session-errors.js";
3
- import { compareTraceOrder } from "../shared/trace-order.js";
3
+ import { compareTraceNodes } from "../shared/trace-nodes.js";
4
4
  import { openReadOnlyDebugDatabase, withStorePath } from "./sql.js";
5
5
  import { formatNextCommands } from "./next-commands.js";
6
+ import { resolveDebugTraceSessionStatus, summarizeDebugTraceStatus } from "./trace-status.js";
6
7
  export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
7
8
  if (!stores.sessions.exists)
8
9
  throw new Error(`Debug store "sessions" not found at ${stores.sessions.path}`);
@@ -16,27 +17,32 @@ export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
16
17
  throw new Error(`Pibo session "${piboSessionId}" not found`);
17
18
  const session = sessionFromRow(sessionRow);
18
19
  const sessions = sessionsDb.prepare("SELECT * FROM sessions").all().map(sessionFromRow);
20
+ const adapterIssues = [];
19
21
  const events = tableExists(chatDb, "event_log")
20
22
  ? chatDb
21
23
  .prepare("SELECT stream_id, session_id, session_sequence, event_id, type, created_at, preview_text, attributes_json FROM event_log WHERE session_id = ? ORDER BY stream_id ASC")
22
- .all(piboSessionId).map(eventFromRow).filter((event) => event !== undefined)
24
+ .all(piboSessionId).map((row) => eventFromRow(row, adapterIssues)).filter((event) => event !== undefined)
23
25
  : [];
26
+ const sessionStatus = resolveDebugTraceSessionStatus(sessionRow.status, events.map((event) => event.type));
24
27
  const view = await buildTraceView({
25
28
  session,
26
29
  sessions,
27
30
  events,
28
- status: sessionRow.status === "running" || sessionRow.status === "error" ? sessionRow.status : "idle",
31
+ status: sessionStatus.status,
29
32
  });
30
33
  const rows = flattenTraceNodes(view.nodes);
34
+ const statusSummary = summarizeDebugTraceStatus(sessionStatus.status, rows.map((node) => node.status));
31
35
  const filtered = options.runningOnly ? rows.filter((node) => node.status === "running") : rows;
32
36
  return {
33
37
  piboSessionId: view.piboSessionId,
34
38
  piSessionId: view.piSessionId,
35
39
  title: view.title,
36
- status: traceStatus(view),
40
+ status: statusSummary.status,
41
+ statusSource: sessionStatus.source,
42
+ errorNodeCount: statusSummary.errorNodeCount,
37
43
  nodes: filtered,
38
44
  rawNodeCount: rows.length,
39
- ...(options.check ? { checks: checkTraceView(view) } : {}),
45
+ ...(options.check ? { checks: checkTraceView(view, adapterIssues) } : {}),
40
46
  nextCommands: buildTraceNextCommands(view.piboSessionId, filtered),
41
47
  };
42
48
  }
@@ -68,6 +74,8 @@ export function formatDebugTrace(result, options = {}) {
68
74
  `piSessionId: ${result.piSessionId}`,
69
75
  `title: ${result.title}`,
70
76
  `status: ${result.status}`,
77
+ `statusSource: ${result.statusSource}`,
78
+ `nodeErrors: ${result.errorNodeCount}`,
71
79
  "",
72
80
  ];
73
81
  if (result.nodes.length === 0) {
@@ -160,18 +168,11 @@ function flattenTraceNodes(nodes, depth = 0) {
160
168
  ...flattenTraceNodes(node.children, depth + 1),
161
169
  ]);
162
170
  }
163
- function traceStatus(view) {
164
- const rows = flattenTraceNodes(view.nodes);
165
- if (rows.some((node) => node.status === "error"))
166
- return "error";
167
- if (rows.some((node) => node.status === "running"))
168
- return "running";
169
- return "done";
170
- }
171
- function checkTraceView(view) {
172
- const issues = [];
171
+ export function checkTraceView(view, adapterIssues = []) {
172
+ const issues = [...adapterIssues];
173
173
  const all = flattenPiboTraceNodes(view.nodes);
174
174
  const ids = new Set();
175
+ const stableKeyOwners = new Map();
175
176
  for (const node of all) {
176
177
  if (ids.has(node.id)) {
177
178
  issues.push({
@@ -206,6 +207,20 @@ function checkTraceView(view) {
206
207
  message: "Trace node has no conceptual stable key.",
207
208
  });
208
209
  }
210
+ else {
211
+ const existingOwner = stableKeyOwners.get(node.stableKey);
212
+ if (existingOwner && existingOwner !== node.id) {
213
+ issues.push({
214
+ severity: "warning",
215
+ code: "duplicate_stable_key",
216
+ nodeId: node.id,
217
+ message: `Stable key "${node.stableKey}" is already used by node "${existingOwner}".`,
218
+ });
219
+ }
220
+ else {
221
+ stableKeyOwners.set(node.stableKey, node.id);
222
+ }
223
+ }
209
224
  }
210
225
  for (const node of all) {
211
226
  if (node.parentId && !ids.has(node.parentId)) {
@@ -235,7 +250,7 @@ function checkSiblingOrder(nodes, issues) {
235
250
  checkSiblingOrder(node.children, issues);
236
251
  }
237
252
  function compareOrder(left, right) {
238
- return compareTraceOrder(left.orderKey, right.orderKey) || left.id.localeCompare(right.id);
253
+ return compareTraceNodes(left, right);
239
254
  }
240
255
  function flattenPiboTraceNodes(nodes) {
241
256
  return nodes.flatMap((node) => [node, ...flattenPiboTraceNodes(node.children)]);
@@ -288,10 +303,18 @@ function sessionFromRow(row) {
288
303
  updatedAt: row.updated_at,
289
304
  };
290
305
  }
291
- function eventFromRow(row) {
306
+ function eventFromRow(row, issues) {
292
307
  const payload = outputPayloadFromV2Row(row);
293
308
  if (!payload)
294
309
  return undefined;
310
+ if ((row.type === "assistant_delta" || row.type === "thinking_delta") && !nonEmptyEventText(payload)) {
311
+ issues.push({
312
+ severity: "warning",
313
+ code: "missing_delta_text",
314
+ nodeId: row.event_id ?? String(row.stream_id),
315
+ message: `Persisted ${row.type} event has no readable text payload.`,
316
+ });
317
+ }
295
318
  return {
296
319
  id: String(row.stream_id),
297
320
  piboSessionId: row.session_id ?? undefined,
@@ -315,12 +338,16 @@ function outputPayloadFromV2Row(row) {
315
338
  const base = { piboSessionId, eventId: row.event_id ?? undefined };
316
339
  if (row.type === "assistant_message")
317
340
  return compactObject({ ...base, type: "assistant_message", text: row.preview_text ?? "" });
341
+ if (row.type === "assistant_delta")
342
+ return compactObject({ ...base, type: "assistant_delta", text: inlineTextPayload(inlinePayload) ?? row.preview_text ?? "" });
318
343
  if (row.type === "message_started")
319
344
  return compactObject({ ...base, type: "message_started", text: row.preview_text ?? "" });
320
345
  if (row.type === "message_finished")
321
346
  return compactObject({ ...base, type: "message_finished" });
322
347
  if (row.type === "thinking_started")
323
348
  return compactObject({ ...base, type: "thinking_started" });
349
+ if (row.type === "thinking_delta")
350
+ return compactObject({ ...base, type: "thinking_delta", text: inlineTextPayload(inlinePayload) ?? row.preview_text ?? "" });
324
351
  if (row.type === "thinking_finished")
325
352
  return compactObject({ ...base, type: "thinking_finished", text: row.preview_text ?? "" });
326
353
  if (row.type === "tool_call")
@@ -337,6 +364,13 @@ function outputPayloadFromV2Row(row) {
337
364
  }
338
365
  return compactObject({ ...base, type: row.type });
339
366
  }
367
+ function inlineTextPayload(value) {
368
+ return typeof value === "string" ? value : undefined;
369
+ }
370
+ function nonEmptyEventText(event) {
371
+ const text = event.text;
372
+ return typeof text === "string" && text.length > 0;
373
+ }
340
374
  function stringAttribute(attributes, key) {
341
375
  const value = attributes[key];
342
376
  return typeof value === "string" ? value : undefined;
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export { LOCAL_TUI_CHANNEL_NAME, LocalRoutedTuiClient, createLocalRoutedTuiClien
19
19
  export { createWebHostChannel, DEFAULT_WEB_CHANNEL_HOST, DEFAULT_WEB_CHANNEL_PORT, WEB_CHANNEL_NAME } from "./web/channel.js";
20
20
  export { sendGatewayEvent, sendGatewayMessageAndWaitForReply } from "./gateway/request.js";
21
21
  export { createSubagentToolDefinitions, createSubagentToolName, } from "./subagents/tool.js";
22
+ export { PiboSteeringUnavailableError } from "./core/events.js";
22
23
  export { InMemoryPiboSessionStore, createPiSessionId, createPiboSessionId, createPiboSession, } from "./sessions/store.js";
23
24
  export { runPiboCli } from "./cli.js";
24
25
  export { DEFAULT_PIBO_CONFIG_PATH, PIBO_CONFIG_KEYS, deletePiboConfigValue, getDefaultPiboConfigPath, getDisplayPiboConfigValue, getPiboConfigValue, isPiboConfigKeySecret, loadPiboConfig, redactPiboConfig, savePiboConfig, setPiboConfigValue, } from "./config/config.js";
@@ -0,0 +1,19 @@
1
+ export function goalActiveTimeSeconds(job) {
2
+ return Math.max(0, Math.floor(job.state.activeTimeSeconds ?? job.state.timeUsedSeconds ?? 0));
3
+ }
4
+ export function goalElapsedWallClockSeconds(job, now = new Date()) {
5
+ if (job.mode !== 'goal' || !job.state.goalStartedAt)
6
+ return 0;
7
+ const startedAt = Date.parse(job.state.goalStartedAt);
8
+ const endedAt = job.state.goalEndedAt ? Date.parse(job.state.goalEndedAt) : now.getTime();
9
+ if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt))
10
+ return 0;
11
+ return Math.max(0, Math.floor((endedAt - startedAt) / 1000));
12
+ }
13
+ export function goalRemainingTokens(job) {
14
+ return job.tokenBudget === undefined ? undefined : Math.max(0, job.tokenBudget - (job.state.tokensUsed ?? 0));
15
+ }
16
+ export function goalCanStartNextTurn(job) {
17
+ const remaining = goalRemainingTokens(job);
18
+ return remaining === undefined || remaining > (job.tokenReserve ?? 0);
19
+ }
package/dist/loops/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { Command } from 'commander';
3
+ import { goalActiveTimeSeconds, goalElapsedWallClockSeconds } from './accounting.js';
3
4
  import { createDefaultPiboLoopStore } from './store.js';
4
5
  import { createBuiltInLoopStopConditions } from './stopping.js';
5
6
  import { DEFAULT_PIBO_PROFILE_NAME } from '../plugins/builtin.js';
@@ -38,6 +39,9 @@ function maxIterations(value) { if (value === undefined)
38
39
  function tokenBudget(value) { if (value === undefined)
39
40
  return undefined; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1)
40
41
  throw new Error('--token-budget must be a positive integer'); return parsed; }
42
+ function tokenReserve(value) { if (value === undefined)
43
+ return undefined; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0)
44
+ throw new Error('--token-reserve must be a non-negative integer'); return parsed; }
41
45
  function loopMode(value) { if (value === undefined)
42
46
  return undefined; if (value !== 'goal' && value !== 'ralph')
43
47
  throw new Error('--mode must be goal or ralph'); return value; }
@@ -110,8 +114,16 @@ export function formatLoopResourceSummary(resources) {
110
114
  parts.push('next=pibo compute reap --dry-run --include-dev');
111
115
  return parts.length ? parts.join(';') : '-';
112
116
  }
113
- function formatLoopJobLine(job) { const goal = job.mode === 'goal' ? job.state.goalStatus ?? (job.enabled ? 'active' : 'paused') : '-'; const tokens = job.mode === 'goal' ? `${job.state.tokensUsed ?? 0}/${job.tokenBudget ?? 'unbounded'}` : '-'; return `${job.id}\t${job.mode}\t${job.enabled ? 'running' : 'stopped'}\t${job.state.runningAt ? 'active' : '-'}\tgoal=${goal}\ttokens=${tokens}\tresources=${formatLoopResourceSummary(job.resources)}\t${job.name}`; }
114
- function formatLoopRunLine(run) { return `${run.id}\t${run.jobId}\t${run.status}\t${run.piboSessionId ?? '-'}\t${run.completedAt ?? '-'}\tresources=${formatLoopResourceSummary(run.resources)}`; }
117
+ function formatLoopJobLine(job) {
118
+ const goal = job.mode === 'goal' ? job.state.goalStatus ?? (job.enabled ? 'active' : 'paused') : '-';
119
+ const budget = job.mode === 'goal' ? job.tokenBudget === undefined ? 'unbounded' : `soft:${job.state.tokensUsed ?? 0}/${job.tokenBudget};reserve=${job.tokenReserve ?? 0}` : '-';
120
+ const time = job.mode === 'goal' ? `activeAgent=${goalActiveTimeSeconds(job)}s;elapsedWall=${goalElapsedWallClockSeconds(job)}s;paused=included` : '-';
121
+ return `${job.id}\t${job.mode}\t${job.enabled ? 'running' : 'stopped'}\t${job.state.runningAt ? 'active' : '-'}\tgoal=${goal}\tbudget=${budget}\ttime=${time}\tresources=${formatLoopResourceSummary(job.resources)}\t${job.name}`;
122
+ }
123
+ function formatLoopRunLine(run) {
124
+ const accounting = run.accounting ? `tokens=${run.accounting.tokensUsed ?? 0};remainingBefore=${run.accounting.remainingTokensBefore ?? 'unbounded'};overshoot=${run.accounting.overshootTokens ?? 0};activeAgent=${run.accounting.activeTimeSeconds ?? 0}s` : '-';
125
+ return `${run.id}\t${run.jobId}\t${run.status}\t${run.piboSessionId ?? '-'}\t${run.completedAt ?? '-'}\taccounting=${accounting}\tresources=${formatLoopResourceSummary(run.resources)}`;
126
+ }
115
127
  export async function runLoopCli(argv = process.argv, defaults = {}) {
116
128
  const program = new Command();
117
129
  program.name(defaults.commandName ?? 'pibo loop').description('Manage continuous Pibo loops').helpOption('-h, --help');
@@ -127,13 +139,13 @@ export async function runLoopCli(argv = process.argv, defaults = {}) {
127
139
  else
128
140
  for (const job of jobs)
129
141
  console.log(formatLoopJobLine(job)); store.close(); });
130
- program.command('add').description(defaults.mode === 'ralph' ? 'Create a Ralph job' : 'Create a Loop job').option('--template <id>', 'Built-in job template id').option('--mode <mode>', 'Loop mode: goal or ralph').option('--prompt <text>', 'Task prompt').option('--name <name>', 'Job name').option('--description <text>', 'Job description').option('--profile <profile>', 'Agent profile', DEFAULT_PIBO_PROFILE_NAME).option('--room <room-id>', 'Target room id').option('--default-chat', 'Target the shared default chat').option('--max-iterations <n>', 'Stop after n completed run attempts').option('--token-budget <n>', 'Stop a Goal loop after reported model usage reaches n tokens').option('--model <provider/model>', 'Runtime model override, for example openai/gpt-5').option('--thinking <level>', 'Runtime thinking level override: off, minimal, low, medium, high, xhigh, max').option('--fast', 'Enable runtime fast mode').option('--no-fast', 'Disable runtime fast mode')
142
+ program.command('add').description(defaults.mode === 'ralph' ? 'Create a Ralph job' : 'Create a Loop job').option('--template <id>', 'Built-in job template id').option('--mode <mode>', 'Loop mode: goal or ralph').option('--prompt <text>', 'Task prompt').option('--name <name>', 'Job name').option('--description <text>', 'Job description').option('--profile <profile>', 'Agent profile', DEFAULT_PIBO_PROFILE_NAME).option('--room <room-id>', 'Target room id').option('--default-chat', 'Target the shared default chat').option('--max-iterations <n>', 'Stop after n completed run attempts').option('--token-budget <n>', 'Set a soft Goal token budget; the final turn can overshoot').option('--token-reserve <n>', 'Require more than n tokens to remain before starting another Goal turn').option('--model <provider/model>', 'Runtime model override, for example openai/gpt-5').option('--thinking <level>', 'Runtime thinking level override: off, minimal, low, medium, high, xhigh, max').option('--fast', 'Enable runtime fast mode').option('--no-fast', 'Disable runtime fast mode')
131
143
  .option('--start', 'Start immediately').option('--json', 'Print JSON').action((options) => { const base = templatePatch(options.template); const prompt = options.prompt ?? base.prompt; if (typeof prompt !== 'string' || !prompt.trim())
132
- throw new Error('Choose --template <id> or provide --prompt <text>'); const input = { mode: loopMode(options.mode) ?? base.mode ?? defaults.mode ?? 'goal', name: options.name ?? base.name, description: options.description ?? base.description, enabled: options.start === true, target: targetFromOptions(options), profile: options.profile, prompt, maxIterations: options.maxIterations !== undefined ? maxIterations(options.maxIterations) : typeof base.maxIterations === 'number' ? base.maxIterations : undefined, tokenBudget: tokenBudget(options.tokenBudget), stopPolicy: base.stopPolicy ?? undefined }; applyRuntimeCreateOptions(input, options); const store = createDefaultPiboLoopStore({ path: program.opts().store }); const job = store.createJob(input); if (options.json)
144
+ throw new Error('Choose --template <id> or provide --prompt <text>'); const input = { mode: loopMode(options.mode) ?? base.mode ?? defaults.mode ?? 'goal', name: options.name ?? base.name, description: options.description ?? base.description, enabled: options.start === true, target: targetFromOptions(options), profile: options.profile, prompt, maxIterations: options.maxIterations !== undefined ? maxIterations(options.maxIterations) : typeof base.maxIterations === 'number' ? base.maxIterations : undefined, tokenBudget: tokenBudget(options.tokenBudget), tokenReserve: tokenReserve(options.tokenReserve), stopPolicy: base.stopPolicy ?? undefined }; applyRuntimeCreateOptions(input, options); const store = createDefaultPiboLoopStore({ path: program.opts().store }); const job = store.createJob(input); if (options.json)
133
145
  printJson(job);
134
146
  else
135
147
  console.log(`${job.id}\t${job.enabled ? 'running' : 'stopped'}\t${job.name}`); store.close(); });
136
- program.command('edit').argument('<id>', defaults.mode === 'ralph' ? 'Ralph job id' : 'Loop job id').description(defaults.mode === 'ralph' ? 'Update a Ralph job' : 'Update a Loop job').option('--template <id>', 'Apply a built-in job template before explicit overrides').option('--mode <mode>', 'Set loop mode: goal or ralph').option('--prompt <text>', 'Task prompt').option('--name <name>', 'Job name').option('--description <text>', 'Job description').option('--profile <profile>', 'Agent profile').option('--room <room-id>', 'Target room id').option('--default-chat', 'Target the shared default chat').option('--max-iterations <n>', 'Stop after n completed run attempts').option('--token-budget <n>', 'Set Goal token budget').option('--clear-token-budget', 'Clear Goal token budget').option('--model <provider/model>', 'Set runtime model override, for example openai/gpt-5').option('--clear-model', 'Clear runtime model override').option('--thinking <level>', 'Set runtime thinking level override: off, minimal, low, medium, high, xhigh, max').option('--clear-thinking', 'Clear runtime thinking level override').option('--fast', 'Enable runtime fast mode').option('--no-fast', 'Disable runtime fast mode').option('--clear-fast', 'Clear runtime fast mode override').option('--json', 'Print JSON').action((id, options) => { const store = createDefaultPiboLoopStore({ path: program.opts().store }); const patch = { ...templatePatch(options.template) }; if (options.mode !== undefined)
148
+ program.command('edit').argument('<id>', defaults.mode === 'ralph' ? 'Ralph job id' : 'Loop job id').description(defaults.mode === 'ralph' ? 'Update a Ralph job' : 'Update a Loop job').option('--template <id>', 'Apply a built-in job template before explicit overrides').option('--mode <mode>', 'Set loop mode: goal or ralph').option('--prompt <text>', 'Task prompt').option('--name <name>', 'Job name').option('--description <text>', 'Job description').option('--profile <profile>', 'Agent profile').option('--room <room-id>', 'Target room id').option('--default-chat', 'Target the shared default chat').option('--max-iterations <n>', 'Stop after n completed run attempts').option('--token-budget <n>', 'Set soft Goal token budget').option('--clear-token-budget', 'Clear Goal token budget').option('--token-reserve <n>', 'Set pre-turn minimum remaining tokens').option('--clear-token-reserve', 'Clear Goal token reserve').option('--model <provider/model>', 'Set runtime model override, for example openai/gpt-5').option('--clear-model', 'Clear runtime model override').option('--thinking <level>', 'Set runtime thinking level override: off, minimal, low, medium, high, xhigh, max').option('--clear-thinking', 'Clear runtime thinking level override').option('--fast', 'Enable runtime fast mode').option('--no-fast', 'Disable runtime fast mode').option('--clear-fast', 'Clear runtime fast mode override').option('--json', 'Print JSON').action((id, options) => { const store = createDefaultPiboLoopStore({ path: program.opts().store }); const patch = { ...templatePatch(options.template) }; if (options.mode !== undefined)
137
149
  patch.mode = loopMode(options.mode); if (options.name !== undefined)
138
150
  patch.name = options.name; if (options.description !== undefined)
139
151
  patch.description = options.description; if (options.profile !== undefined)
@@ -143,7 +155,11 @@ export async function runLoopCli(argv = process.argv, defaults = {}) {
143
155
  throw new Error('Choose either --token-budget or --clear-token-budget, not both'); if (options.clearTokenBudget)
144
156
  patch.tokenBudget = null;
145
157
  else if (options.tokenBudget !== undefined)
146
- patch.tokenBudget = tokenBudget(options.tokenBudget); applyRuntimePatchOptions(patch, options); const target = maybeTargetFromOptions(options); if (target)
158
+ patch.tokenBudget = tokenBudget(options.tokenBudget); if (options.tokenReserve !== undefined && options.clearTokenReserve)
159
+ throw new Error('Choose either --token-reserve or --clear-token-reserve, not both'); if (options.clearTokenReserve)
160
+ patch.tokenReserve = null;
161
+ else if (options.tokenReserve !== undefined)
162
+ patch.tokenReserve = tokenReserve(options.tokenReserve); applyRuntimePatchOptions(patch, options); const target = maybeTargetFromOptions(options); if (target)
147
163
  patch.target = target; if (Object.keys(patch).length === 0)
148
164
  throw new Error('No Loop job update fields provided'); const job = store.updateJob(id, patch); if (!job)
149
165
  throw new Error('Loop job not found'); if (options.json)
@@ -21,6 +21,7 @@ function buildGoalTurnPrompt(job, continuation, goalToolsAvailable) {
21
21
  const tokensUsed = job.state.tokensUsed ?? 0;
22
22
  const tokenBudget = job.tokenBudget;
23
23
  const remainingTokens = tokenBudget === undefined ? 'unbounded' : String(Math.max(0, tokenBudget - tokensUsed));
24
+ const tokenReserve = job.tokenReserve ?? 0;
24
25
  return [
25
26
  continuation ? 'Continue working toward the active Pibo loop goal.' : 'Start working toward the active Pibo loop goal.',
26
27
  '',
@@ -36,8 +37,10 @@ function buildGoalTurnPrompt(job, continuation, goalToolsAvailable) {
36
37
  '- Temporary rough edges are acceptable while work moves toward the requested end state. Completion still requires the requested end state to be true and verified.',
37
38
  '',
38
39
  'Budget:',
40
+ '- Budget enforcement: soft; model usage is reported after a response and the current turn can overshoot the limit.',
39
41
  `- Reported tokens used before this turn: ${tokensUsed}`,
40
- `- Token budget: ${tokenBudget ?? 'none'}`,
42
+ `- Soft token budget: ${tokenBudget ?? 'none'}`,
43
+ `- Pre-turn token reserve: ${tokenReserve}`,
41
44
  `- Reported tokens remaining before this turn: ${remainingTokens}`,
42
45
  '',
43
46
  'Work from evidence:',
@@ -5,7 +5,7 @@ import { getDefaultPiboWorkspace } from '../core/workspace.js';
5
5
  import { PiboDataStore } from '../data/pibo-store.js';
6
6
  import { ChatRoomService } from '../apps/chat/data/room-service.js';
7
7
  import { isPiboRoomArchived } from '../apps/chat/types/rooms.js';
8
- import { browserPoolPaths, releaseBrowserPoolLease } from '../tools/browser-pool.js';
8
+ import { acquireBrowserPoolLease, browserPoolPaths, releaseBrowserPoolLease, restartRecordedBrowserPoolChrome } from '../tools/browser-pool.js';
9
9
  import { createDefaultPiboLoopStore } from './store.js';
10
10
  import { createBuiltInLoopStopConditions, evaluateLoopStopPolicy } from './stopping.js';
11
11
  import { buildLoopTurnPrompt } from './prompts.js';
@@ -48,6 +48,8 @@ export class PiboLoopService {
48
48
  activeRuns = 0;
49
49
  stopped = true;
50
50
  cancelledRuns = new Set();
51
+ browserLeaseHeartbeatTimers = new Map();
52
+ browserLeaseHeartbeatWork = new Map();
51
53
  unsubscribeProductEvents;
52
54
  unsubscribeOutputEvents;
53
55
  constructor(options) {
@@ -62,7 +64,8 @@ export class PiboLoopService {
62
64
  start() { if (!this.stopped)
63
65
  return; this.stopped = false; this.store.recoverInterruptedRuns(); this.unsubscribeProductEvents = this.options.context.subscribeProductEvents?.((event) => this.handleProductEvent(event)); this.unsubscribeOutputEvents = this.options.context.subscribe((event) => this.handleOutputEvent(event)); this.arm(250); }
64
66
  stop() { this.stopped = true; if (this.timer)
65
- clearTimeout(this.timer); this.timer = undefined; this.unsubscribeProductEvents?.(); this.unsubscribeProductEvents = undefined; this.unsubscribeOutputEvents?.(); this.unsubscribeOutputEvents = undefined; this.dataStore.close(); this.store.close(); }
67
+ clearTimeout(this.timer); this.timer = undefined; for (const timer of this.browserLeaseHeartbeatTimers.values())
68
+ clearInterval(timer); this.browserLeaseHeartbeatTimers.clear(); this.unsubscribeProductEvents?.(); this.unsubscribeProductEvents = undefined; this.unsubscribeOutputEvents?.(); this.unsubscribeOutputEvents = undefined; this.dataStore.close(); this.store.close(); }
66
69
  status() { return { enabled: !this.stopped, ...this.store.status() }; }
67
70
  async startJob(id) { const job = this.store.updateJob(id, { enabled: true }); if (!job)
68
71
  return undefined; const reserved = await this.reserveAfterBeforeRunEvaluation(job); if (!reserved)
@@ -109,7 +112,70 @@ export class PiboLoopService {
109
112
  return undefined;
110
113
  }
111
114
  this.store.applyStopEvaluation({ jobId: fresh.id, evaluation, conditionStates, disable: false });
112
- return this.store.reserveRun(fresh.id);
115
+ if (fresh.mode === 'goal' && !await this.renewGoalBrowserLeases(fresh))
116
+ return undefined;
117
+ const reserved = this.store.reserveRun(fresh.id);
118
+ if (reserved)
119
+ this.startGoalBrowserLeaseHeartbeat(reserved.job, reserved.run);
120
+ return reserved;
121
+ }
122
+ startGoalBrowserLeaseHeartbeat(job, run) {
123
+ if (job.mode !== 'goal' || (job.resources?.browserLeaseIds?.length ?? 0) === 0)
124
+ return;
125
+ const renew = () => {
126
+ if (this.browserLeaseHeartbeatWork.has(run.id))
127
+ return;
128
+ const work = this.renewGoalBrowserLeases(this.store.getJob(job.id) ?? job, run).finally(() => { this.browserLeaseHeartbeatWork.delete(run.id); });
129
+ this.browserLeaseHeartbeatWork.set(run.id, work);
130
+ };
131
+ const timer = setInterval(renew, Math.max(10, this.options.resourceCleanup?.browserLeaseRenewIntervalMs ?? 5 * 60_000));
132
+ this.browserLeaseHeartbeatTimers.set(run.id, timer);
133
+ }
134
+ async stopGoalBrowserLeaseHeartbeat(runId) {
135
+ const timer = this.browserLeaseHeartbeatTimers.get(runId);
136
+ if (timer)
137
+ clearInterval(timer);
138
+ this.browserLeaseHeartbeatTimers.delete(runId);
139
+ await this.browserLeaseHeartbeatWork.get(runId);
140
+ }
141
+ async renewGoalBrowserLeases(job, run) {
142
+ const resources = mergeRunResources(job.resources, run?.resources);
143
+ const leaseIds = resources?.browserLeaseIds ?? [];
144
+ if (!resources || leaseIds.length === 0)
145
+ return true;
146
+ const workerId = resources.workerId || process.env.PIBO_BROWSER_POOL_WORKER_ID || process.env.PIBO_COMPUTE_WORKER_ID || process.env.HOSTNAME || 'local';
147
+ const poolId = this.options.resourceCleanup?.browserPoolId || process.env.PIBO_BROWSER_POOL_ID || 'default';
148
+ const rootDir = this.options.resourceCleanup?.browserPoolRootDir || process.env.PIBO_BROWSER_POOL_ROOT || join(process.env.BROWSER_USE_HOME || join(homedir(), '.browser-use'), 'pibo-browser-pool');
149
+ const identity = { workerId, poolId };
150
+ const paths = browserPoolPaths(rootDir, identity);
151
+ const acquire = this.options.resourceCleanup?.acquireBrowserPoolLease ?? acquireBrowserPoolLease;
152
+ let retainedUntil;
153
+ for (const leaseId of leaseIds) {
154
+ try {
155
+ const result = await acquire(paths, identity, {
156
+ leaseId,
157
+ holder: run ? `loop:${job.id}:run:${run.id}` : `loop:${job.id}`,
158
+ idleTimeoutMs: this.options.resourceCleanup?.browserLeaseIdleTimeoutMs,
159
+ startBrowser: restartRecordedBrowserPoolChrome,
160
+ lockOptions: { holder: run ? `loop:${job.id}:run:${run.id}` : `loop:${job.id}` },
161
+ });
162
+ if (!result.acquired)
163
+ throw new Error(result.staleReason);
164
+ retainedUntil = result.state.idleExpiresAt ?? retainedUntil;
165
+ }
166
+ catch (error) {
167
+ const reason = `Browser lease ${leaseId} could not be renewed or reacquired: ${errorMessage(error)}. Authenticated browser access requires operator attention.`;
168
+ this.markRunResourcesDirty(job, reason);
169
+ this.store.updateGoalStatus(job.id, 'blocked');
170
+ return false;
171
+ }
172
+ }
173
+ const updatedAt = new Date().toISOString();
174
+ const next = clearDirtyReason({ ...resources, workerId, cleanupState: 'active', retainedUntil, updatedAt });
175
+ this.store.updateJobResources(job.id, next);
176
+ if (run)
177
+ this.store.updateRunResources({ jobId: job.id, runId: run.id, resources: next });
178
+ return true;
113
179
  }
114
180
  async abortCancelRequestedJobs() { for (const job of this.store.listJobs({ includeDisabled: true })) {
115
181
  if (job.state.cancelRequestedAt)
@@ -134,17 +200,25 @@ export class PiboLoopService {
134
200
  }
135
201
  async executeReserved(job, run) {
136
202
  this.activeRuns += 1;
203
+ const activeStartedAt = Date.now();
204
+ let activeTimeRecorded = false;
205
+ const recordActiveTime = () => {
206
+ if (activeTimeRecorded)
207
+ return;
208
+ activeTimeRecorded = true;
209
+ this.recordGoalRunTime(job, run, activeStartedAt);
210
+ };
137
211
  try {
138
212
  const result = await this.executeJob(job, run);
213
+ recordActiveTime();
139
214
  const cancelled = this.cancelledRuns.delete(run.id);
140
- if (job.mode === 'goal')
141
- this.store.recordGoalProgress(job.id, { timeUsedSeconds: result.timeUsedSeconds });
142
215
  const outcome = { status: cancelled ? 'cancelled' : 'ok', piboSessionId: result.piboSessionId, finalAnswer: result.finalAnswer };
143
216
  const { evaluation, conditionStates } = await this.evaluateStopPolicy(this.store.getJob(job.id) ?? job, 'after-run', run, outcome);
144
217
  this.store.completeRun({ jobId: job.id, runId: run.id, status: outcome.status, piboSessionId: result.piboSessionId, reason: cancelled ? 'cancelled' : evaluation.reason, stopAfterRun: evaluation.finalAction !== 'continue', stopEvaluation: evaluation, conditionStates });
145
218
  await this.cleanupRunResources(job, run);
146
219
  }
147
220
  catch (error) {
221
+ recordActiveTime();
148
222
  const cancelled = this.cancelledRuns.delete(run.id);
149
223
  const message = errorMessage(error);
150
224
  const fatalProfileError = !cancelled && isUnknownProfileErrorMessage(message);
@@ -160,6 +234,11 @@ export class PiboLoopService {
160
234
  this.activeRuns -= 1;
161
235
  }
162
236
  }
237
+ recordGoalRunTime(job, run, startedAt) {
238
+ if (job.mode !== 'goal')
239
+ return;
240
+ this.store.recordGoalRunTime(job.id, run.id, Math.max(0, Math.ceil((Date.now() - startedAt) / 1000)));
241
+ }
163
242
  markRunResourcesDirty(job, dirtyReason) {
164
243
  const latestJob = this.store.getJob(job.id) ?? job;
165
244
  const runId = latestJob.state.lastRunId ?? job.state.lastRunId;
@@ -178,12 +257,20 @@ export class PiboLoopService {
178
257
  }
179
258
  }
180
259
  async cleanupRunResources(job, run) {
260
+ await this.stopGoalBrowserLeaseHeartbeat(run.id);
181
261
  const latestJob = this.store.getJob(job.id) ?? job;
182
262
  const latestRun = this.store.listRuns({ jobId: job.id, limit: 100 }).find((candidate) => candidate.id === run.id) ?? run;
183
263
  const resources = mergeRunResources(latestJob.resources, latestRun.resources);
184
264
  const leaseIds = resources?.browserLeaseIds ?? [];
185
265
  if (!resources || leaseIds.length === 0)
186
266
  return;
267
+ const goalStatus = latestJob.mode === 'goal' ? latestJob.state.goalStatus ?? (latestJob.enabled ? 'active' : 'paused') : undefined;
268
+ if (latestJob.mode === 'goal' && latestJob.enabled && goalStatus === 'active') {
269
+ const retained = clearDirtyReason({ ...resources, cleanupState: 'retained', updatedAt: new Date().toISOString() });
270
+ this.store.updateRunResources({ jobId: job.id, runId: run.id, resources: retained });
271
+ this.store.updateJobResources(job.id, retained);
272
+ return;
273
+ }
187
274
  const workerId = resources.workerId || process.env.PIBO_BROWSER_POOL_WORKER_ID || process.env.PIBO_COMPUTE_WORKER_ID || process.env.HOSTNAME || 'local';
188
275
  const poolId = this.options.resourceCleanup?.browserPoolId || process.env.PIBO_BROWSER_POOL_ID || 'default';
189
276
  const rootDir = this.options.resourceCleanup?.browserPoolRootDir || process.env.PIBO_BROWSER_POOL_ROOT || join(process.env.BROWSER_USE_HOME || join(homedir(), '.browser-use'), 'pibo-browser-pool');
@@ -228,7 +315,10 @@ export class PiboLoopService {
228
315
  const status = job.state.goalStatus ?? (job.enabled ? 'active' : 'paused');
229
316
  if (!job.state.runningAt && status !== 'active')
230
317
  return;
231
- this.store.recordGoalProgress(job.id, { tokens: event.totalTokens });
318
+ if (job.state.runningAt && job.state.lastRunId)
319
+ this.store.recordGoalTurnUsage(job.id, job.state.lastRunId, event.totalTokens);
320
+ else
321
+ this.store.recordGoalProgress(job.id, { tokens: event.totalTokens });
232
322
  }
233
323
  handleProductEvent(event) {
234
324
  if (event.type !== 'pibo.loop.fact' && event.type !== 'loop.fact' && event.type !== 'pibo.ralph.fact' && event.type !== 'ralph.fact')
@@ -304,7 +394,6 @@ export class PiboLoopService {
304
394
  } const room = this.roomService.ensureDefaultRoom({ name: 'Shared Chat' }); return { roomId: room.id, workspace: room.workspace ?? getDefaultPiboWorkspace() }; }
305
395
  async emitMessageAndWait(piboSessionId, text) {
306
396
  const eventId = `loop_msg_${randomUUID()}`;
307
- const startedAt = Date.now();
308
397
  return await new Promise((resolve, reject) => {
309
398
  let settled = false;
310
399
  let deltaAnswer = '';
@@ -323,7 +412,7 @@ export class PiboLoopService {
323
412
  if (error)
324
413
  reject(error);
325
414
  else
326
- resolve({ finalAnswer: finalAnswer || deltaAnswer, timeUsedSeconds: Math.max(0, Math.ceil((Date.now() - startedAt) / 1000)) });
415
+ resolve({ finalAnswer: finalAnswer || deltaAnswer });
327
416
  };
328
417
  if (this.runTimeoutMs !== undefined) {
329
418
  timeout = setTimeout(() => {