@aion0/forge 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/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getDb } from '@/src/core/db/database';
|
|
3
|
+
import { getDbPath, loadTemplate } from '@/src/config';
|
|
4
|
+
import { chatStream, type ChatResult } from '@/src/core/providers/chat';
|
|
5
|
+
import { getMemoryMessages } from '@/src/core/memory/strategy';
|
|
6
|
+
import type { Session, SessionStatus, Message, ProviderName, MemoryConfig } from '@/src/types';
|
|
7
|
+
|
|
8
|
+
// Re-export the SessionManager but as a singleton for server-side use
|
|
9
|
+
class SessionManager {
|
|
10
|
+
private db;
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
this.db = getDb(getDbPath());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
create(opts: { name: string; templateId: string; provider?: ProviderName; model?: string }): Session {
|
|
17
|
+
const template = loadTemplate(opts.templateId);
|
|
18
|
+
if (!template) throw new Error(`Template not found: ${opts.templateId}`);
|
|
19
|
+
|
|
20
|
+
const id = randomUUID().slice(0, 8);
|
|
21
|
+
const provider = opts.provider || template.provider;
|
|
22
|
+
const model = opts.model || template.model || '';
|
|
23
|
+
const memoryConfig = JSON.stringify(template.memory);
|
|
24
|
+
|
|
25
|
+
this.db.prepare(`
|
|
26
|
+
INSERT INTO sessions (id, name, template_id, provider, model, status, memory_config, system_prompt)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?, 'idle', ?, ?)
|
|
28
|
+
`).run(id, opts.name, opts.templateId, provider, model, memoryConfig, template.systemPrompt);
|
|
29
|
+
|
|
30
|
+
return this.get(id)!;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get(id: string): Session | null {
|
|
34
|
+
const row = this.db.prepare(`
|
|
35
|
+
SELECT s.*, COUNT(m.id) as message_count,
|
|
36
|
+
(SELECT content FROM messages WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1) as last_message
|
|
37
|
+
FROM sessions s
|
|
38
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
39
|
+
WHERE s.id = ?
|
|
40
|
+
GROUP BY s.id
|
|
41
|
+
`).get(id) as any;
|
|
42
|
+
|
|
43
|
+
if (!row) return null;
|
|
44
|
+
return this.rowToSession(row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getByName(name: string): Session | null {
|
|
48
|
+
const row = this.db.prepare(`
|
|
49
|
+
SELECT s.*, COUNT(m.id) as message_count,
|
|
50
|
+
(SELECT content FROM messages WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1) as last_message
|
|
51
|
+
FROM sessions s
|
|
52
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
53
|
+
WHERE s.name = ?
|
|
54
|
+
GROUP BY s.id
|
|
55
|
+
`).get(name) as any;
|
|
56
|
+
|
|
57
|
+
if (!row) return null;
|
|
58
|
+
return this.rowToSession(row);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(status?: SessionStatus): Session[] {
|
|
62
|
+
let query = `
|
|
63
|
+
SELECT s.*, COUNT(m.id) as message_count,
|
|
64
|
+
(SELECT content FROM messages WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1) as last_message
|
|
65
|
+
FROM sessions s
|
|
66
|
+
LEFT JOIN messages m ON m.session_id = s.id
|
|
67
|
+
`;
|
|
68
|
+
const params: string[] = [];
|
|
69
|
+
if (status) {
|
|
70
|
+
query += ' WHERE s.status = ?';
|
|
71
|
+
params.push(status);
|
|
72
|
+
}
|
|
73
|
+
query += ' GROUP BY s.id ORDER BY s.updated_at DESC';
|
|
74
|
+
|
|
75
|
+
const rows = this.db.prepare(query).all(...params) as any[];
|
|
76
|
+
return rows.map(r => this.rowToSession(r));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
updateStatus(id: string, status: SessionStatus) {
|
|
80
|
+
this.db.prepare(`UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
addMessage(sessionId: string, role: string, content: string, provider: string, model: string) {
|
|
84
|
+
this.db.prepare(`
|
|
85
|
+
INSERT INTO messages (session_id, role, content, provider, model)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?)
|
|
87
|
+
`).run(sessionId, role, content, provider, model);
|
|
88
|
+
this.db.prepare(`UPDATE sessions SET updated_at = datetime('now') WHERE id = ?`).run(sessionId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getMessages(sessionId: string, limit?: number): Message[] {
|
|
92
|
+
let query = 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC';
|
|
93
|
+
const params: (string | number)[] = [sessionId];
|
|
94
|
+
if (limit) {
|
|
95
|
+
query = `SELECT * FROM (
|
|
96
|
+
SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?
|
|
97
|
+
) ORDER BY created_at ASC`;
|
|
98
|
+
params.push(limit);
|
|
99
|
+
}
|
|
100
|
+
return this.db.prepare(query).all(...params) as Message[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getMemoryMessages(sessionId: string): Message[] {
|
|
104
|
+
const session = this.get(sessionId);
|
|
105
|
+
if (!session) return [];
|
|
106
|
+
const allMessages = this.getMessages(sessionId);
|
|
107
|
+
return getMemoryMessages(allMessages, session.memory);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
delete(id: string) {
|
|
111
|
+
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
recordUsage(sessionId: string, result: ChatResult) {
|
|
115
|
+
this.db.prepare(`
|
|
116
|
+
INSERT INTO usage (provider, model, session_id, input_tokens, output_tokens, cost)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
118
|
+
`).run(result.provider, result.model, sessionId, result.inputTokens, result.outputTokens, 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getUsageSummary() {
|
|
122
|
+
return this.db.prepare(`
|
|
123
|
+
SELECT provider,
|
|
124
|
+
SUM(input_tokens) as totalInput,
|
|
125
|
+
SUM(output_tokens) as totalOutput,
|
|
126
|
+
SUM(cost) as totalCost
|
|
127
|
+
FROM usage
|
|
128
|
+
WHERE created_at >= date('now', '-30 days')
|
|
129
|
+
GROUP BY provider
|
|
130
|
+
`).all() as any[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private rowToSession(row: any): Session {
|
|
134
|
+
return {
|
|
135
|
+
id: row.id,
|
|
136
|
+
name: row.name,
|
|
137
|
+
templateId: row.template_id,
|
|
138
|
+
provider: row.provider,
|
|
139
|
+
model: row.model,
|
|
140
|
+
status: row.status,
|
|
141
|
+
memory: JSON.parse(row.memory_config),
|
|
142
|
+
systemPrompt: row.system_prompt,
|
|
143
|
+
messageCount: row.message_count || 0,
|
|
144
|
+
createdAt: row.created_at,
|
|
145
|
+
updatedAt: row.updated_at,
|
|
146
|
+
lastMessage: row.last_message || undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Singleton
|
|
152
|
+
let instance: SessionManager | null = null;
|
|
153
|
+
export function getSessionManager(): SessionManager {
|
|
154
|
+
if (!instance) instance = new SessionManager();
|
|
155
|
+
return instance;
|
|
156
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Watcher — monitors Claude CLI sessions and sends Telegram notifications.
|
|
3
|
+
*
|
|
4
|
+
* Watchers track entry counts in sessions. When new entries appear,
|
|
5
|
+
* a summary is sent to Telegram. Also detects idle/completed sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { getDb } from '@/src/core/db/database';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import {
|
|
13
|
+
listClaudeSessions,
|
|
14
|
+
getSessionFilePath,
|
|
15
|
+
readSessionEntries,
|
|
16
|
+
type ClaudeSessionInfo,
|
|
17
|
+
type SessionEntry,
|
|
18
|
+
} from './claude-sessions';
|
|
19
|
+
import { scanProjects } from './projects';
|
|
20
|
+
import { loadSettings } from './settings';
|
|
21
|
+
|
|
22
|
+
const DB_PATH = join(homedir(), '.my-workflow', 'data.db');
|
|
23
|
+
|
|
24
|
+
// ─── Types ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface SessionWatcher {
|
|
27
|
+
id: string;
|
|
28
|
+
projectName: string;
|
|
29
|
+
sessionId: string | null; // null = watch all sessions in project
|
|
30
|
+
label: string | null;
|
|
31
|
+
checkInterval: number; // seconds
|
|
32
|
+
lastEntryCount: number;
|
|
33
|
+
lastChecked: string | null;
|
|
34
|
+
notifyOnChange: boolean;
|
|
35
|
+
notifyOnIdle: boolean;
|
|
36
|
+
idleThreshold: number; // seconds
|
|
37
|
+
active: boolean;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Session cache sync ──────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function syncSessionsToDb(projectName?: string) {
|
|
44
|
+
const db = getDb(DB_PATH);
|
|
45
|
+
const projects = projectName
|
|
46
|
+
? [{ name: projectName }]
|
|
47
|
+
: scanProjects().map((p: { name: string }) => ({ name: p.name }));
|
|
48
|
+
|
|
49
|
+
const upsert = db.prepare(`
|
|
50
|
+
INSERT INTO cached_sessions (project_name, session_id, summary, first_prompt, message_count, created, modified, git_branch, file_size, last_synced)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
52
|
+
ON CONFLICT(project_name, session_id) DO UPDATE SET
|
|
53
|
+
summary = excluded.summary,
|
|
54
|
+
first_prompt = excluded.first_prompt,
|
|
55
|
+
message_count = excluded.message_count,
|
|
56
|
+
created = excluded.created,
|
|
57
|
+
modified = excluded.modified,
|
|
58
|
+
git_branch = excluded.git_branch,
|
|
59
|
+
file_size = excluded.file_size,
|
|
60
|
+
last_synced = datetime('now')
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
const updateEntryCount = db.prepare(`
|
|
64
|
+
UPDATE cached_sessions SET entry_count = ? WHERE project_name = ? AND session_id = ?
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
let totalSynced = 0;
|
|
68
|
+
|
|
69
|
+
for (const proj of projects) {
|
|
70
|
+
const sessions = listClaudeSessions(proj.name);
|
|
71
|
+
for (const s of sessions) {
|
|
72
|
+
upsert.run(
|
|
73
|
+
proj.name, s.sessionId, s.summary || null, s.firstPrompt || null,
|
|
74
|
+
s.messageCount || 0, s.created || null, s.modified || null,
|
|
75
|
+
s.gitBranch || null, s.fileSize,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Count entries for watcher comparison
|
|
79
|
+
const fp = getSessionFilePath(proj.name, s.sessionId);
|
|
80
|
+
if (fp) {
|
|
81
|
+
try {
|
|
82
|
+
const entries = readSessionEntries(fp);
|
|
83
|
+
updateEntryCount.run(entries.length, proj.name, s.sessionId);
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
totalSynced++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return totalSynced;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getCachedSessions(projectName: string): ClaudeSessionInfo[] {
|
|
95
|
+
const db = getDb(DB_PATH);
|
|
96
|
+
const rows = db.prepare(`
|
|
97
|
+
SELECT session_id, summary, first_prompt, message_count, created, modified, git_branch, file_size
|
|
98
|
+
FROM cached_sessions WHERE project_name = ? ORDER BY modified DESC
|
|
99
|
+
`).all(projectName) as any[];
|
|
100
|
+
|
|
101
|
+
return rows.map(r => ({
|
|
102
|
+
sessionId: r.session_id,
|
|
103
|
+
summary: r.summary,
|
|
104
|
+
firstPrompt: r.first_prompt,
|
|
105
|
+
messageCount: r.message_count,
|
|
106
|
+
created: r.created,
|
|
107
|
+
modified: r.modified,
|
|
108
|
+
gitBranch: r.git_branch,
|
|
109
|
+
fileSize: r.file_size,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getAllCachedSessions(): Record<string, ClaudeSessionInfo[]> {
|
|
114
|
+
const db = getDb(DB_PATH);
|
|
115
|
+
const rows = db.prepare(`
|
|
116
|
+
SELECT project_name, session_id, summary, first_prompt, message_count, created, modified, git_branch, file_size
|
|
117
|
+
FROM cached_sessions ORDER BY project_name, modified DESC
|
|
118
|
+
`).all() as any[];
|
|
119
|
+
|
|
120
|
+
const result: Record<string, ClaudeSessionInfo[]> = {};
|
|
121
|
+
for (const r of rows) {
|
|
122
|
+
if (!result[r.project_name]) result[r.project_name] = [];
|
|
123
|
+
result[r.project_name].push({
|
|
124
|
+
sessionId: r.session_id,
|
|
125
|
+
summary: r.summary,
|
|
126
|
+
firstPrompt: r.first_prompt,
|
|
127
|
+
messageCount: r.message_count,
|
|
128
|
+
created: r.created,
|
|
129
|
+
modified: r.modified,
|
|
130
|
+
gitBranch: r.git_branch,
|
|
131
|
+
fileSize: r.file_size,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Watcher CRUD ────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export function createWatcher(opts: {
|
|
140
|
+
projectName: string;
|
|
141
|
+
sessionId?: string;
|
|
142
|
+
label?: string;
|
|
143
|
+
checkInterval?: number;
|
|
144
|
+
}): SessionWatcher {
|
|
145
|
+
const db = getDb(DB_PATH);
|
|
146
|
+
const id = randomUUID().slice(0, 8);
|
|
147
|
+
|
|
148
|
+
db.prepare(`
|
|
149
|
+
INSERT INTO session_watchers (id, project_name, session_id, label, check_interval)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?)
|
|
151
|
+
`).run(id, opts.projectName, opts.sessionId || null, opts.label || null, opts.checkInterval || 60);
|
|
152
|
+
|
|
153
|
+
return getWatcher(id)!;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getWatcher(id: string): SessionWatcher | null {
|
|
157
|
+
const db = getDb(DB_PATH);
|
|
158
|
+
const row = db.prepare('SELECT * FROM session_watchers WHERE id = ?').get(id) as any;
|
|
159
|
+
return row ? mapWatcherRow(row) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function listWatchers(activeOnly = false): SessionWatcher[] {
|
|
163
|
+
const db = getDb(DB_PATH);
|
|
164
|
+
const sql = activeOnly
|
|
165
|
+
? 'SELECT * FROM session_watchers WHERE active = 1 ORDER BY created_at DESC'
|
|
166
|
+
: 'SELECT * FROM session_watchers ORDER BY created_at DESC';
|
|
167
|
+
return (db.prepare(sql).all() as any[]).map(mapWatcherRow);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function toggleWatcher(id: string, active: boolean) {
|
|
171
|
+
const db = getDb(DB_PATH);
|
|
172
|
+
db.prepare('UPDATE session_watchers SET active = ? WHERE id = ?').run(active ? 1 : 0, id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function deleteWatcher(id: string) {
|
|
176
|
+
const db = getDb(DB_PATH);
|
|
177
|
+
db.prepare('DELETE FROM session_watchers WHERE id = ?').run(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mapWatcherRow(row: any): SessionWatcher {
|
|
181
|
+
return {
|
|
182
|
+
id: row.id,
|
|
183
|
+
projectName: row.project_name,
|
|
184
|
+
sessionId: row.session_id,
|
|
185
|
+
label: row.label,
|
|
186
|
+
checkInterval: row.check_interval,
|
|
187
|
+
lastEntryCount: row.last_entry_count,
|
|
188
|
+
lastChecked: row.last_checked,
|
|
189
|
+
notifyOnChange: !!row.notify_on_change,
|
|
190
|
+
notifyOnIdle: !!row.notify_on_idle,
|
|
191
|
+
idleThreshold: row.idle_threshold,
|
|
192
|
+
active: !!row.active,
|
|
193
|
+
createdAt: row.created_at,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Watcher check loop ─────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
let watcherInterval: ReturnType<typeof setInterval> | null = null;
|
|
200
|
+
|
|
201
|
+
export function startWatcherLoop() {
|
|
202
|
+
if (watcherInterval) return;
|
|
203
|
+
|
|
204
|
+
// Initial sync
|
|
205
|
+
try { syncSessionsToDb(); } catch (e) { console.error('[watcher] Initial sync error:', e); }
|
|
206
|
+
|
|
207
|
+
// Check every 30 seconds
|
|
208
|
+
watcherInterval = setInterval(runWatcherCheck, 30_000);
|
|
209
|
+
console.log('[watcher] Started session watcher loop');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function stopWatcherLoop() {
|
|
213
|
+
if (watcherInterval) {
|
|
214
|
+
clearInterval(watcherInterval);
|
|
215
|
+
watcherInterval = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runWatcherCheck() {
|
|
220
|
+
const db = getDb(DB_PATH);
|
|
221
|
+
const watchers = listWatchers(true);
|
|
222
|
+
if (watchers.length === 0) return;
|
|
223
|
+
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
|
|
226
|
+
for (const w of watchers) {
|
|
227
|
+
// Check if it's time
|
|
228
|
+
if (w.lastChecked) {
|
|
229
|
+
const elapsed = (now - new Date(w.lastChecked).getTime()) / 1000;
|
|
230
|
+
if (elapsed < w.checkInterval) continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
if (w.sessionId) {
|
|
235
|
+
// Watch specific session
|
|
236
|
+
await checkSession(db, w, w.projectName, w.sessionId);
|
|
237
|
+
} else {
|
|
238
|
+
// Watch all sessions in project
|
|
239
|
+
const sessions = listClaudeSessions(w.projectName);
|
|
240
|
+
for (const s of sessions) {
|
|
241
|
+
await checkSession(db, w, w.projectName, s.sessionId);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Update last checked
|
|
246
|
+
db.prepare('UPDATE session_watchers SET last_checked = datetime(\'now\') WHERE id = ?').run(w.id);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error(`[watcher] Error checking ${w.id}:`, e);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Periodic session sync (every check cycle)
|
|
253
|
+
try { syncSessionsToDb(); } catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function checkSession(
|
|
257
|
+
db: ReturnType<typeof getDb>,
|
|
258
|
+
watcher: SessionWatcher,
|
|
259
|
+
projectName: string,
|
|
260
|
+
sessionId: string,
|
|
261
|
+
) {
|
|
262
|
+
const fp = getSessionFilePath(projectName, sessionId);
|
|
263
|
+
if (!fp) return;
|
|
264
|
+
|
|
265
|
+
const entries = readSessionEntries(fp);
|
|
266
|
+
const currentCount = entries.length;
|
|
267
|
+
|
|
268
|
+
// Get last known count from cached_sessions
|
|
269
|
+
const cached = db.prepare(
|
|
270
|
+
'SELECT entry_count FROM cached_sessions WHERE project_name = ? AND session_id = ?'
|
|
271
|
+
).get(projectName, sessionId) as any;
|
|
272
|
+
|
|
273
|
+
const lastCount = cached?.entry_count || 0;
|
|
274
|
+
|
|
275
|
+
if (currentCount > lastCount && watcher.notifyOnChange) {
|
|
276
|
+
// New entries! Summarize the changes
|
|
277
|
+
const newEntries = entries.slice(lastCount);
|
|
278
|
+
const summary = summarizeEntries(newEntries);
|
|
279
|
+
const label = watcher.label || `${projectName}/${sessionId.slice(0, 8)}`;
|
|
280
|
+
|
|
281
|
+
await sendWatcherNotification(
|
|
282
|
+
`📋 *${esc(label)}*\n\n` +
|
|
283
|
+
`${summary}\n\n` +
|
|
284
|
+
`_${currentCount} total entries (+${currentCount - lastCount} new)_`
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Update cached entry count
|
|
288
|
+
db.prepare(
|
|
289
|
+
'UPDATE cached_sessions SET entry_count = ? WHERE project_name = ? AND session_id = ?'
|
|
290
|
+
).run(currentCount, projectName, sessionId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function summarizeEntries(entries: SessionEntry[]): string {
|
|
295
|
+
const parts: string[] = [];
|
|
296
|
+
let assistantText = '';
|
|
297
|
+
let toolNames: string[] = [];
|
|
298
|
+
|
|
299
|
+
for (const e of entries) {
|
|
300
|
+
if (e.type === 'user') {
|
|
301
|
+
parts.push(`👤 ${e.content.slice(0, 150)}`);
|
|
302
|
+
} else if (e.type === 'assistant_text') {
|
|
303
|
+
assistantText = e.content.slice(0, 300);
|
|
304
|
+
} else if (e.type === 'tool_use' && e.toolName) {
|
|
305
|
+
toolNames.push(e.toolName);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (toolNames.length > 0) {
|
|
310
|
+
const unique = [...new Set(toolNames)];
|
|
311
|
+
parts.push(`🔧 Tools: ${unique.join(', ')}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (assistantText) {
|
|
315
|
+
parts.push(`🤖 ${assistantText}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return parts.join('\n') || 'Activity detected';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function sendWatcherNotification(text: string) {
|
|
322
|
+
const settings = loadSettings();
|
|
323
|
+
const { telegramBotToken, telegramChatId } = settings;
|
|
324
|
+
if (!telegramBotToken || !telegramChatId) return;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
|
|
328
|
+
await fetch(url, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({
|
|
332
|
+
chat_id: telegramChatId,
|
|
333
|
+
text,
|
|
334
|
+
parse_mode: 'Markdown',
|
|
335
|
+
disable_web_page_preview: true,
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error('[watcher] Telegram send failed:', err);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function esc(s: string): string {
|
|
344
|
+
return s.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&');
|
|
345
|
+
}
|
package/lib/settings.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
const SETTINGS_FILE = join(homedir(), '.my-workflow', 'settings.yaml');
|
|
7
|
+
|
|
8
|
+
export interface Settings {
|
|
9
|
+
projectRoots: string[]; // Multiple project directories
|
|
10
|
+
claudePath: string; // Path to claude binary
|
|
11
|
+
telegramBotToken: string; // Telegram Bot API token
|
|
12
|
+
telegramChatId: string; // Telegram chat ID to send notifications to
|
|
13
|
+
notifyOnComplete: boolean; // Notify when task completes
|
|
14
|
+
notifyOnFailure: boolean; // Notify when task fails
|
|
15
|
+
tunnelAutoStart: boolean; // Auto-start Cloudflare Tunnel on startup
|
|
16
|
+
telegramTunnelPassword: string; // Password for getting login password via Telegram
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaults: Settings = {
|
|
20
|
+
projectRoots: [],
|
|
21
|
+
claudePath: '',
|
|
22
|
+
telegramBotToken: '',
|
|
23
|
+
telegramChatId: '',
|
|
24
|
+
notifyOnComplete: true,
|
|
25
|
+
notifyOnFailure: true,
|
|
26
|
+
tunnelAutoStart: false,
|
|
27
|
+
telegramTunnelPassword: '',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function loadSettings(): Settings {
|
|
31
|
+
if (!existsSync(SETTINGS_FILE)) return { ...defaults };
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(SETTINGS_FILE, 'utf-8');
|
|
34
|
+
return { ...defaults, ...YAML.parse(raw) };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ...defaults };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveSettings(settings: Settings) {
|
|
41
|
+
const dir = dirname(SETTINGS_FILE);
|
|
42
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(SETTINGS_FILE, YAML.stringify(settings), 'utf-8');
|
|
44
|
+
}
|