@atercates/claude-deck 0.2.3 → 0.2.4

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.
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export async function POST() {
4
+ // With JSONL-based detection, acknowledge is a no-op.
5
+ // Status is determined by file content, not by a flag.
6
+ // The endpoint exists for API compatibility.
7
+ return NextResponse.json({ ok: true });
8
+ }
@@ -1,237 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
- import { exec } from "child_process";
3
- import { promisify } from "util";
4
- import * as fs from "fs";
5
- import * as path from "path";
6
- import * as os from "os";
7
- import { statusDetector, type SessionStatus } from "@/lib/status-detector";
8
- import type { AgentType } from "@/lib/providers";
9
- import {
10
- getManagedSessionPattern,
11
- getProviderIdFromSessionName,
12
- getSessionIdFromName,
13
- } from "@/lib/providers/registry";
14
- import { getDb } from "@/lib/db";
15
-
16
- const execAsync = promisify(exec);
17
-
18
- interface SessionStatusResponse {
19
- sessionName: string;
20
- status: SessionStatus;
21
- lastLine?: string;
22
- claudeSessionId?: string | null;
23
- agentType?: AgentType;
24
- }
25
-
26
- async function getTmuxSessions(): Promise<string[]> {
27
- try {
28
- const { stdout } = await execAsync(
29
- "tmux list-sessions -F '#{session_name}' 2>/dev/null || true"
30
- );
31
- return stdout.trim().split("\n").filter(Boolean);
32
- } catch {
33
- return [];
34
- }
35
- }
36
-
37
- async function getTmuxSessionCwd(sessionName: string): Promise<string | null> {
38
- try {
39
- const { stdout } = await execAsync(
40
- `tmux display-message -t "${sessionName}" -p "#{pane_current_path}" 2>/dev/null || echo ""`
41
- );
42
- const cwd = stdout.trim();
43
- return cwd || null;
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- // Get Claude session ID from tmux environment variable
50
- async function getClaudeSessionIdFromEnv(
51
- sessionName: string
52
- ): Promise<string | null> {
53
- try {
54
- const { stdout } = await execAsync(
55
- `tmux show-environment -t "${sessionName}" CLAUDE_SESSION_ID 2>/dev/null || echo ""`
56
- );
57
- const line = stdout.trim();
58
- if (line.startsWith("CLAUDE_SESSION_ID=")) {
59
- const sessionId = line.replace("CLAUDE_SESSION_ID=", "");
60
- if (sessionId && sessionId !== "null") {
61
- return sessionId;
62
- }
63
- }
64
- return null;
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- // Get Claude session ID by looking at session files on disk
71
- function getClaudeSessionIdFromFiles(projectPath: string): string | null {
72
- const home = os.homedir();
73
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
74
- const projectDirName = projectPath.replace(/\//g, "-");
75
- const projectDir = path.join(claudeDir, "projects", projectDirName);
76
-
77
- if (!fs.existsSync(projectDir)) {
78
- return null;
79
- }
80
-
81
- const uuidPattern =
82
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
83
-
84
- try {
85
- const files = fs.readdirSync(projectDir);
86
- let mostRecent: string | null = null;
87
- let mostRecentTime = 0;
88
-
89
- for (const file of files) {
90
- if (file.startsWith("agent-")) continue;
91
- if (!uuidPattern.test(file)) continue;
92
-
93
- const filePath = path.join(projectDir, file);
94
- const stat = fs.statSync(filePath);
95
-
96
- if (stat.mtimeMs > mostRecentTime) {
97
- mostRecentTime = stat.mtimeMs;
98
- mostRecent = file.replace(".jsonl", "");
99
- }
100
- }
101
-
102
- if (mostRecent && Date.now() - mostRecentTime < 5 * 60 * 1000) {
103
- return mostRecent;
104
- }
105
-
106
- const configFile = path.join(claudeDir, ".claude.json");
107
- if (fs.existsSync(configFile)) {
108
- try {
109
- const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
110
- if (config.projects?.[projectPath]?.lastSessionId) {
111
- return config.projects[projectPath].lastSessionId;
112
- }
113
- } catch {
114
- // Ignore config parse errors
115
- }
116
- }
117
-
118
- return null;
119
- } catch {
120
- return null;
121
- }
122
- }
123
-
124
- async function getClaudeSessionId(sessionName: string): Promise<string | null> {
125
- const envId = await getClaudeSessionIdFromEnv(sessionName);
126
- if (envId) {
127
- return envId;
128
- }
129
-
130
- const cwd = await getTmuxSessionCwd(sessionName);
131
- if (cwd) {
132
- return getClaudeSessionIdFromFiles(cwd);
133
- }
134
-
135
- return null;
136
- }
137
-
138
- async function getLastLine(sessionName: string): Promise<string> {
139
- try {
140
- const { stdout } = await execAsync(
141
- `tmux capture-pane -t "${sessionName}" -p -S -5 2>/dev/null || echo ""`
142
- );
143
- const lines = stdout.trim().split("\n").filter(Boolean);
144
- return lines.pop() || "";
145
- } catch {
146
- return "";
147
- }
148
- }
149
-
150
- // UUID pattern for claude-deck managed sessions (derived from registry)
151
- const UUID_PATTERN = getManagedSessionPattern();
152
-
153
- // Track previous statuses to detect changes
154
- const previousStatuses = new Map<string, SessionStatus>();
155
-
156
- function getAgentTypeFromSessionName(sessionName: string): AgentType {
157
- return getProviderIdFromSessionName(sessionName) || "claude";
158
- }
2
+ import { getStatusSnapshot } from "@/lib/status-monitor";
159
3
 
160
4
  export async function GET() {
161
- try {
162
- const sessions = await getTmuxSessions();
163
-
164
- // Get status for claude-deck managed sessions
165
- const managedSessions = sessions.filter((s) => UUID_PATTERN.test(s));
166
-
167
- // Use the new status detector
168
- const statusMap: Record<string, SessionStatusResponse> = {};
169
-
170
- const db = getDb();
171
- const sessionsToUpdate: string[] = [];
172
-
173
- // Process all sessions in parallel for speed
174
- const sessionPromises = managedSessions.map(async (sessionName) => {
175
- const [status, claudeSessionId, lastLine] = await Promise.all([
176
- statusDetector.getStatus(sessionName),
177
- getClaudeSessionId(sessionName),
178
- getLastLine(sessionName),
179
- ]);
180
- const id = getSessionIdFromName(sessionName);
181
- const agentType = getAgentTypeFromSessionName(sessionName);
182
-
183
- return { sessionName, id, status, claudeSessionId, lastLine, agentType };
184
- });
185
-
186
- const results = await Promise.all(sessionPromises);
187
-
188
- for (const {
189
- sessionName,
190
- id,
191
- status,
192
- claudeSessionId,
193
- lastLine,
194
- agentType,
195
- } of results) {
196
- // Track status changes - update DB when session becomes active
197
- const prevStatus = previousStatuses.get(id);
198
- if (status === "running" || status === "waiting") {
199
- if (prevStatus !== status) {
200
- sessionsToUpdate.push(id);
201
- }
202
- }
203
- previousStatuses.set(id, status);
204
-
205
- statusMap[id] = {
206
- sessionName,
207
- status,
208
- lastLine,
209
- claudeSessionId,
210
- agentType,
211
- };
212
- }
213
-
214
- // Batch update sessions and claude_session_id
215
- for (const id of sessionsToUpdate) {
216
- db.prepare(
217
- "UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
218
- ).run(id);
219
- }
220
-
221
- for (const { id, claudeSessionId } of results) {
222
- if (claudeSessionId) {
223
- db.prepare(
224
- "UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
225
- ).run(claudeSessionId, id, claudeSessionId);
226
- }
227
- }
228
-
229
- // Cleanup old trackers
230
- statusDetector.cleanup();
231
-
232
- return NextResponse.json({ statuses: statusMap });
233
- } catch (error) {
234
- console.error("Error getting session statuses:", error);
235
- return NextResponse.json({ statuses: {} });
236
- }
5
+ return NextResponse.json({ statuses: getStatusSnapshot() });
237
6
  }
package/app/page.tsx CHANGED
@@ -296,6 +296,37 @@ function HomeContent() {
296
296
  [addTab, focusedPaneId, buildSessionCommand, runSessionInTerminal]
297
297
  );
298
298
 
299
+ // Attach to an already-running tmux session (non-destructive)
300
+ const attachToActiveTmux = useCallback(
301
+ (sessionId: string, tmuxSessionName: string) => {
302
+ const terminalInfo = getTerminalWithFallback();
303
+ if (!terminalInfo) return;
304
+
305
+ const { terminal, paneId } = terminalInfo;
306
+ const activeTab = getActiveTab(paneId);
307
+ const isInTmux = !!activeTab?.attachedTmux;
308
+
309
+ if (isInTmux) {
310
+ terminal.sendInput("\x02d");
311
+ }
312
+
313
+ setTimeout(
314
+ () => {
315
+ terminal.sendInput("\x03");
316
+ setTimeout(() => {
317
+ terminal.sendCommand(
318
+ `tmux attach -t ${tmuxSessionName} 2>/dev/null`
319
+ );
320
+ attachSession(paneId, sessionId, tmuxSessionName);
321
+ terminal.focus();
322
+ }, 50);
323
+ },
324
+ isInTmux ? 100 : 0
325
+ );
326
+ },
327
+ [getTerminalWithFallback, getActiveTab, attachSession]
328
+ );
329
+
299
330
  const resumeClaudeSession = useCallback(
300
331
  (
301
332
  claudeSessionId: string,
@@ -464,6 +495,7 @@ function HomeContent() {
464
495
  paneId={paneId}
465
496
  sessions={sessions}
466
497
  projects={projects}
498
+ sessionStatuses={sessionStatuses}
467
499
  onRegisterTerminal={registerTerminalRef}
468
500
  onMenuClick={isMobile ? () => setSidebarOpen(true) : undefined}
469
501
  onSelectSession={handleSelectSession}
@@ -473,6 +505,7 @@ function HomeContent() {
473
505
  [
474
506
  sessions,
475
507
  projects,
508
+ sessionStatuses,
476
509
  registerTerminalRef,
477
510
  isMobile,
478
511
  handleSelectSession,
@@ -586,6 +619,7 @@ function HomeContent() {
586
619
  updateSettings,
587
620
  requestPermission,
588
621
  attachToSession,
622
+ attachToActiveTmux,
589
623
  openSessionInNewTab,
590
624
  handleNewSessionInProject,
591
625
  handleOpenTerminal,
@@ -21,6 +21,7 @@ import {
21
21
  } from "@/components/ui/tooltip";
22
22
  import { cn } from "@/lib/utils";
23
23
  import type { Session } from "@/lib/db";
24
+ import type { SessionStatus } from "@/components/views/types";
24
25
 
25
26
  type ViewMode = "terminal" | "files" | "git" | "workers";
26
27
 
@@ -35,6 +36,7 @@ interface DesktopTabBarProps {
35
36
  activeTabId: string;
36
37
  session: Session | null | undefined;
37
38
  sessions: Session[];
39
+ sessionStatuses?: Record<string, SessionStatus>;
38
40
  viewMode: ViewMode;
39
41
  isFocused: boolean;
40
42
  isConductor: boolean;
@@ -63,6 +65,7 @@ export function DesktopTabBar({
63
65
  activeTabId,
64
66
  session,
65
67
  sessions,
68
+ sessionStatuses,
66
69
  viewMode,
67
70
  isFocused,
68
71
  isConductor,
@@ -103,34 +106,65 @@ export function DesktopTabBar({
103
106
  >
104
107
  {/* Tabs */}
105
108
  <div className="flex min-w-0 flex-1 items-center gap-0.5">
106
- {tabs.map((tab) => (
107
- <div
108
- key={tab.id}
109
- onClick={(e) => {
110
- e.stopPropagation();
111
- onTabSwitch(tab.id);
112
- }}
113
- className={cn(
114
- "group flex cursor-pointer items-center gap-1.5 rounded-t-md px-3 py-1.5 text-xs transition-colors",
115
- tab.id === activeTabId
116
- ? "bg-background text-foreground"
117
- : "text-muted-foreground hover:text-foreground/80 hover:bg-accent/50"
118
- )}
119
- >
120
- <span className="max-w-[120px] truncate">{getTabName(tab)}</span>
121
- {tabs.length > 1 && (
122
- <button
123
- onClick={(e) => {
124
- e.stopPropagation();
125
- onTabClose(tab.id);
126
- }}
127
- className="hover:text-foreground ml-1 opacity-0 group-hover:opacity-100"
128
- >
129
- <X className="h-3 w-3" />
130
- </button>
131
- )}
132
- </div>
133
- ))}
109
+ {tabs.map((tab) => {
110
+ const tabStatus = tab.sessionId
111
+ ? sessionStatuses?.[tab.sessionId]
112
+ : undefined;
113
+ return (
114
+ <Tooltip key={tab.id}>
115
+ <TooltipTrigger asChild>
116
+ <div
117
+ onClick={(e) => {
118
+ e.stopPropagation();
119
+ onTabSwitch(tab.id);
120
+ }}
121
+ className={cn(
122
+ "group relative flex cursor-pointer items-center gap-1.5 rounded-t-md px-3 py-1.5 text-xs transition-colors",
123
+ tab.id === activeTabId
124
+ ? "bg-background text-foreground"
125
+ : "text-muted-foreground hover:text-foreground/80 hover:bg-accent/50"
126
+ )}
127
+ >
128
+ {tabStatus &&
129
+ tab.id !== activeTabId &&
130
+ (tabStatus.status === "running" ||
131
+ tabStatus.status === "waiting") && (
132
+ <span
133
+ className={cn(
134
+ "absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full",
135
+ tabStatus.status === "running" &&
136
+ "animate-pulse bg-green-500",
137
+ tabStatus.status === "waiting" &&
138
+ "animate-pulse bg-amber-500"
139
+ )}
140
+ />
141
+ )}
142
+ <span className="max-w-[120px] truncate">
143
+ {getTabName(tab)}
144
+ </span>
145
+ {tabs.length > 1 && (
146
+ <button
147
+ onClick={(e) => {
148
+ e.stopPropagation();
149
+ onTabClose(tab.id);
150
+ }}
151
+ className="hover:text-foreground ml-1 opacity-0 group-hover:opacity-100"
152
+ >
153
+ <X className="h-3 w-3" />
154
+ </button>
155
+ )}
156
+ </div>
157
+ </TooltipTrigger>
158
+ {tabStatus?.lastLine && tab.id !== activeTabId && (
159
+ <TooltipContent side="bottom" className="max-w-xs">
160
+ <p className="truncate font-mono text-xs">
161
+ {tabStatus.lastLine}
162
+ </p>
163
+ </TooltipContent>
164
+ )}
165
+ </Tooltip>
166
+ );
167
+ })}
134
168
  <Tooltip>
135
169
  <TooltipTrigger asChild>
136
170
  <Button
@@ -43,10 +43,13 @@ const GitPanel = dynamic(
43
43
  { ssr: false, loading: () => <GitPanelSkeleton /> }
44
44
  );
45
45
 
46
+ import type { SessionStatus } from "@/components/views/types";
47
+
46
48
  interface PaneProps {
47
49
  paneId: string;
48
50
  sessions: Session[];
49
51
  projects: Project[];
52
+ sessionStatuses?: Record<string, SessionStatus>;
50
53
  onRegisterTerminal: (
51
54
  paneId: string,
52
55
  tabId: string,
@@ -68,6 +71,7 @@ export const Pane = memo(function Pane({
68
71
  paneId,
69
72
  sessions,
70
73
  projects,
74
+ sessionStatuses,
71
75
  onRegisterTerminal,
72
76
  onMenuClick,
73
77
  onSelectSession,
@@ -318,6 +322,7 @@ export const Pane = memo(function Pane({
318
322
  activeTabId={paneData.activeTabId}
319
323
  session={session}
320
324
  sessions={sessions}
325
+ sessionStatuses={sessionStatuses}
321
326
  viewMode={viewMode}
322
327
  isFocused={isFocused}
323
328
  isConductor={isConductor}
@@ -14,6 +14,7 @@ import { CodeSearchResults } from "@/components/CodeSearch/CodeSearchResults";
14
14
  import { useRipgrepAvailable } from "@/data/code-search";
15
15
  import { useClaudeProjectsQuery, useClaudeSessionsQuery } from "@/data/claude";
16
16
  import type { ClaudeProject } from "@/data/claude";
17
+ import type { SessionStatus } from "@/components/views/types";
17
18
 
18
19
  interface QuickSwitcherProps {
19
20
  open: boolean;
@@ -27,6 +28,7 @@ interface QuickSwitcherProps {
27
28
  onSelectFile?: (file: string, line: number) => void;
28
29
  currentSessionId?: string;
29
30
  activeSessionWorkingDir?: string;
31
+ sessionStatuses?: Record<string, SessionStatus>;
30
32
  }
31
33
 
32
34
  interface FlatSession {
@@ -45,6 +47,7 @@ export function QuickSwitcher({
45
47
  onSelectFile,
46
48
  currentSessionId,
47
49
  activeSessionWorkingDir,
50
+ sessionStatuses,
48
51
  }: QuickSwitcherProps) {
49
52
  const [mode, setMode] = useState<"sessions" | "code">("sessions");
50
53
  const [query, setQuery] = useState("");
@@ -103,16 +106,46 @@ export function QuickSwitcher({
103
106
  // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when .data changes, not entire query objects
104
107
  }, [s0.data, s1.data, s2.data, s3.data, topProjects]);
105
108
 
109
+ // Build a map of claudeSessionId -> status for quick lookup
110
+ const statusByClaudeId = useMemo(() => {
111
+ if (!sessionStatuses) return new Map<string, SessionStatus>();
112
+ const map = new Map<string, SessionStatus>();
113
+ for (const s of Object.values(sessionStatuses)) {
114
+ if (s.claudeSessionId) {
115
+ map.set(s.claudeSessionId, s);
116
+ }
117
+ }
118
+ return map;
119
+ }, [sessionStatuses]);
120
+
106
121
  const filteredSessions = useMemo(() => {
107
- if (!query) return allSessions;
108
- const q = query.toLowerCase();
109
- return allSessions.filter(
110
- (s) =>
111
- s.summary.toLowerCase().includes(q) ||
112
- s.projectDisplayName.toLowerCase().includes(q) ||
113
- s.cwd.toLowerCase().includes(q)
114
- );
115
- }, [allSessions, query]);
122
+ let sessions = allSessions;
123
+ if (query) {
124
+ const q = query.toLowerCase();
125
+ sessions = sessions.filter(
126
+ (s) =>
127
+ s.summary.toLowerCase().includes(q) ||
128
+ s.projectDisplayName.toLowerCase().includes(q) ||
129
+ s.cwd.toLowerCase().includes(q)
130
+ );
131
+ }
132
+
133
+ // Sort: waiting first, then running, then by time
134
+ return [...sessions].sort((a, b) => {
135
+ const statusA = statusByClaudeId.get(a.sessionId)?.status;
136
+ const statusB = statusByClaudeId.get(b.sessionId)?.status;
137
+ const orderMap: Record<string, number> = {
138
+ waiting: 0,
139
+ running: 1,
140
+ };
141
+ const orderA = statusA && statusA in orderMap ? orderMap[statusA] : 2;
142
+ const orderB = statusB && statusB in orderMap ? orderMap[statusB] : 2;
143
+ if (orderA !== orderB) return orderA - orderB;
144
+ return (
145
+ new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
146
+ );
147
+ });
148
+ }, [allSessions, query, statusByClaudeId]);
116
149
 
117
150
  useEffect(() => {
118
151
  if (open) {
@@ -242,6 +275,7 @@ export function QuickSwitcher({
242
275
  ) : (
243
276
  filteredSessions.map((session, index) => {
244
277
  const isCurrent = session.sessionId === currentSessionId;
278
+ const status = statusByClaudeId.get(session.sessionId);
245
279
  return (
246
280
  <button
247
281
  key={session.sessionId}
@@ -259,11 +293,24 @@ export function QuickSwitcher({
259
293
  index === selectedIndex
260
294
  ? "bg-accent"
261
295
  : "hover:bg-accent/50",
262
- isCurrent && "bg-primary/10"
296
+ isCurrent && "bg-primary/10",
297
+ status?.status === "waiting" && "bg-amber-500/5"
263
298
  )}
264
299
  >
265
- <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-emerald-500/20 text-emerald-400">
300
+ <div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-emerald-500/20 text-emerald-400">
266
301
  <Terminal className="h-4 w-4" />
302
+ {status && (
303
+ <span
304
+ className={cn(
305
+ "border-background absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2",
306
+ status.status === "running" &&
307
+ "animate-pulse bg-green-500",
308
+ status.status === "waiting" &&
309
+ "animate-pulse bg-amber-500",
310
+ status.status === "idle" && "bg-gray-400"
311
+ )}
312
+ />
313
+ )}
267
314
  </div>
268
315
  <div className="min-w-0 flex-1">
269
316
  <span className="block truncate text-sm font-medium">
@@ -272,6 +319,11 @@ export function QuickSwitcher({
272
319
  <span className="text-muted-foreground block truncate text-xs">
273
320
  {session.projectDisplayName}
274
321
  </span>
322
+ {status?.lastLine && (
323
+ <span className="text-muted-foreground block truncate font-mono text-[10px]">
324
+ {status.lastLine}
325
+ </span>
326
+ )}
275
327
  </div>
276
328
  <div className="text-muted-foreground flex flex-shrink-0 items-center gap-1 text-xs">
277
329
  <Clock className="h-3 w-3" />