@cryptiklemur/lattice 1.3.0 → 1.5.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 (109) hide show
  1. package/bun.lock +776 -2
  2. package/client/index.html +1 -13
  3. package/client/package.json +7 -1
  4. package/client/src/App.tsx +2 -0
  5. package/client/src/commands.ts +36 -0
  6. package/client/src/components/analytics/AnalyticsView.tsx +61 -0
  7. package/client/src/components/analytics/ChartCard.tsx +22 -0
  8. package/client/src/components/analytics/PeriodSelector.tsx +42 -0
  9. package/client/src/components/analytics/QuickStats.tsx +99 -0
  10. package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
  11. package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
  12. package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
  13. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
  14. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
  15. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  16. package/client/src/components/chat/ChatInput.tsx +250 -73
  17. package/client/src/components/chat/ChatView.tsx +242 -10
  18. package/client/src/components/chat/CommandPalette.tsx +162 -0
  19. package/client/src/components/chat/Message.tsx +23 -2
  20. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  21. package/client/src/components/chat/TodoCard.tsx +57 -0
  22. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  23. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  24. package/client/src/components/dashboard/DashboardView.tsx +5 -0
  25. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  26. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  27. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  28. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  29. package/client/src/components/settings/Appearance.tsx +1 -0
  30. package/client/src/components/settings/ClaudeSettings.tsx +10 -0
  31. package/client/src/components/settings/Editor.tsx +123 -0
  32. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  33. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  34. package/client/src/components/settings/GlobalRules.tsx +149 -0
  35. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  36. package/client/src/components/settings/Notifications.tsx +88 -0
  37. package/client/src/components/settings/SettingsView.tsx +12 -0
  38. package/client/src/components/settings/skill-shared.tsx +2 -1
  39. package/client/src/components/setup/SetupWizard.tsx +1 -1
  40. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  41. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  42. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  43. package/client/src/components/sidebar/Sidebar.tsx +43 -2
  44. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  45. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  46. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  47. package/client/src/components/workspace/FileTree.tsx +129 -0
  48. package/client/src/components/workspace/FileViewer.tsx +211 -0
  49. package/client/src/components/workspace/NoteCard.tsx +119 -0
  50. package/client/src/components/workspace/NotesView.tsx +102 -0
  51. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  52. package/client/src/components/workspace/SplitPane.tsx +81 -0
  53. package/client/src/components/workspace/TabBar.tsx +185 -0
  54. package/client/src/components/workspace/TaskCard.tsx +158 -0
  55. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  56. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  57. package/client/src/components/workspace/TerminalView.tsx +110 -0
  58. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  59. package/client/src/hooks/useAnalytics.ts +75 -0
  60. package/client/src/hooks/useAttachments.ts +280 -0
  61. package/client/src/hooks/useEditorConfig.ts +28 -0
  62. package/client/src/hooks/useIdleDetection.ts +44 -0
  63. package/client/src/hooks/useInstallPrompt.ts +53 -0
  64. package/client/src/hooks/useNotifications.ts +54 -0
  65. package/client/src/hooks/useOnline.ts +6 -0
  66. package/client/src/hooks/useSession.ts +110 -4
  67. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  68. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  69. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  70. package/client/src/hooks/useWorkspace.ts +48 -0
  71. package/client/src/providers/WebSocketProvider.tsx +18 -0
  72. package/client/src/router.tsx +52 -20
  73. package/client/src/stores/analytics.ts +54 -0
  74. package/client/src/stores/session.ts +136 -0
  75. package/client/src/stores/sidebar.ts +11 -2
  76. package/client/src/stores/workspace.ts +254 -0
  77. package/client/src/styles/global.css +123 -0
  78. package/client/src/utils/editorUrl.ts +62 -0
  79. package/client/vite.config.ts +54 -1
  80. package/package.json +1 -1
  81. package/server/src/analytics/engine.ts +491 -0
  82. package/server/src/daemon.ts +12 -1
  83. package/server/src/features/scheduler.ts +23 -0
  84. package/server/src/features/sticky-notes.ts +5 -3
  85. package/server/src/handlers/analytics.ts +34 -0
  86. package/server/src/handlers/attachment.ts +172 -0
  87. package/server/src/handlers/chat.ts +43 -2
  88. package/server/src/handlers/editor.ts +40 -0
  89. package/server/src/handlers/fs.ts +10 -2
  90. package/server/src/handlers/memory.ts +3 -0
  91. package/server/src/handlers/notes.ts +4 -2
  92. package/server/src/handlers/scheduler.ts +18 -1
  93. package/server/src/handlers/session.ts +14 -8
  94. package/server/src/handlers/settings.ts +37 -2
  95. package/server/src/handlers/terminal.ts +13 -6
  96. package/server/src/project/pty-worker.cjs +83 -0
  97. package/server/src/project/sdk-bridge.ts +266 -11
  98. package/server/src/project/session.ts +4 -4
  99. package/server/src/project/terminal.ts +78 -34
  100. package/shared/src/analytics.ts +24 -0
  101. package/shared/src/index.ts +1 -0
  102. package/shared/src/messages.ts +173 -4
  103. package/shared/src/models.ts +27 -1
  104. package/shared/src/project-settings.ts +1 -1
  105. package/tp.js +19 -0
  106. package/client/public/manifest.json +0 -24
  107. package/client/public/sw.js +0 -61
  108. package/client/src/components/panels/FileBrowser.tsx +0 -241
  109. package/client/src/components/panels/StickyNotes.tsx +0 -187
@@ -1,11 +1,13 @@
1
1
  import type { Query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { query } from "@anthropic-ai/claude-agent-sdk";
3
- import type { SDKMessage, SDKPartialAssistantMessage, SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
3
+ import type { SDKMessage, SDKPartialAssistantMessage, SDKResultMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
4
4
  import type { CanUseTool, PermissionMode, PermissionResult, PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
5
+ import type { MessageParam } from "@anthropic-ai/sdk/resources";
6
+ import type { Attachment } from "@lattice/shared";
5
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
8
  import { join } from "node:path";
7
9
  import { homedir } from "node:os";
8
- import { sendTo } from "../ws/broadcast";
10
+ import { sendTo, broadcast } from "../ws/broadcast";
9
11
  import { syncSessionToPeers } from "../mesh/session-sync";
10
12
  import { resolveSkillContent } from "../handlers/skills";
11
13
  import { guessContextWindow } from "./session";
@@ -19,6 +21,7 @@ interface PendingPermission {
19
21
  suggestions: PermissionUpdate[] | undefined;
20
22
  clientId: string;
21
23
  sessionId: string;
24
+ promptType?: string;
22
25
  }
23
26
 
24
27
  var pendingPermissions = new Map<string, PendingPermission>();
@@ -29,6 +32,7 @@ export interface ChatStreamOptions {
29
32
  projectSlug: string;
30
33
  sessionId: string;
31
34
  text: string;
35
+ attachments?: Attachment[];
32
36
  clientId: string;
33
37
  cwd: string;
34
38
  env?: Record<string, string>;
@@ -57,6 +61,42 @@ var activeStreams = new Map<string, Query>();
57
61
  var streamMetadata = new Map<string, { projectSlug: string; clientId: string; startedAt: number }>();
58
62
  var interruptedSessions = new Set<string>();
59
63
 
64
+ // Track external lock state so we only broadcast on changes
65
+ var externalLockState = new Map<string, boolean>();
66
+ var watchedSessions = new Set<string>();
67
+
68
+ /**
69
+ * Start polling a session's lock file for external CLI usage.
70
+ * Called when a client activates a session.
71
+ */
72
+ export function watchSessionLock(sessionId: string): void {
73
+ watchedSessions.add(sessionId);
74
+ }
75
+
76
+ /**
77
+ * Stop polling a session's lock file.
78
+ */
79
+ export function unwatchSessionLock(sessionId: string): void {
80
+ watchedSessions.delete(sessionId);
81
+ externalLockState.delete(sessionId);
82
+ }
83
+
84
+ // Poll every 3 seconds for external lock changes
85
+ setInterval(function () {
86
+ for (var sessionId of watchedSessions) {
87
+ // Skip sessions with active Lattice streams — those are already tracked
88
+ if (activeStreams.has(sessionId)) continue;
89
+
90
+ var locked = isSessionLockedByExternal(sessionId);
91
+ var prev = externalLockState.get(sessionId) ?? false;
92
+
93
+ if (locked !== prev) {
94
+ externalLockState.set(sessionId, locked);
95
+ broadcast({ type: "session:busy", sessionId, busy: locked });
96
+ }
97
+ }
98
+ }, 3000);
99
+
60
100
  function getStreamStatePath(): string {
61
101
  return join(getLatticeHome(), "active-streams.json");
62
102
  }
@@ -118,6 +158,98 @@ export function getActiveStream(sessionId: string): Query | undefined {
118
158
  return activeStreams.get(sessionId);
119
159
  }
120
160
 
161
+ /**
162
+ * Check if a session is controlled by an external process (not Lattice).
163
+ * Lattice's own active streams are handled by isProcessing on the client,
164
+ * so this ONLY returns true for external CLI instances.
165
+ */
166
+ export function isSessionBusy(sessionId: string): boolean {
167
+ return isSessionLockedByExternal(sessionId);
168
+ }
169
+
170
+ /**
171
+ * Check if a PID is the Lattice daemon or one of its child processes.
172
+ * The SDK spawns child processes (e.g. claude-agent-sdk/cli.js) that hold
173
+ * lock files — those are NOT external.
174
+ */
175
+ function isOwnProcess(pid: number): boolean {
176
+ var myPid = process.pid;
177
+ if (pid === myPid) return true;
178
+ // Walk up the process tree to see if pid is a descendant of us
179
+ var current = pid;
180
+ for (var i = 0; i < 10; i++) {
181
+ try {
182
+ var stat = readFileSync("/proc/" + current + "/stat", "utf-8");
183
+ // Format: pid (comm) state ppid ...
184
+ var match = stat.match(/^\d+\s+\([^)]*\)\s+\S+\s+(\d+)/);
185
+ if (!match) return false;
186
+ var ppid = parseInt(match[1], 10);
187
+ if (ppid === myPid) return true;
188
+ if (ppid <= 1) return false;
189
+ current = ppid;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * Get PIDs holding the session lock file, excluding Lattice's own process tree.
199
+ * Returns the list of truly external PIDs.
200
+ */
201
+ function getExternalLockPids(sessionId: string): number[] {
202
+ var lockPath = join(homedir(), ".claude", "tasks", sessionId, ".lock");
203
+ if (!existsSync(lockPath)) return [];
204
+ try {
205
+ var result = Bun.spawnSync(["fuser", lockPath], {
206
+ stderr: "ignore",
207
+ });
208
+ if (result.exitCode !== 0) return [];
209
+ var output = result.stdout.toString().trim();
210
+ var pids = output.split(/\s+/)
211
+ .map(function (s) { return parseInt(s, 10); })
212
+ .filter(function (p) { return !isNaN(p) && !isOwnProcess(p); });
213
+ return pids;
214
+ } catch {
215
+ return [];
216
+ }
217
+ }
218
+
219
+ function isSessionLockedByExternal(sessionId: string): boolean {
220
+ return getExternalLockPids(sessionId).length > 0;
221
+ }
222
+
223
+ /**
224
+ * Get the first external PID holding the session lock file.
225
+ * Used to send SIGINT to stop the external process.
226
+ */
227
+ function getExternalLockPid(sessionId: string): number | null {
228
+ var pids = getExternalLockPids(sessionId);
229
+ return pids.length > 0 ? pids[0] : null;
230
+ }
231
+
232
+ /**
233
+ * Gracefully stop an external Claude Code CLI process controlling a session.
234
+ * Sends SIGINT which triggers Claude Code's graceful shutdown.
235
+ * Returns true if a signal was sent.
236
+ */
237
+ export function stopExternalSession(sessionId: string): boolean {
238
+ var pid = getExternalLockPid(sessionId);
239
+ if (pid === null) return false;
240
+ try {
241
+ process.kill(pid, "SIGINT");
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ export function getSessionStreamClientId(sessionId: string): string | undefined {
249
+ var meta = streamMetadata.get(sessionId);
250
+ return meta ? meta.clientId : undefined;
251
+ }
252
+
121
253
  export function matchesAllowRules(rules: string[], toolName: string, currentRule: string): boolean {
122
254
  for (var i = 0; i < rules.length; i++) {
123
255
  var rule = rules[i];
@@ -188,7 +320,7 @@ export function buildPermissionRule(toolName: string, input: Record<string, unkn
188
320
  }
189
321
 
190
322
  export function startChatStream(options: ChatStreamOptions): void {
191
- var { projectSlug, sessionId, text, clientId, cwd, env, model, effort, isNewSession } = options;
323
+ var { projectSlug, sessionId, text, attachments, clientId, cwd, env, model, effort, isNewSession } = options;
192
324
  var startTime = Date.now();
193
325
 
194
326
  if (activeStreams.has(sessionId)) {
@@ -240,16 +372,50 @@ export function startChatStream(options: ChatStreamOptions): void {
240
372
  var queryOptions: Parameters<typeof query>[0]["options"] = {
241
373
  cwd,
242
374
  permissionMode: effectiveMode,
375
+ promptSuggestions: true,
243
376
  additionalDirectories: savedAdditionalDirs.length > 0 ? savedAdditionalDirs : undefined,
244
377
  mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers as Record<string, any> : undefined,
245
378
  };
246
379
 
380
+ (queryOptions as any).toolConfig = {
381
+ askUserQuestion: { previewFormat: "html" },
382
+ };
383
+
247
384
  queryOptions.canUseTool = function (toolName, input, options) {
248
385
  var approved = autoApprovedTools.get(sessionId);
249
386
  if (approved && approved.has(toolName)) {
250
387
  return Promise.resolve({ behavior: "allow", updatedInput: input, toolUseID: options.toolUseID } as PermissionResult);
251
388
  }
252
389
 
390
+ if (toolName === "AskUserQuestion") {
391
+ var promptRequestId = options.toolUseID;
392
+ var questions = (input as { questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string; preview?: string }>; multiSelect: boolean }> }).questions;
393
+ sendTo(clientId, {
394
+ type: "chat:prompt_request",
395
+ requestId: promptRequestId,
396
+ questions: questions,
397
+ });
398
+ return new Promise<PermissionResult>(function (resolve) {
399
+ pendingPermissions.set(promptRequestId, {
400
+ resolve: resolve,
401
+ toolName: toolName,
402
+ toolUseID: options.toolUseID,
403
+ input: input,
404
+ suggestions: undefined,
405
+ clientId: clientId,
406
+ sessionId: sessionId,
407
+ promptType: "question",
408
+ });
409
+ });
410
+ }
411
+
412
+ if (toolName === "ExitPlanMode") {
413
+ sendTo(clientId, {
414
+ type: "chat:plan_mode",
415
+ active: false,
416
+ });
417
+ }
418
+
253
419
  if (toolName === "Read") {
254
420
  var readPath = (input.file_path || input.path || "") as string;
255
421
  if (readPath.startsWith("/tmp/") || readPath === "/tmp") {
@@ -377,10 +543,50 @@ export function startChatStream(options: ChatStreamOptions): void {
377
543
  var activeToolBlocks: Record<number, { id: string; name: string; inputJson: string }> = {};
378
544
  var doneSent = false;
379
545
 
380
- var stream = query({ prompt: prompt, options: queryOptions });
546
+ var queryPrompt: string | AsyncIterable<SDKUserMessage>;
547
+
548
+ if (attachments && attachments.length > 0) {
549
+ var contentBlocks: Array<{ type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }> = [];
550
+ contentBlocks.push({ type: "text", text: prompt });
551
+
552
+ for (var ai = 0; ai < attachments.length; ai++) {
553
+ var att = attachments[ai];
554
+ if (att.type === "image" && att.mimeType && !att.mimeType.includes("svg")) {
555
+ contentBlocks.push({
556
+ type: "image",
557
+ source: {
558
+ type: "base64",
559
+ media_type: att.mimeType,
560
+ data: att.content,
561
+ },
562
+ });
563
+ } else {
564
+ var prefix = att.name ? "[Attached: " + att.name + "]\n" : "";
565
+ contentBlocks.push({
566
+ type: "text",
567
+ text: prefix + att.content,
568
+ });
569
+ }
570
+ }
571
+
572
+ var userMessage: SDKUserMessage = {
573
+ type: "user",
574
+ message: { role: "user", content: contentBlocks } as MessageParam,
575
+ parent_tool_use_id: null,
576
+ };
577
+
578
+ queryPrompt = (async function* () {
579
+ yield userMessage;
580
+ })();
581
+ } else {
582
+ queryPrompt = prompt;
583
+ }
584
+
585
+ var stream = query({ prompt: queryPrompt, options: queryOptions });
381
586
  activeStreams.set(sessionId, stream);
382
587
  streamMetadata.set(sessionId, { projectSlug, clientId, startedAt: Date.now() });
383
588
  persistStreamState();
589
+ broadcast({ type: "session:busy", sessionId, busy: true }, clientId);
384
590
 
385
591
  void (async function () {
386
592
  try {
@@ -395,6 +601,7 @@ export function startChatStream(options: ChatStreamOptions): void {
395
601
  activeStreams.delete(sessionId);
396
602
  streamMetadata.delete(sessionId);
397
603
  persistStreamState();
604
+ broadcast({ type: "session:busy", sessionId, busy: false }, clientId);
398
605
 
399
606
  var toCleanup: string[] = [];
400
607
  pendingPermissions.forEach(function (entry, reqId) {
@@ -421,12 +628,9 @@ export function startChatStream(options: ChatStreamOptions): void {
421
628
  if (msg.type === "system") {
422
629
  var sysMsg = msg as { type: "system"; subtype?: string; mcp_servers?: { name: string; status: string }[]; tools?: string[] };
423
630
  if (sysMsg.subtype === "init") {
424
- console.log("[lattice] SDK init - MCP servers:", JSON.stringify(sysMsg.mcp_servers || []));
425
- console.log("[lattice] SDK init - tools count:", (sysMsg.tools || []).length);
426
- var mcpTools = (sysMsg.tools || []).filter(function (t) { return t.startsWith("mcp__"); });
427
- if (mcpTools.length > 0) {
428
- console.log("[lattice] SDK init - MCP tools:", mcpTools.join(", "));
429
- }
631
+ var toolCount = (sysMsg.tools || []).length;
632
+ var mcpCount = (sysMsg.mcp_servers || []).filter(function (s) { return s.status === "connected"; }).length;
633
+ console.log("[lattice] Session ready: " + toolCount + " tools, " + mcpCount + " MCP servers connected");
430
634
  }
431
635
  return;
432
636
  }
@@ -457,6 +661,23 @@ export function startChatStream(options: ChatStreamOptions): void {
457
661
  name: aBlock.name,
458
662
  args: JSON.stringify(aBlock.input ?? {}),
459
663
  });
664
+ if (aBlock.name === "TodoWrite" && aBlock.input) {
665
+ var todoInput = aBlock.input as { todos?: Array<{ id?: string; content: string; status: string; activeForm?: string }> };
666
+ if (todoInput.todos) {
667
+ sendTo(clientId, {
668
+ type: "chat:todo_update",
669
+ todos: todoInput.todos.map(function (t, idx) {
670
+ return { id: t.id || String(idx), content: t.content, status: t.status, priority: "medium" };
671
+ }),
672
+ });
673
+ }
674
+ }
675
+ if (aBlock.name === "EnterPlanMode") {
676
+ sendTo(clientId, { type: "chat:plan_mode", active: true });
677
+ }
678
+ if (aBlock.name === "ExitPlanMode") {
679
+ sendTo(clientId, { type: "chat:plan_mode", active: false });
680
+ }
460
681
  }
461
682
  }
462
683
  } else if (typeof aContent === "string" && aContent) {
@@ -505,7 +726,29 @@ export function startChatStream(options: ChatStreamOptions): void {
505
726
 
506
727
  if (evt.type === "content_block_stop") {
507
728
  var stopIdx = (evt as { index: number }).index;
508
- delete activeToolBlocks[stopIdx];
729
+ var stoppedBlock = activeToolBlocks[stopIdx];
730
+ if (stoppedBlock) {
731
+ if (stoppedBlock.name === "TodoWrite" && stoppedBlock.inputJson) {
732
+ try {
733
+ var todoInput = JSON.parse(stoppedBlock.inputJson) as { todos?: Array<{ id?: string; content: string; status: string }> };
734
+ if (todoInput.todos) {
735
+ sendTo(clientId, {
736
+ type: "chat:todo_update",
737
+ todos: todoInput.todos.map(function (t, idx) {
738
+ return { id: t.id || String(idx), content: t.content, status: t.status, priority: "medium" };
739
+ }),
740
+ });
741
+ }
742
+ } catch {}
743
+ }
744
+ if (stoppedBlock.name === "EnterPlanMode") {
745
+ sendTo(clientId, { type: "chat:plan_mode", active: true });
746
+ }
747
+ if (stoppedBlock.name === "ExitPlanMode") {
748
+ sendTo(clientId, { type: "chat:plan_mode", active: false });
749
+ }
750
+ delete activeToolBlocks[stopIdx];
751
+ }
509
752
  return;
510
753
  }
511
754
 
@@ -533,6 +776,14 @@ export function startChatStream(options: ChatStreamOptions): void {
533
776
  return;
534
777
  }
535
778
 
779
+ if (msg.type === "prompt_suggestion") {
780
+ var suggestion = (msg as { type: string; suggestion: string }).suggestion;
781
+ if (suggestion) {
782
+ sendTo(clientId, { type: "chat:prompt_suggestion", suggestion: suggestion });
783
+ }
784
+ return;
785
+ }
786
+
536
787
  if (msg.type === "result") {
537
788
  var resultMsg = msg as SDKResultMessage;
538
789
  var dur = Date.now() - startTime;
@@ -558,7 +809,11 @@ export function startChatStream(options: ChatStreamOptions): void {
558
809
  }
559
810
 
560
811
  doneSent = true;
812
+ activeStreams.delete(sessionId);
813
+ streamMetadata.delete(sessionId);
814
+ persistStreamState();
561
815
  sendTo(clientId, { type: "chat:done", cost: cost, duration: dur });
816
+ broadcast({ type: "session:busy", sessionId, busy: false }, clientId);
562
817
  syncSessionToPeers(cwd, projectSlug, sessionId);
563
818
  return;
564
819
  }
@@ -18,7 +18,7 @@ function getProjectPath(projectSlug: string): string | null {
18
18
  return project ? project.path : null;
19
19
  }
20
20
 
21
- function projectPathToHash(projectPath: string): string {
21
+ export function projectPathToHash(projectPath: string): string {
22
22
  return projectPath.replace(/\//g, "-");
23
23
  }
24
24
 
@@ -43,7 +43,7 @@ var FALLBACK_PRICING: Record<string, { input: number; output: number }> = {
43
43
  "claude-haiku-4-5": { input: 0.80, output: 4 },
44
44
  };
45
45
 
46
- function loadPricing(): void {
46
+ export function loadPricing(): void {
47
47
  if (pricingLoaded) return;
48
48
  pricingLoaded = true;
49
49
  fetch(LITELLM_PRICING_URL).then(function (res) {
@@ -69,7 +69,7 @@ function loadPricing(): void {
69
69
 
70
70
  loadPricing();
71
71
 
72
- function getPricing(model: string): { input: number; output: number; cacheRead?: number; cacheCreation?: number } {
72
+ export function getPricing(model: string): { input: number; output: number; cacheRead?: number; cacheCreation?: number } {
73
73
  if (pricingCache[model]) return pricingCache[model];
74
74
  for (var key in pricingCache) {
75
75
  if (key.includes(model) || model.includes(key)) return pricingCache[key];
@@ -84,7 +84,7 @@ function getPricing(model: string): { input: number; output: number; cacheRead?:
84
84
  return FALLBACK_PRICING["claude-sonnet-4-6"];
85
85
  }
86
86
 
87
- function estimateCost(model: string, inputTokens: number, outputTokens: number, cacheRead: number, cacheCreation: number): number {
87
+ export function estimateCost(model: string, inputTokens: number, outputTokens: number, cacheRead: number, cacheCreation: number): number {
88
88
  var pricing = getPricing(model);
89
89
  var normalInput = inputTokens - cacheRead - cacheCreation;
90
90
  var inputCost = (normalInput * pricing.input) / 1000000;
@@ -1,15 +1,33 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { IPty } from "node-pty";
2
+ import { spawn } from "node:child_process";
3
+ import type { ChildProcess } from "node:child_process";
4
+ import { join } from "node:path";
3
5
 
4
- var terminals = new Map<string, IPty>();
5
- var pty: typeof import("node-pty") | null = null;
6
+ interface TerminalWorker {
7
+ process: ChildProcess;
8
+ send: (msg: object) => void;
9
+ }
10
+
11
+ var terminals = new Map<string, TerminalWorker>();
6
12
 
7
- function getPty(): typeof import("node-pty") {
8
- if (!pty) {
9
- pty = require("node-pty") as typeof import("node-pty");
13
+ // node-pty doesn't work under Bun (child gets SIGHUP immediately).
14
+ // We run it in a Node.js subprocess instead, communicating via JSON over stdio.
15
+ var WORKER_PATH = join(import.meta.dir, "pty-worker.cjs");
16
+
17
+ // node-pty lives in Bun's module cache — resolve the path so Node can find it.
18
+ var NODE_MODULES_PATH = (function () {
19
+ // Bun can resolve the module — use it to find the actual path
20
+ try {
21
+ var resolved = require.resolve("node-pty");
22
+ // resolved = .../node_modules/.bun/node-pty@X.Y.Z/node_modules/node-pty/lib/index.js
23
+ // We need: .../node_modules/.bun/node-pty@X.Y.Z/node_modules
24
+ var parts = resolved.split("/node_modules/");
25
+ parts.pop(); // remove node-pty/lib/index.js
26
+ return parts.join("/node_modules/") + "/node_modules";
27
+ } catch {
28
+ return join(import.meta.dir, "..", "..", "..", "node_modules");
10
29
  }
11
- return pty;
12
- }
30
+ })();
13
31
 
14
32
  export function createTerminal(
15
33
  cwd: string,
@@ -17,53 +35,79 @@ export function createTerminal(
17
35
  onExit: (code: number) => void,
18
36
  ): string {
19
37
  var termId = randomUUID();
20
- var shell = process.env.SHELL || "bash";
21
- var lib = getPty();
22
38
 
23
- var term = lib.spawn(shell, [], {
24
- name: "xterm-256color",
25
- cols: 80,
26
- rows: 24,
39
+ var child = spawn("node", [WORKER_PATH], {
40
+ stdio: ["pipe", "pipe", "ignore"],
27
41
  cwd: cwd,
28
- env: process.env as Record<string, string>,
42
+ env: { ...process.env, NODE_PATH: NODE_MODULES_PATH },
29
43
  });
30
44
 
31
- term.onData(onData);
32
- term.onExit(function(e) {
33
- terminals.delete(termId);
34
- onExit(e.exitCode ?? 0);
45
+ var buffer = "";
46
+
47
+ child.stdout!.setEncoding("utf-8");
48
+ child.stdout!.on("data", function (chunk: string) {
49
+ buffer += chunk;
50
+ var lines = buffer.split("\n");
51
+ buffer = lines.pop() || "";
52
+ for (var i = 0; i < lines.length; i++) {
53
+ if (!lines[i].trim()) continue;
54
+ try {
55
+ var msg = JSON.parse(lines[i]);
56
+ if (msg.type === "data") {
57
+ onData(msg.data);
58
+ } else if (msg.type === "exit") {
59
+ terminals.delete(termId);
60
+ onExit(msg.code ?? 0);
61
+ }
62
+ } catch {
63
+ // ignore parse errors
64
+ }
65
+ }
66
+ });
67
+
68
+ child.on("exit", function () {
69
+ if (terminals.has(termId)) {
70
+ terminals.delete(termId);
71
+ onExit(0);
72
+ }
35
73
  });
36
74
 
37
- terminals.set(termId, term);
75
+ function sendMsg(msg: object) {
76
+ if (child.stdin && !child.stdin.destroyed) {
77
+ child.stdin.write(JSON.stringify(msg) + "\n");
78
+ }
79
+ }
80
+
81
+ terminals.set(termId, { process: child, send: sendMsg });
82
+
83
+ // Tell the worker to create the PTY
84
+ sendMsg({ type: "create", cwd: cwd, cols: 80, rows: 24 });
85
+
38
86
  return termId;
39
87
  }
40
88
 
41
89
  export function writeToTerminal(termId: string, data: string): void {
42
- var term = terminals.get(termId);
43
- if (term) {
44
- term.write(data);
90
+ var worker = terminals.get(termId);
91
+ if (worker) {
92
+ worker.send({ type: "input", data: data });
45
93
  }
46
94
  }
47
95
 
48
96
  export function resizeTerminal(termId: string, cols: number, rows: number): void {
49
- var term = terminals.get(termId);
50
- if (term) {
51
- term.resize(cols, rows);
97
+ var worker = terminals.get(termId);
98
+ if (worker) {
99
+ worker.send({ type: "resize", cols: cols, rows: rows });
52
100
  }
53
101
  }
54
102
 
55
103
  export function destroyTerminal(termId: string): void {
56
- var term = terminals.get(termId);
57
- if (term) {
58
- try {
59
- term.kill();
60
- } catch {
61
- // already dead
62
- }
104
+ var worker = terminals.get(termId);
105
+ if (worker) {
106
+ worker.send({ type: "kill" });
63
107
  terminals.delete(termId);
64
108
  }
65
109
  }
66
110
 
67
- export function getTerminal(termId: string): IPty | undefined {
111
+ export function getTerminal(termId: string): TerminalWorker | undefined {
68
112
  return terminals.get(termId);
69
113
  }
@@ -0,0 +1,24 @@
1
+ export interface AnalyticsPayload {
2
+ totalCost: number;
3
+ totalSessions: number;
4
+ totalTokens: { input: number; output: number; cacheRead: number; cacheCreation: number };
5
+ cacheHitRate: number;
6
+ avgSessionCost: number;
7
+ avgSessionDuration: number;
8
+
9
+ costOverTime: Array<{ date: string; total: number; opus: number; sonnet: number; haiku: number; other: number }>;
10
+ cumulativeCost: Array<{ date: string; total: number }>;
11
+ sessionsOverTime: Array<{ date: string; count: number }>;
12
+ tokensOverTime: Array<{ date: string; input: number; output: number; cacheRead: number }>;
13
+ cacheHitRateOverTime: Array<{ date: string; rate: number }>;
14
+
15
+ costDistribution: Array<{ bucket: string; count: number }>;
16
+ sessionBubbles: Array<{ id: string; title: string; cost: number; tokens: number; timestamp: number; project: string }>;
17
+
18
+ modelUsage: Array<{ model: string; sessions: number; cost: number; tokens: number; percentage: number }>;
19
+ projectBreakdown: Array<{ project: string; cost: number; sessions: number; tokens: number }>;
20
+ toolUsage: Array<{ tool: string; count: number; avgCost: number }>;
21
+ }
22
+
23
+ export type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d" | "all";
24
+ export type AnalyticsScope = "global" | "project" | "session";
@@ -1,3 +1,4 @@
1
+ export * from "./analytics.js";
1
2
  export * from "./constants.js";
2
3
  export * from "./messages.js";
3
4
  export * from "./models.js";