clay-server 4.0.0-beta.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +255 -0
  3. package/lib/capsule-mate-turn.js +105 -0
  4. package/lib/capsule-pig-logic.js +300 -0
  5. package/lib/capsule-server-runtimes.js +39 -0
  6. package/lib/capsules/pig/display.js +198 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +84 -0
  9. package/lib/config.js +25 -1
  10. package/lib/daemon-projects.js +13 -8
  11. package/lib/daemon.js +127 -14
  12. package/lib/git-cli.js +95 -1
  13. package/lib/knowledge-import.js +436 -0
  14. package/lib/knowledge-record-store.js +188 -0
  15. package/lib/knowledge-search.js +240 -0
  16. package/lib/mate-knowledge-mcp-server.js +151 -0
  17. package/lib/mate-knowledge-migration.js +283 -0
  18. package/lib/mate-knowledge-service.js +488 -0
  19. package/lib/mate-knowledge-sync.js +482 -0
  20. package/lib/migrate-single-user.js +2 -0
  21. package/lib/notes-lifecycle.js +149 -0
  22. package/lib/notes.js +60 -3
  23. package/lib/os-users.js +3 -1
  24. package/lib/project-capsule-catalog.js +9 -1
  25. package/lib/project-capsule-turn.js +96 -0
  26. package/lib/project-connection.js +15 -1
  27. package/lib/project-file-history.js +223 -0
  28. package/lib/project-file-watch.js +77 -49
  29. package/lib/project-filesystem.js +31 -220
  30. package/lib/project-http.js +15 -1
  31. package/lib/project-knowledge.js +22 -0
  32. package/lib/project-log-context.js +172 -0
  33. package/lib/project-log-feedback-delivery.js +118 -0
  34. package/lib/project-logs-comments.js +137 -0
  35. package/lib/project-logs-context-state.js +42 -0
  36. package/lib/project-logs-mcp-server.js +328 -0
  37. package/lib/project-logs-query.js +185 -0
  38. package/lib/project-logs-root.js +75 -0
  39. package/lib/project-logs-schema.js +259 -0
  40. package/lib/project-logs-service.js +463 -0
  41. package/lib/project-logs-snapshot.js +205 -0
  42. package/lib/project-logs-store.js +495 -0
  43. package/lib/project-logs-versioning.js +132 -0
  44. package/lib/project-logs.js +400 -0
  45. package/lib/project-mate-interaction.js +14 -0
  46. package/lib/project-mate-knowledge.js +110 -0
  47. package/lib/project-memory.js +22 -0
  48. package/lib/project-message-delivery.js +70 -0
  49. package/lib/project-pair-lifecycle.js +460 -0
  50. package/lib/project-request-access.js +66 -0
  51. package/lib/project-session-handoff.js +1 -1
  52. package/lib/project-session-notes.js +64 -13
  53. package/lib/project-session-pair.js +159 -146
  54. package/lib/project-session-spawn.js +1 -1
  55. package/lib/project-sessions.js +58 -13
  56. package/lib/project-user-message.js +53 -4
  57. package/lib/project-worker-permission.js +430 -0
  58. package/lib/project-worker-proposal.js +208 -62
  59. package/lib/project.js +185 -6
  60. package/lib/public/app.js +63 -35
  61. package/lib/public/css/admin.css +2 -2
  62. package/lib/public/css/capsule-ui.css +38 -0
  63. package/lib/public/css/command-palette.css +19 -9
  64. package/lib/public/css/git-placard.css +154 -0
  65. package/lib/public/css/home-hub.css +4 -0
  66. package/lib/public/css/home-session-actions.css +66 -0
  67. package/lib/public/css/home-sidebar.css +66 -0
  68. package/lib/public/css/input.css +84 -3
  69. package/lib/public/css/messages.css +40 -1
  70. package/lib/public/css/mobile-nav.css +80 -0
  71. package/lib/public/css/notifications-center.css +67 -0
  72. package/lib/public/css/pane.css +97 -10
  73. package/lib/public/css/project-logs.css +198 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/session-actions.css +16 -101
  76. package/lib/public/css/sidebar.css +103 -0
  77. package/lib/public/css/sticky-notes.css +237 -229
  78. package/lib/public/css/worker-proposal.css +36 -3
  79. package/lib/public/index.html +43 -11
  80. package/lib/public/modules/admin.js +8 -8
  81. package/lib/public/modules/app-connection.js +124 -6
  82. package/lib/public/modules/app-home-hub.js +12 -8
  83. package/lib/public/modules/app-message-cards.js +2 -1
  84. package/lib/public/modules/app-messages.js +92 -11
  85. package/lib/public/modules/app-misc.js +2 -14
  86. package/lib/public/modules/app-notifications.js +11 -7
  87. package/lib/public/modules/app-panels.js +3 -2
  88. package/lib/public/modules/app-projects.js +56 -18
  89. package/lib/public/modules/app-rendering.js +1 -0
  90. package/lib/public/modules/capsule-preference.js +44 -0
  91. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  92. package/lib/public/modules/clay-log-links.js +70 -0
  93. package/lib/public/modules/command-palette.js +52 -15
  94. package/lib/public/modules/context-sources.js +0 -3
  95. package/lib/public/modules/git-agent-sessions.js +85 -0
  96. package/lib/public/modules/git-panel.js +111 -84
  97. package/lib/public/modules/git-placard.js +161 -0
  98. package/lib/public/modules/home-chat-empty-state.js +39 -0
  99. package/lib/public/modules/home-chat-identity.js +9 -0
  100. package/lib/public/modules/home-conversations-sheet.js +102 -48
  101. package/lib/public/modules/home-mate-chat.js +15 -35
  102. package/lib/public/modules/home-session-actions.js +6 -0
  103. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  104. package/lib/public/modules/home-sidebar.js +7 -0
  105. package/lib/public/modules/home-surface-boot.js +7 -3
  106. package/lib/public/modules/home-tool-frame.js +182 -0
  107. package/lib/public/modules/home-tools.js +138 -1
  108. package/lib/public/modules/input.js +38 -7
  109. package/lib/public/modules/markdown.js +2 -0
  110. package/lib/public/modules/mate-sidebar.js +0 -8
  111. package/lib/public/modules/message-delivery.js +97 -0
  112. package/lib/public/modules/paste-modal.js +84 -0
  113. package/lib/public/modules/project-activation.js +45 -0
  114. package/lib/public/modules/project-logs-render.js +405 -0
  115. package/lib/public/modules/project-logs.js +461 -0
  116. package/lib/public/modules/project-removal-target.js +26 -0
  117. package/lib/public/modules/scheduler.js +17 -3
  118. package/lib/public/modules/session-actions.js +29 -105
  119. package/lib/public/modules/session-hierarchy.js +54 -0
  120. package/lib/public/modules/sidebar-mobile.js +40 -15
  121. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  122. package/lib/public/modules/sidebar-sessions.js +77 -11
  123. package/lib/public/modules/sidebar.js +17 -5
  124. package/lib/public/modules/split-group-helpers.js +8 -0
  125. package/lib/public/modules/split-pair-ui.js +15 -4
  126. package/lib/public/modules/split-view.js +5 -2
  127. package/lib/public/modules/split-worker-runtime.js +24 -0
  128. package/lib/public/modules/sticky-note-markdown.js +5 -0
  129. package/lib/public/modules/sticky-notes-browser.js +326 -0
  130. package/lib/public/modules/sticky-notes-card.js +360 -0
  131. package/lib/public/modules/sticky-notes-editor.js +291 -0
  132. package/lib/public/modules/sticky-notes-shared.js +90 -0
  133. package/lib/public/modules/sticky-notes.js +170 -946
  134. package/lib/public/modules/thinking-lifecycle.js +175 -0
  135. package/lib/public/modules/thinking-summary.js +28 -0
  136. package/lib/public/modules/thinking-view.js +75 -0
  137. package/lib/public/modules/tool-palette-order.js +143 -0
  138. package/lib/public/modules/tool-palette-overlays.js +159 -0
  139. package/lib/public/modules/tool-palette.js +21 -188
  140. package/lib/public/modules/tools.js +31 -123
  141. package/lib/public/modules/update-snooze.js +169 -0
  142. package/lib/public/modules/user-settings.js +4 -0
  143. package/lib/public/modules/worker-pane-lock.js +176 -0
  144. package/lib/public/modules/worker-proposal-state.js +16 -0
  145. package/lib/public/modules/worker-proposal.js +49 -20
  146. package/lib/public/style.css +3 -1
  147. package/lib/sdk-bridge.js +135 -46
  148. package/lib/sdk-message-processor.js +36 -7
  149. package/lib/server-experimental-settings.js +49 -0
  150. package/lib/server-global-ws.js +11 -0
  151. package/lib/server-home-chat-events.js +31 -1
  152. package/lib/server-home-chat.js +27 -4
  153. package/lib/server-home-clay-entry.js +1 -1
  154. package/lib/server-home-clay-session-links.js +34 -5
  155. package/lib/server-settings.js +5 -0
  156. package/lib/server-tools.js +127 -9
  157. package/lib/server.js +137 -22
  158. package/lib/session-driver-eligibility.js +38 -0
  159. package/lib/session-driver-orchestration.js +31 -0
  160. package/lib/session-hygiene.js +3 -0
  161. package/lib/session-notes-mcp-server.js +25 -5
  162. package/lib/session-pair-factory.js +247 -0
  163. package/lib/session-pair-mcp-server.js +46 -8
  164. package/lib/session-pair-prompts.js +79 -0
  165. package/lib/session-pair-turn-control.js +153 -0
  166. package/lib/session-provenance.js +119 -0
  167. package/lib/session-spawn-mcp-server.js +1 -1
  168. package/lib/session-split-groups.js +4 -1
  169. package/lib/session-title-policy.js +60 -0
  170. package/lib/session-visibility.js +41 -0
  171. package/lib/sessions.js +57 -10
  172. package/lib/tools-registry.js +89 -10
  173. package/lib/update-snooze.js +437 -0
  174. package/lib/users-experimental-preferences.js +30 -0
  175. package/lib/users-permissions.js +6 -6
  176. package/lib/users.js +22 -16
  177. package/lib/workspace-query-access.js +102 -0
  178. package/lib/workspace-query-service.js +24 -17
  179. package/lib/worktree.js +25 -38
  180. package/lib/ws-schema.js +38 -7
  181. package/lib/yoke/adapters/claude-worker.js +16 -6
  182. package/lib/yoke/adapters/claude.js +12 -0
  183. package/lib/yoke/adapters/codex.js +24 -14
  184. package/lib/yoke/vendor-registry.js +1 -1
  185. package/package.json +2 -2
package/lib/notes.js CHANGED
@@ -3,6 +3,7 @@ var path = require("path");
3
3
  var crypto = require("crypto");
4
4
  var config = require("./config");
5
5
  var utils = require("./utils");
6
+ var lifecycle = require("./notes-lifecycle");
6
7
 
7
8
  function createNotesManager(opts) {
8
9
  var cwd = opts.cwd;
@@ -19,11 +20,14 @@ function createNotesManager(opts) {
19
20
  return "n_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
20
21
  }
21
22
 
23
+ // Legacy files are normalized on load, never on read. Nothing is rewritten
24
+ // just because a note was looked at; the projected fields reach disk the next
25
+ // time some other write happens.
22
26
  function loadFromDisk() {
23
27
  try {
24
28
  var data = fs.readFileSync(notesFile, "utf8");
25
29
  var parsed = JSON.parse(data);
26
- return parsed.notes || [];
30
+ return lifecycle.normalizeAll(parsed.notes || []).notes;
27
31
  } catch (e) {
28
32
  return [];
29
33
  }
@@ -56,6 +60,10 @@ function createNotesManager(opts) {
56
60
  color: data.color || "purple",
57
61
  opacity: typeof data.opacity === "number" ? data.opacity : 0.64,
58
62
  minimized: false,
63
+ // A new note is always an open attention item.
64
+ state: lifecycle.STATE_OPEN,
65
+ closedAt: null,
66
+ closedBy: null,
59
67
  zIndex: notes.length + 1,
60
68
  createdAt: now,
61
69
  updatedAt: now,
@@ -74,13 +82,20 @@ function createNotesManager(opts) {
74
82
  function update(id, changes) {
75
83
  for (var i = 0; i < notes.length; i++) {
76
84
  if (notes[i].id === id) {
77
- var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "hidden", "zIndex", "opacity"];
85
+ var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity"];
78
86
  for (var j = 0; j < allowed.length; j++) {
79
87
  var key = allowed[j];
80
88
  if (changes[key] !== undefined) {
81
89
  notes[i][key] = changes[key];
82
90
  }
83
91
  }
92
+ // `hidden` is the legacy spelling of the lifecycle. Route it through the
93
+ // transitions so state, hidden, and the close provenance can never
94
+ // disagree, and so an old client cannot half-apply a close.
95
+ if (changes.hidden !== undefined) {
96
+ if (changes.hidden === true) lifecycle.applyClose(notes[i], changes.actor || null, Date.now());
97
+ else lifecycle.applyReopen(notes[i]);
98
+ }
84
99
  notes[i].updatedAt = Date.now();
85
100
  saveToDisk();
86
101
  return notes[i];
@@ -89,6 +104,44 @@ function createNotesManager(opts) {
89
104
  return null;
90
105
  }
91
106
 
107
+ // Close a note. This is what completion means: the record stays and only its
108
+ // place on the active canvas changes. Idempotent.
109
+ function close(id, actor) {
110
+ for (var i = 0; i < notes.length; i++) {
111
+ if (notes[i].id !== id) continue;
112
+ if (lifecycle.applyClose(notes[i], actor || null, Date.now())) {
113
+ notes[i].updatedAt = Date.now();
114
+ saveToDisk();
115
+ }
116
+ return notes[i];
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // Reopen a closed note. Idempotent.
122
+ function reopen(id) {
123
+ for (var i = 0; i < notes.length; i++) {
124
+ if (notes[i].id !== id) continue;
125
+ if (lifecycle.applyReopen(notes[i])) {
126
+ notes[i].updatedAt = Date.now();
127
+ saveToDisk();
128
+ }
129
+ return notes[i];
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function openList() {
135
+ return lifecycle.openNotes(notes);
136
+ }
137
+
138
+ function closedList() {
139
+ return lifecycle.closedNotes(notes);
140
+ }
141
+
142
+ // Permanent deletion. Deliberately not reachable from any WebSocket message,
143
+ // MCP tool, or UI control: completion closes, it never erases. Retained only
144
+ // for maintenance callers that must truly discard a record.
92
145
  function remove(id) {
93
146
  for (var i = 0; i < notes.length; i++) {
94
147
  if (notes[i].id === id) {
@@ -123,7 +176,7 @@ function createNotesManager(opts) {
123
176
  function getActiveNotesText() {
124
177
  var active = [];
125
178
  for (var i = 0; i < notes.length; i++) {
126
- if (!notes[i].hidden && notes[i].text) active.push(notes[i]);
179
+ if (lifecycle.isOpen(notes[i]) && notes[i].text) active.push(notes[i]);
127
180
  }
128
181
  if (active.length === 0) return "";
129
182
  var lines = [];
@@ -137,8 +190,12 @@ function createNotesManager(opts) {
137
190
 
138
191
  return {
139
192
  list: list,
193
+ openList: openList,
194
+ closedList: closedList,
140
195
  create: create,
141
196
  update: update,
197
+ close: close,
198
+ reopen: reopen,
142
199
  remove: remove,
143
200
  bringToFront: bringToFront,
144
201
  getActiveNotesText: getActiveNotesText,
package/lib/os-users.js CHANGED
@@ -161,7 +161,9 @@ function fsAsUser(op, args, osUserInfo) {
161
161
  "var fs = require('fs');",
162
162
  "var f = " + JSON.stringify(args.file) + ";",
163
163
  "var content = " + JSON.stringify(args.content || "") + ";",
164
- "fs.writeFileSync(f, content, 'utf8');",
164
+ "var flag = " + JSON.stringify(args.flag === "wx" ? "wx" : "w") + ";",
165
+ "var mode = " + JSON.stringify(typeof args.mode === "number" ? args.mode : 0o666) + ";",
166
+ "fs.writeFileSync(f, content, { encoding: 'utf8', flag: flag, mode: mode });",
165
167
  "process.stdout.write(JSON.stringify({ ok: true }));",
166
168
  ].join(" ");
167
169
  } else if (op === "mkdir") {
@@ -1,4 +1,12 @@
1
1
  // Bounded progressive-disclosure catalog for Capsules visible to a Mate.
2
+ //
3
+ // Skills are gated by Display: a Capsule the human cannot operate by hand is a
4
+ // Capsule no Mate may reach either, so an entry appears here only while its
5
+ // declarative Display floor validates. The gate reads the Capsule's own tree,
6
+ // carried alongside each manifest, and fails closed on a scan error, a missing
7
+ // ID, or a missing or invalid Display.
8
+
9
+ var capsuleFloor = require("./capsule-display-floor");
2
10
 
3
11
  var MAX_NAME_LENGTH = 80;
4
12
  var MAX_DESCRIPTION_LENGTH = 240;
@@ -35,7 +43,7 @@ function discoveryEntry(manifest) {
35
43
  function buildCapsuleCatalogPrompt(manifests) {
36
44
  if (!Array.isArray(manifests) || manifests.length === 0) return "";
37
45
  var sorted = manifests.filter(function (manifest) {
38
- return manifest && !manifest.error && manifest.id;
46
+ return capsuleFloor.hasUsableFloor(manifest);
39
47
  }).slice().sort(function (a, b) {
40
48
  var first = String(a.id);
41
49
  var second = String(b.id);
@@ -0,0 +1,96 @@
1
+ // Delivers a Capsule turn prompt into this Mate project.
2
+ //
3
+ // A Capsule game against a Mate needs the Mate to actually take its turns:
4
+ // when Logic passes the turn to the mate seat, this module wakes the Mate
5
+ // with a prompt telling it to snapshot the game and act. One session hosts
6
+ // the whole game (found again by session.capsuleGame.toolId), so the user
7
+ // watches a single running conversation of the Mate playing rather than a
8
+ // new session per turn. The prompt is recorded as an internal user message,
9
+ // the same shape the debate engine uses, so the transcript shows the nudge
10
+ // without pretending the human typed it.
11
+ //
12
+ // This module only carries words to the Mate. Every actual game mutation the
13
+ // Mate then makes goes through the same clay_tool_act pipeline as always.
14
+
15
+ function attachCapsuleTurn(ctx) {
16
+ var sm = ctx.sm;
17
+
18
+ function ownsSession(session, principal) {
19
+ if (ctx.isMultiUser()) return !!principal.userId && session.ownerId === principal.userId;
20
+ return !session.ownerId;
21
+ }
22
+
23
+ function findGameSession(principal, toolId) {
24
+ var found = null;
25
+ sm.sessions.forEach(function (session) {
26
+ if (found || !session || session.hidden || session.destroying) return;
27
+ if (!session.capsuleGame || session.capsuleGame.toolId !== toolId) return;
28
+ if (!ownsSession(session, principal)) return;
29
+ found = session;
30
+ });
31
+ return found;
32
+ }
33
+
34
+ async function createGameSession(principal, options) {
35
+ var selection = await ctx.resolveModel(principal);
36
+ if (!selection || selection.status !== "ready" || !selection.vendor || !selection.model) {
37
+ throw new Error(selection && selection.error ? selection.error : "No configured model is available for the Mate.");
38
+ }
39
+ var create = typeof sm.createSessionRaw === "function" ? sm.createSessionRaw : sm.createSession;
40
+ var session = create.call(sm, {
41
+ ownerId: principal.userId || null,
42
+ vendor: selection.vendor,
43
+ model: selection.model,
44
+ });
45
+ session.title = (options.toolName || options.toolId) + " game";
46
+ session.capsuleGame = { toolId: options.toolId };
47
+ return session;
48
+ }
49
+
50
+ async function deliverCapsuleTurn(principal, options) {
51
+ if (!principal || !options || !options.toolId || !options.text) {
52
+ throw new Error("A bound owner, toolId, and turn text are required.");
53
+ }
54
+ var sdk = ctx.getSdk();
55
+ if (!sdk || typeof sdk.startQuery !== "function") throw new Error("Mate project runtime is unavailable.");
56
+ var session = findGameSession(principal, options.toolId);
57
+ var created = false;
58
+ if (!session) {
59
+ session = await createGameSession(principal, options);
60
+ created = true;
61
+ }
62
+ // Recorded as a dedicated event, not a user message: the transcript shows
63
+ // a short system note (see historyToHomeChat) instead of pretending the
64
+ // human typed the delivery prompt. The full prompt still reaches the
65
+ // model through the query below.
66
+ sm.sendAndRecord(session, {
67
+ type: "capsule_turn",
68
+ text: options.text,
69
+ toolId: options.toolId,
70
+ toolName: options.toolName || options.toolId,
71
+ kind: options.kind || "turn",
72
+ });
73
+ session.isProcessing = true;
74
+ session.lastActivity = Date.now();
75
+ sm.broadcastSessionList();
76
+ try {
77
+ var delivered = typeof sdk.pushMessage === "function" ? sdk.pushMessage(session, options.text) : false;
78
+ if (!delivered) await Promise.resolve(sdk.startQuery(session, options.text, undefined, ctx.getLinuxUserForSession(session)));
79
+ } catch (error) {
80
+ session.isProcessing = false;
81
+ sm.sendAndRecord(session, { type: "error", text: "Capsule turn could not start: " + (error && error.message ? error.message : String(error)) });
82
+ sm.broadcastSessionList();
83
+ throw error;
84
+ }
85
+ // The reference shape the home chat surface opens sessions by.
86
+ return {
87
+ session: session,
88
+ created: created,
89
+ reference: (typeof session.cliSessionId === "string" && session.cliSessionId) || "local:" + session.localId,
90
+ };
91
+ }
92
+
93
+ return { deliverCapsuleTurn: deliverCapsuleTurn };
94
+ }
95
+
96
+ module.exports = { attachCapsuleTurn: attachCapsuleTurn };
@@ -233,7 +233,7 @@ function attachConnection(ctx) {
233
233
  // so vendor badges showed Claude until the next broadcast).
234
234
  sendTo(ws, {
235
235
  type: "session_list",
236
- sessions: allSessions.map(function (s) { return sm.mapSessionForClient(s); }),
236
+ sessions: sm.mapSessionsForClient(allSessions),
237
237
  });
238
238
  if (_splitGroups) _splitGroups.sendConnectionState(ws);
239
239
 
@@ -342,6 +342,7 @@ function attachConnection(ctx) {
342
342
  tm.detachAll(ws);
343
343
  clients.delete(ws);
344
344
  stopFileWatch(ws);
345
+ stopAllDirWatches(ws);
345
346
  if (clients.size === 0) {
346
347
  stopFileWatch();
347
348
  stopAllDirWatches();
@@ -350,9 +351,22 @@ function attachConnection(ctx) {
350
351
  broadcastPresence();
351
352
  }
352
353
 
354
+ // Keep-alive for the project socket, mirroring the `/ws` global handler.
355
+ //
356
+ // A reverse proxy resets its upstream read timeout on data flowing back from
357
+ // the server, so a client ping is only useful if the server answers it. This
358
+ // reads nothing, mutates nothing, and reveals nothing about the connection:
359
+ // it is a fixed reply on a fixed message type.
360
+ function handleKeepaliveMessage(ws, msg) {
361
+ if (!msg || typeof msg !== "object" || msg.type !== "ping") return false;
362
+ sendTo(ws, { type: "pong" });
363
+ return true;
364
+ }
365
+
353
366
  return {
354
367
  handleConnection: handleConnection,
355
368
  handleDisconnection: handleDisconnection,
369
+ handleKeepaliveMessage: handleKeepaliveMessage,
356
370
  };
357
371
  }
358
372
 
@@ -0,0 +1,223 @@
1
+ var path = require("path");
2
+ var execFileSync = require("child_process").execFileSync;
3
+ var wrapSpawnAsUser = require("./os-users").wrapSpawnAsUser;
4
+
5
+ function attachFileHistory(ctx, access) {
6
+ var cwd = ctx.cwd;
7
+ var sm = ctx.sm;
8
+ var sendTo = ctx.sendTo;
9
+
10
+ function runGit(ws, args, options) {
11
+ options = Object.assign({}, options, { stdio: ["ignore", "pipe", "pipe"] });
12
+ var identity = access.osIdentity(ws);
13
+ if (identity) options = Object.assign({}, options, { uid: identity.uid, gid: identity.gid });
14
+ var command = wrapSpawnAsUser("git", args, options);
15
+ return execFileSync(command.command, command.args, command.options);
16
+ }
17
+
18
+ function handleFileHistory(ws, msg) {
19
+ if (!/^fs_(file_history|git_diff|file_at)$/.test(msg.type)) return false;
20
+ if (typeof msg.path !== "string" || !msg.path || path.isAbsolute(msg.path) ||
21
+ path.relative(cwd, path.resolve(cwd, msg.path)).split(path.sep).indexOf("..") !== -1 ||
22
+ (msg.hash && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash)) ||
23
+ (msg.hash2 && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash2))) {
24
+ sendTo(ws, { type: msg.type + "_result", path: msg.path, error: "Invalid file history request" });
25
+ return true;
26
+ }
27
+ // --- File edit history ---
28
+ if (msg.type === "fs_file_history") {
29
+ var histPath = msg.path;
30
+ if (!histPath) {
31
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
32
+ return true;
33
+ }
34
+ var absHistPath = path.resolve(cwd, histPath);
35
+ var entries = [];
36
+
37
+ // Collect session edits
38
+ sm.sessions.forEach(function (session) {
39
+ if (!access.canReadSession(ws, session)) return;
40
+ var sessionLocalId = session.localId;
41
+ var sessionTitle = session.title || "Untitled";
42
+ var histLen = session.history.length || 1;
43
+
44
+ for (var hi = 0; hi < session.history.length; hi++) {
45
+ var entry = session.history[hi];
46
+ if (entry.type !== "tool_executing") continue;
47
+ if (entry.name !== "Edit" && entry.name !== "Write") continue;
48
+ if (!entry.input || !entry.input.file_path) continue;
49
+ if (entry.input.file_path !== absHistPath) continue;
50
+
51
+ // Find parent assistant UUID + message snippet by scanning backwards
52
+ var assistantUuid = null;
53
+ var uuidIndex = -1;
54
+ for (var hj = hi - 1; hj >= 0; hj--) {
55
+ if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
56
+ assistantUuid = session.history[hj].uuid;
57
+ uuidIndex = hj;
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Find user prompt by scanning backwards from the assistant uuid
63
+ var messageSnippet = "";
64
+ var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
65
+ for (var hk = searchFrom - 1; hk >= 0; hk--) {
66
+ if (session.history[hk].type === "user_message" && session.history[hk].text) {
67
+ messageSnippet = session.history[hk].text.trim().substring(0, 100);
68
+ break;
69
+ }
70
+ }
71
+
72
+ // Collect Claude's explanation: scan backwards from tool_executing
73
+ // to find the nearest delta text block (skipping tool_start).
74
+ // If no delta found immediately before this tool, scan past
75
+ // intervening tool blocks to find the last delta text within
76
+ // the same assistant turn.
77
+ var assistantSnippet = "";
78
+ var deltaChunks = [];
79
+ for (var hd = hi - 1; hd >= 0; hd--) {
80
+ var hEntry = session.history[hd];
81
+ if (hEntry.type === "tool_start") continue;
82
+ if (hEntry.type === "delta" && hEntry.text) {
83
+ deltaChunks.unshift(hEntry.text);
84
+ } else {
85
+ break;
86
+ }
87
+ }
88
+ if (deltaChunks.length === 0) {
89
+ // No delta immediately before; scan past tool blocks
90
+ // to find the nearest preceding delta in the same turn
91
+ for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
92
+ var hEntry2 = session.history[hd2];
93
+ if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
94
+ if (hEntry2.type === "delta" && hEntry2.text) {
95
+ // Found a delta before an earlier tool in the same turn.
96
+ // Collect this contiguous block of deltas.
97
+ for (var hd3 = hd2; hd3 >= 0; hd3--) {
98
+ var hEntry3 = session.history[hd3];
99
+ if (hEntry3.type === "tool_start") continue;
100
+ if (hEntry3.type === "delta" && hEntry3.text) {
101
+ deltaChunks.unshift(hEntry3.text);
102
+ } else {
103
+ break;
104
+ }
105
+ }
106
+ break;
107
+ } else {
108
+ // Hit message_uuid, user_message, etc. Stop.
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
114
+
115
+ // Approximate timestamp: interpolate between session creation and last activity
116
+ var tStart = session.createdAt || 0;
117
+ var tEnd = session.lastActivity || tStart;
118
+ var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
119
+
120
+ var editRecord = {
121
+ source: "session",
122
+ timestamp: ts,
123
+ sessionLocalId: sessionLocalId,
124
+ sessionTitle: sessionTitle,
125
+ assistantUuid: assistantUuid,
126
+ toolId: entry.id,
127
+ messageSnippet: messageSnippet,
128
+ assistantSnippet: assistantSnippet,
129
+ toolName: entry.name,
130
+ };
131
+
132
+ if (entry.name === "Edit") {
133
+ editRecord.old_string = entry.input.old_string || "";
134
+ editRecord.new_string = entry.input.new_string || "";
135
+ } else {
136
+ editRecord.isFullWrite = true;
137
+ }
138
+
139
+ entries.push(editRecord);
140
+ }
141
+ });
142
+
143
+ // Collect git commits
144
+ try {
145
+ var gitLog = runGit(ws,
146
+ ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
147
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
148
+ );
149
+ var gitLines = gitLog.trim().split("\n");
150
+ for (var gi = 0; gi < gitLines.length; gi++) {
151
+ if (!gitLines[gi]) continue;
152
+ var parts = gitLines[gi].split("|");
153
+ if (parts.length < 4) continue;
154
+ entries.push({
155
+ source: "git",
156
+ hash: parts[0],
157
+ timestamp: parseInt(parts[1], 10) * 1000,
158
+ author: parts[2],
159
+ message: parts.slice(3).join("|"),
160
+ });
161
+ }
162
+ } catch (e) {
163
+ // Not a git repo or file not tracked, that is fine
164
+ }
165
+
166
+ // Sort by timestamp descending (newest first)
167
+ entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
168
+
169
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
170
+ return true;
171
+ }
172
+
173
+ // --- Git diff for file history ---
174
+ if (msg.type === "fs_git_diff") {
175
+ var diffPath = msg.path;
176
+ var hash = msg.hash;
177
+ var hash2 = msg.hash2 || null;
178
+ if (!diffPath || !hash) {
179
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
180
+ return true;
181
+ }
182
+ try {
183
+ var diff;
184
+ if (hash2) {
185
+ diff = runGit(ws, ["diff", hash, hash2, "--", diffPath],
186
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
187
+ } else {
188
+ diff = runGit(ws, ["show", hash, "--format=", "--", diffPath],
189
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
190
+ }
191
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
192
+ } catch (e) {
193
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
194
+ }
195
+ return true;
196
+ }
197
+
198
+ // --- File content at a git commit ---
199
+ if (msg.type === "fs_file_at") {
200
+ var atPath = msg.path;
201
+ var atHash = msg.hash;
202
+ if (!atPath || !atHash) {
203
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
204
+ return true;
205
+ }
206
+ try {
207
+ // Convert to repo-relative path (git show requires hash:relative/path)
208
+ var atAbsPath = path.resolve(cwd, atPath);
209
+ var atRelPath = path.relative(cwd, atAbsPath);
210
+ var content = runGit(ws, ["show", atHash + ":" + atRelPath],
211
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
212
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
213
+ } catch (e) {
214
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
215
+ }
216
+ return true;
217
+ }
218
+
219
+ return false;
220
+ }
221
+ return { handleFileHistory: handleFileHistory };
222
+ }
223
+ module.exports = { attachFileHistory: attachFileHistory };