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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -10
  3. package/bin/cli.ts +7 -7
  4. package/bin/ctl.ts +42 -42
  5. package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
  6. package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
  7. package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
  9. package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
  11. package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
  12. package/dist/assets/MediaPage-C48HTTrt.js +1 -0
  13. package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
  18. package/dist/assets/RunsPage-B9UOyO79.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
  20. package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
  21. package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
  22. package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
  24. package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
  28. package/dist/assets/index-BkjSoVgn.css +32 -0
  29. package/dist/assets/sw-register-C7NOHtIu.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/index.html +2 -2
  32. package/dist/sw.js +1 -1
  33. package/package.json +6 -1
  34. package/server/agent-executor.ts +37 -2
  35. package/server/agent-store.ts +3 -3
  36. package/server/agent-types.ts +11 -0
  37. package/server/assistant-store.ts +232 -6
  38. package/server/auth-manager.ts +9 -0
  39. package/server/cache-headers.ts +1 -1
  40. package/server/calendar-service.ts +10 -0
  41. package/server/ceo/document-store.ts +129 -0
  42. package/server/ceo/finance-store.ts +343 -0
  43. package/server/ceo/kpi-store.ts +208 -0
  44. package/server/ceo/memory-import.ts +277 -0
  45. package/server/ceo/news-store.ts +208 -0
  46. package/server/ceo/template-store.ts +134 -0
  47. package/server/ceo/time-tracking-store.ts +227 -0
  48. package/server/claude-auth-monitor.ts +128 -0
  49. package/server/claude-code-worker.ts +86 -0
  50. package/server/claude-session-discovery.ts +74 -1
  51. package/server/cli-launcher.ts +32 -10
  52. package/server/codex-adapter.ts +2 -2
  53. package/server/codex-ws-proxy.cjs +1 -1
  54. package/server/container-manager.ts +4 -4
  55. package/server/content-intelligence/content-engine.ts +1112 -0
  56. package/server/content-intelligence/platform-knowledge.ts +870 -0
  57. package/server/cron-store.ts +3 -3
  58. package/server/embedding-service.ts +49 -0
  59. package/server/event-bus-types.ts +13 -0
  60. package/server/federation/node-store.ts +5 -4
  61. package/server/fs-utils.ts +28 -1
  62. package/server/hank-notifications-store.ts +91 -0
  63. package/server/hank-tool-executor.ts +1835 -0
  64. package/server/hank-tools.ts +2107 -0
  65. package/server/image-pull-manager.ts +2 -2
  66. package/server/index.ts +25 -2
  67. package/server/llm-providers-streaming.ts +541 -0
  68. package/server/llm-providers.ts +12 -0
  69. package/server/marketplace.ts +249 -0
  70. package/server/mcp-registry.ts +158 -0
  71. package/server/memory-service.ts +296 -0
  72. package/server/obsidian-sync.ts +184 -0
  73. package/server/provider-manager.ts +5 -2
  74. package/server/provider-registry.ts +12 -0
  75. package/server/reminder-scheduler.ts +37 -1
  76. package/server/routes/agent-routes.ts +2 -1
  77. package/server/routes/assistant-routes.ts +198 -5
  78. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  79. package/server/routes/ceo-news-time-routes.ts +137 -0
  80. package/server/routes/ceo-routes.ts +99 -0
  81. package/server/routes/content-routes.ts +116 -0
  82. package/server/routes/email-routes.ts +147 -0
  83. package/server/routes/env-routes.ts +3 -3
  84. package/server/routes/fs-routes.ts +12 -9
  85. package/server/routes/hank-chat-routes.ts +592 -0
  86. package/server/routes/llm-routes.ts +12 -0
  87. package/server/routes/marketplace-routes.ts +63 -0
  88. package/server/routes/media-routes.ts +1 -1
  89. package/server/routes/memory-routes.ts +127 -0
  90. package/server/routes/platform-routes.ts +14 -675
  91. package/server/routes/sandbox-routes.ts +1 -1
  92. package/server/routes/settings-routes.ts +51 -1
  93. package/server/routes/socialmedia-routes.ts +152 -2
  94. package/server/routes/system-routes.ts +2 -2
  95. package/server/routes/team-routes.ts +71 -0
  96. package/server/routes/telephony-routes.ts +98 -18
  97. package/server/routes.ts +36 -9
  98. package/server/session-creation-service.ts +2 -2
  99. package/server/session-orchestrator.ts +54 -2
  100. package/server/session-types.ts +2 -0
  101. package/server/settings-manager.ts +50 -2
  102. package/server/skill-discovery.ts +68 -0
  103. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  104. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  105. package/server/socialmedia/manager.ts +234 -15
  106. package/server/socialmedia/store.ts +51 -1
  107. package/server/socialmedia/types.ts +35 -2
  108. package/server/socialview/browser-manager.ts +150 -0
  109. package/server/socialview/extractors.ts +1298 -0
  110. package/server/socialview/image-describe.ts +188 -0
  111. package/server/socialview/library.ts +119 -0
  112. package/server/socialview/poster.ts +276 -0
  113. package/server/socialview/routes.ts +371 -0
  114. package/server/socialview/style-analyzer.ts +187 -0
  115. package/server/socialview/style-profiles.ts +67 -0
  116. package/server/socialview/types.ts +166 -0
  117. package/server/socialview/vision.ts +127 -0
  118. package/server/socialview/vnc-manager.ts +110 -0
  119. package/server/style-injector.ts +135 -0
  120. package/server/team-service.ts +239 -0
  121. package/server/team-store.ts +75 -0
  122. package/server/team-types.ts +52 -0
  123. package/server/telephony/audio-bridge.ts +281 -35
  124. package/server/telephony/audio-recorder.ts +132 -0
  125. package/server/telephony/call-manager.ts +803 -104
  126. package/server/telephony/call-types.ts +67 -1
  127. package/server/telephony/esl-client.ts +319 -0
  128. package/server/telephony/freeswitch-sync.ts +155 -0
  129. package/server/telephony/phone-utils.ts +63 -0
  130. package/server/telephony/telephony-store.ts +9 -8
  131. package/server/url-validator.ts +82 -0
  132. package/server/vault-markdown.ts +317 -0
  133. package/server/vault-migration.ts +121 -0
  134. package/server/vault-store.ts +466 -0
  135. package/server/vault-watcher.ts +59 -0
  136. package/server/vector-store.ts +210 -0
  137. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  138. package/server/voice-pipeline/greeting-cache.ts +200 -0
  139. package/server/voice-pipeline/manager.ts +249 -0
  140. package/server/voice-pipeline/pipeline.ts +335 -0
  141. package/server/voice-pipeline/providers/index.ts +47 -0
  142. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  143. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  144. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  145. package/server/voice-pipeline/types.ts +247 -0
  146. package/server/ws-bridge-types.ts +6 -1
  147. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  148. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  149. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  150. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  151. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  152. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  153. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  154. package/dist/assets/index-C8M_PUmX.css +0 -32
  155. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  156. 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
+ }
@@ -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: info.cliSessionId,
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
- if (options.resumeSessionAt) {
547
- args.push("--resume-session-at", options.resumeSessionAt);
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 {
@@ -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 === "companion/wsReconnected") {
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 "companion/wsReconnected":
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: "companion/wsReconnected",
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. "the-companion:latest", "node:22-slim") */
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 = "the-companion:latest"): 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 === "the-companion:latest") {
945
- return `${DOCKER_REGISTRY}/the-companion:latest`;
944
+ if (localTag === "heyhank:latest") {
945
+ return `${DOCKER_REGISTRY}/heyhank:latest`;
946
946
  }
947
947
  return null;
948
948
  }