@pasko70/pibo 1.10.0 → 1.10.1

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 (54) 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-LHRs1Nhr.js → dist-3Sts0Afa.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-wE9nop9V.js → dist-4NlLjLs7.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-BwKObYnX.js → dist-BA6mhAec.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-Y-AA2omI.js → dist-BPl-fzRB.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-GdEM8UW1.js → dist-BPnn9e2B.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DlATLa-U.js → dist-BpfBSMzK.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-CS7wdk0Z.js → dist-Cvx5M97R.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-nOLTkZrJ.js → dist-DnXWNQRm.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{dist-DUlaXAk7.js → dist-hU-2oAb6.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-CKtT8YGm.js → dist-r31x5Fcc.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{dist-D-cxLQO1.js → dist-uF6UXzI7.js} +1 -1
  16. package/dist/apps/chat-ui/assets/index-CaTGYBOS.js +173 -0
  17. package/dist/apps/chat-ui/assets/{index-C0x9nEcf.css → index-al8DeEjA.css} +1 -1
  18. package/dist/apps/chat-ui/index.html +2 -2
  19. package/dist/apps/chat-vscode-web/assets/index-CK4SMZuu.js +41 -0
  20. package/dist/apps/chat-vscode-web/index.html +1 -1
  21. package/dist/cli-session/localSessionSource.js +34 -12
  22. package/dist/compute/resource-health.js +35 -2
  23. package/dist/core/events.js +6 -1
  24. package/dist/core/routed-session.js +67 -4
  25. package/dist/core/session-router.js +23 -6
  26. package/dist/data/ingest-service.js +3 -1
  27. package/dist/debug/index.js +4 -0
  28. package/dist/debug/trace.js +42 -7
  29. package/dist/index.js +1 -0
  30. package/dist/loops/accounting.js +19 -0
  31. package/dist/loops/cli.js +22 -6
  32. package/dist/loops/prompts.js +4 -1
  33. package/dist/loops/service.js +97 -8
  34. package/dist/loops/store.js +128 -17
  35. package/dist/loops/tools.js +24 -7
  36. package/dist/reliability/store.js +34 -7
  37. package/dist/runs/lifecycle.js +59 -0
  38. package/dist/runs/registry.js +47 -1
  39. package/dist/runs/tools.js +31 -13
  40. package/dist/session-ui/terminalRows.js +66 -2
  41. package/dist/shared/trace-async-agent-runs.js +4 -4
  42. package/dist/shared/trace-event-projection.js +59 -19
  43. package/dist/shared/trace-nodes.js +5 -0
  44. package/dist/shared/trace-page-merge.js +15 -0
  45. package/dist/shared/trace-run-notifications.js +3 -1
  46. package/dist/signals/projector.js +6 -2
  47. package/dist/tools/browser-pool.js +50 -0
  48. package/dist/tools/browser-use-leases.js +12 -8
  49. package/dist/tools/guides.js +8 -1
  50. package/dist/tools/index.js +1 -0
  51. package/package.json +2 -1
  52. package/skills/builtin/loop/SKILL.md +12 -3
  53. package/dist/apps/chat-ui/assets/index-8W_yMHQI.js +0 -173
  54. package/dist/apps/chat-vscode-web/assets/index-BAMxIaI_.js +0 -41
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="theme-color" content="#101d22" />
7
7
  <title>Pibo</title>
8
- <script type="module" crossorigin src="/apps/chat-vscode/assets/index-BAMxIaI_.js"></script>
8
+ <script type="module" crossorigin src="/apps/chat-vscode/assets/index-CK4SMZuu.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/apps/chat-vscode/assets/index-B5QK07zO.css">
10
10
  </head>
11
11
  <body>
@@ -29,6 +29,8 @@ export class LocalCliSessionSource {
29
29
  listeners = new Map();
30
30
  openHandles = new Set();
31
31
  closed = false;
32
+ closing = false;
33
+ closePromise;
32
34
  constructor(options = {}) {
33
35
  this.sessionStore =
34
36
  options.sessionStore ?? createDefaultPiboDataSessionStore();
@@ -349,19 +351,39 @@ export class LocalCliSessionSource {
349
351
  };
350
352
  }
351
353
  async close() {
354
+ if (this.closePromise)
355
+ return this.closePromise;
356
+ this.closePromise = this.closeUnsafe();
357
+ return this.closePromise;
358
+ }
359
+ async closeUnsafe() {
352
360
  if (this.closed)
353
361
  return;
354
- this.closed = true;
355
- for (const handle of [...this.openHandles])
356
- handle.close();
357
- this.listeners.clear();
358
- this.unsubscribeRouter?.();
359
- if (this.ownsRouter)
360
- await this.router?.disposeAll?.();
361
- if (this.ownsSessionStore)
362
- this.sessionStore.close?.();
363
- if (this.ownsDataStore)
364
- this.dataStore?.close();
362
+ this.closing = true;
363
+ let routerError;
364
+ try {
365
+ if (this.ownsRouter)
366
+ await this.router?.disposeAll?.();
367
+ }
368
+ catch (error) {
369
+ routerError = error;
370
+ }
371
+ try {
372
+ this.closed = true;
373
+ for (const handle of [...this.openHandles])
374
+ handle.close();
375
+ this.listeners.clear();
376
+ this.unsubscribeRouter?.();
377
+ if (this.ownsSessionStore)
378
+ this.sessionStore.close?.();
379
+ if (this.ownsDataStore)
380
+ this.dataStore?.close();
381
+ }
382
+ finally {
383
+ this.closing = false;
384
+ }
385
+ if (routerError)
386
+ throw routerError;
365
387
  }
366
388
  listenerCount(sessionId) {
367
389
  if (sessionId)
@@ -825,7 +847,7 @@ export class LocalCliSessionSource {
825
847
  listener(update);
826
848
  }
827
849
  assertOpen() {
828
- if (this.closed)
850
+ if (this.closed || this.closing)
829
851
  throw new CliSourceError("source_closed", "Local CLI session source is closed");
830
852
  }
831
853
  }
@@ -73,8 +73,11 @@ export function buildComputeResourceHealth(options = {}) {
73
73
  if (browserProcessMatchesPool(process, pool.state))
74
74
  assignedMainPids.add(process.pid);
75
75
  }
76
+ const activeWorkerOwnedMainPids = new Set(mainProcesses
77
+ .filter((process) => Boolean(findOwningActiveWorker(process, processes, workers)))
78
+ .map((process) => process.pid));
76
79
  const unassignedMainProcessDetails = mainProcesses
77
- .filter((process) => !assignedMainPids.has(process.pid))
80
+ .filter((process) => !assignedMainPids.has(process.pid) && !activeWorkerOwnedMainPids.has(process.pid))
78
81
  .map((process) => describeUnassignedBrowserProcess(process, workers));
79
82
  const unassignedChromiumMainProcesses = unassignedMainProcessDetails.length;
80
83
  const activePoolIds = perWorker.filter((pool) => pool.activeLeaseCount > 0).map((pool) => `${pool.workerId}/${pool.poolId}`);
@@ -148,6 +151,28 @@ function browserLeakMessage(unassignedCount) {
148
151
  return `${unassignedCount} unmanaged Chromium main process(es) are not associated with a managed browser pool.`;
149
152
  return "Chromium main-process count exceeds managed pool expectations.";
150
153
  }
154
+ function findOwningActiveWorker(process, processes, workers) {
155
+ const containerId = process.containerId;
156
+ if (containerId && containerId.length >= 12) {
157
+ const byContainer = workers.find((worker) => worker.state === "running" && (worker.id === containerId || worker.id.startsWith(containerId) || containerId.startsWith(worker.id)));
158
+ if (byContainer)
159
+ return byContainer;
160
+ }
161
+ const activeWorkersByPid = new Map(workers
162
+ .filter((worker) => worker.state === "running" && worker.hostPid !== undefined && worker.hostPid > 1)
163
+ .map((worker) => [worker.hostPid, worker]));
164
+ const processByPid = new Map(processes.map((candidate) => [candidate.pid, candidate]));
165
+ const visited = new Set();
166
+ let current = process;
167
+ while (current && current.pid > 1 && !visited.has(current.pid)) {
168
+ visited.add(current.pid);
169
+ const direct = activeWorkersByPid.get(current.pid) ?? activeWorkersByPid.get(current.ppid);
170
+ if (direct)
171
+ return direct;
172
+ current = processByPid.get(current.ppid);
173
+ }
174
+ return undefined;
175
+ }
151
176
  function describeUnassignedBrowserProcess(process, workers) {
152
177
  const worker = workers.find((candidate) => candidate.hostPid !== undefined && candidate.hostPid > 0 && candidate.hostPid === process.ppid);
153
178
  const userDataDir = readChromeArg(process.args, "user-data-dir");
@@ -248,7 +273,12 @@ async function collectWorkers() {
248
273
  async function collectProcesses() {
249
274
  try {
250
275
  const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,pgid=,etimes=,comm=,args="], { maxBuffer: 10 * 1024 * 1024 });
251
- return { processes: parseProcessList(stdout) };
276
+ const processes = parseProcessList(stdout);
277
+ await Promise.all(processes.filter((process) => process.isChromium && process.isMainProcess).map(async (process) => {
278
+ const cgroup = await readFile(`/proc/${process.pid}/cgroup`, "utf8").catch(() => "");
279
+ process.containerId = parseDockerContainerIdFromCgroup(cgroup);
280
+ }));
281
+ return { processes };
252
282
  }
253
283
  catch (error) {
254
284
  return { processes: [], error: error instanceof Error ? error.message : String(error) };
@@ -299,6 +329,9 @@ function detectReaperTimerStatus() {
299
329
  }
300
330
  return readResourceReaperTimerStatus();
301
331
  }
332
+ export function parseDockerContainerIdFromCgroup(value) {
333
+ return value.match(/(?:^|\/)docker[-/]([a-f0-9]{12,64})(?:\.scope|\/|$)/im)?.[1];
334
+ }
302
335
  function browserProcessMatchesPool(process, state) {
303
336
  if (state.pid && process.pid === state.pid)
304
337
  return true;
@@ -1 +1,6 @@
1
- export {};
1
+ export class PiboSteeringUnavailableError extends Error {
2
+ constructor(message = "The active session cannot accept steering right now.", options) {
3
+ super(message, options);
4
+ this.name = "PiboSteeringUnavailableError";
5
+ }
6
+ }
@@ -1,4 +1,5 @@
1
1
  import { SessionManager, shouldCompact } from "@earendil-works/pi-coding-agent";
2
+ import { PiboSteeringUnavailableError } from "./events.js";
2
3
  import { getOpenAiCodexProviderUsageForActiveModel } from "../auth/openai-codex-usage.js";
3
4
  import { normalizeSessionErrorDetails, runtimeSessionErrorDetails } from "./session-errors.js";
4
5
  import { expandInlineSkills } from "./skill-expansion.js";
@@ -422,6 +423,8 @@ export class RoutedSession {
422
423
  queue = [];
423
424
  processing = false;
424
425
  disposed = false;
426
+ disposePromise;
427
+ drainPromise;
425
428
  fastMode = false;
426
429
  fastModePatchedAgents = new WeakSet();
427
430
  activeMessage;
@@ -683,7 +686,32 @@ export class RoutedSession {
683
686
  };
684
687
  this.emit(output);
685
688
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: this.disposed });
686
- void this.drain();
689
+ this.startDrain();
690
+ return output;
691
+ }
692
+ async steerMessage(event) {
693
+ this.assertActive();
694
+ const activeMessage = this.activeMessage;
695
+ if (!activeMessage || !this.processing || !this.runtime.session.isStreaming) {
696
+ throw new PiboSteeringUnavailableError();
697
+ }
698
+ const session = this.runtime.session;
699
+ const expandedText = expandInlineSkills(event.text, session.resourceLoader.getSkills().skills);
700
+ try {
701
+ await session.steer(expandedText);
702
+ }
703
+ catch (error) {
704
+ throw new PiboSteeringUnavailableError(`The active session could not accept steering: ${errorMessage(error)}`, { cause: error });
705
+ }
706
+ const output = {
707
+ type: "message_steered",
708
+ piboSessionId: this.piboSessionId,
709
+ eventId: event.id,
710
+ activeEventId: activeMessage.id,
711
+ text: event.text,
712
+ source: event.source,
713
+ };
714
+ this.emit(output);
687
715
  return output;
688
716
  }
689
717
  async executeAction(event) {
@@ -875,9 +903,26 @@ export class RoutedSession {
875
903
  return await this.runtime.session.compact(customInstructions);
876
904
  }
877
905
  async dispose() {
906
+ if (this.disposePromise)
907
+ return this.disposePromise;
908
+ this.disposePromise = this.disposeUnsafe();
909
+ return this.disposePromise;
910
+ }
911
+ async disposeUnsafe() {
878
912
  if (this.disposed)
879
913
  return;
914
+ const activeMessage = this.activeMessage;
880
915
  this.notifyMessagesInterrupted(this.activeAndQueuedMessages(), "session disposed");
916
+ if (activeMessage) {
917
+ const error = "Session disposed while a message was active.";
918
+ this.emit({
919
+ type: "session_error",
920
+ piboSessionId: this.piboSessionId,
921
+ eventId: activeMessage.id,
922
+ error,
923
+ errorDetails: runtimeSessionErrorDetails(error),
924
+ });
925
+ }
881
926
  this.cancelProviderRecovery();
882
927
  this.queue.length = 0;
883
928
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: true });
@@ -888,7 +933,15 @@ export class RoutedSession {
888
933
  this.recoverySession = undefined;
889
934
  }
890
935
  this.disposed = true;
891
- await this.runtime.dispose();
936
+ const abort = this.runtime.session.abort;
937
+ if (abort)
938
+ await Promise.allSettled([abort.call(this.runtime.session)]);
939
+ try {
940
+ await this.drainPromise;
941
+ }
942
+ finally {
943
+ await this.runtime.dispose();
944
+ }
892
945
  }
893
946
  async kill() {
894
947
  this.notifyMessagesInterrupted(this.activeAndQueuedMessages(), "session killed");
@@ -918,6 +971,16 @@ export class RoutedSession {
918
971
  }
919
972
  return false;
920
973
  }
974
+ startDrain() {
975
+ if (this.drainPromise)
976
+ return;
977
+ const drain = this.drain();
978
+ this.drainPromise = drain;
979
+ void drain.finally(() => {
980
+ if (this.drainPromise === drain)
981
+ this.drainPromise = undefined;
982
+ });
983
+ }
921
984
  async drain() {
922
985
  if (this.processing || this.disposed)
923
986
  return;
@@ -974,7 +1037,7 @@ export class RoutedSession {
974
1037
  }
975
1038
  }
976
1039
  catch (error) {
977
- if (error instanceof PiboProviderRecoveryCancelledError)
1040
+ if (error instanceof PiboProviderRecoveryCancelledError || this.disposed)
978
1041
  return;
979
1042
  const message = errorMessage(error);
980
1043
  this.emit({
@@ -1030,7 +1093,7 @@ export class RoutedSession {
1030
1093
  };
1031
1094
  this.emit(output);
1032
1095
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: this.disposed });
1033
- void this.drain();
1096
+ this.startDrain();
1034
1097
  return output;
1035
1098
  }
1036
1099
  async runAction(event) {
@@ -6,6 +6,7 @@ import { RoutedSession } from "./routed-session.js";
6
6
  import { runtimeSessionErrorDetails } from "./session-errors.js";
7
7
  import { createSubagentToolName } from "../subagents/tool.js";
8
8
  import { PiboRunRegistry } from "../runs/registry.js";
9
+ import { PiboRunExecutionTimeoutError } from "../runs/lifecycle.js";
9
10
  import { createPiboSignalRegistry } from "../signals/registry.js";
10
11
  import { createDefaultPiboReliabilityStore } from "../reliability/store.js";
11
12
  import { InMemoryPiboSessionStore, } from "../sessions/store.js";
@@ -85,6 +86,15 @@ function formatRunReminderMessage(notification) {
85
86
  toolName: run.toolName,
86
87
  summary: run.summary,
87
88
  })),
89
+ timedOut: notification.timedOut.map((run) => ({
90
+ runId: run.runId,
91
+ kind: run.kind,
92
+ status: run.status,
93
+ toolName: run.toolName,
94
+ summary: run.summary,
95
+ timeoutMs: run.timeoutMs,
96
+ timeoutPhase: run.timeoutPhase,
97
+ })),
88
98
  cancelled: notification.cancelled.map((run) => ({
89
99
  runId: run.runId,
90
100
  kind: run.kind,
@@ -99,7 +109,7 @@ function formatRunReminderMessage(notification) {
99
109
  toolName: run.toolName,
100
110
  summary: run.summary,
101
111
  })),
102
- instruction: "Use pibo_run_read for completed or failed runs. Use pibo_run_wait, pibo_run_status, pibo_run_cancel, or pibo_run_ack for runs you still need to manage.",
112
+ instruction: "Use pibo_run_read for completed, failed, or timed_out runs. Use pibo_run_wait, pibo_run_status, pibo_run_cancel, or pibo_run_ack for runs you still need to manage.",
103
113
  }),
104
114
  "</pibo_run_notification>",
105
115
  ].join("\n");
@@ -108,7 +118,7 @@ function isRunReminderServiceMessage(event) {
108
118
  return event.source === "service" && event.text.startsWith("<pibo_run_notification>");
109
119
  }
110
120
  function isTerminalRunStatus(status) {
111
- return status === "completed" || status === "failed" || status === "cancelled";
121
+ return status === "completed" || status === "failed" || status === "timed_out" || status === "cancelled";
112
122
  }
113
123
  function asJsonObject(value) {
114
124
  return value ?? {};
@@ -212,7 +222,9 @@ export class PiboSessionRouter {
212
222
  this.clearIdleSessionTimer(event.piboSessionId);
213
223
  try {
214
224
  if (event.type === "message") {
215
- return session.enqueueMessage(event);
225
+ return event.delivery === "steer"
226
+ ? await session.steerMessage(event)
227
+ : session.enqueueMessage(event);
216
228
  }
217
229
  if (event.action === "abort") {
218
230
  this.signalRegistry.project({ type: "session_interrupted", piboSessionId: event.piboSessionId, reason: "abort action" });
@@ -667,7 +679,7 @@ export class PiboSessionRouter {
667
679
  }
668
680
  createRunToolController(parentPiboSessionId) {
669
681
  return {
670
- startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, execute }) => {
682
+ startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, timeoutMs, serviceWarning, execute }) => {
671
683
  assertGatewayResourceAvailableForWork(`yielded run ${toolName}`);
672
684
  const run = this.runRegistry.startToolRun({
673
685
  controllerPiboSessionId: parentPiboSessionId,
@@ -676,6 +688,8 @@ export class PiboSessionRouter {
676
688
  completionPolicy,
677
689
  retryable,
678
690
  maxAttempts,
691
+ timeoutMs,
692
+ serviceWarning,
679
693
  });
680
694
  void (async () => {
681
695
  try {
@@ -685,8 +699,11 @@ export class PiboSessionRouter {
685
699
  this.scheduleRunReminder(parentPiboSessionId, false);
686
700
  }
687
701
  catch (error) {
688
- const failed = this.runRegistry.fail(run.runId, error instanceof Error ? error.message : String(error));
689
- if (failed)
702
+ const message = error instanceof Error ? error.message : String(error);
703
+ const terminalRun = error instanceof PiboRunExecutionTimeoutError
704
+ ? this.runRegistry.timeOut(run.runId, message, error.timeoutPhase)
705
+ : this.runRegistry.fail(run.runId, message);
706
+ if (terminalRun)
690
707
  this.scheduleRunReminder(parentPiboSessionId, false);
691
708
  }
692
709
  })();
@@ -303,7 +303,7 @@ function payloadForOutputEvent(event) {
303
303
  function previewTextForOutputEvent(event) {
304
304
  if (event.type === "assistant_message" || event.type === "assistant_delta" || event.type === "thinking_delta" || event.type === "thinking_finished")
305
305
  return previewText(event.text ?? "");
306
- if (event.type === "message_queued" || event.type === "message_started")
306
+ if (event.type === "message_queued" || event.type === "message_steered" || event.type === "message_started")
307
307
  return previewText(event.text);
308
308
  if (event.type === "tool_call" || event.type === "tool_execution_started" || event.type === "tool_execution_updated" || event.type === "tool_execution_finished")
309
309
  return event.toolName;
@@ -320,6 +320,8 @@ function previewTextForOutputEvent(event) {
320
320
  function attributesForOutputEvent(event) {
321
321
  if (event.type === "message_queued")
322
322
  return { inlineText: event.text, source: event.source, queuedMessages: event.queuedMessages };
323
+ if (event.type === "message_steered")
324
+ return { inlineText: event.text, source: event.source, activeEventId: event.activeEventId };
323
325
  if (event.type === "assistant_message" || event.type === "assistant_delta")
324
326
  return { assistantIndex: event.assistantIndex, contentIndex: event.contentIndex };
325
327
  if (event.type === "thinking_started" || event.type === "thinking_delta" || event.type === "thinking_finished")
@@ -985,6 +985,10 @@ function compactRunRow(run) {
985
985
  policy: run.completionPolicy,
986
986
  consumed: run.consumed,
987
987
  updatedAt: run.updatedAt,
988
+ timeoutMs: run.timeoutMs,
989
+ timeoutAt: run.timeoutAt,
990
+ timeoutPhase: run.timeoutPhase,
991
+ serviceWarning: run.serviceWarning,
988
992
  summary: run.summary,
989
993
  };
990
994
  }
@@ -1,6 +1,6 @@
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
6
  export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
@@ -16,10 +16,11 @@ export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
16
16
  throw new Error(`Pibo session "${piboSessionId}" not found`);
17
17
  const session = sessionFromRow(sessionRow);
18
18
  const sessions = sessionsDb.prepare("SELECT * FROM sessions").all().map(sessionFromRow);
19
+ const adapterIssues = [];
19
20
  const events = tableExists(chatDb, "event_log")
20
21
  ? chatDb
21
22
  .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)
23
+ .all(piboSessionId).map((row) => eventFromRow(row, adapterIssues)).filter((event) => event !== undefined)
23
24
  : [];
24
25
  const view = await buildTraceView({
25
26
  session,
@@ -36,7 +37,7 @@ export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
36
37
  status: traceStatus(view),
37
38
  nodes: filtered,
38
39
  rawNodeCount: rows.length,
39
- ...(options.check ? { checks: checkTraceView(view) } : {}),
40
+ ...(options.check ? { checks: checkTraceView(view, adapterIssues) } : {}),
40
41
  nextCommands: buildTraceNextCommands(view.piboSessionId, filtered),
41
42
  };
42
43
  }
@@ -168,10 +169,11 @@ function traceStatus(view) {
168
169
  return "running";
169
170
  return "done";
170
171
  }
171
- function checkTraceView(view) {
172
- const issues = [];
172
+ export function checkTraceView(view, adapterIssues = []) {
173
+ const issues = [...adapterIssues];
173
174
  const all = flattenPiboTraceNodes(view.nodes);
174
175
  const ids = new Set();
176
+ const stableKeyOwners = new Map();
175
177
  for (const node of all) {
176
178
  if (ids.has(node.id)) {
177
179
  issues.push({
@@ -206,6 +208,20 @@ function checkTraceView(view) {
206
208
  message: "Trace node has no conceptual stable key.",
207
209
  });
208
210
  }
211
+ else {
212
+ const existingOwner = stableKeyOwners.get(node.stableKey);
213
+ if (existingOwner && existingOwner !== node.id) {
214
+ issues.push({
215
+ severity: "warning",
216
+ code: "duplicate_stable_key",
217
+ nodeId: node.id,
218
+ message: `Stable key "${node.stableKey}" is already used by node "${existingOwner}".`,
219
+ });
220
+ }
221
+ else {
222
+ stableKeyOwners.set(node.stableKey, node.id);
223
+ }
224
+ }
209
225
  }
210
226
  for (const node of all) {
211
227
  if (node.parentId && !ids.has(node.parentId)) {
@@ -235,7 +251,7 @@ function checkSiblingOrder(nodes, issues) {
235
251
  checkSiblingOrder(node.children, issues);
236
252
  }
237
253
  function compareOrder(left, right) {
238
- return compareTraceOrder(left.orderKey, right.orderKey) || left.id.localeCompare(right.id);
254
+ return compareTraceNodes(left, right);
239
255
  }
240
256
  function flattenPiboTraceNodes(nodes) {
241
257
  return nodes.flatMap((node) => [node, ...flattenPiboTraceNodes(node.children)]);
@@ -288,10 +304,18 @@ function sessionFromRow(row) {
288
304
  updatedAt: row.updated_at,
289
305
  };
290
306
  }
291
- function eventFromRow(row) {
307
+ function eventFromRow(row, issues) {
292
308
  const payload = outputPayloadFromV2Row(row);
293
309
  if (!payload)
294
310
  return undefined;
311
+ if ((row.type === "assistant_delta" || row.type === "thinking_delta") && !nonEmptyEventText(payload)) {
312
+ issues.push({
313
+ severity: "warning",
314
+ code: "missing_delta_text",
315
+ nodeId: row.event_id ?? String(row.stream_id),
316
+ message: `Persisted ${row.type} event has no readable text payload.`,
317
+ });
318
+ }
295
319
  return {
296
320
  id: String(row.stream_id),
297
321
  piboSessionId: row.session_id ?? undefined,
@@ -315,12 +339,16 @@ function outputPayloadFromV2Row(row) {
315
339
  const base = { piboSessionId, eventId: row.event_id ?? undefined };
316
340
  if (row.type === "assistant_message")
317
341
  return compactObject({ ...base, type: "assistant_message", text: row.preview_text ?? "" });
342
+ if (row.type === "assistant_delta")
343
+ return compactObject({ ...base, type: "assistant_delta", text: inlineTextPayload(inlinePayload) ?? row.preview_text ?? "" });
318
344
  if (row.type === "message_started")
319
345
  return compactObject({ ...base, type: "message_started", text: row.preview_text ?? "" });
320
346
  if (row.type === "message_finished")
321
347
  return compactObject({ ...base, type: "message_finished" });
322
348
  if (row.type === "thinking_started")
323
349
  return compactObject({ ...base, type: "thinking_started" });
350
+ if (row.type === "thinking_delta")
351
+ return compactObject({ ...base, type: "thinking_delta", text: inlineTextPayload(inlinePayload) ?? row.preview_text ?? "" });
324
352
  if (row.type === "thinking_finished")
325
353
  return compactObject({ ...base, type: "thinking_finished", text: row.preview_text ?? "" });
326
354
  if (row.type === "tool_call")
@@ -337,6 +365,13 @@ function outputPayloadFromV2Row(row) {
337
365
  }
338
366
  return compactObject({ ...base, type: row.type });
339
367
  }
368
+ function inlineTextPayload(value) {
369
+ return typeof value === "string" ? value : undefined;
370
+ }
371
+ function nonEmptyEventText(event) {
372
+ const text = event.text;
373
+ return typeof text === "string" && text.length > 0;
374
+ }
340
375
  function stringAttribute(attributes, key) {
341
376
  const value = attributes[key];
342
377
  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:',