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,457 @@
|
|
|
1
|
+
import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
|
|
2
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
3
|
+
import type { WorktreeTracker } from "./worktree-tracker.js";
|
|
4
|
+
import type { CreationStepId } from "./session-types.js";
|
|
5
|
+
import type { ContainerConfig, ContainerInfo } from "./container-manager.js";
|
|
6
|
+
import * as envManager from "./env-manager.js";
|
|
7
|
+
import * as sandboxManager from "./sandbox-manager.js";
|
|
8
|
+
import * as gitUtils from "./git-utils.js";
|
|
9
|
+
import { containerManager } from "./container-manager.js";
|
|
10
|
+
import { hasContainerClaudeAuth } from "./claude-container-auth.js";
|
|
11
|
+
import { hasContainerCodexAuth } from "./codex-container-auth.js";
|
|
12
|
+
import { imagePullManager } from "./image-pull-manager.js";
|
|
13
|
+
import { discoverCommandsAndSkills } from "./commands-discovery.js";
|
|
14
|
+
import { VSCODE_EDITOR_CONTAINER_PORT, CODEX_APP_SERVER_CONTAINER_PORT, NOVNC_CONTAINER_PORT } from "./constants.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export type ProgressCallback = (
|
|
21
|
+
step: CreationStepId,
|
|
22
|
+
label: string,
|
|
23
|
+
status: "in_progress" | "done" | "error",
|
|
24
|
+
detail?: string,
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
export interface SessionCreationDeps {
|
|
28
|
+
launcher: CliLauncher;
|
|
29
|
+
wsBridge: WsBridge;
|
|
30
|
+
worktreeTracker: WorktreeTracker;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionCreationResult {
|
|
34
|
+
session: SdkSessionInfo;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SessionCreationError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
message: string,
|
|
40
|
+
public readonly statusCode: number = 500,
|
|
41
|
+
public readonly step?: CreationStepId,
|
|
42
|
+
) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "SessionCreationError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helper: emit progress if a callback is provided (no-op otherwise)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
async function emit(
|
|
53
|
+
onProgress: ProgressCallback | undefined,
|
|
54
|
+
step: CreationStepId,
|
|
55
|
+
label: string,
|
|
56
|
+
status: "in_progress" | "done" | "error",
|
|
57
|
+
detail?: string,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
if (onProgress) {
|
|
60
|
+
await onProgress(step, label, status, detail);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Main service function
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export async function executeSessionCreation(
|
|
69
|
+
body: Record<string, unknown>,
|
|
70
|
+
deps: SessionCreationDeps,
|
|
71
|
+
onProgress?: ProgressCallback,
|
|
72
|
+
): Promise<SessionCreationResult> {
|
|
73
|
+
const { launcher, wsBridge, worktreeTracker } = deps;
|
|
74
|
+
|
|
75
|
+
// -- Parse input --
|
|
76
|
+
const resumeSessionAt =
|
|
77
|
+
typeof body.resumeSessionAt === "string" && (body.resumeSessionAt as string).trim()
|
|
78
|
+
? (body.resumeSessionAt as string).trim()
|
|
79
|
+
: undefined;
|
|
80
|
+
const forkSession = body.forkSession === true;
|
|
81
|
+
const backend = (body.backend as string) ?? "claude";
|
|
82
|
+
if (backend !== "claude" && backend !== "codex") {
|
|
83
|
+
throw new SessionCreationError(`Invalid backend: ${String(backend)}`, 400);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- Step: Resolve environment --
|
|
87
|
+
await emit(onProgress, "resolving_env", "Resolving environment...", "in_progress");
|
|
88
|
+
|
|
89
|
+
let envVars: Record<string, string> | undefined = body.env as Record<string, string> | undefined;
|
|
90
|
+
const heyhankEnv = body.envSlug ? envManager.getEnv(body.envSlug as string) : null;
|
|
91
|
+
if (body.envSlug && heyhankEnv) {
|
|
92
|
+
console.log(
|
|
93
|
+
`[session-creation] Injecting env "${heyhankEnv.name}" (${Object.keys(heyhankEnv.variables).length} vars):`,
|
|
94
|
+
Object.keys(heyhankEnv.variables).join(", "),
|
|
95
|
+
);
|
|
96
|
+
envVars = { ...heyhankEnv.variables, ...(body.env as Record<string, string>) };
|
|
97
|
+
} else if (body.envSlug) {
|
|
98
|
+
console.warn(`[session-creation] Environment "${body.envSlug}" not found, ignoring`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Resolve sandbox configuration
|
|
102
|
+
const sandboxEnabled = body.sandboxEnabled === true;
|
|
103
|
+
const sandbox = body.sandboxSlug ? sandboxManager.getSandbox(body.sandboxSlug as string) : null;
|
|
104
|
+
if (sandboxEnabled && body.sandboxSlug && !sandbox) {
|
|
105
|
+
throw new SessionCreationError(`Sandbox "${body.sandboxSlug}" not found`, 404, "resolving_env");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resolve Docker image early
|
|
109
|
+
let effectiveImage: string | null = null;
|
|
110
|
+
if (sandboxEnabled) {
|
|
111
|
+
effectiveImage = "the-companion:latest";
|
|
112
|
+
} else if ((body.container as Record<string, unknown>)?.image) {
|
|
113
|
+
effectiveImage = (body.container as Record<string, unknown>).image as string;
|
|
114
|
+
}
|
|
115
|
+
const isDockerSession = !!effectiveImage;
|
|
116
|
+
|
|
117
|
+
await emit(onProgress, "resolving_env", "Environment resolved", "done");
|
|
118
|
+
|
|
119
|
+
// -- Step: Git operations (host-only) --
|
|
120
|
+
let cwd = body.cwd as string | undefined;
|
|
121
|
+
let worktreeInfo: {
|
|
122
|
+
isWorktree: boolean;
|
|
123
|
+
repoRoot: string;
|
|
124
|
+
branch: string;
|
|
125
|
+
actualBranch: string;
|
|
126
|
+
worktreePath: string;
|
|
127
|
+
} | undefined;
|
|
128
|
+
|
|
129
|
+
// Validate branch name
|
|
130
|
+
if (body.branch && !/^[a-zA-Z0-9/_.\-]+$/.test(body.branch as string)) {
|
|
131
|
+
throw new SessionCreationError("Invalid branch name", 400, "checkout_branch");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isDockerSession && body.useWorktree && body.branch && cwd) {
|
|
135
|
+
const repoInfo = gitUtils.getRepoInfo(cwd);
|
|
136
|
+
if (repoInfo) {
|
|
137
|
+
await emit(onProgress, "fetching_git", "Fetching from remote...", "in_progress");
|
|
138
|
+
const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
|
|
139
|
+
if (!fetchResult.success) {
|
|
140
|
+
console.warn(`[session-creation] git fetch failed (non-fatal): ${fetchResult.output}`);
|
|
141
|
+
}
|
|
142
|
+
await emit(onProgress, "fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
|
|
143
|
+
|
|
144
|
+
await emit(onProgress, "creating_worktree", "Creating worktree...", "in_progress");
|
|
145
|
+
const result = gitUtils.ensureWorktree(repoInfo.repoRoot, body.branch as string, {
|
|
146
|
+
baseBranch: repoInfo.defaultBranch,
|
|
147
|
+
createBranch: body.createBranch as boolean | undefined,
|
|
148
|
+
forceNew: true,
|
|
149
|
+
});
|
|
150
|
+
cwd = result.worktreePath;
|
|
151
|
+
worktreeInfo = {
|
|
152
|
+
isWorktree: true,
|
|
153
|
+
repoRoot: repoInfo.repoRoot,
|
|
154
|
+
branch: body.branch as string,
|
|
155
|
+
actualBranch: result.actualBranch,
|
|
156
|
+
worktreePath: result.worktreePath,
|
|
157
|
+
};
|
|
158
|
+
await emit(onProgress, "creating_worktree", "Worktree ready", "done");
|
|
159
|
+
}
|
|
160
|
+
} else if (!isDockerSession && body.branch && cwd) {
|
|
161
|
+
const repoInfo = gitUtils.getRepoInfo(cwd);
|
|
162
|
+
if (repoInfo) {
|
|
163
|
+
await emit(onProgress, "fetching_git", "Fetching from remote...", "in_progress");
|
|
164
|
+
const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
|
|
165
|
+
if (!fetchResult.success) {
|
|
166
|
+
console.warn(`[session-creation] git fetch failed (non-fatal): ${fetchResult.output}`);
|
|
167
|
+
}
|
|
168
|
+
await emit(onProgress, "fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
|
|
169
|
+
|
|
170
|
+
if (repoInfo.currentBranch !== body.branch) {
|
|
171
|
+
await emit(onProgress, "checkout_branch", `Checking out ${body.branch}...`, "in_progress");
|
|
172
|
+
gitUtils.checkoutOrCreateBranch(repoInfo.repoRoot, body.branch as string, {
|
|
173
|
+
createBranch: body.createBranch as boolean | undefined,
|
|
174
|
+
defaultBranch: repoInfo.defaultBranch,
|
|
175
|
+
});
|
|
176
|
+
await emit(onProgress, "checkout_branch", `On branch ${body.branch}`, "done");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await emit(onProgress, "pulling_git", "Pulling latest changes...", "in_progress");
|
|
180
|
+
const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
|
|
181
|
+
if (!pullResult.success) {
|
|
182
|
+
console.warn(`[session-creation] git pull warning (non-fatal): ${pullResult.output}`);
|
|
183
|
+
}
|
|
184
|
+
await emit(onProgress, "pulling_git", "Up to date", "done");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -- Step: Container creation --
|
|
189
|
+
let containerInfo: ContainerInfo | undefined;
|
|
190
|
+
let containerId: string | undefined;
|
|
191
|
+
let containerName: string | undefined;
|
|
192
|
+
let containerImage: string | undefined;
|
|
193
|
+
let tempId: string | undefined;
|
|
194
|
+
|
|
195
|
+
// Validate cwd before container operations (cwd! assertions below rely on this)
|
|
196
|
+
if (effectiveImage && !cwd) {
|
|
197
|
+
throw new SessionCreationError(
|
|
198
|
+
"Working directory (cwd) is required for containerized sessions",
|
|
199
|
+
400,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Auth checks for containerized sessions
|
|
204
|
+
if (effectiveImage && backend === "claude" && !hasContainerClaudeAuth(envVars)) {
|
|
205
|
+
throw new SessionCreationError(
|
|
206
|
+
"Containerized Claude requires auth available inside the container. " +
|
|
207
|
+
"Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_AUTH_TOKEN) in the selected environment.",
|
|
208
|
+
400,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (effectiveImage && backend === "codex" && !hasContainerCodexAuth(envVars)) {
|
|
212
|
+
throw new SessionCreationError(
|
|
213
|
+
"Containerized Codex requires auth available inside the container. " +
|
|
214
|
+
"Set OPENAI_API_KEY in the selected environment, or ensure ~/.codex/auth.json exists on the host.",
|
|
215
|
+
400,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (effectiveImage) {
|
|
220
|
+
// -- Image pull --
|
|
221
|
+
if (!imagePullManager.isReady(effectiveImage)) {
|
|
222
|
+
const pullState = imagePullManager.getState(effectiveImage);
|
|
223
|
+
if (pullState.status === "idle" || pullState.status === "error") {
|
|
224
|
+
imagePullManager.ensureImage(effectiveImage);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await emit(onProgress, "pulling_image", "Pulling Docker image...", "in_progress");
|
|
228
|
+
|
|
229
|
+
// Stream pull progress lines if the caller wants progress
|
|
230
|
+
let unsub: (() => void) | undefined;
|
|
231
|
+
if (onProgress) {
|
|
232
|
+
unsub = imagePullManager.onProgress(effectiveImage, (line) => {
|
|
233
|
+
emit(onProgress, "pulling_image", "Pulling Docker image...", "in_progress", line).catch(() => {});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
|
|
238
|
+
unsub?.();
|
|
239
|
+
|
|
240
|
+
if (ready) {
|
|
241
|
+
await emit(onProgress, "pulling_image", "Image ready", "done");
|
|
242
|
+
} else {
|
|
243
|
+
const state = imagePullManager.getState(effectiveImage);
|
|
244
|
+
throw new SessionCreationError(
|
|
245
|
+
state.error ||
|
|
246
|
+
`Docker image ${effectiveImage} could not be pulled or built. Use the environment manager to pull/build the image first.`,
|
|
247
|
+
503,
|
|
248
|
+
"pulling_image",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -- Create container --
|
|
254
|
+
await emit(onProgress, "creating_container", "Starting container...", "in_progress");
|
|
255
|
+
tempId = crypto.randomUUID().slice(0, 8);
|
|
256
|
+
const requestedPorts = Array.isArray((body.container as Record<string, unknown>)?.ports)
|
|
257
|
+
? ((body.container as Record<string, unknown>).ports as number[]).map(Number).filter((n: number) => n > 0)
|
|
258
|
+
: [];
|
|
259
|
+
const containerPorts: (number | { port: number; hostIp?: string })[] = [
|
|
260
|
+
...Array.from(new Set([
|
|
261
|
+
...requestedPorts.filter((p: number) => p !== NOVNC_CONTAINER_PORT),
|
|
262
|
+
VSCODE_EDITOR_CONTAINER_PORT,
|
|
263
|
+
...(backend === "codex" ? [CODEX_APP_SERVER_CONTAINER_PORT] : []),
|
|
264
|
+
])),
|
|
265
|
+
{ port: NOVNC_CONTAINER_PORT, hostIp: "127.0.0.1" },
|
|
266
|
+
];
|
|
267
|
+
const cConfig: ContainerConfig = {
|
|
268
|
+
image: effectiveImage,
|
|
269
|
+
ports: containerPorts,
|
|
270
|
+
volumes: (body.container as Record<string, unknown>)?.volumes as string[] | undefined,
|
|
271
|
+
env: { ...(envVars ?? {}), DISPLAY: ":99" },
|
|
272
|
+
privileged: sandboxEnabled && effectiveImage === "the-companion:latest",
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
278
|
+
throw new SessionCreationError(
|
|
279
|
+
`Docker is required to run this environment image (${effectiveImage}) ` +
|
|
280
|
+
`but container startup failed: ${reason}`,
|
|
281
|
+
503,
|
|
282
|
+
"creating_container",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
containerId = containerInfo.containerId;
|
|
286
|
+
containerName = containerInfo.name;
|
|
287
|
+
containerImage = effectiveImage;
|
|
288
|
+
await emit(onProgress, "creating_container", "Container running", "done");
|
|
289
|
+
|
|
290
|
+
// -- Copy workspace --
|
|
291
|
+
await emit(onProgress, "copying_workspace", "Copying workspace files...", "in_progress");
|
|
292
|
+
try {
|
|
293
|
+
await containerManager.copyWorkspaceToContainer(containerInfo.containerId, cwd!);
|
|
294
|
+
containerManager.reseedGitAuth(containerInfo.containerId);
|
|
295
|
+
await emit(onProgress, "copying_workspace", "Workspace copied", "done");
|
|
296
|
+
} catch (err) {
|
|
297
|
+
containerManager.removeContainer(tempId);
|
|
298
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
299
|
+
throw new SessionCreationError(
|
|
300
|
+
`Failed to copy workspace to container: ${reason}`,
|
|
301
|
+
503,
|
|
302
|
+
"copying_workspace",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -- Git ops in container --
|
|
307
|
+
if (body.branch) {
|
|
308
|
+
const repoInfo = cwd ? gitUtils.getRepoInfo(cwd) : null;
|
|
309
|
+
|
|
310
|
+
await emit(onProgress, "fetching_git", "Fetching from remote (in container)...", "in_progress");
|
|
311
|
+
const gitResult = containerManager.gitOpsInContainer(containerInfo.containerId, {
|
|
312
|
+
branch: body.branch as string,
|
|
313
|
+
currentBranch: repoInfo?.currentBranch || "HEAD",
|
|
314
|
+
createBranch: body.createBranch as boolean | undefined,
|
|
315
|
+
defaultBranch: repoInfo?.defaultBranch,
|
|
316
|
+
});
|
|
317
|
+
await emit(onProgress, "fetching_git", gitResult.fetchOk ? "Fetch complete" : "Fetch skipped", "done");
|
|
318
|
+
|
|
319
|
+
if (repoInfo?.currentBranch !== body.branch) {
|
|
320
|
+
await emit(
|
|
321
|
+
onProgress,
|
|
322
|
+
"checkout_branch",
|
|
323
|
+
gitResult.checkoutOk ? `On branch ${body.branch}` : "Checkout failed",
|
|
324
|
+
gitResult.checkoutOk ? "done" : "error",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await emit(onProgress, "pulling_git", gitResult.pullOk ? "Up to date" : "Pull skipped", "done");
|
|
329
|
+
|
|
330
|
+
if (gitResult.errors.length > 0) {
|
|
331
|
+
console.warn(`[session-creation] In-container git ops warnings: ${gitResult.errors.join("; ")}`);
|
|
332
|
+
}
|
|
333
|
+
if (!gitResult.checkoutOk) {
|
|
334
|
+
containerManager.removeContainer(tempId);
|
|
335
|
+
throw new SessionCreationError(
|
|
336
|
+
`Failed to checkout branch "${body.branch}" inside container: ${gitResult.errors.join("; ")}`,
|
|
337
|
+
400,
|
|
338
|
+
"checkout_branch",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// -- Init script --
|
|
344
|
+
const initScript = sandbox?.initScript?.trim();
|
|
345
|
+
if (initScript) {
|
|
346
|
+
await emit(onProgress, "running_init_script", "Running init script...", "in_progress");
|
|
347
|
+
try {
|
|
348
|
+
const initTimeout = Number(process.env.HEYHANK_INIT_SCRIPT_TIMEOUT || process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
|
|
349
|
+
const result = await containerManager.execInContainerAsync(
|
|
350
|
+
containerInfo.containerId,
|
|
351
|
+
["sh", "-lc", initScript],
|
|
352
|
+
{
|
|
353
|
+
timeout: initTimeout,
|
|
354
|
+
onOutput: onProgress
|
|
355
|
+
? (line) => {
|
|
356
|
+
emit(onProgress, "running_init_script", "Running init script...", "in_progress", line).catch(() => {});
|
|
357
|
+
}
|
|
358
|
+
: undefined,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
if (result.exitCode !== 0) {
|
|
362
|
+
console.error(
|
|
363
|
+
`[session-creation] Init script failed for sandbox "${sandbox?.name || "sandbox"}" (exit ${result.exitCode}):\n${result.output}`,
|
|
364
|
+
);
|
|
365
|
+
containerManager.removeContainer(tempId);
|
|
366
|
+
const truncated =
|
|
367
|
+
result.output.length > 2000
|
|
368
|
+
? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
|
|
369
|
+
: result.output;
|
|
370
|
+
throw new SessionCreationError(
|
|
371
|
+
`Init script failed (exit ${result.exitCode}):\n${truncated}`,
|
|
372
|
+
503,
|
|
373
|
+
"running_init_script",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
console.log(`[session-creation] Init script completed successfully for sandbox "${sandbox?.name || "sandbox"}"`);
|
|
377
|
+
await emit(onProgress, "running_init_script", "Init script complete", "done");
|
|
378
|
+
} catch (e) {
|
|
379
|
+
if (e instanceof SessionCreationError) throw e;
|
|
380
|
+
containerManager.removeContainer(tempId);
|
|
381
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
382
|
+
throw new SessionCreationError(
|
|
383
|
+
`Init script execution failed: ${reason}`,
|
|
384
|
+
503,
|
|
385
|
+
"running_init_script",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// -- Step: Launch CLI --
|
|
392
|
+
await emit(
|
|
393
|
+
onProgress,
|
|
394
|
+
"launching_cli",
|
|
395
|
+
`Launching ${backend === "codex" ? "Codex" : "Claude Code"}...`,
|
|
396
|
+
"in_progress",
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
let session: SdkSessionInfo;
|
|
400
|
+
try {
|
|
401
|
+
session = launcher.launch({
|
|
402
|
+
model: body.model as string | undefined,
|
|
403
|
+
permissionMode: body.permissionMode as string | undefined,
|
|
404
|
+
cwd,
|
|
405
|
+
claudeBinary: body.claudeBinary as string | undefined,
|
|
406
|
+
codexBinary: body.codexBinary as string | undefined,
|
|
407
|
+
codexInternetAccess: backend === "codex",
|
|
408
|
+
codexSandbox: backend === "codex" ? "danger-full-access" : undefined,
|
|
409
|
+
allowedTools: body.allowedTools as string[] | undefined,
|
|
410
|
+
env: envVars,
|
|
411
|
+
backendType: backend,
|
|
412
|
+
containerId,
|
|
413
|
+
containerName,
|
|
414
|
+
containerImage,
|
|
415
|
+
containerCwd: containerInfo?.containerCwd,
|
|
416
|
+
resumeSessionAt,
|
|
417
|
+
forkSession,
|
|
418
|
+
systemPrompt: undefined,
|
|
419
|
+
sandboxSlug: sandboxEnabled ? ((body.sandboxSlug as string) || undefined) : undefined,
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (tempId) containerManager.removeContainer(tempId);
|
|
423
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
424
|
+
throw new SessionCreationError(
|
|
425
|
+
`Failed to launch CLI: ${reason}`,
|
|
426
|
+
503,
|
|
427
|
+
"launching_cli",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// -- Post-launch tracking --
|
|
432
|
+
if (containerInfo) {
|
|
433
|
+
containerManager.retrack(containerInfo.containerId, session.sessionId);
|
|
434
|
+
wsBridge.markContainerized(session.sessionId, cwd!);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (worktreeInfo) {
|
|
438
|
+
worktreeTracker.addMapping({
|
|
439
|
+
sessionId: session.sessionId,
|
|
440
|
+
repoRoot: worktreeInfo.repoRoot,
|
|
441
|
+
branch: worktreeInfo.branch,
|
|
442
|
+
actualBranch: worktreeInfo.actualBranch,
|
|
443
|
+
worktreePath: worktreeInfo.worktreePath,
|
|
444
|
+
createdAt: Date.now(),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const discovered = await discoverCommandsAndSkills(cwd).catch(() => ({
|
|
449
|
+
slash_commands: [] as string[],
|
|
450
|
+
skills: [] as string[],
|
|
451
|
+
}));
|
|
452
|
+
wsBridge.prePopulateCommands(session.sessionId, discovered.slash_commands, discovered.skills);
|
|
453
|
+
|
|
454
|
+
await emit(onProgress, "launching_cli", "Session started", "done");
|
|
455
|
+
|
|
456
|
+
return { session };
|
|
457
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { SessionState } from "./session-types.js";
|
|
4
|
+
import { containerManager } from "./container-manager.js";
|
|
5
|
+
|
|
6
|
+
function shellEscapeSingle(value: string): string {
|
|
7
|
+
return value.replace(/'/g, "'\\''");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function runGitCommand(sessionId: string, state: SessionState, command: string): string {
|
|
11
|
+
if (state.is_containerized) {
|
|
12
|
+
const container = containerManager.getContainer(sessionId);
|
|
13
|
+
if (container?.containerId) {
|
|
14
|
+
const containerCwd = container.containerCwd || "/workspace";
|
|
15
|
+
const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
|
|
16
|
+
const dockerCmd = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
|
|
17
|
+
return execSync(dockerCmd, { encoding: "utf-8", timeout: 3000 }).trim();
|
|
18
|
+
}
|
|
19
|
+
throw new Error("container not tracked");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return execSync(command, {
|
|
23
|
+
cwd: state.cwd,
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
timeout: 3000,
|
|
26
|
+
}).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapContainerPathToHost(sessionId: string, state: SessionState, pathValue: string): string {
|
|
30
|
+
if (!state.is_containerized || !pathValue) return pathValue;
|
|
31
|
+
const container = containerManager.getContainer(sessionId);
|
|
32
|
+
const containerCwd = (container?.containerCwd || "/workspace").replace(/\/+$/, "") || "/";
|
|
33
|
+
const hostCwd = (container?.hostCwd || state.cwd || "").replace(/\/+$/, "") || "/";
|
|
34
|
+
|
|
35
|
+
if (pathValue === containerCwd) return hostCwd;
|
|
36
|
+
if (containerCwd !== "/" && pathValue.startsWith(`${containerCwd}/`)) {
|
|
37
|
+
return `${hostCwd}${pathValue.slice(containerCwd.length)}`;
|
|
38
|
+
}
|
|
39
|
+
return pathValue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveSessionGitInfo(sessionId: string, state: SessionState): void {
|
|
43
|
+
if (!state.cwd) return;
|
|
44
|
+
const wasContainerized = state.is_containerized;
|
|
45
|
+
const previous = {
|
|
46
|
+
git_branch: state.git_branch,
|
|
47
|
+
is_worktree: state.is_worktree,
|
|
48
|
+
repo_root: state.repo_root,
|
|
49
|
+
git_ahead: state.git_ahead,
|
|
50
|
+
git_behind: state.git_behind,
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
state.git_branch = runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const gitDir = runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
|
|
57
|
+
state.is_worktree = gitDir.includes("/worktrees/");
|
|
58
|
+
} catch {
|
|
59
|
+
state.is_worktree = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (state.is_worktree) {
|
|
64
|
+
const commonDir = runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
|
|
65
|
+
state.repo_root = resolve(state.cwd, commonDir, "..");
|
|
66
|
+
} else {
|
|
67
|
+
state.repo_root = runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
|
|
68
|
+
}
|
|
69
|
+
state.repo_root = mapContainerPathToHost(sessionId, state, state.repo_root);
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore repo root resolution failures
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const counts = runGitCommand(
|
|
76
|
+
sessionId,
|
|
77
|
+
state,
|
|
78
|
+
"git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
|
|
79
|
+
);
|
|
80
|
+
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
|
81
|
+
state.git_ahead = ahead || 0;
|
|
82
|
+
state.git_behind = behind || 0;
|
|
83
|
+
} catch {
|
|
84
|
+
state.git_ahead = 0;
|
|
85
|
+
state.git_behind = 0;
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (state.is_containerized && error instanceof Error && error.message === "container not tracked") {
|
|
89
|
+
state.git_branch = previous.git_branch;
|
|
90
|
+
state.is_worktree = previous.is_worktree;
|
|
91
|
+
state.repo_root = previous.repo_root;
|
|
92
|
+
state.git_ahead = previous.git_ahead;
|
|
93
|
+
state.git_behind = previous.git_behind;
|
|
94
|
+
state.is_containerized = wasContainerized;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
state.git_branch = "";
|
|
98
|
+
state.is_worktree = false;
|
|
99
|
+
state.repo_root = "";
|
|
100
|
+
state.git_ahead = 0;
|
|
101
|
+
state.git_behind = 0;
|
|
102
|
+
}
|
|
103
|
+
state.is_containerized = wasContainerized;
|
|
104
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PATH = join(HEYHANK_HOME, "session-names.json");
|
|
13
|
+
|
|
14
|
+
// ─── Store ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
let names: Record<string, string> = {};
|
|
17
|
+
let loaded = false;
|
|
18
|
+
let filePath = DEFAULT_PATH;
|
|
19
|
+
|
|
20
|
+
function ensureLoaded(): void {
|
|
21
|
+
if (loaded) return;
|
|
22
|
+
try {
|
|
23
|
+
if (existsSync(filePath)) {
|
|
24
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
25
|
+
names = JSON.parse(raw) as Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
names = {};
|
|
29
|
+
}
|
|
30
|
+
loaded = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function persist(): void {
|
|
34
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
35
|
+
writeFileSync(filePath, JSON.stringify(names, null, 2), "utf-8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function getName(sessionId: string): string | undefined {
|
|
41
|
+
ensureLoaded();
|
|
42
|
+
return names[sessionId];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setName(sessionId: string, name: string): void {
|
|
46
|
+
ensureLoaded();
|
|
47
|
+
names[sessionId] = name;
|
|
48
|
+
persist();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getAllNames(): Record<string, string> {
|
|
52
|
+
ensureLoaded();
|
|
53
|
+
return { ...names };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removeName(sessionId: string): void {
|
|
57
|
+
ensureLoaded();
|
|
58
|
+
delete names[sessionId];
|
|
59
|
+
persist();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reset internal state and optionally set a custom file path (for testing). */
|
|
63
|
+
export function _resetForTest(customPath?: string): void {
|
|
64
|
+
names = {};
|
|
65
|
+
loaded = false;
|
|
66
|
+
filePath = customPath || DEFAULT_PATH;
|
|
67
|
+
}
|