@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,123 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export const PIBO_MACHINE_SESSION_COOKIE = "pibo_machine_session";
3
+ export const DEFAULT_MACHINE_SESSION_TTL_SECONDS = 8 * 60 * 60;
4
+ const MACHINE_SESSION_VERSION = 1;
5
+ const MAX_COOKIE_VALUE_LENGTH = 1024;
6
+ function signPayload(secret, encodedPayload) {
7
+ return createHmac("sha256", secret).update(encodedPayload, "utf8").digest();
8
+ }
9
+ function encodePayload(secret, payload) {
10
+ const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
11
+ const signature = signPayload(secret, encodedPayload).toString("base64url");
12
+ return `${encodedPayload}.${signature}`;
13
+ }
14
+ function decodePayload(secret, value) {
15
+ if (value.length === 0 || value.length > MAX_COOKIE_VALUE_LENGTH)
16
+ return undefined;
17
+ const parts = value.split(".");
18
+ if (parts.length !== 2 || !parts[0] || !parts[1])
19
+ return undefined;
20
+ let providedSignature;
21
+ try {
22
+ providedSignature = Buffer.from(parts[1], "base64url");
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ const expectedSignature = signPayload(secret, parts[0]);
28
+ if (providedSignature.length !== expectedSignature.length ||
29
+ !timingSafeEqual(providedSignature, expectedSignature)) {
30
+ return undefined;
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
34
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
35
+ return undefined;
36
+ const payload = parsed;
37
+ if (payload.v !== MACHINE_SESSION_VERSION ||
38
+ typeof payload.keyId !== "string" ||
39
+ !/^[a-f0-9]{16}$/.test(payload.keyId) ||
40
+ typeof payload.expiresAt !== "number" ||
41
+ !Number.isSafeInteger(payload.expiresAt)) {
42
+ return undefined;
43
+ }
44
+ return { v: MACHINE_SESSION_VERSION, keyId: payload.keyId, expiresAt: payload.expiresAt };
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ function cookieValue(headers) {
51
+ const cookieHeader = headers.get("cookie");
52
+ if (!cookieHeader)
53
+ return undefined;
54
+ for (const part of cookieHeader.split(";")) {
55
+ const separator = part.indexOf("=");
56
+ if (separator < 0)
57
+ continue;
58
+ const name = part.slice(0, separator).trim();
59
+ if (name !== PIBO_MACHINE_SESSION_COOKIE)
60
+ continue;
61
+ return part.slice(separator + 1).trim();
62
+ }
63
+ return undefined;
64
+ }
65
+ function sessionCookieHeader(value, expiresAt, maxAgeSeconds) {
66
+ return [
67
+ `${PIBO_MACHINE_SESSION_COOKIE}=${value}`,
68
+ "Path=/",
69
+ "HttpOnly",
70
+ "Secure",
71
+ "SameSite=Strict",
72
+ `Max-Age=${maxAgeSeconds}`,
73
+ `Expires=${expiresAt.toUTCString()}`,
74
+ ].join("; ");
75
+ }
76
+ export function createMachineSessionManager(options) {
77
+ const ttlSeconds = options.ttlSeconds ?? DEFAULT_MACHINE_SESSION_TTL_SECONDS;
78
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
79
+ throw new Error("Machine session TTL must be a positive integer");
80
+ }
81
+ const now = options.now ?? (() => new Date());
82
+ return {
83
+ create(authentication) {
84
+ const createdAt = now();
85
+ const ttlExpiry = createdAt.getTime() + ttlSeconds * 1000;
86
+ const keyExpiry = authentication.session.expiresAt?.getTime();
87
+ const expiryMs = keyExpiry === undefined ? ttlExpiry : Math.min(ttlExpiry, keyExpiry);
88
+ if (expiryMs <= createdAt.getTime())
89
+ throw new Error("Machine key is expired");
90
+ const expiresAt = new Date(expiryMs);
91
+ const value = encodePayload(options.secret, {
92
+ v: MACHINE_SESSION_VERSION,
93
+ keyId: authentication.id,
94
+ expiresAt: expiryMs,
95
+ });
96
+ return {
97
+ header: sessionCookieHeader(value, expiresAt, Math.max(1, Math.floor((expiryMs - createdAt.getTime()) / 1000))),
98
+ expiresAt,
99
+ session: { ...authentication.session, expiresAt },
100
+ };
101
+ },
102
+ getSession(headers) {
103
+ const value = cookieValue(headers);
104
+ if (!value)
105
+ return undefined;
106
+ const payload = decodePayload(options.secret, value);
107
+ const currentTime = now().getTime();
108
+ if (!payload || payload.expiresAt <= currentTime)
109
+ return undefined;
110
+ const keySession = options.machineKeys.getSessionById(payload.keyId);
111
+ if (!keySession)
112
+ return undefined;
113
+ const keyExpiry = keySession.expiresAt?.getTime();
114
+ const effectiveExpiry = keyExpiry === undefined ? payload.expiresAt : Math.min(payload.expiresAt, keyExpiry);
115
+ if (effectiveExpiry <= currentTime)
116
+ return undefined;
117
+ return { ...keySession, expiresAt: new Date(effectiveExpiry) };
118
+ },
119
+ clearHeader() {
120
+ return sessionCookieHeader("", new Date(0), 0);
121
+ },
122
+ };
123
+ }
package/dist/bin/pibo.js CHANGED
File without changes
package/dist/bin/rg.js CHANGED
File without changes
@@ -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
  }
package/dist/cli.js CHANGED
@@ -80,6 +80,11 @@ export async function runPiboCli(argv = process.argv) {
80
80
  printPiboVersion();
81
81
  return;
82
82
  }
83
+ if (argv[2] === "auth") {
84
+ const { runAuthCli } = await import("./auth/cli.js");
85
+ await runAuthCli([argv[0] ?? "node", "pibo auth", ...argv.slice(3)]);
86
+ return;
87
+ }
83
88
  if (argv[2] === "mcp") {
84
89
  const { runMcpCli } = await import("./mcp/index.js");
85
90
  await runMcpCli([argv[0] ?? "node", "pibo mcp", ...argv.slice(3)]);
@@ -465,6 +470,7 @@ function printRootDiscoveryText() {
465
470
 
466
471
  Commands:
467
472
  config Manage local pibo config
473
+ auth Manage Web authentication and machine identities
468
474
  mcp Discover and call configured MCP servers
469
475
  tools Install and inspect curated external CLI tools
470
476
  pi-packages Register Pi Coding Agent packages
@@ -123,6 +123,13 @@ export function renderComputeResourceHealthText(health) {
123
123
  const lines = [`Compute resource health: ${health.severity} (read-only)`];
124
124
  lines.push(`Generated at: ${health.generatedAt}`);
125
125
  lines.push(`Browser processes: ${health.browserProcesses.totalChromiumMainProcesses} main / ${health.browserProcesses.totalChromiumProcesses} total Chromium processes`);
126
+ if (health.browserProcesses.exemptMainProcessDetails.length > 0) {
127
+ lines.push(`Explicitly exempt browser main processes: ${health.browserProcesses.exemptMainProcessDetails.length}`);
128
+ lines.push("PID\tUSER_DATA_DIR\tCOMMAND");
129
+ for (const process of health.browserProcesses.exemptMainProcessDetails.slice(0, 5)) {
130
+ lines.push(`${process.pid}\t${process.userDataDir ?? "-"}\t${process.commandName}`);
131
+ }
132
+ }
126
133
  if (health.browserProcesses.unassignedMainProcessDetails.length > 0) {
127
134
  lines.push(`Unmanaged browser main processes: ${health.browserProcesses.unassignedMainProcessDetails.length}`);
128
135
  lines.push("PID\tWORKER\tUSER_DATA_DIR\tCOMMAND");
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir, readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { basename, join } from "node:path";
5
+ import { basename, isAbsolute, join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { readResourceReaperTimerStatus } from "../resources/reaper-state.js";
8
8
  import { createEmptyBrowserPoolState, normalizeBrowserPoolState } from "../tools/browser-pool.js";
@@ -43,6 +43,7 @@ export function buildComputeResourceHealth(options = {}) {
43
43
  const disk = options.disk;
44
44
  const checks = [];
45
45
  const mainProcesses = processes.filter((process) => process.isChromium && process.isMainProcess);
46
+ const exemptBrowserUserDataDirs = new Set(normalizeBrowserUserDataDirs(options.exemptBrowserUserDataDirs ?? readExemptBrowserUserDataDirs()));
46
47
  const perWorker = browserPools.map(({ state, statePath }) => {
47
48
  const activeLeaseCount = state.activeLeaseId ? Math.max(1, state.activeLeaseCount ?? 1) : state.activeLeaseCount ?? 0;
48
49
  const matched = mainProcesses.filter((process) => browserProcessMatchesPool(process, state));
@@ -73,8 +74,16 @@ export function buildComputeResourceHealth(options = {}) {
73
74
  if (browserProcessMatchesPool(process, pool.state))
74
75
  assignedMainPids.add(process.pid);
75
76
  }
76
- const unassignedMainProcessDetails = mainProcesses
77
- .filter((process) => !assignedMainPids.has(process.pid))
77
+ const activeWorkerOwnedMainPids = new Set(mainProcesses
78
+ .filter((process) => Boolean(findOwningActiveWorker(process, processes, workers)))
79
+ .map((process) => process.pid));
80
+ const unmatchedMainProcesses = mainProcesses
81
+ .filter((process) => !assignedMainPids.has(process.pid) && !activeWorkerOwnedMainPids.has(process.pid));
82
+ const exemptMainProcessDetails = unmatchedMainProcesses
83
+ .filter((process) => browserProcessMatchesExemptUserDataDir(process, exemptBrowserUserDataDirs))
84
+ .map((process) => describeUnassignedBrowserProcess(process, workers));
85
+ const unassignedMainProcessDetails = unmatchedMainProcesses
86
+ .filter((process) => !browserProcessMatchesExemptUserDataDir(process, exemptBrowserUserDataDirs))
78
87
  .map((process) => describeUnassignedBrowserProcess(process, workers));
79
88
  const unassignedChromiumMainProcesses = unassignedMainProcessDetails.length;
80
89
  const activePoolIds = perWorker.filter((pool) => pool.activeLeaseCount > 0).map((pool) => `${pool.workerId}/${pool.poolId}`);
@@ -118,6 +127,8 @@ export function buildComputeResourceHealth(options = {}) {
118
127
  totalChromiumMainProcesses: mainProcesses.length,
119
128
  unassignedChromiumMainProcesses,
120
129
  unassignedMainProcessDetails,
130
+ exemptChromiumMainProcesses: exemptMainProcessDetails.length,
131
+ exemptMainProcessDetails,
121
132
  perWorker,
122
133
  },
123
134
  browserLeases: { active: activePoolIds.length, activePoolIds, staleCdpFiles },
@@ -148,6 +159,28 @@ function browserLeakMessage(unassignedCount) {
148
159
  return `${unassignedCount} unmanaged Chromium main process(es) are not associated with a managed browser pool.`;
149
160
  return "Chromium main-process count exceeds managed pool expectations.";
150
161
  }
162
+ function findOwningActiveWorker(process, processes, workers) {
163
+ const containerId = process.containerId;
164
+ if (containerId && containerId.length >= 12) {
165
+ const byContainer = workers.find((worker) => worker.state === "running" && (worker.id === containerId || worker.id.startsWith(containerId) || containerId.startsWith(worker.id)));
166
+ if (byContainer)
167
+ return byContainer;
168
+ }
169
+ const activeWorkersByPid = new Map(workers
170
+ .filter((worker) => worker.state === "running" && worker.hostPid !== undefined && worker.hostPid > 1)
171
+ .map((worker) => [worker.hostPid, worker]));
172
+ const processByPid = new Map(processes.map((candidate) => [candidate.pid, candidate]));
173
+ const visited = new Set();
174
+ let current = process;
175
+ while (current && current.pid > 1 && !visited.has(current.pid)) {
176
+ visited.add(current.pid);
177
+ const direct = activeWorkersByPid.get(current.pid) ?? activeWorkersByPid.get(current.ppid);
178
+ if (direct)
179
+ return direct;
180
+ current = processByPid.get(current.ppid);
181
+ }
182
+ return undefined;
183
+ }
151
184
  function describeUnassignedBrowserProcess(process, workers) {
152
185
  const worker = workers.find((candidate) => candidate.hostPid !== undefined && candidate.hostPid > 0 && candidate.hostPid === process.ppid);
153
186
  const userDataDir = readChromeArg(process.args, "user-data-dir");
@@ -173,6 +206,21 @@ function readChromeArg(args, name) {
173
206
  const pattern = new RegExp(`(?:^|\\s)--${name}=([^\\s]+)`);
174
207
  return args.match(pattern)?.[1];
175
208
  }
209
+ function readExemptBrowserUserDataDirs() {
210
+ return (process.env.PIBO_RESOURCE_REAPER_EXEMPT_BROWSER_USER_DATA_DIRS ?? "").split(",");
211
+ }
212
+ function normalizeBrowserUserDataDirs(values) {
213
+ return [...new Set([...values]
214
+ .map((value) => value.trim())
215
+ .filter((value) => value.length > 0 && isAbsolute(value))
216
+ .map((value) => resolve(value)))];
217
+ }
218
+ function browserProcessMatchesExemptUserDataDir(process, exemptions) {
219
+ const userDataDir = readChromeArg(process.args, "user-data-dir");
220
+ if (!userDataDir || !isAbsolute(userDataDir))
221
+ return false;
222
+ return exemptions.has(resolve(userDataDir));
223
+ }
176
224
  function sanitizeArgsPreview(args) {
177
225
  const redacted = args
178
226
  .replace(/(token|access_token|refresh_token|password|passwd|cookie|secret)=([^\s]+)/gi, "$1=<redacted>")
@@ -199,6 +247,7 @@ export async function getComputeResourceHealth(options = {}) {
199
247
  browserPools,
200
248
  staleCdpFiles,
201
249
  reaperTimers: detectReaperTimerStatus(),
250
+ exemptBrowserUserDataDirs: options.exemptBrowserUserDataDirs,
202
251
  });
203
252
  }
204
253
  export function defaultBrowserUseHome() {
@@ -248,7 +297,12 @@ async function collectWorkers() {
248
297
  async function collectProcesses() {
249
298
  try {
250
299
  const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,pgid=,etimes=,comm=,args="], { maxBuffer: 10 * 1024 * 1024 });
251
- return { processes: parseProcessList(stdout) };
300
+ const processes = parseProcessList(stdout);
301
+ await Promise.all(processes.filter((process) => process.isChromium && process.isMainProcess).map(async (process) => {
302
+ const cgroup = await readFile(`/proc/${process.pid}/cgroup`, "utf8").catch(() => "");
303
+ process.containerId = parseDockerContainerIdFromCgroup(cgroup);
304
+ }));
305
+ return { processes };
252
306
  }
253
307
  catch (error) {
254
308
  return { processes: [], error: error instanceof Error ? error.message : String(error) };
@@ -299,6 +353,9 @@ function detectReaperTimerStatus() {
299
353
  }
300
354
  return readResourceReaperTimerStatus();
301
355
  }
356
+ export function parseDockerContainerIdFromCgroup(value) {
357
+ return value.match(/(?:^|\/)docker[-/]([a-f0-9]{12,64})(?:\.scope|\/|$)/im)?.[1];
358
+ }
302
359
  function browserProcessMatchesPool(process, state) {
303
360
  if (state.pid && process.pid === state.pid)
304
361
  return true;
@@ -43,6 +43,11 @@ export const PIBO_CONFIG_KEYS = [
43
43
  type: "string",
44
44
  description: "SQLite path for Better Auth data.",
45
45
  },
46
+ {
47
+ key: "auth.machineKeyStorePath",
48
+ type: "string",
49
+ description: "Optional path for revocable machine-key records. Default $PIBO_HOME/machine-keys.json.",
50
+ },
46
51
  {
47
52
  key: "auth.mode",
48
53
  type: "string",
@@ -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" });
@@ -232,6 +244,23 @@ export class PiboSessionRouter {
232
244
  }
233
245
  return output;
234
246
  }
247
+ catch (error) {
248
+ if (event.type === "message" && event.id) {
249
+ this.signalRegistry.project({
250
+ type: "message_rejected",
251
+ piboSessionId: event.piboSessionId,
252
+ eventId: event.id,
253
+ });
254
+ const status = session.getStatus();
255
+ this.signalRegistry.project({
256
+ type: "session_processing_changed",
257
+ piboSessionId: event.piboSessionId,
258
+ processing: status.processing,
259
+ queuedMessages: status.queuedMessages,
260
+ });
261
+ }
262
+ throw error;
263
+ }
235
264
  finally {
236
265
  this.scheduleIdleSessionEvictionIfIdle(event.piboSessionId);
237
266
  }
@@ -667,7 +696,7 @@ export class PiboSessionRouter {
667
696
  }
668
697
  createRunToolController(parentPiboSessionId) {
669
698
  return {
670
- startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, execute }) => {
699
+ startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, timeoutMs, serviceWarning, execute }) => {
671
700
  assertGatewayResourceAvailableForWork(`yielded run ${toolName}`);
672
701
  const run = this.runRegistry.startToolRun({
673
702
  controllerPiboSessionId: parentPiboSessionId,
@@ -676,6 +705,8 @@ export class PiboSessionRouter {
676
705
  completionPolicy,
677
706
  retryable,
678
707
  maxAttempts,
708
+ timeoutMs,
709
+ serviceWarning,
679
710
  });
680
711
  void (async () => {
681
712
  try {
@@ -685,8 +716,11 @@ export class PiboSessionRouter {
685
716
  this.scheduleRunReminder(parentPiboSessionId, false);
686
717
  }
687
718
  catch (error) {
688
- const failed = this.runRegistry.fail(run.runId, error instanceof Error ? error.message : String(error));
689
- if (failed)
719
+ const message = error instanceof Error ? error.message : String(error);
720
+ const terminalRun = error instanceof PiboRunExecutionTimeoutError
721
+ ? this.runRegistry.timeOut(run.runId, message, error.timeoutPhase)
722
+ : this.runRegistry.fail(run.runId, message);
723
+ if (terminalRun)
690
724
  this.scheduleRunReminder(parentPiboSessionId, false);
691
725
  }
692
726
  })();
@@ -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")
@@ -354,6 +354,10 @@ export function applyPiboDataSchema(db) {
354
354
  ON sessions(channel, kind, updated_at DESC);
355
355
  CREATE INDEX IF NOT EXISTS idx_event_log_session_stream
356
356
  ON event_log(session_id, stream_id);
357
+ CREATE INDEX IF NOT EXISTS idx_event_log_unread_session_stream
358
+ ON event_log(session_id, stream_id)
359
+ WHERE (retention_class = 'chat_message' AND type IN ('user.message.accepted', 'assistant_message'))
360
+ OR type = 'session_error';
357
361
  CREATE INDEX IF NOT EXISTS idx_event_log_session_sequence_stream
358
362
  ON event_log(session_id, session_sequence DESC, stream_id DESC);
359
363
  CREATE INDEX IF NOT EXISTS idx_event_log_session_type_sequence_stream
@@ -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
  }