@pasko70/pibo 2.4.0 → 2.4.2

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 (39) hide show
  1. package/dist/agent-runtime/routed-session.js +151 -7
  2. package/dist/agent-runtimes/codex-native/turn.js +3 -1
  3. package/dist/agent-runtimes/omp/turn.js +36 -7
  4. package/dist/agent-runtimes/pi/routed-session.js +62 -16
  5. package/dist/apps/chat/web-app.js +6 -8
  6. package/dist/apps/chat-ui/assets/{dist-CUcAofmV.js → dist-3YG57JXi.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-Byygd1lH.js → dist-BeqHbnGN.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-DusFwy0L.js → dist-CrDtveZB.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-C9BrS7sL.js → dist-Cw9po47P.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-D4RU6xu3.js → dist-DTRjeLwO.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{index-Bifi_kjN.js → index-AjnP3ci-.js} +89 -89
  12. package/dist/apps/chat-ui/index.html +1 -1
  13. package/dist/apps/chat-vscode-web/assets/{index-WsLm1mo3.js → index-DvTSSvzN.js} +5 -5
  14. package/dist/apps/chat-vscode-web/index.html +1 -1
  15. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  16. package/dist/apps/vscode-artifacts/{pibo-vscode-ext-2.4.0.vsix → pibo-vscode-ext-2.4.2.vsix} +0 -0
  17. package/dist/core/session-router.js +153 -63
  18. package/dist/gateway/web.js +8 -1
  19. package/dist/loops/accounting.js +27 -0
  20. package/dist/loops/cli.js +6 -5
  21. package/dist/loops/prompts.js +13 -5
  22. package/dist/loops/service.js +8 -2
  23. package/dist/loops/store.js +27 -11
  24. package/dist/loops/tools.js +10 -5
  25. package/dist/mcp/config-command.js +3 -2
  26. package/dist/mcp/config.js +10 -4
  27. package/dist/mcp/errors.js +1 -1
  28. package/dist/mcp/index.js +19 -33
  29. package/dist/reliability/store.js +11 -6
  30. package/dist/runs/lifecycle.js +12 -0
  31. package/dist/runs/registry.js +23 -4
  32. package/dist/runs/tools.js +12 -3
  33. package/dist/session-ui/sessionActivity.js +6 -2
  34. package/dist/signals/status.js +9 -4
  35. package/dist/tools/guides.js +1 -1
  36. package/dist/web/channel.js +10 -4
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
  39. package/skills/builtin/loop/SKILL.md +1 -1
@@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { piboHomePath } from '../core/pibo-home.js';
6
6
  import { isPiboThinkingLevel } from '../core/thinking.js';
7
+ import { newGoalTokenAccounting, normalizeLoopTokenAccounting } from './accounting.js';
7
8
  function nowIso(now = new Date()) { return now.toISOString(); }
8
9
  function parseJson(json) { return JSON.parse(json); }
9
10
  function defaultName(prompt) { const normalized = prompt.replace(/\s+/g, ' ').trim(); return normalized ? normalized.slice(0, 80) : 'Loop job'; }
@@ -80,7 +81,9 @@ function parseRunAccounting(json) {
80
81
  return undefined;
81
82
  try {
82
83
  const value = JSON.parse(json);
83
- return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
84
+ return value && typeof value === 'object' && !Array.isArray(value)
85
+ ? { ...value, tokenAccounting: normalizeLoopTokenAccounting(value.tokenAccounting) }
86
+ : undefined;
84
87
  }
85
88
  catch {
86
89
  return undefined;
@@ -104,7 +107,7 @@ function normalizeJobState(state, mode, enabled, createdAt) {
104
107
  return state;
105
108
  const activeTimeSeconds = Math.max(0, Math.floor(state.activeTimeSeconds ?? state.timeUsedSeconds ?? 0));
106
109
  const goalStartedAt = state.goalStartedAt ?? (enabled || (state.completedIterations ?? 0) > 0 || (state.tokensUsed ?? 0) > 0 || (state.goalStatus !== undefined && state.goalStatus !== 'paused') ? createdAt : undefined);
107
- const normalized = { ...state, activeTimeSeconds, ...(goalStartedAt ? { goalStartedAt } : {}) };
110
+ const normalized = { ...state, tokenAccounting: normalizeLoopTokenAccounting(state.tokenAccounting), activeTimeSeconds, ...(goalStartedAt ? { goalStartedAt } : {}) };
108
111
  delete normalized.timeUsedSeconds;
109
112
  return normalized;
110
113
  }
@@ -296,7 +299,7 @@ export class PiboLoopStore {
296
299
  const enabled = input.enabled === true;
297
300
  const state = {
298
301
  completedIterations: 0,
299
- ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: timestamp } : {}) } : {}),
302
+ ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokenAccounting: newGoalTokenAccounting(), tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: timestamp } : {}) } : {}),
300
303
  ...(input.initialPiboSessionId?.trim() ? { lastPiboSessionId: input.initialPiboSessionId.trim() } : {}),
301
304
  };
302
305
  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 };
@@ -422,7 +425,7 @@ export class PiboLoopStore {
422
425
  if (job?.mode === 'goal') {
423
426
  const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
424
427
  if (row) {
425
- const accounting = parseRunAccounting(row.accounting_json) ?? {};
428
+ const accounting = parseRunAccounting(row.accounting_json) ?? { tokenAccounting: normalizeLoopTokenAccounting(job.state.tokenAccounting) };
426
429
  const turnTokens = (accounting.tokensUsed ?? 0) + Math.max(0, Math.floor(tokens));
427
430
  const budget = accounting.tokenBudget;
428
431
  const before = accounting.tokensUsedBefore ?? 0;
@@ -446,7 +449,7 @@ export class PiboLoopStore {
446
449
  if (job?.mode === 'goal') {
447
450
  const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
448
451
  if (row) {
449
- const accounting = { ...(parseRunAccounting(row.accounting_json) ?? {}), activeTimeSeconds: seconds };
452
+ const accounting = { ...(parseRunAccounting(row.accounting_json) ?? { tokenAccounting: normalizeLoopTokenAccounting(job.state.tokenAccounting) }), activeTimeSeconds: seconds };
450
453
  this.db.prepare('UPDATE pibo_ralph_runs SET accounting_json = ?, updated_at = ? WHERE id = ?').run(runAccountingJson(accounting), nowIso(now), runId);
451
454
  }
452
455
  }
@@ -481,7 +484,7 @@ export class PiboLoopStore {
481
484
  const enabled = patch.enabled ?? existing.enabled;
482
485
  let state = mode === existing.mode
483
486
  ? { ...existing.state }
484
- : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: nowIso(now) } : {}) } : {}) };
487
+ : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokenAccounting: newGoalTokenAccounting(), tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: nowIso(now) } : {}) } : {}) };
485
488
  if (mode === 'goal' && patch.enabled !== undefined) {
486
489
  const currentGoalStatus = goalStatus({ mode, enabled: existing.enabled, state: existing.state }) ?? 'paused';
487
490
  if (patch.enabled) {
@@ -633,7 +636,11 @@ export class PiboLoopStore {
633
636
  const completedIterations = (job.state.completedIterations ?? 0) + 1;
634
637
  const reachedMaxIterations = job.maxIterations !== undefined && completedIterations >= job.maxIterations;
635
638
  const currentGoalStatus = goalStatus(job);
636
- const nextGoalStatus = job.mode === 'goal' ? isTerminalGoalStatus(currentGoalStatus) ? currentGoalStatus : input.goalStatus ?? currentGoalStatus : undefined;
639
+ const nextGoalStatus = job.mode === 'goal'
640
+ ? isTerminalGoalStatus(currentGoalStatus) || currentGoalStatus === 'paused'
641
+ ? currentGoalStatus
642
+ : input.goalStatus ?? currentGoalStatus
643
+ : undefined;
637
644
  const terminalGoalStatus = job.mode === 'goal' && isTerminalGoalStatus(nextGoalStatus);
638
645
  const shouldDisable = terminalGoalStatus || reachedMaxIterations || input.stopAfterRun === true || input.stopEvaluation?.finalAction === 'stop-after-run' || input.stopEvaluation?.finalAction === 'cancel-current-run';
639
646
  const state = {
@@ -807,6 +814,7 @@ export class PiboLoopStore {
807
814
  createRunLocked(job, timestamp) {
808
815
  const tokensUsedBefore = job.state.tokensUsed ?? 0;
809
816
  const accounting = job.mode === 'goal' ? {
817
+ tokenAccounting: normalizeLoopTokenAccounting(job.state.tokenAccounting),
810
818
  ...(job.tokenBudget !== undefined ? { tokenBudget: job.tokenBudget, remainingTokensBefore: Math.max(0, job.tokenBudget - tokensUsedBefore) } : {}),
811
819
  ...(job.tokenReserve !== undefined ? { tokenReserve: job.tokenReserve } : {}),
812
820
  tokensUsedBefore,
@@ -829,15 +837,23 @@ export function createLoopMessagePreflight(options = {}) {
829
837
  const job = store.getJob(jobId);
830
838
  const run = store.getRun(runId);
831
839
  const status = job?.mode === 'goal' ? goalStatus(job) ?? (job.enabled ? 'active' : 'paused') : undefined;
840
+ const causalReminder = event.provenance.cause === 'run-reminder';
841
+ const validMessageBinding = causalReminder
842
+ ? event.source === 'service'
843
+ && event.text.startsWith('<pibo_run_notification>')
844
+ && typeof event.provenance.rootEventId === 'string'
845
+ && run?.messageEventId === event.provenance.rootEventId
846
+ : run?.messageEventId === event.id;
847
+ const validRunState = causalReminder
848
+ ? true
849
+ : run?.status === 'running' && Boolean(job?.state.runningAt) && job?.state.lastRunId === runId;
832
850
  const allowed = Boolean(job
833
851
  && run
834
852
  && run.jobId === jobId
835
- && run.status === 'running'
836
- && run.messageEventId === event.id
853
+ && validMessageBinding
854
+ && validRunState
837
855
  && (!run.piboSessionId || run.piboSessionId === event.piboSessionId)
838
856
  && job.enabled
839
- && job.state.runningAt
840
- && job.state.lastRunId === runId
841
857
  && (job.mode !== 'goal' || status === 'active'));
842
858
  if (allowed)
843
859
  return { allowed: true };
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { piboStringEnum } from "../tools/schema.js";
3
3
  import { definePiboTool } from "../tools/contract.js";
4
- import { goalActiveTimeSeconds, goalCanStartNextTurn, goalElapsedWallClockSeconds, goalRemainingTokens } from './accounting.js';
4
+ import { goalActiveTimeSeconds, goalCanStartNextTurn, goalElapsedWallClockSeconds, goalRemainingTokens, goalTokenAccounting } from './accounting.js';
5
5
  import { createDefaultPiboLoopStore } from './store.js';
6
6
  export const PIBO_GOAL_TOOL_NAMES = ['get_goal', 'create_goal', 'update_goal'];
7
7
  let configuredStorePath;
@@ -34,7 +34,9 @@ function resolveGoalForTurn(store, context, piboSessionId) {
34
34
  if (provenance?.kind !== 'loop-run')
35
35
  return store.getSessionGoalOwner(piboSessionId) ?? store.getLatestGoalForSession(piboSessionId);
36
36
  const run = store.getRun(provenance.runId);
37
- if (!run || run.jobId !== provenance.jobId || run.piboSessionId !== piboSessionId || run.messageEventId !== activeMessage?.id) {
37
+ const expectedEventId = provenance.cause === 'run-reminder' ? provenance.rootEventId : activeMessage?.id;
38
+ const validReminder = provenance.cause !== 'run-reminder' || (activeMessage?.source === 'service' && typeof provenance.rootEventId === 'string');
39
+ if (!run || !validReminder || run.jobId !== provenance.jobId || run.piboSessionId !== piboSessionId || run.messageEventId !== expectedEventId) {
38
40
  throw new Error('cannot resolve goal because this turn has stale or invalid Loop provenance');
39
41
  }
40
42
  const job = store.getJob(provenance.jobId);
@@ -68,11 +70,13 @@ function nonNegativeInteger(value, field) {
68
70
  }
69
71
  function goalPayload(job) {
70
72
  const tokenBudget = job.tokenBudget;
73
+ const tokenAccounting = goalTokenAccounting(job);
71
74
  return {
72
75
  goalId: job.id,
73
76
  objective: job.prompt,
74
77
  status: effectiveGoalStatus(job),
75
78
  budgetType: tokenBudget === undefined ? 'unbounded' : 'soft',
79
+ tokenAccounting,
76
80
  tokenBudget: tokenBudget ?? null,
77
81
  tokenReserve: job.tokenReserve ?? 0,
78
82
  tokensUsed: job.state.tokensUsed ?? 0,
@@ -130,8 +134,8 @@ function createCreateGoalTool(context, options) {
130
134
  promptSnippet: 'Call create_goal only when the user or system explicitly requests a persistent goal. Do not infer a goal from an ordinary task.',
131
135
  inputSchema: Type.Object({
132
136
  objective: Type.String({ description: 'Concrete objective to pursue across automatic continuations.' }),
133
- token_budget: Type.Optional(Type.Number({ description: 'Optional soft token budget. Usage arrives after each model response, so the final turn can overshoot.' })),
134
- token_reserve: Type.Optional(Type.Number({ description: 'Optional non-negative minimum remaining tokens required before Pibo starts another turn.' })),
137
+ token_budget: Type.Optional(Type.Number({ description: 'Optional soft uncached-token budget. Cache reads and writes are excluded. Usage arrives after each model response, so the final turn can overshoot.' })),
138
+ token_reserve: Type.Optional(Type.Number({ description: 'Optional non-negative minimum remaining uncached tokens required before Pibo starts another turn.' })),
135
139
  }),
136
140
  async execute(_toolCallId, params) {
137
141
  try {
@@ -182,11 +186,12 @@ function createUpdateGoalTool(context, options) {
182
186
  const job = store.updateGoalStatus(existing.id, status);
183
187
  if (!job)
184
188
  throw new Error('goal no longer exists');
189
+ const tokenBasis = goalTokenAccounting(job).basis;
185
190
  return toolResult({
186
191
  ok: true,
187
192
  goal: goalPayload(job),
188
193
  ...(status === 'complete' && job.tokenBudget !== undefined
189
- ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed against a soft budget before the current model turn finishes` }
194
+ ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported ${tokenBasis} tokens consumed against a soft budget before the current model turn finishes` }
190
195
  : {}),
191
196
  });
192
197
  });
@@ -36,8 +36,9 @@ MCP config lookup order:
36
36
  1. -c/--config <path>
37
37
  2. MCP_CONFIG_PATH
38
38
  3. ./mcp_servers.json
39
- 4. ~/.mcp_servers.json
40
- 5. ~/.config/mcp/mcp_servers.json
39
+ 4. ~/mcp_servers.json
40
+ 5. ~/.mcp_servers.json
41
+ 6. ~/.config/mcp/mcp_servers.json
41
42
  `);
42
43
  }
43
44
  export function printConfigSchema() {
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import { homedir } from 'node:os';
8
- import { dirname, join, resolve } from 'node:path';
8
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
9
9
  import { ErrorCode, configInvalidJsonError, configMissingFieldError, configNotFoundError, configSearchError, formatCliError, serverNotFoundError, } from './errors.js';
10
10
  export const DEFAULT_MCP_CONFIG_FILE = 'mcp_servers.json';
11
11
  // ============================================================================
@@ -306,9 +306,15 @@ export function getDefaultConfigPaths() {
306
306
  const home = homedir();
307
307
  // Current directory
308
308
  paths.push(resolve(DEFAULT_MCP_CONFIG_FILE));
309
- // Home directory variants
310
- paths.push(join(home, '.mcp_servers.json'));
311
- paths.push(join(home, '.config', 'mcp', 'mcp_servers.json'));
309
+ // Home directory variants. Include the non-dot filename because `pibo mcp
310
+ // config` creates it when invoked from the home directory, and services can
311
+ // later run with a different working directory. Ignore empty or relative
312
+ // platform home values instead of treating them as paths below the cwd.
313
+ if (home && isAbsolute(home)) {
314
+ paths.push(join(home, DEFAULT_MCP_CONFIG_FILE));
315
+ paths.push(join(home, '.mcp_servers.json'));
316
+ paths.push(join(home, '.config', 'mcp', 'mcp_servers.json'));
317
+ }
312
318
  return paths;
313
319
  }
314
320
  export function getConfigSearchPaths(explicitPath) {
@@ -24,7 +24,7 @@ export function configSearchError() {
24
24
  code: ErrorCode.CLIENT_ERROR,
25
25
  type: 'CONFIG_NOT_FOUND',
26
26
  message: 'No mcp_servers.json found in search paths',
27
- details: 'Searched: ./mcp_servers.json, ~/.mcp_servers.json, ~/.config/mcp/mcp_servers.json',
27
+ details: 'Searched: ./mcp_servers.json, ~/mcp_servers.json, ~/.mcp_servers.json, ~/.config/mcp/mcp_servers.json',
28
28
  suggestion: 'Create mcp_servers.json in current directory or use -c/--config to specify path',
29
29
  };
30
30
  }
package/dist/mcp/index.js CHANGED
@@ -9,9 +9,8 @@
9
9
  * mcp-cli call <server> <tool> Call tool (reads JSON from stdin if no args)
10
10
  * mcp-cli call <server> <tool> {} Call tool with JSON args
11
11
  */
12
- import { readFileSync } from 'node:fs';
13
12
  import { configCommand } from './config-command.js';
14
- import { ensureConfigExists, } from './config.js';
13
+ import { ensureConfigExists, listServerNames, loadConfigUnresolved, } from './config.js';
15
14
  import { ErrorCode, ambiguousCommandError, formatCliError, missingArgumentError, tooManyArgumentsError, unknownOptionError, unknownSubcommandError, } from './errors.js';
16
15
  import { registryCommand } from './registry.js';
17
16
  import { VERSION } from './version.js';
@@ -127,37 +126,8 @@ function parseArgs(args) {
127
126
  result.command = 'info';
128
127
  const remaining = positional.slice(1);
129
128
  const { server, tool } = parseServerTool(remaining);
130
- // info requires a server argument - show available servers in error
131
129
  if (!server) {
132
- // Try to load config synchronously to show available servers
133
- let availableServers = [];
134
- const configPaths = [
135
- result.configPath,
136
- process.env.MCP_CONFIG_PATH,
137
- './mcp_servers.json',
138
- `${process.env.HOME}/.mcp_servers.json`,
139
- `${process.env.HOME}/.config/mcp/mcp_servers.json`,
140
- ].filter(Boolean);
141
- for (const cfgPath of configPaths) {
142
- try {
143
- const content = readFileSync(cfgPath, 'utf-8');
144
- const config = JSON.parse(content);
145
- if (config.mcpServers) {
146
- availableServers = Object.keys(config.mcpServers);
147
- break;
148
- }
149
- }
150
- catch {
151
- // Try next path
152
- }
153
- }
154
- const serverList = availableServers.length > 0
155
- ? availableServers.join(', ')
156
- : '(none found)';
157
- console.error('Error [MISSING_ARGUMENT]: Missing required argument for info: server');
158
- console.error(` Available servers: ${serverList}`);
159
- console.error(` Suggestion: Use 'pibo mcp info <server>' to see server details, or just 'pibo mcp' to list all`);
160
- process.exit(ErrorCode.CLIENT_ERROR);
130
+ return result;
161
131
  }
162
132
  result.server = server;
163
133
  result.tool = tool;
@@ -371,10 +341,26 @@ export async function runMcpCli(argv = process.argv) {
371
341
  }
372
342
  break;
373
343
  case 'info':
344
+ if (!args.server) {
345
+ let availableServers = [];
346
+ try {
347
+ availableServers = listServerNames(await loadConfigUnresolved(args.configPath));
348
+ }
349
+ catch {
350
+ // Keep the missing-argument error focused when no valid config exists.
351
+ }
352
+ const serverList = availableServers.length > 0
353
+ ? availableServers.join(', ')
354
+ : '(none found)';
355
+ console.error('Error [MISSING_ARGUMENT]: Missing required argument for info: server');
356
+ console.error(` Available servers: ${serverList}`);
357
+ console.error(` Suggestion: Use 'pibo mcp info <server>' to see server details, or just 'pibo mcp' to list all`);
358
+ process.exitCode = ErrorCode.CLIENT_ERROR;
359
+ break;
360
+ }
374
361
  await ensureConfigExists(args.configPath);
375
362
  {
376
363
  const { infoCommand } = await import('./commands/info.js');
377
- // info always has a server (validated in parseArgs)
378
364
  await infoCommand({
379
365
  target: buildTarget(args.server, args.tool),
380
366
  withDescriptions: args.withDescriptions,
@@ -149,7 +149,8 @@ export class PiboReliabilityStore {
149
149
  timeout_at TEXT,
150
150
  timeout_phase TEXT,
151
151
  service_warning TEXT,
152
- resource_json TEXT
152
+ resource_json TEXT,
153
+ origin_json TEXT
153
154
  );
154
155
  CREATE INDEX IF NOT EXISTS idx_pibo_runs_controller_updated
155
156
  ON pibo_runs(controller_pibo_session_id, updated_at);
@@ -161,6 +162,7 @@ export class PiboReliabilityStore {
161
162
  ensurePiboRunColumn(this.db, "timeout_phase", "TEXT");
162
163
  ensurePiboRunColumn(this.db, "service_warning", "TEXT");
163
164
  ensurePiboRunColumn(this.db, "resource_json", "TEXT");
165
+ ensurePiboRunColumn(this.db, "origin_json", "TEXT");
164
166
  this.appendEventStatement = this.db.prepare(`
165
167
  INSERT INTO pibo_event_stream (topic, key, event_id, idempotency_key, created_at, retention_class, payload_json)
166
168
  VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -502,10 +504,10 @@ export class PiboReliabilityStore {
502
504
  INSERT INTO pibo_runs (
503
505
  run_id, kind, controller_pibo_session_id, status, completion_policy, consumed, tool_name,
504
506
  summary, result_json, error, notified_status, acknowledged_status, created_at, updated_at,
505
- completed_at, job_id, retryable, max_attempts, timeout_ms, timeout_at, timeout_phase, service_warning, resource_json
506
- ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)
507
+ completed_at, job_id, retryable, max_attempts, timeout_ms, timeout_at, timeout_phase, service_warning, resource_json, origin_json
508
+ ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
507
509
  `)
508
- .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, input.resources ? JSON.stringify(input.resources) : null);
510
+ .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, input.resources ? JSON.stringify(input.resources) : null, input.origin ? JSON.stringify(input.origin) : null);
509
511
  this.claimJob(job.jobId, input.workerId ?? `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
510
512
  return this.requireRun(runId);
511
513
  }
@@ -534,10 +536,11 @@ export class PiboReliabilityStore {
534
536
  timeout_at = ?,
535
537
  timeout_phase = ?,
536
538
  service_warning = ?,
537
- resource_json = ?
539
+ resource_json = ?,
540
+ origin_json = ?
538
541
  WHERE run_id = ?
539
542
  `)
540
- .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, next.resources ? JSON.stringify(next.resources) : null, runId);
543
+ .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, next.resources ? JSON.stringify(next.resources) : null, next.origin ? JSON.stringify(next.origin) : null, runId);
541
544
  return this.requireRun(runId);
542
545
  }
543
546
  getRun(runId) {
@@ -810,6 +813,8 @@ function runFromRow(row) {
810
813
  output.serviceWarning = row.service_warning;
811
814
  if (row.resource_json)
812
815
  output.resources = JSON.parse(row.resource_json);
816
+ if (row.origin_json)
817
+ output.origin = JSON.parse(row.origin_json);
813
818
  return output;
814
819
  }
815
820
  function retryDelayMs(attempts, input) {
@@ -6,6 +6,18 @@ export class PiboRunExecutionTimeoutError extends Error {
6
6
  this.name = "PiboRunExecutionTimeoutError";
7
7
  }
8
8
  }
9
+ export class PiboRunCancellationError extends Error {
10
+ constructor(message, options) {
11
+ super(message, options);
12
+ this.name = "PiboRunCancellationError";
13
+ }
14
+ }
15
+ export class PiboRunCancelledError extends Error {
16
+ constructor(message = "Yielded run was cancelled.", options) {
17
+ super(message, options);
18
+ this.name = "PiboRunCancelledError";
19
+ }
20
+ }
9
21
  export function resolveRunTimeoutMs(toolName, params) {
10
22
  if (!params || typeof params !== "object" || Array.isArray(params))
11
23
  return undefined;
@@ -7,6 +7,16 @@ function now() {
7
7
  function runTimeoutAt(createdAt, timeoutMs) {
8
8
  return timeoutMs === undefined ? undefined : new Date(Date.parse(createdAt) + timeoutMs).toISOString();
9
9
  }
10
+ function sameOrigin(left, right) {
11
+ if (!left || !right)
12
+ return left === right;
13
+ return left.eventId === right.eventId
14
+ && left.provenance.kind === right.provenance.kind
15
+ && left.provenance.jobId === right.provenance.jobId
16
+ && left.provenance.runId === right.provenance.runId
17
+ && left.provenance.cause === right.provenance.cause
18
+ && left.provenance.rootEventId === right.provenance.rootEventId;
19
+ }
10
20
  function formatTimeout(timeoutMs) {
11
21
  if (timeoutMs === undefined)
12
22
  return "its configured timeout";
@@ -83,6 +93,7 @@ export class PiboRunRegistry {
83
93
  serviceWarning: input.serviceWarning,
84
94
  resources: input.resources,
85
95
  workerId: this.workerId,
96
+ origin: input.origin,
86
97
  });
87
98
  const record = recordFromStored(stored);
88
99
  this.runs.set(record.runId, record);
@@ -108,6 +119,7 @@ export class PiboRunRegistry {
108
119
  ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs, timeoutAt: runTimeoutAt(timestamp, input.timeoutMs) } : {}),
109
120
  ...(input.serviceWarning ? { serviceWarning: input.serviceWarning } : {}),
110
121
  ...(input.resources ? { resources: structuredClone(input.resources) } : {}),
122
+ ...(input.origin ? { origin: structuredClone(input.origin) } : {}),
111
123
  };
112
124
  this.runs.set(runId, record);
113
125
  const output = snapshot(record);
@@ -245,7 +257,7 @@ export class PiboRunRegistry {
245
257
  }
246
258
  read(controllerPiboSessionId, runId) {
247
259
  const record = this.requireRunForController(controllerPiboSessionId, runId);
248
- if (terminal(record.status)) {
260
+ if (terminal(record.status) && !record.consumed) {
249
261
  record.consumed = true;
250
262
  record.updatedAt = now();
251
263
  this.options.store?.updateRun(runId, record);
@@ -277,6 +289,9 @@ export class PiboRunRegistry {
277
289
  }
278
290
  ack(controllerPiboSessionId, runId) {
279
291
  const record = this.requireRunForController(controllerPiboSessionId, runId);
292
+ const consumesTerminalRun = terminal(record.status) && !record.consumed;
293
+ if (record.acknowledgedStatus === record.status && !consumesTerminalRun)
294
+ return { ...snapshot(record), changed: false };
280
295
  record.acknowledgedStatus = record.status;
281
296
  if (terminal(record.status))
282
297
  record.consumed = true;
@@ -284,7 +299,7 @@ export class PiboRunRegistry {
284
299
  this.options.store?.updateRun(runId, record);
285
300
  const output = snapshot(record);
286
301
  this.notify({ type: "run_acknowledged", run: output });
287
- return output;
302
+ return { ...output, changed: true };
288
303
  }
289
304
  suppressNotification(controllerPiboSessionId, runId) {
290
305
  const record = this.requireRunForController(controllerPiboSessionId, runId);
@@ -306,14 +321,17 @@ export class PiboRunRegistry {
306
321
  return suppressed;
307
322
  }
308
323
  createNotification(controllerPiboSessionId, options = {}) {
309
- const records = [...this.runs.values()].filter((record) => this.needsNotification(record, controllerPiboSessionId, options));
310
- if (records.length === 0)
324
+ const pendingRecords = [...this.runs.values()].filter((record) => this.needsNotification(record, controllerPiboSessionId, options));
325
+ if (pendingRecords.length === 0)
311
326
  return undefined;
327
+ const origin = pendingRecords[0].origin;
328
+ const records = pendingRecords.filter((record) => sameOrigin(record.origin, origin));
312
329
  for (const record of records) {
313
330
  record.notifiedStatus = record.status;
314
331
  this.options.store?.updateRun(record.runId, record);
315
332
  }
316
333
  const notification = {
334
+ ...(origin ? { origin: structuredClone(origin) } : {}),
317
335
  completed: [],
318
336
  failed: [],
319
337
  timedOut: [],
@@ -461,5 +479,6 @@ function recordFromStored(record) {
461
479
  timeoutPhase: record.timeoutPhase,
462
480
  serviceWarning: record.serviceWarning,
463
481
  resources: record.resources,
482
+ origin: record.origin,
464
483
  };
465
484
  }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { piboStringEnum } from "../tools/schema.js";
3
3
  import { definePiboTool } from "../tools/contract.js";
4
- import { foregroundServiceWarning, hasMeaningfulTimeoutOutput, isConfiguredTimeoutError, PiboRunExecutionTimeoutError, resolveRunTimeoutMs } from "./lifecycle.js";
4
+ import { foregroundServiceWarning, hasMeaningfulTimeoutOutput, isConfiguredTimeoutError, PiboRunCancellationError, PiboRunCancelledError, PiboRunExecutionTimeoutError, resolveRunTimeoutMs } from "./lifecycle.js";
5
5
  import { PiboRunResourceLimitError, prepareYieldedRunExecution } from "./resource-isolation.js";
6
6
  function resultText(prefix, value) {
7
7
  return `${prefix}\n${JSON.stringify(value, null, 2)}`;
@@ -73,6 +73,7 @@ export function createRunToolDefinitions(yieldableTools, controller) {
73
73
  resolveExecutionSettled = resolve;
74
74
  });
75
75
  let observedOutput = false;
76
+ let cancellationFailure;
76
77
  const run = controller.startToolRun({
77
78
  toolName: tool.name,
78
79
  params: params.arguments,
@@ -95,6 +96,8 @@ export function createRunToolDefinitions(yieldableTools, controller) {
95
96
  throw processCancellationError;
96
97
  if (executionStarted)
97
98
  await waitForRunCancellationSettlement(executionSettled);
99
+ if (cancellationFailure)
100
+ throw cancellationFailure;
98
101
  },
99
102
  async execute() {
100
103
  executionStarted = true;
@@ -113,8 +116,13 @@ export function createRunToolDefinitions(yieldableTools, controller) {
113
116
  return { text, details: resultObject.details ?? result };
114
117
  }
115
118
  catch (error) {
116
- if (error instanceof PiboRunExecutionTimeoutError || error instanceof PiboRunResourceLimitError)
119
+ if (error instanceof PiboRunCancellationError)
120
+ cancellationFailure = error;
121
+ if (error instanceof PiboRunExecutionTimeoutError || error instanceof PiboRunResourceLimitError || error instanceof PiboRunCancellationError)
117
122
  throw error;
123
+ if (runAbortController.signal.aborted) {
124
+ throw new PiboRunCancelledError("Yielded run was cancelled; execution ended after cancellation.", { cause: error });
125
+ }
118
126
  if (timeoutMs !== undefined && isConfiguredTimeoutError(error))
119
127
  throw new PiboRunExecutionTimeoutError(error instanceof Error ? error.message : String(error), observedOutput ? "lifetime" : "startup");
120
128
  throw error;
@@ -240,8 +248,9 @@ export function createRunToolDefinitions(yieldableTools, controller) {
240
248
  }),
241
249
  async execute(_toolCallId, params) {
242
250
  const run = controller.ackRun(params.runId);
251
+ const prefix = run.changed ? `Acknowledged run ${run.runId}.` : `Run ${run.runId} was already acknowledged in state ${run.status}; no state changed.`;
243
252
  return {
244
- content: [{ type: "text", text: resultText(`Acknowledged run ${run.runId}.`, run) }],
253
+ content: [{ type: "text", text: resultText(prefix, run) }],
245
254
  details: run,
246
255
  };
247
256
  },
@@ -1,12 +1,16 @@
1
+ import { resolveSessionSignalStatus } from "../signals/status.js";
1
2
  export function resolveSessionActivity(signal, fallback = {}) {
2
3
  if (!signal)
3
4
  return fallbackSessionActivity(fallback);
4
5
  const latestTurn = signal.latestTurn;
5
6
  const isTurnActive = latestTurn?.state === "running";
6
7
  const isTreeActive = signal.isTreeActive || isTurnActive;
7
- const hasError = signal.hasError || signal.hasErrorDescendant || signal.aggregateStatus === "error";
8
8
  return {
9
- status: hasError ? "error" : isTreeActive ? "running" : "idle",
9
+ status: resolveSessionSignalStatus({
10
+ isTreeActive,
11
+ localStatus: signal.localStatus,
12
+ latestTurn,
13
+ }),
10
14
  isTreeActive,
11
15
  isTurnActive,
12
16
  activeTurnId: isTurnActive ? latestTurn.eventId : undefined,
@@ -1,12 +1,17 @@
1
+ export function resolveSessionSignalStatus(snapshot) {
2
+ if (snapshot.isTreeActive || snapshot.latestTurn?.state === "running")
3
+ return "running";
4
+ if (snapshot.localStatus === "error" || snapshot.latestTurn?.state === "failed")
5
+ return "error";
6
+ return "idle";
7
+ }
1
8
  export function summarizeSessionSignalStatus(snapshot) {
2
- const isTurnActive = snapshot.latestTurn?.state === "running";
3
- const hasError = snapshot.hasError || snapshot.hasErrorDescendant || snapshot.aggregateStatus === "error";
4
- const isTreeActive = snapshot.isTreeActive || isTurnActive;
9
+ const isTreeActive = snapshot.isTreeActive || snapshot.latestTurn?.state === "running";
5
10
  return {
6
11
  piboSessionId: snapshot.piboSessionId,
7
12
  rootPiboSessionId: snapshot.rootPiboSessionId,
8
13
  updatedAt: snapshot.updatedAt,
9
- status: isTreeActive ? "running" : hasError ? "error" : "idle",
14
+ status: resolveSessionSignalStatus(snapshot),
10
15
  isTreeActive,
11
16
  };
12
17
  }
@@ -46,7 +46,7 @@ pibo loop stop <job-id>
46
46
  pibo loop cancel <job-id>
47
47
  \`\`\`
48
48
 
49
- Token budgets count usage reported by completed assistant model messages. One request can overshoot because usage is known after the response returns.
49
+ New Goals persist token-accounting version \`1\` with basis \`uncached\`: their budgets count uncached input and output usage reported by completed assistant model messages, while cache-read and cache-write tokens remain telemetry and do not consume the budget. Legacy persisted Goal jobs and runs without an accounting descriptor remain on version \`1\` basis \`total\`, including cache reads and writes, so their existing counters are neither relabeled nor numerically reconstructed without source data. Inspect \`pibo loop list --all --json\` and \`pibo loop runs --job <job-id> --json\` for the persisted basis. Both bases are soft because usage arrives after the response, so one request can overshoot.
50
50
  `,
51
51
  };
52
52
  export const RALPH_GUIDE = {
@@ -284,11 +284,17 @@ export function createWebHostChannel(options = {}) {
284
284
  await sendResponse(nodeResponse, response ?? notFound());
285
285
  return;
286
286
  }
287
- if (url.pathname === "/" && apps[0]) {
288
- await sendResponse(nodeResponse, redirect(apps[0].mountPath));
289
- return;
290
- }
291
287
  if (url.pathname === "/") {
288
+ const landingApp = options.landingAppName
289
+ ? apps.find((candidate) => candidate.name === options.landingAppName)
290
+ : apps[0];
291
+ if (options.landingAppName && !landingApp) {
292
+ throw new Error(`Configured landing web app "${options.landingAppName}" is not registered`);
293
+ }
294
+ if (landingApp) {
295
+ await sendResponse(nodeResponse, redirect(`${landingApp.mountPath}${url.search}`));
296
+ return;
297
+ }
292
298
  await sendResponse(nodeResponse, responseHtml("<!doctype html><title>Pibo</title><p>No web apps registered.</p>"));
293
299
  return;
294
300
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@pasko70/pibo",
9
- "version": "2.4.0",
9
+ "version": "2.4.2",
10
10
  "workspaces": [
11
11
  "packages/workflows"
12
12
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  "packages/workflows"
@@ -51,7 +51,7 @@ Prefer creating the job stopped when its prompt, target, profile, or safety boun
51
51
 
52
52
  ## Token budgets
53
53
 
54
- Goal token budgets are soft: Pibo accumulates usage reported after model responses, so the final turn can overshoot. Each Goal run records tokens used before the turn, remaining tokens before the turn, turn usage, and overshoot.
54
+ Goal token budgets are soft because usage arrives after model responses, so the final turn can overshoot. New Goals use versioned `uncached` accounting: cache-read and cache-write tokens do not consume the budget. Legacy persisted Goals without an accounting descriptor remain on `total` accounting, including cache usage, because their existing counters cannot be reconstructed safely. Job status and each Goal run expose the accounting basis together with tokens used before the turn, remaining tokens, turn usage, and overshoot.
55
55
 
56
56
  Set `--token-reserve <n>` to require more than `n` tokens to remain before Pibo starts another turn. Increase or clear the budget, or lower the reserve, before resuming a budget-limited Goal.
57
57