@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.
- package/dist/adapter/claude-cli.d.ts +41 -0
- package/dist/adapter/claude-cli.d.ts.map +1 -0
- package/dist/adapter/claude-cli.js +525 -0
- package/dist/adapter/claude-cli.js.map +1 -0
- package/dist/adapter/events.d.ts +52 -0
- package/dist/adapter/events.d.ts.map +1 -0
- package/dist/adapter/events.js +134 -0
- package/dist/adapter/events.js.map +1 -0
- package/dist/client/messages.d.ts +140 -0
- package/dist/client/messages.d.ts.map +1 -0
- package/dist/client/messages.js +6 -0
- package/dist/client/messages.js.map +1 -0
- package/dist/client/websocket.d.ts +115 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +434 -0
- package/dist/client/websocket.js.map +1 -0
- package/dist/commands/register.d.ts +10 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +175 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +54 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +38 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +5 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/unregister.d.ts +5 -0
- package/dist/commands/unregister.d.ts.map +1 -0
- package/dist/commands/unregister.js +28 -0
- package/dist/commands/unregister.js.map +1 -0
- package/dist/config/config.d.ts +68 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +193 -0
- package/dist/config/config.js.map +1 -0
- package/dist/handlers/context-handler.d.ts +37 -0
- package/dist/handlers/context-handler.d.ts.map +1 -0
- package/dist/handlers/context-handler.js +303 -0
- package/dist/handlers/context-handler.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/message-reader.d.ts +25 -0
- package/dist/message-reader.d.ts.map +1 -0
- package/dist/message-reader.js +454 -0
- package/dist/message-reader.js.map +1 -0
- package/dist/session-discovery.d.ts +48 -0
- package/dist/session-discovery.d.ts.map +1 -0
- package/dist/session-discovery.js +496 -0
- package/dist/session-discovery.js.map +1 -0
- package/dist/session-watcher.d.ts +92 -0
- package/dist/session-watcher.d.ts.map +1 -0
- package/dist/session-watcher.js +494 -0
- package/dist/session-watcher.js.map +1 -0
- package/dist/session-watcher.test.d.ts +9 -0
- package/dist/session-watcher.test.d.ts.map +1 -0
- package/dist/session-watcher.test.js +149 -0
- package/dist/session-watcher.test.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +42 -0
- package/src/adapter/claude-cli.ts +591 -0
- package/src/adapter/events.ts +186 -0
- package/src/client/messages.ts +193 -0
- package/src/client/websocket.ts +509 -0
- package/src/commands/register.ts +201 -0
- package/src/commands/start.ts +70 -0
- package/src/commands/status.ts +47 -0
- package/src/commands/stop.ts +58 -0
- package/src/commands/unregister.ts +30 -0
- package/src/config/config.ts +163 -0
- package/src/handlers/context-handler.ts +337 -0
- package/src/index.ts +45 -0
- package/src/message-reader.ts +485 -0
- package/src/session-discovery.ts +557 -0
- package/src/session-watcher.test.ts +141 -0
- package/src/session-watcher.ts +560 -0
- 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
|
+
}
|