clay-server 4.0.0-beta.10 → 4.0.0-beta.12

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 (55) hide show
  1. package/lib/config.js +25 -1
  2. package/lib/git-cli.js +94 -1
  3. package/lib/knowledge-search.js +17 -1
  4. package/lib/mate-knowledge-service.js +2 -2
  5. package/lib/notes-lifecycle.js +149 -0
  6. package/lib/notes.js +60 -3
  7. package/lib/project-http.js +15 -1
  8. package/lib/project-logs-mcp-server.js +24 -0
  9. package/lib/project-logs.js +1 -0
  10. package/lib/project-pair-lifecycle.js +461 -0
  11. package/lib/project-session-notes.js +64 -13
  12. package/lib/project-session-pair.js +124 -111
  13. package/lib/project-sessions.js +46 -6
  14. package/lib/project-user-message.js +41 -4
  15. package/lib/project-worker-permission.js +429 -0
  16. package/lib/project-worker-proposal.js +16 -1
  17. package/lib/project.js +57 -1
  18. package/lib/public/app.js +28 -17
  19. package/lib/public/css/git-placard.css +154 -0
  20. package/lib/public/css/notifications-center.css +67 -0
  21. package/lib/public/css/pane.css +52 -0
  22. package/lib/public/css/sticky-notes.css +231 -226
  23. package/lib/public/index.html +9 -0
  24. package/lib/public/modules/app-messages.js +8 -1
  25. package/lib/public/modules/app-notifications.js +11 -7
  26. package/lib/public/modules/app-projects.js +2 -2
  27. package/lib/public/modules/git-agent-sessions.js +85 -0
  28. package/lib/public/modules/git-panel.js +111 -84
  29. package/lib/public/modules/git-placard.js +161 -0
  30. package/lib/public/modules/project-logs.js +3 -2
  31. package/lib/public/modules/sidebar.js +17 -5
  32. package/lib/public/modules/sticky-notes-browser.js +324 -0
  33. package/lib/public/modules/sticky-notes-card.js +358 -0
  34. package/lib/public/modules/sticky-notes-editor.js +288 -0
  35. package/lib/public/modules/sticky-notes-shared.js +90 -0
  36. package/lib/public/modules/sticky-notes.js +69 -899
  37. package/lib/public/modules/tool-palette-order.js +143 -0
  38. package/lib/public/modules/tool-palette-overlays.js +159 -0
  39. package/lib/public/modules/tool-palette.js +11 -250
  40. package/lib/public/modules/tools.js +21 -1
  41. package/lib/public/modules/update-snooze.js +169 -0
  42. package/lib/public/modules/worker-pane-lock.js +174 -0
  43. package/lib/public/style.css +1 -0
  44. package/lib/sdk-bridge.js +77 -10
  45. package/lib/server.js +6 -6
  46. package/lib/session-driver-eligibility.js +183 -0
  47. package/lib/session-notes-mcp-server.js +25 -5
  48. package/lib/session-pair-factory.js +244 -0
  49. package/lib/session-pair-mcp-server.js +45 -5
  50. package/lib/session-pair-prompts.js +60 -0
  51. package/lib/update-snooze.js +437 -0
  52. package/lib/workspace-query-access.js +102 -0
  53. package/lib/workspace-query-service.js +24 -17
  54. package/lib/ws-schema.js +7 -3
  55. package/package.json +1 -1
@@ -0,0 +1,461 @@
1
+ // Autonomous Split Worker lifecycle: bounded status, atomic replacement, and
2
+ // the per-generation evaluation ledger.
3
+ //
4
+ // A qualified Driver manages its visible Worker without asking the user: it
5
+ // decides reuse against replacement from exact server-derived accounting, and
6
+ // it replaces in one operation rather than a close/create dance. Nothing here
7
+ // posts a proposal or waits for an approval card.
8
+ //
9
+ // Everything is bound to the exact live pair. The Driver is re-resolved from
10
+ // the split store on every call, its eligibility is re-checked every time, and
11
+ // ownership must match on both sessions. No active-tab or global-session
12
+ // fallback exists in this module.
13
+ //
14
+ // What replacement deliberately does NOT do: delete history. Dissolving a pair
15
+ // leaves both sessions in the project exactly as close_partner already does, so
16
+ // the previous Worker's conversation stays browsable and recoverable under the
17
+ // existing session semantics. There is no archive concept in the repo to hook
18
+ // into, and inventing a destructive one would lose work.
19
+
20
+ var eligibility = require("./session-driver-eligibility");
21
+
22
+ var MAX_GENERATIONS = 5;
23
+ var MAX_NOTE_CHARS = 400;
24
+ var MAX_TASK_PREVIEW_CHARS = 200;
25
+
26
+ var EVALUATION_OUTCOMES = ["succeeded", "partial", "failed", "abandoned"];
27
+
28
+ function toolResult(value) {
29
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
30
+ }
31
+
32
+ function toolError(message) {
33
+ return Promise.resolve({
34
+ content: [{ type: "text", text: "Error: " + message }],
35
+ isError: true,
36
+ });
37
+ }
38
+
39
+ function clampText(value, max) {
40
+ var text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
41
+ if (text.length > max) text = text.slice(0, max - 1) + "…";
42
+ return text;
43
+ }
44
+
45
+ function firstNumber() {
46
+ for (var i = 0; i < arguments.length; i++) {
47
+ var value = arguments[i];
48
+ if (typeof value === "number" && isFinite(value) && value >= 0) return value;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // The Worker's context position, from authoritative session accounting only:
54
+ // the SDK's own getContextUsage snapshot when the adapter provides one, else
55
+ // the last result message's usage totals. Field names differ across adapters,
56
+ // so each candidate is probed and `source` states which reading was used.
57
+ // Never estimated from history text.
58
+ function contextStatus(session) {
59
+ var snapshot = session.lastContextUsage || null;
60
+ var used = null;
61
+ var window = null;
62
+ var source = "unavailable";
63
+
64
+ if (snapshot && typeof snapshot === "object") {
65
+ used = firstNumber(snapshot.totalTokens, snapshot.usedTokens, snapshot.tokens, snapshot.inputTokens);
66
+ window = firstNumber(snapshot.contextWindow, snapshot.maxTokens, snapshot.windowSize, snapshot.limit);
67
+ if (used !== null || window !== null) source = "sdk_context_usage";
68
+ }
69
+
70
+ if (used === null) {
71
+ var history = session.history || [];
72
+ for (var i = history.length - 1; i >= 0; i--) {
73
+ if (!history[i] || history[i].type !== "result") continue;
74
+ var usage = history[i].usage || null;
75
+ if (!usage) break;
76
+ var input = firstNumber(usage.input_tokens, usage.inputTokens) || 0;
77
+ var cacheRead = firstNumber(usage.cache_read_input_tokens, usage.cacheReadInputTokens) || 0;
78
+ var cacheWrite = firstNumber(usage.cache_creation_input_tokens, usage.cacheCreationInputTokens) || 0;
79
+ var output = firstNumber(usage.output_tokens, usage.outputTokens) || 0;
80
+ used = input + cacheRead + cacheWrite + output;
81
+ source = "last_result_usage";
82
+ break;
83
+ }
84
+ }
85
+
86
+ var ratio = null;
87
+ if (used !== null && window !== null && window > 0) {
88
+ ratio = Math.round((used / window) * 1000) / 1000;
89
+ if (ratio > 1) ratio = 1;
90
+ }
91
+ return {
92
+ source: source,
93
+ usedTokens: used,
94
+ contextWindow: window,
95
+ usedRatio: ratio,
96
+ };
97
+ }
98
+
99
+ function continuityStatus(session) {
100
+ var history = session.history || [];
101
+ var userTurns = 0;
102
+ var errors = 0;
103
+ for (var i = 0; i < history.length; i++) {
104
+ if (!history[i]) continue;
105
+ if (history[i].type === "user_message") userTurns++;
106
+ else if (history[i].type === "error") errors++;
107
+ }
108
+ var lastActivity = typeof session.lastActivity === "number" ? session.lastActivity : null;
109
+ return {
110
+ historyEntries: history.length,
111
+ userTurns: userTurns,
112
+ errorEntries: errors,
113
+ idleSeconds: lastActivity ? Math.max(0, Math.round((Date.now() - lastActivity) / 1000)) : null,
114
+ };
115
+ }
116
+
117
+ // The current task, as a bounded preview of the delegated instruction only.
118
+ // Never the transcript: a Driver deciding reuse needs to know what the Worker
119
+ // is on, not to re-read its conversation.
120
+ function activityStatus(session) {
121
+ var token = session._pairDelegation || null;
122
+ return {
123
+ isProcessing: !!(session.isProcessing || session._queryStarting),
124
+ delegated: !!token,
125
+ currentTask: token ? clampText(token.message, MAX_TASK_PREVIEW_CHARS) : "",
126
+ lastTurnInterrupted: !!session._lastTurnInterrupted,
127
+ };
128
+ }
129
+
130
+ function attachPairLifecycle(ctx) {
131
+ var sm = ctx.sm;
132
+ var store = ctx.splitStore;
133
+
134
+ // Per-Driver ledger of the Worker generations it has run, newest last.
135
+ // Lives on the live Driver session object: it informs the Driver's next
136
+ // choice within this session and is deliberately not persisted, because a
137
+ // bounded observation of a session that no longer exists would only be
138
+ // misleading after a restart.
139
+ function ledgerFor(driver) {
140
+ if (!Array.isArray(driver._workerGenerations)) driver._workerGenerations = [];
141
+ return driver._workerGenerations;
142
+ }
143
+
144
+ function recordGenerationStart(driver, worker) {
145
+ var ledger = ledgerFor(driver);
146
+ var generation = ledger.length + 1;
147
+ ledger.push({
148
+ generation: generation,
149
+ workerSessionId: worker.localId,
150
+ vendor: worker.vendor || null,
151
+ model: worker.model || null,
152
+ effort: worker.effort || null,
153
+ startedAt: Date.now(),
154
+ endedAt: null,
155
+ observed: null,
156
+ evaluation: null,
157
+ });
158
+ while (ledger.length > MAX_GENERATIONS) ledger.shift();
159
+ worker._pairGeneration = generation;
160
+ return generation;
161
+ }
162
+
163
+ function findGeneration(driver, workerSessionId) {
164
+ var ledger = ledgerFor(driver);
165
+ for (var i = ledger.length - 1; i >= 0; i--) {
166
+ if (ledger[i].workerSessionId === workerSessionId) return ledger[i];
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // Objective signals the server already has. Recorded when a generation ends
172
+ // so the Driver's next decision can use them without a transcript.
173
+ function closeGeneration(driver, worker) {
174
+ var record = findGeneration(driver, worker.localId);
175
+ if (!record || record.endedAt) return record;
176
+ var continuity = continuityStatus(worker);
177
+ var context = contextStatus(worker);
178
+ record.endedAt = Date.now();
179
+ record.observed = {
180
+ userTurns: continuity.userTurns,
181
+ errorEntries: continuity.errorEntries,
182
+ interrupted: !!worker._lastTurnInterrupted,
183
+ usedTokens: context.usedTokens,
184
+ usedRatio: context.usedRatio,
185
+ };
186
+ return record;
187
+ }
188
+
189
+ // The exact pair, with the Driver's eligibility re-checked on every call.
190
+ function resolveDriverPair(caller) {
191
+ if (!caller) throw new Error("pair lifecycle tools require a session-bound tool server");
192
+ // Exact live object identity, before anything is read off the caller. A
193
+ // tool handler captured by a query outlives the session it was bound to, so
194
+ // a stale object — or a different object that happens to carry the same
195
+ // localId — must not be able to act as the Driver.
196
+ if (sm.sessions.get(caller.localId) !== caller) {
197
+ throw new Error("this session is no longer live; the pair tools are bound to an exact session");
198
+ }
199
+ var verdict = eligibility.evaluateDriverSession(caller, sm);
200
+ if (!verdict.ok) throw new Error(verdict.error);
201
+ var group = store.groupForMember(caller.localId);
202
+ if (!group) throw new Error("this session is not in a split group");
203
+ if (!group.pair) throw new Error("this split group has no Driver/Split Worker roles");
204
+ if (group.pair.driverId !== caller.localId) throw new Error("only the configured Driver can direct this pair");
205
+ var worker = sm.sessions.get(group.pair.workerId);
206
+ if (!worker) throw new Error("split partner session was not found");
207
+ if ((caller.ownerId || null) !== (worker.ownerId || null)) throw new Error("split partner access denied");
208
+ return { group: group, worker: worker, tier: verdict.tier };
209
+ }
210
+
211
+ function replaceBlockedReason(worker) {
212
+ if (worker.isProcessing || worker._queryStarting) return "the Split Worker is mid-turn";
213
+ if (worker._pairDelegation) return "a delegated task is still open";
214
+ return null;
215
+ }
216
+
217
+ // Bounded status for the reuse-vs-replace decision. Exact pair only; no
218
+ // other user's data and no transcript.
219
+ function partnerStatus(caller) {
220
+ var resolved = resolveDriverPair(caller);
221
+ var worker = resolved.worker;
222
+ var blocked = replaceBlockedReason(worker);
223
+ var ledger = ledgerFor(caller).map(function (record) {
224
+ return {
225
+ generation: record.generation,
226
+ vendor: record.vendor,
227
+ model: record.model,
228
+ effort: record.effort,
229
+ observed: record.observed,
230
+ evaluation: record.evaluation,
231
+ };
232
+ });
233
+ return {
234
+ worker: {
235
+ sessionId: worker.localId,
236
+ title: worker.title || "New Session",
237
+ vendor: worker.vendor || null,
238
+ model: worker.model || null,
239
+ effort: worker.effort || null,
240
+ generation: worker._pairGeneration || null,
241
+ },
242
+ activity: activityStatus(worker),
243
+ context: contextStatus(worker),
244
+ continuity: continuityStatus(worker),
245
+ replaceSafe: !blocked,
246
+ replaceBlockedReason: blocked,
247
+ driverTier: resolved.tier ? resolved.tier.name : null,
248
+ generations: ledger,
249
+ };
250
+ }
251
+
252
+ // Atomic replacement. Rejects an active Worker unless interrupt is
253
+ // explicitly true, cancels anything the old Worker was waiting on, dissolves
254
+ // the exact pair without deleting history, and creates a fresh Worker.
255
+ // Idempotent in the sense that it either completes or leaves the existing
256
+ // pair untouched; it never half-dissolves.
257
+ function replacePartner(args, caller) {
258
+ var resolved = resolveDriverPair(caller);
259
+ var group = resolved.group;
260
+ var oldWorker = resolved.worker;
261
+
262
+ // Validate the replacement while the old pair is still fully intact. An
263
+ // uninstalled vendor, an unavailable model or an unsupported effort must
264
+ // never cost the user their running Worker, so this precedes the interrupt,
265
+ // the permission cancellation and the dissolve.
266
+ ctx.preflightWorkerForDriver(caller, {
267
+ workerVendor: args.workerVendor,
268
+ workerModel: args.workerModel,
269
+ workerEffort: args.workerEffort,
270
+ });
271
+
272
+ var blocked = replaceBlockedReason(oldWorker);
273
+ if (blocked && args.interrupt !== true) {
274
+ throw new Error("cannot replace the Split Worker because " + blocked +
275
+ "; call again with interrupt set to true to stop it first");
276
+ }
277
+
278
+ if (blocked) {
279
+ oldWorker.taskStopRequested = true;
280
+ if (oldWorker.abortController) {
281
+ try { oldWorker.abortController.abort(); } catch (e) {}
282
+ }
283
+ if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
284
+ ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
285
+ }
286
+ }
287
+
288
+ // Any permission decision the old Worker was waiting on dies with the pair.
289
+ if (typeof ctx.cancelWorkerPermissions === "function") {
290
+ ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
291
+ }
292
+
293
+ // The outgoing generation is closed only after the replacement exists, so a
294
+ // rollback leaves its ledger entry open and its evaluation untouched. The
295
+ // evaluation shape is still checked here, before anything is destroyed, so
296
+ // a malformed assessment cannot dissolve the pair and then be rejected.
297
+ if (args.evaluation) validateEvaluation(args.evaluation);
298
+
299
+ var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
300
+ var dissolved = store.dissolve(ws, { id: group.id });
301
+ if (!dissolved.ok) throw new Error(dissolved.error || "could not dissolve the existing pair");
302
+
303
+ // History is preserved: the old Worker session stays in the project.
304
+ //
305
+ // Preflight already cleared every input, but the group write itself can
306
+ // still fail, so the dissolve is rolled back rather than leaving the Driver
307
+ // with no pair at all. The restored group is an equivalent record with the
308
+ // same members and roles; its group id is newly issued.
309
+ //
310
+ // What rollback can and cannot undo:
311
+ // - Idle replacement failure is fully recoverable. The Worker session,
312
+ // its history and its still-open ledger generation are all preserved,
313
+ // and the pair is restored.
314
+ // - An explicit interrupt=true is NOT reversible. Stopping a mid-turn
315
+ // Worker aborts its query and cancels the permission decisions it was
316
+ // waiting on; a later creation failure cannot resume that turn. The
317
+ // session and its history survive, but the interrupted work does not
318
+ // come back. The error says so rather than implying a clean restore.
319
+ var created;
320
+ try {
321
+ created = ctx.createWorkerForDriver(caller, {
322
+ workerVendor: typeof args.workerVendor === "string" ? args.workerVendor : "",
323
+ workerModel: typeof args.workerModel === "string" ? args.workerModel : "",
324
+ workerEffort: typeof args.workerEffort === "string" ? args.workerEffort : "",
325
+ });
326
+ } catch (e) {
327
+ var restored = store.create(ws, {
328
+ members: [caller.localId, oldWorker.localId],
329
+ pair: { driverId: caller.localId, workerId: oldWorker.localId },
330
+ });
331
+ var restoreNote = restored && restored.ok
332
+ ? "The previous pair was restored with its session, history and open generation intact."
333
+ : "The previous pair could not be restored; both sessions are intact and unpaired.";
334
+ var interruptNote = blocked
335
+ ? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
336
+ : "";
337
+ throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
338
+ ". " + restoreNote + interruptNote);
339
+ }
340
+ var closed = closeGeneration(caller, oldWorker);
341
+ if (closed && args.evaluation) applyEvaluation(closed, args.evaluation);
342
+ var generation = recordGenerationStart(caller, created.worker);
343
+
344
+ var result = {
345
+ status: "replaced",
346
+ previousWorkerSessionId: oldWorker.localId,
347
+ previousWorkerHistoryPreserved: true,
348
+ previousGeneration: closed ? closed.generation : null,
349
+ interrupted: !!blocked,
350
+ workerSessionId: created.worker.localId,
351
+ generation: generation,
352
+ vendor: created.worker.vendor || null,
353
+ model: created.worker.model || null,
354
+ effort: created.worker.effort || null,
355
+ };
356
+
357
+ var message = typeof args.message === "string" ? args.message.trim() : "";
358
+ if (!message) return Promise.resolve(result);
359
+ return Promise.resolve(ctx.sendToPartner({ message: message, wait: args.wait, timeoutSeconds: args.timeoutSeconds }, caller))
360
+ .then(function (delivered) {
361
+ result.delivery = delivered;
362
+ return result;
363
+ });
364
+ }
365
+
366
+ // Shape check only; writes nothing. Lets a caller that is about to destroy
367
+ // something reject a malformed assessment first.
368
+ function validateEvaluation(raw) {
369
+ var input = raw && typeof raw === "object" ? raw : {};
370
+ var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
371
+ if (EVALUATION_OUTCOMES.indexOf(outcome) === -1) {
372
+ throw new Error('evaluation outcome must be one of: ' + EVALUATION_OUTCOMES.join(", "));
373
+ }
374
+ return { outcome: outcome, note: clampText(input.note, MAX_NOTE_CHARS) };
375
+ }
376
+
377
+ function applyEvaluation(record, raw) {
378
+ var clean = validateEvaluation(raw);
379
+ record.evaluation = {
380
+ outcome: clean.outcome,
381
+ note: clean.note,
382
+ recordedAt: Date.now(),
383
+ };
384
+ return record.evaluation;
385
+ }
386
+
387
+ // Attach a bounded assessment to one exact Worker generation. The Driver
388
+ // supplies the judgement; the server supplies the objective observations and
389
+ // refuses anything outside the enum. No global or cross-user ranking is
390
+ // formed from this.
391
+ function recordEvaluation(args, caller) {
392
+ var resolved = resolveDriverPair(caller);
393
+ var target = Number.isInteger(args.generation)
394
+ ? (function () {
395
+ var ledger = ledgerFor(caller);
396
+ for (var i = 0; i < ledger.length; i++) {
397
+ if (ledger[i].generation === args.generation) return ledger[i];
398
+ }
399
+ return null;
400
+ })()
401
+ : findGeneration(caller, resolved.worker.localId);
402
+ if (!target) throw new Error("no such Split Worker generation for this Driver");
403
+ var evaluation = applyEvaluation(target, args);
404
+ return {
405
+ status: "recorded",
406
+ generation: target.generation,
407
+ vendor: target.vendor,
408
+ model: target.model,
409
+ evaluation: evaluation,
410
+ observed: target.observed,
411
+ };
412
+ }
413
+
414
+ // Tool handlers for the three lifecycle tools, with this module's own error
415
+ // shaping. Kept here so the pair coordinator only wires names to handlers.
416
+ // partnerStatus, or null when this session cannot legitimately ask for it.
417
+ // Lets read_partner fold the capacity report in without duplicating the
418
+ // guard chain or swallowing errors inline at the call site.
419
+ function optionalStatus(boundSession) {
420
+ try { return partnerStatus(boundSession); } catch (e) { return null; }
421
+ }
422
+
423
+ function toolHandlers(boundSession) {
424
+ return {
425
+ status: function () {
426
+ try { return toolResult(partnerStatus(boundSession)); }
427
+ catch (e) { return toolError(e.message || String(e)); }
428
+ },
429
+ replace: function (args) {
430
+ try {
431
+ return Promise.resolve(replacePartner(args || {}, boundSession))
432
+ .then(toolResult, function (e) { return toolError(e.message || String(e)); });
433
+ } catch (e) { return toolError(e.message || String(e)); }
434
+ },
435
+ evaluate: function (args) {
436
+ try { return toolResult(recordEvaluation(args || {}, boundSession)); }
437
+ catch (e) { return toolError(e.message || String(e)); }
438
+ },
439
+ };
440
+ }
441
+
442
+ return {
443
+ EVALUATION_OUTCOMES: EVALUATION_OUTCOMES,
444
+ optionalStatus: optionalStatus,
445
+ toolHandlers: toolHandlers,
446
+ closeGeneration: closeGeneration,
447
+ contextStatus: contextStatus,
448
+ ledgerFor: ledgerFor,
449
+ partnerStatus: partnerStatus,
450
+ recordEvaluation: recordEvaluation,
451
+ recordGenerationStart: recordGenerationStart,
452
+ replacePartner: replacePartner,
453
+ resolveDriverPair: resolveDriverPair,
454
+ };
455
+ }
456
+
457
+ module.exports = {
458
+ EVALUATION_OUTCOMES: EVALUATION_OUTCOMES,
459
+ attachPairLifecycle: attachPairLifecycle,
460
+ contextStatus: contextStatus,
461
+ };
@@ -1,4 +1,5 @@
1
1
  var sessionNotesMcp = require("./session-notes-mcp-server");
2
+ var lifecycle = require("./notes-lifecycle");
2
3
 
3
4
  var MAX_NOTE_TEXT_CHARS = 20000;
4
5
  var MAX_ACTIVE_NOTES = 20;
@@ -20,10 +21,16 @@ function toolError(message) {
20
21
  });
21
22
  }
22
23
 
24
+ // The active board is the open notes: the ones still asking for action. Closed
25
+ // notes remain on record and are reachable by asking for them explicitly.
23
26
  function activeNotes(notes) {
24
- return (notes || []).filter(function (note) {
25
- return note && !note.hidden;
26
- });
27
+ return lifecycle.openNotes(notes);
28
+ }
29
+
30
+ function notesForState(notes, state) {
31
+ if (state === "closed") return lifecycle.closedNotes(notes);
32
+ if (state === "all") return (notes || []).filter(Boolean);
33
+ return activeNotes(notes);
27
34
  }
28
35
 
29
36
  function memoryNotes(notes) {
@@ -39,6 +46,8 @@ function publicNote(note) {
39
46
  color: note.color,
40
47
  updatedAt: note.updatedAt,
41
48
  origin: note.origin || null,
49
+ state: lifecycle.stateOf(note),
50
+ closedAt: note.closedAt || null,
42
51
  };
43
52
  }
44
53
 
@@ -122,7 +131,9 @@ function attachSessionNotes(ctx) {
122
131
 
123
132
  function listNotes(args, caller) {
124
133
  if (!caller) return toolError("list_notes requires a session-bound tool server");
125
- return toolResult(activeNotes(nm.list()).map(publicNote));
134
+ var state = args && typeof args.state === "string" ? args.state : "open";
135
+ if (["open", "closed", "all"].indexOf(state) === -1) return toolError("state must be open, closed, or all");
136
+ return toolResult(notesForState(nm.list(), state).map(publicNote));
126
137
  }
127
138
 
128
139
  function writeNote(args, caller) {
@@ -145,7 +156,7 @@ function attachSessionNotes(ctx) {
145
156
  return toolResult(publicNote(updated));
146
157
  }
147
158
  if (activeNotes(notes).length >= MAX_ACTIVE_NOTES) {
148
- return toolError("20 active notes already exist; consolidate or remove stale notes before creating another");
159
+ return toolError("20 open notes already exist; consolidate or close resolved notes before creating another");
149
160
  }
150
161
  var placement = autoPlacement(notes);
151
162
  var created = nm.create({
@@ -161,16 +172,51 @@ function attachSessionNotes(ctx) {
161
172
  return toolResult(publicNote(created));
162
173
  }
163
174
 
164
- function removeNote(args, caller) {
165
- if (!caller) return toolError("remove_note requires a session-bound tool server");
166
- var note = findNote(nm.list() || [], args.id);
167
- if (!note) return toolError("note not found: " + (args.id || "unknown"));
175
+ // A session may only act on notes it created. Ownership is checked against the
176
+ // note's recorded origin and the server-bound caller, never against anything
177
+ // in the arguments.
178
+ function ownedNote(args, caller, verb) {
179
+ var note = findNote(nm.list() || [], args && args.id);
180
+ if (!note) return { error: "note not found: " + ((args && args.id) || "unknown") };
168
181
  if (!note.origin || note.origin.sessionId !== caller.localId) {
169
- return toolError("this session can only remove notes it created");
182
+ return { error: "this session can only " + verb + " notes it created" };
170
183
  }
171
- if (!nm.remove(note.id)) return toolError("note could not be removed: " + note.id);
172
- send({ type: "note_deleted", id: note.id });
173
- return toolResult({ removed: true, id: note.id });
184
+ return { note: note };
185
+ }
186
+
187
+ function closeNote(args, caller) {
188
+ if (!caller) return toolError("close_note requires a session-bound tool server");
189
+ var found = ownedNote(args, caller, "close");
190
+ if (found.error) return toolError(found.error);
191
+ // Actor provenance comes from the bound session object, not the payload.
192
+ var closed = nm.close(found.note.id, lifecycle.sessionActor(caller));
193
+ if (!closed) return toolError("note could not be closed: " + found.note.id);
194
+ send({ type: "note_updated", note: closed });
195
+ return toolResult(publicNote(closed));
196
+ }
197
+
198
+ function reopenNote(args, caller) {
199
+ if (!caller) return toolError("reopen_note requires a session-bound tool server");
200
+ var found = ownedNote(args, caller, "reopen");
201
+ if (found.error) return toolError(found.error);
202
+ var reopened = nm.reopen(found.note.id);
203
+ if (!reopened) return toolError("note could not be reopened: " + found.note.id);
204
+ send({ type: "note_updated", note: reopened });
205
+ return toolResult(publicNote(reopened));
206
+ }
207
+
208
+ // Deprecated compatibility path. An older caller asking to remove a note gets
209
+ // a close instead, so no existing agent can destroy a record. The response
210
+ // still reports `removed: true` because that is the shape the old caller
211
+ // parses, and `state` says what actually happened.
212
+ function removeNote(args, caller) {
213
+ if (!caller) return toolError("remove_note requires a session-bound tool server");
214
+ var found = ownedNote(args, caller, "close");
215
+ if (found.error) return toolError(found.error);
216
+ var closed = nm.close(found.note.id, lifecycle.sessionActor(caller));
217
+ if (!closed) return toolError("note could not be closed: " + found.note.id);
218
+ send({ type: "note_updated", note: closed });
219
+ return toolResult({ removed: true, closed: true, deleted: false, id: closed.id, state: lifecycle.stateOf(closed) });
174
220
  }
175
221
 
176
222
  function getToolDefs(boundSession) {
@@ -178,6 +224,8 @@ function attachSessionNotes(ctx) {
178
224
  return sessionNotesMcp.getToolDefs({
179
225
  list: function (args) { return listNotes(args, boundSession || null); },
180
226
  write: function (args) { return writeNote(args, boundSession || null); },
227
+ close: function (args) { return closeNote(args, boundSession || null); },
228
+ reopen: function (args) { return reopenNote(args, boundSession || null); },
181
229
  remove: function (args) { return removeNote(args, boundSession || null); },
182
230
  });
183
231
  }
@@ -211,6 +259,9 @@ module.exports = {
211
259
  NOTES_LABEL: NOTES_LABEL,
212
260
  PROACTIVE_POLICY: PROACTIVE_POLICY,
213
261
  attachSessionNotes: attachSessionNotes,
262
+ activeNotes: activeNotes,
263
+ notesForState: notesForState,
264
+ publicNote: publicNote,
214
265
  autoPlacement: autoPlacement,
215
266
  buildNotesPrompt: buildNotesPrompt,
216
267
  composeSystemPrompts: composeSystemPrompts,