@neuralnomads/codenomad-dev 0.10.3-dev-20260213-ba418a85 → 0.10.3-dev-20260213-e9f281a6

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.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/public/apple-touch-icon-180x180.png +0 -0
  3. package/public/assets/{main-CSlDZj4f.js → main-crtt5pqm.js} +82 -80
  4. package/public/index.html +1 -1
  5. package/public/sw.js +1 -1
  6. package/public/ui-version.json +1 -1
  7. package/dist/integrations/github/bot-signature.js +0 -11
  8. package/dist/integrations/github/git-ops.js +0 -133
  9. package/dist/integrations/github/github-types.js +0 -1
  10. package/dist/integrations/github/job-runner.js +0 -608
  11. package/dist/integrations/github/octokit.js +0 -58
  12. package/dist/integrations/github/sanitize-webhook.js +0 -42
  13. package/dist/integrations/github/webhook-verify.js +0 -21
  14. package/dist/integrations/github/workspace-context.js +0 -10
  15. package/dist/integrations/github/worktree-context.js +0 -15
  16. package/dist/opencode/request-context.js +0 -39
  17. package/dist/opencode/worktree-directory.js +0 -42
  18. package/dist/opencode-config-template/README.md +0 -32
  19. package/dist/opencode-config-template/opencode.jsonc +0 -3
  20. package/dist/opencode-config-template/plugin/codenomad.ts +0 -40
  21. package/dist/opencode-config-template/plugin/lib/background-process.ts +0 -160
  22. package/dist/opencode-config-template/plugin/lib/client.ts +0 -165
  23. package/dist/server/routes/github-plugin.js +0 -215
  24. package/dist/server/routes/github-webhook.js +0 -32
  25. package/scripts/copy-auth-pages.mjs +0 -22
  26. package/scripts/copy-opencode-config.mjs +0 -61
  27. package/scripts/copy-ui-dist.mjs +0 -21
  28. package/src/api-types.ts +0 -326
  29. package/src/auth/auth-store.ts +0 -175
  30. package/src/auth/http-auth.ts +0 -38
  31. package/src/auth/manager.ts +0 -163
  32. package/src/auth/password-hash.ts +0 -49
  33. package/src/auth/session-manager.ts +0 -23
  34. package/src/auth/token-manager.ts +0 -32
  35. package/src/background-processes/manager.ts +0 -519
  36. package/src/bin.ts +0 -29
  37. package/src/config/binaries.ts +0 -192
  38. package/src/config/location.ts +0 -78
  39. package/src/config/schema.ts +0 -104
  40. package/src/config/store.ts +0 -244
  41. package/src/events/bus.ts +0 -45
  42. package/src/filesystem/__tests__/search-cache.test.ts +0 -61
  43. package/src/filesystem/browser.ts +0 -353
  44. package/src/filesystem/search-cache.ts +0 -66
  45. package/src/filesystem/search.ts +0 -184
  46. package/src/index.ts +0 -540
  47. package/src/launcher.ts +0 -177
  48. package/src/loader.ts +0 -21
  49. package/src/logger.ts +0 -133
  50. package/src/opencode-config.ts +0 -31
  51. package/src/plugins/channel.ts +0 -55
  52. package/src/plugins/handlers.ts +0 -36
  53. package/src/releases/dev-release-monitor.ts +0 -118
  54. package/src/releases/release-monitor.ts +0 -149
  55. package/src/server/http-server.ts +0 -693
  56. package/src/server/network-addresses.ts +0 -75
  57. package/src/server/routes/auth-pages/login.html +0 -134
  58. package/src/server/routes/auth-pages/token.html +0 -93
  59. package/src/server/routes/auth.ts +0 -164
  60. package/src/server/routes/background-processes.ts +0 -85
  61. package/src/server/routes/config.ts +0 -76
  62. package/src/server/routes/events.ts +0 -61
  63. package/src/server/routes/filesystem.ts +0 -54
  64. package/src/server/routes/meta.ts +0 -58
  65. package/src/server/routes/plugin.ts +0 -75
  66. package/src/server/routes/storage.ts +0 -66
  67. package/src/server/routes/workspaces.ts +0 -113
  68. package/src/server/routes/worktrees.ts +0 -195
  69. package/src/server/tls.ts +0 -283
  70. package/src/storage/instance-store.ts +0 -64
  71. package/src/ui/__tests__/remote-ui.test.ts +0 -58
  72. package/src/ui/remote-ui.ts +0 -571
  73. package/src/workspaces/git-worktrees.ts +0 -241
  74. package/src/workspaces/instance-events.ts +0 -226
  75. package/src/workspaces/manager.ts +0 -493
  76. package/src/workspaces/opencode-auth.ts +0 -22
  77. package/src/workspaces/runtime.ts +0 -428
  78. package/src/workspaces/worktree-map.ts +0 -129
  79. package/tsconfig.json +0 -17
@@ -1,42 +0,0 @@
1
- const KEEP_URL_KEYS = new Set(["html_url"]);
2
- function shouldDropKey(key) {
3
- if (key === "installation")
4
- return true;
5
- if (key === "url")
6
- return true;
7
- if (key.endsWith("_url") && !KEEP_URL_KEYS.has(key))
8
- return true;
9
- return false;
10
- }
11
- function sanitizeValue(value) {
12
- if (Array.isArray(value)) {
13
- return value.map((item) => sanitizeValue(item));
14
- }
15
- if (!value || typeof value !== "object") {
16
- return value;
17
- }
18
- const out = {};
19
- for (const [key, raw] of Object.entries(value)) {
20
- // Never modify body fields; this preserves user content.
21
- if (key === "body") {
22
- out[key] = raw;
23
- continue;
24
- }
25
- if (shouldDropKey(key)) {
26
- continue;
27
- }
28
- out[key] = sanitizeValue(raw);
29
- }
30
- return out;
31
- }
32
- export function sanitizeGitHubWebhookPayload(payload) {
33
- // Deep clone first to drop prototypes/functions and ensure deterministic output.
34
- let cloned;
35
- try {
36
- cloned = JSON.parse(JSON.stringify(payload));
37
- }
38
- catch {
39
- cloned = payload;
40
- }
41
- return sanitizeValue(cloned);
42
- }
@@ -1,21 +0,0 @@
1
- import crypto from "crypto";
2
- export function verifyGitHubWebhookSignature(params) {
3
- const secret = (params.secret ?? "").trim();
4
- if (!secret)
5
- return false;
6
- const signature = (params.signatureHeader ?? "").trim();
7
- if (!signature.startsWith("sha256="))
8
- return false;
9
- const expectedHex = crypto.createHmac("sha256", secret).update(params.body).digest("hex");
10
- const expected = `sha256=${expectedHex}`;
11
- try {
12
- const a = Buffer.from(signature, "utf8");
13
- const b = Buffer.from(expected, "utf8");
14
- if (a.length !== b.length)
15
- return false;
16
- return crypto.timingSafeEqual(a, b);
17
- }
18
- catch {
19
- return false;
20
- }
21
- }
@@ -1,10 +0,0 @@
1
- const workspaceContext = new Map();
2
- export function setGitHubWorkspaceContext(workspaceId, context) {
3
- workspaceContext.set(workspaceId, context);
4
- }
5
- export function getGitHubWorkspaceContext(workspaceId) {
6
- return workspaceContext.get(workspaceId) ?? null;
7
- }
8
- export function clearGitHubWorkspaceContext(workspaceId) {
9
- workspaceContext.delete(workspaceId);
10
- }
@@ -1,15 +0,0 @@
1
- const worktreeContextByWorkspace = new Map();
2
- export function setGitHubWorktreeContext(workspaceId, worktreeSlug, context) {
3
- let map = worktreeContextByWorkspace.get(workspaceId);
4
- if (!map) {
5
- map = new Map();
6
- worktreeContextByWorkspace.set(workspaceId, map);
7
- }
8
- map.set(worktreeSlug, context);
9
- }
10
- export function getGitHubWorktreeContext(workspaceId, worktreeSlug) {
11
- return worktreeContextByWorkspace.get(workspaceId)?.get(worktreeSlug) ?? null;
12
- }
13
- export function clearGitHubWorktreeContext(workspaceId) {
14
- worktreeContextByWorkspace.delete(workspaceId);
15
- }
@@ -1,39 +0,0 @@
1
- import { isValidWorktreeSlug } from "../workspaces/git-worktrees";
2
- import { resolveWorktreeDirectory } from "./worktree-directory";
3
- const INSTANCE_PROXY_HOST = "127.0.0.1";
4
- export async function buildOpencodeRequestContext(params) {
5
- const workspace = params.workspaceManager.get(params.workspaceId);
6
- if (!workspace) {
7
- throw new Error("Workspace not found");
8
- }
9
- const port = params.workspaceManager.getInstancePort(params.workspaceId);
10
- if (!port) {
11
- throw new Error("Workspace instance is not ready");
12
- }
13
- if (!isValidWorktreeSlug(params.worktreeSlug)) {
14
- throw new Error("Invalid worktree slug");
15
- }
16
- const directory = await resolveWorktreeDirectory({
17
- workspaceId: params.workspaceId,
18
- workspacePath: workspace.path,
19
- worktreeSlug: params.worktreeSlug,
20
- logger: params.logger,
21
- });
22
- if (!directory) {
23
- throw new Error("Worktree not found");
24
- }
25
- const instanceAuthHeader = params.workspaceManager.getInstanceAuthorizationHeader(params.workspaceId);
26
- const isNonASCII = /[^\x00-\x7F]/.test(directory);
27
- const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory;
28
- const headers = {
29
- "x-opencode-directory": encodedDirectory,
30
- };
31
- if (instanceAuthHeader) {
32
- headers.authorization = instanceAuthHeader;
33
- }
34
- return {
35
- baseUrl: `http://${INSTANCE_PROXY_HOST}:${port}`,
36
- directory,
37
- headers,
38
- };
39
- }
@@ -1,42 +0,0 @@
1
- import { resolveRepoRoot, listWorktrees } from "../workspaces/git-worktrees";
2
- const WORKTREE_CACHE_TTL_MS = 2000;
3
- const worktreeCache = new Map();
4
- async function getCachedWorktrees(params) {
5
- const cached = worktreeCache.get(params.workspaceId);
6
- const now = Date.now();
7
- if (cached && cached.expiresAt > now) {
8
- return cached;
9
- }
10
- const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger);
11
- const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger });
12
- const entry = {
13
- expiresAt: now + WORKTREE_CACHE_TTL_MS,
14
- repoRoot,
15
- worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
16
- };
17
- worktreeCache.set(params.workspaceId, entry);
18
- return entry;
19
- }
20
- export async function resolveWorktreeDirectory(params) {
21
- const { worktreeSlug } = params;
22
- const cached = await getCachedWorktrees({
23
- workspaceId: params.workspaceId,
24
- workspacePath: params.workspacePath,
25
- logger: params.logger,
26
- });
27
- const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug);
28
- if (match) {
29
- return match.directory;
30
- }
31
- // If the slug is new (e.g., created moments ago), refresh once.
32
- worktreeCache.delete(params.workspaceId);
33
- const refreshed = await getCachedWorktrees({
34
- workspaceId: params.workspaceId,
35
- workspacePath: params.workspacePath,
36
- logger: params.logger,
37
- });
38
- return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null;
39
- }
40
- export function clearWorktreeDirectoryCache(workspaceId) {
41
- worktreeCache.delete(workspaceId);
42
- }
@@ -1,32 +0,0 @@
1
- # opencode-config
2
-
3
- ## TLDR
4
- Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
5
-
6
- ## What it is
7
- A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
8
-
9
- ## How it works
10
- - CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
11
- - This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
12
- - OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
13
- - The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
14
- - The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
15
-
16
- ## Expectations
17
- - Local-only bridge (no auth/token yet).
18
- - Plugin must fail startup if it cannot connect after 3 retries.
19
- - Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
20
- - Keep event shapes small and explicit; use `type` + `properties` only.
21
-
22
- ## Ideas
23
- - Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
24
- - Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
25
- - Promote stable event shapes and version tags once the protocol settles.
26
-
27
- ## Pointers
28
- - Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
29
- - Plugin client: `packages/opencode-config/plugin/lib/client.ts`
30
- - Plugin server routes: `packages/server/src/server/routes/plugin.ts`
31
- - Plugin event handling: `packages/server/src/plugins/handlers.ts`
32
- - Workspace env injection: `packages/server/src/workspaces/manager.ts`
@@ -1,3 +0,0 @@
1
- {
2
- "$schema": "https://opencode.ai/config.json"
3
- }
@@ -1,40 +0,0 @@
1
- import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
2
- import { createBackgroundProcessTools } from "./lib/background-process"
3
-
4
- export async function CodeNomadPlugin() {
5
- const config = getCodeNomadConfig()
6
- const client = createCodeNomadClient(config)
7
- const backgroundProcessTools = createBackgroundProcessTools(config)
8
-
9
- await client.startEvents((event) => {
10
- if (event.type === "codenomad.ping") {
11
- void client.postEvent({
12
- type: "codenomad.pong",
13
- properties: {
14
- ts: Date.now(),
15
- pingTs: (event.properties as any)?.ts,
16
- },
17
- }).catch(() => {})
18
- }
19
- })
20
-
21
- return {
22
- tool: {
23
- ...backgroundProcessTools,
24
- },
25
- async event(input: { event: any }) {
26
- const opencodeEvent = input?.event
27
- if (!opencodeEvent || typeof opencodeEvent !== "object") return
28
-
29
- if (opencodeEvent.type === "session.idle") {
30
- const sessionID = (opencodeEvent as any).properties?.sessionID
31
- void client.postEvent({
32
- type: "opencode.session.idle",
33
- properties: {
34
- sessionID,
35
- },
36
- }).catch(() => {})
37
- }
38
- },
39
- }
40
- }
@@ -1,160 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin/tool"
2
-
3
- type BackgroundProcess = {
4
- id: string
5
- title: string
6
- command: string
7
- status: "running" | "stopped" | "error"
8
- startedAt: string
9
- stoppedAt?: string
10
- exitCode?: number
11
- outputSizeBytes?: number
12
- }
13
-
14
- type CodeNomadConfig = {
15
- instanceId: string
16
- baseUrl: string
17
- }
18
-
19
- export function createBackgroundProcessTools(config: CodeNomadConfig) {
20
- const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
21
-
22
- const base = config.baseUrl.replace(/\/+$/, "")
23
- const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
24
- const headers = normalizeHeaders(init?.headers)
25
- if (init?.body !== undefined) {
26
- headers["Content-Type"] = "application/json"
27
- }
28
-
29
- const response = await fetch(url, {
30
- ...init,
31
- headers,
32
- })
33
-
34
- if (!response.ok) {
35
- const message = await response.text()
36
- throw new Error(message || `Request failed with ${response.status}`)
37
- }
38
-
39
- if (response.status === 204) {
40
- return undefined as T
41
- }
42
-
43
- return (await response.json()) as T
44
- }
45
-
46
- return {
47
- run_background_process: tool({
48
- description:
49
- "Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
50
- args: {
51
- title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
52
- command: tool.schema.string().describe("Shell command to run in the workspace"),
53
- },
54
- async execute(args) {
55
- const process = await request<BackgroundProcess>("", {
56
- method: "POST",
57
- body: JSON.stringify({ title: args.title, command: args.command }),
58
- })
59
-
60
- return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
61
- },
62
- }),
63
- list_background_processes: tool({
64
- description: "List background processes running for this workspace.",
65
- args: {},
66
- async execute() {
67
- const response = await request<{ processes: BackgroundProcess[] }>("")
68
- if (response.processes.length === 0) {
69
- return "No background processes running."
70
- }
71
-
72
- return response.processes
73
- .map((process) => {
74
- const status = process.status === "running" ? "running" : process.status
75
- const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
76
- const size =
77
- typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
78
- return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
79
- })
80
- .join("\n")
81
- },
82
- }),
83
- read_background_process_output: tool({
84
- description: "Read output from a background process. Use full, grep, head, or tail.",
85
- args: {
86
- id: tool.schema.string().describe("Background process ID"),
87
- method: tool.schema
88
- .enum(["full", "grep", "head", "tail"])
89
- .default("full")
90
- .describe("Method to read output"),
91
- pattern: tool.schema.string().optional().describe("Pattern for grep method"),
92
- lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
93
- },
94
- async execute(args) {
95
- if (args.method === "grep" && !args.pattern) {
96
- return "Pattern is required for grep method."
97
- }
98
-
99
- const params = new URLSearchParams({ method: args.method })
100
- if (args.pattern) {
101
- params.set("pattern", args.pattern)
102
- }
103
- if (args.lines) {
104
- params.set("lines", String(args.lines))
105
- }
106
-
107
- const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
108
- `/${args.id}/output?${params.toString()}`,
109
- )
110
-
111
- const header = response.truncated
112
- ? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
113
- : `Output (${Math.round(response.sizeBytes / 1024)}KB):`
114
-
115
- return `${header}\n\n${response.content}`
116
- },
117
- }),
118
- stop_background_process: tool({
119
- description: "Stop a background process (SIGTERM) but keep its output and entry.",
120
- args: {
121
- id: tool.schema.string().describe("Background process ID"),
122
- },
123
- async execute(args) {
124
- const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
125
- return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
126
- },
127
- }),
128
- terminate_background_process: tool({
129
- description: "Terminate a background process and delete its output + entry.",
130
- args: {
131
- id: tool.schema.string().describe("Background process ID"),
132
- },
133
- async execute(args) {
134
- await request<void>(`/${args.id}/terminate`, { method: "POST" })
135
- return `Terminated background process ${args.id} and removed its output.`
136
- },
137
- }),
138
- }
139
- }
140
-
141
- function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
142
- const output: Record<string, string> = {}
143
- if (!headers) return output
144
-
145
- if (headers instanceof Headers) {
146
- headers.forEach((value, key) => {
147
- output[key] = value
148
- })
149
- return output
150
- }
151
-
152
- if (Array.isArray(headers)) {
153
- for (const [key, value] of headers) {
154
- output[key] = value
155
- }
156
- return output
157
- }
158
-
159
- return { ...headers }
160
- }
@@ -1,165 +0,0 @@
1
- export type PluginEvent = {
2
- type: string
3
- properties?: Record<string, unknown>
4
- }
5
-
6
- export type CodeNomadConfig = {
7
- instanceId: string
8
- baseUrl: string
9
- }
10
-
11
- export function getCodeNomadConfig(): CodeNomadConfig {
12
- return {
13
- instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
14
- baseUrl: requireEnv("CODENOMAD_BASE_URL"),
15
- }
16
- }
17
-
18
- export function createCodeNomadClient(config: CodeNomadConfig) {
19
- return {
20
- postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
21
- startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
22
- }
23
- }
24
-
25
- function requireEnv(key: string): string {
26
- const value = process.env[key]
27
- if (!value || !value.trim()) {
28
- throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
29
- }
30
- return value
31
- }
32
-
33
- function delay(ms: number) {
34
- return new Promise<void>((resolve) => setTimeout(resolve, ms))
35
- }
36
-
37
- async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
38
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
39
- const response = await fetch(url, {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- },
44
- body: JSON.stringify(event),
45
- })
46
-
47
- if (!response.ok) {
48
- throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
49
- }
50
- }
51
-
52
- async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
53
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
54
-
55
- // Fail plugin startup if we cannot establish the initial connection.
56
- const initialBody = await connectWithRetries(url, 3)
57
-
58
- // After startup, keep reconnecting; throw after 3 consecutive failures.
59
- void consumeWithReconnect(url, onEvent, initialBody)
60
- }
61
-
62
- async function connectWithRetries(url: string, maxAttempts: number) {
63
- let lastError: unknown
64
-
65
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
66
- try {
67
- const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
68
- if (!response.ok || !response.body) {
69
- throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
70
- }
71
- return response.body
72
- } catch (error) {
73
- lastError = error
74
- await delay(500 * attempt)
75
- }
76
- }
77
-
78
- const reason = lastError instanceof Error ? lastError.message : String(lastError)
79
- throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
80
- }
81
-
82
- async function consumeWithReconnect(
83
- url: string,
84
- onEvent: (event: PluginEvent) => void,
85
- initialBody: ReadableStream<Uint8Array>,
86
- ) {
87
- let consecutiveFailures = 0
88
- let body: ReadableStream<Uint8Array> | null = initialBody
89
-
90
- while (true) {
91
- try {
92
- if (!body) {
93
- body = await connectWithRetries(url, 3)
94
- }
95
-
96
- await consumeSseBody(body, onEvent)
97
- body = null
98
- consecutiveFailures = 0
99
- } catch (error) {
100
- body = null
101
- consecutiveFailures += 1
102
- if (consecutiveFailures >= 3) {
103
- const reason = error instanceof Error ? error.message : String(error)
104
- throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
105
- }
106
- await delay(500 * consecutiveFailures)
107
- }
108
- }
109
- }
110
-
111
- async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
112
- const reader = body.getReader()
113
- const decoder = new TextDecoder()
114
- let buffer = ""
115
-
116
- while (true) {
117
- const { done, value } = await reader.read()
118
- if (done || !value) {
119
- break
120
- }
121
-
122
- buffer += decoder.decode(value, { stream: true })
123
-
124
- let separatorIndex = buffer.indexOf("\n\n")
125
- while (separatorIndex >= 0) {
126
- const chunk = buffer.slice(0, separatorIndex)
127
- buffer = buffer.slice(separatorIndex + 2)
128
- separatorIndex = buffer.indexOf("\n\n")
129
-
130
- const event = parseSseChunk(chunk)
131
- if (event) {
132
- onEvent(event)
133
- }
134
- }
135
- }
136
-
137
- throw new Error("SSE stream ended")
138
- }
139
-
140
- function parseSseChunk(chunk: string): PluginEvent | null {
141
- const lines = chunk.split(/\r?\n/)
142
- const dataLines: string[] = []
143
-
144
- for (const line of lines) {
145
- if (line.startsWith(":")) continue
146
- if (line.startsWith("data:")) {
147
- dataLines.push(line.slice(5).trimStart())
148
- }
149
- }
150
-
151
- if (dataLines.length === 0) return null
152
-
153
- const payload = dataLines.join("\n").trim()
154
- if (!payload) return null
155
-
156
- try {
157
- const parsed = JSON.parse(payload)
158
- if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
159
- return null
160
- }
161
- return parsed as PluginEvent
162
- } catch {
163
- return null
164
- }
165
- }