clay-server 4.0.0-beta.2 → 4.0.0-beta.21

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 (177) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/capsule-display-floor.js +51 -0
  3. package/lib/capsule-frame-server.js +255 -0
  4. package/lib/capsule-mate-turn.js +105 -0
  5. package/lib/capsule-pig-logic.js +300 -0
  6. package/lib/capsule-server-runtimes.js +39 -0
  7. package/lib/capsules/pig/display.js +198 -0
  8. package/lib/capsules/pig/manifest.json +9 -0
  9. package/lib/capsules/pig/ui.json +84 -0
  10. package/lib/config.js +25 -1
  11. package/lib/daemon-projects.js +13 -8
  12. package/lib/daemon.js +127 -14
  13. package/lib/git-cli.js +95 -1
  14. package/lib/knowledge-import.js +436 -0
  15. package/lib/knowledge-record-store.js +188 -0
  16. package/lib/knowledge-search.js +240 -0
  17. package/lib/mate-knowledge-mcp-server.js +151 -0
  18. package/lib/mate-knowledge-migration.js +283 -0
  19. package/lib/mate-knowledge-service.js +488 -0
  20. package/lib/mate-knowledge-sync.js +482 -0
  21. package/lib/migrate-single-user.js +2 -0
  22. package/lib/notes-lifecycle.js +149 -0
  23. package/lib/notes.js +60 -3
  24. package/lib/os-users.js +3 -1
  25. package/lib/project-capsule-catalog.js +9 -1
  26. package/lib/project-capsule-turn.js +96 -0
  27. package/lib/project-connection.js +14 -1
  28. package/lib/project-http.js +15 -1
  29. package/lib/project-knowledge.js +22 -0
  30. package/lib/project-log-context.js +172 -0
  31. package/lib/project-log-feedback-delivery.js +118 -0
  32. package/lib/project-logs-comments.js +137 -0
  33. package/lib/project-logs-context-state.js +42 -0
  34. package/lib/project-logs-mcp-server.js +328 -0
  35. package/lib/project-logs-query.js +185 -0
  36. package/lib/project-logs-root.js +75 -0
  37. package/lib/project-logs-schema.js +259 -0
  38. package/lib/project-logs-service.js +463 -0
  39. package/lib/project-logs-snapshot.js +205 -0
  40. package/lib/project-logs-store.js +495 -0
  41. package/lib/project-logs-versioning.js +132 -0
  42. package/lib/project-logs.js +400 -0
  43. package/lib/project-mate-interaction.js +14 -0
  44. package/lib/project-mate-knowledge.js +110 -0
  45. package/lib/project-memory.js +22 -0
  46. package/lib/project-message-delivery.js +70 -0
  47. package/lib/project-pair-lifecycle.js +460 -0
  48. package/lib/project-session-handoff.js +1 -1
  49. package/lib/project-session-notes.js +64 -13
  50. package/lib/project-session-pair.js +159 -146
  51. package/lib/project-session-spawn.js +1 -1
  52. package/lib/project-sessions.js +58 -13
  53. package/lib/project-user-message.js +53 -4
  54. package/lib/project-vendor-login.js +20 -3
  55. package/lib/project-worker-permission.js +430 -0
  56. package/lib/project-worker-proposal.js +208 -62
  57. package/lib/project.js +178 -6
  58. package/lib/public/app.js +58 -35
  59. package/lib/public/css/admin.css +2 -2
  60. package/lib/public/css/capsule-ui.css +38 -0
  61. package/lib/public/css/command-palette.css +19 -9
  62. package/lib/public/css/git-placard.css +154 -0
  63. package/lib/public/css/home-hub.css +4 -0
  64. package/lib/public/css/home-session-actions.css +66 -0
  65. package/lib/public/css/home-sidebar.css +66 -0
  66. package/lib/public/css/input.css +163 -28
  67. package/lib/public/css/mobile-nav.css +80 -0
  68. package/lib/public/css/notifications-center.css +67 -0
  69. package/lib/public/css/pane.css +52 -0
  70. package/lib/public/css/project-logs.css +198 -0
  71. package/lib/public/css/scheduler.css +1 -1
  72. package/lib/public/css/session-actions.css +16 -101
  73. package/lib/public/css/sidebar.css +103 -0
  74. package/lib/public/css/sticky-notes.css +237 -229
  75. package/lib/public/css/worker-proposal.css +34 -1
  76. package/lib/public/index.html +45 -11
  77. package/lib/public/modules/admin.js +8 -8
  78. package/lib/public/modules/app-connection.js +109 -4
  79. package/lib/public/modules/app-home-hub.js +19 -7
  80. package/lib/public/modules/app-message-cards.js +2 -1
  81. package/lib/public/modules/app-messages.js +84 -7
  82. package/lib/public/modules/app-misc.js +2 -14
  83. package/lib/public/modules/app-notifications.js +11 -7
  84. package/lib/public/modules/app-panels.js +46 -4
  85. package/lib/public/modules/app-projects.js +13 -8
  86. package/lib/public/modules/app-rendering.js +1 -0
  87. package/lib/public/modules/background-tasks-ui.js +139 -16
  88. package/lib/public/modules/capsule-preference.js +44 -0
  89. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  90. package/lib/public/modules/clay-log-links.js +70 -0
  91. package/lib/public/modules/command-palette.js +52 -15
  92. package/lib/public/modules/context-sources.js +0 -3
  93. package/lib/public/modules/git-agent-sessions.js +85 -0
  94. package/lib/public/modules/git-panel.js +111 -84
  95. package/lib/public/modules/git-placard.js +161 -0
  96. package/lib/public/modules/home-chat-empty-state.js +39 -0
  97. package/lib/public/modules/home-chat-identity.js +9 -0
  98. package/lib/public/modules/home-conversations-sheet.js +102 -48
  99. package/lib/public/modules/home-mate-chat.js +15 -35
  100. package/lib/public/modules/home-session-actions.js +6 -0
  101. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  102. package/lib/public/modules/home-sidebar.js +7 -0
  103. package/lib/public/modules/home-tool-frame.js +182 -0
  104. package/lib/public/modules/home-tools.js +138 -1
  105. package/lib/public/modules/input.js +38 -7
  106. package/lib/public/modules/markdown.js +2 -0
  107. package/lib/public/modules/mate-sidebar.js +0 -8
  108. package/lib/public/modules/message-delivery.js +97 -0
  109. package/lib/public/modules/paste-modal.js +84 -0
  110. package/lib/public/modules/project-logs-render.js +405 -0
  111. package/lib/public/modules/project-logs.js +461 -0
  112. package/lib/public/modules/project-removal-target.js +26 -0
  113. package/lib/public/modules/scheduler.js +17 -3
  114. package/lib/public/modules/session-actions.js +29 -105
  115. package/lib/public/modules/session-hierarchy.js +54 -0
  116. package/lib/public/modules/sidebar-mobile.js +40 -15
  117. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  118. package/lib/public/modules/sidebar-sessions.js +77 -11
  119. package/lib/public/modules/sidebar.js +17 -5
  120. package/lib/public/modules/split-group-helpers.js +8 -0
  121. package/lib/public/modules/split-view.js +5 -2
  122. package/lib/public/modules/sticky-note-markdown.js +5 -0
  123. package/lib/public/modules/sticky-notes-browser.js +326 -0
  124. package/lib/public/modules/sticky-notes-card.js +360 -0
  125. package/lib/public/modules/sticky-notes-editor.js +291 -0
  126. package/lib/public/modules/sticky-notes-shared.js +90 -0
  127. package/lib/public/modules/sticky-notes.js +170 -946
  128. package/lib/public/modules/tool-palette-order.js +143 -0
  129. package/lib/public/modules/tool-palette-overlays.js +159 -0
  130. package/lib/public/modules/tool-palette.js +21 -188
  131. package/lib/public/modules/tools.js +21 -1
  132. package/lib/public/modules/update-snooze.js +169 -0
  133. package/lib/public/modules/user-settings.js +4 -0
  134. package/lib/public/modules/worker-pane-lock.js +176 -0
  135. package/lib/public/modules/worker-proposal-state.js +16 -0
  136. package/lib/public/modules/worker-proposal.js +49 -12
  137. package/lib/public/style.css +3 -1
  138. package/lib/sdk-bridge.js +132 -46
  139. package/lib/sdk-message-processor.js +60 -14
  140. package/lib/server-experimental-settings.js +49 -0
  141. package/lib/server-global-ws.js +11 -0
  142. package/lib/server-home-chat-events.js +31 -1
  143. package/lib/server-home-chat.js +27 -4
  144. package/lib/server-home-clay-entry.js +1 -1
  145. package/lib/server-home-clay-session-links.js +34 -5
  146. package/lib/server-settings.js +5 -0
  147. package/lib/server-tools.js +127 -9
  148. package/lib/server.js +124 -18
  149. package/lib/session-driver-eligibility.js +38 -0
  150. package/lib/session-driver-orchestration.js +31 -0
  151. package/lib/session-hygiene.js +3 -0
  152. package/lib/session-notes-mcp-server.js +25 -5
  153. package/lib/session-pair-factory.js +247 -0
  154. package/lib/session-pair-mcp-server.js +46 -8
  155. package/lib/session-pair-prompts.js +79 -0
  156. package/lib/session-pair-turn-control.js +153 -0
  157. package/lib/session-provenance.js +119 -0
  158. package/lib/session-spawn-mcp-server.js +1 -1
  159. package/lib/session-split-groups.js +4 -1
  160. package/lib/session-title-policy.js +60 -0
  161. package/lib/session-visibility.js +39 -0
  162. package/lib/sessions.js +61 -11
  163. package/lib/tools-registry.js +89 -10
  164. package/lib/update-snooze.js +437 -0
  165. package/lib/users-experimental-preferences.js +30 -0
  166. package/lib/users-permissions.js +2 -2
  167. package/lib/users.js +22 -16
  168. package/lib/workspace-query-access.js +102 -0
  169. package/lib/workspace-query-service.js +24 -17
  170. package/lib/worktree.js +25 -38
  171. package/lib/ws-schema.js +38 -7
  172. package/lib/yoke/adapters/claude-worker.js +16 -6
  173. package/lib/yoke/adapters/claude.js +81 -4
  174. package/lib/yoke/adapters/codex.js +63 -15
  175. package/lib/yoke/codex-background-tasks.js +8 -2
  176. package/lib/yoke/vendor-registry.js +1 -1
  177. package/package.json +3 -3
package/lib/sdk-bridge.js CHANGED
@@ -12,6 +12,7 @@ var { createMessageQueue } = require("./sdk-message-queue");
12
12
  var { attachMessageProcessor } = require("./sdk-message-processor");
13
13
  var homeDebateToolPolicy = require("./home-debate-tool-policy");
14
14
  var yoke = require("./yoke");
15
+ var sessionTitlePolicy = require("./session-title-policy");
15
16
 
16
17
  // Extract serializable tool descriptors from MCP server instances.
17
18
  // Used for IPC to worker processes (McpSdkServerConfigWithInstance is not serializable).
@@ -128,6 +129,9 @@ function createSDKBridge(opts) {
128
129
  var clayTls = opts.clayTls || false;
129
130
  var clayAuthToken = opts.clayAuthToken || null;
130
131
  var getSessionToolDefs = opts.getSessionToolDefs || function () { return []; };
132
+ // Split Worker permission routing. A getter because the pair module is
133
+ // constructed before this bridge exists.
134
+ var getWorkerPermissionRouter = opts.getWorkerPermissionRouter || function () { return null; };
131
135
  var onProcessingChanged = opts.onProcessingChanged || function () {};
132
136
  var _cachedFreshAuthState = null;
133
137
  var _cachedFreshAuthAt = 0;
@@ -601,27 +605,77 @@ function createSDKBridge(opts) {
601
605
  return { behavior: "allow", updatedInput: input };
602
606
  }
603
607
 
604
- // Auto-approve split-pair partner tools. The pair itself is
605
- // user-constructed (the user created the split and its roles and
606
- // watches both panes live), the target is structurally confined to the
607
- // split partner, and a prompt on every delegation hop defeats the
608
- // driver/worker workflow. spawn_sessions is NOT here: it creates new
609
- // sessions and keeps its prompt.
610
- var pairPartnerTools = { send_to_partner: true, read_partner: true, propose_worker: true };
611
- if (toolName.indexOf("mcp__clay-sessions__") === 0) {
612
- var sessionsToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
613
- if (pairPartnerTools[sessionsToolName]) {
608
+ // Auto-approve the non-mutating configuration proposal and the visible
609
+ // Driver/Split Worker pair tools, including the Driver's answer to a Worker
610
+ // permission request. Creation and replacement still require a separate
611
+ // human decision in the proposal card; skip-permissions is never treated as
612
+ // that product choice. Every handler is confined to the caller's exact
613
+ // session/pair and re-checks eligibility, ownership and live identity.
614
+ //
615
+ // spawn_sessions is deliberately absent: it creates arbitrary sessions.
616
+ //
617
+ // Only the two name forms the bridge can genuinely emit are accepted:
618
+ //
619
+ // mcp__clay-sessions__<tool> every MCP-mounted vendor. The server
620
+ // segment is matched exactly, so a tool of
621
+ // the same name on another MCP server is
622
+ // never auto-approved.
623
+ // <tool> a Codex dynamic tool. Codex canonicalizes
624
+ // send_to_partner and read_partner to the MCP
625
+ // form itself, so only the remaining names
626
+ // can legitimately arrive bare.
627
+ var PAIR_MCP_PREFIX = "mcp__clay-sessions__";
628
+ var pairMcpTools = {
629
+ propose_worker: true,
630
+ send_to_partner: true,
631
+ read_partner: true,
632
+ partner_status: true,
633
+ replace_partner: true,
634
+ interrupt_partner: true,
635
+ close_partner: true,
636
+ record_partner_evaluation: true,
637
+ respond_to_worker_permission: true,
638
+ };
639
+ var pairDynamicTools = {
640
+ propose_worker: true,
641
+ partner_status: true,
642
+ replace_partner: true,
643
+ interrupt_partner: true,
644
+ close_partner: true,
645
+ record_partner_evaluation: true,
646
+ respond_to_worker_permission: true,
647
+ };
648
+ if (toolName.indexOf("mcp__") === 0) {
649
+ if (toolName.indexOf(PAIR_MCP_PREFIX) === 0
650
+ && pairMcpTools[toolName.slice(PAIR_MCP_PREFIX.length)]) {
614
651
  return { behavior: "allow", updatedInput: input };
615
652
  }
653
+ } else if (pairDynamicTools[toolName]) {
654
+ return { behavior: "allow", updatedInput: input };
655
+ }
656
+
657
+ // Sticky-note operations are immediately visible and reversible on the
658
+ // shared board. The exact session-bound handlers still enforce ownership,
659
+ // lifecycle rules, and input validation, so a provider prompt adds no
660
+ // authority. Keep this confined to the exact Clay Notes namespace.
661
+ var NOTES_MCP_PREFIX = "mcp__clay-notes__";
662
+ var notesTools = {
663
+ list_notes: true,
664
+ write_note: true,
665
+ close_note: true,
666
+ reopen_note: true,
667
+ remove_note: true,
668
+ };
669
+ if (toolName.indexOf(NOTES_MCP_PREFIX) === 0
670
+ && notesTools[toolName.slice(NOTES_MCP_PREFIX.length)]) {
671
+ return { behavior: "allow", updatedInput: input };
616
672
  }
617
673
 
618
- // Sticky-note reads and writes are immediately visible on the shared
619
- // board. Deletion stays outside the whitelist and therefore prompts;
620
- // its session ownership is enforced again inside the server handler.
621
- var notesToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
622
- var isClayNotesTool = toolName.indexOf("mcp__clay-notes__") === 0
623
- || toolName.indexOf("clay-notes__") !== -1;
624
- if (isClayNotesTool && (notesToolName === "list_notes" || notesToolName === "write_note")) {
674
+ // Project Logs are exact-session, project-bound tools whose authority is
675
+ // enforced again by the server binding. Canonical changes are append-only
676
+ // revisions, and the surface exposes no destructive delete operation, so
677
+ // a second provider permission prompt adds no additional safety.
678
+ if (toolName.indexOf("mcp__clay-logs__") === 0) {
625
679
  return { behavior: "allow", updatedInput: input };
626
680
  }
627
681
 
@@ -720,6 +774,19 @@ function createSDKBridge(opts) {
720
774
  // Full-auto mode: auto-approve everything except AskUserQuestion
721
775
  // (which still needs to go through the user interaction flow).
722
776
  var clayPermissionMode = session.permissionMode || sm.currentPermissionMode;
777
+ // A configured Split Worker is Driver-operated, so it runs under its
778
+ // Driver's permission mode, re-resolved from the live pair on every single
779
+ // decision. Nothing is copied at creation and the Worker's own persisted
780
+ // setting is never written, so toggling skip permissions on the Driver
781
+ // applies to the Worker's next call in either direction, mid-turn included.
782
+ var _wpMode = getWorkerPermissionRouter();
783
+ if (_wpMode && typeof _wpMode.inheritedPermissionMode === "function") {
784
+ var inherited = _wpMode.inheritedPermissionMode(session);
785
+ if (inherited) clayPermissionMode = inherited;
786
+ }
787
+ // AskUserQuestion is genuine user input, not an authorization, so it is
788
+ // excluded here exactly as it always was: a Driver in skip mode still
789
+ // cannot silently answer a question addressed to the person.
723
790
  if (clayPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
724
791
  return Promise.resolve({ behavior: "allow", updatedInput: input });
725
792
  }
@@ -747,6 +814,22 @@ function createSDKBridge(opts) {
747
814
  return Promise.resolve({ behavior: "allow", updatedInput: input });
748
815
  }
749
816
 
817
+ // A visible Split Worker's remaining prompts belong to its paired Driver,
818
+ // not to the Worker pane. This sits below every auto-decision above, so
819
+ // skip permissions, the whitelist, allowedTools and the loop/debate
820
+ // policies all still resolve first and never wait on a Driver.
821
+ var _wp = getWorkerPermissionRouter();
822
+ if (_wp) {
823
+ var routed = _wp.routeIfWorker(session, {
824
+ toolName: toolName,
825
+ input: input,
826
+ toolUseId: opts.toolUseID,
827
+ decisionReason: opts.decisionReason || "",
828
+ signal: opts.signal,
829
+ });
830
+ if (routed) return routed;
831
+ }
832
+
750
833
  // Regular tool permission request: send to client and wait
751
834
  return new Promise(function(resolve) {
752
835
  var requestId = crypto.randomUUID();
@@ -1540,25 +1623,33 @@ function createSDKBridge(opts) {
1540
1623
  if (selectedUserInputMode === "fallback") {
1541
1624
  sessionToolDefs = sessionToolDefs.concat(yoke.userInput.fallbackToolDefs(sessionUserInputHandler));
1542
1625
  }
1626
+ function findSessionToolDef(toolName) {
1627
+ // Codex restores a thread's original dynamic-tool catalog on resume, but
1628
+ // pair state can change after this query starts. Resolve live session
1629
+ // handlers first so the dispatcher cannot lag behind that catalog. Keep
1630
+ // the query snapshot as a fallback for query-bound tools such as the
1631
+ // structured-input fallback, whose handler exists only for this turn.
1632
+ var liveToolDefs = getSessionToolDefs(session) || [];
1633
+ for (var liveIndex = 0; liveIndex < liveToolDefs.length; liveIndex++) {
1634
+ if (liveToolDefs[liveIndex].name === toolName) return liveToolDefs[liveIndex];
1635
+ }
1636
+ for (var snapshotIndex = 0; snapshotIndex < sessionToolDefs.length; snapshotIndex++) {
1637
+ if (sessionToolDefs[snapshotIndex].name === toolName) return sessionToolDefs[snapshotIndex];
1638
+ }
1639
+ return null;
1640
+ }
1543
1641
 
1544
- // Derive an explicit session title for fresh queries so the SDK records
1545
- // it at session creation and skips its own auto-generation. This also
1546
- // lets us short-circuit autoGenerateTitle below for the common case.
1642
+ // Give fresh queries an immediate provisional label. The visible user
1643
+ // message in history wins over `text`, which may contain an internal
1644
+ // provider wrapper rather than what the person typed.
1547
1645
  // Only applied to NEW sessions (no cliSessionId yet) — when resuming,
1548
1646
  // the SDK ignores Options.title in favor of the persisted title.
1549
1647
  var initialTitle = null;
1648
+ var initialTitleProvisional = false;
1550
1649
  if (!session.cliSessionId && !session.titleManuallySet && !session.titleAutoGenerated) {
1551
- if (session.title) {
1552
- // Loop / scheduled / mate-seeded sessions arrive with a title already set.
1553
- initialTitle = session.title;
1554
- } else if (typeof text === "string") {
1555
- // Derive a quick first-line snippet from the user's first message.
1556
- // Skip if too short to be meaningful — fall back to autoGenerateTitle.
1557
- var firstLine = text.replace(/\s+/g, " ").trim();
1558
- if (firstLine.length >= 10) {
1559
- initialTitle = firstLine.length > 60 ? firstLine.substring(0, 60) : firstLine;
1560
- }
1561
- }
1650
+ var initialTitleChoice = sessionTitlePolicy.selectInitialTitle(session, text);
1651
+ initialTitle = initialTitleChoice.title;
1652
+ initialTitleProvisional = initialTitleChoice.provisional;
1562
1653
  }
1563
1654
 
1564
1655
  var queryOpts = {
@@ -1587,10 +1678,9 @@ function createSDKBridge(opts) {
1587
1678
  toolServerDescriptors: extractMcpDescriptors(mergedMcpServers) || undefined,
1588
1679
  dynamicTools: sessionToolDefs,
1589
1680
  callDynamicTool: function (toolName, args) {
1590
- for (var dti = 0; dti < sessionToolDefs.length; dti++) {
1591
- if (sessionToolDefs[dti].name === toolName && typeof sessionToolDefs[dti].handler === "function") {
1592
- return sessionToolDefs[dti].handler(args || {});
1593
- }
1681
+ var toolDef = findSessionToolDef(toolName);
1682
+ if (toolDef && typeof toolDef.handler === "function") {
1683
+ return toolDef.handler(args || {});
1594
1684
  }
1595
1685
  return Promise.reject(new Error("Session tool not found: " + toolName));
1596
1686
  },
@@ -1598,12 +1688,8 @@ function createSDKBridge(opts) {
1598
1688
  abortController: linuxUser ? undefined : session.abortController,
1599
1689
  canUseTool: function(toolName, input, toolOpts) {
1600
1690
  var permissionName = toolName;
1601
- for (var pti = 0; pti < sessionToolDefs.length; pti++) {
1602
- if (sessionToolDefs[pti].name === toolName && sessionToolDefs[pti].permissionName) {
1603
- permissionName = sessionToolDefs[pti].permissionName;
1604
- break;
1605
- }
1606
- }
1691
+ var permissionToolDef = findSessionToolDef(toolName);
1692
+ if (permissionToolDef && permissionToolDef.permissionName) permissionName = permissionToolDef.permissionName;
1607
1693
  return handleCanUseTool(session, permissionName, input, toolOpts);
1608
1694
  },
1609
1695
  onUserInputRequest: function(request, respond) {
@@ -1653,16 +1739,16 @@ function createSDKBridge(opts) {
1653
1739
  try {
1654
1740
  handle = await sessionAdapter.createQuery(queryOpts);
1655
1741
  console.log("[sdk-bridge] createQuery returned handle, vendor=" + sessionAdapter.vendor);
1656
- // SDK accepted the explicit title adopt it locally so the session
1657
- // list reflects it immediately and autoGenerateTitle skips this
1658
- // session (titleAutoGenerated gates re-trigger).
1742
+ // SDK accepted the explicit title. Provisional labels remain eligible
1743
+ // for semantic generation after the first Home exchange (or the normal
1744
+ // project threshold); seeded titles remain fixed.
1659
1745
  if (initialTitle && !session.title) {
1660
1746
  session.title = initialTitle;
1661
- session.titleAutoGenerated = true;
1747
+ session.titleAutoGenerated = !initialTitleProvisional;
1662
1748
  sm.saveSessionFile(session);
1663
1749
  sm.broadcastSessionList();
1664
1750
  } else if (initialTitle && session.title === initialTitle) {
1665
- session.titleAutoGenerated = true;
1751
+ session.titleAutoGenerated = !initialTitleProvisional;
1666
1752
  sm.saveSessionFile(session);
1667
1753
  }
1668
1754
  } catch (e) {
@@ -1,5 +1,9 @@
1
1
  var usersModule = require("./users");
2
2
  var yoke = require("./yoke");
3
+ var backgroundTaskTiming = require("./background-task-timing");
4
+ var sessionTitlePolicy = require("./session-title-policy");
5
+
6
+ var OVERLOAD_RETRY_LIMIT = 3;
3
7
 
4
8
  function attachMessageProcessor(ctx) {
5
9
  var sm = ctx.sm;
@@ -22,8 +26,6 @@ function attachMessageProcessor(ctx) {
22
26
  var saveImageFile = ctx.saveImageFile;
23
27
  var getSessionLinuxUser = ctx.getSessionLinuxUser || function () { return null; };
24
28
 
25
- var AUTO_TITLE_TURN_THRESHOLD = 2;
26
-
27
29
  function getMateIdForNotification() {
28
30
  if (!isMate) return null;
29
31
  if (typeof slug === "string" && slug.indexOf("mate-") === 0) {
@@ -60,6 +62,8 @@ function attachMessageProcessor(ctx) {
60
62
  if (session._lastAuthEmit && (now - session._lastAuthEmit) < 5000) return;
61
63
  session._lastAuthEmit = now;
62
64
 
65
+ var vendor = session.vendor || "claude";
66
+
63
67
  var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
64
68
  var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
65
69
  var canAutoLogin = !usersModule.isMultiUser()
@@ -69,10 +73,13 @@ function attachMessageProcessor(ctx) {
69
73
  var authTitle = ((vendorInfo && vendorInfo.displayName) || "Claude Code") + " is not logged in.";
70
74
  var loginCommand = (vendorInfo && vendorInfo.loginCommand) || "claude login";
71
75
  var _nmLogin = getNotificationsModule();
72
- sendAndRecord(session, {
76
+ // Authentication recovery is transient UI state, not transcript content.
77
+ // Recording it makes history replay look like a fresh auth failure and
78
+ // re-opens the login terminal after a successful sign-in.
79
+ sendToSession(session, {
73
80
  type: "auth_required",
74
81
  text: authTitle,
75
- vendor: session.vendor || "claude",
82
+ vendor: vendor,
76
83
  loginCommand: loginCommand,
77
84
  linuxUser: authLinuxUser,
78
85
  canAutoLogin: canAutoLogin,
@@ -84,7 +91,7 @@ function attachMessageProcessor(ctx) {
84
91
  slug: slug,
85
92
  sessionId: session.localId,
86
93
  ownerId: session.ownerId || null,
87
- vendor: session.vendor || "claude",
94
+ vendor: vendor,
88
95
  loginCommand: loginCommand,
89
96
  linuxUser: authLinuxUser,
90
97
  canAutoLogin: canAutoLogin,
@@ -192,7 +199,9 @@ function attachMessageProcessor(ctx) {
192
199
 
193
200
  // Cache slash_commands and model from CLI init message
194
201
  if (parsed.yokeType === "init") {
195
- var previousBackgroundTaskCount = (session.activeBackgroundTasks || []).length;
202
+ // Ambient housekeeping never drove the sidebar badge, so a reset that
203
+ // only clears ambient watchers must not rebroadcast the session list.
204
+ var previousBackgroundTaskCount = backgroundTaskTiming.countUserTasks(session.activeBackgroundTasks);
196
205
  session.activeBackgroundTasks = [];
197
206
  sendToSession(session, { type: "active_background_tasks", tasks: [] });
198
207
  if (previousBackgroundTaskCount !== 0) sm.broadcastSessionList();
@@ -480,6 +489,8 @@ function attachMessageProcessor(ctx) {
480
489
  }
481
490
 
482
491
  } else if (parsed.yokeType === "result") {
492
+ var overloadAbortRequested = !!session._overloadAbortRequested;
493
+ session._overloadAbortRequested = false;
483
494
  session.blocks = {};
484
495
  session.sentToolResults = {};
485
496
  session.pendingPermissions = {};
@@ -525,7 +536,9 @@ function attachMessageProcessor(ctx) {
525
536
  session._awaitingTurnResult = false;
526
537
  session._queuedTurnCount = 0;
527
538
  onProcessingChanged();
528
- sendAndRecord(session, { type: "error", text: "Claude error: " + execError });
539
+ if (!overloadAbortRequested) {
540
+ sendAndRecord(session, { type: "error", text: "Claude error: " + execError });
541
+ }
529
542
  sendAndRecord(session, { type: "done", code: 1 });
530
543
  sm.broadcastSessionList();
531
544
  return;
@@ -617,7 +630,7 @@ function attachMessageProcessor(ctx) {
617
630
  sm.broadcastSessionList();
618
631
 
619
632
  // Auto-generate title after N turns (skip if loop or already auto-generated)
620
- if (session.turnCount === AUTO_TITLE_TURN_THRESHOLD
633
+ if (session.turnCount === sessionTitlePolicy.generationTurn(isMate)
621
634
  && !session.titleAutoGenerated
622
635
  && !session.titleManuallySet
623
636
  && !session.loop
@@ -686,11 +699,20 @@ function attachMessageProcessor(ctx) {
686
699
  }
687
700
 
688
701
  } else if (parsed.yokeType === "background_tasks_changed") {
689
- var previousBackgroundTaskCount = (session.activeBackgroundTasks || []).length;
690
- var activeBackgroundTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
702
+ var previousBackgroundTasks = session.activeBackgroundTasks || [];
703
+ // Count user work only: an ambient watcher starting or stopping must not
704
+ // rebroadcast the session list and flicker every client's sidebar badge.
705
+ var previousBackgroundTaskCount = backgroundTaskTiming.countUserTasks(previousBackgroundTasks);
706
+ // Vendors re-send the whole list on every change, so carry each task's
707
+ // first-seen time forward; otherwise the composer's elapsed timer would
708
+ // restart whenever any other task starts or finishes.
709
+ var activeBackgroundTasks = backgroundTaskTiming.mergeStartTimes(
710
+ previousBackgroundTasks,
711
+ Array.isArray(parsed.tasks) ? parsed.tasks : []
712
+ );
691
713
  session.activeBackgroundTasks = activeBackgroundTasks;
692
714
  sendToSession(session, { type: "active_background_tasks", tasks: activeBackgroundTasks });
693
- if (previousBackgroundTaskCount !== activeBackgroundTasks.length) {
715
+ if (previousBackgroundTaskCount !== backgroundTaskTiming.countUserTasks(activeBackgroundTasks)) {
694
716
  sm.broadcastSessionList();
695
717
  }
696
718
 
@@ -803,9 +825,33 @@ function attachMessageProcessor(ctx) {
803
825
  }
804
826
 
805
827
  } else if (parsed.yokeType === "api_retry") {
806
- // Transient retry notification, show in UI but don't persist in history
807
- var retryText = parsed.message || parsed.error || "Retrying API request...";
808
- sendToSession(session, { type: "system_info", text: retryText });
828
+ // The Claude SDK may otherwise paint one persistent red error for every
829
+ // retry and remain busy for many minutes. Show one transient status, and
830
+ // stop a repeatedly overloaded turn after a small bounded number of
831
+ // attempts so the user regains control and can retry or change models.
832
+ var apiRetryAttempt = parsed.attempt || 1;
833
+ if (parsed.error === "overloaded") {
834
+ if (apiRetryAttempt === 1) {
835
+ session._overloadAbortRequested = false;
836
+ sendToSession(session, {
837
+ type: "system_info",
838
+ text: "The model is overloaded. Retrying briefly...",
839
+ });
840
+ }
841
+ if (apiRetryAttempt >= OVERLOAD_RETRY_LIMIT && !session._overloadAbortRequested) {
842
+ session._overloadAbortRequested = true;
843
+ sendAndRecord(session, {
844
+ type: "error",
845
+ text: "The model remained overloaded after " + apiRetryAttempt +
846
+ " attempts, so this turn was stopped. Try again or choose another model.",
847
+ });
848
+ if (session.abortController && typeof session.abortController.abort === "function") {
849
+ try { session.abortController.abort(); } catch (e) {}
850
+ }
851
+ }
852
+ } else if (apiRetryAttempt === 1) {
853
+ sendToSession(session, { type: "system_info", text: "The model request failed temporarily. Retrying..." });
854
+ }
809
855
 
810
856
  } else if (parsed.yokeType === "commands_changed") {
811
857
  // Mid-session command list change: rebuild the union with skills (same as
@@ -0,0 +1,49 @@
1
+ function attachExperimentalSettings(ctx) {
2
+ var users = ctx.users;
3
+ var getMultiUserFromReq = ctx.getMultiUserFromReq;
4
+ var opts = ctx.opts;
5
+
6
+ function handleRequest(req, res, fullUrl) {
7
+ if (req.method !== "PUT" || fullUrl !== "/api/user/capsules-enabled") return false;
8
+ var isMultiUser = users.isMultiUser();
9
+ var mu = getMultiUserFromReq(req);
10
+ if (isMultiUser && !mu) {
11
+ res.writeHead(401, { "Content-Type": "application/json" });
12
+ res.end('{"error":"unauthorized"}');
13
+ return true;
14
+ }
15
+ var body = "";
16
+ req.on("data", function (chunk) { body += chunk; });
17
+ req.on("end", function () {
18
+ try {
19
+ var data = JSON.parse(body);
20
+ var result;
21
+ var userId = "default";
22
+ if (isMultiUser) {
23
+ userId = mu.id;
24
+ result = users.setCapsulesEnabled(userId, data.enabled === true);
25
+ } else if (typeof opts.onSetCapsulesEnabled === "function") {
26
+ result = opts.onSetCapsulesEnabled(data.enabled === true);
27
+ } else {
28
+ result = { ok: true, capsulesEnabled: false };
29
+ }
30
+ if (result.error) {
31
+ res.writeHead(400, { "Content-Type": "application/json" });
32
+ res.end(JSON.stringify({ error: result.error }));
33
+ return;
34
+ }
35
+ if (typeof ctx.onCapsulesPreferenceChanged === "function") ctx.onCapsulesPreferenceChanged(userId);
36
+ res.writeHead(200, { "Content-Type": "application/json" });
37
+ res.end(JSON.stringify({ ok: true, capsulesEnabled: result.capsulesEnabled === true }));
38
+ } catch (e) {
39
+ res.writeHead(400, { "Content-Type": "application/json" });
40
+ res.end('{"error":"Invalid request"}');
41
+ }
42
+ });
43
+ return true;
44
+ }
45
+
46
+ return { handleRequest: handleRequest };
47
+ }
48
+
49
+ module.exports = { attachExperimentalSettings: attachExperimentalSettings };
@@ -7,6 +7,7 @@
7
7
  // Bound to the slug-less `/ws` endpoint by lib/server.js. Only handles the
8
8
  // small set of messages needed to bootstrap into a project context:
9
9
  // - ping -> pong keep-alive
10
+ // - Home and Mate -> the same user-scoped handlers as project sockets
10
11
  // - browse_dir -> directory picker for the add-project modal
11
12
  // - add_project -> register an existing directory
12
13
  // - create_project -> make a new empty project
@@ -37,6 +38,8 @@ function attachGlobalWs(opts) {
37
38
  var onAddProject = opts.onAddProject;
38
39
  var onCreateProject = opts.onCreateProject;
39
40
  var onCloneProject = opts.onCloneProject;
41
+ var onHomePreferenceMessage = opts.onHomePreferenceMessage;
42
+ var onAppMessage = opts.onAppMessage;
40
43
 
41
44
  function handleMessage(ws, msg) {
42
45
  if (!msg || typeof msg !== "object") return;
@@ -46,6 +49,14 @@ function attachGlobalWs(opts) {
46
49
  return;
47
50
  }
48
51
 
52
+ // A user does not need an ordinary project before Home becomes useful.
53
+ // Route Mate discovery, Home chat/debates, and tools through the same
54
+ // handlers used by project sockets. This also lets mate_list repair an
55
+ // existing account whose built-ins were missing from its final storage.
56
+ if (typeof onAppMessage === "function" && onAppMessage(ws, msg)) return;
57
+
58
+ if (typeof onHomePreferenceMessage === "function" && onHomePreferenceMessage(ws, msg)) return;
59
+
49
60
  // --- Directory picker for the add-project modal ---
50
61
  if (msg.type === "browse_dir") {
51
62
  var rawPath = (msg.path || "").replace(/^~/, config.REAL_HOME);
@@ -134,9 +134,24 @@ function historyToHomeChat(history, debateSetupMode, mateCreationMode) {
134
134
  applyDebateHistoryEvent(messages, event);
135
135
  continue;
136
136
  }
137
+ if (event.type === "capsule_turn") {
138
+ // A Capsule engagement delivery. The transcript shows a short system
139
+ // note; the full delivery prompt is model-facing, not conversation.
140
+ flushAssistant();
141
+ messages.push({
142
+ role: "capsule_turn",
143
+ text: (event.toolName || event.toolId || "Capsule") + (event.kind === "start" ? ": a new game started." : ": your Mate is taking its turn."),
144
+ });
145
+ continue;
146
+ }
137
147
  if (event.type === "user_message" && event.text && event.askUserAnswer !== true && !interviewMode) {
138
148
  flushAssistant();
139
149
  messages.push({ role: "user", text: event.text });
150
+ } else if (!interviewMode && (event.type === "tool_start" || event.type === "tool_executing" || event.type === "tool_result")) {
151
+ // A tool call ends the current spoken burst. Without this flush the
152
+ // text on both sides of the call concatenates into one run-on bubble,
153
+ // which is exactly how a Capsule game transcript turns into a wall.
154
+ flushAssistant();
140
155
  } else if (event.type === "delta" && typeof event.text === "string" && !interviewMode) {
141
156
  pending += event.text;
142
157
  } else if (event.type === "result" || event.type === "done") {
@@ -185,13 +200,17 @@ function historyToHomeChat(history, debateSetupMode, mateCreationMode) {
185
200
  return messages;
186
201
  }
187
202
 
203
+ // The authoritative text for the bubble being finalized: only the deltas
204
+ // since the last conversational boundary. Tool calls and Capsule turn
205
+ // deliveries are boundaries too, or a session that is never driven by typed
206
+ // user messages (a Capsule game) would collapse into one run-on bubble.
188
207
  function latestAssistantText(history) {
189
208
  var text = "";
190
209
  for (var i = (history || []).length - 1; i >= 0; i--) {
191
210
  var event = history[i];
192
211
  if (!event) continue;
193
212
  if (event.type === "delta" && typeof event.text === "string") text = event.text + text;
194
- if (event.type === "user_message" || event.type === "debate_proposal" || event.type === "debate_proposal_resolved" || event.type === "project_assignment_proposal" || event.type === "project_assignment_status" || (event.type === "tool_executing" && event.name === "AskUserQuestion")) break;
213
+ if (event.type === "user_message" || event.type === "capsule_turn" || event.type === "tool_start" || event.type === "tool_executing" || event.type === "tool_result" || event.type === "debate_proposal" || event.type === "debate_proposal_resolved" || event.type === "project_assignment_proposal" || event.type === "project_assignment_status") break;
195
214
  }
196
215
  return text;
197
216
  }
@@ -209,6 +228,9 @@ function searchToolLabel(name, input) {
209
228
  if (name === "list_project_sessions") return "Reviewing project conversations";
210
229
  if (name === "list_projects") return "Reviewing your projects";
211
230
  if (name === "search_project_history") return query ? "Searching a project for \u201c" + query + "\u201d" : "Searching a project";
231
+ if (name === "search_project_logs") return query ? "Searching project logs for \u201c" + query + "\u201d" : "Searching project logs";
232
+ if (name === "list_project_logs") return "Reviewing project logs";
233
+ if (name === "read_project_log" || name === "read_project_log_revision" || name === "project_log_history") return "Reading a project log";
212
234
  return "Using " + (cleanActivityText(name, 64) || "a workspace tool");
213
235
  }
214
236
 
@@ -281,6 +303,14 @@ function transformEvent(event, mateId, session, requestId, stableSessionId) {
281
303
  if (session && session.mateCreationMode === true && event.type === "tool_executing" && event.name === "AskUserQuestion" && event.id && event.input) return Object.assign({ type: "home_mate_creation_question", toolId: event.id, questions: homeDebateQuestions(event.input) }, metadata);
282
304
  if (session && session.mateCreationMode === true && event.type === "ask_user_answered" && event.toolId) return Object.assign({ type: "home_mate_creation_question_resolved", toolId: event.toolId, status: "answered", answers: event.answers || null }, metadata);
283
305
  if (session && session.mateCreationMode === true && event.type === "home_mate_creation_question_expired" && event.toolId) return Object.assign({ type: "home_mate_creation_question_resolved", toolId: event.toolId, status: "expired", error: event.error || "This question expired. Ask Clay to repeat it." }, metadata);
306
+ if ((event.type === "tool_start" || event.type === "tool_executing") && event.name !== "AskUserQuestion") {
307
+ // A tool call ends the current spoken burst: the client seals the bubble
308
+ // it has streamed so far, and later text starts a fresh one. Without this
309
+ // a session driven by tool play (a Capsule game) streams into one
310
+ // ever-growing run-on bubble. Last so every special surface above (search
311
+ // activity, debates, interviews, AskUserQuestion) keeps precedence.
312
+ return Object.assign({ type: "home_mate_segment" }, metadata);
313
+ }
284
314
  return null;
285
315
  }
286
316