@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,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { CronJob, CronJobCreateInput } from "./cron-types.js";
|
|
11
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const CRON_DIR = join(COMPANION_HOME, "cron");
|
|
16
|
+
|
|
17
|
+
function ensureDir(): void {
|
|
18
|
+
mkdirSync(CRON_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function filePath(id: string): string {
|
|
22
|
+
return join(CRON_DIR, `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function slugify(name: string): string {
|
|
28
|
+
return name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/\s+/g, "-")
|
|
31
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
32
|
+
.replace(/-+/g, "-")
|
|
33
|
+
.replace(/^-|-$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function listJobs(): CronJob[] {
|
|
39
|
+
ensureDir();
|
|
40
|
+
try {
|
|
41
|
+
const files = readdirSync(CRON_DIR).filter((f) => f.endsWith(".json"));
|
|
42
|
+
const jobs: CronJob[] = [];
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(join(CRON_DIR, file), "utf-8");
|
|
46
|
+
jobs.push(JSON.parse(raw));
|
|
47
|
+
} catch {
|
|
48
|
+
// Skip corrupt files
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
jobs.sort((a, b) => a.name.localeCompare(b.name));
|
|
52
|
+
return jobs;
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getJob(id: string): CronJob | null {
|
|
59
|
+
ensureDir();
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(filePath(id), "utf-8");
|
|
62
|
+
return JSON.parse(raw) as CronJob;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createJob(data: CronJobCreateInput): CronJob {
|
|
69
|
+
if (!data.name || !data.name.trim()) throw new Error("Job name is required");
|
|
70
|
+
if (!data.prompt || !data.prompt.trim()) throw new Error("Job prompt is required");
|
|
71
|
+
if (!data.schedule || !data.schedule.trim()) throw new Error("Job schedule is required");
|
|
72
|
+
if (!data.cwd || !data.cwd.trim()) throw new Error("Job working directory is required");
|
|
73
|
+
|
|
74
|
+
const id = slugify(data.name.trim());
|
|
75
|
+
if (!id) throw new Error("Job name must contain alphanumeric characters");
|
|
76
|
+
|
|
77
|
+
ensureDir();
|
|
78
|
+
if (existsSync(filePath(id))) {
|
|
79
|
+
throw new Error(`A job with a similar name already exists ("${id}")`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const job: CronJob = {
|
|
84
|
+
...data,
|
|
85
|
+
id,
|
|
86
|
+
name: data.name.trim(),
|
|
87
|
+
prompt: data.prompt.trim(),
|
|
88
|
+
schedule: data.schedule.trim(),
|
|
89
|
+
cwd: data.cwd.trim(),
|
|
90
|
+
createdAt: now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
consecutiveFailures: 0,
|
|
93
|
+
totalRuns: 0,
|
|
94
|
+
};
|
|
95
|
+
writeFileSync(filePath(id), JSON.stringify(job, null, 2), "utf-8");
|
|
96
|
+
return job;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function updateJob(
|
|
100
|
+
id: string,
|
|
101
|
+
updates: Partial<CronJob>,
|
|
102
|
+
): CronJob | null {
|
|
103
|
+
ensureDir();
|
|
104
|
+
const existing = getJob(id);
|
|
105
|
+
if (!existing) return null;
|
|
106
|
+
|
|
107
|
+
const newName = updates.name?.trim() || existing.name;
|
|
108
|
+
const newId = slugify(newName);
|
|
109
|
+
if (!newId) throw new Error("Job name must contain alphanumeric characters");
|
|
110
|
+
|
|
111
|
+
// If name changed, check for slug collision with a different job
|
|
112
|
+
if (newId !== id && existsSync(filePath(newId))) {
|
|
113
|
+
throw new Error(`A job with a similar name already exists ("${newId}")`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const job: CronJob = {
|
|
117
|
+
...existing,
|
|
118
|
+
...updates,
|
|
119
|
+
id: newId,
|
|
120
|
+
name: newName,
|
|
121
|
+
updatedAt: Date.now(),
|
|
122
|
+
// Preserve immutable fields
|
|
123
|
+
createdAt: existing.createdAt,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// If id changed, delete old file
|
|
127
|
+
if (newId !== id) {
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(filePath(id));
|
|
130
|
+
} catch {
|
|
131
|
+
/* ok */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
writeFileSync(filePath(newId), JSON.stringify(job, null, 2), "utf-8");
|
|
136
|
+
return job;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function deleteJob(id: string): boolean {
|
|
140
|
+
ensureDir();
|
|
141
|
+
if (!existsSync(filePath(id))) return false;
|
|
142
|
+
try {
|
|
143
|
+
unlinkSync(filePath(id));
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── Cron Job Types ────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface CronJob {
|
|
4
|
+
/** Unique slug-based ID (derived from name) */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Human-readable job name */
|
|
7
|
+
name: string;
|
|
8
|
+
/** The prompt to send when the job fires */
|
|
9
|
+
prompt: string;
|
|
10
|
+
/** Cron expression (e.g. "0 8 * * *") or ISO datetime string for one-shot */
|
|
11
|
+
schedule: string;
|
|
12
|
+
/** true = recurring cron, false = one-shot at a specific time */
|
|
13
|
+
recurring: boolean;
|
|
14
|
+
/** Backend to use */
|
|
15
|
+
backendType: "claude" | "codex";
|
|
16
|
+
/** Model to use (e.g. "claude-sonnet-4-6") */
|
|
17
|
+
model: string;
|
|
18
|
+
/** Working directory for the session */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** Optional environment slug (references ~/.companion/envs/) */
|
|
21
|
+
envSlug?: string;
|
|
22
|
+
/** Whether the job is currently enabled */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Permission mode — defaults to "bypassPermissions" for autonomy */
|
|
25
|
+
permissionMode: string;
|
|
26
|
+
/** Codex-only: enable internet access */
|
|
27
|
+
codexInternetAccess?: boolean;
|
|
28
|
+
|
|
29
|
+
// ── Tracking ──
|
|
30
|
+
createdAt: number;
|
|
31
|
+
updatedAt: number;
|
|
32
|
+
/** Last time this job was triggered */
|
|
33
|
+
lastRunAt?: number;
|
|
34
|
+
/** Session ID of the last execution */
|
|
35
|
+
lastSessionId?: string;
|
|
36
|
+
/** Number of consecutive failures */
|
|
37
|
+
consecutiveFailures: number;
|
|
38
|
+
/** Total number of runs */
|
|
39
|
+
totalRuns: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CronJobExecution {
|
|
43
|
+
/** The session ID created for this execution */
|
|
44
|
+
sessionId: string;
|
|
45
|
+
/** The job ID that triggered this */
|
|
46
|
+
jobId: string;
|
|
47
|
+
/** When the execution started */
|
|
48
|
+
startedAt: number;
|
|
49
|
+
/** When the execution completed (result received) */
|
|
50
|
+
completedAt?: number;
|
|
51
|
+
/** Whether the execution succeeded */
|
|
52
|
+
success?: boolean;
|
|
53
|
+
/** Error message if it failed */
|
|
54
|
+
error?: string;
|
|
55
|
+
/** Cost in USD */
|
|
56
|
+
costUsd?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Input for creating a cron job (without auto-generated fields) */
|
|
60
|
+
export type CronJobCreateInput = Omit<
|
|
61
|
+
CronJob,
|
|
62
|
+
"id" | "createdAt" | "updatedAt" | "consecutiveFailures" | "totalRuns" | "lastRunAt" | "lastSessionId"
|
|
63
|
+
>;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
let tempDir: string;
|
|
6
|
+
let envManager: typeof import("./env-manager.js");
|
|
7
|
+
|
|
8
|
+
const mockHomedir = vi.hoisted(() => {
|
|
9
|
+
let dir = "";
|
|
10
|
+
return {
|
|
11
|
+
get: () => dir,
|
|
12
|
+
set: (d: string) => {
|
|
13
|
+
dir = d;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
homedir: () => mockHomedir.get(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
tempDir = mkdtempSync(join(tmpdir(), "env-test-"));
|
|
28
|
+
mockHomedir.set(tempDir);
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
envManager = await import("./env-manager.js");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helper to get the envs directory path used by the module
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function envsDir(): string {
|
|
41
|
+
return join(tempDir, ".companion", "envs");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ===========================================================================
|
|
45
|
+
// Slugification (tested indirectly via createEnv)
|
|
46
|
+
// ===========================================================================
|
|
47
|
+
describe("slugification via createEnv", () => {
|
|
48
|
+
it("converts spaces to hyphens and lowercases", async () => {
|
|
49
|
+
const env = envManager.createEnv("My App");
|
|
50
|
+
expect(env.slug).toBe("my-app");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("strips special characters", async () => {
|
|
54
|
+
const env = envManager.createEnv("Hello World! @#$%");
|
|
55
|
+
expect(env.slug).toBe("hello-world");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("collapses consecutive hyphens", async () => {
|
|
59
|
+
const env = envManager.createEnv("a --- b");
|
|
60
|
+
expect(env.slug).toBe("a-b");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("trims leading and trailing hyphens", async () => {
|
|
64
|
+
const env = envManager.createEnv(" -cool env- ");
|
|
65
|
+
expect(env.slug).toBe("cool-env");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws when name is empty string", () => {
|
|
69
|
+
expect(() => envManager.createEnv("")).toThrow("Environment name is required");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("throws when name is only whitespace", () => {
|
|
73
|
+
expect(() => envManager.createEnv(" ")).toThrow("Environment name is required");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws when name contains no alphanumeric characters", () => {
|
|
77
|
+
expect(() => envManager.createEnv("@#$%^&")).toThrow(
|
|
78
|
+
"Environment name must contain alphanumeric characters",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ===========================================================================
|
|
84
|
+
// listEnvs
|
|
85
|
+
// ===========================================================================
|
|
86
|
+
describe("listEnvs", () => {
|
|
87
|
+
it("returns empty array when no envs exist", () => {
|
|
88
|
+
const result = envManager.listEnvs();
|
|
89
|
+
expect(result).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns envs sorted alphabetically by name", () => {
|
|
93
|
+
envManager.createEnv("Zebra");
|
|
94
|
+
envManager.createEnv("Alpha");
|
|
95
|
+
envManager.createEnv("Mango");
|
|
96
|
+
|
|
97
|
+
const result = envManager.listEnvs();
|
|
98
|
+
expect(result.map((e) => e.name)).toEqual(["Alpha", "Mango", "Zebra"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips corrupt JSON files", () => {
|
|
102
|
+
// Create a valid env first
|
|
103
|
+
envManager.createEnv("Valid");
|
|
104
|
+
|
|
105
|
+
// Write a corrupt file directly into the envs directory
|
|
106
|
+
writeFileSync(join(envsDir(), "corrupt.json"), "NOT VALID JSON{{{", "utf-8");
|
|
107
|
+
|
|
108
|
+
const result = envManager.listEnvs();
|
|
109
|
+
expect(result).toHaveLength(1);
|
|
110
|
+
expect(result[0].name).toBe("Valid");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ===========================================================================
|
|
115
|
+
// getEnv
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
describe("getEnv", () => {
|
|
118
|
+
it("returns the env when it exists", () => {
|
|
119
|
+
envManager.createEnv("My Service", { PORT: "3000" });
|
|
120
|
+
|
|
121
|
+
const result = envManager.getEnv("my-service");
|
|
122
|
+
expect(result).not.toBeNull();
|
|
123
|
+
expect(result!.name).toBe("My Service");
|
|
124
|
+
expect(result!.slug).toBe("my-service");
|
|
125
|
+
expect(result!.variables).toEqual({ PORT: "3000" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null when the env does not exist", () => {
|
|
129
|
+
const result = envManager.getEnv("nonexistent");
|
|
130
|
+
expect(result).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ===========================================================================
|
|
135
|
+
// createEnv
|
|
136
|
+
// ===========================================================================
|
|
137
|
+
describe("createEnv", () => {
|
|
138
|
+
it("returns an env with correct structure and timestamps", () => {
|
|
139
|
+
const before = Date.now();
|
|
140
|
+
const env = envManager.createEnv("Production", { NODE_ENV: "production" });
|
|
141
|
+
const after = Date.now();
|
|
142
|
+
|
|
143
|
+
expect(env.name).toBe("Production");
|
|
144
|
+
expect(env.slug).toBe("production");
|
|
145
|
+
expect(env.variables).toEqual({ NODE_ENV: "production" });
|
|
146
|
+
expect(env.createdAt).toBeGreaterThanOrEqual(before);
|
|
147
|
+
expect(env.createdAt).toBeLessThanOrEqual(after);
|
|
148
|
+
expect(env.updatedAt).toBe(env.createdAt);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("persists the env to disk as JSON", () => {
|
|
152
|
+
envManager.createEnv("Disk Check");
|
|
153
|
+
|
|
154
|
+
const raw = readFileSync(join(envsDir(), "disk-check.json"), "utf-8");
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
expect(parsed.name).toBe("Disk Check");
|
|
157
|
+
expect(parsed.slug).toBe("disk-check");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("defaults variables to empty object", () => {
|
|
161
|
+
const env = envManager.createEnv("No Vars");
|
|
162
|
+
expect(env.variables).toEqual({});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws when creating a duplicate slug", () => {
|
|
166
|
+
envManager.createEnv("My App");
|
|
167
|
+
expect(() => envManager.createEnv("My App")).toThrow(
|
|
168
|
+
'An environment with a similar name already exists ("my-app")',
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("trims the name before saving", () => {
|
|
173
|
+
const env = envManager.createEnv(" Spaced Out ");
|
|
174
|
+
expect(env.name).toBe("Spaced Out");
|
|
175
|
+
expect(env.slug).toBe("spaced-out");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ===========================================================================
|
|
180
|
+
// updateEnv
|
|
181
|
+
// ===========================================================================
|
|
182
|
+
describe("updateEnv", () => {
|
|
183
|
+
it("updates name and variables", () => {
|
|
184
|
+
envManager.createEnv("Original", { KEY: "old" });
|
|
185
|
+
|
|
186
|
+
const updated = envManager.updateEnv("original", {
|
|
187
|
+
name: "Renamed",
|
|
188
|
+
variables: { KEY: "new" },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(updated).not.toBeNull();
|
|
192
|
+
expect(updated!.name).toBe("Renamed");
|
|
193
|
+
expect(updated!.slug).toBe("renamed");
|
|
194
|
+
expect(updated!.variables).toEqual({ KEY: "new" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("renames the file on disk when slug changes", () => {
|
|
198
|
+
envManager.createEnv("Old Name");
|
|
199
|
+
|
|
200
|
+
envManager.updateEnv("old-name", { name: "New Name" });
|
|
201
|
+
|
|
202
|
+
// Old file should be gone, new file should exist
|
|
203
|
+
const oldPath = join(envsDir(), "old-name.json");
|
|
204
|
+
const newPath = join(envsDir(), "new-name.json");
|
|
205
|
+
|
|
206
|
+
expect(() => readFileSync(oldPath, "utf-8")).toThrow();
|
|
207
|
+
const parsed = JSON.parse(readFileSync(newPath, "utf-8"));
|
|
208
|
+
expect(parsed.name).toBe("New Name");
|
|
209
|
+
expect(parsed.slug).toBe("new-name");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("throws on slug collision during rename", () => {
|
|
213
|
+
envManager.createEnv("Alpha");
|
|
214
|
+
envManager.createEnv("Beta");
|
|
215
|
+
|
|
216
|
+
expect(() => envManager.updateEnv("alpha", { name: "Beta" })).toThrow(
|
|
217
|
+
'An environment with a similar name already exists ("beta")',
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns null for a non-existent slug", () => {
|
|
222
|
+
const result = envManager.updateEnv("ghost", { name: "New" });
|
|
223
|
+
expect(result).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("preserves createdAt and advances updatedAt", async () => {
|
|
227
|
+
const env = envManager.createEnv("Timestamps");
|
|
228
|
+
const originalCreatedAt = env.createdAt;
|
|
229
|
+
|
|
230
|
+
// Small delay to ensure Date.now() advances
|
|
231
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
232
|
+
|
|
233
|
+
const updated = envManager.updateEnv("timestamps", { variables: { A: "1" } });
|
|
234
|
+
|
|
235
|
+
expect(updated).not.toBeNull();
|
|
236
|
+
expect(updated!.createdAt).toBe(originalCreatedAt);
|
|
237
|
+
expect(updated!.updatedAt).toBeGreaterThan(originalCreatedAt);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("keeps existing variables when only name is updated", () => {
|
|
241
|
+
envManager.createEnv("Keep Vars", { SECRET: "abc" });
|
|
242
|
+
|
|
243
|
+
const updated = envManager.updateEnv("keep-vars", { name: "Kept Vars" });
|
|
244
|
+
expect(updated!.variables).toEqual({ SECRET: "abc" });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Docker-related tests (getEffectiveImage, updateBuildStatus, createEnv with docker options)
|
|
249
|
+
// have been moved to sandbox-manager.test.ts as part of the sandbox/environment separation.
|
|
250
|
+
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
// deleteEnv
|
|
253
|
+
// ===========================================================================
|
|
254
|
+
describe("deleteEnv", () => {
|
|
255
|
+
it("deletes an existing env and returns true", () => {
|
|
256
|
+
envManager.createEnv("To Delete");
|
|
257
|
+
const result = envManager.deleteEnv("to-delete");
|
|
258
|
+
expect(result).toBe(true);
|
|
259
|
+
|
|
260
|
+
// Confirm it is gone
|
|
261
|
+
expect(envManager.getEnv("to-delete")).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("returns false when the env does not exist", () => {
|
|
265
|
+
const result = envManager.deleteEnv("missing");
|
|
266
|
+
expect(result).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface CompanionEnv {
|
|
15
|
+
name: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
variables: Record<string, string>;
|
|
18
|
+
|
|
19
|
+
createdAt: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fields that can be updated via the update API */
|
|
24
|
+
export interface EnvUpdateFields {
|
|
25
|
+
name?: string;
|
|
26
|
+
variables?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const ENVS_DIR = join(COMPANION_HOME, "envs");
|
|
32
|
+
|
|
33
|
+
function ensureDir(): void {
|
|
34
|
+
mkdirSync(ENVS_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate that a slug contains only safe characters (prevents path traversal) */
|
|
38
|
+
function validateSlug(slug: string): void {
|
|
39
|
+
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
40
|
+
throw new Error("Invalid slug: must contain only lowercase alphanumeric characters and hyphens");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function filePath(slug: string): string {
|
|
45
|
+
validateSlug(slug);
|
|
46
|
+
return join(ENVS_DIR, `${slug}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function slugify(name: string): string {
|
|
52
|
+
return name
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/\s+/g, "-")
|
|
55
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
56
|
+
.replace(/-+/g, "-")
|
|
57
|
+
.replace(/^-|-$/g, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function listEnvs(): CompanionEnv[] {
|
|
63
|
+
ensureDir();
|
|
64
|
+
try {
|
|
65
|
+
const files = readdirSync(ENVS_DIR).filter((f) => f.endsWith(".json"));
|
|
66
|
+
const envs: CompanionEnv[] = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(join(ENVS_DIR, file), "utf-8");
|
|
70
|
+
envs.push(JSON.parse(raw));
|
|
71
|
+
} catch {
|
|
72
|
+
// Skip corrupt files
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
envs.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
return envs;
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getEnv(slug: string): CompanionEnv | null {
|
|
83
|
+
ensureDir();
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(filePath(slug), "utf-8");
|
|
86
|
+
return JSON.parse(raw) as CompanionEnv;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createEnv(
|
|
93
|
+
name: string,
|
|
94
|
+
variables: Record<string, string> = {},
|
|
95
|
+
): CompanionEnv {
|
|
96
|
+
if (!name || !name.trim()) throw new Error("Environment name is required");
|
|
97
|
+
const slug = slugify(name.trim());
|
|
98
|
+
if (!slug) throw new Error("Environment name must contain alphanumeric characters");
|
|
99
|
+
|
|
100
|
+
ensureDir();
|
|
101
|
+
if (existsSync(filePath(slug))) {
|
|
102
|
+
throw new Error(`An environment with a similar name already exists ("${slug}")`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const env: CompanionEnv = {
|
|
107
|
+
name: name.trim(),
|
|
108
|
+
slug,
|
|
109
|
+
variables,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
writeFileSync(filePath(slug), JSON.stringify(env, null, 2), "utf-8");
|
|
115
|
+
return env;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function updateEnv(
|
|
119
|
+
slug: string,
|
|
120
|
+
updates: EnvUpdateFields,
|
|
121
|
+
): CompanionEnv | null {
|
|
122
|
+
ensureDir();
|
|
123
|
+
const existing = getEnv(slug);
|
|
124
|
+
if (!existing) return null;
|
|
125
|
+
|
|
126
|
+
const newName = updates.name?.trim() || existing.name;
|
|
127
|
+
const newSlug = slugify(newName);
|
|
128
|
+
if (!newSlug) throw new Error("Environment name must contain alphanumeric characters");
|
|
129
|
+
|
|
130
|
+
// If name changed, check for slug collision with a different env
|
|
131
|
+
if (newSlug !== slug && existsSync(filePath(newSlug))) {
|
|
132
|
+
throw new Error(`An environment with a similar name already exists ("${newSlug}")`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const env: CompanionEnv = {
|
|
136
|
+
...existing,
|
|
137
|
+
name: newName,
|
|
138
|
+
slug: newSlug,
|
|
139
|
+
variables: updates.variables ?? existing.variables,
|
|
140
|
+
updatedAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// If slug changed, delete old file
|
|
144
|
+
if (newSlug !== slug) {
|
|
145
|
+
try { unlinkSync(filePath(slug)); } catch { /* ok */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeFileSync(filePath(newSlug), JSON.stringify(env, null, 2), "utf-8");
|
|
149
|
+
return env;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function deleteEnv(slug: string): boolean {
|
|
153
|
+
ensureDir();
|
|
154
|
+
if (!existsSync(filePath(slug))) return false;
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(filePath(slug));
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Typed event map for the Companion internal event bus.
|
|
2
|
+
// Each key is a namespaced event name; values are the payload passed to handlers.
|
|
3
|
+
|
|
4
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
5
|
+
import type { CodexAdapter } from "./codex-adapter.js";
|
|
6
|
+
import type { SessionPhase } from "./session-state-machine.js";
|
|
7
|
+
|
|
8
|
+
export interface CompanionEventMap {
|
|
9
|
+
// ── Session lifecycle ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** CLI reported its internal session ID (used for --resume). */
|
|
12
|
+
"session:cli-id-received": { sessionId: string; cliSessionId: string };
|
|
13
|
+
|
|
14
|
+
/** CLI/Codex process exited. */
|
|
15
|
+
"session:exited": { sessionId: string; exitCode: number | null };
|
|
16
|
+
|
|
17
|
+
/** CLI WebSocket disconnected and a browser needs a relaunch. */
|
|
18
|
+
"session:relaunch-needed": { sessionId: string };
|
|
19
|
+
|
|
20
|
+
/** Idle-kill threshold reached with no connected browsers. */
|
|
21
|
+
"session:idle-kill": { sessionId: string };
|
|
22
|
+
|
|
23
|
+
/** First non-error turn completed (triggers auto-naming). */
|
|
24
|
+
"session:first-turn-completed": {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
firstUserMessage: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Git info resolved for a session (branch and cwd known). */
|
|
30
|
+
"session:git-info-ready": { sessionId: string; cwd: string; branch: string };
|
|
31
|
+
|
|
32
|
+
/** Session phase changed (formal state machine transition). */
|
|
33
|
+
"session:phase-changed": {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
from: SessionPhase;
|
|
36
|
+
to: SessionPhase;
|
|
37
|
+
trigger: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Backend integration ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Codex adapter created and ready to be attached to WsBridge. */
|
|
43
|
+
"backend:codex-adapter-created": {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
adapter: CodexAdapter;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Per-session messages (high volume) ─────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** An assistant message was processed and broadcast to browsers. */
|
|
51
|
+
"message:assistant": {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
message: BrowserIncomingMessage;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A stream event was processed and broadcast to browsers. */
|
|
57
|
+
"message:stream_event": {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
message: BrowserIncomingMessage;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A result (turn completion) was processed and broadcast to browsers. */
|
|
63
|
+
"message:result": { sessionId: string; message: BrowserIncomingMessage };
|
|
64
|
+
}
|