heyhank 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/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { SocketData } from "./ws-bridge.js";
|
|
5
|
+
|
|
6
|
+
/** Bun's PTY terminal handle exposed on proc when spawned with `terminal` option */
|
|
7
|
+
interface BunTerminalHandle {
|
|
8
|
+
write(data: string): void;
|
|
9
|
+
resize(cols: number, rows: number): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TerminalInstance {
|
|
14
|
+
id: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
containerId?: string;
|
|
17
|
+
proc: ReturnType<typeof Bun.spawn>;
|
|
18
|
+
terminal: BunTerminalHandle;
|
|
19
|
+
browserSockets: Set<ServerWebSocket<SocketData>>;
|
|
20
|
+
cols: number;
|
|
21
|
+
rows: number;
|
|
22
|
+
orphanTimer: ReturnType<typeof setTimeout> | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveShell(): string {
|
|
26
|
+
if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
|
|
27
|
+
if (existsSync("/bin/bash")) return "/bin/bash";
|
|
28
|
+
return "/bin/sh";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TerminalManager {
|
|
32
|
+
private instances = new Map<string, TerminalInstance>();
|
|
33
|
+
|
|
34
|
+
/** Spawn a terminal in the given directory (host or container). */
|
|
35
|
+
spawn(cwd: string, cols = 80, rows = 24, options?: { containerId?: string }): string {
|
|
36
|
+
const id = randomUUID();
|
|
37
|
+
const containerId = options?.containerId?.trim() || undefined;
|
|
38
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
39
|
+
const shell = resolveShell();
|
|
40
|
+
const cmd = containerId
|
|
41
|
+
? [
|
|
42
|
+
"docker",
|
|
43
|
+
"exec",
|
|
44
|
+
"-i",
|
|
45
|
+
"-t",
|
|
46
|
+
"-w",
|
|
47
|
+
cwd,
|
|
48
|
+
containerId,
|
|
49
|
+
"sh",
|
|
50
|
+
"-lc",
|
|
51
|
+
"if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi",
|
|
52
|
+
]
|
|
53
|
+
: [shell, "-l"];
|
|
54
|
+
|
|
55
|
+
const proc = Bun.spawn(cmd, {
|
|
56
|
+
cwd: containerId ? undefined : cwd,
|
|
57
|
+
env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: undefined },
|
|
58
|
+
terminal: {
|
|
59
|
+
cols,
|
|
60
|
+
rows,
|
|
61
|
+
data: (_terminal, data) => {
|
|
62
|
+
// Broadcast raw PTY output as binary to all connected browsers
|
|
63
|
+
for (const ws of sockets) {
|
|
64
|
+
try {
|
|
65
|
+
ws.sendBinary(data);
|
|
66
|
+
} catch {
|
|
67
|
+
// socket may have closed
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
exit: () => {
|
|
72
|
+
// PTY stream closed — get exit code from proc
|
|
73
|
+
const inst = this.instances.get(id);
|
|
74
|
+
if (inst) {
|
|
75
|
+
const exitMsg = JSON.stringify({ type: "exit", exitCode: proc.exitCode ?? 0 });
|
|
76
|
+
for (const ws of inst.browserSockets) {
|
|
77
|
+
try {
|
|
78
|
+
ws.send(exitMsg);
|
|
79
|
+
} catch {
|
|
80
|
+
// socket may have closed
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Extract the terminal handle from the proc — Bun attaches it when spawned with `terminal` option
|
|
89
|
+
const terminal = (proc as any).terminal as BunTerminalHandle;
|
|
90
|
+
this.instances.set(id, {
|
|
91
|
+
id,
|
|
92
|
+
cwd,
|
|
93
|
+
containerId,
|
|
94
|
+
proc,
|
|
95
|
+
terminal,
|
|
96
|
+
browserSockets: sockets,
|
|
97
|
+
cols,
|
|
98
|
+
rows,
|
|
99
|
+
orphanTimer: null,
|
|
100
|
+
});
|
|
101
|
+
console.log(
|
|
102
|
+
`[terminal] Spawned terminal ${id} in ${cwd}${containerId ? ` (container ${containerId.slice(0, 12)})` : ""} (${containerId ? "docker-shell" : shell}, ${cols}x${rows})`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Handle process exit
|
|
106
|
+
proc.exited.then((exitCode) => {
|
|
107
|
+
const inst = this.instances.get(id);
|
|
108
|
+
if (!inst) return;
|
|
109
|
+
console.log(`[terminal] Terminal ${id} exited with code ${exitCode}`);
|
|
110
|
+
this.cleanupInstance(id);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private getTerminalIdFromSocket(ws: ServerWebSocket<SocketData>): string | null {
|
|
117
|
+
const data = ws.data;
|
|
118
|
+
if (data.kind !== "terminal") return null;
|
|
119
|
+
return data.terminalId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private cleanupInstance(terminalId: string): void {
|
|
123
|
+
const inst = this.instances.get(terminalId);
|
|
124
|
+
if (!inst) return;
|
|
125
|
+
if (inst.orphanTimer) clearTimeout(inst.orphanTimer);
|
|
126
|
+
this.instances.delete(terminalId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Handle a message from a browser WebSocket */
|
|
130
|
+
handleBrowserMessage(ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
|
|
131
|
+
const terminalId = this.getTerminalIdFromSocket(ws);
|
|
132
|
+
if (!terminalId) return;
|
|
133
|
+
const inst = this.instances.get(terminalId);
|
|
134
|
+
if (!inst) return;
|
|
135
|
+
try {
|
|
136
|
+
const str = typeof msg === "string" ? msg : msg.toString();
|
|
137
|
+
const parsed = JSON.parse(str);
|
|
138
|
+
if (parsed.type === "input" && typeof parsed.data === "string") {
|
|
139
|
+
inst.terminal.write(parsed.data);
|
|
140
|
+
} else if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
|
|
141
|
+
this.resize(terminalId, parsed.cols, parsed.rows);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Malformed message, ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resize the PTY */
|
|
149
|
+
resize(terminalId: string, cols: number, rows: number): void {
|
|
150
|
+
const inst = this.instances.get(terminalId);
|
|
151
|
+
if (!inst) return;
|
|
152
|
+
inst.cols = cols;
|
|
153
|
+
inst.rows = rows;
|
|
154
|
+
try {
|
|
155
|
+
inst.terminal.resize(cols, rows);
|
|
156
|
+
} catch {
|
|
157
|
+
// resize not available or failed
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private killInstance(inst: TerminalInstance): void {
|
|
162
|
+
if (inst.orphanTimer) clearTimeout(inst.orphanTimer);
|
|
163
|
+
this.instances.delete(inst.id);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
inst.proc.kill();
|
|
167
|
+
} catch {
|
|
168
|
+
// process may have already exited
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SIGKILL fallback if SIGTERM doesn't work within 2 seconds
|
|
172
|
+
const pid = inst.proc.pid;
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
try {
|
|
175
|
+
process.kill(pid, 0); // check if still alive
|
|
176
|
+
inst.proc.kill(9); // SIGKILL
|
|
177
|
+
} catch {
|
|
178
|
+
// already dead, good
|
|
179
|
+
}
|
|
180
|
+
}, 2_000);
|
|
181
|
+
|
|
182
|
+
console.log(`[terminal] Killed terminal ${inst.id}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Kill one terminal instance. */
|
|
186
|
+
kill(terminalId: string): void {
|
|
187
|
+
const inst = this.instances.get(terminalId);
|
|
188
|
+
if (!inst) return;
|
|
189
|
+
this.killInstance(inst);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Get current terminal info */
|
|
193
|
+
getInfo(terminalId?: string): { id: string; cwd: string; containerId?: string } | null {
|
|
194
|
+
if (terminalId) {
|
|
195
|
+
const inst = this.instances.get(terminalId);
|
|
196
|
+
if (!inst) return null;
|
|
197
|
+
return { id: inst.id, cwd: inst.cwd, containerId: inst.containerId };
|
|
198
|
+
}
|
|
199
|
+
const first = this.instances.values().next().value as TerminalInstance | undefined;
|
|
200
|
+
if (!first) return null;
|
|
201
|
+
return { id: first.id, cwd: first.cwd, containerId: first.containerId };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Attach a browser WebSocket to the terminal */
|
|
205
|
+
addBrowserSocket(ws: ServerWebSocket<SocketData>): void {
|
|
206
|
+
const terminalId = this.getTerminalIdFromSocket(ws);
|
|
207
|
+
if (!terminalId) return;
|
|
208
|
+
const inst = this.instances.get(terminalId);
|
|
209
|
+
if (!inst) return;
|
|
210
|
+
|
|
211
|
+
// Cancel orphan kill timer if any
|
|
212
|
+
if (inst.orphanTimer) {
|
|
213
|
+
clearTimeout(inst.orphanTimer);
|
|
214
|
+
inst.orphanTimer = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
inst.browserSockets.add(ws);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Remove a browser WebSocket from the terminal */
|
|
221
|
+
removeBrowserSocket(ws: ServerWebSocket<SocketData>): void {
|
|
222
|
+
const terminalId = this.getTerminalIdFromSocket(ws);
|
|
223
|
+
if (!terminalId) return;
|
|
224
|
+
const inst = this.instances.get(terminalId);
|
|
225
|
+
if (!inst) return;
|
|
226
|
+
inst.browserSockets.delete(ws);
|
|
227
|
+
|
|
228
|
+
// If no browsers remain, start a grace timer to kill the orphaned terminal
|
|
229
|
+
if (inst.browserSockets.size === 0) {
|
|
230
|
+
const id = inst.id;
|
|
231
|
+
inst.orphanTimer = setTimeout(() => {
|
|
232
|
+
const alive = this.instances.get(id);
|
|
233
|
+
if (alive && alive.browserSockets.size === 0) {
|
|
234
|
+
console.log(`[terminal] No browsers connected, killing orphaned terminal ${id}`);
|
|
235
|
+
this.kill(id);
|
|
236
|
+
}
|
|
237
|
+
}, 5_000);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { getSettings, type UpdateChannel } from "./settings-manager.js";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
// Read current version from package.json
|
|
9
|
+
const packageJsonPath = resolve(__dirname, "..", "package.json");
|
|
10
|
+
const currentVersion: string = JSON.parse(
|
|
11
|
+
readFileSync(packageJsonPath, "utf-8"),
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
const NPM_REGISTRY_BASE = "https://registry.npmjs.org/heyhank";
|
|
15
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
16
|
+
const INITIAL_DELAY_MS = 10_000; // 10 seconds after boot
|
|
17
|
+
|
|
18
|
+
interface UpdateState {
|
|
19
|
+
currentVersion: string;
|
|
20
|
+
latestVersion: string | null;
|
|
21
|
+
lastChecked: number;
|
|
22
|
+
isServiceMode: boolean;
|
|
23
|
+
checking: boolean;
|
|
24
|
+
updateInProgress: boolean;
|
|
25
|
+
channel: UpdateChannel;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const state: UpdateState = {
|
|
29
|
+
currentVersion,
|
|
30
|
+
latestVersion: null,
|
|
31
|
+
lastChecked: 0,
|
|
32
|
+
isServiceMode: false,
|
|
33
|
+
checking: false,
|
|
34
|
+
updateInProgress: false,
|
|
35
|
+
channel: "stable",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function getUpdateState(): Readonly<UpdateState> {
|
|
39
|
+
return { ...state };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getCurrentVersion(): string {
|
|
43
|
+
return currentVersion;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns the npm registry URL for the given dist-tag. */
|
|
47
|
+
function getRegistryUrl(channel: UpdateChannel): string {
|
|
48
|
+
const distTag = channel === "prerelease" ? "next" : "latest";
|
|
49
|
+
return `${NPM_REGISTRY_BASE}/${distTag}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function checkForUpdate(): Promise<void> {
|
|
53
|
+
if (state.checking) return;
|
|
54
|
+
state.checking = true;
|
|
55
|
+
try {
|
|
56
|
+
// Read channel from settings on each check so switching is immediate
|
|
57
|
+
const channel = getSettings().updateChannel;
|
|
58
|
+
if (channel !== state.channel) {
|
|
59
|
+
state.latestVersion = null; // avoid cross-channel stale comparison
|
|
60
|
+
}
|
|
61
|
+
state.channel = channel;
|
|
62
|
+
const url = getRegistryUrl(channel);
|
|
63
|
+
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
headers: { Accept: "application/json" },
|
|
66
|
+
signal: AbortSignal.timeout(10_000),
|
|
67
|
+
});
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
const data = (await res.json()) as { version: string };
|
|
70
|
+
state.latestVersion = data.version;
|
|
71
|
+
state.lastChecked = Date.now();
|
|
72
|
+
if (isUpdateAvailable()) {
|
|
73
|
+
console.log(
|
|
74
|
+
`[update-checker] Update available (${channel}): ${currentVersion} -> ${state.latestVersion}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"[update-checker] Failed to check for updates:",
|
|
81
|
+
err instanceof Error ? err.message : String(err),
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
state.checking = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setServiceMode(isService: boolean): void {
|
|
89
|
+
state.isServiceMode = isService;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function setUpdateInProgress(inProgress: boolean): void {
|
|
93
|
+
state.updateInProgress = inProgress;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isUpdateAvailable(): boolean {
|
|
97
|
+
if (!state.latestVersion || !state.currentVersion) return false;
|
|
98
|
+
return isNewerVersion(state.latestVersion, state.currentVersion);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a semver string into its components.
|
|
103
|
+
* Handles versions like "1.2.3", "1.2.3-preview.20260228120000.abc1234"
|
|
104
|
+
*/
|
|
105
|
+
function parseSemver(v: string): { major: number; minor: number; patch: number; prerelease: string[] } {
|
|
106
|
+
const [corePart, ...prereleaseParts] = v.split("-");
|
|
107
|
+
const prerelease = prereleaseParts.length > 0 ? prereleaseParts.join("-").split(".") : [];
|
|
108
|
+
const parts = corePart.split(".").map(Number);
|
|
109
|
+
return {
|
|
110
|
+
major: parts[0] || 0,
|
|
111
|
+
minor: parts[1] || 0,
|
|
112
|
+
patch: parts[2] || 0,
|
|
113
|
+
prerelease,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compare two semver prerelease identifier arrays.
|
|
119
|
+
* Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
|
120
|
+
* A version with no prerelease identifiers has higher precedence than one with.
|
|
121
|
+
*/
|
|
122
|
+
function comparePrereleaseArrays(a: string[], b: string[]): number {
|
|
123
|
+
// No prerelease on both = equal
|
|
124
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
125
|
+
// No prerelease > has prerelease (stable is newer than prerelease of same core version)
|
|
126
|
+
if (a.length === 0) return 1;
|
|
127
|
+
if (b.length === 0) return -1;
|
|
128
|
+
|
|
129
|
+
const maxLen = Math.max(a.length, b.length);
|
|
130
|
+
for (let i = 0; i < maxLen; i++) {
|
|
131
|
+
// Fewer fields = lower precedence
|
|
132
|
+
if (i >= a.length) return -1;
|
|
133
|
+
if (i >= b.length) return 1;
|
|
134
|
+
|
|
135
|
+
const aNum = Number(a[i]);
|
|
136
|
+
const bNum = Number(b[i]);
|
|
137
|
+
const aIsNum = !isNaN(aNum);
|
|
138
|
+
const bIsNum = !isNaN(bNum);
|
|
139
|
+
|
|
140
|
+
if (aIsNum && bIsNum) {
|
|
141
|
+
if (aNum > bNum) return 1;
|
|
142
|
+
if (aNum < bNum) return -1;
|
|
143
|
+
} else if (aIsNum) {
|
|
144
|
+
// Numeric identifiers have lower precedence than alphanumeric
|
|
145
|
+
return -1;
|
|
146
|
+
} else if (bIsNum) {
|
|
147
|
+
return 1;
|
|
148
|
+
} else {
|
|
149
|
+
// Both alphanumeric: compare lexically
|
|
150
|
+
if (a[i] > b[i]) return 1;
|
|
151
|
+
if (a[i] < b[i]) return -1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Prerelease-aware semver comparison: returns true if a > b.
|
|
159
|
+
* Handles both stable versions (1.2.3) and prerelease versions
|
|
160
|
+
* (1.2.3-preview.20260228120000.abc1234).
|
|
161
|
+
*/
|
|
162
|
+
export function isNewerVersion(a: string, b: string): boolean {
|
|
163
|
+
const pa = parseSemver(a);
|
|
164
|
+
const pb = parseSemver(b);
|
|
165
|
+
|
|
166
|
+
// Compare major.minor.patch
|
|
167
|
+
if (pa.major !== pb.major) return pa.major > pb.major;
|
|
168
|
+
if (pa.minor !== pb.minor) return pa.minor > pb.minor;
|
|
169
|
+
if (pa.patch !== pb.patch) return pa.patch > pb.patch;
|
|
170
|
+
|
|
171
|
+
// Core versions are equal — compare prerelease
|
|
172
|
+
return comparePrereleaseArrays(pa.prerelease, pb.prerelease) > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
176
|
+
|
|
177
|
+
export function startPeriodicCheck(): void {
|
|
178
|
+
if (intervalId) return;
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
checkForUpdate();
|
|
181
|
+
}, INITIAL_DELAY_MS);
|
|
182
|
+
intervalId = setInterval(() => {
|
|
183
|
+
checkForUpdate();
|
|
184
|
+
}, CHECK_INTERVAL_MS);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function stopPeriodicCheck(): void {
|
|
188
|
+
if (intervalId) {
|
|
189
|
+
clearInterval(intervalId);
|
|
190
|
+
intervalId = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface UsageLimits {
|
|
7
|
+
five_hour: { utilization: number; resets_at: string | null } | null;
|
|
8
|
+
seven_day: { utilization: number; resets_at: string | null } | null;
|
|
9
|
+
extra_usage: {
|
|
10
|
+
is_enabled: boolean;
|
|
11
|
+
monthly_limit: number;
|
|
12
|
+
used_credits: number;
|
|
13
|
+
utilization: number | null;
|
|
14
|
+
} | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
18
|
+
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
19
|
+
|
|
20
|
+
// In-memory cache (60s TTL)
|
|
21
|
+
const CACHE_DURATION_MS = 60 * 1000;
|
|
22
|
+
let cache: { data: UsageLimits; timestamp: number } | null = null;
|
|
23
|
+
|
|
24
|
+
interface OAuthCredentials {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RawCredentials {
|
|
32
|
+
raw: string;
|
|
33
|
+
parsed: Record<string, unknown>;
|
|
34
|
+
oauth: OAuthCredentials;
|
|
35
|
+
sourcePath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Credential file candidates - matches claude-container-auth.ts
|
|
39
|
+
const CREDENTIAL_FILE_NAMES = [
|
|
40
|
+
".credentials.json",
|
|
41
|
+
"auth.json",
|
|
42
|
+
".auth.json",
|
|
43
|
+
"credentials.json",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function readCredentialsFromFile(): RawCredentials | null {
|
|
47
|
+
const home =
|
|
48
|
+
process.env.USERPROFILE || process.env.HOME || homedir() || "";
|
|
49
|
+
const claudeDir = join(home, ".claude");
|
|
50
|
+
|
|
51
|
+
for (const fileName of CREDENTIAL_FILE_NAMES) {
|
|
52
|
+
const credPath = join(claudeDir, fileName);
|
|
53
|
+
if (!existsSync(credPath)) continue;
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(credPath, "utf-8");
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (!parsed?.claudeAiOauth?.accessToken) continue;
|
|
58
|
+
return { raw, parsed, oauth: parsed.claudeAiOauth, sourcePath: credPath };
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readRawCredentials(): RawCredentials | null {
|
|
67
|
+
try {
|
|
68
|
+
// macOS: use Keychain via security command
|
|
69
|
+
if (process.platform === "darwin") {
|
|
70
|
+
const raw = execSync(
|
|
71
|
+
'security find-generic-password -s "Claude Code-credentials" -w',
|
|
72
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
73
|
+
).trim();
|
|
74
|
+
|
|
75
|
+
const decoded = raw.startsWith("{")
|
|
76
|
+
? raw
|
|
77
|
+
: Buffer.from(raw, "hex").toString("utf-8");
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(decoded);
|
|
80
|
+
if (!parsed?.claudeAiOauth?.accessToken) return null;
|
|
81
|
+
return { raw: decoded, parsed, oauth: parsed.claudeAiOauth };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Windows, Linux, Docker, and other platforms: read from credential files
|
|
85
|
+
return readCredentialsFromFile();
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeCredentials(creds: Record<string, unknown>, sourcePath?: string): void {
|
|
92
|
+
try {
|
|
93
|
+
const json = JSON.stringify(creds);
|
|
94
|
+
if (process.platform === "darwin") {
|
|
95
|
+
execFileSync(
|
|
96
|
+
"security",
|
|
97
|
+
["add-generic-password", "-U", "-s", "Claude Code-credentials", "-a", "Claude Code", "-w", json],
|
|
98
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
// Write back to the same file that was read, or default to .credentials.json
|
|
102
|
+
const credPath = sourcePath ?? join(
|
|
103
|
+
process.env.USERPROFILE || process.env.HOME || homedir() || "",
|
|
104
|
+
".claude",
|
|
105
|
+
".credentials.json",
|
|
106
|
+
);
|
|
107
|
+
writeFileSync(credPath, json, "utf-8");
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function refreshAccessToken(refreshToken: string): Promise<{
|
|
115
|
+
accessToken: string;
|
|
116
|
+
refreshToken: string;
|
|
117
|
+
expiresIn: number;
|
|
118
|
+
} | null> {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
123
|
+
body: new URLSearchParams({
|
|
124
|
+
grant_type: "refresh_token",
|
|
125
|
+
refresh_token: refreshToken,
|
|
126
|
+
client_id: OAUTH_CLIENT_ID,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) return null;
|
|
130
|
+
const data = await res.json();
|
|
131
|
+
return {
|
|
132
|
+
accessToken: data.access_token,
|
|
133
|
+
refreshToken: data.refresh_token,
|
|
134
|
+
expiresIn: data.expires_in,
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getCredentials(): string | null {
|
|
142
|
+
const creds = readRawCredentials();
|
|
143
|
+
return creds?.oauth.accessToken ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function getValidAccessToken(): Promise<string | null> {
|
|
147
|
+
const creds = readRawCredentials();
|
|
148
|
+
if (!creds) return null;
|
|
149
|
+
|
|
150
|
+
const { oauth } = creds;
|
|
151
|
+
|
|
152
|
+
// Token still valid (with 5min buffer)
|
|
153
|
+
if (oauth.expiresAt && Date.now() < oauth.expiresAt - 5 * 60 * 1000) {
|
|
154
|
+
return oauth.accessToken;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Token expired - try to refresh
|
|
158
|
+
if (!oauth.refreshToken) return null;
|
|
159
|
+
|
|
160
|
+
const refreshed = await refreshAccessToken(oauth.refreshToken);
|
|
161
|
+
if (!refreshed) return null;
|
|
162
|
+
|
|
163
|
+
// Update stored credentials
|
|
164
|
+
creds.parsed.claudeAiOauth = {
|
|
165
|
+
...oauth,
|
|
166
|
+
accessToken: refreshed.accessToken,
|
|
167
|
+
refreshToken: refreshed.refreshToken,
|
|
168
|
+
expiresAt: Date.now() + refreshed.expiresIn * 1000,
|
|
169
|
+
};
|
|
170
|
+
writeCredentials(creds.parsed, creds.sourcePath);
|
|
171
|
+
|
|
172
|
+
return refreshed.accessToken;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchUsageLimits(
|
|
176
|
+
token: string,
|
|
177
|
+
): Promise<UsageLimits | null> {
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
180
|
+
method: "GET",
|
|
181
|
+
headers: {
|
|
182
|
+
Accept: "application/json",
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
"User-Agent": "claude-code/2.1.39",
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!response.ok) return null;
|
|
191
|
+
|
|
192
|
+
const data = await response.json();
|
|
193
|
+
return {
|
|
194
|
+
five_hour: data.five_hour || null,
|
|
195
|
+
seven_day: data.seven_day || null,
|
|
196
|
+
extra_usage: data.extra_usage || null,
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function getUsageLimits(): Promise<UsageLimits> {
|
|
204
|
+
const empty: UsageLimits = {
|
|
205
|
+
five_hour: null,
|
|
206
|
+
seven_day: null,
|
|
207
|
+
extra_usage: null,
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
if (cache && Date.now() - cache.timestamp < CACHE_DURATION_MS) {
|
|
211
|
+
return cache.data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = await getValidAccessToken();
|
|
215
|
+
if (!token) return empty;
|
|
216
|
+
|
|
217
|
+
const limits = await fetchUsageLimits(token);
|
|
218
|
+
if (!limits) return empty;
|
|
219
|
+
|
|
220
|
+
cache = { data: limits, timestamp: Date.now() };
|
|
221
|
+
return limits;
|
|
222
|
+
} catch {
|
|
223
|
+
return empty;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
declare module "web-push" {
|
|
2
|
+
interface VapidKeys {
|
|
3
|
+
publicKey: string;
|
|
4
|
+
privateKey: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface PushSubscription {
|
|
8
|
+
endpoint: string;
|
|
9
|
+
keys: {
|
|
10
|
+
p256dh: string;
|
|
11
|
+
auth: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SendOptions {
|
|
16
|
+
TTL?: number;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
vapidDetails?: {
|
|
19
|
+
subject: string;
|
|
20
|
+
publicKey: string;
|
|
21
|
+
privateKey: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SendResult {
|
|
26
|
+
statusCode: number;
|
|
27
|
+
body: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generateVAPIDKeys(): VapidKeys;
|
|
32
|
+
function setVapidDetails(
|
|
33
|
+
subject: string,
|
|
34
|
+
publicKey: string,
|
|
35
|
+
privateKey: string,
|
|
36
|
+
): void;
|
|
37
|
+
function sendNotification(
|
|
38
|
+
subscription: PushSubscription,
|
|
39
|
+
payload: string | Buffer | null,
|
|
40
|
+
options?: SendOptions,
|
|
41
|
+
): Promise<SendResult>;
|
|
42
|
+
|
|
43
|
+
const webpush: {
|
|
44
|
+
generateVAPIDKeys: typeof generateVAPIDKeys;
|
|
45
|
+
setVapidDetails: typeof setVapidDetails;
|
|
46
|
+
sendNotification: typeof sendNotification;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default webpush;
|
|
50
|
+
export { generateVAPIDKeys, setVapidDetails, sendNotification };
|
|
51
|
+
}
|