@pasko70/pibo 1.10.1 → 1.11.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 (48) hide show
  1. package/dist/apps/chat/static-assets.js +10 -2
  2. package/dist/apps/chat/web-app.js +18 -2
  3. package/dist/apps/chat/workflow-manual-trigger-runtime.js +46 -149
  4. package/dist/apps/chat-ui/assets/{dist-BA6mhAec.js → dist-BBVpFHAq.js} +1 -1
  5. package/dist/apps/chat-ui/assets/{dist-hU-2oAb6.js → dist-BXyVMdHv.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-BPl-fzRB.js → dist-BiGfVaXN.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-BpfBSMzK.js → dist-CCGKu-Wj.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-uF6UXzI7.js → dist-CE0MvPLM.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-Cvx5M97R.js → dist-CuGiEm5l.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BPnn9e2B.js → dist-DBnh8gXR.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-r31x5Fcc.js → dist-DeOnZ-pw.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-4NlLjLs7.js → dist-DnQYnLQS.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{dist-DnXWNQRm.js → dist-VT4x40uL.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-3Sts0Afa.js → dist-xtnVygdr.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{index-al8DeEjA.css → index-DNeE4HrG.css} +1 -1
  16. package/dist/apps/chat-ui/assets/{index-CaTGYBOS.js → index-vcg8JNj9.js} +69 -69
  17. package/dist/apps/chat-ui/favicon.svg +4 -0
  18. package/dist/apps/chat-ui/index.html +4 -2
  19. package/dist/apps/chat-ui/sw.js +12 -1
  20. package/dist/apps/chat-vscode-web/assets/index-Bf2JvJ9z.css +2 -0
  21. package/dist/apps/chat-vscode-web/assets/index-DVlCO7v_.js +41 -0
  22. package/dist/apps/chat-vscode-web/index.html +2 -2
  23. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  24. package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.11.1.vsix +0 -0
  25. package/dist/auth/better-auth.js +49 -1
  26. package/dist/auth/cli.js +220 -0
  27. package/dist/auth/machine-keys.js +258 -0
  28. package/dist/auth/machine-session.js +123 -0
  29. package/dist/bin/pibo.js +0 -0
  30. package/dist/bin/rg.js +0 -0
  31. package/dist/cli.js +6 -0
  32. package/dist/compute/cli.js +7 -0
  33. package/dist/compute/resource-health.js +27 -3
  34. package/dist/config/config.js +5 -0
  35. package/dist/core/session-router.js +17 -0
  36. package/dist/data/schema.js +4 -0
  37. package/dist/debug/trace-status.js +25 -0
  38. package/dist/debug/trace.js +9 -10
  39. package/dist/resources/cli.js +15 -0
  40. package/dist/resources/lifecycle.js +28 -4
  41. package/dist/resources/reaper.js +1 -0
  42. package/dist/session-ui/terminalRows.js +10 -0
  43. package/dist/shared/trace-event-projection.js +20 -3
  44. package/dist/shared/trace-transcript.js +4 -0
  45. package/dist/signals/projector.js +10 -1
  46. package/package.json +3 -5
  47. package/dist/apps/chat-vscode-web/assets/index-B5QK07zO.css +0 -2
  48. package/dist/apps/chat-vscode-web/assets/index-CK4SMZuu.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
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));
@@ -76,8 +77,13 @@ export function buildComputeResourceHealth(options = {}) {
76
77
  const activeWorkerOwnedMainPids = new Set(mainProcesses
77
78
  .filter((process) => Boolean(findOwningActiveWorker(process, processes, workers)))
78
79
  .map((process) => process.pid));
79
- const unassignedMainProcessDetails = mainProcesses
80
- .filter((process) => !assignedMainPids.has(process.pid) && !activeWorkerOwnedMainPids.has(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))
81
87
  .map((process) => describeUnassignedBrowserProcess(process, workers));
82
88
  const unassignedChromiumMainProcesses = unassignedMainProcessDetails.length;
83
89
  const activePoolIds = perWorker.filter((pool) => pool.activeLeaseCount > 0).map((pool) => `${pool.workerId}/${pool.poolId}`);
@@ -121,6 +127,8 @@ export function buildComputeResourceHealth(options = {}) {
121
127
  totalChromiumMainProcesses: mainProcesses.length,
122
128
  unassignedChromiumMainProcesses,
123
129
  unassignedMainProcessDetails,
130
+ exemptChromiumMainProcesses: exemptMainProcessDetails.length,
131
+ exemptMainProcessDetails,
124
132
  perWorker,
125
133
  },
126
134
  browserLeases: { active: activePoolIds.length, activePoolIds, staleCdpFiles },
@@ -198,6 +206,21 @@ function readChromeArg(args, name) {
198
206
  const pattern = new RegExp(`(?:^|\\s)--${name}=([^\\s]+)`);
199
207
  return args.match(pattern)?.[1];
200
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
+ }
201
224
  function sanitizeArgsPreview(args) {
202
225
  const redacted = args
203
226
  .replace(/(token|access_token|refresh_token|password|passwd|cookie|secret)=([^\s]+)/gi, "$1=<redacted>")
@@ -224,6 +247,7 @@ export async function getComputeResourceHealth(options = {}) {
224
247
  browserPools,
225
248
  staleCdpFiles,
226
249
  reaperTimers: detectReaperTimerStatus(),
250
+ exemptBrowserUserDataDirs: options.exemptBrowserUserDataDirs,
227
251
  });
228
252
  }
229
253
  export function defaultBrowserUseHome() {
@@ -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",
@@ -244,6 +244,23 @@ export class PiboSessionRouter {
244
244
  }
245
245
  return output;
246
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
+ }
247
264
  finally {
248
265
  this.scheduleIdleSessionEvictionIfIdle(event.piboSessionId);
249
266
  }
@@ -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
@@ -0,0 +1,25 @@
1
+ export function resolveDebugTraceSessionStatus(sessionStatus, eventTypes) {
2
+ for (let index = eventTypes.length - 1; index >= 0; index -= 1) {
3
+ switch (eventTypes[index]) {
4
+ case "session_error":
5
+ return { status: "error", source: "event-log" };
6
+ case "message_started":
7
+ return { status: "running", source: "event-log" };
8
+ case "message_finished":
9
+ return { status: "idle", source: "event-log" };
10
+ }
11
+ }
12
+ return {
13
+ status: sessionStatus === "running" || sessionStatus === "error" ? sessionStatus : "idle",
14
+ source: "session-store",
15
+ };
16
+ }
17
+ export function summarizeDebugTraceStatus(sessionStatus, nodeStatuses) {
18
+ const errorNodeCount = nodeStatuses.filter((status) => status === "error").length;
19
+ if (sessionStatus === "error")
20
+ return { status: "error", errorNodeCount };
21
+ if (sessionStatus === "running" || nodeStatuses.some((status) => status === "running")) {
22
+ return { status: "running", errorNodeCount };
23
+ }
24
+ return { status: "done", errorNodeCount };
25
+ }
@@ -3,6 +3,7 @@ import { normalizeSessionErrorDetails } from "../core/session-errors.js";
3
3
  import { compareTraceNodes } from "../shared/trace-nodes.js";
4
4
  import { openReadOnlyDebugDatabase, withStorePath } from "./sql.js";
5
5
  import { formatNextCommands } from "./next-commands.js";
6
+ import { resolveDebugTraceSessionStatus, summarizeDebugTraceStatus } from "./trace-status.js";
6
7
  export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
7
8
  if (!stores.sessions.exists)
8
9
  throw new Error(`Debug store "sessions" not found at ${stores.sessions.path}`);
@@ -22,19 +23,23 @@ export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
22
23
  .prepare("SELECT stream_id, session_id, session_sequence, event_id, type, created_at, preview_text, attributes_json FROM event_log WHERE session_id = ? ORDER BY stream_id ASC")
23
24
  .all(piboSessionId).map((row) => eventFromRow(row, adapterIssues)).filter((event) => event !== undefined)
24
25
  : [];
26
+ const sessionStatus = resolveDebugTraceSessionStatus(sessionRow.status, events.map((event) => event.type));
25
27
  const view = await buildTraceView({
26
28
  session,
27
29
  sessions,
28
30
  events,
29
- status: sessionRow.status === "running" || sessionRow.status === "error" ? sessionRow.status : "idle",
31
+ status: sessionStatus.status,
30
32
  });
31
33
  const rows = flattenTraceNodes(view.nodes);
34
+ const statusSummary = summarizeDebugTraceStatus(sessionStatus.status, rows.map((node) => node.status));
32
35
  const filtered = options.runningOnly ? rows.filter((node) => node.status === "running") : rows;
33
36
  return {
34
37
  piboSessionId: view.piboSessionId,
35
38
  piSessionId: view.piSessionId,
36
39
  title: view.title,
37
- status: traceStatus(view),
40
+ status: statusSummary.status,
41
+ statusSource: sessionStatus.source,
42
+ errorNodeCount: statusSummary.errorNodeCount,
38
43
  nodes: filtered,
39
44
  rawNodeCount: rows.length,
40
45
  ...(options.check ? { checks: checkTraceView(view, adapterIssues) } : {}),
@@ -69,6 +74,8 @@ export function formatDebugTrace(result, options = {}) {
69
74
  `piSessionId: ${result.piSessionId}`,
70
75
  `title: ${result.title}`,
71
76
  `status: ${result.status}`,
77
+ `statusSource: ${result.statusSource}`,
78
+ `nodeErrors: ${result.errorNodeCount}`,
72
79
  "",
73
80
  ];
74
81
  if (result.nodes.length === 0) {
@@ -161,14 +168,6 @@ function flattenTraceNodes(nodes, depth = 0) {
161
168
  ...flattenTraceNodes(node.children, depth + 1),
162
169
  ]);
163
170
  }
164
- function traceStatus(view) {
165
- const rows = flattenTraceNodes(view.nodes);
166
- if (rows.some((node) => node.status === "error"))
167
- return "error";
168
- if (rows.some((node) => node.status === "running"))
169
- return "running";
170
- return "done";
171
- }
172
171
  export function checkTraceView(view, adapterIssues = []) {
173
172
  const issues = [...adapterIssues];
174
173
  const all = flattenPiboTraceNodes(view.nodes);
@@ -1,3 +1,4 @@
1
+ import { isAbsolute, resolve } from "node:path";
1
2
  import { Command } from "commander";
2
3
  import { getComputeResourceHealth } from "../compute/resource-health.js";
3
4
  import { renderComputeResourceHealthText } from "../compute/cli.js";
@@ -17,6 +18,15 @@ function parsePidList(value) {
17
18
  throw new Error("PIDs must be positive integers separated by commas");
18
19
  return [...new Set(pids)];
19
20
  }
21
+ function parseAbsolutePathList(value) {
22
+ const paths = value.split(",").map((item) => item.trim());
23
+ if (paths.some((path) => !path || !isAbsolute(path)))
24
+ throw new Error("Browser user-data directories must be absolute paths separated by commas");
25
+ return [...new Set(paths.map((path) => resolve(path)))];
26
+ }
27
+ function shellQuote(value) {
28
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
29
+ }
20
30
  export function renderResourceLeasesText(leases) {
21
31
  if (leases.length === 0)
22
32
  return "No active managed browser-pool leases.\nNext: pibo resources status";
@@ -53,6 +63,10 @@ export function renderResourceReapText(value) {
53
63
  ];
54
64
  if (plan.options.includeDev)
55
65
  args.push("--include-dev");
66
+ if (plan.options.exemptBrowserPids.length > 0)
67
+ args.push(`--exempt-browser-pids ${plan.options.exemptBrowserPids.join(",")}`);
68
+ if (plan.options.exemptBrowserUserDataDirs.length > 0)
69
+ args.push(`--exempt-browser-user-data-dirs ${shellQuote(plan.options.exemptBrowserUserDataDirs.join(","))}`);
56
70
  if (plan.options.browserPoolRoot)
57
71
  args.push(`--browser-pool-root ${plan.options.browserPoolRoot}`);
58
72
  if (plan.options.browserUseHome)
@@ -118,6 +132,7 @@ export async function runResourcesCli(argv) {
118
132
  .option("--idle-timeout-minutes <n>", "Select browser pools idle for this many minutes", parseNonNegativeNumber, 10)
119
133
  .option("--unmanaged-browser-grace-minutes <n>", "Select unmanaged Chromium older than this many minutes", parseNonNegativeNumber, 10)
120
134
  .option("--exempt-browser-pids <list>", "Comma-separated browser PIDs or process groups to preserve", parsePidList)
135
+ .option("--exempt-browser-user-data-dirs <list>", "Comma-separated absolute browser profile directories to preserve", parseAbsolutePathList)
121
136
  .option("--browser-pool-root <path>", "Browser pool root directory to scan")
122
137
  .option("--browser-use-home <path>", "Browser-use home directory to scan for stale CDP files")
123
138
  .option("--json", "Print machine-readable cleanup plan or result")
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir, readFile, rm } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { applyComputeWorkerReapPlan, buildComputeWorkerReapPlan, planReapWorkers, } from "../compute/docker.js";
7
7
  import { defaultBrowserPoolRoot, defaultBrowserUseHome, getComputeResourceHealth, parseProcessList, } from "../compute/resource-health.js";
@@ -60,9 +60,14 @@ export async function planResourceReap(options = {}) {
60
60
  collectManagedBrowserPools(resolved.browserPoolRoot),
61
61
  planStaleCdpFiles(resolved.browserUseHome),
62
62
  planComputeReapSafely({ includeDev: resolved.includeDev, maxAgeMinutes: resolved.maxAgeMinutes, now }),
63
- getComputeResourceHealth({ now, browserPoolRoot: resolved.browserPoolRoot, browserUseHome: resolved.browserUseHome }),
63
+ getComputeResourceHealth({
64
+ now,
65
+ browserPoolRoot: resolved.browserPoolRoot,
66
+ browserUseHome: resolved.browserUseHome,
67
+ exemptBrowserUserDataDirs: [],
68
+ }),
64
69
  ]);
65
- const unmanagedBrowsers = buildUnmanagedBrowserPlanItems(health.browserProcesses.unassignedMainProcessDetails, resolved.unmanagedBrowserGraceMinutes, new Set(resolved.exemptBrowserPids));
70
+ const unmanagedBrowsers = buildUnmanagedBrowserPlanItems(health.browserProcesses.unassignedMainProcessDetails, resolved.unmanagedBrowserGraceMinutes, new Set(resolved.exemptBrowserPids), new Set(resolved.exemptBrowserUserDataDirs));
66
71
  return buildResourceReapPlan({ now, options: resolved, records, staleFiles, unmanagedBrowsers, compute });
67
72
  }
68
73
  export function buildResourceReapPlan(input) {
@@ -126,9 +131,10 @@ function resolveReapOptions(options) {
126
131
  browserPoolRoot: options.browserPoolRoot ?? defaultBrowserPoolRoot(),
127
132
  browserUseHome: options.browserUseHome ?? defaultBrowserUseHome(),
128
133
  exemptBrowserPids: options.exemptBrowserPids ?? readExemptBrowserPids(),
134
+ exemptBrowserUserDataDirs: normalizeBrowserUserDataDirs(options.exemptBrowserUserDataDirs ?? readExemptBrowserUserDataDirs()),
129
135
  };
130
136
  }
131
- export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPids = new Set()) {
137
+ export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPids = new Set(), exemptUserDataDirs = new Set()) {
132
138
  const graceSeconds = graceMinutes * 60;
133
139
  return processes.map((process) => {
134
140
  let action = "terminate";
@@ -141,6 +147,10 @@ export function buildUnmanagedBrowserPlanItems(processes, graceMinutes, exemptPi
141
147
  action = "skip";
142
148
  reason = "explicitly exempted pid or process group";
143
149
  }
150
+ else if (browserUserDataDirIsExempt(process.userDataDir, exemptUserDataDirs)) {
151
+ action = "skip";
152
+ reason = "explicitly exempted browser user-data-dir";
153
+ }
144
154
  else if (process.elapsedSeconds !== undefined && process.elapsedSeconds < graceSeconds) {
145
155
  action = "skip";
146
156
  reason = `process age ${process.elapsedSeconds}s is within ${graceSeconds}s grace period`;
@@ -211,6 +221,20 @@ function readExemptBrowserPids() {
211
221
  .map((value) => Number.parseInt(value.trim(), 10))
212
222
  .filter((value) => Number.isInteger(value) && value > 0);
213
223
  }
224
+ function readExemptBrowserUserDataDirs() {
225
+ return (process.env.PIBO_RESOURCE_REAPER_EXEMPT_BROWSER_USER_DATA_DIRS ?? "").split(",");
226
+ }
227
+ function normalizeBrowserUserDataDirs(values) {
228
+ return [...new Set([...values]
229
+ .map((value) => value.trim())
230
+ .filter((value) => value.length > 0 && isAbsolute(value))
231
+ .map((value) => resolve(value)))];
232
+ }
233
+ function browserUserDataDirIsExempt(userDataDir, exemptions) {
234
+ if (!userDataDir || !isAbsolute(userDataDir))
235
+ return false;
236
+ return exemptions.has(resolve(userDataDir));
237
+ }
214
238
  function buildBrowserReapPlanItem(record, now, idleTimeoutMinutes) {
215
239
  const { state } = record;
216
240
  let action = "skip";
@@ -76,6 +76,7 @@ export class ResourceReaperService {
76
76
  browserPoolRoot: this.options.browserPoolRoot,
77
77
  browserUseHome: this.options.browserUseHome,
78
78
  exemptBrowserPids: this.options.exemptBrowserPids,
79
+ exemptBrowserUserDataDirs: this.options.exemptBrowserUserDataDirs,
79
80
  now: runAt,
80
81
  });
81
82
  result = await this.apply(plan);
@@ -205,6 +205,7 @@ function createUserMessageRow(node) {
205
205
  lines: [{ prefix: "prompt", tokens: [token(text)] }],
206
206
  sourceNodeIds: [node.id],
207
207
  forkEntryId: node.entryId,
208
+ pendingMessageDelivery: pendingUserMessageDelivery(node),
208
209
  startedAt: node.startedAt,
209
210
  output: text,
210
211
  payloadRefs: node.payloadRefs,
@@ -1193,6 +1194,15 @@ function delegationVerb(status) {
1193
1194
  return "Spawn failed";
1194
1195
  return "Spawned";
1195
1196
  }
1197
+ function pendingUserMessageDelivery(node) {
1198
+ if (node.status !== "running")
1199
+ return undefined;
1200
+ if (node.id.startsWith("event:message_steered:"))
1201
+ return "steer";
1202
+ if (node.id.startsWith("event:message_queued:"))
1203
+ return "queue";
1204
+ return undefined;
1205
+ }
1196
1206
  function asyncVerb(status) {
1197
1207
  if (status === "running")
1198
1208
  return "Waiting for";
@@ -42,6 +42,14 @@ export function applySingleEventToNodes(nodes, byId, piboSessionId, storedEvent,
42
42
  const node = traceNodeFromEvent(piboSessionId, payload, childByParent, linkedChildByToolCallId, sessionStatus, storedEvent.createdAt, storedEvent.eventSequence, storedEvent.streamId, storedEvent.streamFrameIndex);
43
43
  if (!node)
44
44
  return;
45
+ if (node.type === "user.message" &&
46
+ node.status === "running" &&
47
+ !node.parentId &&
48
+ payload.type === "message_steered") {
49
+ const activeTurn = [...byId.values()].reverse().find((candidate) => candidate.type === "agent.turn" && candidate.status === "running");
50
+ if (activeTurn)
51
+ node.parentId = activeTurn.id;
52
+ }
45
53
  if (node.type === "agent.turn" && node.eventId) {
46
54
  const existingTurn = [...byId.values()].find((candidate) => candidate.type === "agent.turn" && candidate.eventId === node.eventId);
47
55
  if (existingTurn) {
@@ -94,8 +102,12 @@ export function applySingleEventToNodes(nodes, byId, piboSessionId, storedEvent,
94
102
  if (node.eventId) {
95
103
  const existing = byId.get(node.id);
96
104
  if (existing) {
97
- if (node.type === "user.message" && node.parentId)
98
- existing.parentId = node.parentId;
105
+ if (node.type === "user.message") {
106
+ existing.status = node.status;
107
+ existing.parentId = node.parentId ?? existing.parentId;
108
+ existing.summary = node.summary ?? existing.summary;
109
+ existing.output = node.output ?? existing.output;
110
+ }
99
111
  return;
100
112
  }
101
113
  }
@@ -297,7 +309,7 @@ function traceNodeFromEvent(piboSessionId, event, childByParent, linkedChildByTo
297
309
  ...(event.type === "message_steered" && event.activeEventId ? { parentId: messageTurnNodeId(event.activeEventId) } : {}),
298
310
  type: "user.message",
299
311
  title: "User Message",
300
- status: "done",
312
+ status: isOptimisticUserMessageEvent(event) ? "running" : "done",
301
313
  summary: event.text,
302
314
  output: event.text,
303
315
  };
@@ -538,6 +550,11 @@ function mergeReasoningEvent(target, update) {
538
550
  target.output = update.output ?? target.output;
539
551
  target.completedAt = update.completedAt ?? target.completedAt;
540
552
  }
553
+ function isOptimisticUserMessageEvent(event) {
554
+ return ((event.type === "message_queued" || event.type === "message_steered") &&
555
+ "clientTxnId" in event &&
556
+ typeof event.clientTxnId === "string");
557
+ }
541
558
  function isInternalSessionOperation(action) {
542
559
  return action === "session.fork" || action === "session.clone" || action === "session.switch";
543
560
  }
@@ -181,6 +181,7 @@ function createAssistantTurnNodes(piboSessionId, entries, timing) {
181
181
  return [];
182
182
  const orderedNodes = [];
183
183
  const toolsByCallId = new Map();
184
+ let assistantIndex = 0;
184
185
  for (const { entry, index: entryIndex } of entries) {
185
186
  if (messageRole(entry) === "toolResult") {
186
187
  mergePersistedToolResult(toolsByCallId, orderedNodes, entry, piboSessionId, entryIndex);
@@ -224,6 +225,9 @@ function createAssistantTurnNodes(piboSessionId, entries, timing) {
224
225
  if (responseNode) {
225
226
  responseNode.status = responseStatus;
226
227
  responseNode.error = responseError;
228
+ if (timing?.eventId)
229
+ responseNode.stableKey = `assistant:${timing.eventId}:assistant:${assistantIndex}`;
230
+ assistantIndex += 1;
227
231
  }
228
232
  }
229
233
  const finalNode = orderedNodes.at(-1);
@@ -28,7 +28,7 @@ function settleActiveSessionNodes(piboSessionId, context, status, terminalSource
28
28
  }
29
29
  export const sessionLifecycleSignalProducer = {
30
30
  name: "session-lifecycle",
31
- accepts: (input) => ["session_created", "session_disposed", "session_processing_changed", "message_accepted", "queue_changed", "recovery", "session_interrupted", "signal_node_pruned"].includes(input.type),
31
+ accepts: (input) => ["session_created", "session_disposed", "session_processing_changed", "message_accepted", "message_rejected", "queue_changed", "recovery", "session_interrupted", "signal_node_pruned"].includes(input.type),
32
32
  project(input, context) {
33
33
  const data = input;
34
34
  if (data.type === "session_created") {
@@ -78,6 +78,15 @@ export const sessionLifecycleSignalProducer = {
78
78
  }
79
79
  return mutations;
80
80
  }
81
+ if (data.type === "message_rejected") {
82
+ const acceptedTurn = context.getNode(`turn:${data.piboSessionId}:${data.eventId}`);
83
+ return [
84
+ { type: "remove_node", nodeId: `message:${data.piboSessionId}:${data.eventId}` },
85
+ ...(acceptedTurn?.metadata?.accepted === true
86
+ ? [{ type: "remove_node", nodeId: acceptedTurn.id }]
87
+ : []),
88
+ ];
89
+ }
81
90
  if (data.type === "session_disposed") {
82
91
  return [
83
92
  ...settleActiveSessionNodes(data.piboSessionId, context, "cancelled", "session_disposed"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "1.10.1",
3
+ "version": "1.11.1",
4
4
  "type": "module",
5
5
  "imports": {
6
6
  "vscode": "./src/apps/chat-vscode/extension/src/vscode-shim.js"
@@ -57,8 +57,6 @@
57
57
  "@earendil-works/pi-ai": "0.80.6",
58
58
  "@earendil-works/pi-coding-agent": "0.80.6",
59
59
  "@earendil-works/pi-tui": "0.80.6",
60
- "@langchain/core": "^1.2.3",
61
- "@langchain/langgraph": "^1.4.8",
62
60
  "@mdxeditor/editor": "^3.55.0",
63
61
  "@modelcontextprotocol/sdk": "^1.29.0",
64
62
  "@tailwindcss/vite": "^4.2.4",
@@ -79,8 +77,7 @@
79
77
  "react-markdown": "^10.1.0",
80
78
  "react-virtuoso": "^4.18.6",
81
79
  "remark-gfm": "^4.0.1",
82
- "tailwindcss": "^4.2.4",
83
- "zod": "4.4.1"
80
+ "tailwindcss": "^4.2.4"
84
81
  },
85
82
  "devDependencies": {
86
83
  "@tanstack/router-plugin": "^1.167.28",
@@ -91,6 +88,7 @@
91
88
  "@vitejs/plugin-react": "^6.0.1",
92
89
  "@vscode/vsce": "^3.6.0",
93
90
  "esbuild": "^0.27.7",
91
+ "react-test-renderer": "19.2.5",
94
92
  "tsx": "^4.21.0",
95
93
  "typescript": "^6.0.3",
96
94
  "vite": "^8.0.10",