@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.
- package/bun.lock +776 -2
- package/client/index.html +1 -13
- package/client/package.json +7 -1
- package/client/src/App.tsx +2 -0
- package/client/src/commands.ts +36 -0
- package/client/src/components/analytics/AnalyticsView.tsx +61 -0
- package/client/src/components/analytics/ChartCard.tsx +22 -0
- package/client/src/components/analytics/PeriodSelector.tsx +42 -0
- package/client/src/components/analytics/QuickStats.tsx +99 -0
- package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
- package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
- package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
- package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
- package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
- package/client/src/components/chat/AttachmentChips.tsx +116 -0
- package/client/src/components/chat/ChatInput.tsx +250 -73
- package/client/src/components/chat/ChatView.tsx +242 -10
- package/client/src/components/chat/CommandPalette.tsx +162 -0
- package/client/src/components/chat/Message.tsx +23 -2
- package/client/src/components/chat/PromptQuestion.tsx +271 -0
- package/client/src/components/chat/TodoCard.tsx +57 -0
- package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
- package/client/src/components/chat/VoiceRecorder.tsx +85 -0
- package/client/src/components/dashboard/DashboardView.tsx +5 -0
- package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
- package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
- package/client/src/components/project-settings/ProjectRules.tsx +10 -1
- package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
- package/client/src/components/settings/Appearance.tsx +1 -0
- package/client/src/components/settings/ClaudeSettings.tsx +10 -0
- package/client/src/components/settings/Editor.tsx +123 -0
- package/client/src/components/settings/GlobalMcp.tsx +10 -1
- package/client/src/components/settings/GlobalMemory.tsx +19 -0
- package/client/src/components/settings/GlobalRules.tsx +149 -0
- package/client/src/components/settings/GlobalSkills.tsx +10 -0
- package/client/src/components/settings/Notifications.tsx +88 -0
- package/client/src/components/settings/SettingsView.tsx +12 -0
- package/client/src/components/settings/skill-shared.tsx +2 -1
- package/client/src/components/setup/SetupWizard.tsx +1 -1
- package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
- package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
- package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
- package/client/src/components/sidebar/Sidebar.tsx +43 -2
- package/client/src/components/sidebar/UserIsland.tsx +18 -7
- package/client/src/components/ui/UpdatePrompt.tsx +47 -0
- package/client/src/components/workspace/FileBrowser.tsx +174 -0
- package/client/src/components/workspace/FileTree.tsx +129 -0
- package/client/src/components/workspace/FileViewer.tsx +211 -0
- package/client/src/components/workspace/NoteCard.tsx +119 -0
- package/client/src/components/workspace/NotesView.tsx +102 -0
- package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
- package/client/src/components/workspace/SplitPane.tsx +81 -0
- package/client/src/components/workspace/TabBar.tsx +185 -0
- package/client/src/components/workspace/TaskCard.tsx +158 -0
- package/client/src/components/workspace/TaskEditModal.tsx +114 -0
- package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
- package/client/src/components/workspace/TerminalView.tsx +110 -0
- package/client/src/components/workspace/WorkspaceView.tsx +116 -0
- package/client/src/hooks/useAnalytics.ts +75 -0
- package/client/src/hooks/useAttachments.ts +280 -0
- package/client/src/hooks/useEditorConfig.ts +28 -0
- package/client/src/hooks/useIdleDetection.ts +44 -0
- package/client/src/hooks/useInstallPrompt.ts +53 -0
- package/client/src/hooks/useNotifications.ts +54 -0
- package/client/src/hooks/useOnline.ts +6 -0
- package/client/src/hooks/useSession.ts +110 -4
- package/client/src/hooks/useSpinnerVerb.ts +36 -0
- package/client/src/hooks/useSwipeDrawer.ts +275 -0
- package/client/src/hooks/useVoiceRecorder.ts +123 -0
- package/client/src/hooks/useWorkspace.ts +48 -0
- package/client/src/providers/WebSocketProvider.tsx +18 -0
- package/client/src/router.tsx +52 -20
- package/client/src/stores/analytics.ts +54 -0
- package/client/src/stores/session.ts +136 -0
- package/client/src/stores/sidebar.ts +11 -2
- package/client/src/stores/workspace.ts +254 -0
- package/client/src/styles/global.css +123 -0
- package/client/src/utils/editorUrl.ts +62 -0
- package/client/vite.config.ts +54 -1
- package/package.json +1 -1
- package/server/src/analytics/engine.ts +491 -0
- package/server/src/daemon.ts +12 -1
- package/server/src/features/scheduler.ts +23 -0
- package/server/src/features/sticky-notes.ts +5 -3
- package/server/src/handlers/analytics.ts +34 -0
- package/server/src/handlers/attachment.ts +172 -0
- package/server/src/handlers/chat.ts +43 -2
- package/server/src/handlers/editor.ts +40 -0
- package/server/src/handlers/fs.ts +10 -2
- package/server/src/handlers/memory.ts +3 -0
- package/server/src/handlers/notes.ts +4 -2
- package/server/src/handlers/scheduler.ts +18 -1
- package/server/src/handlers/session.ts +14 -8
- package/server/src/handlers/settings.ts +37 -2
- package/server/src/handlers/terminal.ts +13 -6
- package/server/src/project/pty-worker.cjs +83 -0
- package/server/src/project/sdk-bridge.ts +266 -11
- package/server/src/project/session.ts +4 -4
- package/server/src/project/terminal.ts +78 -34
- package/shared/src/analytics.ts +24 -0
- package/shared/src/index.ts +1 -0
- package/shared/src/messages.ts +173 -4
- package/shared/src/models.ts +27 -1
- package/shared/src/project-settings.ts +1 -1
- package/tp.js +19 -0
- package/client/public/manifest.json +0 -24
- package/client/public/sw.js +0 -61
- package/client/src/components/panels/FileBrowser.tsx +0 -241
- 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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { ChildProcess } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
interface TerminalWorker {
|
|
7
|
+
process: ChildProcess;
|
|
8
|
+
send: (msg: object) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
var terminals = new Map<string, TerminalWorker>();
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
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
|
|
42
|
+
env: { ...process.env, NODE_PATH: NODE_MODULES_PATH },
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
if (
|
|
44
|
-
|
|
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
|
|
50
|
-
if (
|
|
51
|
-
|
|
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
|
|
57
|
-
if (
|
|
58
|
-
|
|
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):
|
|
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";
|
package/shared/src/index.ts
CHANGED