@jmfederico/pi-web 1.202609.1 → 1.202610.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 (48) hide show
  1. package/dist/client/assets/{index-AroYaCLx.js → index-_1GwJfSs.js} +353 -307
  2. package/dist/client/index.html +1 -1
  3. package/dist/pi-packages/captains-log/dist/browser/index.js +4 -3
  4. package/dist/pi-packages/captains-log/dist/browser/panel.js +2 -0
  5. package/dist/pi-packages/captains-log/docs/usage.md +3 -1
  6. package/dist/plugin-api.d.ts +13 -0
  7. package/dist/server/app.js +12 -19
  8. package/dist/server/app.js.map +1 -1
  9. package/dist/server/browserMessageProjection.js +39 -20
  10. package/dist/server/browserMessageProjection.js.map +1 -1
  11. package/dist/server/machines/machineProxyRoutes.js +31 -0
  12. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  13. package/dist/server/projects/projectLifecycleService.js +91 -0
  14. package/dist/server/projects/projectLifecycleService.js.map +1 -0
  15. package/dist/server/realtime/sessionEventHub.js +22 -9
  16. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  17. package/dist/server/sessiond/projectMutationRoutes.js +38 -0
  18. package/dist/server/sessiond/projectMutationRoutes.js.map +1 -0
  19. package/dist/server/sessiond/sessionProxyRoutes.js +24 -0
  20. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  21. package/dist/server/sessiond.js +34 -9
  22. package/dist/server/sessiond.js.map +1 -1
  23. package/dist/server/sessions/piSessionManagerGateway.js +8 -2
  24. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  25. package/dist/server/sessions/piSessionService.js +144 -18
  26. package/dist/server/sessions/piSessionService.js.map +1 -1
  27. package/dist/server/sessions/sessionMediaIndex.js +184 -0
  28. package/dist/server/sessions/sessionMediaIndex.js.map +1 -0
  29. package/dist/server/sessions/sessionRoutes.js +46 -4
  30. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  31. package/dist/server/sessions/sessionUnreadStore.js +26 -1
  32. package/dist/server/sessions/sessionUnreadStore.js.map +1 -1
  33. package/dist/server/sessions/spawnSessionTool.js +6 -1
  34. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  35. package/dist/server/sessions/spawnSubsessionTool.js +6 -1
  36. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  37. package/dist/server/workspaceExplorerRoutes.js +1 -1
  38. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  39. package/dist/server/workspaces/filePreviewService.js +27 -3
  40. package/dist/server/workspaces/filePreviewService.js.map +1 -1
  41. package/dist/sessiond/sessionDaemonClient.js +24 -0
  42. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  43. package/dist/shared/federatedRoutes.js +2 -0
  44. package/dist/shared/federatedRoutes.js.map +1 -1
  45. package/dist/shared/sessionMedia.js +6 -0
  46. package/dist/shared/sessionMedia.js.map +1 -0
  47. package/docs/plugins.md +16 -0
  48. package/package.json +3 -2
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
4
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEventBus, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
+ import { isSessionMediaId } from "../../shared/sessionMedia.js";
6
7
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
7
8
  import { clientSessionFirstMessagePreview } from "./clientSessionPreview.js";
8
9
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
@@ -535,6 +536,8 @@ export class PiSessionService {
535
536
  this.unpersistedTreeBranchLeaves = new WeakMap();
536
537
  /** Counts async operations that may append an entry before they settle. */
537
538
  this.sessionEntryMutationCounts = new WeakMap();
539
+ this.pendingPromptEchoes = new WeakMap();
540
+ this.publishedAssistantPartials = new WeakMap();
538
541
  /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
539
542
  this.modelScopeMutationQueue = Promise.resolve();
540
543
  /** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
@@ -563,8 +566,8 @@ export class PiSessionService {
563
566
  this.subsessionHydratedParents = new Set();
564
567
  /**
565
568
  * Tracked subsession id -> whether a completion notification is armed.
566
- * Armed when the child starts working; firing on completion disarms it so a
567
- * child that works again (and stops again) notifies the parent each time.
569
+ * Armed when the child's agent starts a run or a prompt fails before starting
570
+ * one (not for standalone commands or compaction); completion disarms it.
568
571
  */
569
572
  this.subsessionNotifyArmed = new Map();
570
573
  this.notificationGenerationBySession = new WeakMap();
@@ -637,6 +640,9 @@ export class PiSessionService {
637
640
  notificationCatalog() {
638
641
  return this.notificationStore.catalogSnapshot();
639
642
  }
643
+ async reconcileUnreadWorkspaces(cwds) {
644
+ await this.publishUnreadMutations(this.unreadStore.reconcileWorkspaces(cwds));
645
+ }
640
646
  async unreadCatalog() {
641
647
  await this.publishUnreadMutations([]);
642
648
  return this.unreadStore.durableCatalogSnapshot();
@@ -744,7 +750,10 @@ export class PiSessionService {
744
750
  finally {
745
751
  await active.runtime.dispose();
746
752
  }
747
- })).finally(() => this.activityMarker.dispose());
753
+ })).finally(async () => {
754
+ this.events.mediaIndex.clear();
755
+ await this.activityMarker.dispose();
756
+ });
748
757
  await this.publishUnreadMutations([]);
749
758
  }
750
759
  async list(cwd) {
@@ -1518,31 +1527,34 @@ export class PiSessionService {
1518
1527
  });
1519
1528
  }
1520
1529
  /**
1521
- * Drive parent notifications from a tracked child's status. Arms a pending
1522
- * notification while the child is working, and when it stops fires a single
1523
- * follow-up message to the parent via {@link prompt} (which queues if the
1524
- * parent is busy and delivers immediately when it is idle).
1530
+ * Arm on a tracked child's agent_start, then notify the parent once it stops
1531
+ * working. Prompt failures before agent_start explicitly arm the same latch.
1532
+ * Standalone commands and compactions must not arm a completion notice.
1525
1533
  */
1526
- updateSubsessionTracking(session) {
1534
+ updateSubsessionTracking(session, agentStarted = false) {
1527
1535
  const link = this.subsessionLinkForActiveChild(session);
1528
1536
  if (link === undefined)
1529
1537
  return;
1530
1538
  const childId = link.childSessionId;
1531
- if (this.hasActiveWork(session)) {
1539
+ if (agentStarted) {
1532
1540
  this.subsessionNotifyArmed.set(childId, true);
1533
1541
  return;
1534
1542
  }
1543
+ if (this.hasActiveWork(session))
1544
+ return;
1535
1545
  if (this.subsessionNotifyArmed.get(childId) !== true)
1536
1546
  return;
1537
1547
  this.subsessionNotifyArmed.set(childId, false);
1538
- const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
1548
+ const activity = this.activities.get(childId);
1549
+ const status = activity?.phase === "error" ? "error" : "idle";
1550
+ const errorSection = status === "error" && activity?.detail !== undefined ? `Error: ${activity.detail}\n\n` : "";
1539
1551
  const finalText = finalAssistantText(historyMessages(session));
1540
1552
  const outputSection = formatSubsessionNotificationOutput(childId, finalText);
1541
1553
  const workingIds = this.workingSubsessionIds(link.parentSessionId);
1542
1554
  const next = workingIds.length === 0
1543
1555
  ? "No other tracked subsessions are working."
1544
1556
  : `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
1545
- const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
1557
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${errorSection}${outputSection}`;
1546
1558
  void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
1547
1559
  }
1548
1560
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
@@ -1590,10 +1602,69 @@ export class PiSessionService {
1590
1602
  logSubsessionNotificationFailure(parentId, childId, error) {
1591
1603
  this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1592
1604
  }
1605
+ async transcriptSnapshot(ref, page) {
1606
+ const session = await this.getOrOpen(ref);
1607
+ const seqBeforeRead = this.events.currentSeq(session.sessionId);
1608
+ const activeAtCapture = this.hasActiveWork(session);
1609
+ const diskBranch = activeAtCapture ? undefined : await this.readableSessionBranch(ref, session);
1610
+ // Disk reads yield, even if a whole turn starts AND ends while they run.
1611
+ // From the final runtime read through the watermark capture nothing may yield.
1612
+ const branch = diskBranch === undefined || this.hasActiveWork(session) || this.events.currentSeq(session.sessionId) !== seqBeforeRead
1613
+ ? session.sessionManager.getBranch()
1614
+ : diskBranch;
1615
+ const messages = historyMessagesFromEntries(branch);
1616
+ const userCount = messages.filter((message) => isRecord(message) && message["role"] === "user").length;
1617
+ const echoes = (this.pendingPromptEchoes.get(session) ?? []).filter((echo) => echo.userIndex >= userCount);
1618
+ messages.push(...echoes.map((echo) => echo.message));
1619
+ return {
1620
+ page: pageMessagesAtSafeBoundary(messages, page),
1621
+ status: this.statusFromSession(session, transcriptMessageCount(branch) + echoes.length),
1622
+ seq: this.events.currentSeq(session.sessionId),
1623
+ partial: this.publishedAssistantPartials.get(session) ?? null,
1624
+ };
1625
+ }
1593
1626
  async messages(ref, page) {
1594
1627
  const session = await this.getOrOpen(ref);
1595
1628
  return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1596
1629
  }
1630
+ async media(ref, mediaId) {
1631
+ if (!isSessionMediaId(mediaId))
1632
+ throw new Error("Invalid media id");
1633
+ const index = this.events.mediaIndex;
1634
+ const session = this.activeForRef(ref)?.runtime.session ?? this.startupSessionForRef(ref);
1635
+ // Verify session/cwd ownership before consulting the session-scoped cache.
1636
+ // Cold binary reads do not construct an SDK runtime (which can migrate files).
1637
+ const archived = session === undefined ? await this.getArchived(ref) : undefined;
1638
+ const resolved = session === undefined && archived?.archivePath === undefined
1639
+ ? await this.sessionManager.resolveSessionFile(ref.cwd, ref.id)
1640
+ : undefined;
1641
+ const sessionId = session?.sessionId ?? archived?.sessionId ?? resolved?.id;
1642
+ if (sessionId === undefined || (resolved !== undefined && !cwdPathsEqual(resolved.cwd, ref.cwd)))
1643
+ throw new Error("Session not found");
1644
+ const scope = { id: sessionId, cwd: canonicalizeStoredCwd(session?.sessionManager.getCwd() ?? archived?.cwd ?? resolved?.cwd ?? ref.cwd) };
1645
+ const cached = index.get(scope, mediaId);
1646
+ if (cached !== undefined)
1647
+ return cached;
1648
+ if (session !== undefined) {
1649
+ const live = index.find(scope, mediaId, [
1650
+ session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(),
1651
+ session.messages,
1652
+ session.state.streamingMessage,
1653
+ this.publishedAssistantPartials.get(session),
1654
+ ...(this.pendingPromptEchoes.get(session) ?? []).map((echo) => echo.message),
1655
+ ]);
1656
+ if (live !== undefined)
1657
+ return live;
1658
+ }
1659
+ // Read disk on misses even for a cached idle runtime: other processes can
1660
+ // append images on any branch without emitting events through this daemon.
1661
+ const path = session?.sessionFile ?? session?.sessionManager.getSessionFile() ?? archived?.archivePath ?? resolved?.path
1662
+ ?? (session === undefined ? undefined : (await this.sessionManager.resolveSessionFile(ref.cwd, sessionId))?.path);
1663
+ if (path === undefined)
1664
+ return undefined;
1665
+ const entries = await this.sessionManager.readEntries?.(path);
1666
+ return entries === undefined ? undefined : index.find(scope, mediaId, entries);
1667
+ }
1597
1668
  async status(ref) {
1598
1669
  const session = await this.sessionForStatusOrDialogClose(ref);
1599
1670
  await this.activityMarker.refresh(session.sessionFile, this.hasActiveWork(session));
@@ -1844,14 +1915,49 @@ export class PiSessionService {
1844
1915
  }
1845
1916
  beginPromptSubmission(session, text, behavior, images = [], echoUserMessage = true) {
1846
1917
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1847
- if (behavior === undefined && echoUserMessage)
1848
- this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
1918
+ const echoes = this.pendingPromptEchoes.get(session) ?? [];
1919
+ const userCount = historyMessagesFromEntries(session.sessionManager.getBranch()).filter((message) => isRecord(message) && message["role"] === "user").length;
1920
+ const echo = behavior === undefined && echoUserMessage
1921
+ ? { userIndex: Math.max(userCount, ...echoes.map((pending) => pending.userIndex + 1)), message: userMessage(text, images) }
1922
+ : undefined;
1923
+ if (echo !== undefined) {
1924
+ // SDK input hooks may await before appending the user message. Keep the
1925
+ // already-published echo visible in snapshots until that append occurs.
1926
+ this.pendingPromptEchoes.set(session, [...echoes, echo]);
1927
+ this.events.publish(session.sessionId, { type: "message.append", message: echo.message }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
1928
+ }
1849
1929
  const promptOptions = buildPromptOptions(behavior, images);
1850
- const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions));
1930
+ const commandName = text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
1931
+ const trackEarlyFailure = behavior === undefined
1932
+ && this.subsessionLinkForActiveChild(session) !== undefined
1933
+ && !session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
1934
+ let agentStarted = false;
1935
+ const unsubscribe = !trackEarlyFailure ? undefined : session.subscribe((event) => {
1936
+ if (getString(event, "type") === "agent_start")
1937
+ agentStarted = true;
1938
+ });
1939
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).finally(() => {
1940
+ unsubscribe?.();
1941
+ if (echo !== undefined) {
1942
+ const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
1943
+ if (remaining.length === 0)
1944
+ this.pendingPromptEchoes.delete(session);
1945
+ else
1946
+ this.pendingPromptEchoes.set(session, remaining);
1947
+ }
1948
+ });
1851
1949
  void promptPromise.catch((error) => {
1852
1950
  const message = error instanceof Error ? error.message : String(error);
1853
1951
  this.publishActivity(session, "error", "error", message);
1854
1952
  this.events.publish(session.sessionId, { type: "session.error", message });
1953
+ if (this.isCurrentActiveSession(session)) {
1954
+ const link = this.subsessionLinkForActiveChild(session);
1955
+ // Model/auth validation can reject without any agent event. Wake the
1956
+ // parent, but do not re-arm a run that already emitted agent_start.
1957
+ if (trackEarlyFailure && !agentStarted && link !== undefined)
1958
+ this.subsessionNotifyArmed.set(link.childSessionId, true);
1959
+ this.updateSubsessionTracking(session);
1960
+ }
1855
1961
  });
1856
1962
  return promptPromise;
1857
1963
  }
@@ -2617,6 +2723,7 @@ export class PiSessionService {
2617
2723
  // Promises inside the dying runtime: settle them rather than dropping them.
2618
2724
  this.endSessionExtensionDialogs(sessionId);
2619
2725
  this.active.delete(sessionId);
2726
+ this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
2620
2727
  this.activities.delete(sessionId);
2621
2728
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
2622
2729
  this.clearAuthLossWarningsForSession(sessionId);
@@ -3085,7 +3192,7 @@ export class PiSessionService {
3085
3192
  // now rather than after the durable flush: it reads in-memory unread state
3086
3193
  // and must not lag behind the rows the browser is about to see.
3087
3194
  if (mutations.length > 0)
3088
- this.onUnreadChanged?.();
3195
+ this.onUnreadChanged?.(mutations.some(({ event }) => event.unread !== null));
3089
3196
  this.enqueueUnreadMutations(mutations);
3090
3197
  this.unreadPublicationFlushRequested = true;
3091
3198
  if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
@@ -3185,9 +3292,27 @@ export class PiSessionService {
3185
3292
  }
3186
3293
  }
3187
3294
  const unsubscribe = session.subscribe((event) => {
3188
- this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
3189
- this.publishActivityForEvent(session, event);
3190
3295
  const eventType = getString(event, "type");
3296
+ const message = getProperty(event, "message");
3297
+ if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
3298
+ // SDK state advances before async extension hooks finish. Capture only
3299
+ // published messages, detaching even blocks left intact by projection.
3300
+ this.publishedAssistantPartials.set(session, structuredClone(annotateAssistantThinkingLevel(projectBrowserMessage(message), session.thinkingLevel)));
3301
+ }
3302
+ else if (eventType === "message_start" || eventType === "message_end" || eventType === "agent_end") {
3303
+ this.publishedAssistantPartials.delete(session);
3304
+ }
3305
+ this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel), { id: session.sessionId, cwd: session.sessionManager.getCwd() });
3306
+ this.publishActivityForEvent(session, event);
3307
+ // Queued messages can reach the model after an ask opened, even though
3308
+ // there was no ask to dismiss when the user originally submitted them.
3309
+ if (eventType === "message_start" && isRecord(event) && getString(event["message"], "role") === "user") {
3310
+ void this.voidOpenAskForUserMessage(session).catch((error) => {
3311
+ const message = error instanceof Error ? error.message : String(error);
3312
+ this.publishActivity(session, "error", "error", message);
3313
+ this.events.publish(session.sessionId, { type: "session.error", message });
3314
+ });
3315
+ }
3191
3316
  if (eventType === "agent_end")
3192
3317
  this.abortRunScopedExtensionDialogs(session.sessionId);
3193
3318
  if (eventType === "compaction_end")
@@ -3195,11 +3320,12 @@ export class PiSessionService {
3195
3320
  if (eventType === "agent_start" || eventType === "agent_end")
3196
3321
  this.scheduleCompactionQueueDrain(session.sessionId);
3197
3322
  this.publishStatus(session);
3198
- this.updateSubsessionTracking(session);
3323
+ this.updateSubsessionTracking(session, eventType === "agent_start");
3199
3324
  });
3200
3325
  active.unsubscribe = () => {
3201
3326
  this.sessionEvents.close(session);
3202
3327
  unsubscribe();
3328
+ this.publishedAssistantPartials.delete(session);
3203
3329
  };
3204
3330
  this.active.set(session.sessionId, active);
3205
3331
  }