clay-server 4.0.0-beta.1 → 4.0.0-beta.11

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 (99) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/config.js +25 -1
  3. package/lib/daemon.js +22 -0
  4. package/lib/git-cli.js +94 -1
  5. package/lib/knowledge-import.js +436 -0
  6. package/lib/knowledge-record-store.js +188 -0
  7. package/lib/knowledge-search.js +240 -0
  8. package/lib/mate-knowledge-mcp-server.js +151 -0
  9. package/lib/mate-knowledge-migration.js +283 -0
  10. package/lib/mate-knowledge-service.js +488 -0
  11. package/lib/mate-knowledge-sync.js +482 -0
  12. package/lib/notes-lifecycle.js +149 -0
  13. package/lib/notes.js +60 -3
  14. package/lib/project-connection.js +17 -0
  15. package/lib/project-http.js +15 -1
  16. package/lib/project-knowledge.js +22 -0
  17. package/lib/project-logs-comments.js +137 -0
  18. package/lib/project-logs-mcp-server.js +314 -0
  19. package/lib/project-logs-query.js +167 -0
  20. package/lib/project-logs-root.js +67 -0
  21. package/lib/project-logs-schema.js +259 -0
  22. package/lib/project-logs-service.js +357 -0
  23. package/lib/project-logs-snapshot.js +202 -0
  24. package/lib/project-logs-store.js +498 -0
  25. package/lib/project-logs-versioning.js +128 -0
  26. package/lib/project-logs.js +348 -0
  27. package/lib/project-mate-interaction.js +14 -0
  28. package/lib/project-mate-knowledge.js +110 -0
  29. package/lib/project-memory.js +22 -0
  30. package/lib/project-pair-lifecycle.js +461 -0
  31. package/lib/project-session-notes.js +64 -13
  32. package/lib/project-session-pair.js +124 -111
  33. package/lib/project-sessions.js +46 -6
  34. package/lib/project-user-message.js +41 -4
  35. package/lib/project-vendor-login.js +414 -0
  36. package/lib/project-worker-permission.js +429 -0
  37. package/lib/project-worker-proposal.js +16 -1
  38. package/lib/project.js +164 -4
  39. package/lib/public/app.js +49 -20
  40. package/lib/public/css/git-placard.css +154 -0
  41. package/lib/public/css/input.css +79 -25
  42. package/lib/public/css/notifications-center.css +67 -0
  43. package/lib/public/css/pane.css +52 -0
  44. package/lib/public/css/project-logs.css +234 -0
  45. package/lib/public/css/scheduler.css +1 -1
  46. package/lib/public/css/sticky-notes.css +231 -226
  47. package/lib/public/index.html +12 -0
  48. package/lib/public/modules/app-connection.js +90 -4
  49. package/lib/public/modules/app-messages.js +54 -19
  50. package/lib/public/modules/app-notifications.js +15 -124
  51. package/lib/public/modules/app-panels.js +43 -2
  52. package/lib/public/modules/app-projects.js +2 -2
  53. package/lib/public/modules/background-tasks-ui.js +139 -16
  54. package/lib/public/modules/git-agent-sessions.js +85 -0
  55. package/lib/public/modules/git-panel.js +111 -84
  56. package/lib/public/modules/git-placard.js +161 -0
  57. package/lib/public/modules/home-sidebar.js +7 -0
  58. package/lib/public/modules/mate-sidebar.js +0 -8
  59. package/lib/public/modules/pane-bridge.js +9 -0
  60. package/lib/public/modules/project-logs-ambient.js +238 -0
  61. package/lib/public/modules/project-logs-render.js +359 -0
  62. package/lib/public/modules/project-logs.js +329 -0
  63. package/lib/public/modules/scheduler.js +17 -3
  64. package/lib/public/modules/sidebar-mobile.js +5 -9
  65. package/lib/public/modules/sidebar-sessions.js +25 -1
  66. package/lib/public/modules/sidebar.js +17 -5
  67. package/lib/public/modules/split-view.js +7 -0
  68. package/lib/public/modules/sticky-notes-browser.js +324 -0
  69. package/lib/public/modules/sticky-notes-card.js +358 -0
  70. package/lib/public/modules/sticky-notes-editor.js +288 -0
  71. package/lib/public/modules/sticky-notes-shared.js +90 -0
  72. package/lib/public/modules/sticky-notes.js +168 -946
  73. package/lib/public/modules/tool-palette-order.js +143 -0
  74. package/lib/public/modules/tool-palette-overlays.js +159 -0
  75. package/lib/public/modules/tool-palette.js +21 -188
  76. package/lib/public/modules/tui-attention.js +10 -0
  77. package/lib/public/modules/update-snooze.js +169 -0
  78. package/lib/public/modules/vendor-login.js +287 -0
  79. package/lib/public/modules/worker-pane-lock.js +174 -0
  80. package/lib/public/style.css +2 -0
  81. package/lib/sdk-bridge.js +77 -10
  82. package/lib/sdk-message-processor.js +24 -7
  83. package/lib/server.js +61 -6
  84. package/lib/session-driver-eligibility.js +183 -0
  85. package/lib/session-notes-mcp-server.js +25 -5
  86. package/lib/session-pair-factory.js +233 -0
  87. package/lib/session-pair-mcp-server.js +45 -5
  88. package/lib/session-pair-prompts.js +60 -0
  89. package/lib/sessions.js +4 -1
  90. package/lib/tools-registry.js +11 -1
  91. package/lib/update-snooze.js +437 -0
  92. package/lib/workspace-query-access.js +102 -0
  93. package/lib/workspace-query-service.js +24 -17
  94. package/lib/ws-schema.js +28 -3
  95. package/lib/yoke/adapters/claude.js +69 -4
  96. package/lib/yoke/adapters/codex.js +42 -5
  97. package/lib/yoke/codex-app-server.js +20 -2
  98. package/lib/yoke/codex-background-tasks.js +8 -2
  99. package/package.json +3 -3
package/lib/sdk-bridge.js CHANGED
@@ -128,6 +128,9 @@ function createSDKBridge(opts) {
128
128
  var clayTls = opts.clayTls || false;
129
129
  var clayAuthToken = opts.clayAuthToken || null;
130
130
  var getSessionToolDefs = opts.getSessionToolDefs || function () { return []; };
131
+ // Split Worker permission routing. A getter because the pair module is
132
+ // constructed before this bridge exists.
133
+ var getWorkerPermissionRouter = opts.getWorkerPermissionRouter || function () { return null; };
131
134
  var onProcessingChanged = opts.onProcessingChanged || function () {};
132
135
  var _cachedFreshAuthState = null;
133
136
  var _cachedFreshAuthAt = 0;
@@ -601,18 +604,53 @@ function createSDKBridge(opts) {
601
604
  return { behavior: "allow", updatedInput: input };
602
605
  }
603
606
 
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]) {
607
+ // Auto-approve the visible Driver/Split Worker pair tools, including the
608
+ // Driver's answer to a Worker permission request. The pair is
609
+ // user-constructed and both panes are on screen; every one of these tools
610
+ // is structurally confined to the caller's own exact pair and re-checks
611
+ // Driver eligibility, ownership and the live pair inside its handler, so
612
+ // the whitelist grants no authority the handler would not already demand.
613
+ // A prompt on each hop would reinstate exactly the approval gate the
614
+ // autonomous lifecycle exists to remove.
615
+ //
616
+ // spawn_sessions is deliberately absent: it creates arbitrary sessions.
617
+ //
618
+ // Only the two name forms the bridge can genuinely emit are accepted:
619
+ //
620
+ // mcp__clay-sessions__<tool> every MCP-mounted vendor. The server
621
+ // segment is matched exactly, so a tool of
622
+ // the same name on another MCP server is
623
+ // never auto-approved.
624
+ // <tool> a Codex dynamic tool. Codex canonicalizes
625
+ // send_to_partner and read_partner to the MCP
626
+ // form itself, so only the remaining names
627
+ // can legitimately arrive bare.
628
+ var PAIR_MCP_PREFIX = "mcp__clay-sessions__";
629
+ var pairMcpTools = {
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
+ partner_status: true,
641
+ replace_partner: true,
642
+ interrupt_partner: true,
643
+ close_partner: true,
644
+ record_partner_evaluation: true,
645
+ respond_to_worker_permission: true,
646
+ };
647
+ if (toolName.indexOf("mcp__") === 0) {
648
+ if (toolName.indexOf(PAIR_MCP_PREFIX) === 0
649
+ && pairMcpTools[toolName.slice(PAIR_MCP_PREFIX.length)]) {
614
650
  return { behavior: "allow", updatedInput: input };
615
651
  }
652
+ } else if (pairDynamicTools[toolName]) {
653
+ return { behavior: "allow", updatedInput: input };
616
654
  }
617
655
 
618
656
  // Sticky-note reads and writes are immediately visible on the shared
@@ -720,6 +758,19 @@ function createSDKBridge(opts) {
720
758
  // Full-auto mode: auto-approve everything except AskUserQuestion
721
759
  // (which still needs to go through the user interaction flow).
722
760
  var clayPermissionMode = session.permissionMode || sm.currentPermissionMode;
761
+ // A configured Split Worker is Driver-operated, so it runs under its
762
+ // Driver's permission mode, re-resolved from the live pair on every single
763
+ // decision. Nothing is copied at creation and the Worker's own persisted
764
+ // setting is never written, so toggling skip permissions on the Driver
765
+ // applies to the Worker's next call in either direction, mid-turn included.
766
+ var _wpMode = getWorkerPermissionRouter();
767
+ if (_wpMode && typeof _wpMode.inheritedPermissionMode === "function") {
768
+ var inherited = _wpMode.inheritedPermissionMode(session);
769
+ if (inherited) clayPermissionMode = inherited;
770
+ }
771
+ // AskUserQuestion is genuine user input, not an authorization, so it is
772
+ // excluded here exactly as it always was: a Driver in skip mode still
773
+ // cannot silently answer a question addressed to the person.
723
774
  if (clayPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
724
775
  return Promise.resolve({ behavior: "allow", updatedInput: input });
725
776
  }
@@ -747,6 +798,22 @@ function createSDKBridge(opts) {
747
798
  return Promise.resolve({ behavior: "allow", updatedInput: input });
748
799
  }
749
800
 
801
+ // A visible Split Worker's remaining prompts belong to its paired Driver,
802
+ // not to the Worker pane. This sits below every auto-decision above, so
803
+ // skip permissions, the whitelist, allowedTools and the loop/debate
804
+ // policies all still resolve first and never wait on a Driver.
805
+ var _wp = getWorkerPermissionRouter();
806
+ if (_wp) {
807
+ var routed = _wp.routeIfWorker(session, {
808
+ toolName: toolName,
809
+ input: input,
810
+ toolUseId: opts.toolUseID,
811
+ decisionReason: opts.decisionReason || "",
812
+ signal: opts.signal,
813
+ });
814
+ if (routed) return routed;
815
+ }
816
+
750
817
  // Regular tool permission request: send to client and wait
751
818
  return new Promise(function(resolve) {
752
819
  var requestId = crypto.randomUUID();
@@ -1,5 +1,6 @@
1
1
  var usersModule = require("./users");
2
2
  var yoke = require("./yoke");
3
+ var backgroundTaskTiming = require("./background-task-timing");
3
4
 
4
5
  function attachMessageProcessor(ctx) {
5
6
  var sm = ctx.sm;
@@ -60,6 +61,8 @@ function attachMessageProcessor(ctx) {
60
61
  if (session._lastAuthEmit && (now - session._lastAuthEmit) < 5000) return;
61
62
  session._lastAuthEmit = now;
62
63
 
64
+ var vendor = session.vendor || "claude";
65
+
63
66
  var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
64
67
  var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
65
68
  var canAutoLogin = !usersModule.isMultiUser()
@@ -69,10 +72,13 @@ function attachMessageProcessor(ctx) {
69
72
  var authTitle = ((vendorInfo && vendorInfo.displayName) || "Claude Code") + " is not logged in.";
70
73
  var loginCommand = (vendorInfo && vendorInfo.loginCommand) || "claude login";
71
74
  var _nmLogin = getNotificationsModule();
72
- sendAndRecord(session, {
75
+ // Authentication recovery is transient UI state, not transcript content.
76
+ // Recording it makes history replay look like a fresh auth failure and
77
+ // re-opens the login terminal after a successful sign-in.
78
+ sendToSession(session, {
73
79
  type: "auth_required",
74
80
  text: authTitle,
75
- vendor: session.vendor || "claude",
81
+ vendor: vendor,
76
82
  loginCommand: loginCommand,
77
83
  linuxUser: authLinuxUser,
78
84
  canAutoLogin: canAutoLogin,
@@ -84,7 +90,7 @@ function attachMessageProcessor(ctx) {
84
90
  slug: slug,
85
91
  sessionId: session.localId,
86
92
  ownerId: session.ownerId || null,
87
- vendor: session.vendor || "claude",
93
+ vendor: vendor,
88
94
  loginCommand: loginCommand,
89
95
  linuxUser: authLinuxUser,
90
96
  canAutoLogin: canAutoLogin,
@@ -192,7 +198,9 @@ function attachMessageProcessor(ctx) {
192
198
 
193
199
  // Cache slash_commands and model from CLI init message
194
200
  if (parsed.yokeType === "init") {
195
- var previousBackgroundTaskCount = (session.activeBackgroundTasks || []).length;
201
+ // Ambient housekeeping never drove the sidebar badge, so a reset that
202
+ // only clears ambient watchers must not rebroadcast the session list.
203
+ var previousBackgroundTaskCount = backgroundTaskTiming.countUserTasks(session.activeBackgroundTasks);
196
204
  session.activeBackgroundTasks = [];
197
205
  sendToSession(session, { type: "active_background_tasks", tasks: [] });
198
206
  if (previousBackgroundTaskCount !== 0) sm.broadcastSessionList();
@@ -686,11 +694,20 @@ function attachMessageProcessor(ctx) {
686
694
  }
687
695
 
688
696
  } else if (parsed.yokeType === "background_tasks_changed") {
689
- var previousBackgroundTaskCount = (session.activeBackgroundTasks || []).length;
690
- var activeBackgroundTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
697
+ var previousBackgroundTasks = session.activeBackgroundTasks || [];
698
+ // Count user work only: an ambient watcher starting or stopping must not
699
+ // rebroadcast the session list and flicker every client's sidebar badge.
700
+ var previousBackgroundTaskCount = backgroundTaskTiming.countUserTasks(previousBackgroundTasks);
701
+ // Vendors re-send the whole list on every change, so carry each task's
702
+ // first-seen time forward; otherwise the composer's elapsed timer would
703
+ // restart whenever any other task starts or finishes.
704
+ var activeBackgroundTasks = backgroundTaskTiming.mergeStartTimes(
705
+ previousBackgroundTasks,
706
+ Array.isArray(parsed.tasks) ? parsed.tasks : []
707
+ );
691
708
  session.activeBackgroundTasks = activeBackgroundTasks;
692
709
  sendToSession(session, { type: "active_background_tasks", tasks: activeBackgroundTasks });
693
- if (previousBackgroundTaskCount !== activeBackgroundTasks.length) {
710
+ if (previousBackgroundTaskCount !== backgroundTaskTiming.countUserTasks(activeBackgroundTasks)) {
694
711
  sm.broadcastSessionList();
695
712
  }
696
713
 
package/lib/server.js CHANGED
@@ -23,6 +23,8 @@ var serverPalette = require("./server-palette");
23
23
  var workspaceQueryModule = require("./workspace-query-service");
24
24
  var { attachWorkspaceQueryService } = workspaceQueryModule;
25
25
  var { attachWorkspaceAssignmentService } = require("./workspace-assignment-service");
26
+ var { attachProjectLogsService } = require("./project-logs-service");
27
+ var { attachMateKnowledgeService } = require("./mate-knowledge-service");
26
28
  var serverEmail = require("./server-email");
27
29
  var serverGlobalWs = require("./server-global-ws");
28
30
  var { parseWsRequestUrl } = require("./ws-request");
@@ -306,18 +308,50 @@ function createServer(opts) {
306
308
  var workspaceQueryService = attachWorkspaceQueryService({
307
309
  getProjects: function () { return projects; },
308
310
  isMultiUser: function () { return users.isMultiUser(); },
309
- canAccessProject: function (userId, status) {
310
- return users.canAccessProject(userId, {
311
- ownerId: status && status.projectOwnerId || null,
312
- visibility: status && status.visibility || "public",
313
- allowedUsers: status && status.allowedUsers || [],
314
- });
311
+ // Project access is resolved from the authoritative project record, not
312
+ // from getStatus(), which carries no visibility or allowedUsers fields and
313
+ // would otherwise default every project to public.
314
+ onGetProjectAccess: onGetProjectAccess,
315
+ canAccessProject: function (userId, access) {
316
+ return users.canAccessProject(userId, access) === true;
315
317
  },
316
318
  resolveMate: function (userId, mateId) {
317
319
  return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
318
320
  },
319
321
  assignmentService: assignmentService,
320
322
  });
323
+ // Project access is resolved from the authoritative project record, not from
324
+ // getStatus(), which carries no visibility or allowedUsers fields.
325
+ var projectLogsService = attachProjectLogsService({
326
+ getProjects: function () { return projects; },
327
+ isMultiUser: function () { return users.isMultiUser(); },
328
+ canAccessProject: function (userId, status) {
329
+ if (!status || !status.slug || !onGetProjectAccess) return false;
330
+ var access = onGetProjectAccess(status.slug);
331
+ if (!access || access.error) return false;
332
+ return users.canAccessProject(userId, access) === true;
333
+ },
334
+ resolveMate: function (userId, mateId) {
335
+ return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
336
+ },
337
+ findUserById: function (userId) { return users.findUserById(userId); },
338
+ });
339
+ // Mate Knowledge reads the synchronized Knowledge records. The Mate list is
340
+ // derived from the owning user alone, so a binding can never span users.
341
+ var mateKnowledgeService = attachMateKnowledgeService({
342
+ getProjects: function () { return projects; },
343
+ isMultiUser: function () { return users.isMultiUser(); },
344
+ resolveMate: function (userId, mateId) {
345
+ return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
346
+ },
347
+ listMates: function (userId) {
348
+ var mateCtx = mates.buildMateCtx(users.isMultiUser() ? userId : null);
349
+ var all = mates.getAllMates(mateCtx) || [];
350
+ return all.map(function (mate) {
351
+ return { id: mate.id, name: mate.name || null, createdBy: mate.createdBy || null, dir: mates.getMateDir(mateCtx, mate.id) };
352
+ });
353
+ },
354
+ });
321
355
 
322
356
  // --- Admin module ---
323
357
  var admin = serverAdmin.attachAdmin({
@@ -1155,6 +1189,24 @@ function createServer(opts) {
1155
1189
  }
1156
1190
 
1157
1191
  // --- Project management ---
1192
+ function refreshVendorAuthEverywhere(vendor, linuxUser) {
1193
+ var refreshes = [];
1194
+ projects.forEach(function (projectCtx) {
1195
+ if (!projectCtx || typeof projectCtx.refreshVendorAuth !== "function") return;
1196
+ try {
1197
+ refreshes.push(Promise.resolve(projectCtx.refreshVendorAuth(vendor, linuxUser)).catch(function (err) {
1198
+ console.error("[server] Cross-project " + vendor + " auth refresh failed:", err && err.message ? err.message : err);
1199
+ return false;
1200
+ }));
1201
+ } catch (err) {
1202
+ console.error("[server] Cross-project " + vendor + " auth refresh threw:", err && err.message ? err.message : err);
1203
+ }
1204
+ });
1205
+ return Promise.all(refreshes).then(function (results) {
1206
+ return results.some(function (refreshed) { return !!refreshed; });
1207
+ });
1208
+ }
1209
+
1158
1210
  function addProject(cwd, slug, title, icon, projectOwnerId, worktreeMeta, extraOpts) {
1159
1211
  if (projects.has(slug)) return false;
1160
1212
  var extra = extraOpts || {};
@@ -1174,6 +1226,8 @@ function createServer(opts) {
1174
1226
  return mateHandler.createReadyMateFromInterview(ws, userId, definition);
1175
1227
  },
1176
1228
  workspaceQueryService: workspaceQueryService,
1229
+ projectLogsService: projectLogsService,
1230
+ mateKnowledgeService: mateKnowledgeService,
1177
1231
  getProjectAccess: function () {
1178
1232
  return onGetProjectAccess ? onGetProjectAccess(slug) : { visibility: "public", ownerId: projectOwnerId || null };
1179
1233
  },
@@ -1327,6 +1381,7 @@ function createServer(opts) {
1327
1381
  onDmMessage: handleDmMessage,
1328
1382
  broadcastAll: broadcastAll,
1329
1383
  notificationsModule: _globalNotifications,
1384
+ refreshVendorAuthEverywhere: refreshVendorAuthEverywhere,
1330
1385
  getProject: function (s) { return projects.get(s) || null; },
1331
1386
  isUserOnline: isUserOnline,
1332
1387
  });
@@ -0,0 +1,183 @@
1
+ // Driver eligibility: which models may hold the Driver role of a visible
2
+ // Driver/Split Worker pair and receive the autonomous lifecycle tools.
3
+ //
4
+ // There is no canonical tier helper in the repo to reuse. Claude models are
5
+ // enumerated by the SDK at runtime, so there is no static list; Codex ships a
6
+ // fixed catalog in yoke/adapters/codex.js. What both have in common is a tier
7
+ // token in the model id and in its display name, which the existing
8
+ // project-worker-proposal.js already keys on ("fable"). This module makes that
9
+ // same idea explicit, ordered, and vendor-scoped.
10
+ //
11
+ // Ranks are per vendor family and are only ever compared within a family; no
12
+ // cross-vendor ranking is implied or invented.
13
+ //
14
+ // claude family haiku 1 < sonnet 2 < opus 3 < fable 4
15
+ // real ids: claude-haiku-4-5-20251001, claude-sonnet-5,
16
+ // claude-opus-5, claude-fable-5
17
+ // Driver threshold: fable
18
+ //
19
+ // codex family gpt-5.2 1 < gpt-5.5 2 < luna 3 < sol 4 < terra 5
20
+ // real ids: the CODEX_MODELS catalog, strongest first
21
+ // Driver threshold: sol
22
+ //
23
+ // Matching is by token, not by exact id, so a later release in the same family
24
+ // (claude-fable-6, gpt-5.7-terra) stays eligible without a code change. A
25
+ // model in a known family whose tier token is unrecognized is NOT eligible:
26
+ // unknown capability fails closed rather than inheriting the threshold.
27
+ //
28
+ // Any other vendor is not eligible for the Driver role at all, because no
29
+ // equivalent tier metadata exists to compare against. Those vendors remain
30
+ // perfectly usable as Workers.
31
+
32
+ var models = require("./project-models");
33
+
34
+ var FAMILIES = {
35
+ claude: {
36
+ label: "Claude",
37
+ threshold: 4,
38
+ thresholdName: "Fable",
39
+ // Longest/most specific tokens first so "haiku" cannot shadow a future
40
+ // compound name. Each entry is a token searched in id and display name.
41
+ tiers: [
42
+ { token: "fable", rank: 4, name: "Fable" },
43
+ { token: "opus", rank: 3, name: "Opus" },
44
+ { token: "sonnet", rank: 2, name: "Sonnet" },
45
+ { token: "haiku", rank: 1, name: "Haiku" },
46
+ ],
47
+ },
48
+ codex: {
49
+ label: "OpenAI",
50
+ threshold: 4,
51
+ thresholdName: "Sol",
52
+ tiers: [
53
+ { token: "terra", rank: 5, name: "Terra" },
54
+ { token: "sol", rank: 4, name: "Sol" },
55
+ { token: "luna", rank: 3, name: "Luna" },
56
+ { token: "gpt-5.5", rank: 2, name: "GPT-5.5" },
57
+ { token: "gpt-5.2", rank: 1, name: "GPT-5.2" },
58
+ ],
59
+ },
60
+ };
61
+
62
+ function familyFor(vendor) {
63
+ var key = String(vendor || "").toLowerCase();
64
+ return Object.prototype.hasOwnProperty.call(FAMILIES, key) ? FAMILIES[key] : null;
65
+ }
66
+
67
+ // Every string the catalog knows this model by. The catalog entry is found with
68
+ // the repo's own matcher so an alias ("fable"), an id, or a resolvedModel all
69
+ // resolve to the same entry, and the display name participates in tier
70
+ // detection exactly as project-worker-proposal.js already relies on.
71
+ function searchTextFor(vendor, model, modelsByVendor) {
72
+ var parts = [String(model || "")];
73
+ var catalog = (modelsByVendor && modelsByVendor[vendor]) || [];
74
+ for (var i = 0; i < catalog.length; i++) {
75
+ if (!models.modelEntryMatches(catalog[i], model)) continue;
76
+ var entry = catalog[i];
77
+ if (typeof entry === "string") {
78
+ parts.push(entry);
79
+ } else {
80
+ parts.push(entry.value || "");
81
+ parts.push(entry.id || "");
82
+ parts.push(entry.resolvedModel || "");
83
+ parts.push(entry.displayName || "");
84
+ parts.push(entry.name || "");
85
+ }
86
+ break;
87
+ }
88
+ return parts.join(" ").toLowerCase();
89
+ }
90
+
91
+ // Resolve the tier of one model inside its vendor family. Returns null when
92
+ // the vendor has no tier metadata or the tier token is unrecognized.
93
+ function resolveTier(vendor, model, modelsByVendor) {
94
+ var family = familyFor(vendor);
95
+ if (!family || !model) return null;
96
+ var text = searchTextFor(vendor, model, modelsByVendor);
97
+ for (var i = 0; i < family.tiers.length; i++) {
98
+ if (text.indexOf(family.tiers[i].token) !== -1) {
99
+ return {
100
+ vendor: String(vendor).toLowerCase(),
101
+ family: family.label,
102
+ rank: family.tiers[i].rank,
103
+ name: family.tiers[i].name,
104
+ threshold: family.threshold,
105
+ thresholdName: family.thresholdName,
106
+ };
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // The hard invariant. Returns { ok, tier, error } and never throws. `error` is
113
+ // a complete English sentence suitable for returning to a tool caller.
114
+ function evaluateDriverModel(vendor, model, modelsByVendor) {
115
+ var family = familyFor(vendor);
116
+ if (!family) {
117
+ return {
118
+ ok: false,
119
+ tier: null,
120
+ error: "The Driver role requires a Claude model of Fable tier or higher, or an OpenAI model of " +
121
+ "Sol tier or higher. Vendor \"" + String(vendor || "unknown") + "\" has no comparable tier " +
122
+ "metadata, so it cannot take the Driver role. It can still be used for the Split Worker.",
123
+ };
124
+ }
125
+ if (!model) {
126
+ return {
127
+ ok: false,
128
+ tier: null,
129
+ error: "This session has no resolved model yet, so its " + family.label +
130
+ " tier cannot be confirmed. The Driver role requires " + family.thresholdName +
131
+ " tier or higher.",
132
+ };
133
+ }
134
+ var tier = resolveTier(vendor, model, modelsByVendor);
135
+ if (!tier) {
136
+ return {
137
+ ok: false,
138
+ tier: null,
139
+ error: "Model \"" + model + "\" is not a recognized " + family.label +
140
+ " tier, so it cannot take the Driver role. The Driver role requires " +
141
+ family.thresholdName + " tier or higher.",
142
+ };
143
+ }
144
+ if (tier.rank < tier.threshold) {
145
+ return {
146
+ ok: false,
147
+ tier: tier,
148
+ error: "The Driver role requires a " + family.label + " model of " + family.thresholdName +
149
+ " tier or higher. This session is " + tier.name + " tier.",
150
+ };
151
+ }
152
+ return { ok: true, tier: tier, error: null };
153
+ }
154
+
155
+ // The session-level form. Resolves the session's effective model the same way
156
+ // the rest of the server does: the session's own model, else the vendor
157
+ // default. Identity is read from the session object, never from a caller.
158
+ function evaluateDriverSession(session, sm) {
159
+ if (!session) return { ok: false, tier: null, error: "No session was bound to this request." };
160
+ if (session.mode === "tui") {
161
+ return {
162
+ ok: false,
163
+ tier: null,
164
+ error: "An embedded terminal session cannot take the Driver role.",
165
+ };
166
+ }
167
+ var vendor = session.vendor || "claude";
168
+ var fallback = (sm && sm.defaultModelByVendor && sm.defaultModelByVendor[vendor]) || "";
169
+ var model = session.model || fallback;
170
+ return evaluateDriverModel(vendor, model, sm && sm.modelsByVendor);
171
+ }
172
+
173
+ function isEligibleDriverSession(session, sm) {
174
+ return evaluateDriverSession(session, sm).ok;
175
+ }
176
+
177
+ module.exports = {
178
+ FAMILIES: FAMILIES,
179
+ evaluateDriverModel: evaluateDriverModel,
180
+ evaluateDriverSession: evaluateDriverSession,
181
+ isEligibleDriverSession: isEligibleDriverSession,
182
+ resolveTier: resolveTier,
183
+ };
@@ -9,14 +9,18 @@ var MEMORY_CONTRACT =
9
9
  "Important exception for deferred defects: while doing code or technical work, actively create a sticky note when you discover a concrete defect, regression risk, security issue, or data-loss risk that is outside the current session goal and will remain unfixed when the turn ends. Do not wait for the user to ask. Include the observable evidence, affected component, likely impact, and a clear next action. Check active notes first when a duplicate is plausible. Do not create defect notes for speculation, general cleanup ideas, or problems you fixed in the current session. " +
10
10
  "Important exception for deferred proposals: when you propose work (a fix, follow-up, improvement, or next step) and the user defers it rather than declining it (\"let's do that later\", \"next time\", \"after this\"), actively create a sticky note before the topic moves on. Do not wait to be asked. Capture what was proposed, why it mattered, and the agreed timing if any. Deferred agreements scroll out of chat history quickly and are hard to track; the board is where they survive. If the user declines the idea outright, do not write a note. " +
11
11
  "Never create a note merely because work is important, lengthy, spans agents or restarts, or might help another agent. Do not record completed work, implementation details, test results, investigation logs, transient blockers, conversation summaries, or announcements of your own activity. When uncertain, do not write. " +
12
- "Updates are visible too: update only when durable state materially changes, and remove a note created by your session when it stops being useful instead of turning it into a completion log. Put a concise plain-text title on the first line, stay focused on one topic, and include only the context needed for future action.";
12
+ "Updates are visible too: update only when durable state materially changes. " +
13
+ "A note is an attention item, so finishing it means closing it, never erasing it: call close_note once the thing it is asking for is done, and the note leaves the active board while the record stays and can be reopened. Do not turn a note into a completion log instead of closing it. " +
14
+ "Put a concise plain-text title on the first line, stay focused on one topic, and include only the context needed for future action.";
13
15
 
14
16
  function getToolDefs(handlers) {
15
17
  return [
16
18
  {
17
19
  name: "list_notes",
18
- description: MEMORY_CONTRACT + " List the active notes before writing when you need to avoid duplicates or inspect full memory beyond the injected summary.",
19
- inputSchema: buildShape({}),
20
+ description: MEMORY_CONTRACT + " List the notes before writing when you need to avoid duplicates or inspect full memory beyond the injected summary. Lists open notes by default; pass state to see closed ones.",
21
+ inputSchema: buildShape({
22
+ state: { type: "string", enum: ["open", "closed", "all"], description: "Which notes to list. Defaults to open, the notes still asking for action." },
23
+ }),
20
24
  handler: function (args) { return handlers.list(args || {}); },
21
25
  },
22
26
  {
@@ -29,11 +33,27 @@ function getToolDefs(handlers) {
29
33
  }, ["text"]),
30
34
  handler: function (args) { return handlers.write(args || {}); },
31
35
  },
36
+ {
37
+ name: "close_note",
38
+ description: MEMORY_CONTRACT + " Close a note created by this same session once what it asked for is done. Closing is not deletion: the note leaves the active board, stays queryable under state closed, and can be reopened. Notes created by users or other sessions cannot be closed by this session.",
39
+ inputSchema: buildShape({
40
+ id: { type: "string", description: "Id of the note to close." },
41
+ }, ["id"]),
42
+ handler: function (args) { return handlers.close(args || {}); },
43
+ },
44
+ {
45
+ name: "reopen_note",
46
+ description: MEMORY_CONTRACT + " Reopen a closed note created by this same session when it turns out to still need action. The note returns to the active board unchanged.",
47
+ inputSchema: buildShape({
48
+ id: { type: "string", description: "Id of the note to reopen." },
49
+ }, ["id"]),
50
+ handler: function (args) { return handlers.reopen(args || {}); },
51
+ },
32
52
  {
33
53
  name: "remove_note",
34
- description: MEMORY_CONTRACT + " Remove a note created by this same session when it is no longer true. Notes created by users or other sessions cannot be removed.",
54
+ description: MEMORY_CONTRACT + " Deprecated: use close_note. This tool no longer deletes anything and now closes the note instead, so an older caller cannot destroy a record. Same ownership rule as close_note.",
35
55
  inputSchema: buildShape({
36
- id: { type: "string", description: "Id of the note to remove." },
56
+ id: { type: "string", description: "Id of the note to close. Nothing is deleted." },
37
57
  }, ["id"]),
38
58
  handler: function (args) { return handlers.remove(args || {}); },
39
59
  },