@pasko70/pibo 3.0.2 → 3.1.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 (72) hide show
  1. package/compute-image/Dockerfile +47 -0
  2. package/compute-image/Dockerfile.dockerignore +5 -0
  3. package/dist/agent-runtime/contract.js +78 -28
  4. package/dist/agent-runtime/portable-history.js +19 -9
  5. package/dist/agent-runtime/registry.js +8 -2
  6. package/dist/agent-runtime/routed-session.js +12 -6
  7. package/dist/agent-runtimes/pi/routed-session.js +10 -4
  8. package/dist/apps/chat/agent-store.js +55 -3
  9. package/dist/apps/chat/data/project-service.js +119 -15
  10. package/dist/apps/chat/data/room-service.js +21 -0
  11. package/dist/apps/chat/loop-api.js +10 -2
  12. package/dist/apps/chat/web-app.js +84 -25
  13. package/dist/apps/chat-ui/assets/{dist-CvaPTBTN.js → dist-CUQJqggN.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-BPotHecb.js → dist-ClUlQWYN.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{dist-BzqQKeVO.js → dist-Djul9BmZ.js} +1 -1
  16. package/dist/apps/chat-ui/assets/{dist-paagejNj.js → dist-GD0JGdCi.js} +1 -1
  17. package/dist/apps/chat-ui/assets/{dist-B6GZgWSj.js → dist-W5HOqHym.js} +1 -1
  18. package/dist/apps/chat-ui/assets/index-BcjkX-iP.js +228 -0
  19. package/dist/apps/chat-ui/assets/index-CmqRSbBU.css +1 -0
  20. package/dist/apps/chat-ui/index.html +2 -2
  21. package/dist/apps/chat-vscode-web/assets/index-JvrPUGvI.js +43 -0
  22. package/dist/apps/chat-vscode-web/index.html +1 -1
  23. package/dist/apps/cli-ui/InkSessionApp.js +111 -12
  24. package/dist/compute/cli.js +35 -16
  25. package/dist/compute/docker.js +341 -94
  26. package/dist/config/config.js +3 -0
  27. package/dist/core/output-render-sequence.js +148 -1
  28. package/dist/core/session-router.js +33 -105
  29. package/dist/data/ingest-service.js +14 -6
  30. package/dist/data/pibo-store.js +8 -1
  31. package/dist/data/schema.js +135 -5
  32. package/dist/debug/agents.js +91 -89
  33. package/dist/debug/index.js +2 -0
  34. package/dist/debug/pty.js +127 -48
  35. package/dist/gateway/cli.js +4 -0
  36. package/dist/loops/channel.js +3 -1
  37. package/dist/loops/cli.js +2 -1
  38. package/dist/loops/service.js +90 -11
  39. package/dist/loops/store.js +100 -74
  40. package/dist/mcp/agent-context.js +13 -26
  41. package/dist/mcp/commands/info.js +3 -1
  42. package/dist/mcp/config-command.js +46 -2
  43. package/dist/mcp/config.js +18 -4
  44. package/dist/plugins/context-files-store.js +6 -6
  45. package/dist/previews/base-url.js +34 -0
  46. package/dist/previews/config.js +2 -32
  47. package/dist/ralph/store.js +5 -0
  48. package/dist/reliability/store.js +174 -111
  49. package/dist/session-ui/terminalRows.js +2 -1
  50. package/dist/sessions/pibo-data-store.js +81 -0
  51. package/dist/subagents/observation-query.js +121 -0
  52. package/dist/tools/agent-browser-leases.js +58 -23
  53. package/dist/tools/agent-browser-wrapper.js +1 -1
  54. package/dist/tools/browser-use-leases.js +129 -16
  55. package/dist/tools/browser-use-wrapper.js +1 -1
  56. package/dist/tools/index.js +27 -11
  57. package/dist/tools/python-runtime.js +10 -3
  58. package/dist/tools/registry.js +1 -1
  59. package/dist/tools/runtime/node-backend.js +16 -5
  60. package/dist/tools/runtime/node-worker-source.js +112 -33
  61. package/dist/tools/runtime/python-backend.js +16 -5
  62. package/dist/tools/runtime/python-worker-source.js +167 -16
  63. package/dist/tools/runtime/registry.js +23 -10
  64. package/dist/user-skills/store.js +0 -1
  65. package/npm-shrinkwrap.json +9 -2
  66. package/package.json +8 -2
  67. package/scripts/docker-entrypoint.sh +46 -0
  68. package/scripts/prepare-agent-browser-wrapper.sh +70 -0
  69. package/scripts/prepare-browser-use-wrapper.sh +255 -0
  70. package/dist/apps/chat-ui/assets/index-6JJV-eic.js +0 -228
  71. package/dist/apps/chat-ui/assets/index-CeL9JPP5.css +0 -1
  72. package/dist/apps/chat-vscode-web/assets/index-U-MSErGa.js +0 -43
@@ -0,0 +1,47 @@
1
+ FROM node:24-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ python3 python3-venv python3-dev \
5
+ build-essential curl git ca-certificates \
6
+ chromium chromium-driver xvfb xauth x11-xserver-utils \
7
+ fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 \
8
+ libatk1.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 libnspr4 \
9
+ libnss3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 xdg-utils \
10
+ dbus dbus-x11 \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ RUN curl -LsSf https://astral.sh/uv/install.sh | sh
14
+ ENV PATH="/root/.local/bin:${PATH}"
15
+ ENV DISPLAY=:99
16
+ ENV PIBO_BROWSER_USE_CHROME=/usr/bin/chromium
17
+
18
+ # The host stages only the published package archive and this Dockerfile, so an
19
+ # installed package's production node_modules never enters the build context.
20
+ COPY pibo-package.tgz /tmp/pibo-package.tgz
21
+
22
+ WORKDIR /app
23
+ RUN npm init -y >/dev/null && \
24
+ npm install --omit=dev --no-audit --no-fund /tmp/pibo-package.tgz && \
25
+ rm -f /tmp/pibo-package.tgz
26
+
27
+ RUN mkdir -p /root/.pibo/tools/browser-use/home/bin && \
28
+ uv venv /root/.pibo/tools/browser-use/.venv --python 3.12 && \
29
+ uv pip install --python /root/.pibo/tools/browser-use/.venv/bin/python \
30
+ 'browser-use[cli]==0.12.6'
31
+
32
+ RUN mkdir -p /root/.pibo/tools/agent-browser/home/bin \
33
+ /root/.pibo/tools/agent-browser/home/profiles \
34
+ /root/.pibo/tools/agent-browser/node && \
35
+ npm install --prefix /root/.pibo/tools/agent-browser/node agent-browser@0.27.0
36
+
37
+ WORKDIR /app/node_modules/@pasko70/pibo
38
+ RUN sed -i 's/\r$//' scripts/*.sh && \
39
+ chmod +x scripts/*.sh && \
40
+ scripts/prepare-browser-use-wrapper.sh && \
41
+ scripts/prepare-agent-browser-wrapper.sh && \
42
+ mkdir -p /root/.pibo /root/.browser-use /root/.pibo/tools/agent-browser/home
43
+
44
+ EXPOSE 4788 4789 56663
45
+
46
+ ENTRYPOINT ["/app/node_modules/@pasko70/pibo/scripts/docker-entrypoint.sh"]
47
+ CMD ["gateway:web"]
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ node_modules/**
3
+ .pibo
4
+ .pibo/**
5
+ *.log
@@ -1,46 +1,96 @@
1
1
  import { AgentRuntimeContractError } from "./errors.js";
2
+ import { validateAgentRuntimeCapabilities } from "./capabilities.js";
2
3
  import { hasPendingNativeSession } from "../sessions/runtime-binding.js";
4
+ const REQUIRED_SESSION_METHODS = ["getBinding", "subscribe", "prompt", "abort", "dispose", "getStatus"];
5
+ const BINDING_STATES = new Set(["unbound", "bound", "missing", "error"]);
6
+ function isRecord(value) {
7
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
8
+ }
3
9
  export function validateAgentRuntimeSessionContract(session) {
4
10
  const errors = [];
5
- const capabilities = session.capabilities;
6
- const controls = session.controls;
11
+ if (!isRecord(session))
12
+ return ["session must be an object"];
13
+ for (const field of ["adapterId", "runtimeInstanceId", "cwd"]) {
14
+ if (typeof session[field] !== "string" || !session[field].trim()) {
15
+ errors.push(`session.${field} must be a non-empty string`);
16
+ }
17
+ }
18
+ for (const method of REQUIRED_SESSION_METHODS) {
19
+ if (typeof session[method] !== "function")
20
+ errors.push(`session.${method}() is required`);
21
+ }
22
+ let capabilities;
23
+ if (!isRecord(session.capabilities)) {
24
+ errors.push("session.capabilities must be an object");
25
+ }
26
+ else {
27
+ const capabilityErrors = validateAgentRuntimeCapabilities(session.capabilities);
28
+ errors.push(...capabilityErrors.map((error) => `session.capabilities.${error}`));
29
+ if (capabilityErrors.length === 0)
30
+ capabilities = session.capabilities;
31
+ }
32
+ const controls = isRecord(session.controls) ? session.controls : undefined;
33
+ if (session.controls !== undefined && !controls)
34
+ errors.push("session.controls must be an object when provided");
7
35
  const requireMethod = (enabled, path, method) => {
8
36
  if (enabled && typeof controls?.[method] !== "function") {
9
37
  errors.push(`${path} requires controls.${String(method)}()`);
10
38
  }
11
39
  };
12
- if (capabilities.input.steering && typeof session.steer !== "function") {
40
+ if (capabilities?.input.steering && typeof session.steer !== "function") {
13
41
  errors.push("input.steering requires session.steer()");
14
42
  }
15
- requireMethod(capabilities.lifecycle.listNativeSessions, "lifecycle.listNativeSessions", "listSessions");
16
- requireMethod(capabilities.lifecycle.fork, "lifecycle.fork", "forkSession");
17
- requireMethod(capabilities.lifecycle.clone, "lifecycle.clone", "cloneSession");
18
- requireMethod(capabilities.lifecycle.tree, "lifecycle.tree", "getSessionTree");
19
- requireMethod(capabilities.lifecycle.tree, "lifecycle.tree", "navigateSessionTree");
20
- requireMethod(capabilities.models.switchInSession, "models.switchInSession", "setModel");
21
- requireMethod(capabilities.reasoning.supported, "reasoning.supported", "getReasoning");
22
- requireMethod(capabilities.reasoning.supported, "reasoning.supported", "setReasoning");
23
- requireMethod(capabilities.approvals.supported, "approvals.supported", "respondToApproval");
24
- requireMethod(capabilities.approvals.structuredUserInput, "approvals.structuredUserInput", "respondToUserInput");
25
- requireMethod(capabilities.maintenance.compaction, "maintenance.compaction", "compact");
26
- const binding = session.getBinding();
27
- if (binding.adapterId !== session.adapterId) {
28
- errors.push(`binding.adapterId "${binding.adapterId}" does not match session.adapterId "${session.adapterId}"`);
29
- }
30
- if (binding.runtimeInstanceId !== session.runtimeInstanceId) {
31
- errors.push(`binding.runtimeInstanceId "${binding.runtimeInstanceId}" does not match session.runtimeInstanceId "${session.runtimeInstanceId}"`);
32
- }
33
- if (binding.state === "bound" && !binding.nativeSessionId) {
34
- errors.push("a bound session requires binding.nativeSessionId");
35
- }
36
- if (binding.state === "unbound" && binding.nativeSessionId && !hasPendingNativeSession(binding)) {
37
- errors.push("an unbound session must not expose binding.nativeSessionId");
43
+ if (capabilities) {
44
+ requireMethod(capabilities.lifecycle.listNativeSessions, "lifecycle.listNativeSessions", "listSessions");
45
+ requireMethod(capabilities.lifecycle.fork, "lifecycle.fork", "forkSession");
46
+ requireMethod(capabilities.lifecycle.clone, "lifecycle.clone", "cloneSession");
47
+ requireMethod(capabilities.lifecycle.tree, "lifecycle.tree", "getSessionTree");
48
+ requireMethod(capabilities.lifecycle.tree, "lifecycle.tree", "navigateSessionTree");
49
+ requireMethod(capabilities.models.switchInSession, "models.switchInSession", "setModel");
50
+ requireMethod(capabilities.reasoning.supported, "reasoning.supported", "getReasoning");
51
+ requireMethod(capabilities.reasoning.supported, "reasoning.supported", "setReasoning");
52
+ requireMethod(capabilities.approvals.supported, "approvals.supported", "respondToApproval");
53
+ requireMethod(capabilities.approvals.structuredUserInput, "approvals.structuredUserInput", "respondToUserInput");
54
+ requireMethod(capabilities.maintenance.compaction, "maintenance.compaction", "compact");
55
+ }
56
+ if (typeof session.getBinding === "function") {
57
+ try {
58
+ const binding = session.getBinding();
59
+ if (!isRecord(binding)) {
60
+ errors.push("session.getBinding() must return an object");
61
+ }
62
+ else {
63
+ for (const field of ["piboSessionId", "runtimeInstanceId", "adapterId"]) {
64
+ if (typeof binding[field] !== "string" || !binding[field].trim()) {
65
+ errors.push(`session.getBinding().${field} must be a non-empty string`);
66
+ }
67
+ }
68
+ if (!BINDING_STATES.has(String(binding.state))) {
69
+ errors.push("session.getBinding().state is invalid");
70
+ }
71
+ if (binding.adapterId !== session.adapterId) {
72
+ errors.push(`binding.adapterId "${String(binding.adapterId)}" does not match session.adapterId "${String(session.adapterId)}"`);
73
+ }
74
+ if (binding.runtimeInstanceId !== session.runtimeInstanceId) {
75
+ errors.push(`binding.runtimeInstanceId "${String(binding.runtimeInstanceId)}" does not match session.runtimeInstanceId "${String(session.runtimeInstanceId)}"`);
76
+ }
77
+ if (binding.state === "bound" && !binding.nativeSessionId) {
78
+ errors.push("a bound session requires binding.nativeSessionId");
79
+ }
80
+ if (binding.state === "unbound" && binding.nativeSessionId && !hasPendingNativeSession(binding)) {
81
+ errors.push("an unbound session must not expose binding.nativeSessionId");
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ errors.push("session.getBinding() must not throw during contract validation");
87
+ }
38
88
  }
39
89
  return errors;
40
90
  }
41
- export function assertAgentRuntimeSessionContract(session) {
91
+ export function assertAgentRuntimeSessionContract(session, runtimeInstanceId) {
42
92
  const errors = validateAgentRuntimeSessionContract(session);
43
93
  if (errors.length > 0) {
44
- throw new AgentRuntimeContractError(session.runtimeInstanceId, `Agent runtime session contract failed: ${errors.join("; ")}`);
94
+ throw new AgentRuntimeContractError(runtimeInstanceId ?? (isRecord(session) && typeof session.runtimeInstanceId === "string" ? session.runtimeInstanceId : "unknown"), `Agent runtime session contract failed: ${errors.join("; ")}`);
45
95
  }
46
96
  }
@@ -46,6 +46,9 @@ function boundedIdentifierText(value, fallback, maxLength = 512) {
46
46
  function optionalBoundedIdentifierText(value) {
47
47
  return value ? boundedIdentifierText(value, "portable-id") : undefined;
48
48
  }
49
+ function toolInvocationKey(turnId, toolCallId) {
50
+ return JSON.stringify([turnId ?? null, toolCallId]);
51
+ }
49
52
  function boundedText(value, maxBytes = MAX_ENTRY_BYTES) {
50
53
  if (Buffer.byteLength(value, "utf8") <= maxBytes)
51
54
  return value;
@@ -274,18 +277,22 @@ export class PiboDataPortableHistoryProvider {
274
277
  for (const row of rows) {
275
278
  if (!row.tool_call_id)
276
279
  continue;
280
+ const invocationKey = toolInvocationKey(row.turn_id, row.tool_call_id);
277
281
  if (row.type === "tool_call") {
278
282
  const attributes = parseObject(row.attributes_json);
279
- const existing = calls.get(row.tool_call_id);
283
+ const existing = calls.get(invocationKey);
280
284
  if (!existing || attributes.argsComplete === true)
281
- calls.set(row.tool_call_id, row);
285
+ calls.set(invocationKey, row);
282
286
  }
283
287
  else {
284
- results.set(row.tool_call_id, row);
288
+ results.set(invocationKey, row);
285
289
  }
286
290
  }
287
291
  const entries = [];
288
- for (const [toolCallId, row] of calls) {
292
+ for (const row of calls.values()) {
293
+ const toolCallId = row.tool_call_id;
294
+ if (!toolCallId)
295
+ continue;
289
296
  const attributes = parseObject(row.attributes_json);
290
297
  const portableToolCallId = boundedIdentifierText(toolCallId, `tool-call-${row.stream_id}`);
291
298
  const toolName = boundedIdentifierText(typeof attributes.toolName === "string" ? attributes.toolName : row.preview_text ?? "tool", "tool", 256);
@@ -309,7 +316,10 @@ export class PiboDataPortableHistoryProvider {
309
316
  toolName,
310
317
  });
311
318
  }
312
- for (const [toolCallId, row] of results) {
319
+ for (const row of results.values()) {
320
+ const toolCallId = row.tool_call_id;
321
+ if (!toolCallId)
322
+ continue;
313
323
  const attributes = parseObject(row.attributes_json);
314
324
  const portableToolCallId = boundedIdentifierText(toolCallId, `tool-call-${row.stream_id}`);
315
325
  const toolName = boundedIdentifierText(typeof attributes.toolName === "string" ? attributes.toolName : row.preview_text ?? "tool", "tool", 256);
@@ -374,18 +384,18 @@ function normalizeToolPairs(entries) {
374
384
  if (entry.type !== "message")
375
385
  continue;
376
386
  if (entry.role === "tool" && entry.toolCallId)
377
- resultIds.add(entry.toolCallId);
387
+ resultIds.add(toolInvocationKey(entry.turnId, entry.toolCallId));
378
388
  if (!Array.isArray(entry.content))
379
389
  continue;
380
390
  for (const part of entry.content) {
381
391
  if (part.type === "tool_call")
382
- callIds.add(part.toolCallId);
392
+ callIds.add(toolInvocationKey(entry.turnId, part.toolCallId));
383
393
  }
384
394
  }
385
395
  return entries.map((entry) => {
386
396
  if (entry.type !== "message")
387
397
  return entry;
388
- if (entry.role === "tool" && entry.toolCallId && !callIds.has(entry.toolCallId)) {
398
+ if (entry.role === "tool" && entry.toolCallId && !callIds.has(toolInvocationKey(entry.turnId, entry.toolCallId))) {
389
399
  return {
390
400
  ...entry,
391
401
  role: "user",
@@ -402,7 +412,7 @@ function normalizeToolPairs(entries) {
402
412
  if (entry.role !== "assistant" || !Array.isArray(entry.content))
403
413
  return entry;
404
414
  const content = entry.content.map((part) => {
405
- if (part.type !== "tool_call" || resultIds.has(part.toolCallId))
415
+ if (part.type !== "tool_call" || resultIds.has(toolInvocationKey(entry.turnId, part.toolCallId)))
406
416
  return part;
407
417
  return {
408
418
  type: "text",
@@ -450,10 +450,16 @@ export class AgentRuntimeAdapterRegistry {
450
450
  const adapter = this.requireInstance(instanceId);
451
451
  const session = await adapter.openSession(input);
452
452
  try {
453
- assertAgentRuntimeSessionContract(session);
453
+ assertAgentRuntimeSessionContract(session, instanceId);
454
454
  }
455
455
  catch (error) {
456
- await session.dispose().catch(() => { });
456
+ try {
457
+ if (typeof session?.dispose === "function")
458
+ await session.dispose();
459
+ }
460
+ catch {
461
+ // Preserve the admission error when best-effort cleanup also fails.
462
+ }
457
463
  throw error;
458
464
  }
459
465
  return session;
@@ -14,7 +14,7 @@ const RUN_REMINDER_CAPABILITY_TOOLS = new Set([
14
14
  const RUN_REMINDER_MAX_TOOL_EXECUTIONS = 64;
15
15
  const RUN_REMINDER_MAX_PROVIDER_ROUNDS = 64;
16
16
  const RUN_REMINDER_MAX_ACTIVE_TOKENS = 2_000_000;
17
- const RUN_REMINDER_MAX_DURATION_MS = 15 * 60 * 1000;
17
+ export const RUN_REMINDER_MAX_DURATION_MS = 15 * 60 * 1000;
18
18
  const RUN_REMINDER_MAX_REPEATED_TOOL_CALLS = 12;
19
19
  function serializedToolArgs(value) {
20
20
  try {
@@ -208,14 +208,20 @@ export class RuntimeRoutedSession {
208
208
  this.assertActive();
209
209
  if (event.action === "compact")
210
210
  return this.enqueueCompactAction(event);
211
- const changesSessionIdentity = event.action === "session.fork" || event.action === "session.clone";
212
- if (changesSessionIdentity) {
213
- this.assertSessionIdentityOperationIdle(event.action === "session.fork" ? "fork" : "clone");
211
+ const sessionIdentityOperation = event.action === "session.fork"
212
+ ? "fork"
213
+ : event.action === "session.clone"
214
+ ? "clone"
215
+ : event.action === "session.switch"
216
+ ? "switch"
217
+ : undefined;
218
+ if (sessionIdentityOperation) {
219
+ this.assertSessionIdentityOperationIdle(sessionIdentityOperation);
214
220
  this.sessionIdentityOperationInFlight = true;
215
221
  this.notifyState();
216
222
  }
217
223
  try {
218
- if (changesSessionIdentity)
224
+ if (sessionIdentityOperation)
219
225
  await this.options.onBeforeSessionIdentityOperation?.(event);
220
226
  const result = await this.runAction(event);
221
227
  if (isSessionOperationResult(result))
@@ -231,7 +237,7 @@ export class RuntimeRoutedSession {
231
237
  return output;
232
238
  }
233
239
  finally {
234
- if (changesSessionIdentity) {
240
+ if (sessionIdentityOperation) {
235
241
  this.sessionIdentityOperationInFlight = false;
236
242
  this.notifyState();
237
243
  this.startDrain();
@@ -1107,9 +1107,15 @@ export class RoutedSession {
1107
1107
  if (event.action === "compact") {
1108
1108
  return this.enqueueCompactAction(event);
1109
1109
  }
1110
- const changesSessionIdentity = event.action === "session.fork" || event.action === "session.clone";
1111
- if (changesSessionIdentity) {
1112
- this.assertSessionIdentityOperationIdle(event.action === "session.fork" ? "fork" : "clone");
1110
+ const sessionIdentityOperation = event.action === "session.fork"
1111
+ ? "fork"
1112
+ : event.action === "session.clone"
1113
+ ? "clone"
1114
+ : event.action === "session.switch"
1115
+ ? "switch"
1116
+ : undefined;
1117
+ if (sessionIdentityOperation) {
1118
+ this.assertSessionIdentityOperationIdle(sessionIdentityOperation);
1113
1119
  this.sessionIdentityOperationInFlight = true;
1114
1120
  }
1115
1121
  try {
@@ -1127,7 +1133,7 @@ export class RoutedSession {
1127
1133
  return output;
1128
1134
  }
1129
1135
  finally {
1130
- if (changesSessionIdentity)
1136
+ if (sessionIdentityOperation)
1131
1137
  this.sessionIdentityOperationInFlight = false;
1132
1138
  }
1133
1139
  }
@@ -6,6 +6,17 @@ import { DatabaseSync } from "node:sqlite";
6
6
  import { DEFAULT_AGENT_RUNTIME_INSTANCE_ID, DEFAULT_BUILTIN_TOOL_NAMES } from "../../core/profiles.js";
7
7
  import { isPiboThinkingLevel } from "../../core/thinking.js";
8
8
  import { findPiPackage } from "../../pi-packages/store.js";
9
+ export class CustomAgentTargetReferenceError extends Error {
10
+ targetProfileName;
11
+ dependentProfileNames;
12
+ constructor(targetProfileName, dependentProfileNames) {
13
+ const sortedNames = [...dependentProfileNames].sort((left, right) => left.localeCompare(right));
14
+ super(`Custom agent "${targetProfileName}" is targeted by custom agents: ${sortedNames.join(", ")}`);
15
+ this.name = "CustomAgentTargetReferenceError";
16
+ this.targetProfileName = targetProfileName;
17
+ this.dependentProfileNames = sortedNames;
18
+ }
19
+ }
9
20
  const CUSTOM_AGENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
10
21
  export function previewCustomAgentCreate(input, options = {}) {
11
22
  const now = options.now ?? new Date().toISOString();
@@ -264,10 +275,42 @@ export class CustomAgentStore {
264
275
  .run(archivedAt, new Date().toISOString(), id);
265
276
  return this.get(id);
266
277
  }
278
+ listReferencingAgents(id) {
279
+ this.migrateLegacyProfileNames();
280
+ const target = this.get(id);
281
+ if (!target)
282
+ return [];
283
+ const targetNames = new Set([
284
+ target.profileName,
285
+ target.id,
286
+ `custom-agent:${target.id}`,
287
+ ...target.profileAliases,
288
+ ]);
289
+ return this.list({ includeArchived: true })
290
+ .filter((agent) => agent.id !== id && agent.subagents.some((subagent) => targetNames.has(subagent.targetProfile)))
291
+ .sort((left, right) => left.profileName.localeCompare(right.profileName));
292
+ }
267
293
  delete(id) {
268
- this.db.prepare("DELETE FROM chat_agent_profile_aliases WHERE agent_id = ?").run(id);
269
- const result = this.db.prepare("DELETE FROM chat_agents WHERE id = ?").run(id);
270
- return Number(result.changes ?? 0) > 0;
294
+ this.db.exec("BEGIN IMMEDIATE");
295
+ try {
296
+ const target = this.get(id);
297
+ if (!target) {
298
+ this.db.exec("COMMIT");
299
+ return false;
300
+ }
301
+ const dependents = this.listReferencingAgents(id);
302
+ if (dependents.length > 0) {
303
+ throw new CustomAgentTargetReferenceError(target.profileName, dependents.map((agent) => agent.profileName));
304
+ }
305
+ this.db.prepare("DELETE FROM chat_agent_profile_aliases WHERE agent_id = ?").run(id);
306
+ const result = this.db.prepare("DELETE FROM chat_agents WHERE id = ?").run(id);
307
+ this.db.exec("COMMIT");
308
+ return Number(result.changes ?? 0) > 0;
309
+ }
310
+ catch (error) {
311
+ this.db.exec("ROLLBACK");
312
+ throw error;
313
+ }
271
314
  }
272
315
  close() {
273
316
  this.db.close();
@@ -369,6 +412,15 @@ export class CustomAgentStore {
369
412
  .prepare("UPDATE chat_agents SET profile_name = ?, display_name = ? WHERE id = ?")
370
413
  .run(nextName, nextName, row.id);
371
414
  }
415
+ this.db.prepare(`
416
+ DELETE FROM chat_agent_profile_aliases
417
+ WHERE EXISTS (
418
+ SELECT 1
419
+ FROM chat_agents
420
+ WHERE chat_agents.profile_name = chat_agent_profile_aliases.old_profile_name
421
+ AND chat_agents.id != chat_agent_profile_aliases.agent_id
422
+ )
423
+ `).run();
372
424
  }
373
425
  migrateAgentFolderColumn() {
374
426
  if (!this.tableColumns().has("folder_id")) {
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, rmSync, statSync } from "node:fs";
3
- import { dirname, isAbsolute, resolve } from "node:path";
2
+ import { mkdirSync, realpathSync, rmSync, statSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  import { piboHomePath } from "../../../core/pibo-home.js";
6
6
  export class ChatProjectService {
@@ -72,7 +72,7 @@ export class ChatProjectService {
72
72
  const projectFolder = resolve(normalizeProjectFolder(input.projectFolder));
73
73
  this.assertNameAvailable(name);
74
74
  this.assertFolderAvailable(projectFolder);
75
- this.ensureProjectFolderUsable(projectFolder);
75
+ this.ensureProjectFolderUsable(projectFolder, input.createFolder === true);
76
76
  const now = new Date().toISOString();
77
77
  const id = `prj_${randomUUID()}`;
78
78
  this.db.prepare(`INSERT INTO projects (id, name, description, project_folder, configuration_status, metadata_json, created_at, updated_at)
@@ -93,16 +93,26 @@ export class ChatProjectService {
93
93
  return this.getProject(id, { includeArchived: true });
94
94
  }
95
95
  deleteProject(id, options) {
96
- const project = this.requireProject(id, { includeArchived: true });
97
- if (!project.archivedAt)
98
- throw new Error("Archive the project before permanently deleting it.");
99
- if (options.confirmName !== project.name)
100
- throw new Error(`Type \"${project.name}\" to permanently delete this project.`);
101
- this.db.prepare("DELETE FROM project_sessions WHERE project_id = ?").run(id);
102
- this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
103
- if (options.deleteFiles)
104
- rmSync(project.projectFolder, { recursive: true, force: true });
105
- return { deletedProjectId: id };
96
+ this.db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ const project = this.requireProject(id, { includeArchived: true });
99
+ if (!project.archivedAt)
100
+ throw new Error("Archive the project before permanently deleting it.");
101
+ if (options.confirmName !== project.name)
102
+ throw new Error(`Type \"${project.name}\" to permanently delete this project.`);
103
+ if (options.deleteFiles)
104
+ this.assertFolderDoesNotOverlapAnotherProject(project);
105
+ this.db.prepare("DELETE FROM project_sessions WHERE project_id = ?").run(id);
106
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
107
+ if (options.deleteFiles)
108
+ rmSync(project.projectFolder, { recursive: true, force: true });
109
+ this.db.exec("COMMIT");
110
+ return { deletedProjectId: id };
111
+ }
112
+ catch (error) {
113
+ this.db.exec("ROLLBACK");
114
+ throw error;
115
+ }
106
116
  }
107
117
  listProjectSessions(projectId, options = {}) {
108
118
  const rows = this.db.prepare(`SELECT * FROM project_sessions WHERE project_id = ? ${options.includeArchived ? "" : "AND archived = 0"} ORDER BY created_at`).all(projectId);
@@ -112,6 +122,60 @@ export class ChatProjectService {
112
122
  const row = this.db.prepare("SELECT * FROM project_sessions WHERE pibo_session_id = ?").get(piboSessionId);
113
123
  return row ? projectSessionFromRow(row) : undefined;
114
124
  }
125
+ deleteProjectSessions(piboSessionIds) {
126
+ const ids = [...new Set(piboSessionIds.filter(Boolean))];
127
+ if (ids.length === 0)
128
+ return 0;
129
+ this.db.exec("BEGIN IMMEDIATE");
130
+ try {
131
+ const deleted = this.deleteProjectSessionsLocked(ids, new Date().toISOString());
132
+ this.db.exec("COMMIT");
133
+ return deleted;
134
+ }
135
+ catch (error) {
136
+ this.db.exec("ROLLBACK");
137
+ throw error;
138
+ }
139
+ }
140
+ async deleteProjectSessionWithCanonicalDelete(piboSessionId, deleteCanonicalSession) {
141
+ this.db.exec("BEGIN IMMEDIATE");
142
+ try {
143
+ this.deleteProjectSessionsLocked([piboSessionId], new Date().toISOString());
144
+ const result = await deleteCanonicalSession();
145
+ this.db.exec("COMMIT");
146
+ return result;
147
+ }
148
+ catch (error) {
149
+ this.db.exec("ROLLBACK");
150
+ throw error;
151
+ }
152
+ }
153
+ deleteProjectSessionsLocked(ids, now) {
154
+ const placeholders = ids.map(() => "?").join(", ");
155
+ const affectedProjects = this.db.prepare(`SELECT DISTINCT id FROM projects
156
+ WHERE current_main_session_id IN (${placeholders})
157
+ UNION
158
+ SELECT DISTINCT project_id AS id FROM project_sessions WHERE pibo_session_id IN (${placeholders})`)
159
+ .all(...ids, ...ids);
160
+ const deleted = this.db.prepare(`DELETE FROM project_sessions WHERE pibo_session_id IN (${placeholders})`).run(...ids);
161
+ for (const project of affectedProjects) {
162
+ const current = this.db.prepare("SELECT current_main_session_id FROM projects WHERE id = ?")
163
+ .get(project.id);
164
+ if (!current?.current_main_session_id)
165
+ continue;
166
+ const currentLink = this.db.prepare("SELECT 1 FROM project_sessions WHERE project_id = ? AND pibo_session_id = ?")
167
+ .get(project.id, current.current_main_session_id);
168
+ if (currentLink)
169
+ continue;
170
+ const replacement = this.db.prepare(`SELECT pibo_session_id FROM project_sessions
171
+ WHERE project_id = ? AND kind = 'main' AND archived = 0
172
+ ORDER BY updated_at DESC, created_at DESC, pibo_session_id DESC
173
+ LIMIT 1`).get(project.id);
174
+ this.db.prepare("UPDATE projects SET current_main_session_id = ?, updated_at = ? WHERE id = ?")
175
+ .run(replacement?.pibo_session_id ?? null, now, project.id);
176
+ }
177
+ return Number(deleted.changes ?? 0);
178
+ }
115
179
  addProjectSession(input) {
116
180
  const existing = this.getProjectSession(input.piboSessionId);
117
181
  const now = new Date().toISOString();
@@ -599,9 +663,17 @@ export class ChatProjectService {
599
663
  if (existing)
600
664
  throw new Error("Project folder already exists");
601
665
  }
602
- ensureProjectFolderUsable(folder) {
666
+ assertFolderDoesNotOverlapAnotherProject(project) {
667
+ const projects = this.db.prepare("SELECT name, project_folder FROM projects WHERE id != ? ORDER BY lower(name), created_at").all(project.id);
668
+ const overlapping = projects.find((other) => pathsOverlap(project.projectFolder, other.project_folder));
669
+ if (overlapping) {
670
+ throw new Error(`Cannot delete project files because its folder overlaps Project \"${overlapping.name}\". Delete without deleting files or choose a non-overlapping folder.`);
671
+ }
672
+ }
673
+ ensureProjectFolderUsable(folder, createFolder) {
603
674
  try {
604
- mkdirSync(folder, { recursive: true });
675
+ if (createFolder)
676
+ mkdirSync(folder, { recursive: true });
605
677
  if (!statSync(folder).isDirectory())
606
678
  throw new Error("Project folder is not a directory");
607
679
  }
@@ -770,6 +842,38 @@ function normalizeProjectFolder(value) {
770
842
  throw new Error("Project folder must be an absolute path, e.g. ~/code/my-project or /home/me/code/my-project");
771
843
  return folder;
772
844
  }
845
+ function pathsOverlap(left, right) {
846
+ if (pathContains(left, right) || pathContains(right, left))
847
+ return true;
848
+ const canonicalLeft = canonicalProjectPath(left);
849
+ const canonicalRight = canonicalProjectPath(right);
850
+ if (!canonicalLeft || !canonicalRight)
851
+ return true;
852
+ return pathContains(canonicalLeft, canonicalRight) || pathContains(canonicalRight, canonicalLeft);
853
+ }
854
+ function canonicalProjectPath(path) {
855
+ let current = resolve(path);
856
+ const missingSegments = [];
857
+ while (true) {
858
+ try {
859
+ return resolve(realpathSync(current), ...missingSegments);
860
+ }
861
+ catch (error) {
862
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
863
+ if (code !== "ENOENT" && code !== "ENOTDIR")
864
+ return undefined;
865
+ const parent = dirname(current);
866
+ if (parent === current)
867
+ return undefined;
868
+ missingSegments.unshift(basename(current));
869
+ current = parent;
870
+ }
871
+ }
872
+ }
873
+ function pathContains(parent, candidate) {
874
+ const pathFromParent = relative(resolve(parent), resolve(candidate));
875
+ return pathFromParent === "" || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent));
876
+ }
773
877
  const PROJECT_SESSION_STATES = new Set([
774
878
  "simple_chat",
775
879
  "workflow",
@@ -1,6 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { roomWorkspaceFromMetadata } from "../types/rooms.js";
3
3
  import { roomFromRow } from "./chat-data-mappers.js";
4
+ export class PiboRoomHierarchyCycleError extends Error {
5
+ constructor() {
6
+ super("Room parent assignment would create a cycle.");
7
+ this.name = "PiboRoomHierarchyCycleError";
8
+ }
9
+ }
4
10
  export class ChatRoomService {
5
11
  store;
6
12
  constructor(store) {
@@ -17,6 +23,9 @@ export class ChatRoomService {
17
23
  const existing = this.getRoom(id);
18
24
  if (!existing)
19
25
  return undefined;
26
+ if (input.parentRoomId && this.parentAssignmentCreatesCycle(id, input.parentRoomId)) {
27
+ throw new PiboRoomHierarchyCycleError();
28
+ }
20
29
  const metadata = input.metadata ?? existing.metadata;
21
30
  const updated = { ...existing, name: input.name ?? existing.name, topic: input.topic === null ? undefined : input.topic ?? existing.topic, parentRoomId: input.parentRoomId === null ? undefined : input.parentRoomId ?? existing.parentRoomId, retentionPolicyId: input.retentionPolicyId === null ? undefined : input.retentionPolicyId ?? existing.retentionPolicyId, metadata, workspace: roomWorkspaceFromMetadata(metadata), updatedAt: new Date().toISOString() };
22
31
  this.store.db.prepare("UPDATE rooms SET name = ?, topic = ?, parent_room_id = ?, workspace = ?, retention_policy_id = ?, metadata_json = ?, updated_at = ? WHERE id = ?").run(updated.name, updated.topic ?? null, updated.parentRoomId ?? null, updated.workspace ?? null, updated.retentionPolicyId ?? null, JSON.stringify(updated.metadata), updated.updatedAt, id);
@@ -49,4 +58,16 @@ export class ChatRoomService {
49
58
  requireRoom(roomId) { const room = this.getRoom(roomId); if (!room)
50
59
  throw new Error("Room not found"); return room; }
51
60
  close() { }
61
+ parentAssignmentCreatesCycle(roomId, parentRoomId) {
62
+ const roomsById = new Map(this.listRooms().map((room) => [room.id, room]));
63
+ const visited = new Set();
64
+ let currentId = parentRoomId;
65
+ while (currentId && !visited.has(currentId)) {
66
+ if (currentId === roomId)
67
+ return true;
68
+ visited.add(currentId);
69
+ currentId = roomsById.get(currentId)?.parentRoomId;
70
+ }
71
+ return false;
72
+ }
52
73
  }