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,824 @@
|
|
|
1
|
+
import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
|
|
2
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
3
|
+
import type { SessionStore } from "./session-store.js";
|
|
4
|
+
import type { WorktreeTracker } from "./worktree-tracker.js";
|
|
5
|
+
import type { AgentExecutor } from "./agent-executor.js";
|
|
6
|
+
import type { BackendType, CreationStepId } from "./session-types.js";
|
|
7
|
+
import type { ContainerConfig, ContainerInfo } from "./container-manager.js";
|
|
8
|
+
import { containerManager } from "./container-manager.js";
|
|
9
|
+
import { imagePullManager } from "./image-pull-manager.js";
|
|
10
|
+
import * as envManager from "./env-manager.js";
|
|
11
|
+
import * as sandboxManager from "./sandbox-manager.js";
|
|
12
|
+
import * as gitUtils from "./git-utils.js";
|
|
13
|
+
import * as sessionNames from "./session-names.js";
|
|
14
|
+
import { hasContainerClaudeAuth } from "./claude-container-auth.js";
|
|
15
|
+
import { hasContainerCodexAuth } from "./codex-container-auth.js";
|
|
16
|
+
import { discoverCommandsAndSkills } from "./commands-discovery.js";
|
|
17
|
+
import { getSettings } from "./settings-manager.js";
|
|
18
|
+
import { generateSessionTitle } from "./auto-namer.js";
|
|
19
|
+
import { heyHankBus } from "./event-bus.js";
|
|
20
|
+
import { metricsCollector } from "./metrics-collector.js";
|
|
21
|
+
import { log } from "./logger.js";
|
|
22
|
+
|
|
23
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const MAX_AUTO_RELAUNCHES = 3;
|
|
26
|
+
const RELAUNCH_GRACE_MS = 10_000;
|
|
27
|
+
const RELAUNCH_COOLDOWN_MS = 5_000;
|
|
28
|
+
const RECONNECT_GRACE_MS = Number(process.env.HEYHANK_RECONNECT_GRACE_MS || process.env.COMPANION_RECONNECT_GRACE_MS || "30000");
|
|
29
|
+
|
|
30
|
+
const VSCODE_EDITOR_CONTAINER_PORT = 13337;
|
|
31
|
+
const CODEX_APP_SERVER_CONTAINER_PORT = Number(
|
|
32
|
+
process.env.HEYHANK_CODEX_CONTAINER_WS_PORT || process.env.COMPANION_CODEX_CONTAINER_WS_PORT || "4502",
|
|
33
|
+
);
|
|
34
|
+
const NOVNC_CONTAINER_PORT = 6080;
|
|
35
|
+
|
|
36
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface SessionOrchestratorDeps {
|
|
39
|
+
launcher: CliLauncher;
|
|
40
|
+
wsBridge: WsBridge;
|
|
41
|
+
sessionStore: SessionStore;
|
|
42
|
+
worktreeTracker: WorktreeTracker;
|
|
43
|
+
prPoller: {
|
|
44
|
+
watch(sessionId: string, cwd: string, branch: string): void;
|
|
45
|
+
unwatch(sessionId: string): void;
|
|
46
|
+
};
|
|
47
|
+
agentExecutor: AgentExecutor;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreateSessionRequest {
|
|
51
|
+
backend?: string;
|
|
52
|
+
model?: string;
|
|
53
|
+
permissionMode?: string;
|
|
54
|
+
cwd?: string;
|
|
55
|
+
claudeBinary?: string;
|
|
56
|
+
codexBinary?: string;
|
|
57
|
+
allowedTools?: string[];
|
|
58
|
+
env?: Record<string, string>;
|
|
59
|
+
envSlug?: string;
|
|
60
|
+
sandboxEnabled?: boolean;
|
|
61
|
+
sandboxSlug?: string;
|
|
62
|
+
branch?: string;
|
|
63
|
+
createBranch?: boolean;
|
|
64
|
+
useWorktree?: boolean;
|
|
65
|
+
container?: { image?: string; ports?: number[]; volumes?: string[] };
|
|
66
|
+
resumeSessionAt?: string;
|
|
67
|
+
forkSession?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CreateSessionResult =
|
|
71
|
+
| { ok: true; session: SdkSessionInfo }
|
|
72
|
+
| { ok: false; error: string; status: number };
|
|
73
|
+
|
|
74
|
+
export type ProgressCallback = (
|
|
75
|
+
step: CreationStepId,
|
|
76
|
+
label: string,
|
|
77
|
+
status: "in_progress" | "done" | "error",
|
|
78
|
+
detail?: string,
|
|
79
|
+
) => Promise<void>;
|
|
80
|
+
|
|
81
|
+
export interface ArchiveSessionOptions {
|
|
82
|
+
force?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ArchiveSessionResult {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DeleteSessionResult {
|
|
91
|
+
ok: boolean;
|
|
92
|
+
worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Orchestrator ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Single entry point for session lifecycle operations: create, resume,
|
|
99
|
+
* reconnect, and terminate. Coordinates between CliLauncher (process
|
|
100
|
+
* management), WsBridge (message routing), and SessionStore (persistence).
|
|
101
|
+
*/
|
|
102
|
+
export class SessionOrchestrator {
|
|
103
|
+
private launcher: CliLauncher;
|
|
104
|
+
private wsBridge: WsBridge;
|
|
105
|
+
private sessionStore: SessionStore;
|
|
106
|
+
private worktreeTracker: WorktreeTracker;
|
|
107
|
+
private prPoller: SessionOrchestratorDeps["prPoller"];
|
|
108
|
+
private agentExecutor: AgentExecutor;
|
|
109
|
+
|
|
110
|
+
// Auto-relaunch state
|
|
111
|
+
private relaunchingSet = new Set<string>();
|
|
112
|
+
private autoRelaunchCounts = new Map<string, number>();
|
|
113
|
+
// Sessions that have already been notified about relaunch exhaustion.
|
|
114
|
+
// Prevents repeated "keeps crashing" warnings for dead sessions.
|
|
115
|
+
private relaunchExhaustedNotified = new Set<string>();
|
|
116
|
+
|
|
117
|
+
// Idempotency guard for initialize()
|
|
118
|
+
private _initialized = false;
|
|
119
|
+
|
|
120
|
+
// Event listeners
|
|
121
|
+
private exitCallbacks: ((sessionId: string, exitCode: number | null) => void)[] = [];
|
|
122
|
+
|
|
123
|
+
constructor(deps: SessionOrchestratorDeps) {
|
|
124
|
+
this.launcher = deps.launcher;
|
|
125
|
+
this.wsBridge = deps.wsBridge;
|
|
126
|
+
this.sessionStore = deps.sessionStore;
|
|
127
|
+
this.worktreeTracker = deps.worktreeTracker;
|
|
128
|
+
this.prPoller = deps.prPoller;
|
|
129
|
+
this.agentExecutor = deps.agentExecutor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Initialization (event wiring) ──────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
initialize(): void {
|
|
135
|
+
if (this._initialized) return;
|
|
136
|
+
this._initialized = true;
|
|
137
|
+
|
|
138
|
+
// When the CLI reports its internal session_id, store it for --resume
|
|
139
|
+
heyHankBus.on("session:cli-id-received", ({ sessionId, cliSessionId }) => {
|
|
140
|
+
this.launcher.setCLISessionId(sessionId, cliSessionId);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// When a Codex adapter is created, attach it to the WsBridge
|
|
144
|
+
heyHankBus.on("backend:codex-adapter-created", ({ sessionId, adapter }) => {
|
|
145
|
+
this.wsBridge.attachBackendAdapter(sessionId, adapter, "codex");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// When a CLI/Codex process exits, notify agent executor and external listeners
|
|
149
|
+
// separately so a throw in one doesn't skip the other (bus isolates each handler).
|
|
150
|
+
heyHankBus.on("session:exited", ({ sessionId, exitCode }) => {
|
|
151
|
+
this.agentExecutor.handleSessionExited(sessionId, exitCode);
|
|
152
|
+
});
|
|
153
|
+
heyHankBus.on("session:exited", ({ sessionId, exitCode }) => {
|
|
154
|
+
for (const cb of this.exitCallbacks) {
|
|
155
|
+
try {
|
|
156
|
+
cb(sessionId, exitCode);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error("[orchestrator] exitCallback error:", err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
heyHankBus.on("session:exited", ({ sessionId }) => {
|
|
163
|
+
const session = this.wsBridge.getSession(sessionId);
|
|
164
|
+
if (session?.stateMachine) {
|
|
165
|
+
session.stateMachine.transition("terminated", "process_exited");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Start watching PRs when git info is resolved
|
|
170
|
+
heyHankBus.on("session:git-info-ready", ({ sessionId, cwd, branch }) => {
|
|
171
|
+
this.prPoller.watch(sessionId, cwd, branch);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Auto-relaunch CLI when a browser connects to a session with no CLI
|
|
175
|
+
heyHankBus.on("session:relaunch-needed", async ({ sessionId }) => {
|
|
176
|
+
await this.handleAutoRelaunch(sessionId);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Kill CLI process when idle with no browsers for 24 hours.
|
|
180
|
+
// Only kills the CLI process — containers are preserved so the session
|
|
181
|
+
// can be relaunched without recreating the container.
|
|
182
|
+
heyHankBus.on("session:idle-kill", async ({ sessionId }) => {
|
|
183
|
+
const info = this.launcher.getSession(sessionId);
|
|
184
|
+
if (!info || info.archived) return;
|
|
185
|
+
log.info("orchestrator", "Idle-killing session (preserving container)", { sessionId, reason: "no browsers, no activity" });
|
|
186
|
+
await this.launcher.kill(sessionId);
|
|
187
|
+
// Clear relaunch counters so the session gets a fresh budget when the user
|
|
188
|
+
// returns. Idle-kill is intentional cleanup, not a crash — the session
|
|
189
|
+
// should be fully relaunchable.
|
|
190
|
+
this.clearAutoRelaunchCount(sessionId);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Auto-generate session title after first turn completes
|
|
194
|
+
heyHankBus.on("session:first-turn-completed", async ({ sessionId, firstUserMessage }) => {
|
|
195
|
+
await this.handleAutoNaming(sessionId, firstUserMessage);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Reconnection watchdog for stale sessions after server restart
|
|
199
|
+
this.startReconnectionWatchdog();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Session Creation ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async createSession(body: CreateSessionRequest): Promise<CreateSessionResult> {
|
|
205
|
+
return this.doCreateSession(body);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async createSessionStreaming(
|
|
209
|
+
body: CreateSessionRequest,
|
|
210
|
+
onProgress: ProgressCallback,
|
|
211
|
+
): Promise<CreateSessionResult> {
|
|
212
|
+
return this.doCreateSession(body, onProgress);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async doCreateSession(
|
|
216
|
+
body: CreateSessionRequest,
|
|
217
|
+
onProgress?: ProgressCallback,
|
|
218
|
+
): Promise<CreateSessionResult> {
|
|
219
|
+
try {
|
|
220
|
+
const resumeSessionAt =
|
|
221
|
+
typeof body.resumeSessionAt === "string" && body.resumeSessionAt.trim()
|
|
222
|
+
? body.resumeSessionAt.trim()
|
|
223
|
+
: undefined;
|
|
224
|
+
const forkSession = body.forkSession === true;
|
|
225
|
+
const backend = (body.backend ?? "claude") as BackendType;
|
|
226
|
+
if (backend !== "claude" && backend !== "codex") {
|
|
227
|
+
return { ok: false, error: `Invalid backend: ${String(body.backend)}`, status: 400 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Step: Resolve environment ---
|
|
231
|
+
if (onProgress) await onProgress("resolving_env", "Resolving environment...", "in_progress");
|
|
232
|
+
|
|
233
|
+
let envVars: Record<string, string> | undefined = body.env;
|
|
234
|
+
const heyhankEnv = body.envSlug ? envManager.getEnv(body.envSlug) : null;
|
|
235
|
+
if (body.envSlug && heyhankEnv) {
|
|
236
|
+
console.log(
|
|
237
|
+
`[orchestrator] Injecting env "${heyhankEnv.name}" (${Object.keys(heyhankEnv.variables).length} vars):`,
|
|
238
|
+
Object.keys(heyhankEnv.variables).join(", "),
|
|
239
|
+
);
|
|
240
|
+
envVars = { ...heyhankEnv.variables, ...body.env };
|
|
241
|
+
} else if (body.envSlug) {
|
|
242
|
+
console.warn(`[orchestrator] Environment "${body.envSlug}" not found, ignoring`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Inject provider tokens from global settings (if not already set by env profile).
|
|
246
|
+
// Note: these tokens also flow into containerized sessions intentionally — the
|
|
247
|
+
// global onboarding tokens serve as defaults for all session types, including
|
|
248
|
+
// containers, so that container auth preflight checks pass automatically.
|
|
249
|
+
const globalSettings = getSettings();
|
|
250
|
+
if (backend === "claude" && globalSettings.claudeCodeOAuthToken && !("CLAUDE_CODE_OAUTH_TOKEN" in (envVars ?? {}))) {
|
|
251
|
+
envVars = { ...envVars, CLAUDE_CODE_OAUTH_TOKEN: globalSettings.claudeCodeOAuthToken };
|
|
252
|
+
}
|
|
253
|
+
if (backend === "codex" && globalSettings.openaiApiKey && !("OPENAI_API_KEY" in (envVars ?? {}))) {
|
|
254
|
+
envVars = { ...envVars, OPENAI_API_KEY: globalSettings.openaiApiKey };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Inject provider env vars if a providerId is specified
|
|
258
|
+
if (body.providerId && backend === "claude") {
|
|
259
|
+
const { getProviderEnvVars } = await import("./provider-manager.js");
|
|
260
|
+
const providerEnv = getProviderEnvVars(body.providerId);
|
|
261
|
+
if (providerEnv) {
|
|
262
|
+
// Provider env vars are defaults — env profile overrides them
|
|
263
|
+
envVars = { ...providerEnv, ...envVars };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Resolve sandbox configuration
|
|
268
|
+
const sandboxEnabled = body.sandboxEnabled === true;
|
|
269
|
+
const sandbox = body.sandboxSlug ? sandboxManager.getSandbox(body.sandboxSlug) : null;
|
|
270
|
+
if (sandboxEnabled && body.sandboxSlug && !sandbox) {
|
|
271
|
+
return { ok: false, error: `Sandbox "${body.sandboxSlug}" not found`, status: 404 };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Resolve Docker image early
|
|
275
|
+
let effectiveImage: string | null = null;
|
|
276
|
+
if (sandboxEnabled) {
|
|
277
|
+
effectiveImage = "the-companion:latest";
|
|
278
|
+
} else if (body.container?.image) {
|
|
279
|
+
effectiveImage = body.container.image;
|
|
280
|
+
}
|
|
281
|
+
const isDockerSession = !!effectiveImage;
|
|
282
|
+
|
|
283
|
+
if (onProgress) await onProgress("resolving_env", "Environment resolved", "done");
|
|
284
|
+
|
|
285
|
+
let cwd = body.cwd;
|
|
286
|
+
let worktreeInfo: { isWorktree: boolean; repoRoot: string; branch: string; actualBranch: string; worktreePath: string } | undefined;
|
|
287
|
+
|
|
288
|
+
// Validate branch name to prevent command injection
|
|
289
|
+
if (body.branch && !/^[a-zA-Z0-9/_.\-]+$/.test(body.branch)) {
|
|
290
|
+
return { ok: false, error: "Invalid branch name", status: 400 };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Step: Git operations (host only) ---
|
|
294
|
+
if (!isDockerSession && body.useWorktree && body.branch && cwd) {
|
|
295
|
+
const repoInfo = gitUtils.getRepoInfo(cwd);
|
|
296
|
+
if (repoInfo) {
|
|
297
|
+
if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
|
|
298
|
+
const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
|
|
299
|
+
if (!fetchResult.success) {
|
|
300
|
+
console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
|
|
301
|
+
}
|
|
302
|
+
if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
|
|
303
|
+
|
|
304
|
+
if (onProgress) await onProgress("creating_worktree", "Creating worktree...", "in_progress");
|
|
305
|
+
const result = gitUtils.ensureWorktree(repoInfo.repoRoot, body.branch, {
|
|
306
|
+
baseBranch: repoInfo.defaultBranch,
|
|
307
|
+
createBranch: body.createBranch,
|
|
308
|
+
forceNew: true,
|
|
309
|
+
});
|
|
310
|
+
cwd = result.worktreePath;
|
|
311
|
+
worktreeInfo = {
|
|
312
|
+
isWorktree: true,
|
|
313
|
+
repoRoot: repoInfo.repoRoot,
|
|
314
|
+
branch: body.branch,
|
|
315
|
+
actualBranch: result.actualBranch,
|
|
316
|
+
worktreePath: result.worktreePath,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (onProgress) await onProgress("creating_worktree", "Worktree ready", "done");
|
|
320
|
+
} else if (!isDockerSession && body.branch && cwd) {
|
|
321
|
+
const repoInfo = gitUtils.getRepoInfo(cwd);
|
|
322
|
+
if (repoInfo) {
|
|
323
|
+
if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
|
|
324
|
+
const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
|
|
325
|
+
if (!fetchResult.success) {
|
|
326
|
+
console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
|
|
327
|
+
}
|
|
328
|
+
if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
|
|
329
|
+
|
|
330
|
+
if (repoInfo.currentBranch !== body.branch) {
|
|
331
|
+
if (onProgress) await onProgress("checkout_branch", `Checking out ${body.branch}...`, "in_progress");
|
|
332
|
+
gitUtils.checkoutOrCreateBranch(repoInfo.repoRoot, body.branch, {
|
|
333
|
+
createBranch: body.createBranch,
|
|
334
|
+
defaultBranch: repoInfo.defaultBranch,
|
|
335
|
+
});
|
|
336
|
+
if (onProgress) await onProgress("checkout_branch", `On branch ${body.branch}`, "done");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (onProgress) await onProgress("pulling_git", "Pulling latest changes...", "in_progress");
|
|
340
|
+
const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
|
|
341
|
+
if (!pullResult.success) {
|
|
342
|
+
console.warn(`[orchestrator] git pull warning (non-fatal): ${pullResult.output}`);
|
|
343
|
+
}
|
|
344
|
+
if (onProgress) await onProgress("pulling_git", "Up to date", "done");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let containerInfo: ContainerInfo | undefined;
|
|
349
|
+
let containerId: string | undefined;
|
|
350
|
+
let containerName: string | undefined;
|
|
351
|
+
let containerImage: string | undefined;
|
|
352
|
+
|
|
353
|
+
// Container auth pre-flight check
|
|
354
|
+
if (effectiveImage && backend === "claude" && !hasContainerClaudeAuth(envVars)) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: "Containerized Claude requires auth available inside the container. " +
|
|
358
|
+
"Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_AUTH_TOKEN) in the selected environment.",
|
|
359
|
+
status: 400,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (effectiveImage && backend === "codex" && !hasContainerCodexAuth(envVars)) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: "Containerized Codex requires auth available inside the container. " +
|
|
366
|
+
"Set OPENAI_API_KEY in the selected environment, or ensure ~/.codex/auth.json exists on the host.",
|
|
367
|
+
status: 400,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Step: Container setup ---
|
|
372
|
+
if (effectiveImage) {
|
|
373
|
+
if (!imagePullManager.isReady(effectiveImage)) {
|
|
374
|
+
const pullState = imagePullManager.getState(effectiveImage);
|
|
375
|
+
if (pullState.status === "idle" || pullState.status === "error") {
|
|
376
|
+
imagePullManager.ensureImage(effectiveImage);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (onProgress) {
|
|
380
|
+
await onProgress("pulling_image", "Pulling Docker image...", "in_progress");
|
|
381
|
+
const unsub = imagePullManager.onProgress(effectiveImage, (line: string) => {
|
|
382
|
+
onProgress("pulling_image", "Pulling Docker image...", "in_progress", line).catch(() => {});
|
|
383
|
+
});
|
|
384
|
+
const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
|
|
385
|
+
unsub();
|
|
386
|
+
if (ready) {
|
|
387
|
+
await onProgress("pulling_image", "Image ready", "done");
|
|
388
|
+
} else {
|
|
389
|
+
const state = imagePullManager.getState(effectiveImage);
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
|
|
393
|
+
status: 503,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
|
|
398
|
+
if (!ready) {
|
|
399
|
+
const state = imagePullManager.getState(effectiveImage);
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
|
|
403
|
+
status: 503,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Create container
|
|
410
|
+
if (onProgress) await onProgress("creating_container", "Starting container...", "in_progress");
|
|
411
|
+
const tempId = crypto.randomUUID().slice(0, 8);
|
|
412
|
+
const requestedPorts = Array.isArray(body.container?.ports)
|
|
413
|
+
? body.container!.ports!.map(Number).filter((n: number) => n > 0)
|
|
414
|
+
: [];
|
|
415
|
+
const containerPorts: (number | { port: number; hostIp?: string })[] = [
|
|
416
|
+
...Array.from(new Set([
|
|
417
|
+
...requestedPorts.filter((p: number) => p !== NOVNC_CONTAINER_PORT),
|
|
418
|
+
VSCODE_EDITOR_CONTAINER_PORT,
|
|
419
|
+
...(backend === "codex" ? [CODEX_APP_SERVER_CONTAINER_PORT] : []),
|
|
420
|
+
])),
|
|
421
|
+
{ port: NOVNC_CONTAINER_PORT, hostIp: "127.0.0.1" },
|
|
422
|
+
];
|
|
423
|
+
const cConfig: ContainerConfig = {
|
|
424
|
+
image: effectiveImage,
|
|
425
|
+
ports: containerPorts,
|
|
426
|
+
volumes: body.container?.volumes,
|
|
427
|
+
env: { ...(envVars ?? {}), DISPLAY: ":99" },
|
|
428
|
+
privileged: sandboxEnabled && effectiveImage === "the-companion:latest",
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
error: `Docker is required to run this environment image (${effectiveImage}) but container startup failed: ${reason}`,
|
|
437
|
+
status: 503,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
containerId = containerInfo.containerId;
|
|
441
|
+
containerName = containerInfo.name;
|
|
442
|
+
containerImage = effectiveImage;
|
|
443
|
+
if (onProgress) await onProgress("creating_container", "Container running", "done");
|
|
444
|
+
|
|
445
|
+
// Copy workspace
|
|
446
|
+
if (onProgress) await onProgress("copying_workspace", "Copying workspace files...", "in_progress");
|
|
447
|
+
try {
|
|
448
|
+
await containerManager.copyWorkspaceToContainer(containerInfo.containerId, cwd!);
|
|
449
|
+
containerManager.reseedGitAuth(containerInfo.containerId);
|
|
450
|
+
if (onProgress) await onProgress("copying_workspace", "Workspace copied", "done");
|
|
451
|
+
} catch (err) {
|
|
452
|
+
containerManager.removeContainer(tempId);
|
|
453
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
454
|
+
return { ok: false, error: `Failed to copy workspace to container: ${reason}`, status: 503 };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Git operations inside container
|
|
458
|
+
if (body.branch) {
|
|
459
|
+
const repoInfo = cwd ? gitUtils.getRepoInfo(cwd) : null;
|
|
460
|
+
if (onProgress) await onProgress("fetching_git", "Fetching from remote (in container)...", "in_progress");
|
|
461
|
+
const gitResult = containerManager.gitOpsInContainer(containerInfo.containerId, {
|
|
462
|
+
branch: body.branch,
|
|
463
|
+
currentBranch: repoInfo?.currentBranch || "HEAD",
|
|
464
|
+
createBranch: body.createBranch,
|
|
465
|
+
defaultBranch: repoInfo?.defaultBranch,
|
|
466
|
+
});
|
|
467
|
+
if (onProgress) await onProgress("fetching_git", gitResult.fetchOk ? "Fetch complete" : "Fetch skipped", "done");
|
|
468
|
+
if (onProgress && repoInfo?.currentBranch !== body.branch) {
|
|
469
|
+
await onProgress("checkout_branch",
|
|
470
|
+
gitResult.checkoutOk ? `On branch ${body.branch}` : "Checkout failed",
|
|
471
|
+
gitResult.checkoutOk ? "done" : "error",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (onProgress) await onProgress("pulling_git", gitResult.pullOk ? "Up to date" : "Pull skipped", "done");
|
|
475
|
+
if (gitResult.errors.length > 0) {
|
|
476
|
+
console.warn(`[orchestrator] In-container git ops warnings: ${gitResult.errors.join("; ")}`);
|
|
477
|
+
}
|
|
478
|
+
if (!gitResult.checkoutOk) {
|
|
479
|
+
containerManager.removeContainer(tempId);
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
error: `Failed to checkout branch "${body.branch}" inside container: ${gitResult.errors.join("; ")}`,
|
|
483
|
+
status: 400,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Init script
|
|
489
|
+
const initScript = sandbox?.initScript?.trim();
|
|
490
|
+
if (initScript) {
|
|
491
|
+
if (onProgress) await onProgress("running_init_script", "Running init script...", "in_progress");
|
|
492
|
+
try {
|
|
493
|
+
console.log(`[orchestrator] Running init script for sandbox "${sandbox?.name || "sandbox"}" in container ${containerInfo.name}...`);
|
|
494
|
+
const initTimeout = Number(process.env.HEYHANK_INIT_SCRIPT_TIMEOUT || process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
|
|
495
|
+
const result = await containerManager.execInContainerAsync(
|
|
496
|
+
containerInfo.containerId,
|
|
497
|
+
["sh", "-lc", initScript],
|
|
498
|
+
{
|
|
499
|
+
timeout: initTimeout,
|
|
500
|
+
onOutput: onProgress
|
|
501
|
+
? (line: string) => { onProgress("running_init_script", "Running init script...", "in_progress", line).catch(() => {}); }
|
|
502
|
+
: undefined,
|
|
503
|
+
},
|
|
504
|
+
);
|
|
505
|
+
if (result.exitCode !== 0) {
|
|
506
|
+
console.error(`[orchestrator] Init script failed (exit ${result.exitCode}):\n${result.output}`);
|
|
507
|
+
containerManager.removeContainer(tempId);
|
|
508
|
+
const truncated = result.output.length > 2000
|
|
509
|
+
? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
|
|
510
|
+
: result.output;
|
|
511
|
+
return { ok: false, error: `Init script failed (exit ${result.exitCode}):\n${truncated}`, status: 503 };
|
|
512
|
+
}
|
|
513
|
+
if (onProgress) await onProgress("running_init_script", "Init script complete", "done");
|
|
514
|
+
console.log(`[orchestrator] Init script completed successfully for sandbox "${sandbox?.name || "sandbox"}"`);
|
|
515
|
+
} catch (e) {
|
|
516
|
+
containerManager.removeContainer(tempId);
|
|
517
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
518
|
+
return { ok: false, error: `Init script execution failed: ${reason}`, status: 503 };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- Step: Launch CLI ---
|
|
524
|
+
if (onProgress) await onProgress("launching_cli", `Launching ${backend === "codex" ? "Codex" : "Claude Code"}...`, "in_progress");
|
|
525
|
+
|
|
526
|
+
let session: SdkSessionInfo;
|
|
527
|
+
try {
|
|
528
|
+
session = this.launcher.launch({
|
|
529
|
+
model: body.model,
|
|
530
|
+
permissionMode: body.permissionMode,
|
|
531
|
+
cwd,
|
|
532
|
+
claudeBinary: body.claudeBinary,
|
|
533
|
+
codexBinary: body.codexBinary,
|
|
534
|
+
codexInternetAccess: backend === "codex",
|
|
535
|
+
codexSandbox: backend === "codex" ? "danger-full-access" : undefined,
|
|
536
|
+
allowedTools: body.allowedTools,
|
|
537
|
+
env: envVars,
|
|
538
|
+
backendType: backend,
|
|
539
|
+
containerId,
|
|
540
|
+
containerName,
|
|
541
|
+
containerImage,
|
|
542
|
+
containerCwd: containerInfo?.containerCwd,
|
|
543
|
+
resumeSessionAt,
|
|
544
|
+
forkSession,
|
|
545
|
+
systemPrompt: undefined,
|
|
546
|
+
sandboxSlug: sandboxEnabled ? (body.sandboxSlug || undefined) : undefined,
|
|
547
|
+
provider: body.providerId,
|
|
548
|
+
});
|
|
549
|
+
} catch (e) {
|
|
550
|
+
// Clean up container if it was created but launch failed
|
|
551
|
+
if (containerId) containerManager.removeContainer(containerId);
|
|
552
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
553
|
+
return { ok: false, error: `Failed to launch CLI: ${reason}`, status: 503 };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Post-launch wiring
|
|
557
|
+
if (containerInfo) {
|
|
558
|
+
containerManager.retrack(containerInfo.containerId, session.sessionId);
|
|
559
|
+
this.wsBridge.markContainerized(session.sessionId, cwd!);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (worktreeInfo) {
|
|
563
|
+
this.worktreeTracker.addMapping({
|
|
564
|
+
sessionId: session.sessionId,
|
|
565
|
+
repoRoot: worktreeInfo.repoRoot,
|
|
566
|
+
branch: worktreeInfo.branch,
|
|
567
|
+
actualBranch: worktreeInfo.actualBranch,
|
|
568
|
+
worktreePath: worktreeInfo.worktreePath,
|
|
569
|
+
createdAt: Date.now(),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const discovered = await discoverCommandsAndSkills(cwd).catch(() => ({ slash_commands: [] as string[], skills: [] as string[] }));
|
|
574
|
+
this.wsBridge.prePopulateCommands(session.sessionId, discovered.slash_commands, discovered.skills);
|
|
575
|
+
|
|
576
|
+
if (onProgress) await onProgress("launching_cli", "Session started", "done");
|
|
577
|
+
|
|
578
|
+
metricsCollector.recordSessionCreated(backend);
|
|
579
|
+
metricsCollector.recordSessionSpawned(session.sessionId);
|
|
580
|
+
|
|
581
|
+
return { ok: true, session };
|
|
582
|
+
} catch (e: unknown) {
|
|
583
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
584
|
+
log.error("orchestrator", "Failed to create session", { error: msg });
|
|
585
|
+
return { ok: false, error: msg, status: 500 };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Kill ───────────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
async killSession(sessionId: string): Promise<{ ok: boolean }> {
|
|
592
|
+
const killed = await this.launcher.kill(sessionId);
|
|
593
|
+
if (killed) {
|
|
594
|
+
containerManager.removeContainer(sessionId);
|
|
595
|
+
}
|
|
596
|
+
return { ok: killed };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Relaunch ───────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
async relaunchSession(sessionId: string): Promise<{ ok: boolean; error?: string }> {
|
|
602
|
+
const info = this.launcher.getSession(sessionId);
|
|
603
|
+
if (info?.archived) {
|
|
604
|
+
return { ok: false, error: "Session is archived and cannot be relaunched" };
|
|
605
|
+
}
|
|
606
|
+
this.clearAutoRelaunchCount(sessionId);
|
|
607
|
+
const session = this.wsBridge.getSession(sessionId);
|
|
608
|
+
if (session?.stateMachine) {
|
|
609
|
+
session.stateMachine.transition("starting", "relaunch_initiated");
|
|
610
|
+
}
|
|
611
|
+
return this.launcher.relaunch(sessionId);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Archive ────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
async archiveSession(sessionId: string, options?: ArchiveSessionOptions): Promise<ArchiveSessionResult> {
|
|
617
|
+
await this.launcher.kill(sessionId);
|
|
618
|
+
containerManager.removeContainer(sessionId);
|
|
619
|
+
this.prPoller.unwatch(sessionId);
|
|
620
|
+
|
|
621
|
+
const worktreeResult = this.cleanupWorktree(sessionId, options?.force);
|
|
622
|
+
this.launcher.setArchived(sessionId, true);
|
|
623
|
+
this.sessionStore.setArchived(sessionId, true);
|
|
624
|
+
|
|
625
|
+
return { ok: true, worktree: worktreeResult };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── Delete ─────────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
async deleteSession(sessionId: string): Promise<DeleteSessionResult> {
|
|
631
|
+
await this.launcher.kill(sessionId);
|
|
632
|
+
containerManager.removeContainer(sessionId);
|
|
633
|
+
const worktreeResult = this.cleanupWorktree(sessionId, true);
|
|
634
|
+
this.prPoller.unwatch(sessionId);
|
|
635
|
+
this.launcher.removeSession(sessionId);
|
|
636
|
+
this.wsBridge.closeSession(sessionId);
|
|
637
|
+
this.autoRelaunchCounts.delete(sessionId);
|
|
638
|
+
this.relaunchExhaustedNotified.delete(sessionId);
|
|
639
|
+
this.relaunchingSet.delete(sessionId);
|
|
640
|
+
return { ok: true, worktree: worktreeResult };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── Unarchive ──────────────────────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
unarchiveSession(sessionId: string): { ok: boolean } {
|
|
646
|
+
this.launcher.setArchived(sessionId, false);
|
|
647
|
+
this.sessionStore.setArchived(sessionId, false);
|
|
648
|
+
return { ok: true };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── Auto-relaunch count ────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
clearAutoRelaunchCount(sessionId: string): void {
|
|
654
|
+
this.autoRelaunchCounts.delete(sessionId);
|
|
655
|
+
this.relaunchExhaustedNotified.delete(sessionId);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Event registration ─────────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
/** Register a callback for session exit events. Returns unsubscribe function. */
|
|
661
|
+
onSessionExited(cb: (sessionId: string, exitCode: number | null) => void): () => void {
|
|
662
|
+
this.exitCallbacks.push(cb);
|
|
663
|
+
return () => {
|
|
664
|
+
const idx = this.exitCallbacks.indexOf(cb);
|
|
665
|
+
if (idx !== -1) this.exitCallbacks.splice(idx, 1);
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── Query delegation ───────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
getSession(sessionId: string): SdkSessionInfo | undefined {
|
|
672
|
+
return this.launcher.getSession(sessionId);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
shutdown(): void {
|
|
678
|
+
// Timers are owned by the process lifecycle
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── Private: Auto-relaunch ─────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
private async handleAutoRelaunch(sessionId: string): Promise<void> {
|
|
684
|
+
if (this.relaunchingSet.has(sessionId)) return;
|
|
685
|
+
const info = this.launcher.getSession(sessionId);
|
|
686
|
+
if (info?.archived) return;
|
|
687
|
+
|
|
688
|
+
// If we've already notified the user about relaunch exhaustion, bail out
|
|
689
|
+
// silently. Without this, every reconnect event from a dead session
|
|
690
|
+
// (e.g. deleted container) re-logs the "limit reached" warning endlessly.
|
|
691
|
+
if (this.relaunchExhaustedNotified.has(sessionId)) return;
|
|
692
|
+
|
|
693
|
+
this.relaunchingSet.add(sessionId);
|
|
694
|
+
|
|
695
|
+
await new Promise((r) => setTimeout(r, RELAUNCH_GRACE_MS));
|
|
696
|
+
if (this.wsBridge.isCliConnected(sessionId)) { this.relaunchingSet.delete(sessionId); return; }
|
|
697
|
+
const freshInfo = this.launcher.getSession(sessionId);
|
|
698
|
+
if (freshInfo && (freshInfo.state === "connected" || freshInfo.state === "running")) {
|
|
699
|
+
this.relaunchingSet.delete(sessionId); return;
|
|
700
|
+
}
|
|
701
|
+
// Only check PID liveness if the session is NOT already "exited".
|
|
702
|
+
// After idle-kill or explicit kill(), the PID field stays set but the
|
|
703
|
+
// process is dead. If the kernel recycles the PID to a different process,
|
|
704
|
+
// kill(pid, 0) would incorrectly succeed, preventing any relaunch.
|
|
705
|
+
// For containerized sessions, use container liveness instead of PID check
|
|
706
|
+
// (the PID is the `docker exec` wrapper, which exits immediately for some
|
|
707
|
+
// transports and is unreliable for container health).
|
|
708
|
+
if (freshInfo && freshInfo.state !== "exited") {
|
|
709
|
+
if (freshInfo.containerId) {
|
|
710
|
+
const containerState = containerManager.isContainerAlive(freshInfo.containerId);
|
|
711
|
+
if (containerState === "running") {
|
|
712
|
+
this.relaunchingSet.delete(sessionId);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
} else if (freshInfo.pid) {
|
|
716
|
+
try { process.kill(freshInfo.pid, 0); this.relaunchingSet.delete(sessionId); return; } catch {}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const count = this.autoRelaunchCounts.get(sessionId) ?? 0;
|
|
721
|
+
if (count >= MAX_AUTO_RELAUNCHES) {
|
|
722
|
+
metricsCollector.recordRelaunchExhausted();
|
|
723
|
+
log.warn("orchestrator", "Auto-relaunch limit reached", { sessionId, maxAttempts: MAX_AUTO_RELAUNCHES });
|
|
724
|
+
this.wsBridge.broadcastToSession(sessionId, {
|
|
725
|
+
type: "error",
|
|
726
|
+
message: "Session keeps crashing. Please relaunch manually.",
|
|
727
|
+
});
|
|
728
|
+
this.relaunchExhaustedNotified.add(sessionId);
|
|
729
|
+
this.relaunchingSet.delete(sessionId);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (freshInfo && freshInfo.state !== "starting") {
|
|
734
|
+
this.autoRelaunchCounts.set(sessionId, count + 1);
|
|
735
|
+
metricsCollector.recordRelaunchAttempted();
|
|
736
|
+
log.info("orchestrator", "Auto-relaunching CLI", { sessionId, attempt: count + 1, maxAttempts: MAX_AUTO_RELAUNCHES });
|
|
737
|
+
const session = this.wsBridge.getSession(sessionId);
|
|
738
|
+
if (session?.stateMachine) {
|
|
739
|
+
session.stateMachine.transition("starting", "relaunch_initiated");
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const result = await this.launcher.relaunch(sessionId);
|
|
743
|
+
if (!result.ok && result.error) {
|
|
744
|
+
this.wsBridge.broadcastToSession(sessionId, { type: "error", message: result.error });
|
|
745
|
+
} else if (result.ok) {
|
|
746
|
+
metricsCollector.recordRelaunchSucceeded();
|
|
747
|
+
this.autoRelaunchCounts.delete(sessionId);
|
|
748
|
+
this.relaunchExhaustedNotified.delete(sessionId);
|
|
749
|
+
}
|
|
750
|
+
// ok=false without error: keep count to preserve the retry budget
|
|
751
|
+
} finally {
|
|
752
|
+
setTimeout(() => this.relaunchingSet.delete(sessionId), RELAUNCH_COOLDOWN_MS);
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
this.relaunchingSet.delete(sessionId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Private: Auto-naming ───────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
private async handleAutoNaming(sessionId: string, firstUserMessage: string): Promise<void> {
|
|
762
|
+
if (sessionNames.getName(sessionId)) return;
|
|
763
|
+
if (!getSettings().anthropicApiKey.trim()) return;
|
|
764
|
+
const info = this.launcher.getSession(sessionId);
|
|
765
|
+
const model = info?.model || "claude-sonnet-4-6";
|
|
766
|
+
console.log(`[orchestrator] Auto-naming session ${sessionId} via Anthropic with model ${model}...`);
|
|
767
|
+
const title = await generateSessionTitle(firstUserMessage, model);
|
|
768
|
+
if (title && !sessionNames.getName(sessionId)) {
|
|
769
|
+
console.log(`[orchestrator] Auto-named session ${sessionId}: "${title}"`);
|
|
770
|
+
sessionNames.setName(sessionId, title);
|
|
771
|
+
this.wsBridge.broadcastNameUpdate(sessionId, title);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── Private: Reconnection watchdog ─────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
private startReconnectionWatchdog(): void {
|
|
778
|
+
const starting = this.launcher.getStartingSessions();
|
|
779
|
+
if (starting.length > 0) {
|
|
780
|
+
console.log(`[orchestrator] Waiting ${RECONNECT_GRACE_MS / 1000}s for ${starting.length} CLI process(es) to reconnect...`);
|
|
781
|
+
setTimeout(async () => {
|
|
782
|
+
const stale = this.launcher.getStartingSessions();
|
|
783
|
+
for (const info of stale) {
|
|
784
|
+
if (info.archived) continue;
|
|
785
|
+
console.log(`[orchestrator] CLI for session ${info.sessionId} did not reconnect, relaunching...`);
|
|
786
|
+
await this.launcher.relaunch(info.sessionId);
|
|
787
|
+
}
|
|
788
|
+
}, RECONNECT_GRACE_MS);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ── Private: Worktree cleanup ──────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
private cleanupWorktree(
|
|
795
|
+
sessionId: string,
|
|
796
|
+
force?: boolean,
|
|
797
|
+
): { cleaned?: boolean; dirty?: boolean; path?: string } | undefined {
|
|
798
|
+
const mapping = this.worktreeTracker.getBySession(sessionId);
|
|
799
|
+
if (!mapping) return undefined;
|
|
800
|
+
|
|
801
|
+
if (this.worktreeTracker.isWorktreeInUse(mapping.worktreePath, sessionId)) {
|
|
802
|
+
this.worktreeTracker.removeBySession(sessionId);
|
|
803
|
+
return { cleaned: false, path: mapping.worktreePath };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const dirty = gitUtils.isWorktreeDirty(mapping.worktreePath);
|
|
807
|
+
if (dirty && !force) {
|
|
808
|
+
return { cleaned: false, dirty: true, path: mapping.worktreePath };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const branchToDelete =
|
|
812
|
+
mapping.actualBranch && mapping.actualBranch !== mapping.branch
|
|
813
|
+
? mapping.actualBranch
|
|
814
|
+
: undefined;
|
|
815
|
+
const result = gitUtils.removeWorktree(mapping.repoRoot, mapping.worktreePath, {
|
|
816
|
+
force: dirty,
|
|
817
|
+
branchToDelete,
|
|
818
|
+
});
|
|
819
|
+
if (result.removed) {
|
|
820
|
+
this.worktreeTracker.removeBySession(sessionId);
|
|
821
|
+
}
|
|
822
|
+
return { cleaned: result.removed, path: mapping.worktreePath };
|
|
823
|
+
}
|
|
824
|
+
}
|