heyhank 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync } from "fs";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { atomicWriteFileSync } from "../fs-utils.js";
|
|
5
|
+
|
|
6
|
+
export interface TimeEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
task: string;
|
|
9
|
+
project?: string;
|
|
10
|
+
category?: string;
|
|
11
|
+
startTime: string;
|
|
12
|
+
endTime?: string;
|
|
13
|
+
duration?: number; // minutes
|
|
14
|
+
notes?: string;
|
|
15
|
+
source: "manual" | "timer" | "auto"; // auto = derived from calls/emails/etc
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ActiveTimer {
|
|
20
|
+
id: string;
|
|
21
|
+
task: string;
|
|
22
|
+
project?: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
startTime: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TimeReport {
|
|
28
|
+
period: string;
|
|
29
|
+
startDate: string;
|
|
30
|
+
endDate: string;
|
|
31
|
+
totalMinutes: number;
|
|
32
|
+
byProject: Record<string, number>;
|
|
33
|
+
byCategory: Record<string, number>;
|
|
34
|
+
byDay: Record<string, number>;
|
|
35
|
+
entries: TimeEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "time-tracking");
|
|
39
|
+
const ENTRIES_FILE = join(DATA_DIR, "entries.json");
|
|
40
|
+
const TIMER_FILE = join(DATA_DIR, "active-timer.json");
|
|
41
|
+
|
|
42
|
+
function ensureDir() {
|
|
43
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadEntries(): TimeEntry[] {
|
|
47
|
+
ensureDir();
|
|
48
|
+
if (!existsSync(ENTRIES_FILE)) return [];
|
|
49
|
+
try { return JSON.parse(readFileSync(ENTRIES_FILE, "utf-8")); }
|
|
50
|
+
catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveEntries(entries: TimeEntry[]) {
|
|
54
|
+
ensureDir();
|
|
55
|
+
atomicWriteFileSync(ENTRIES_FILE, JSON.stringify(entries, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadTimer(): ActiveTimer | null {
|
|
59
|
+
ensureDir();
|
|
60
|
+
if (!existsSync(TIMER_FILE)) return null;
|
|
61
|
+
try { return JSON.parse(readFileSync(TIMER_FILE, "utf-8")); }
|
|
62
|
+
catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveTimer(timer: ActiveTimer | null) {
|
|
66
|
+
ensureDir();
|
|
67
|
+
if (timer) {
|
|
68
|
+
atomicWriteFileSync(TIMER_FILE, JSON.stringify(timer, null, 2));
|
|
69
|
+
} else if (existsSync(TIMER_FILE)) {
|
|
70
|
+
atomicWriteFileSync(TIMER_FILE, "null");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Timer
|
|
75
|
+
export function startTimer(task: string, project?: string, category?: string): ActiveTimer {
|
|
76
|
+
// Stop existing timer first
|
|
77
|
+
const existing = loadTimer();
|
|
78
|
+
if (existing) stopTimer();
|
|
79
|
+
|
|
80
|
+
const timer: ActiveTimer = {
|
|
81
|
+
id: randomUUID(),
|
|
82
|
+
task,
|
|
83
|
+
project,
|
|
84
|
+
category,
|
|
85
|
+
startTime: new Date().toISOString()
|
|
86
|
+
};
|
|
87
|
+
saveTimer(timer);
|
|
88
|
+
return timer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getActiveTimer(): ActiveTimer | null {
|
|
92
|
+
return loadTimer();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function stopTimer(notes?: string): TimeEntry | null {
|
|
96
|
+
const timer = loadTimer();
|
|
97
|
+
if (!timer) return null;
|
|
98
|
+
|
|
99
|
+
const endTime = new Date().toISOString();
|
|
100
|
+
const duration = Math.round((new Date(endTime).getTime() - new Date(timer.startTime).getTime()) / 60000);
|
|
101
|
+
|
|
102
|
+
const entry: TimeEntry = {
|
|
103
|
+
id: timer.id,
|
|
104
|
+
task: timer.task,
|
|
105
|
+
project: timer.project,
|
|
106
|
+
category: timer.category,
|
|
107
|
+
startTime: timer.startTime,
|
|
108
|
+
endTime,
|
|
109
|
+
duration,
|
|
110
|
+
notes,
|
|
111
|
+
source: "timer",
|
|
112
|
+
createdAt: new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const entries = loadEntries();
|
|
116
|
+
entries.unshift(entry);
|
|
117
|
+
saveEntries(entries);
|
|
118
|
+
saveTimer(null);
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Manual entries
|
|
123
|
+
export function logTime(task: string, duration: number, project?: string, category?: string, notes?: string, date?: string): TimeEntry {
|
|
124
|
+
const entries = loadEntries();
|
|
125
|
+
const startTime = date || new Date().toISOString();
|
|
126
|
+
const entry: TimeEntry = {
|
|
127
|
+
id: randomUUID(),
|
|
128
|
+
task,
|
|
129
|
+
project,
|
|
130
|
+
category,
|
|
131
|
+
startTime,
|
|
132
|
+
duration,
|
|
133
|
+
notes,
|
|
134
|
+
source: "manual",
|
|
135
|
+
createdAt: new Date().toISOString()
|
|
136
|
+
};
|
|
137
|
+
entries.unshift(entry);
|
|
138
|
+
saveEntries(entries);
|
|
139
|
+
return entry;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function listEntries(startDate?: string, endDate?: string, project?: string): TimeEntry[] {
|
|
143
|
+
let entries = loadEntries();
|
|
144
|
+
if (startDate) entries = entries.filter(e => e.startTime >= startDate);
|
|
145
|
+
if (endDate) entries = entries.filter(e => e.startTime <= endDate);
|
|
146
|
+
if (project) entries = entries.filter(e => e.project === project);
|
|
147
|
+
return entries;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function deleteEntry(id: string): boolean {
|
|
151
|
+
const entries = loadEntries();
|
|
152
|
+
const idx = entries.findIndex(e => e.id === id);
|
|
153
|
+
if (idx === -1) return false;
|
|
154
|
+
entries.splice(idx, 1);
|
|
155
|
+
saveEntries(entries);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function updateEntry(id: string, patch: Partial<Pick<TimeEntry, "task" | "project" | "category" | "duration" | "notes">>): TimeEntry | null {
|
|
160
|
+
const entries = loadEntries();
|
|
161
|
+
const idx = entries.findIndex(e => e.id === id);
|
|
162
|
+
if (idx === -1) return null;
|
|
163
|
+
Object.assign(entries[idx], patch);
|
|
164
|
+
saveEntries(entries);
|
|
165
|
+
return entries[idx];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Reports
|
|
169
|
+
export function getReport(period: "today" | "week" | "month" | "custom", startDate?: string, endDate?: string): TimeReport {
|
|
170
|
+
const now = new Date();
|
|
171
|
+
let start: Date;
|
|
172
|
+
let end: Date = now;
|
|
173
|
+
|
|
174
|
+
switch (period) {
|
|
175
|
+
case "today":
|
|
176
|
+
start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
177
|
+
break;
|
|
178
|
+
case "week":
|
|
179
|
+
start = new Date(now);
|
|
180
|
+
start.setDate(start.getDate() - start.getDay() + 1); // Monday
|
|
181
|
+
start.setHours(0, 0, 0, 0);
|
|
182
|
+
break;
|
|
183
|
+
case "month":
|
|
184
|
+
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
185
|
+
break;
|
|
186
|
+
case "custom":
|
|
187
|
+
start = startDate ? new Date(startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
|
|
188
|
+
end = endDate ? new Date(endDate) : now;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const entries = listEntries(start.toISOString(), end.toISOString());
|
|
193
|
+
|
|
194
|
+
const byProject: Record<string, number> = {};
|
|
195
|
+
const byCategory: Record<string, number> = {};
|
|
196
|
+
const byDay: Record<string, number> = {};
|
|
197
|
+
let totalMinutes = 0;
|
|
198
|
+
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const mins = entry.duration || 0;
|
|
201
|
+
totalMinutes += mins;
|
|
202
|
+
|
|
203
|
+
const proj = entry.project || "Uncategorized";
|
|
204
|
+
byProject[proj] = (byProject[proj] || 0) + mins;
|
|
205
|
+
|
|
206
|
+
const cat = entry.category || "General";
|
|
207
|
+
byCategory[cat] = (byCategory[cat] || 0) + mins;
|
|
208
|
+
|
|
209
|
+
const day = entry.startTime.slice(0, 10);
|
|
210
|
+
byDay[day] = (byDay[day] || 0) + mins;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
period,
|
|
215
|
+
startDate: start.toISOString(),
|
|
216
|
+
endDate: end.toISOString(),
|
|
217
|
+
totalMinutes,
|
|
218
|
+
byProject,
|
|
219
|
+
byCategory,
|
|
220
|
+
byDay,
|
|
221
|
+
entries
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function listProjects(): string[] {
|
|
226
|
+
return [...new Set(loadEntries().map(e => e.project).filter(Boolean) as string[])].sort();
|
|
227
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─── Claude Auth Monitor ────────────────────────────────────────────────────
|
|
2
|
+
// Monitors Claude sessions for auth failures and attempts automatic re-auth.
|
|
3
|
+
|
|
4
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
|
|
7
|
+
export const authEvents = new EventEmitter();
|
|
8
|
+
|
|
9
|
+
export type AuthStatus = "ok" | "warning" | "expired" | "unknown";
|
|
10
|
+
|
|
11
|
+
interface AuthState {
|
|
12
|
+
status: AuthStatus;
|
|
13
|
+
lastCheck: number;
|
|
14
|
+
lastError?: string;
|
|
15
|
+
refreshAttempts: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let currentState: AuthState = {
|
|
19
|
+
status: "unknown",
|
|
20
|
+
lastCheck: 0,
|
|
21
|
+
refreshAttempts: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Patterns that indicate auth failure in CLI stderr
|
|
25
|
+
const AUTH_FAILURE_PATTERNS = [
|
|
26
|
+
/401/i,
|
|
27
|
+
/unauthorized/i,
|
|
28
|
+
/token expired/i,
|
|
29
|
+
/invalid.*bearer.*token/i,
|
|
30
|
+
/authentication.*failed/i,
|
|
31
|
+
/oauth.*error/i,
|
|
32
|
+
/EAUTH/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Check if a stderr line indicates an auth failure */
|
|
36
|
+
export function isAuthFailure(line: string): boolean {
|
|
37
|
+
return AUTH_FAILURE_PATTERNS.some(p => p.test(line));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Called when an auth failure is detected in CLI output */
|
|
41
|
+
export function reportAuthFailure(error: string, sessionId?: string): void {
|
|
42
|
+
currentState = {
|
|
43
|
+
status: "expired",
|
|
44
|
+
lastCheck: Date.now(),
|
|
45
|
+
lastError: error,
|
|
46
|
+
refreshAttempts: currentState.refreshAttempts,
|
|
47
|
+
};
|
|
48
|
+
authEvents.emit("auth:failure", { error, sessionId });
|
|
49
|
+
console.log(`[auth-monitor] Auth failure detected: ${error}`);
|
|
50
|
+
|
|
51
|
+
// Auto-attempt refresh if we haven't tried too many times
|
|
52
|
+
if (currentState.refreshAttempts < 3) {
|
|
53
|
+
attemptRefresh().catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Attempt to refresh the OAuth token */
|
|
58
|
+
export async function attemptRefresh(): Promise<boolean> {
|
|
59
|
+
currentState.refreshAttempts++;
|
|
60
|
+
console.log(`[auth-monitor] Attempting token refresh (attempt ${currentState.refreshAttempts})`);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const settings = getSettings();
|
|
64
|
+
const token = settings.claudeCodeOAuthToken;
|
|
65
|
+
|
|
66
|
+
if (!token) {
|
|
67
|
+
console.log("[auth-monitor] No OAuth token configured, cannot refresh");
|
|
68
|
+
currentState.status = "expired";
|
|
69
|
+
authEvents.emit("auth:refresh-failed", { reason: "no_token" });
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Try to validate the current token by making a test request
|
|
74
|
+
const testRes = await fetch("https://api.anthropic.com/v1/messages", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"x-api-key": settings.anthropicApiKey || "",
|
|
79
|
+
"anthropic-version": "2023-06-01",
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model: "claude-sonnet-4-20250514",
|
|
83
|
+
max_tokens: 1,
|
|
84
|
+
messages: [{ role: "user", content: "test" }],
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (testRes.ok || testRes.status === 400) {
|
|
89
|
+
// Token works (400 = bad request but auth is fine)
|
|
90
|
+
currentState = { status: "ok", lastCheck: Date.now(), refreshAttempts: 0 };
|
|
91
|
+
authEvents.emit("auth:refreshed");
|
|
92
|
+
console.log("[auth-monitor] Token validated successfully");
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (testRes.status === 401) {
|
|
97
|
+
currentState.status = "expired";
|
|
98
|
+
authEvents.emit("auth:refresh-failed", { reason: "token_invalid" });
|
|
99
|
+
console.log("[auth-monitor] Token is invalid (401)");
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Other errors — mark as warning
|
|
104
|
+
currentState.status = "warning";
|
|
105
|
+
authEvents.emit("auth:refresh-failed", { reason: `http_${testRes.status}` });
|
|
106
|
+
return false;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.log(`[auth-monitor] Refresh error: ${err instanceof Error ? err.message : err}`);
|
|
109
|
+
currentState.status = "warning";
|
|
110
|
+
authEvents.emit("auth:refresh-failed", { reason: "network_error" });
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Get current auth status */
|
|
116
|
+
export function getAuthStatus(): AuthState {
|
|
117
|
+
return { ...currentState };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Mark auth as OK (e.g., after successful session start) */
|
|
121
|
+
export function markAuthOk(): void {
|
|
122
|
+
currentState = { status: "ok", lastCheck: Date.now(), refreshAttempts: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reset auth state */
|
|
126
|
+
export function resetAuthState(): void {
|
|
127
|
+
currentState = { status: "unknown", lastCheck: 0, refreshAttempts: 0 };
|
|
128
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ─── Claude Code Headless Worker ─────────────────────────────────────────────
|
|
2
|
+
// One-shot LLM call via the locally-installed Claude Code CLI in --print mode.
|
|
3
|
+
// Uses the Claude Code Subscription (no Anthropic API charges) instead of
|
|
4
|
+
// `callInternalAI`. Tools are disabled (`--tools ""`) so the call behaves as a
|
|
5
|
+
// pure completion endpoint. Session persistence is off so we don't pollute the
|
|
6
|
+
// user's local Claude Code history.
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
interface HeadlessOpts {
|
|
11
|
+
systemPrompt: string;
|
|
12
|
+
userPrompt: string;
|
|
13
|
+
/** "sonnet" | "opus" | "haiku" | full model id. Defaults to "sonnet". */
|
|
14
|
+
model?: string;
|
|
15
|
+
/** Total wall-clock timeout. Defaults to 120s. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type HeadlessResult =
|
|
20
|
+
| { ok: true; text: string }
|
|
21
|
+
| { ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
/** Spawn `claude -p` and return its stdout. */
|
|
24
|
+
export function callClaudeCodeHeadless(opts: HeadlessOpts): Promise<HeadlessResult> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const args = [
|
|
27
|
+
"-p",
|
|
28
|
+
"--tools", "",
|
|
29
|
+
"--no-session-persistence",
|
|
30
|
+
"--model", opts.model ?? "sonnet",
|
|
31
|
+
"--system-prompt", opts.systemPrompt,
|
|
32
|
+
opts.userPrompt,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
let child;
|
|
36
|
+
try {
|
|
37
|
+
// Unset CLAUDECODE so the headless invocation isn't refused as a "nested
|
|
38
|
+
// session" if our own process happens to be running inside Claude Code.
|
|
39
|
+
// PM2-managed prod doesn't have it; dev under Claude Code does.
|
|
40
|
+
const childEnv: Record<string, string | undefined> = { ...process.env, NO_COLOR: "1" };
|
|
41
|
+
delete childEnv.CLAUDECODE;
|
|
42
|
+
delete childEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
43
|
+
|
|
44
|
+
child = spawn("claude", args, {
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
env: childEnv,
|
|
47
|
+
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
resolve({ ok: false, error: `spawn failed: ${e instanceof Error ? e.message : String(e)}` });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let stdout = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
let settled = false;
|
|
56
|
+
|
|
57
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
58
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
59
|
+
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
child.kill("SIGKILL");
|
|
64
|
+
resolve({ ok: false, error: `claude headless timeout after ${opts.timeoutMs ?? 120_000}ms` });
|
|
65
|
+
}, opts.timeoutMs ?? 120_000);
|
|
66
|
+
|
|
67
|
+
child.on("error", (err) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve({ ok: false, error: `claude spawn error: ${err.message}` });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
child.on("close", (code) => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (code === 0) {
|
|
79
|
+
resolve({ ok: true, text: stdout.trim() });
|
|
80
|
+
} else {
|
|
81
|
+
const tail = stderr.trim().slice(-500) || stdout.trim().slice(-500);
|
|
82
|
+
resolve({ ok: false, error: `claude exit ${code}: ${tail || "no output"}` });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
|
|
@@ -155,3 +155,76 @@ export function discoverClaudeSessions(
|
|
|
155
155
|
sessionId: session.sessionId || basename(session.sourceFile, ".jsonl"),
|
|
156
156
|
}));
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
export interface DeleteClaudeSessionTranscriptOptions {
|
|
160
|
+
projectsRoot?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DeleteClaudeSessionTranscriptResult {
|
|
164
|
+
deleted: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Delete the Claude Code transcript file (and any sidecar directory) for a
|
|
169
|
+
* given CLI session ID. Claude Code stores transcripts under
|
|
170
|
+
* `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` and may also create a
|
|
171
|
+
* `<sessionId>/` directory next to it. We walk all project subdirs because
|
|
172
|
+
* the same session can occasionally appear under multiple encoded-cwd dirs
|
|
173
|
+
* (e.g. when the working directory was renamed mid-session).
|
|
174
|
+
*
|
|
175
|
+
* Safe to call with a session that has no transcript on disk — returns an
|
|
176
|
+
* empty `deleted` list in that case.
|
|
177
|
+
*/
|
|
178
|
+
export function deleteClaudeSessionTranscript(
|
|
179
|
+
sessionId: string,
|
|
180
|
+
options: DeleteClaudeSessionTranscriptOptions = {},
|
|
181
|
+
): DeleteClaudeSessionTranscriptResult {
|
|
182
|
+
const deleted: string[] = [];
|
|
183
|
+
if (!sessionId || typeof sessionId !== "string") return { deleted };
|
|
184
|
+
|
|
185
|
+
const projectsRoot = options.projectsRoot
|
|
186
|
+
|| process.env.CLAUDE_PROJECTS_DIR
|
|
187
|
+
|| join(homedir(), ".claude", "projects");
|
|
188
|
+
|
|
189
|
+
if (!existsSync(projectsRoot)) return { deleted };
|
|
190
|
+
|
|
191
|
+
let projectDirs: string[] = [];
|
|
192
|
+
try {
|
|
193
|
+
projectDirs = readdirSync(projectsRoot);
|
|
194
|
+
} catch {
|
|
195
|
+
return { deleted };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const projectDir of projectDirs) {
|
|
199
|
+
const projectPath = join(projectsRoot, projectDir);
|
|
200
|
+
try {
|
|
201
|
+
if (!statSync(projectPath).isDirectory()) continue;
|
|
202
|
+
} catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const filePath = join(projectPath, `${sessionId}.jsonl`);
|
|
207
|
+
if (existsSync(filePath)) {
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(filePath);
|
|
210
|
+
deleted.push(filePath);
|
|
211
|
+
} catch {
|
|
212
|
+
// Skip; another process may have removed it concurrently.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sidecarPath = join(projectPath, sessionId);
|
|
217
|
+
if (existsSync(sidecarPath)) {
|
|
218
|
+
try {
|
|
219
|
+
if (statSync(sidecarPath).isDirectory()) {
|
|
220
|
+
rmSync(sidecarPath, { recursive: true, force: true });
|
|
221
|
+
deleted.push(sidecarPath);
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// no-op
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { deleted };
|
|
230
|
+
}
|
package/server/cli-launcher.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getLegacyCodexHome,
|
|
21
21
|
resolveHeyHankCodexSessionHome,
|
|
22
22
|
} from "./codex-home.js";
|
|
23
|
+
import { isAuthFailure, reportAuthFailure } from "./claude-auth-monitor.js";
|
|
23
24
|
|
|
24
25
|
/** Whether WebSocket transport is enabled for Codex sessions. */
|
|
25
26
|
function isCodexWsTransportEnabled(): boolean {
|
|
@@ -443,11 +444,20 @@ export class CliLauncher {
|
|
|
443
444
|
env: runtimeEnv,
|
|
444
445
|
});
|
|
445
446
|
} else {
|
|
447
|
+
// Prefer the CLI's reported internal session id (populated after the
|
|
448
|
+
// first system/init). Fall back to the user-chosen branch-from-session
|
|
449
|
+
// target, so that a Reconnect after an initial-spawn crash still
|
|
450
|
+
// resumes the intended session instead of starting a fresh one.
|
|
451
|
+
const resumeSessionId = info.cliSessionId || info.resumeSessionAt;
|
|
446
452
|
this.spawnCLI(sessionId, info, {
|
|
447
453
|
model: info.model,
|
|
448
454
|
permissionMode: info.permissionMode,
|
|
449
455
|
cwd: info.cwd,
|
|
450
|
-
resumeSessionId
|
|
456
|
+
resumeSessionId,
|
|
457
|
+
// Preserve fork intent only while we don't yet have a post-fork
|
|
458
|
+
// cliSessionId. Once cliSessionId is known, we resume into the fork
|
|
459
|
+
// directly and must not re-fork.
|
|
460
|
+
forkSession: !info.cliSessionId && info.forkSession ? true : false,
|
|
451
461
|
containerId: info.containerId,
|
|
452
462
|
containerName: info.containerName,
|
|
453
463
|
containerImage: info.containerImage,
|
|
@@ -543,19 +553,28 @@ export class CliLauncher {
|
|
|
543
553
|
args.push("--allowedTools", tool);
|
|
544
554
|
}
|
|
545
555
|
}
|
|
546
|
-
|
|
547
|
-
|
|
556
|
+
// The "Branch from session" UI passes the parent CLI session UUID via
|
|
557
|
+
// `resumeSessionAt`. Despite the field name, this is a *session* UUID,
|
|
558
|
+
// not the message UUID that the CLI's `--resume-session-at` flag
|
|
559
|
+
// expects — and that flag also requires `--resume <sessionId>` to be
|
|
560
|
+
// present, so emitting it alone makes the CLI exit immediately with
|
|
561
|
+
// "Error: --resume-session-at requires --resume". Map this to
|
|
562
|
+
// `--resume <id>` instead, plus `--fork-session` for the Fork case.
|
|
563
|
+
//
|
|
564
|
+
// `resumeSessionId` (set by the relaunch path with the CLI's reported
|
|
565
|
+
// internal session id) takes precedence when both are present, since
|
|
566
|
+
// the post-init id is the authoritative resume target.
|
|
567
|
+
const resumeTarget = options.resumeSessionId || options.resumeSessionAt;
|
|
568
|
+
if (resumeTarget) {
|
|
569
|
+
args.push("--resume", resumeTarget);
|
|
548
570
|
}
|
|
549
|
-
if (options.forkSession) {
|
|
571
|
+
if (options.forkSession && !options.resumeSessionId) {
|
|
572
|
+
// Only emit `--fork-session` on the *initial* spawn after the user
|
|
573
|
+
// chose Fork. Subsequent relaunches use the post-fork CLI session id
|
|
574
|
+
// via `resumeSessionId` and must not fork again.
|
|
550
575
|
args.push("--fork-session");
|
|
551
576
|
}
|
|
552
577
|
|
|
553
|
-
// Always pass -p "" for headless mode. When relaunching, also pass --resume
|
|
554
|
-
// to restore the CLI's conversation context.
|
|
555
|
-
if (options.resumeSessionId) {
|
|
556
|
-
args.push("--resume", options.resumeSessionId);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
578
|
args.push("-p", "");
|
|
560
579
|
|
|
561
580
|
let spawnCmd: string[];
|
|
@@ -1283,6 +1302,9 @@ export class CliLauncher {
|
|
|
1283
1302
|
const text = decoder.decode(value);
|
|
1284
1303
|
if (text.trim()) {
|
|
1285
1304
|
log(`[session:${sessionId}:${label}] ${text.trimEnd()}`);
|
|
1305
|
+
if (label === "stderr" && isAuthFailure(text)) {
|
|
1306
|
+
reportAuthFailure(text.trimEnd());
|
|
1307
|
+
}
|
|
1286
1308
|
}
|
|
1287
1309
|
}
|
|
1288
1310
|
} catch {
|
package/server/codex-adapter.ts
CHANGED
|
@@ -314,7 +314,7 @@ export class StdioTransport implements ICodexTransport {
|
|
|
314
314
|
// When the WS proxy reconnects to Codex, all pending RPC calls are
|
|
315
315
|
// orphaned (Codex sees a fresh connection and won't respond to them).
|
|
316
316
|
// Reject them immediately so callers don't hang until timeout.
|
|
317
|
-
if ((msg as JsonRpcNotification).method === "
|
|
317
|
+
if ((msg as JsonRpcNotification).method === "heyhank/wsReconnected") {
|
|
318
318
|
const pendingCount = this.pending.size;
|
|
319
319
|
if (pendingCount > 0) {
|
|
320
320
|
console.warn(
|
|
@@ -1659,7 +1659,7 @@ export class CodexAdapter implements IBackendAdapter {
|
|
|
1659
1659
|
}
|
|
1660
1660
|
break;
|
|
1661
1661
|
}
|
|
1662
|
-
case "
|
|
1662
|
+
case "heyhank/wsReconnected":
|
|
1663
1663
|
this.handleWsReconnected();
|
|
1664
1664
|
break;
|
|
1665
1665
|
default:
|
|
@@ -140,7 +140,7 @@ function connect() {
|
|
|
140
140
|
// rejects all pending calls and cleans up.
|
|
141
141
|
if (wasReconnect) {
|
|
142
142
|
const reconnectNotification = JSON.stringify({
|
|
143
|
-
method: "
|
|
143
|
+
method: "heyhank/wsReconnected",
|
|
144
144
|
params: {},
|
|
145
145
|
});
|
|
146
146
|
process.stdout.write(reconnectNotification + "\n");
|
|
@@ -20,7 +20,7 @@ export interface ContainerPortSpec {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface ContainerConfig {
|
|
23
|
-
/** Docker image to use (e.g. "
|
|
23
|
+
/** Docker image to use (e.g. "heyhank:latest", "node:22-slim") */
|
|
24
24
|
image: string;
|
|
25
25
|
/** Container ports to expose (e.g. [3000, 8080] or [{ port: 6080, hostIp: "127.0.0.1" }]) */
|
|
26
26
|
ports: (number | ContainerPortSpec)[];
|
|
@@ -832,7 +832,7 @@ export class ContainerManager {
|
|
|
832
832
|
* Build a Docker image from a provided Dockerfile path.
|
|
833
833
|
* Returns the build output log. Throws on failure.
|
|
834
834
|
*/
|
|
835
|
-
buildImage(dockerfilePath: string, tag: string = "
|
|
835
|
+
buildImage(dockerfilePath: string, tag: string = "heyhank:latest"): string {
|
|
836
836
|
const contextDir = dockerfilePath.replace(/\/[^/]+$/, "") || ".";
|
|
837
837
|
try {
|
|
838
838
|
const output = exec(
|
|
@@ -941,8 +941,8 @@ export class ContainerManager {
|
|
|
941
941
|
* Return the Docker Hub remote path for a default image, or null for non-default images.
|
|
942
942
|
*/
|
|
943
943
|
static getRegistryImage(localTag: string): string | null {
|
|
944
|
-
if (localTag === "
|
|
945
|
-
return `${DOCKER_REGISTRY}/
|
|
944
|
+
if (localTag === "heyhank:latest") {
|
|
945
|
+
return `${DOCKER_REGISTRY}/heyhank:latest`;
|
|
946
946
|
}
|
|
947
947
|
return null;
|
|
948
948
|
}
|