@hellcoder/companion 0.96.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/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-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -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/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -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/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { containerManager, ContainerManager } from "./container-manager.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Types
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface ImagePullState {
|
|
11
|
+
image: string;
|
|
12
|
+
status: "idle" | "pulling" | "ready" | "error";
|
|
13
|
+
/** Last N lines of pull/build output (ring buffer) */
|
|
14
|
+
progress: string[];
|
|
15
|
+
error?: string;
|
|
16
|
+
startedAt?: number;
|
|
17
|
+
completedAt?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const MAX_PROGRESS_LINES = 50;
|
|
25
|
+
const WEB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// ImagePullManager — singleton that tracks background image pulls
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
type ReadyListener = () => void;
|
|
32
|
+
|
|
33
|
+
class ImagePullManager {
|
|
34
|
+
private states = new Map<string, ImagePullState>();
|
|
35
|
+
/** Listeners waiting for a specific image to become ready */
|
|
36
|
+
private readyListeners = new Map<string, ReadyListener[]>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the current state for an image.
|
|
40
|
+
* If the image exists locally and we have no tracking entry, return "ready".
|
|
41
|
+
*/
|
|
42
|
+
getState(image: string): ImagePullState {
|
|
43
|
+
const existing = this.states.get(image);
|
|
44
|
+
if (existing) return existing;
|
|
45
|
+
|
|
46
|
+
// Check if already available locally
|
|
47
|
+
const ready = containerManager.imageExists(image);
|
|
48
|
+
return {
|
|
49
|
+
image,
|
|
50
|
+
status: ready ? "ready" : "idle",
|
|
51
|
+
progress: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Quick check: is the image available locally right now? */
|
|
56
|
+
isReady(image: string): boolean {
|
|
57
|
+
return this.getState(image).status === "ready";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensure the image is available. Starts a background pull if missing.
|
|
62
|
+
* No-op if already pulling or ready.
|
|
63
|
+
*/
|
|
64
|
+
ensureImage(image: string): void {
|
|
65
|
+
const state = this.getState(image);
|
|
66
|
+
if (state.status === "ready" || state.status === "pulling") return;
|
|
67
|
+
this.startPull(image);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wait for an image that is currently pulling to become ready.
|
|
72
|
+
* Resolves true if ready, false if pull failed or timed out.
|
|
73
|
+
* If image is already ready, resolves immediately.
|
|
74
|
+
*/
|
|
75
|
+
waitForReady(image: string, timeoutMs = 300_000): Promise<boolean> {
|
|
76
|
+
const state = this.getState(image);
|
|
77
|
+
if (state.status === "ready") return Promise.resolve(true);
|
|
78
|
+
if (state.status === "error") return Promise.resolve(false);
|
|
79
|
+
if (state.status === "idle") {
|
|
80
|
+
// Not pulling yet — start it
|
|
81
|
+
this.startPull(image);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new Promise<boolean>((resolve) => {
|
|
85
|
+
let settled = false;
|
|
86
|
+
const done = (result: boolean) => {
|
|
87
|
+
if (settled) return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
// Clean up the listener to avoid memory leaks
|
|
91
|
+
const arr = this.readyListeners.get(image);
|
|
92
|
+
if (arr) {
|
|
93
|
+
const idx = arr.indexOf(listener);
|
|
94
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
95
|
+
if (arr.length === 0) this.readyListeners.delete(image);
|
|
96
|
+
}
|
|
97
|
+
resolve(result);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
101
|
+
|
|
102
|
+
const listener: ReadyListener = () => {
|
|
103
|
+
const s = this.getState(image);
|
|
104
|
+
if (s.status === "ready") done(true);
|
|
105
|
+
else if (s.status === "error") done(false);
|
|
106
|
+
// else still pulling — keep waiting
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const listeners = this.readyListeners.get(image) ?? [];
|
|
110
|
+
listeners.push(listener);
|
|
111
|
+
this.readyListeners.set(image, listeners);
|
|
112
|
+
|
|
113
|
+
// Re-check after registering the listener to catch races where
|
|
114
|
+
// the pull completed synchronously before the listener was added.
|
|
115
|
+
const currentState = this.getState(image);
|
|
116
|
+
if (currentState.status === "ready") done(true);
|
|
117
|
+
else if (currentState.status === "error") done(false);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Trigger a pull even if image is already present (for updates).
|
|
123
|
+
*/
|
|
124
|
+
pull(image: string): void {
|
|
125
|
+
const state = this.getState(image);
|
|
126
|
+
if (state.status === "pulling") return; // already in progress
|
|
127
|
+
this.startPull(image);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Subscribe to progress lines for a specific image.
|
|
132
|
+
* Returns an unsubscribe function.
|
|
133
|
+
* The callback fires for each new progress line while pulling.
|
|
134
|
+
*/
|
|
135
|
+
onProgress(image: string, cb: (line: string) => void): () => void {
|
|
136
|
+
const key = `progress:${image}`;
|
|
137
|
+
const listeners = (this.progressListeners.get(key) ?? []);
|
|
138
|
+
listeners.push(cb);
|
|
139
|
+
this.progressListeners.set(key, listeners);
|
|
140
|
+
return () => {
|
|
141
|
+
const arr = this.progressListeners.get(key);
|
|
142
|
+
if (arr) {
|
|
143
|
+
const idx = arr.indexOf(cb);
|
|
144
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
private progressListeners = new Map<string, Array<(line: string) => void>>();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* On server startup, check all environments and pre-pull missing images.
|
|
152
|
+
* Environments no longer carry Docker fields — this is now a no-op stub
|
|
153
|
+
* kept for backwards compatibility with callers.
|
|
154
|
+
*/
|
|
155
|
+
initFromEnvironments(): void {
|
|
156
|
+
// Environments no longer have imageTag/baseImage (moved to Sandboxes).
|
|
157
|
+
// Nothing to pre-pull from envs.
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
private startPull(image: string): void {
|
|
163
|
+
const state: ImagePullState = {
|
|
164
|
+
image,
|
|
165
|
+
status: "pulling",
|
|
166
|
+
progress: [],
|
|
167
|
+
startedAt: Date.now(),
|
|
168
|
+
};
|
|
169
|
+
this.states.set(image, state);
|
|
170
|
+
|
|
171
|
+
// Determine if we can pull from registry
|
|
172
|
+
const registryImage = ContainerManager.getRegistryImage(image);
|
|
173
|
+
|
|
174
|
+
if (registryImage) {
|
|
175
|
+
this.doPullFromRegistry(image, registryImage);
|
|
176
|
+
} else {
|
|
177
|
+
// No registry mapping — mark as error since we can't pull custom images
|
|
178
|
+
this.markError(image, `No registry mapping for image "${image}". Build it from a Dockerfile instead.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async doPullFromRegistry(localTag: string, registryImage: string): Promise<void> {
|
|
183
|
+
try {
|
|
184
|
+
const pulled = await containerManager.pullImage(registryImage, localTag, (line) => {
|
|
185
|
+
this.appendProgress(localTag, line);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (pulled) {
|
|
189
|
+
this.markReady(localTag);
|
|
190
|
+
} else {
|
|
191
|
+
// Pull failed — try local build for default image
|
|
192
|
+
if (localTag === "the-companion:latest") {
|
|
193
|
+
this.appendProgress(localTag, "Pull failed, falling back to local build...");
|
|
194
|
+
await this.doLocalBuild(localTag);
|
|
195
|
+
} else {
|
|
196
|
+
this.markError(localTag, "Pull failed from registry");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
201
|
+
// Try local build fallback for default image
|
|
202
|
+
if (localTag === "the-companion:latest") {
|
|
203
|
+
this.appendProgress(localTag, `Pull error (${reason}), falling back to local build...`);
|
|
204
|
+
await this.doLocalBuild(localTag);
|
|
205
|
+
} else {
|
|
206
|
+
this.markError(localTag, reason);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async doLocalBuild(localTag: string): Promise<void> {
|
|
212
|
+
const dockerfilePath = join(WEB_DIR, "docker", "Dockerfile.the-companion");
|
|
213
|
+
if (!existsSync(dockerfilePath)) {
|
|
214
|
+
this.markError(localTag, `Dockerfile not found at ${dockerfilePath}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
this.appendProgress(localTag, `Building ${localTag} from local Dockerfile...`);
|
|
220
|
+
containerManager.buildImage(dockerfilePath, localTag);
|
|
221
|
+
this.markReady(localTag);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
224
|
+
this.markError(localTag, `Build failed: ${reason}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private appendProgress(image: string, line: string): void {
|
|
229
|
+
const state = this.states.get(image);
|
|
230
|
+
if (!state) return;
|
|
231
|
+
state.progress.push(line);
|
|
232
|
+
if (state.progress.length > MAX_PROGRESS_LINES) {
|
|
233
|
+
state.progress.splice(0, state.progress.length - MAX_PROGRESS_LINES);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Notify progress listeners
|
|
237
|
+
const key = `progress:${image}`;
|
|
238
|
+
const listeners = this.progressListeners.get(key);
|
|
239
|
+
if (listeners) {
|
|
240
|
+
for (const cb of listeners) {
|
|
241
|
+
try { cb(line); } catch { /* ignore */ }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private markReady(image: string): void {
|
|
247
|
+
const state = this.states.get(image);
|
|
248
|
+
if (state) {
|
|
249
|
+
state.status = "ready";
|
|
250
|
+
state.completedAt = Date.now();
|
|
251
|
+
this.appendProgress(image, "Image ready");
|
|
252
|
+
}
|
|
253
|
+
this.notifyListeners(image);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private markError(image: string, error: string): void {
|
|
257
|
+
const state = this.states.get(image);
|
|
258
|
+
if (state) {
|
|
259
|
+
state.status = "error";
|
|
260
|
+
state.error = error;
|
|
261
|
+
state.completedAt = Date.now();
|
|
262
|
+
this.appendProgress(image, `Error: ${error}`);
|
|
263
|
+
}
|
|
264
|
+
this.notifyListeners(image);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private notifyListeners(image: string): void {
|
|
268
|
+
const listeners = this.readyListeners.get(image);
|
|
269
|
+
if (listeners) {
|
|
270
|
+
for (const listener of listeners) {
|
|
271
|
+
try { listener(); } catch { /* ignore */ }
|
|
272
|
+
}
|
|
273
|
+
this.readyListeners.delete(image);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Singleton export
|
|
279
|
+
export const imagePullManager = new ImagePullManager();
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
|
|
2
|
+
|
|
3
|
+
// Enrich process PATH at startup so binary resolution and `which` calls can find
|
|
4
|
+
// binaries installed via version managers (nvm, volta, fnm, etc.).
|
|
5
|
+
// Critical when running as a launchd/systemd service with a restricted PATH.
|
|
6
|
+
import { getEnrichedPath } from "./path-resolver.js";
|
|
7
|
+
process.env.PATH = getEnrichedPath();
|
|
8
|
+
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { cors } from "hono/cors";
|
|
13
|
+
import { serveStatic } from "hono/bun";
|
|
14
|
+
import { cacheControlMiddleware } from "./cache-headers.js";
|
|
15
|
+
import { createRoutes } from "./routes.js";
|
|
16
|
+
import { CliLauncher } from "./cli-launcher.js";
|
|
17
|
+
import { WsBridge } from "./ws-bridge.js";
|
|
18
|
+
import { SessionStore } from "./session-store.js";
|
|
19
|
+
import { WorktreeTracker } from "./worktree-tracker.js";
|
|
20
|
+
import { containerManager } from "./container-manager.js";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
23
|
+
import { TerminalManager } from "./terminal-manager.js";
|
|
24
|
+
import { PRPoller } from "./pr-poller.js";
|
|
25
|
+
import { RecorderManager } from "./recorder.js";
|
|
26
|
+
import { initLogFile, closeLogFile } from "./logger.js";
|
|
27
|
+
import { CronScheduler } from "./cron-scheduler.js";
|
|
28
|
+
import { AgentExecutor } from "./agent-executor.js";
|
|
29
|
+
import { SessionOrchestrator } from "./session-orchestrator.js";
|
|
30
|
+
import { migrateCronJobsToAgents } from "./agent-cron-migrator.js";
|
|
31
|
+
import { migrateLinearCredentialsToAgents } from "./linear-credential-migration.js";
|
|
32
|
+
import { authenticateManagedWebSocket } from "./ws-auth.js";
|
|
33
|
+
import { LinearAgentBridge } from "./linear-agent-bridge.js";
|
|
34
|
+
import { NoVncProxy } from "./novnc-proxy.js";
|
|
35
|
+
|
|
36
|
+
import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
|
|
37
|
+
import { imagePullManager } from "./image-pull-manager.js";
|
|
38
|
+
import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
|
|
39
|
+
import { isRunningAsService } from "./service.js";
|
|
40
|
+
import { getToken, verifyToken } from "./auth-manager.js";
|
|
41
|
+
import { getCookie } from "hono/cookie";
|
|
42
|
+
import type { SocketData } from "./ws-bridge.js";
|
|
43
|
+
import type { ServerWebSocket } from "bun";
|
|
44
|
+
|
|
45
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const packageRoot = process.env.__COMPANION_PACKAGE_ROOT || resolve(__dirname, "..");
|
|
47
|
+
|
|
48
|
+
import { DEFAULT_PORT_DEV, DEFAULT_PORT_PROD } from "./constants.js";
|
|
49
|
+
|
|
50
|
+
const defaultPort = process.env.NODE_ENV === "production" ? DEFAULT_PORT_PROD : DEFAULT_PORT_DEV;
|
|
51
|
+
const port = Number(process.env.PORT) || defaultPort;
|
|
52
|
+
const host = process.env.HOST || "0.0.0.0";
|
|
53
|
+
const sessionStore = new SessionStore(process.env.COMPANION_SESSION_DIR);
|
|
54
|
+
const wsBridge = new WsBridge();
|
|
55
|
+
const launcher = new CliLauncher(port);
|
|
56
|
+
const worktreeTracker = new WorktreeTracker();
|
|
57
|
+
const CONTAINER_STATE_PATH = join(COMPANION_HOME, "containers.json");
|
|
58
|
+
const terminalManager = new TerminalManager();
|
|
59
|
+
const noVncProxy = new NoVncProxy();
|
|
60
|
+
const prPoller = new PRPoller(wsBridge);
|
|
61
|
+
const recorder = new RecorderManager();
|
|
62
|
+
const cronScheduler = new CronScheduler(launcher, wsBridge);
|
|
63
|
+
const agentExecutor = new AgentExecutor(launcher, wsBridge);
|
|
64
|
+
const linearAgentBridge = new LinearAgentBridge(agentExecutor, wsBridge);
|
|
65
|
+
|
|
66
|
+
const orchestrator = new SessionOrchestrator({
|
|
67
|
+
launcher, wsBridge, sessionStore, worktreeTracker,
|
|
68
|
+
prPoller, agentExecutor,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── Cloud relay connection (for receiving webhooks behind a firewall) ────────
|
|
72
|
+
// The relay forwards platform webhooks (e.g. GitHub, Slack) to the Companion
|
|
73
|
+
// instance via an outbound WebSocket. Currently no webhook handlers are
|
|
74
|
+
// registered (Chat SDK was removed). The relay is left disabled until handlers
|
|
75
|
+
// are wired up (e.g. LinearAgentBridge or future platform integrations).
|
|
76
|
+
if (process.env.COMPANION_RELAY_URL && process.env.COMPANION_RELAY_SECRET) {
|
|
77
|
+
console.warn(
|
|
78
|
+
"[server] COMPANION_RELAY_URL is set but no relay webhook handlers are registered. " +
|
|
79
|
+
"The relay client will not be started. Remove COMPANION_RELAY_URL/COMPANION_RELAY_SECRET " +
|
|
80
|
+
"or wire up webhook handlers to use relay mode.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Restore persisted sessions from disk ────────────────────────────────────
|
|
85
|
+
wsBridge.setStore(sessionStore);
|
|
86
|
+
wsBridge.setRecorder(recorder);
|
|
87
|
+
launcher.setStore(sessionStore);
|
|
88
|
+
launcher.setRecorder(recorder);
|
|
89
|
+
launcher.restoreFromDisk();
|
|
90
|
+
wsBridge.restoreFromDisk();
|
|
91
|
+
containerManager.restoreState(CONTAINER_STATE_PATH);
|
|
92
|
+
|
|
93
|
+
// ── Session orchestrator — centralizes lifecycle event wiring ────────────────
|
|
94
|
+
orchestrator.initialize();
|
|
95
|
+
|
|
96
|
+
console.log(`[server] Session persistence: ${sessionStore.directory}`);
|
|
97
|
+
if (recorder.isGloballyEnabled()) {
|
|
98
|
+
console.log(`[server] Recording enabled (dir: ${recorder.getRecordingsDir()}, max: ${recorder.getMaxLines()} lines)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Log file persistence — writes all log output to ~/.companion/logs/ ───────
|
|
102
|
+
const logFileWriter = initLogFile();
|
|
103
|
+
if (logFileWriter) {
|
|
104
|
+
console.log(`[server] Log file enabled (dir: ${logFileWriter.getLogsDir()}, max: ${logFileWriter.getMaxLines()} lines, file: ${logFileWriter.filePath})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const app = new Hono();
|
|
108
|
+
|
|
109
|
+
// ── Health endpoint — always unauthenticated (used by Fly.io + control plane) ─
|
|
110
|
+
const startTime = Date.now();
|
|
111
|
+
app.get("/health", (c) => {
|
|
112
|
+
return c.json({
|
|
113
|
+
ok: true,
|
|
114
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
115
|
+
sessions: launcher.listSessions().length,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── Managed auth middleware — only active when COMPANION_AUTH_ENABLED=1 ────
|
|
120
|
+
const hasManagedAuthSecret = Boolean(process.env.COMPANION_AUTH_SECRET?.trim());
|
|
121
|
+
const managedAuthEnabled =
|
|
122
|
+
process.env.COMPANION_AUTH_ENABLED === "1" ||
|
|
123
|
+
(hasManagedAuthSecret && process.env.COMPANION_AUTH_ENABLED !== "0");
|
|
124
|
+
|
|
125
|
+
if (managedAuthEnabled) {
|
|
126
|
+
const { managedAuth } = await import("./middleware/managed-auth.js");
|
|
127
|
+
app.use("/*", managedAuth);
|
|
128
|
+
console.log("[server] Managed auth enabled");
|
|
129
|
+
} else {
|
|
130
|
+
console.log("[server] Managed auth disabled");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
app.use("/api/*", cors());
|
|
134
|
+
app.route("/api", createRoutes(orchestrator, launcher, wsBridge, terminalManager, prPoller, recorder, cronScheduler, agentExecutor, linearAgentBridge, port));
|
|
135
|
+
|
|
136
|
+
// Dynamic manifest — embeds auth token in start_url so PWA auto-authenticates
|
|
137
|
+
// on first launch. iOS gives standalone PWAs isolated storage from Safari,
|
|
138
|
+
// so this is the only way to bridge auth across the install boundary.
|
|
139
|
+
app.get("/manifest.json", (c) => {
|
|
140
|
+
const manifest = {
|
|
141
|
+
name: "The Companion",
|
|
142
|
+
short_name: "Companion",
|
|
143
|
+
description: "Web UI for Claude Code and Codex",
|
|
144
|
+
start_url: "/",
|
|
145
|
+
scope: "/",
|
|
146
|
+
display: "standalone" as const,
|
|
147
|
+
background_color: "#262624",
|
|
148
|
+
theme_color: "#d97757",
|
|
149
|
+
icons: [
|
|
150
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
151
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// If the user has an auth cookie (set during login), embed token in start_url.
|
|
156
|
+
// Safari sends this cookie when fetching the manifest at "Add to Home Screen" time.
|
|
157
|
+
const authCookie = getCookie(c, "companion_auth");
|
|
158
|
+
if (authCookie && verifyToken(authCookie)) {
|
|
159
|
+
manifest.start_url = `/?token=${authCookie}`;
|
|
160
|
+
} else {
|
|
161
|
+
// Localhost bypass — always embed the token for same-machine installs
|
|
162
|
+
const bunServer = c.env as { requestIP?: (req: Request) => { address: string } | null };
|
|
163
|
+
const ip = bunServer?.requestIP?.(c.req.raw);
|
|
164
|
+
const addr = ip?.address ?? "";
|
|
165
|
+
if (addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1") {
|
|
166
|
+
manifest.start_url = `/?token=${getToken()}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
c.header("Content-Type", "application/manifest+json");
|
|
171
|
+
return c.json(manifest);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// In production, serve built frontend using absolute path (works when installed as npm package)
|
|
175
|
+
if (process.env.NODE_ENV === "production") {
|
|
176
|
+
const distDir = resolve(packageRoot, "dist");
|
|
177
|
+
app.use("/*", cacheControlMiddleware());
|
|
178
|
+
app.use("/*", serveStatic({ root: distDir }));
|
|
179
|
+
app.get("/*", serveStatic({ path: resolve(distDir, "index.html") }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const server = Bun.serve<SocketData>({
|
|
183
|
+
hostname: host,
|
|
184
|
+
port,
|
|
185
|
+
idleTimeout: 0, // Disable top-level idle timeout — it kills idle browser WebSockets (code 1006)
|
|
186
|
+
async fetch(req, server) {
|
|
187
|
+
const url = new URL(req.url);
|
|
188
|
+
|
|
189
|
+
// ── CLI WebSocket — Claude Code CLI connects here via --sdk-url ────
|
|
190
|
+
const cliMatch = url.pathname.match(/^\/ws\/cli\/([a-f0-9-]+)$/);
|
|
191
|
+
if (cliMatch) {
|
|
192
|
+
const sessionId = cliMatch[1];
|
|
193
|
+
const upgraded = server.upgrade(req, {
|
|
194
|
+
data: { kind: "cli" as const, sessionId },
|
|
195
|
+
});
|
|
196
|
+
if (upgraded) return undefined;
|
|
197
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Helper: check if request is from localhost (same machine)
|
|
201
|
+
const reqIp = server.requestIP(req);
|
|
202
|
+
const reqAddr = reqIp?.address ?? "";
|
|
203
|
+
const isLocalhost = reqAddr === "127.0.0.1" || reqAddr === "::1" || reqAddr === "::ffff:127.0.0.1";
|
|
204
|
+
|
|
205
|
+
// ── Browser WebSocket — connects to a specific session ─────────────
|
|
206
|
+
const browserMatch = url.pathname.match(/^\/ws\/browser\/([a-f0-9-]+)$/);
|
|
207
|
+
if (browserMatch) {
|
|
208
|
+
if (managedAuthEnabled) {
|
|
209
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
210
|
+
if (!auth.ok) {
|
|
211
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const wsToken = url.searchParams.get("token");
|
|
215
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
216
|
+
return new Response("Unauthorized", { status: 401 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const sessionId = browserMatch[1];
|
|
220
|
+
const upgraded = server.upgrade(req, {
|
|
221
|
+
data: { kind: "browser" as const, sessionId },
|
|
222
|
+
});
|
|
223
|
+
if (upgraded) return undefined;
|
|
224
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Terminal WebSocket — embedded terminal PTY connection ─────────
|
|
228
|
+
const termMatch = url.pathname.match(/^\/ws\/terminal\/([a-f0-9-]+)$/);
|
|
229
|
+
if (termMatch) {
|
|
230
|
+
if (managedAuthEnabled) {
|
|
231
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
232
|
+
if (!auth.ok) {
|
|
233
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
const wsToken = url.searchParams.get("token");
|
|
237
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
238
|
+
return new Response("Unauthorized", { status: 401 });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const terminalId = termMatch[1];
|
|
242
|
+
const upgraded = server.upgrade(req, {
|
|
243
|
+
data: { kind: "terminal" as const, terminalId },
|
|
244
|
+
});
|
|
245
|
+
if (upgraded) return undefined;
|
|
246
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── noVNC WebSocket — proxies VNC data to container's websockify ────
|
|
250
|
+
const novncMatch = url.pathname.match(/^\/ws\/novnc\/([a-f0-9-]+)$/);
|
|
251
|
+
if (novncMatch) {
|
|
252
|
+
if (managedAuthEnabled) {
|
|
253
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
254
|
+
if (!auth.ok) {
|
|
255
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
const wsToken = url.searchParams.get("token");
|
|
259
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
260
|
+
return new Response("Unauthorized", { status: 401 });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const sessionId = novncMatch[1];
|
|
264
|
+
const upgraded = server.upgrade(req, {
|
|
265
|
+
data: { kind: "novnc" as const, sessionId },
|
|
266
|
+
});
|
|
267
|
+
if (upgraded) return undefined;
|
|
268
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Hono handles the rest
|
|
272
|
+
return app.fetch(req, server);
|
|
273
|
+
},
|
|
274
|
+
websocket: {
|
|
275
|
+
idleTimeout: 0,
|
|
276
|
+
sendPings: false, // Disable Bun ping timeout that kills CLI connections (code 1006)
|
|
277
|
+
open(ws: ServerWebSocket<SocketData>) {
|
|
278
|
+
const data = ws.data;
|
|
279
|
+
if (data.kind === "cli") {
|
|
280
|
+
wsBridge.handleCLIOpen(ws, data.sessionId);
|
|
281
|
+
launcher.markConnected(data.sessionId);
|
|
282
|
+
} else if (data.kind === "browser") {
|
|
283
|
+
wsBridge.handleBrowserOpen(ws, data.sessionId);
|
|
284
|
+
} else if (data.kind === "terminal") {
|
|
285
|
+
terminalManager.addBrowserSocket(ws);
|
|
286
|
+
} else if (data.kind === "novnc") {
|
|
287
|
+
noVncProxy.handleOpen(ws, data.sessionId);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
|
|
291
|
+
const data = ws.data;
|
|
292
|
+
if (data.kind === "cli") {
|
|
293
|
+
wsBridge.handleCLIMessage(ws, msg);
|
|
294
|
+
} else if (data.kind === "browser") {
|
|
295
|
+
wsBridge.handleBrowserMessage(ws, msg);
|
|
296
|
+
} else if (data.kind === "terminal") {
|
|
297
|
+
terminalManager.handleBrowserMessage(ws, msg);
|
|
298
|
+
} else if (data.kind === "novnc") {
|
|
299
|
+
noVncProxy.handleMessage(ws, msg);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
close(ws: ServerWebSocket<SocketData>, code?: number, _reason?: string) {
|
|
303
|
+
console.log("[ws-close]", ws.data.kind, "code=" + code);
|
|
304
|
+
const data = ws.data;
|
|
305
|
+
if (data.kind === "cli") {
|
|
306
|
+
wsBridge.handleCLIClose(ws);
|
|
307
|
+
} else if (data.kind === "browser") {
|
|
308
|
+
wsBridge.handleBrowserClose(ws);
|
|
309
|
+
} else if (data.kind === "terminal") {
|
|
310
|
+
terminalManager.removeBrowserSocket(ws);
|
|
311
|
+
} else if (data.kind === "novnc") {
|
|
312
|
+
noVncProxy.handleClose(ws);
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const authToken = getToken();
|
|
319
|
+
console.log(`Server running on http://${host}:${server.port}`);
|
|
320
|
+
console.log();
|
|
321
|
+
console.log(` Auth token: ${authToken}`);
|
|
322
|
+
if (process.env.COMPANION_AUTH_TOKEN) {
|
|
323
|
+
console.log(" (using COMPANION_AUTH_TOKEN env var)");
|
|
324
|
+
}
|
|
325
|
+
console.log();
|
|
326
|
+
console.log(` CLI WebSocket: ws://localhost:${server.port}/ws/cli/:sessionId`);
|
|
327
|
+
console.log(` Browser WebSocket: ws://localhost:${server.port}/ws/browser/:sessionId`);
|
|
328
|
+
|
|
329
|
+
if (process.env.NODE_ENV !== "production") {
|
|
330
|
+
console.log("Dev mode: frontend at http://localhost:5174");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Cron scheduler ──────────────────────────────────────────────────────────
|
|
334
|
+
cronScheduler.startAll();
|
|
335
|
+
|
|
336
|
+
// ── Agent system ────────────────────────────────────────────────────────────
|
|
337
|
+
migrateCronJobsToAgents();
|
|
338
|
+
migrateLinearCredentialsToAgents();
|
|
339
|
+
agentExecutor.startAll();
|
|
340
|
+
|
|
341
|
+
// ── Image pull manager — pre-pull missing Docker images for environments ────
|
|
342
|
+
imagePullManager.initFromEnvironments();
|
|
343
|
+
|
|
344
|
+
// ── Tailscale Funnel restoration ────────────────────────────────────────────
|
|
345
|
+
restoreTailscaleFunnel(port).catch((err) => {
|
|
346
|
+
console.warn("[server] Tailscale Funnel restoration failed:", err);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ── Update checker ──────────────────────────────────────────────────────────
|
|
350
|
+
startPeriodicCheck();
|
|
351
|
+
if (isRunningAsService()) {
|
|
352
|
+
setServiceMode(true);
|
|
353
|
+
console.log("[server] Running as background service (auto-update available)");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Runtime diagnostics ──────────────────────────────────────────────────────
|
|
357
|
+
import { log } from "./logger.js";
|
|
358
|
+
import { metricsCollector } from "./metrics-collector.js";
|
|
359
|
+
|
|
360
|
+
const DIAGNOSTICS_INTERVAL_MS = 5 * 60_000; // every 5 minutes
|
|
361
|
+
setInterval(() => {
|
|
362
|
+
const snap = metricsCollector.getSnapshot(wsBridge);
|
|
363
|
+
const mem = snap.gauges.memory;
|
|
364
|
+
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
|
|
365
|
+
const sessionStats = wsBridge.getSessionMemoryStats();
|
|
366
|
+
const topSessions = sessionStats
|
|
367
|
+
.sort((a, b) => b.historyLen - a.historyLen)
|
|
368
|
+
.slice(0, 3)
|
|
369
|
+
.map((s) => `${s.id.slice(0, 8)}(h=${s.historyLen},b=${s.browsers})`)
|
|
370
|
+
.join(", ");
|
|
371
|
+
|
|
372
|
+
log.info("diagnostics", "Runtime snapshot", {
|
|
373
|
+
rss: `${mb(mem.rss)}MB`,
|
|
374
|
+
heap: `${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB`,
|
|
375
|
+
external: `${mb(mem.external)}MB`,
|
|
376
|
+
sessions: snap.gauges.totalActiveSessions,
|
|
377
|
+
browsers: snap.gauges.connectedBrowsers,
|
|
378
|
+
historyMsgs: snap.gauges.totalHistoryMessages,
|
|
379
|
+
pendingMsgs: snap.gauges.totalPendingMessages,
|
|
380
|
+
eventBuffer: snap.gauges.totalEventBufferSize,
|
|
381
|
+
errors: Object.values(snap.counters.errors).reduce((a, b) => a + b, 0),
|
|
382
|
+
topSessions: topSessions || "none",
|
|
383
|
+
});
|
|
384
|
+
}, DIAGNOSTICS_INTERVAL_MS);
|
|
385
|
+
|
|
386
|
+
// ── Graceful shutdown — persist container state ──────────────────────────────
|
|
387
|
+
function gracefulShutdown() {
|
|
388
|
+
console.log("[server] Persisting container state before shutdown...");
|
|
389
|
+
containerManager.persistState(CONTAINER_STATE_PATH);
|
|
390
|
+
cleanupTailscaleFunnel(port);
|
|
391
|
+
closeLogFile();
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
395
|
+
process.on("SIGINT", gracefulShutdown);
|
|
396
|
+
|