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,30 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true when Claude running inside a container has a plausible auth source:
|
|
7
|
+
* - explicit auth env vars, or
|
|
8
|
+
* - known auth files under ~/.claude that can be copied into the container.
|
|
9
|
+
*/
|
|
10
|
+
export function hasContainerClaudeAuth(envVars?: Record<string, string>): boolean {
|
|
11
|
+
if (
|
|
12
|
+
!!envVars?.ANTHROPIC_API_KEY
|
|
13
|
+
|| !!envVars?.ANTHROPIC_AUTH_TOKEN
|
|
14
|
+
|| !!envVars?.CLAUDE_CODE_AUTH_TOKEN
|
|
15
|
+
|| !!envVars?.CLAUDE_CODE_OAUTH_TOKEN
|
|
16
|
+
) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
21
|
+
const candidates = [
|
|
22
|
+
join(home, ".claude", ".credentials.json"),
|
|
23
|
+
join(home, ".claude", "auth.json"),
|
|
24
|
+
join(home, ".claude", ".auth.json"),
|
|
25
|
+
join(home, ".claude", "credentials.json"),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return candidates.some((p) => existsSync(p));
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export interface DiscoveredClaudeSession {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
gitBranch?: string;
|
|
9
|
+
slug?: string;
|
|
10
|
+
lastActivityAt: number;
|
|
11
|
+
sourceFile: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DiscoverClaudeSessionsOptions {
|
|
15
|
+
limit?: number;
|
|
16
|
+
projectsRoot?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DISCOVERY_LIMIT = 200;
|
|
20
|
+
const MAX_DISCOVERY_LIMIT = 1000;
|
|
21
|
+
const METADATA_SCAN_BYTES = 1024 * 1024; // 1 MiB from file head is enough for first metadata records.
|
|
22
|
+
|
|
23
|
+
function extractMetadataFromJsonl(
|
|
24
|
+
filePath: string,
|
|
25
|
+
): Pick<DiscoveredClaudeSession, "sessionId" | "cwd" | "gitBranch" | "slug"> | null {
|
|
26
|
+
let content = "";
|
|
27
|
+
const buffer = Buffer.allocUnsafe(METADATA_SCAN_BYTES);
|
|
28
|
+
let fd: number | null = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(filePath, "r");
|
|
31
|
+
const bytesRead = readSync(fd, buffer, 0, METADATA_SCAN_BYTES, 0);
|
|
32
|
+
content = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
} finally {
|
|
36
|
+
if (fd !== null) {
|
|
37
|
+
try {
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
} catch {
|
|
40
|
+
// no-op
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lines = content.split("\n");
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed.startsWith("{")) continue;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(trimmed) as {
|
|
51
|
+
sessionId?: unknown;
|
|
52
|
+
cwd?: unknown;
|
|
53
|
+
gitBranch?: unknown;
|
|
54
|
+
slug?: unknown;
|
|
55
|
+
};
|
|
56
|
+
if (typeof parsed.sessionId !== "string" || typeof parsed.cwd !== "string") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
sessionId: parsed.sessionId,
|
|
61
|
+
cwd: parsed.cwd,
|
|
62
|
+
gitBranch: typeof parsed.gitBranch === "string" ? parsed.gitBranch : undefined,
|
|
63
|
+
slug: typeof parsed.slug === "string" ? parsed.slug : undefined,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore malformed/truncated line fragments near chunk boundary.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function discoverClaudeSessions(
|
|
74
|
+
options: DiscoverClaudeSessionsOptions = {},
|
|
75
|
+
): DiscoveredClaudeSession[] {
|
|
76
|
+
const projectsRoot = options.projectsRoot
|
|
77
|
+
|| process.env.CLAUDE_PROJECTS_DIR
|
|
78
|
+
|| join(homedir(), ".claude", "projects");
|
|
79
|
+
const requestedLimit = Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_DISCOVERY_LIMIT;
|
|
80
|
+
const limit = Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.floor(requestedLimit || DEFAULT_DISCOVERY_LIMIT)));
|
|
81
|
+
|
|
82
|
+
if (!existsSync(projectsRoot)) return [];
|
|
83
|
+
|
|
84
|
+
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = [];
|
|
85
|
+
let projectDirs: string[] = [];
|
|
86
|
+
try {
|
|
87
|
+
projectDirs = readdirSync(projectsRoot);
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const projectDir of projectDirs) {
|
|
93
|
+
const projectPath = join(projectsRoot, projectDir);
|
|
94
|
+
let stats: ReturnType<typeof statSync>;
|
|
95
|
+
try {
|
|
96
|
+
stats = statSync(projectPath);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!stats.isDirectory()) continue;
|
|
101
|
+
|
|
102
|
+
let entries: string[] = [];
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(projectPath);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
111
|
+
const filePath = join(projectPath, entry);
|
|
112
|
+
try {
|
|
113
|
+
const fileStats = statSync(filePath);
|
|
114
|
+
if (!fileStats.isFile()) continue;
|
|
115
|
+
sessionFiles.push({
|
|
116
|
+
filePath,
|
|
117
|
+
mtimeMs: fileStats.mtimeMs,
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip deleted/corrupt entries.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
126
|
+
|
|
127
|
+
const uniqueBySessionId = new Map<string, DiscoveredClaudeSession>();
|
|
128
|
+
for (const candidate of sessionFiles) {
|
|
129
|
+
if (uniqueBySessionId.size >= limit) break;
|
|
130
|
+
|
|
131
|
+
const metadata = extractMetadataFromJsonl(candidate.filePath);
|
|
132
|
+
if (!metadata) continue;
|
|
133
|
+
|
|
134
|
+
const prev = uniqueBySessionId.get(metadata.sessionId);
|
|
135
|
+
if (prev && prev.lastActivityAt >= candidate.mtimeMs) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
uniqueBySessionId.set(metadata.sessionId, {
|
|
140
|
+
sessionId: metadata.sessionId,
|
|
141
|
+
cwd: metadata.cwd,
|
|
142
|
+
gitBranch: metadata.gitBranch,
|
|
143
|
+
slug: metadata.slug,
|
|
144
|
+
lastActivityAt: candidate.mtimeMs,
|
|
145
|
+
sourceFile: candidate.filePath,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Array.from(uniqueBySessionId.values())
|
|
150
|
+
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
151
|
+
.slice(0, limit)
|
|
152
|
+
.map((session) => ({
|
|
153
|
+
...session,
|
|
154
|
+
// Defensive fallback if older records don't carry sessionId in JSONL.
|
|
155
|
+
sessionId: session.sessionId || basename(session.sourceFile, ".jsonl"),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { ContentBlock } from "./session-types.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_HISTORY_PAGE_LIMIT = 40;
|
|
7
|
+
const MAX_HISTORY_PAGE_LIMIT = 200;
|
|
8
|
+
|
|
9
|
+
interface ClaudeSessionHistoryMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
role: "user" | "assistant";
|
|
12
|
+
content: string;
|
|
13
|
+
contentBlocks?: ContentBlock[];
|
|
14
|
+
timestamp: number;
|
|
15
|
+
model?: string;
|
|
16
|
+
stopReason?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClaudeSessionHistoryPage {
|
|
20
|
+
sourceFile: string;
|
|
21
|
+
messages: ClaudeSessionHistoryMessage[];
|
|
22
|
+
nextCursor: number;
|
|
23
|
+
hasMore: boolean;
|
|
24
|
+
totalMessages: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ClaudeSessionHistoryPageOptions {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
cursor?: number;
|
|
30
|
+
limit?: number;
|
|
31
|
+
projectsRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TimelineMessage extends ClaudeSessionHistoryMessage {
|
|
35
|
+
order: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ParsedHistoryCacheEntry {
|
|
39
|
+
sourceFile: string;
|
|
40
|
+
mtimeMs: number;
|
|
41
|
+
messages: ClaudeSessionHistoryMessage[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsedHistoryCache = new Map<string, ParsedHistoryCacheEntry>();
|
|
45
|
+
|
|
46
|
+
function getProjectsRoot(projectsRoot?: string): string {
|
|
47
|
+
return projectsRoot
|
|
48
|
+
|| process.env.CLAUDE_PROJECTS_DIR
|
|
49
|
+
|| join(homedir(), ".claude", "projects");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getHistoryCacheKey(sessionId: string, projectsRoot: string): string {
|
|
53
|
+
return `${projectsRoot}::${sessionId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveSessionSourceFile(
|
|
57
|
+
sessionId: string,
|
|
58
|
+
projectsRoot: string,
|
|
59
|
+
): { sourceFile: string; mtimeMs: number } | null {
|
|
60
|
+
if (!sessionId || !existsSync(projectsRoot)) return null;
|
|
61
|
+
|
|
62
|
+
let projectDirs: string[] = [];
|
|
63
|
+
try {
|
|
64
|
+
projectDirs = readdirSync(projectsRoot);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let newest: { sourceFile: string; mtimeMs: number } | null = null;
|
|
70
|
+
for (const projectDir of projectDirs) {
|
|
71
|
+
const projectPath = join(projectsRoot, projectDir);
|
|
72
|
+
let projectStats: ReturnType<typeof statSync>;
|
|
73
|
+
try {
|
|
74
|
+
projectStats = statSync(projectPath);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!projectStats.isDirectory()) continue;
|
|
79
|
+
|
|
80
|
+
const candidate = join(projectPath, `${sessionId}.jsonl`);
|
|
81
|
+
let candidateStats: ReturnType<typeof statSync>;
|
|
82
|
+
try {
|
|
83
|
+
candidateStats = statSync(candidate);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!candidateStats.isFile()) continue;
|
|
88
|
+
if (!newest || candidateStats.mtimeMs > newest.mtimeMs) {
|
|
89
|
+
newest = { sourceFile: candidate, mtimeMs: candidateStats.mtimeMs };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return newest;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseTimestamp(raw: unknown, fallback: number): number {
|
|
97
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
98
|
+
if (typeof raw === "string") {
|
|
99
|
+
const parsed = Date.parse(raw);
|
|
100
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
101
|
+
}
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractUserContent(content: unknown): string {
|
|
106
|
+
if (typeof content === "string") return content.trim();
|
|
107
|
+
if (!Array.isArray(content)) return "";
|
|
108
|
+
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
for (const block of content) {
|
|
111
|
+
if (!block || typeof block !== "object") continue;
|
|
112
|
+
const typed = block as { type?: unknown; text?: unknown };
|
|
113
|
+
if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
|
|
114
|
+
parts.push(typed.text.trim());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return parts.join("\n").trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isCommandNoiseUserContent(content: string): boolean {
|
|
121
|
+
const trimmed = content.trim();
|
|
122
|
+
if (!trimmed) return true;
|
|
123
|
+
return trimmed.startsWith("<command-name>")
|
|
124
|
+
|| trimmed.startsWith("<command-message>")
|
|
125
|
+
|| trimmed.startsWith("<command-args>")
|
|
126
|
+
|| trimmed.startsWith("<local-command-caveat>")
|
|
127
|
+
|| trimmed.startsWith("<local-command-stdout>")
|
|
128
|
+
|| trimmed.startsWith("<local-command-stderr>");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toContentBlocks(raw: unknown): ContentBlock[] {
|
|
132
|
+
if (!Array.isArray(raw)) return [];
|
|
133
|
+
const out: ContentBlock[] = [];
|
|
134
|
+
|
|
135
|
+
for (const item of raw) {
|
|
136
|
+
if (!item || typeof item !== "object") continue;
|
|
137
|
+
const block = item as Record<string, unknown>;
|
|
138
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
139
|
+
out.push({ type: "text", text: block.text });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
143
|
+
out.push({
|
|
144
|
+
type: "thinking",
|
|
145
|
+
thinking: block.thinking,
|
|
146
|
+
budget_tokens: typeof block.budget_tokens === "number" ? block.budget_tokens : undefined,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
block.type === "tool_use"
|
|
152
|
+
&& typeof block.id === "string"
|
|
153
|
+
&& typeof block.name === "string"
|
|
154
|
+
&& block.input
|
|
155
|
+
&& typeof block.input === "object"
|
|
156
|
+
) {
|
|
157
|
+
out.push({
|
|
158
|
+
type: "tool_use",
|
|
159
|
+
id: block.id,
|
|
160
|
+
name: block.name,
|
|
161
|
+
input: block.input as Record<string, unknown>,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
166
|
+
let content: string | ContentBlock[] = "";
|
|
167
|
+
if (typeof block.content === "string") {
|
|
168
|
+
content = block.content;
|
|
169
|
+
} else if (Array.isArray(block.content)) {
|
|
170
|
+
content = toContentBlocks(block.content);
|
|
171
|
+
}
|
|
172
|
+
out.push({
|
|
173
|
+
type: "tool_result",
|
|
174
|
+
tool_use_id: block.tool_use_id,
|
|
175
|
+
content,
|
|
176
|
+
is_error: block.is_error === true,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractAssistantContent(blocks: ContentBlock[]): string {
|
|
185
|
+
return blocks
|
|
186
|
+
.map((block) => {
|
|
187
|
+
if (block.type === "text") return block.text;
|
|
188
|
+
if (block.type === "thinking") return block.thinking;
|
|
189
|
+
return "";
|
|
190
|
+
})
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.join("\n")
|
|
193
|
+
.trim();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mergeContentBlocks(
|
|
197
|
+
previous?: ContentBlock[],
|
|
198
|
+
next?: ContentBlock[],
|
|
199
|
+
): ContentBlock[] | undefined {
|
|
200
|
+
const prev = previous || [];
|
|
201
|
+
const nxt = next || [];
|
|
202
|
+
if (prev.length === 0 && nxt.length === 0) return undefined;
|
|
203
|
+
|
|
204
|
+
const merged: ContentBlock[] = [];
|
|
205
|
+
const seen = new Set<string>();
|
|
206
|
+
|
|
207
|
+
const pushUnique = (block: ContentBlock) => {
|
|
208
|
+
const key = JSON.stringify(block);
|
|
209
|
+
if (seen.has(key)) return;
|
|
210
|
+
seen.add(key);
|
|
211
|
+
merged.push(block);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const block of prev) pushUnique(block);
|
|
215
|
+
for (const block of nxt) pushUnique(block);
|
|
216
|
+
return merged;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function buildMessageId(
|
|
220
|
+
sessionId: string,
|
|
221
|
+
role: "user" | "assistant",
|
|
222
|
+
baseId: string,
|
|
223
|
+
): string {
|
|
224
|
+
return `resume-${sessionId}-${role}-${baseId}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseHistoryFile(
|
|
228
|
+
sessionId: string,
|
|
229
|
+
sourceFile: string,
|
|
230
|
+
): ClaudeSessionHistoryMessage[] {
|
|
231
|
+
let fileContent: string;
|
|
232
|
+
try {
|
|
233
|
+
fileContent = readFileSync(sourceFile, "utf-8");
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const timeline: TimelineMessage[] = [];
|
|
239
|
+
const assistantById = new Map<string, TimelineMessage>();
|
|
240
|
+
let lineOrder = 0;
|
|
241
|
+
|
|
242
|
+
for (const line of fileContent.split("\n")) {
|
|
243
|
+
const trimmed = line.trim();
|
|
244
|
+
if (!trimmed.startsWith("{")) continue;
|
|
245
|
+
|
|
246
|
+
let parsed: Record<string, unknown>;
|
|
247
|
+
try {
|
|
248
|
+
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
249
|
+
} catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (parsed.isSidechain === true) continue;
|
|
254
|
+
if (typeof parsed.sessionId === "string" && parsed.sessionId !== sessionId) continue;
|
|
255
|
+
|
|
256
|
+
const fallbackTs = Date.now() + lineOrder;
|
|
257
|
+
const timestamp = parseTimestamp(parsed.timestamp, fallbackTs);
|
|
258
|
+
const message = parsed.message as Record<string, unknown> | undefined;
|
|
259
|
+
const role = typeof message?.role === "string" ? message.role : null;
|
|
260
|
+
|
|
261
|
+
if (parsed.type === "user" && role === "user") {
|
|
262
|
+
if (parsed.isMeta === true) {
|
|
263
|
+
lineOrder++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const userContent = extractUserContent(message?.content);
|
|
267
|
+
if (!userContent || isCommandNoiseUserContent(userContent)) {
|
|
268
|
+
lineOrder++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const rawId =
|
|
272
|
+
(typeof parsed.uuid === "string" && parsed.uuid)
|
|
273
|
+
|| (typeof parsed.parentUuid === "string" && parsed.parentUuid)
|
|
274
|
+
|| String(lineOrder);
|
|
275
|
+
timeline.push({
|
|
276
|
+
id: buildMessageId(sessionId, "user", rawId),
|
|
277
|
+
role: "user",
|
|
278
|
+
content: userContent,
|
|
279
|
+
timestamp,
|
|
280
|
+
order: lineOrder,
|
|
281
|
+
});
|
|
282
|
+
lineOrder++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (parsed.type === "assistant" && role === "assistant") {
|
|
287
|
+
const rawAssistantId =
|
|
288
|
+
(typeof message?.id === "string" && message.id)
|
|
289
|
+
|| (typeof parsed.uuid === "string" && parsed.uuid)
|
|
290
|
+
|| String(lineOrder);
|
|
291
|
+
const assistantId = buildMessageId(sessionId, "assistant", rawAssistantId);
|
|
292
|
+
|
|
293
|
+
const incomingBlocks = toContentBlocks(message?.content);
|
|
294
|
+
const existing = assistantById.get(assistantId);
|
|
295
|
+
const mergedBlocks = mergeContentBlocks(existing?.contentBlocks, incomingBlocks);
|
|
296
|
+
const nextContent = mergedBlocks ? extractAssistantContent(mergedBlocks) : "";
|
|
297
|
+
|
|
298
|
+
if (existing) {
|
|
299
|
+
existing.contentBlocks = mergedBlocks;
|
|
300
|
+
existing.content = nextContent || existing.content;
|
|
301
|
+
existing.model =
|
|
302
|
+
(typeof message?.model === "string" ? message.model : undefined)
|
|
303
|
+
|| existing.model;
|
|
304
|
+
existing.stopReason =
|
|
305
|
+
(typeof message?.stop_reason === "string" || message?.stop_reason === null)
|
|
306
|
+
? (message.stop_reason as string | null)
|
|
307
|
+
: existing.stopReason;
|
|
308
|
+
} else {
|
|
309
|
+
const created: TimelineMessage = {
|
|
310
|
+
id: assistantId,
|
|
311
|
+
role: "assistant",
|
|
312
|
+
content: nextContent,
|
|
313
|
+
contentBlocks: mergedBlocks,
|
|
314
|
+
timestamp,
|
|
315
|
+
model: typeof message?.model === "string" ? message.model : undefined,
|
|
316
|
+
stopReason:
|
|
317
|
+
(typeof message?.stop_reason === "string" || message?.stop_reason === null)
|
|
318
|
+
? (message.stop_reason as string | null)
|
|
319
|
+
: null,
|
|
320
|
+
order: lineOrder,
|
|
321
|
+
};
|
|
322
|
+
assistantById.set(assistantId, created);
|
|
323
|
+
timeline.push(created);
|
|
324
|
+
}
|
|
325
|
+
lineOrder++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
lineOrder++;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return timeline
|
|
333
|
+
.filter((entry) => {
|
|
334
|
+
if (entry.role === "assistant") {
|
|
335
|
+
return (entry.content && entry.content.trim().length > 0)
|
|
336
|
+
|| (entry.contentBlocks && entry.contentBlocks.length > 0);
|
|
337
|
+
}
|
|
338
|
+
return entry.content.trim().length > 0;
|
|
339
|
+
})
|
|
340
|
+
.sort((a, b) => {
|
|
341
|
+
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
|
|
342
|
+
return a.order - b.order;
|
|
343
|
+
})
|
|
344
|
+
.map(({ order, ...entry }) => entry);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getParsedHistory(
|
|
348
|
+
sessionId: string,
|
|
349
|
+
projectsRoot: string,
|
|
350
|
+
): ParsedHistoryCacheEntry | null {
|
|
351
|
+
const resolved = resolveSessionSourceFile(sessionId, projectsRoot);
|
|
352
|
+
if (!resolved) return null;
|
|
353
|
+
|
|
354
|
+
const cacheKey = getHistoryCacheKey(sessionId, projectsRoot);
|
|
355
|
+
const cached = parsedHistoryCache.get(cacheKey);
|
|
356
|
+
if (
|
|
357
|
+
cached
|
|
358
|
+
&& cached.sourceFile === resolved.sourceFile
|
|
359
|
+
&& cached.mtimeMs === resolved.mtimeMs
|
|
360
|
+
) {
|
|
361
|
+
return cached;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const parsed: ParsedHistoryCacheEntry = {
|
|
365
|
+
sourceFile: resolved.sourceFile,
|
|
366
|
+
mtimeMs: resolved.mtimeMs,
|
|
367
|
+
messages: parseHistoryFile(sessionId, resolved.sourceFile),
|
|
368
|
+
};
|
|
369
|
+
parsedHistoryCache.set(cacheKey, parsed);
|
|
370
|
+
return parsed;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function getClaudeSessionHistoryPage(
|
|
374
|
+
options: ClaudeSessionHistoryPageOptions,
|
|
375
|
+
): ClaudeSessionHistoryPage | null {
|
|
376
|
+
const sessionId = options.sessionId.trim();
|
|
377
|
+
if (!sessionId) return null;
|
|
378
|
+
|
|
379
|
+
const projectsRoot = getProjectsRoot(options.projectsRoot);
|
|
380
|
+
const parsed = getParsedHistory(sessionId, projectsRoot);
|
|
381
|
+
if (!parsed) return null;
|
|
382
|
+
|
|
383
|
+
const totalMessages = parsed.messages.length;
|
|
384
|
+
const limit = Math.max(
|
|
385
|
+
1,
|
|
386
|
+
Math.min(
|
|
387
|
+
MAX_HISTORY_PAGE_LIMIT,
|
|
388
|
+
Number.isFinite(options.limit) ? Math.floor(options.limit as number) : DEFAULT_HISTORY_PAGE_LIMIT,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
const cursorInput = Number.isFinite(options.cursor) ? Math.floor(options.cursor as number) : 0;
|
|
392
|
+
const cursor = Math.max(0, Math.min(totalMessages, cursorInput));
|
|
393
|
+
|
|
394
|
+
const endExclusive = Math.max(0, totalMessages - cursor);
|
|
395
|
+
const start = Math.max(0, endExclusive - limit);
|
|
396
|
+
const messages = parsed.messages.slice(start, endExclusive);
|
|
397
|
+
const nextCursor = cursor + messages.length;
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
sourceFile: parsed.sourceFile,
|
|
401
|
+
messages,
|
|
402
|
+
nextCursor,
|
|
403
|
+
hasMore: start > 0,
|
|
404
|
+
totalMessages,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function clearClaudeSessionHistoryCacheForTests(): void {
|
|
409
|
+
parsedHistoryCache.clear();
|
|
410
|
+
}
|