@cmdctrl/claude-code 0.1.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 (83) hide show
  1. package/dist/adapter/claude-cli.d.ts +41 -0
  2. package/dist/adapter/claude-cli.d.ts.map +1 -0
  3. package/dist/adapter/claude-cli.js +525 -0
  4. package/dist/adapter/claude-cli.js.map +1 -0
  5. package/dist/adapter/events.d.ts +52 -0
  6. package/dist/adapter/events.d.ts.map +1 -0
  7. package/dist/adapter/events.js +134 -0
  8. package/dist/adapter/events.js.map +1 -0
  9. package/dist/client/messages.d.ts +140 -0
  10. package/dist/client/messages.d.ts.map +1 -0
  11. package/dist/client/messages.js +6 -0
  12. package/dist/client/messages.js.map +1 -0
  13. package/dist/client/websocket.d.ts +115 -0
  14. package/dist/client/websocket.d.ts.map +1 -0
  15. package/dist/client/websocket.js +434 -0
  16. package/dist/client/websocket.js.map +1 -0
  17. package/dist/commands/register.d.ts +10 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +175 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/commands/start.d.ts +9 -0
  22. package/dist/commands/start.d.ts.map +1 -0
  23. package/dist/commands/start.js +54 -0
  24. package/dist/commands/start.js.map +1 -0
  25. package/dist/commands/status.d.ts +5 -0
  26. package/dist/commands/status.d.ts.map +1 -0
  27. package/dist/commands/status.js +38 -0
  28. package/dist/commands/status.js.map +1 -0
  29. package/dist/commands/stop.d.ts +5 -0
  30. package/dist/commands/stop.d.ts.map +1 -0
  31. package/dist/commands/stop.js +59 -0
  32. package/dist/commands/stop.js.map +1 -0
  33. package/dist/commands/unregister.d.ts +5 -0
  34. package/dist/commands/unregister.d.ts.map +1 -0
  35. package/dist/commands/unregister.js +28 -0
  36. package/dist/commands/unregister.js.map +1 -0
  37. package/dist/config/config.d.ts +68 -0
  38. package/dist/config/config.d.ts.map +1 -0
  39. package/dist/config/config.js +193 -0
  40. package/dist/config/config.js.map +1 -0
  41. package/dist/handlers/context-handler.d.ts +37 -0
  42. package/dist/handlers/context-handler.d.ts.map +1 -0
  43. package/dist/handlers/context-handler.js +303 -0
  44. package/dist/handlers/context-handler.js.map +1 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +39 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/message-reader.d.ts +25 -0
  50. package/dist/message-reader.d.ts.map +1 -0
  51. package/dist/message-reader.js +454 -0
  52. package/dist/message-reader.js.map +1 -0
  53. package/dist/session-discovery.d.ts +48 -0
  54. package/dist/session-discovery.d.ts.map +1 -0
  55. package/dist/session-discovery.js +496 -0
  56. package/dist/session-discovery.js.map +1 -0
  57. package/dist/session-watcher.d.ts +92 -0
  58. package/dist/session-watcher.d.ts.map +1 -0
  59. package/dist/session-watcher.js +494 -0
  60. package/dist/session-watcher.js.map +1 -0
  61. package/dist/session-watcher.test.d.ts +9 -0
  62. package/dist/session-watcher.test.d.ts.map +1 -0
  63. package/dist/session-watcher.test.js +149 -0
  64. package/dist/session-watcher.test.js.map +1 -0
  65. package/jest.config.js +8 -0
  66. package/package.json +42 -0
  67. package/src/adapter/claude-cli.ts +591 -0
  68. package/src/adapter/events.ts +186 -0
  69. package/src/client/messages.ts +193 -0
  70. package/src/client/websocket.ts +509 -0
  71. package/src/commands/register.ts +201 -0
  72. package/src/commands/start.ts +70 -0
  73. package/src/commands/status.ts +47 -0
  74. package/src/commands/stop.ts +58 -0
  75. package/src/commands/unregister.ts +30 -0
  76. package/src/config/config.ts +163 -0
  77. package/src/handlers/context-handler.ts +337 -0
  78. package/src/index.ts +45 -0
  79. package/src/message-reader.ts +485 -0
  80. package/src/session-discovery.ts +557 -0
  81. package/src/session-watcher.test.ts +141 -0
  82. package/src/session-watcher.ts +560 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,70 @@
1
+ import {
2
+ readConfig,
3
+ readCredentials,
4
+ isRegistered,
5
+ writePidFile,
6
+ isDaemonRunning
7
+ } from '../config/config';
8
+ import { DaemonClient } from '../client/websocket';
9
+
10
+ interface StartOptions {
11
+ foreground?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Start command - launch the daemon and connect to server
16
+ */
17
+ export async function start(options: StartOptions): Promise<void> {
18
+ // Check registration
19
+ if (!isRegistered()) {
20
+ console.error('Device not registered. Run "cmdctrl-claude-code-daemon register" first.');
21
+ process.exit(1);
22
+ }
23
+
24
+ // Check if already running
25
+ if (isDaemonRunning()) {
26
+ console.error('Daemon is already running. Run "cmdctrl-claude-code-daemon stop" first.');
27
+ process.exit(1);
28
+ }
29
+
30
+ const config = readConfig()!;
31
+ const credentials = readCredentials()!;
32
+
33
+ console.log(`Starting CmdCtrl daemon...`);
34
+ console.log(`Server: ${config.serverUrl}`);
35
+ console.log(`Device: ${config.deviceName} (${config.deviceId})`);
36
+
37
+ // Write PID file
38
+ writePidFile(process.pid);
39
+
40
+ // Create and start client
41
+ const client = new DaemonClient(config, credentials);
42
+
43
+ // Handle shutdown signals
44
+ const shutdown = async () => {
45
+ console.log('\nShutting down...');
46
+ await client.disconnect();
47
+ process.exit(0);
48
+ };
49
+
50
+ process.on('SIGINT', shutdown);
51
+ process.on('SIGTERM', shutdown);
52
+
53
+ // Connect and run
54
+ try {
55
+ await client.connect();
56
+ console.log('Connected to server.');
57
+
58
+ if (options.foreground) {
59
+ console.log('Running in foreground. Press Ctrl+C to stop.\n');
60
+ }
61
+
62
+ // Keep process alive - the WebSocket client handles events
63
+ await new Promise(() => {
64
+ // Never resolves - daemon runs until killed
65
+ });
66
+ } catch (err) {
67
+ console.error('Failed to start daemon:', err);
68
+ process.exit(1);
69
+ }
70
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ readConfig,
3
+ readCredentials,
4
+ isRegistered,
5
+ isDaemonRunning,
6
+ readPidFile
7
+ } from '../config/config';
8
+
9
+ /**
10
+ * Status command - check daemon and connection status
11
+ */
12
+ export async function status(): Promise<void> {
13
+ console.log('CmdCtrl Daemon Status\n');
14
+
15
+ // Registration status
16
+ if (!isRegistered()) {
17
+ console.log('Registration: Not registered');
18
+ console.log('\nRun "cmdctrl-claude-code-daemon register" to register this device.');
19
+ return;
20
+ }
21
+
22
+ const config = readConfig()!;
23
+ const credentials = readCredentials()!;
24
+
25
+ console.log('Registration: Registered');
26
+ console.log(` Server: ${config.serverUrl}`);
27
+ console.log(` Device: ${config.deviceName}`);
28
+ console.log(` Device ID: ${config.deviceId}`);
29
+
30
+ // Token status
31
+ const tokenExpired = credentials.expiresAt < Date.now();
32
+ console.log(`\nToken: ${tokenExpired ? 'Expired' : 'Valid'}`);
33
+ if (!tokenExpired) {
34
+ const expiresIn = Math.round((credentials.expiresAt - Date.now()) / 1000 / 60);
35
+ console.log(` Expires in: ${expiresIn} minutes`);
36
+ }
37
+
38
+ // Daemon status
39
+ const running = isDaemonRunning();
40
+ console.log(`\nDaemon: ${running ? 'Running' : 'Stopped'}`);
41
+ if (running) {
42
+ const pid = readPidFile();
43
+ console.log(` PID: ${pid}`);
44
+ }
45
+
46
+ // TODO: Query daemon for actual connection status via local socket or HTTP
47
+ }
@@ -0,0 +1,58 @@
1
+ import { isDaemonRunning, readPidFile, deletePidFile } from '../config/config';
2
+
3
+ /**
4
+ * Stop command - stop the running daemon
5
+ */
6
+ export async function stop(): Promise<void> {
7
+ if (!isDaemonRunning()) {
8
+ console.log('Daemon is not running.');
9
+ return;
10
+ }
11
+
12
+ const pid = readPidFile();
13
+ if (pid === null) {
14
+ console.log('No PID file found.');
15
+ return;
16
+ }
17
+
18
+ console.log(`Stopping daemon (PID ${pid})...`);
19
+
20
+ try {
21
+ // Send SIGTERM for graceful shutdown
22
+ process.kill(pid, 'SIGTERM');
23
+
24
+ // Wait for process to exit (up to 5 seconds)
25
+ let attempts = 0;
26
+ while (attempts < 50) {
27
+ await new Promise((resolve) => setTimeout(resolve, 100));
28
+ try {
29
+ process.kill(pid, 0); // Check if still running
30
+ attempts++;
31
+ } catch {
32
+ // Process has exited
33
+ break;
34
+ }
35
+ }
36
+
37
+ // If still running after 5 seconds, force kill
38
+ try {
39
+ process.kill(pid, 0);
40
+ console.log('Daemon did not stop gracefully, sending SIGKILL...');
41
+ process.kill(pid, 'SIGKILL');
42
+ } catch {
43
+ // Already dead, good
44
+ }
45
+
46
+ deletePidFile();
47
+ console.log('Daemon stopped.');
48
+ } catch (err) {
49
+ if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
50
+ // Process doesn't exist
51
+ deletePidFile();
52
+ console.log('Daemon was not running (stale PID file removed).');
53
+ } else {
54
+ console.error('Failed to stop daemon:', err);
55
+ process.exit(1);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,30 @@
1
+ import { readConfig, clearRegistration, isDaemonRunning } from '../config/config';
2
+
3
+ /**
4
+ * Unregister command - removes local registration data
5
+ */
6
+ export async function unregister(): Promise<void> {
7
+ const config = readConfig();
8
+
9
+ if (!config) {
10
+ console.log('Not registered.');
11
+ return;
12
+ }
13
+
14
+ // Check if daemon is running
15
+ if (isDaemonRunning()) {
16
+ console.error('Error: Daemon is currently running.');
17
+ console.error('Please stop the daemon first with: cmdctrl-claude-code stop');
18
+ process.exit(1);
19
+ }
20
+
21
+ console.log(`Unregistering device "${config.deviceName}" (${config.deviceId})...`);
22
+ console.log(`Server: ${config.serverUrl}`);
23
+
24
+ // Clear local registration data
25
+ clearRegistration();
26
+
27
+ console.log('\nLocal registration data cleared.');
28
+ console.log('Note: The device entry may still exist on the server.');
29
+ console.log('You can now register again with: cmdctrl-claude-code register -s <server-url>');
30
+ }
@@ -0,0 +1,163 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ export interface CmdCtrlConfig {
6
+ serverUrl: string;
7
+ deviceId: string;
8
+ deviceName: string;
9
+ }
10
+
11
+ export interface Credentials {
12
+ accessToken: string;
13
+ refreshToken: string;
14
+ expiresAt: number; // Unix timestamp
15
+ }
16
+
17
+ const CONFIG_DIR = path.join(os.homedir(), '.cmdctrl-claude-code');
18
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
19
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials');
20
+ const PID_FILE = path.join(CONFIG_DIR, 'daemon.pid');
21
+
22
+ /**
23
+ * Ensure the config directory exists with proper permissions
24
+ */
25
+ export function ensureConfigDir(): void {
26
+ if (!fs.existsSync(CONFIG_DIR)) {
27
+ fs.mkdirSync(CONFIG_DIR, { mode: 0o700 });
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Read the config file
33
+ */
34
+ export function readConfig(): CmdCtrlConfig | null {
35
+ try {
36
+ if (!fs.existsSync(CONFIG_FILE)) {
37
+ return null;
38
+ }
39
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
40
+ return JSON.parse(content) as CmdCtrlConfig;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Write the config file
48
+ */
49
+ export function writeConfig(config: CmdCtrlConfig): void {
50
+ ensureConfigDir();
51
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
52
+ }
53
+
54
+ /**
55
+ * Read credentials (access/refresh tokens)
56
+ */
57
+ export function readCredentials(): Credentials | null {
58
+ try {
59
+ if (!fs.existsSync(CREDENTIALS_FILE)) {
60
+ return null;
61
+ }
62
+ const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
63
+ return JSON.parse(content) as Credentials;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Write credentials with restrictive permissions (600)
71
+ */
72
+ export function writeCredentials(creds: Credentials): void {
73
+ ensureConfigDir();
74
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
75
+ }
76
+
77
+ /**
78
+ * Delete credentials (for logout/revoke)
79
+ */
80
+ export function deleteCredentials(): void {
81
+ if (fs.existsSync(CREDENTIALS_FILE)) {
82
+ fs.unlinkSync(CREDENTIALS_FILE);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Delete config file
88
+ */
89
+ export function deleteConfig(): void {
90
+ if (fs.existsSync(CONFIG_FILE)) {
91
+ fs.unlinkSync(CONFIG_FILE);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Clear all registration data (config and credentials)
97
+ */
98
+ export function clearRegistration(): void {
99
+ deleteConfig();
100
+ deleteCredentials();
101
+ }
102
+
103
+ /**
104
+ * Check if device is registered
105
+ */
106
+ export function isRegistered(): boolean {
107
+ const config = readConfig();
108
+ const creds = readCredentials();
109
+ return config !== null && creds !== null && config.deviceId !== '';
110
+ }
111
+
112
+ /**
113
+ * Write daemon PID file
114
+ */
115
+ export function writePidFile(pid: number): void {
116
+ ensureConfigDir();
117
+ fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
118
+ }
119
+
120
+ /**
121
+ * Read daemon PID
122
+ */
123
+ export function readPidFile(): number | null {
124
+ try {
125
+ if (!fs.existsSync(PID_FILE)) {
126
+ return null;
127
+ }
128
+ const content = fs.readFileSync(PID_FILE, 'utf-8');
129
+ return parseInt(content, 10);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Delete PID file
137
+ */
138
+ export function deletePidFile(): void {
139
+ if (fs.existsSync(PID_FILE)) {
140
+ fs.unlinkSync(PID_FILE);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Check if daemon is running
146
+ */
147
+ export function isDaemonRunning(): boolean {
148
+ const pid = readPidFile();
149
+ if (pid === null) {
150
+ return false;
151
+ }
152
+ try {
153
+ // Signal 0 doesn't kill, just checks if process exists
154
+ process.kill(pid, 0);
155
+ return true;
156
+ } catch {
157
+ // Process doesn't exist, clean up stale PID file
158
+ deletePidFile();
159
+ return false;
160
+ }
161
+ }
162
+
163
+ export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE, PID_FILE };
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Context handler for extracting session context for dashboard summaries
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { findSessionFile } from '../message-reader';
8
+ import { SessionStatus, ContextResponseMessage } from '../client/messages';
9
+
10
+ interface JournalEntry {
11
+ type: string;
12
+ uuid?: string;
13
+ sessionId?: string;
14
+ timestamp?: string;
15
+ cwd?: string;
16
+ message?: {
17
+ role?: string;
18
+ content?: unknown;
19
+ stop_reason?: string;
20
+ };
21
+ // Tool use entries have different structure
22
+ name?: string; // tool name for tool_use entries
23
+ input?: unknown; // tool input
24
+ }
25
+
26
+ interface SessionContext {
27
+ title: string;
28
+ projectPath: string;
29
+ initialPrompt?: string;
30
+ recentMessages: Array<{ role: 'USER' | 'AGENT'; content: string }>;
31
+ lastToolUse?: string;
32
+ messageCount: number;
33
+ startedAt?: string;
34
+ lastActivityAt: string;
35
+ status: SessionStatus;
36
+ statusDetail?: string;
37
+ }
38
+
39
+ /**
40
+ * Extract readable text from message content (handles string or array of content blocks)
41
+ */
42
+ function extractReadableText(content: unknown): string {
43
+ if (typeof content === 'string') {
44
+ return content.trim();
45
+ }
46
+
47
+ if (Array.isArray(content)) {
48
+ const textParts: string[] = [];
49
+ for (const block of content) {
50
+ if (typeof block === 'string') {
51
+ textParts.push(block);
52
+ } else if (block && typeof block === 'object') {
53
+ if (block.type === 'text' && typeof block.text === 'string') {
54
+ // Strip thinking tags
55
+ const text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
56
+ if (text) {
57
+ textParts.push(text);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ return textParts.join(' ').trim();
63
+ }
64
+
65
+ if (content && typeof content === 'object' && 'text' in content) {
66
+ const text = (content as { text: unknown }).text;
67
+ if (typeof text === 'string') {
68
+ return text.trim();
69
+ }
70
+ }
71
+
72
+ return '';
73
+ }
74
+
75
+ /**
76
+ * Detect if a message contains a question (agent asking user for input)
77
+ */
78
+ function isQuestionToUser(content: string): { isQuestion: boolean; questionText?: string } {
79
+ // Common question patterns
80
+ const questionPatterns = [
81
+ /\?$/m, // Ends with question mark
82
+ /^(should i|would you|do you|can you|shall i|which|what|how|where|when)/im,
83
+ /please (confirm|specify|provide|let me know|clarify)/i,
84
+ /waiting for (your|user) (input|response|confirmation)/i,
85
+ ];
86
+
87
+ for (const pattern of questionPatterns) {
88
+ if (pattern.test(content)) {
89
+ // Extract the first sentence/line that looks like a question
90
+ const lines = content.split('\n').filter(l => l.trim());
91
+ for (const line of lines) {
92
+ if (/\?$/.test(line.trim())) {
93
+ return { isQuestion: true, questionText: line.trim().slice(0, 100) };
94
+ }
95
+ }
96
+ return { isQuestion: true, questionText: content.slice(0, 100) };
97
+ }
98
+ }
99
+
100
+ return { isQuestion: false };
101
+ }
102
+
103
+ /**
104
+ * Extract context from a session JSONL file
105
+ */
106
+ export function extractSessionContext(
107
+ sessionId: string,
108
+ options: {
109
+ includeInitialPrompt?: boolean;
110
+ recentMessagesCount?: number;
111
+ includeLastToolUse?: boolean;
112
+ } = {}
113
+ ): SessionContext | null {
114
+ const {
115
+ includeInitialPrompt = true,
116
+ recentMessagesCount = 10,
117
+ includeLastToolUse = true,
118
+ } = options;
119
+
120
+ const filePath = findSessionFile(sessionId);
121
+ if (!filePath) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const stat = fs.statSync(filePath);
127
+ const content = fs.readFileSync(filePath, 'utf-8');
128
+ const lines = content.split('\n').filter(l => l.trim());
129
+
130
+ let title = '';
131
+ let projectPath = '';
132
+ let initialPrompt: string | undefined;
133
+ let lastToolUse: string | undefined;
134
+ let startedAt: string | undefined;
135
+ let lastActivityAt = stat.mtime.toISOString();
136
+ let status: SessionStatus = 'working';
137
+ let statusDetail: string | undefined;
138
+
139
+ const allMessages: Array<{ role: 'USER' | 'AGENT'; content: string; timestamp?: string }> = [];
140
+ let messageCount = 0;
141
+ let lastEntry: JournalEntry | null = null;
142
+ let lastAssistantContent = '';
143
+
144
+ for (const line of lines) {
145
+ try {
146
+ const entry: JournalEntry = JSON.parse(line);
147
+ lastEntry = entry;
148
+
149
+ // Extract metadata
150
+ if (entry.cwd && !projectPath) {
151
+ projectPath = entry.cwd;
152
+ }
153
+
154
+ if (entry.timestamp && !startedAt) {
155
+ startedAt = entry.timestamp;
156
+ }
157
+
158
+ if (entry.timestamp) {
159
+ lastActivityAt = entry.timestamp;
160
+ }
161
+
162
+ // Count and extract messages
163
+ if (entry.type === 'user' || entry.type === 'assistant') {
164
+ messageCount++;
165
+
166
+ const text = extractReadableText(entry.message?.content);
167
+ if (text) {
168
+ const role: 'USER' | 'AGENT' = entry.type === 'user' ? 'USER' : 'AGENT';
169
+ allMessages.push({ role, content: text, timestamp: entry.timestamp });
170
+
171
+ // Track first user message for initial prompt
172
+ if (entry.type === 'user' && !initialPrompt && includeInitialPrompt) {
173
+ initialPrompt = text;
174
+ // Generate title from first user message
175
+ const firstLine = text.split('\n')[0].trim();
176
+ title = firstLine.length > 50 ? firstLine.slice(0, 50) + '...' : firstLine;
177
+ }
178
+
179
+ // Track last assistant content for status detection
180
+ if (entry.type === 'assistant') {
181
+ lastAssistantContent = text;
182
+ }
183
+ }
184
+ }
185
+
186
+ // Track tool use
187
+ if (entry.type === 'tool_use' && includeLastToolUse && entry.name) {
188
+ // Format: "Read file: src/main.ts" or "Edit: src/main.ts"
189
+ let toolDesc = entry.name;
190
+ if (entry.input && typeof entry.input === 'object') {
191
+ const input = entry.input as Record<string, unknown>;
192
+ if (input.file_path) {
193
+ toolDesc = `${entry.name}: ${input.file_path}`;
194
+ } else if (input.path) {
195
+ toolDesc = `${entry.name}: ${input.path}`;
196
+ } else if (input.command) {
197
+ const cmd = String(input.command).slice(0, 50);
198
+ toolDesc = `${entry.name}: ${cmd}`;
199
+ }
200
+ }
201
+ lastToolUse = toolDesc;
202
+ }
203
+
204
+ // Also check for tool_use blocks within assistant messages
205
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
206
+ for (const block of entry.message.content as Array<{ type: string; name?: string; input?: unknown }>) {
207
+ if (block.type === 'tool_use' && block.name && includeLastToolUse) {
208
+ let toolDesc = block.name;
209
+ if (block.input && typeof block.input === 'object') {
210
+ const input = block.input as Record<string, unknown>;
211
+ if (input.file_path) {
212
+ toolDesc = `${block.name}: ${input.file_path}`;
213
+ } else if (input.path) {
214
+ toolDesc = `${block.name}: ${input.path}`;
215
+ } else if (input.command) {
216
+ const cmd = String(input.command).slice(0, 50);
217
+ toolDesc = `${block.name}: ${cmd}`;
218
+ }
219
+ }
220
+ lastToolUse = toolDesc;
221
+ }
222
+ }
223
+ }
224
+
225
+ } catch {
226
+ // Skip invalid JSON lines
227
+ continue;
228
+ }
229
+ }
230
+
231
+ // Determine status based on last entry
232
+ if (lastEntry) {
233
+ // Check if there's an error event
234
+ if (lastEntry.type === 'error') {
235
+ status = 'errored';
236
+ statusDetail = extractReadableText(lastEntry.message?.content) || 'Error occurred';
237
+ }
238
+ // Check if last message is from assistant with a question
239
+ else if (lastEntry.type === 'assistant' && lastAssistantContent) {
240
+ const { isQuestion, questionText } = isQuestionToUser(lastAssistantContent);
241
+ if (isQuestion) {
242
+ status = 'waiting_for_input';
243
+ statusDetail = questionText ? `Asked: ${questionText}` : 'Waiting for user input';
244
+ } else {
245
+ // Assistant responded without asking - could be completed or still working
246
+ const stopReason = lastEntry.message?.stop_reason;
247
+ if (stopReason === 'end_turn' || stopReason === null) {
248
+ // Check if it's a completion or still working based on tool use
249
+ const hasToolUse = Array.isArray(lastEntry.message?.content) &&
250
+ (lastEntry.message.content as Array<{ type: string }>).some(b => b.type === 'tool_use');
251
+ if (!hasToolUse && lastAssistantContent.length > 20) {
252
+ status = 'completed';
253
+ statusDetail = lastAssistantContent.slice(0, 100);
254
+ }
255
+ }
256
+ }
257
+ }
258
+ // Check for stale sessions (no activity in 30+ minutes)
259
+ const timeSinceActivity = Date.now() - stat.mtime.getTime();
260
+ if (timeSinceActivity > 30 * 60 * 1000 && status === 'working') {
261
+ status = 'stale';
262
+ }
263
+ }
264
+
265
+ // Get recent messages
266
+ const recentMessages = allMessages.slice(-recentMessagesCount).map(m => ({
267
+ role: m.role,
268
+ content: m.content.length > 500 ? m.content.slice(0, 500) + '...' : m.content,
269
+ }));
270
+
271
+ return {
272
+ title: title || sessionId.slice(0, 8),
273
+ projectPath: projectPath || path.dirname(filePath),
274
+ initialPrompt: includeInitialPrompt ? initialPrompt : undefined,
275
+ recentMessages,
276
+ lastToolUse: includeLastToolUse ? lastToolUse : undefined,
277
+ messageCount,
278
+ startedAt,
279
+ lastActivityAt,
280
+ status,
281
+ statusDetail,
282
+ };
283
+
284
+ } catch (err) {
285
+ console.error(`[ContextHandler] Failed to extract context for session ${sessionId}:`, err);
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Build a context response message
292
+ */
293
+ export function buildContextResponse(
294
+ requestId: string,
295
+ sessionId: string,
296
+ options: {
297
+ includeInitialPrompt?: boolean;
298
+ recentMessagesCount?: number;
299
+ includeLastToolUse?: boolean;
300
+ } = {}
301
+ ): ContextResponseMessage {
302
+ const context = extractSessionContext(sessionId, options);
303
+
304
+ if (!context) {
305
+ return {
306
+ type: 'context_response',
307
+ request_id: requestId,
308
+ session_id: sessionId,
309
+ context: {
310
+ title: '',
311
+ project_path: '',
312
+ message_count: 0,
313
+ last_activity_at: new Date().toISOString(),
314
+ status: 'stale',
315
+ },
316
+ error: `Session ${sessionId} not found`,
317
+ };
318
+ }
319
+
320
+ return {
321
+ type: 'context_response',
322
+ request_id: requestId,
323
+ session_id: sessionId,
324
+ context: {
325
+ title: context.title,
326
+ project_path: context.projectPath,
327
+ initial_prompt: context.initialPrompt,
328
+ recent_messages: context.recentMessages,
329
+ last_tool_use: context.lastToolUse,
330
+ message_count: context.messageCount,
331
+ started_at: context.startedAt,
332
+ last_activity_at: context.lastActivityAt,
333
+ status: context.status,
334
+ status_detail: context.statusDetail,
335
+ },
336
+ };
337
+ }