claude-code-monitor 1.0.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/dist/bin/ccm.d.ts +3 -0
- package/dist/bin/ccm.d.ts.map +1 -0
- package/dist/bin/ccm.js +128 -0
- package/dist/components/Dashboard.d.ts +3 -0
- package/dist/components/Dashboard.d.ts.map +1 -0
- package/dist/components/Dashboard.js +64 -0
- package/dist/components/SessionCard.d.ts +10 -0
- package/dist/components/SessionCard.d.ts.map +1 -0
- package/dist/components/SessionCard.js +16 -0
- package/dist/components/Spinner.d.ts +7 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +39 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +17 -0
- package/dist/hook/handler.d.ts +9 -0
- package/dist/hook/handler.d.ts.map +1 -0
- package/dist/hook/handler.js +62 -0
- package/dist/hooks/useSessions.d.ts +7 -0
- package/dist/hooks/useSessions.d.ts.map +1 -0
- package/dist/hooks/useSessions.js +41 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/setup/index.d.ts +39 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +183 -0
- package/dist/store/file-store.d.ts +17 -0
- package/dist/store/file-store.d.ts.map +1 -0
- package/dist/store/file-store.js +134 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/focus.d.ts +16 -0
- package/dist/utils/focus.d.ts.map +1 -0
- package/dist/utils/focus.js +109 -0
- package/dist/utils/prompt.d.ts +7 -0
- package/dist/utils/prompt.d.ts.map +1 -0
- package/dist/utils/prompt.js +20 -0
- package/dist/utils/status.d.ts +8 -0
- package/dist/utils/status.d.ts.map +1 -0
- package/dist/utils/status.js +10 -0
- package/dist/utils/time.d.ts +5 -0
- package/dist/utils/time.d.ts.map +1 -0
- package/dist/utils/time.js +19 -0
- package/dist/utils/tty-cache.d.ts +12 -0
- package/dist/utils/tty-cache.d.ts.map +1 -0
- package/dist/utils/tty-cache.js +36 -0
- package/package.json +77 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSessions.d.ts","sourceRoot":"","sources":["../../src/hooks/useSessions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAIjD,wBAAgB,WAAW,IAAI;IAC7B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB,CAyCA"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { getSessions, getStorePath } from '../store/file-store.js';
|
|
4
|
+
const REFRESH_INTERVAL_MS = 60_000; // タイムアウト検出のための定期リフレッシュ(chokidarが主で、これはバックアップ)
|
|
5
|
+
export function useSessions() {
|
|
6
|
+
const [sessions, setSessions] = useState([]);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const loadSessions = () => {
|
|
11
|
+
try {
|
|
12
|
+
const data = getSessions();
|
|
13
|
+
setSessions(data);
|
|
14
|
+
setError(null);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
setError(e instanceof Error ? e : new Error('Failed to load sessions'));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
setLoading(false);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
// Initial load
|
|
24
|
+
loadSessions();
|
|
25
|
+
// Watch file changes
|
|
26
|
+
const storePath = getStorePath();
|
|
27
|
+
const watcher = chokidar.watch(storePath, {
|
|
28
|
+
persistent: true,
|
|
29
|
+
ignoreInitial: true,
|
|
30
|
+
});
|
|
31
|
+
watcher.on('change', loadSessions);
|
|
32
|
+
watcher.on('add', loadSessions);
|
|
33
|
+
// Periodic refresh (for timeout detection)
|
|
34
|
+
const interval = setInterval(loadSessions, REFRESH_INTERVAL_MS);
|
|
35
|
+
return () => {
|
|
36
|
+
watcher.close();
|
|
37
|
+
clearInterval(interval);
|
|
38
|
+
};
|
|
39
|
+
}, []);
|
|
40
|
+
return { sessions, loading, error };
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { clearSessions, getSession, getSessions, getStorePath, } from './store/file-store.js';
|
|
2
|
+
export type { HookEvent, HookEventName, Session, SessionStatus, StoreData, } from './types/index.js';
|
|
3
|
+
export { focusSession, getSupportedTerminals, isMacOS } from './utils/focus.js';
|
|
4
|
+
export { getStatusDisplay } from './utils/status.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,aAAa,EACb,UAAU,EACV,WAAW,EACX,YAAY,GACb,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,SAAS,EACT,aAAa,EACb,OAAO,EACP,aAAa,EACb,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhF,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
// Store functions
|
|
3
|
+
export { clearSessions, getSession, getSessions, getStorePath, } from './store/file-store.js';
|
|
4
|
+
export { focusSession, getSupportedTerminals, isMacOS } from './utils/focus.js';
|
|
5
|
+
// Utilities
|
|
6
|
+
export { getStatusDisplay } from './utils/status.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export interface HookConfig {
|
|
3
|
+
type: 'command';
|
|
4
|
+
command: string;
|
|
5
|
+
}
|
|
6
|
+
/** @internal */
|
|
7
|
+
export interface HookEntry {
|
|
8
|
+
matcher?: string;
|
|
9
|
+
hooks: HookConfig[];
|
|
10
|
+
}
|
|
11
|
+
/** @internal */
|
|
12
|
+
export interface Settings {
|
|
13
|
+
hooks?: Record<string, HookEntry[]>;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Check if the ccm hook is already configured for the given event
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export declare function hasCcmHookForEvent(entries: HookEntry[] | undefined, eventName: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Create a hook entry for the given event
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export declare function createHookEntry(eventName: string, baseCommand: string): HookEntry;
|
|
26
|
+
/**
|
|
27
|
+
* Determine which hooks need to be added or skipped
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export declare function categorizeHooks(settings: Settings): {
|
|
31
|
+
toAdd: string[];
|
|
32
|
+
toSkip: string[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Check if hooks are already configured
|
|
36
|
+
*/
|
|
37
|
+
export declare function isHooksConfigured(): boolean;
|
|
38
|
+
export declare function setupHooks(): Promise<void>;
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/setup/index.ts"],"names":[],"mappings":"AAUA,gBAAgB;AAChB,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,gBAAgB;AAChB,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED,gBAAgB;AAChB,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACpC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAUD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAG/F;AAaD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,CAcjF;AAkBD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAazF;AA+CD;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAwB3C;AAED,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAyChD"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { HOOK_EVENTS, PACKAGE_NAME } from '../constants.js';
|
|
6
|
+
import { askConfirmation } from '../utils/prompt.js';
|
|
7
|
+
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
8
|
+
const SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
|
|
9
|
+
/**
|
|
10
|
+
* Check if a command string is a ccm hook command for the given event
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
function isCcmHookCommand(command, eventName) {
|
|
14
|
+
return command === `ccm hook ${eventName}` || command === `npx ${PACKAGE_NAME} hook ${eventName}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Check if the ccm hook is already configured for the given event
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export function hasCcmHookForEvent(entries, eventName) {
|
|
21
|
+
if (!entries)
|
|
22
|
+
return false;
|
|
23
|
+
return entries.some((entry) => entry.hooks.some((h) => isCcmHookCommand(h.command, eventName)));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if ccm command is in PATH and return the appropriate command
|
|
27
|
+
*/
|
|
28
|
+
function getCcmCommand() {
|
|
29
|
+
const result = spawnSync('which', ['ccm'], { encoding: 'utf-8' });
|
|
30
|
+
if (result.status === 0) {
|
|
31
|
+
return 'ccm';
|
|
32
|
+
}
|
|
33
|
+
return `npx ${PACKAGE_NAME}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a hook entry for the given event
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function createHookEntry(eventName, baseCommand) {
|
|
40
|
+
const entry = {
|
|
41
|
+
hooks: [
|
|
42
|
+
{
|
|
43
|
+
type: 'command',
|
|
44
|
+
command: `${baseCommand} hook ${eventName}`,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
// Events other than UserPromptSubmit require a matcher
|
|
49
|
+
if (eventName !== 'UserPromptSubmit') {
|
|
50
|
+
entry.matcher = '';
|
|
51
|
+
}
|
|
52
|
+
return entry;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Load existing settings.json or return empty settings
|
|
56
|
+
*/
|
|
57
|
+
function loadSettings() {
|
|
58
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const content = readFileSync(SETTINGS_FILE, 'utf-8');
|
|
63
|
+
return JSON.parse(content);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
console.error('Warning: Failed to parse existing settings.json, creating new one');
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Determine which hooks need to be added or skipped
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export function categorizeHooks(settings) {
|
|
75
|
+
const toAdd = [];
|
|
76
|
+
const toSkip = [];
|
|
77
|
+
for (const eventName of HOOK_EVENTS) {
|
|
78
|
+
if (hasCcmHookForEvent(settings.hooks?.[eventName], eventName)) {
|
|
79
|
+
toSkip.push(eventName);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
toAdd.push(eventName);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { toAdd, toSkip };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Display setup preview to the user
|
|
89
|
+
*/
|
|
90
|
+
function showSetupPreview(hooksToAdd, hooksToSkip, settingsExist) {
|
|
91
|
+
console.log(`Target file: ${SETTINGS_FILE}`);
|
|
92
|
+
console.log(settingsExist ? '(file exists, will be modified)' : '(file will be created)');
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log('The following hooks will be added:');
|
|
95
|
+
for (const eventName of hooksToAdd) {
|
|
96
|
+
console.log(` [add] ${eventName}`);
|
|
97
|
+
}
|
|
98
|
+
if (hooksToSkip.length > 0) {
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log('Already configured (will be skipped):');
|
|
101
|
+
for (const eventName of hooksToSkip) {
|
|
102
|
+
console.log(` [skip] ${eventName}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply hooks to settings and save to file
|
|
109
|
+
*/
|
|
110
|
+
function applyHooks(settings, hooksToAdd, baseCommand) {
|
|
111
|
+
if (!settings.hooks) {
|
|
112
|
+
settings.hooks = {};
|
|
113
|
+
}
|
|
114
|
+
for (const eventName of hooksToAdd) {
|
|
115
|
+
const existing = settings.hooks[eventName];
|
|
116
|
+
if (!existing) {
|
|
117
|
+
settings.hooks[eventName] = [createHookEntry(eventName, baseCommand)];
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
existing.push(createHookEntry(eventName, baseCommand));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf-8');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Check if hooks are already configured
|
|
127
|
+
*/
|
|
128
|
+
export function isHooksConfigured() {
|
|
129
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const content = readFileSync(SETTINGS_FILE, 'utf-8');
|
|
134
|
+
const settings = JSON.parse(content);
|
|
135
|
+
if (!settings.hooks) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// Check if all hook events are configured
|
|
139
|
+
for (const eventName of HOOK_EVENTS) {
|
|
140
|
+
if (!hasCcmHookForEvent(settings.hooks[eventName], eventName)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function setupHooks() {
|
|
151
|
+
console.log('Claude Code Monitor Setup');
|
|
152
|
+
console.log('=========================');
|
|
153
|
+
console.log('');
|
|
154
|
+
const baseCommand = getCcmCommand();
|
|
155
|
+
console.log(`Using command: ${baseCommand}`);
|
|
156
|
+
console.log('');
|
|
157
|
+
// Ensure .claude directory exists
|
|
158
|
+
if (!existsSync(CLAUDE_DIR)) {
|
|
159
|
+
mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
const settingsExist = existsSync(SETTINGS_FILE);
|
|
162
|
+
const settings = loadSettings();
|
|
163
|
+
const { toAdd: hooksToAdd, toSkip: hooksToSkip } = categorizeHooks(settings);
|
|
164
|
+
// No changes needed
|
|
165
|
+
if (hooksToAdd.length === 0) {
|
|
166
|
+
console.log('All hooks already configured. No changes needed.');
|
|
167
|
+
console.log('');
|
|
168
|
+
console.log(`Start monitoring with: ${baseCommand} watch`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
showSetupPreview(hooksToAdd, hooksToSkip, settingsExist);
|
|
172
|
+
const confirmed = await askConfirmation('Do you want to apply these changes?');
|
|
173
|
+
if (!confirmed) {
|
|
174
|
+
console.log('');
|
|
175
|
+
console.log('Setup cancelled. No changes were made.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
applyHooks(settings, hooksToAdd, baseCommand);
|
|
179
|
+
console.log('');
|
|
180
|
+
console.log(`Setup complete! Added ${hooksToAdd.length} hook(s) to ${SETTINGS_FILE}`);
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(`Start monitoring with: ${baseCommand} watch`);
|
|
183
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HookEvent, Session, SessionStatus, StoreData } from '../types/index.js';
|
|
2
|
+
export { isTtyAlive } from '../utils/tty-cache.js';
|
|
3
|
+
export declare function readStore(): StoreData;
|
|
4
|
+
export declare function writeStore(data: StoreData): void;
|
|
5
|
+
/** @internal */
|
|
6
|
+
export declare function getSessionKey(sessionId: string, tty?: string): string;
|
|
7
|
+
/** @internal */
|
|
8
|
+
export declare function removeOldSessionsOnSameTty(sessions: Record<string, Session>, newSessionId: string, tty: string): void;
|
|
9
|
+
/** @internal */
|
|
10
|
+
export declare function determineStatus(event: HookEvent, currentStatus?: SessionStatus): SessionStatus;
|
|
11
|
+
export declare function updateSession(event: HookEvent): Session;
|
|
12
|
+
export declare function getSessions(): Session[];
|
|
13
|
+
export declare function getSession(sessionId: string, tty?: string): Session | undefined;
|
|
14
|
+
export declare function removeSession(sessionId: string, tty?: string): void;
|
|
15
|
+
export declare function clearSessions(): void;
|
|
16
|
+
export declare function getStorePath(): string;
|
|
17
|
+
//# sourceMappingURL=file-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-store.d.ts","sourceRoot":"","sources":["../../src/store/file-store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAItF,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAkBnD,wBAAgB,SAAS,IAAI,SAAS,CAWrC;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAIhD;AAED,gBAAgB;AAChB,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAErE;AAED,gBAAgB;AAChB,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,YAAY,EAAE,MAAM,EACpB,GAAG,EAAE,MAAM,GACV,IAAI,CAMN;AAED,gBAAgB;AAChB,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,aAAa,CAAC,EAAE,aAAa,GAAG,aAAa,CA8B9F;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAyBvD;AAED,wBAAgB,WAAW,IAAI,OAAO,EAAE,CAwBvC;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAI/E;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAKnE;AAED,wBAAgB,aAAa,IAAI,IAAI,CAEpC;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { SESSION_TIMEOUT_MS } from '../constants.js';
|
|
5
|
+
import { isTtyAlive } from '../utils/tty-cache.js';
|
|
6
|
+
// Re-export for backward compatibility
|
|
7
|
+
export { isTtyAlive } from '../utils/tty-cache.js';
|
|
8
|
+
const STORE_DIR = join(homedir(), '.claude-monitor');
|
|
9
|
+
const STORE_FILE = join(STORE_DIR, 'sessions.json');
|
|
10
|
+
function ensureStoreDir() {
|
|
11
|
+
if (!existsSync(STORE_DIR)) {
|
|
12
|
+
mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function getEmptyStoreData() {
|
|
16
|
+
return {
|
|
17
|
+
sessions: {},
|
|
18
|
+
updated_at: new Date().toISOString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function readStore() {
|
|
22
|
+
ensureStoreDir();
|
|
23
|
+
if (!existsSync(STORE_FILE)) {
|
|
24
|
+
return getEmptyStoreData();
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const content = readFileSync(STORE_FILE, 'utf-8');
|
|
28
|
+
return JSON.parse(content);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return getEmptyStoreData();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function writeStore(data) {
|
|
35
|
+
ensureStoreDir();
|
|
36
|
+
data.updated_at = new Date().toISOString();
|
|
37
|
+
writeFileSync(STORE_FILE, JSON.stringify(data), { encoding: 'utf-8', mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
/** @internal */
|
|
40
|
+
export function getSessionKey(sessionId, tty) {
|
|
41
|
+
return tty ? `${sessionId}:${tty}` : sessionId;
|
|
42
|
+
}
|
|
43
|
+
/** @internal */
|
|
44
|
+
export function removeOldSessionsOnSameTty(sessions, newSessionId, tty) {
|
|
45
|
+
for (const [key, session] of Object.entries(sessions)) {
|
|
46
|
+
if (session.tty === tty && session.session_id !== newSessionId) {
|
|
47
|
+
delete sessions[key];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** @internal */
|
|
52
|
+
export function determineStatus(event, currentStatus) {
|
|
53
|
+
// Explicit stop event
|
|
54
|
+
if (event.hook_event_name === 'Stop') {
|
|
55
|
+
return 'stopped';
|
|
56
|
+
}
|
|
57
|
+
// UserPromptSubmit starts a new operation, so resume even if stopped
|
|
58
|
+
if (event.hook_event_name === 'UserPromptSubmit') {
|
|
59
|
+
return 'running';
|
|
60
|
+
}
|
|
61
|
+
// Keep stopped state (don't resume except for UserPromptSubmit)
|
|
62
|
+
if (currentStatus === 'stopped') {
|
|
63
|
+
return 'stopped';
|
|
64
|
+
}
|
|
65
|
+
// Active operation event
|
|
66
|
+
if (event.hook_event_name === 'PreToolUse') {
|
|
67
|
+
return 'running';
|
|
68
|
+
}
|
|
69
|
+
// Waiting for permission prompt
|
|
70
|
+
const isPermissionPrompt = event.hook_event_name === 'Notification' && event.notification_type === 'permission_prompt';
|
|
71
|
+
if (isPermissionPrompt) {
|
|
72
|
+
return 'waiting_input';
|
|
73
|
+
}
|
|
74
|
+
// Default: running for other events (PostToolUse, etc.)
|
|
75
|
+
return 'running';
|
|
76
|
+
}
|
|
77
|
+
export function updateSession(event) {
|
|
78
|
+
const store = readStore();
|
|
79
|
+
const key = getSessionKey(event.session_id, event.tty);
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
// Remove old session if a different session exists on the same TTY
|
|
82
|
+
// (e.g., when a new session starts after /clear)
|
|
83
|
+
if (event.tty) {
|
|
84
|
+
removeOldSessionsOnSameTty(store.sessions, event.session_id, event.tty);
|
|
85
|
+
}
|
|
86
|
+
const existing = store.sessions[key];
|
|
87
|
+
const session = {
|
|
88
|
+
session_id: event.session_id,
|
|
89
|
+
cwd: event.cwd,
|
|
90
|
+
tty: event.tty ?? existing?.tty,
|
|
91
|
+
status: determineStatus(event, existing?.status),
|
|
92
|
+
created_at: existing?.created_at ?? now,
|
|
93
|
+
updated_at: now,
|
|
94
|
+
};
|
|
95
|
+
store.sessions[key] = session;
|
|
96
|
+
writeStore(store);
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
export function getSessions() {
|
|
100
|
+
const store = readStore();
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
let hasChanges = false;
|
|
103
|
+
for (const [key, session] of Object.entries(store.sessions)) {
|
|
104
|
+
const lastUpdateMs = new Date(session.updated_at).getTime();
|
|
105
|
+
const isSessionActive = now - lastUpdateMs <= SESSION_TIMEOUT_MS;
|
|
106
|
+
const isTtyStillAlive = isTtyAlive(session.tty);
|
|
107
|
+
const shouldRemoveSession = !isSessionActive || !isTtyStillAlive;
|
|
108
|
+
if (shouldRemoveSession) {
|
|
109
|
+
delete store.sessions[key];
|
|
110
|
+
hasChanges = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (hasChanges) {
|
|
114
|
+
writeStore(store);
|
|
115
|
+
}
|
|
116
|
+
return Object.values(store.sessions).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
117
|
+
}
|
|
118
|
+
export function getSession(sessionId, tty) {
|
|
119
|
+
const store = readStore();
|
|
120
|
+
const key = getSessionKey(sessionId, tty);
|
|
121
|
+
return store.sessions[key];
|
|
122
|
+
}
|
|
123
|
+
export function removeSession(sessionId, tty) {
|
|
124
|
+
const store = readStore();
|
|
125
|
+
const key = getSessionKey(sessionId, tty);
|
|
126
|
+
delete store.sessions[key];
|
|
127
|
+
writeStore(store);
|
|
128
|
+
}
|
|
129
|
+
export function clearSessions() {
|
|
130
|
+
writeStore(getEmptyStoreData());
|
|
131
|
+
}
|
|
132
|
+
export function getStorePath() {
|
|
133
|
+
return STORE_FILE;
|
|
134
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type HookEventName = 'PreToolUse' | 'PostToolUse' | 'Notification' | 'Stop' | 'UserPromptSubmit';
|
|
2
|
+
export interface HookEvent {
|
|
3
|
+
session_id: string;
|
|
4
|
+
cwd: string;
|
|
5
|
+
tty?: string;
|
|
6
|
+
hook_event_name: HookEventName;
|
|
7
|
+
notification_type?: string;
|
|
8
|
+
}
|
|
9
|
+
export type SessionStatus = 'running' | 'waiting_input' | 'stopped';
|
|
10
|
+
export interface Session {
|
|
11
|
+
session_id: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
tty?: string;
|
|
14
|
+
status: SessionStatus;
|
|
15
|
+
created_at: string;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StoreData {
|
|
19
|
+
sessions: Record<string, Session>;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,aAAa,GACrB,YAAY,GACZ,aAAa,GACb,cAAc,GACd,MAAM,GACN,kBAAkB,CAAC;AAGvB,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,aAAa,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAGD,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,eAAe,GAAG,SAAS,CAAC;AAGpE,MAAM,WAAW,OAAO;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,aAAa,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAGD,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;CACpB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize a string for safe use in AppleScript.
|
|
3
|
+
* Escapes backslashes, double quotes, and control characters to prevent injection.
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export declare function sanitizeForAppleScript(str: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Validate TTY path format.
|
|
9
|
+
* Only allows paths like /dev/ttys000, /dev/pts/0, etc.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function isValidTtyPath(tty: string): boolean;
|
|
13
|
+
export declare function isMacOS(): boolean;
|
|
14
|
+
export declare function focusSession(tty: string): boolean;
|
|
15
|
+
export declare function getSupportedTerminals(): string[];
|
|
16
|
+
//# sourceMappingURL=focus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus.d.ts","sourceRoot":"","sources":["../../src/utils/focus.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEnD;AA4ED,wBAAgB,OAAO,IAAI,OAAO,CAEjC;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAYjD;AAED,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAEhD"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Sanitize a string for safe use in AppleScript.
|
|
4
|
+
* Escapes backslashes, double quotes, and control characters to prevent injection.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeForAppleScript(str) {
|
|
8
|
+
return str
|
|
9
|
+
.replace(/\\/g, '\\\\')
|
|
10
|
+
.replace(/"/g, '\\"')
|
|
11
|
+
.replace(/\n/g, '\\n')
|
|
12
|
+
.replace(/\r/g, '\\r')
|
|
13
|
+
.replace(/\t/g, '\\t');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate TTY path format.
|
|
17
|
+
* Only allows paths like /dev/ttys000, /dev/pts/0, etc.
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export function isValidTtyPath(tty) {
|
|
21
|
+
return /^\/dev\/(ttys?\d+|pts\/\d+)$/.test(tty);
|
|
22
|
+
}
|
|
23
|
+
function executeAppleScript(script) {
|
|
24
|
+
try {
|
|
25
|
+
const result = execFileSync('osascript', ['-e', script], {
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
}).trim();
|
|
29
|
+
return result === 'true';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function buildITerm2Script(tty) {
|
|
36
|
+
const safeTty = sanitizeForAppleScript(tty);
|
|
37
|
+
return `
|
|
38
|
+
tell application "iTerm2"
|
|
39
|
+
repeat with aWindow in windows
|
|
40
|
+
repeat with aTab in tabs of aWindow
|
|
41
|
+
repeat with aSession in sessions of aTab
|
|
42
|
+
if tty of aSession is "${safeTty}" then
|
|
43
|
+
select aSession
|
|
44
|
+
select aTab
|
|
45
|
+
tell aWindow to select
|
|
46
|
+
activate
|
|
47
|
+
return true
|
|
48
|
+
end if
|
|
49
|
+
end repeat
|
|
50
|
+
end repeat
|
|
51
|
+
end repeat
|
|
52
|
+
return false
|
|
53
|
+
end tell
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
function buildTerminalAppScript(tty) {
|
|
57
|
+
const safeTty = sanitizeForAppleScript(tty);
|
|
58
|
+
return `
|
|
59
|
+
tell application "Terminal"
|
|
60
|
+
repeat with aWindow in windows
|
|
61
|
+
repeat with aTab in tabs of aWindow
|
|
62
|
+
if tty of aTab is "${safeTty}" then
|
|
63
|
+
set selected of aTab to true
|
|
64
|
+
set index of aWindow to 1
|
|
65
|
+
activate
|
|
66
|
+
return true
|
|
67
|
+
end if
|
|
68
|
+
end repeat
|
|
69
|
+
end repeat
|
|
70
|
+
return false
|
|
71
|
+
end tell
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
function buildGhosttyScript() {
|
|
75
|
+
return `
|
|
76
|
+
tell application "Ghostty"
|
|
77
|
+
activate
|
|
78
|
+
end tell
|
|
79
|
+
return true
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
function focusITerm2(tty) {
|
|
83
|
+
return executeAppleScript(buildITerm2Script(tty));
|
|
84
|
+
}
|
|
85
|
+
function focusTerminalApp(tty) {
|
|
86
|
+
return executeAppleScript(buildTerminalAppScript(tty));
|
|
87
|
+
}
|
|
88
|
+
function focusGhostty() {
|
|
89
|
+
return executeAppleScript(buildGhosttyScript());
|
|
90
|
+
}
|
|
91
|
+
export function isMacOS() {
|
|
92
|
+
return process.platform === 'darwin';
|
|
93
|
+
}
|
|
94
|
+
export function focusSession(tty) {
|
|
95
|
+
if (!isMacOS())
|
|
96
|
+
return false;
|
|
97
|
+
if (!isValidTtyPath(tty))
|
|
98
|
+
return false;
|
|
99
|
+
// Try each terminal in order (use the first one that succeeds)
|
|
100
|
+
const focusStrategies = [
|
|
101
|
+
() => focusITerm2(tty),
|
|
102
|
+
() => focusTerminalApp(tty),
|
|
103
|
+
() => focusGhostty(),
|
|
104
|
+
];
|
|
105
|
+
return focusStrategies.some((tryFocus) => tryFocus());
|
|
106
|
+
}
|
|
107
|
+
export function getSupportedTerminals() {
|
|
108
|
+
return ['iTerm2', 'Terminal.app', 'Ghostty'];
|
|
109
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask for user confirmation with Y/n prompt
|
|
3
|
+
* @param message - The message to display
|
|
4
|
+
* @returns true if user confirms (Enter, 'y', or 'yes'), false otherwise
|
|
5
|
+
*/
|
|
6
|
+
export declare function askConfirmation(message: string): Promise<boolean>;
|
|
7
|
+
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/utils/prompt.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAcvE"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
/**
|
|
3
|
+
* Ask for user confirmation with Y/n prompt
|
|
4
|
+
* @param message - The message to display
|
|
5
|
+
* @returns true if user confirms (Enter, 'y', or 'yes'), false otherwise
|
|
6
|
+
*/
|
|
7
|
+
export async function askConfirmation(message) {
|
|
8
|
+
const rl = readline.createInterface({
|
|
9
|
+
input: process.stdin,
|
|
10
|
+
output: process.stdout,
|
|
11
|
+
});
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
rl.question(`${message} [Y/n]: `, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
const normalized = answer.trim().toLowerCase();
|
|
16
|
+
// Accept: Enter only, 'y', or 'yes'
|
|
17
|
+
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SessionStatus } from '../types/index.js';
|
|
2
|
+
export interface StatusDisplay {
|
|
3
|
+
symbol: string;
|
|
4
|
+
color: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function getStatusDisplay(status: SessionStatus): StatusDisplay;
|
|
8
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/utils/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,GAAG,aAAa,CASrE"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function getStatusDisplay(status) {
|
|
2
|
+
switch (status) {
|
|
3
|
+
case 'running':
|
|
4
|
+
return { symbol: '●', color: 'green', label: 'Running' };
|
|
5
|
+
case 'waiting_input':
|
|
6
|
+
return { symbol: '◐', color: 'yellow', label: 'Waiting' };
|
|
7
|
+
case 'stopped':
|
|
8
|
+
return { symbol: '✓', color: 'cyan', label: 'Done' };
|
|
9
|
+
}
|
|
10
|
+
}
|