@devlln/helm
Advanced tools
| import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import path from "node:path"; | ||
| import type { CodexAutomationSummary, CreateCodexAutomationRequest } from "./types.js"; | ||
| type TomlValue = string | number | string[]; | ||
| const DAY_NAMES: Record<string, string> = { | ||
| MO: "Monday", | ||
| TU: "Tuesday", | ||
| WE: "Wednesday", | ||
| TH: "Thursday", | ||
| FR: "Friday", | ||
| SA: "Saturday", | ||
| SU: "Sunday", | ||
| }; | ||
| function codexAutomationsDir(): string { | ||
| return process.env.CODEX_AUTOMATIONS_DIR?.trim() | ||
| || path.join(homedir(), ".codex", "automations"); | ||
| } | ||
| function parseTomlString(raw: string): string | null { | ||
| const trimmed = raw.trim(); | ||
| if (!trimmed.startsWith("\"") || !trimmed.endsWith("\"")) { | ||
| return null; | ||
| } | ||
| let result = ""; | ||
| for (let index = 1; index < trimmed.length - 1; index += 1) { | ||
| const char = trimmed[index]; | ||
| if (char !== "\\") { | ||
| result += char; | ||
| continue; | ||
| } | ||
| index += 1; | ||
| const escaped = trimmed[index]; | ||
| switch (escaped) { | ||
| case "b": | ||
| result += "\b"; | ||
| break; | ||
| case "t": | ||
| result += "\t"; | ||
| break; | ||
| case "n": | ||
| result += "\n"; | ||
| break; | ||
| case "f": | ||
| result += "\f"; | ||
| break; | ||
| case "r": | ||
| result += "\r"; | ||
| break; | ||
| case "\"": | ||
| case "\\": | ||
| result += escaped; | ||
| break; | ||
| default: | ||
| result += escaped ?? ""; | ||
| break; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function splitTomlArrayItems(raw: string): string[] { | ||
| const trimmed = raw.trim(); | ||
| if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { | ||
| return []; | ||
| } | ||
| const body = trimmed.slice(1, -1); | ||
| const items: string[] = []; | ||
| let current = ""; | ||
| let quoted = false; | ||
| let escaping = false; | ||
| for (const char of body) { | ||
| if (escaping) { | ||
| current += `\\${char}`; | ||
| escaping = false; | ||
| continue; | ||
| } | ||
| if (char === "\\" && quoted) { | ||
| escaping = true; | ||
| continue; | ||
| } | ||
| if (char === "\"") { | ||
| quoted = !quoted; | ||
| current += char; | ||
| continue; | ||
| } | ||
| if (char === "," && !quoted) { | ||
| items.push(current.trim()); | ||
| current = ""; | ||
| continue; | ||
| } | ||
| current += char; | ||
| } | ||
| if (current.trim()) { | ||
| items.push(current.trim()); | ||
| } | ||
| return items; | ||
| } | ||
| function parseTomlStringArray(raw: string): string[] | null { | ||
| const values = splitTomlArrayItems(raw) | ||
| .map(parseTomlString) | ||
| .filter((value): value is string => value !== null); | ||
| return values.length > 0 ? values : []; | ||
| } | ||
| export function parseCodexAutomationToml(text: string, sourcePath = ""): CodexAutomationSummary | null { | ||
| const values = new Map<string, TomlValue>(); | ||
| for (const rawLine of text.split(/\r?\n/)) { | ||
| const line = rawLine.trim(); | ||
| if (!line || line.startsWith("#")) { | ||
| continue; | ||
| } | ||
| const separatorIndex = line.indexOf("="); | ||
| if (separatorIndex <= 0) { | ||
| continue; | ||
| } | ||
| const key = line.slice(0, separatorIndex).trim(); | ||
| const rawValue = line.slice(separatorIndex + 1).trim(); | ||
| const stringValue = parseTomlString(rawValue); | ||
| if (stringValue !== null) { | ||
| values.set(key, stringValue); | ||
| continue; | ||
| } | ||
| if (rawValue.startsWith("[")) { | ||
| values.set(key, parseTomlStringArray(rawValue) ?? []); | ||
| continue; | ||
| } | ||
| const numberValue = Number(rawValue); | ||
| if (Number.isFinite(numberValue)) { | ||
| values.set(key, numberValue); | ||
| } | ||
| } | ||
| const id = stringValue(values.get("id")); | ||
| const name = stringValue(values.get("name")) ?? id; | ||
| if (!id || !name) { | ||
| return null; | ||
| } | ||
| const prompt = stringValue(values.get("prompt")) ?? ""; | ||
| const cwds = stringArrayValue(values.get("cwds")); | ||
| const rrule = stringValue(values.get("rrule")); | ||
| const kind = stringValue(values.get("kind")) ?? "manual"; | ||
| return { | ||
| id, | ||
| name, | ||
| kind, | ||
| status: stringValue(values.get("status")) ?? "UNKNOWN", | ||
| schedule: rrule, | ||
| scheduleSummary: automationScheduleSummary(kind, rrule), | ||
| model: stringValue(values.get("model")), | ||
| reasoningEffort: stringValue(values.get("reasoning_effort")), | ||
| executionEnvironment: stringValue(values.get("execution_environment")), | ||
| cwds, | ||
| cwd: cwds[0] ?? null, | ||
| prompt, | ||
| promptPreview: promptPreview(prompt), | ||
| createdAt: numberValue(values.get("created_at")), | ||
| updatedAt: numberValue(values.get("updated_at")), | ||
| sourcePath, | ||
| }; | ||
| } | ||
| export async function listCodexAutomations(): Promise<CodexAutomationSummary[]> { | ||
| const root = codexAutomationsDir(); | ||
| let entries: Array<{ isDirectory(): boolean; name: string }>; | ||
| try { | ||
| entries = (await readdir(root, { withFileTypes: true })).map((entry) => ({ | ||
| isDirectory: () => entry.isDirectory(), | ||
| name: String(entry.name), | ||
| })); | ||
| } catch { | ||
| return []; | ||
| } | ||
| const automations: CodexAutomationSummary[] = []; | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) { | ||
| continue; | ||
| } | ||
| const sourcePath = path.join(root, entry.name, "automation.toml"); | ||
| try { | ||
| const automation = parseCodexAutomationToml(await readFile(sourcePath, "utf8"), sourcePath); | ||
| if (automation) { | ||
| automations.push(automation); | ||
| } | ||
| } catch { | ||
| // A partially-written automation should not hide the rest of the list. | ||
| } | ||
| } | ||
| return automations.sort(automationPrecedes); | ||
| } | ||
| export async function createCodexAutomation(input: CreateCodexAutomationRequest): Promise<CodexAutomationSummary> { | ||
| const name = input.name.trim(); | ||
| const prompt = input.prompt.trim(); | ||
| const rrule = input.rrule.trim(); | ||
| if (!name) { | ||
| throw new Error("Automation name is required"); | ||
| } | ||
| if (!prompt) { | ||
| throw new Error("Automation prompt is required"); | ||
| } | ||
| if (!rrule) { | ||
| throw new Error("Automation schedule is required"); | ||
| } | ||
| const root = codexAutomationsDir(); | ||
| await mkdir(root, { recursive: true }); | ||
| const id = await uniqueAutomationId(root, slugify(name)); | ||
| const automationDir = path.join(root, id); | ||
| await mkdir(automationDir, { recursive: false }); | ||
| const now = Date.now(); | ||
| const status = normalizedStatus(input.status); | ||
| const content = [ | ||
| "version = 1", | ||
| `id = ${tomlString(id)}`, | ||
| `kind = "cron"`, | ||
| `name = ${tomlString(name)}`, | ||
| `prompt = ${tomlString(prompt)}`, | ||
| `status = ${tomlString(status)}`, | ||
| `rrule = ${tomlString(rrule)}`, | ||
| optionalTomlString("model", input.model), | ||
| optionalTomlString("reasoning_effort", input.reasoningEffort), | ||
| optionalTomlString("execution_environment", input.executionEnvironment), | ||
| `cwds = ${tomlStringArray(input.cwd?.trim() ? [input.cwd.trim()] : [])}`, | ||
| `created_at = ${now}`, | ||
| `updated_at = ${now}`, | ||
| "", | ||
| ].filter((line): line is string => line !== null).join("\n"); | ||
| const sourcePath = path.join(automationDir, "automation.toml"); | ||
| await writeFile(sourcePath, content, { encoding: "utf8", flag: "wx" }); | ||
| const automation = parseCodexAutomationToml(content, sourcePath); | ||
| if (!automation) { | ||
| throw new Error("Created automation could not be parsed"); | ||
| } | ||
| return automation; | ||
| } | ||
| function automationPrecedes(lhs: CodexAutomationSummary, rhs: CodexAutomationSummary): number { | ||
| const lhsActive = lhs.status.toUpperCase() === "ACTIVE"; | ||
| const rhsActive = rhs.status.toUpperCase() === "ACTIVE"; | ||
| if (lhsActive !== rhsActive) { | ||
| return lhsActive ? -1 : 1; | ||
| } | ||
| const lhsUpdatedAt = lhs.updatedAt ?? 0; | ||
| const rhsUpdatedAt = rhs.updatedAt ?? 0; | ||
| if (lhsUpdatedAt !== rhsUpdatedAt) { | ||
| return rhsUpdatedAt - lhsUpdatedAt; | ||
| } | ||
| return lhs.name.localeCompare(rhs.name, undefined, { sensitivity: "base" }); | ||
| } | ||
| function automationScheduleSummary(kind: string, rrule: string | null): string { | ||
| if (!rrule) { | ||
| return kind === "cron" ? "Scheduled" : kind; | ||
| } | ||
| const parsed = Object.fromEntries( | ||
| rrule | ||
| .replace(/^RRULE:/i, "") | ||
| .split(";") | ||
| .map((part) => { | ||
| const [key, value] = part.split("="); | ||
| return [key, value]; | ||
| }) | ||
| .filter(([key, value]) => key && value) | ||
| ); | ||
| const frequency = parsed.FREQ; | ||
| const interval = Number(parsed.INTERVAL ?? "1"); | ||
| const time = formattedTime(parsed.BYHOUR, parsed.BYMINUTE); | ||
| if (frequency === "HOURLY") { | ||
| const base = interval > 1 ? `Every ${interval} hours` : "Hourly"; | ||
| return parsed.BYMINUTE !== undefined | ||
| ? `${base} at minute ${String(parsed.BYMINUTE).padStart(2, "0")}` | ||
| : base; | ||
| } | ||
| if (frequency === "DAILY") { | ||
| return time ? `Daily at ${time}` : "Daily"; | ||
| } | ||
| if (frequency === "WEEKLY") { | ||
| const days = daySummary(parsed.BYDAY); | ||
| if (days && time) { | ||
| return `Weekly ${days} at ${time}`; | ||
| } | ||
| if (days) { | ||
| return `Weekly ${days}`; | ||
| } | ||
| return time ? `Weekly at ${time}` : "Weekly"; | ||
| } | ||
| return rrule.replace(/^RRULE:/i, ""); | ||
| } | ||
| function formattedTime(hour: string | undefined, minute: string | undefined): string | null { | ||
| if (hour === undefined) { | ||
| return null; | ||
| } | ||
| const hourValue = Number(hour); | ||
| const minuteValue = Number(minute ?? "0"); | ||
| if (!Number.isFinite(hourValue) || !Number.isFinite(minuteValue)) { | ||
| return null; | ||
| } | ||
| const period = hourValue >= 12 ? "PM" : "AM"; | ||
| const displayHour = hourValue % 12 === 0 ? 12 : hourValue % 12; | ||
| return `${displayHour}:${String(minuteValue).padStart(2, "0")} ${period}`; | ||
| } | ||
| function daySummary(value: string | undefined): string | null { | ||
| if (!value) { | ||
| return null; | ||
| } | ||
| const names = value | ||
| .split(",") | ||
| .map((day) => DAY_NAMES[day]) | ||
| .filter(Boolean); | ||
| if (names.length === 7) { | ||
| return "every day"; | ||
| } | ||
| if (names.length === 1) { | ||
| return names[0] ?? null; | ||
| } | ||
| return names.join(", "); | ||
| } | ||
| function promptPreview(prompt: string): string { | ||
| const compact = prompt.replace(/\s+/g, " ").trim(); | ||
| if (compact.length <= 180) { | ||
| return compact; | ||
| } | ||
| return `${compact.slice(0, 177).trimEnd()}...`; | ||
| } | ||
| async function uniqueAutomationId(root: string, base: string): Promise<string> { | ||
| const cleanBase = base || "automation"; | ||
| for (let suffix = 0; suffix < 100; suffix += 1) { | ||
| const id = suffix === 0 ? cleanBase : `${cleanBase}-${suffix + 1}`; | ||
| try { | ||
| await readFile(path.join(root, id, "automation.toml"), "utf8"); | ||
| } catch { | ||
| return id; | ||
| } | ||
| } | ||
| return `${cleanBase}-${Date.now()}`; | ||
| } | ||
| function slugify(value: string): string { | ||
| return value | ||
| .trim() | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "-") | ||
| .replace(/^-+|-+$/g, "") | ||
| .slice(0, 64); | ||
| } | ||
| function normalizedStatus(value: string | null | undefined): string { | ||
| const trimmed = value?.trim().toUpperCase(); | ||
| return trimmed === "PAUSED" ? "PAUSED" : "ACTIVE"; | ||
| } | ||
| function optionalTomlString(key: string, value: string | null | undefined): string | null { | ||
| const trimmed = value?.trim(); | ||
| return trimmed ? `${key} = ${tomlString(trimmed)}` : null; | ||
| } | ||
| function tomlString(value: string): string { | ||
| return `"${value | ||
| .replace(/\\/g, "\\\\") | ||
| .replace(/"/g, "\\\"") | ||
| .replace(/\n/g, "\\n") | ||
| .replace(/\r/g, "\\r") | ||
| .replace(/\t/g, "\\t")}"`; | ||
| } | ||
| function tomlStringArray(values: string[]): string { | ||
| return `[${values.map(tomlString).join(", ")}]`; | ||
| } | ||
| function stringValue(value: TomlValue | undefined): string | null { | ||
| return typeof value === "string" ? value : null; | ||
| } | ||
| function stringArrayValue(value: TomlValue | undefined): string[] { | ||
| return Array.isArray(value) ? value : []; | ||
| } | ||
| function numberValue(value: TomlValue | undefined): number | null { | ||
| return typeof value === "number" ? value : null; | ||
| } |
| import { existsSync, readFileSync, statSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import path from "node:path"; | ||
| type CodexGlobalState = { | ||
| "electron-workspace-root-labels"?: Record<string, unknown>; | ||
| }; | ||
| let cachedLabels: Map<string, string> | null = null; | ||
| let cachedMtimeMS: number | null = null; | ||
| let cachedPath: string | null = null; | ||
| function codexGlobalStatePath(): string { | ||
| return process.env.CODEX_GLOBAL_STATE_PATH?.trim() | ||
| || path.join(homedir(), ".codex", ".codex-global-state.json"); | ||
| } | ||
| function normalizePath(value: string | null | undefined): string { | ||
| return path.resolve(value?.trim() || "/"); | ||
| } | ||
| function readCodexWorkspaceRootLabels(): Map<string, string> { | ||
| const filePath = codexGlobalStatePath(); | ||
| if (!existsSync(filePath)) { | ||
| cachedLabels = new Map(); | ||
| cachedMtimeMS = null; | ||
| cachedPath = filePath; | ||
| return cachedLabels; | ||
| } | ||
| try { | ||
| const stat = statSync(filePath); | ||
| if (cachedLabels && cachedMtimeMS === stat.mtimeMs && cachedPath === filePath) { | ||
| return cachedLabels; | ||
| } | ||
| const parsed = JSON.parse(readFileSync(filePath, "utf8")) as CodexGlobalState; | ||
| const rawLabels = parsed["electron-workspace-root-labels"] ?? {}; | ||
| const labels = new Map<string, string>(); | ||
| for (const [root, label] of Object.entries(rawLabels)) { | ||
| if (typeof label !== "string") { | ||
| continue; | ||
| } | ||
| const normalizedLabel = label.trim(); | ||
| if (!normalizedLabel) { | ||
| continue; | ||
| } | ||
| labels.set(normalizePath(root), normalizedLabel); | ||
| } | ||
| cachedLabels = labels; | ||
| cachedMtimeMS = stat.mtimeMs; | ||
| cachedPath = filePath; | ||
| return labels; | ||
| } catch { | ||
| cachedLabels = new Map(); | ||
| cachedMtimeMS = null; | ||
| cachedPath = filePath; | ||
| return cachedLabels; | ||
| } | ||
| } | ||
| export function codexProjectNameForPath(value: string | null | undefined): string | null { | ||
| const normalizedPath = normalizePath(value); | ||
| return readCodexWorkspaceRootLabels().get(normalizedPath) ?? null; | ||
| } |
| { | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "lockfileVersion": 3, | ||
@@ -9,3 +9,3 @@ "requires": true, | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "dependencies": { | ||
@@ -12,0 +12,0 @@ "dotenv": "^16.6.1", |
| { | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "private": true, | ||
@@ -5,0 +5,0 @@ "type": "module", |
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { BridgeServer } from "./bridgeServer.js"; | ||
| import { BridgeServer, readGitBranchStatus } from "./bridgeServer.js"; | ||
| import { createCodexAutomation, listCodexAutomations, parseCodexAutomationToml } from "./codexAutomations.js"; | ||
| import type { | ||
@@ -179,2 +184,31 @@ BackendSummary, | ||
| test("git branch status reports local branches for toolbar branch switching", () => { | ||
| const repo = mkdtempSync(path.join(tmpdir(), "helm-git-branches-")); | ||
| execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); | ||
| execFileSync("git", ["config", "user.email", "helm@example.com"], { cwd: repo, stdio: "ignore" }); | ||
| execFileSync("git", ["config", "user.name", "Helm Test"], { cwd: repo, stdio: "ignore" }); | ||
| writeFileSync(path.join(repo, "README.md"), "helm\n"); | ||
| execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" }); | ||
| execFileSync("git", ["commit", "-m", "init"], { cwd: repo, stdio: "ignore" }); | ||
| execFileSync("git", ["branch", "feature/toolbar"], { cwd: repo, stdio: "ignore" }); | ||
| assert.deepEqual(readGitBranchStatus(repo), { | ||
| cwd: repo, | ||
| isRepository: true, | ||
| currentBranch: "main", | ||
| branches: ["feature/toolbar", "main"], | ||
| }); | ||
| }); | ||
| test("git branch status treats non-repositories as unavailable", () => { | ||
| const directory = mkdtempSync(path.join(tmpdir(), "helm-no-git-")); | ||
| assert.deepEqual(readGitBranchStatus(directory), { | ||
| cwd: directory, | ||
| isRepository: false, | ||
| currentBranch: null, | ||
| branches: [], | ||
| }); | ||
| }); | ||
| test("running thread summaries synthesize a waiting preview when detail is blank", () => { | ||
@@ -258,2 +292,97 @@ const server = new BridgeServer() as unknown as BridgeServerInternals; | ||
| test("Codex automation parser extracts schedule and execution metadata", () => { | ||
| const automation = parseCodexAutomationToml(` | ||
| version = 1 | ||
| id = "performance-audit" | ||
| kind = "cron" | ||
| name = "Performance audit" | ||
| prompt = "Audit performance regressions.\\nReport measurements." | ||
| status = "ACTIVE" | ||
| rrule = "RRULE:FREQ=WEEKLY;BYHOUR=8;BYMINUTE=0;BYDAY=MO" | ||
| model = "gpt-5.4" | ||
| reasoning_effort = "medium" | ||
| execution_environment = "worktree" | ||
| cwds = ["/Users/devlin/GitHub/prediction-markets-bot"] | ||
| created_at = 1776825539662 | ||
| updated_at = 1776825539662 | ||
| `, "/tmp/automation.toml"); | ||
| assert.equal(automation?.id, "performance-audit"); | ||
| assert.equal(automation?.name, "Performance audit"); | ||
| assert.equal(automation?.status, "ACTIVE"); | ||
| assert.equal(automation?.scheduleSummary, "Weekly Monday at 8:00 AM"); | ||
| assert.equal(automation?.cwd, "/Users/devlin/GitHub/prediction-markets-bot"); | ||
| assert.equal(automation?.prompt, "Audit performance regressions.\nReport measurements."); | ||
| }); | ||
| test("Codex automation list reads automation directories and sorts active entries first", async () => { | ||
| const root = mkdtempSync(path.join(tmpdir(), "helm-automations-")); | ||
| mkdirSync(path.join(root, "paused-task")); | ||
| mkdirSync(path.join(root, "active-task")); | ||
| writeFileSync(path.join(root, "paused-task", "automation.toml"), ` | ||
| id = "paused-task" | ||
| kind = "cron" | ||
| name = "Paused task" | ||
| prompt = "Paused" | ||
| status = "PAUSED" | ||
| updated_at = 200 | ||
| `); | ||
| writeFileSync(path.join(root, "active-task", "automation.toml"), ` | ||
| id = "active-task" | ||
| kind = "cron" | ||
| name = "Active task" | ||
| prompt = "Active" | ||
| status = "ACTIVE" | ||
| updated_at = 100 | ||
| `); | ||
| const previous = process.env.CODEX_AUTOMATIONS_DIR; | ||
| process.env.CODEX_AUTOMATIONS_DIR = root; | ||
| try { | ||
| assert.deepEqual( | ||
| (await listCodexAutomations()).map((automation) => automation.id), | ||
| ["active-task", "paused-task"] | ||
| ); | ||
| } finally { | ||
| if (previous === undefined) { | ||
| delete process.env.CODEX_AUTOMATIONS_DIR; | ||
| } else { | ||
| process.env.CODEX_AUTOMATIONS_DIR = previous; | ||
| } | ||
| } | ||
| }); | ||
| test("Codex automation creation writes parseable automation files", async () => { | ||
| const root = mkdtempSync(path.join(tmpdir(), "helm-create-automation-")); | ||
| const previous = process.env.CODEX_AUTOMATIONS_DIR; | ||
| process.env.CODEX_AUTOMATIONS_DIR = root; | ||
| try { | ||
| const automation = await createCodexAutomation({ | ||
| name: "Daily mobile audit", | ||
| prompt: "Check the mobile app.", | ||
| rrule: "RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0", | ||
| model: "gpt-5.4", | ||
| reasoningEffort: "medium", | ||
| executionEnvironment: "local", | ||
| cwd: "/Users/devlin/GitHub/helm-dev", | ||
| status: "ACTIVE", | ||
| }); | ||
| assert.equal(automation.id, "daily-mobile-audit"); | ||
| assert.equal(automation.name, "Daily mobile audit"); | ||
| assert.equal(automation.scheduleSummary, "Daily at 9:00 AM"); | ||
| assert.equal(automation.cwd, "/Users/devlin/GitHub/helm-dev"); | ||
| assert.deepEqual( | ||
| (await listCodexAutomations()).map((entry) => entry.id), | ||
| ["daily-mobile-audit"] | ||
| ); | ||
| } finally { | ||
| if (previous === undefined) { | ||
| delete process.env.CODEX_AUTOMATIONS_DIR; | ||
| } else { | ||
| process.env.CODEX_AUTOMATIONS_DIR = previous; | ||
| } | ||
| } | ||
| }); | ||
| test("idle thread preview merge prefers fresh detail over stale fallback text", () => { | ||
@@ -1803,2 +1932,57 @@ const server = new BridgeServer() as unknown as BridgeServerInternals; | ||
| test("oversized turns preserve the latest plan item for pinned task lists", () => { | ||
| const items: ThreadDetailItem[] = [ | ||
| threadItem({ | ||
| id: "user-1", | ||
| type: "userMessage", | ||
| title: "User message", | ||
| rawText: "start", | ||
| detail: "start", | ||
| }), | ||
| threadItem({ | ||
| id: "plan-1", | ||
| type: "plan", | ||
| title: "2 out of 5 tasks completed", | ||
| rawText: [ | ||
| "2 out of 5 tasks completed", | ||
| "✓ Rank replay blockers", | ||
| "✓ Inspect diagnostics", | ||
| "◉ Patch passive completion hold gate", | ||
| "□ Run focused replay/tests", | ||
| "□ Commit and push", | ||
| ].join("\n"), | ||
| detail: [ | ||
| "2 out of 5 tasks completed", | ||
| "✓ Rank replay blockers", | ||
| "✓ Inspect diagnostics", | ||
| "◉ Patch passive completion hold gate", | ||
| "□ Run focused replay/tests", | ||
| "□ Commit and push", | ||
| ].join("\n"), | ||
| }), | ||
| ]; | ||
| for (let index = 0; index < 30; index += 1) { | ||
| items.push(threadItem({ | ||
| id: `command-${index}`, | ||
| type: "commandExecution", | ||
| title: "Terminal", | ||
| rawText: `command output ${index}`, | ||
| detail: `command output ${index}`, | ||
| })); | ||
| } | ||
| const compacted = compactTurn({ | ||
| id: "turn-1", | ||
| status: "running", | ||
| error: null, | ||
| items, | ||
| }); | ||
| assert.equal(compacted.items[0]?.id, "user-1"); | ||
| assert.equal(compacted.items.some((item) => item.id === "plan-1"), true); | ||
| assert.equal(compacted.items.at(-1)?.id, "command-29"); | ||
| assert.ok(compacted.items.length <= 24); | ||
| }); | ||
| test("live runtime tail is appended as the newest turn without lowering detail timestamp", () => { | ||
@@ -1805,0 +1989,0 @@ const detail: ThreadDetail = { |
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| import net from "node:net"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { CodexAppServerClient, currentPromptDraftFromTerminalTail } from "./codexAppServerClient.js"; | ||
| import { codexProjectNameForPath } from "./codexProjectNames.js"; | ||
| import type { JSONValue } from "./types.js"; | ||
| type TestStartTurnOptions = { | ||
| deliveryMode?: "queue" | "steer" | "interrupt"; | ||
| imageAttachments?: Array<{ path: string; filename?: string; mimeType?: string }>; | ||
| fileAttachments?: Array<{ path: string; filename?: string; mimeType?: string }>; | ||
| }; | ||
| type TestQueuedFollowUp = { | ||
| id: string; | ||
| text: string; | ||
| context: { | ||
| prompt: string; | ||
| addedFiles: JSONValue[]; | ||
| fileAttachments: JSONValue[]; | ||
| commentAttachments: JSONValue[]; | ||
| ideContext: JSONValue | null; | ||
| imageAttachments: JSONValue[]; | ||
| workspaceRoots: string[]; | ||
| collaborationMode: JSONValue | null; | ||
| }; | ||
| cwd: string | null; | ||
| createdAt: number; | ||
| }; | ||
| type CodexClientPrivateHooks = { | ||
@@ -38,2 +66,3 @@ localThreadReadFallback(threadId: string, includeTurns: boolean): Promise<JSONValue | undefined>; | ||
| sourceKind?: string | null; | ||
| projectName?: string | null; | ||
| updatedAt?: number; | ||
@@ -75,4 +104,143 @@ name: string | null; | ||
| shouldPreferShellRelayFirst(threadId: string): Promise<boolean>; | ||
| setModelAndReasoningViaCodexDesktopIpc( | ||
| threadId: string, | ||
| model: string, | ||
| reasoningEffort: string | null | ||
| ): Promise<JSONValue | undefined>; | ||
| enqueueTurnViaCodexDesktopIpc( | ||
| threadId: string, | ||
| text: string, | ||
| options: TestStartTurnOptions, | ||
| thread: { cwd: string; sourceKind?: string | null } | ||
| ): Promise<JSONValue | undefined>; | ||
| startTurnViaCodexDesktopIpc( | ||
| threadId: string, | ||
| text: string, | ||
| options: TestStartTurnOptions, | ||
| baseline: unknown | ||
| ): Promise<JSONValue | undefined>; | ||
| codexDesktopQueuedFollowUpsWithAppendedMessage( | ||
| currentMessages: TestQueuedFollowUp[], | ||
| message: TestQueuedFollowUp | ||
| ): TestQueuedFollowUp[]; | ||
| }; | ||
| function encodeRawServerTextFrame(text: string): Buffer { | ||
| const payload = Buffer.from(text, "utf8"); | ||
| if (payload.length < 126) { | ||
| return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); | ||
| } | ||
| if (payload.length < 65_536) { | ||
| const header = Buffer.alloc(4); | ||
| header[0] = 0x81; | ||
| header[1] = 126; | ||
| header.writeUInt16BE(payload.length, 2); | ||
| return Buffer.concat([header, payload]); | ||
| } | ||
| const header = Buffer.alloc(10); | ||
| header[0] = 0x81; | ||
| header[1] = 127; | ||
| header.writeBigUInt64BE(BigInt(payload.length), 2); | ||
| return Buffer.concat([header, payload]); | ||
| } | ||
| function decodeRawClientTextFrame(buffer: Buffer): { text: string; consumed: number } | null { | ||
| if (buffer.length < 2) { | ||
| return null; | ||
| } | ||
| const firstByte = buffer[0]!; | ||
| const secondByte = buffer[1]!; | ||
| const opcode = firstByte & 0x0f; | ||
| let length = secondByte & 0x7f; | ||
| let offset = 2; | ||
| if (length === 126) { | ||
| if (buffer.length < offset + 2) { | ||
| return null; | ||
| } | ||
| length = buffer.readUInt16BE(offset); | ||
| offset += 2; | ||
| } else if (length === 127) { | ||
| if (buffer.length < offset + 8) { | ||
| return null; | ||
| } | ||
| length = Number(buffer.readBigUInt64BE(offset)); | ||
| offset += 8; | ||
| } | ||
| const masked = (secondByte & 0x80) !== 0; | ||
| if (!masked || opcode !== 1) { | ||
| throw new Error("expected masked text frame"); | ||
| } | ||
| if (buffer.length < offset + 4 + length) { | ||
| return null; | ||
| } | ||
| const mask = buffer.subarray(offset, offset + 4); | ||
| offset += 4; | ||
| const payload = Buffer.from(buffer.subarray(offset, offset + length)); | ||
| for (let index = 0; index < payload.length; index += 1) { | ||
| payload[index] = payload[index]! ^ mask[index % 4]!; | ||
| } | ||
| return { text: payload.toString("utf8"), consumed: offset + length }; | ||
| } | ||
| test("Codex app-server client initializes over raw unix socket transport", async (t) => { | ||
| if (process.platform === "win32") { | ||
| return; | ||
| } | ||
| const directory = mkdtempSync(path.join(tmpdir(), "codex-app-server-unix-")); | ||
| const socketPath = path.join(directory, "app.sock"); | ||
| const server = net.createServer((socket) => { | ||
| let buffer = Buffer.alloc(0); | ||
| socket.on("data", (chunk) => { | ||
| buffer = Buffer.concat([buffer, chunk]); | ||
| const frame = decodeRawClientTextFrame(buffer); | ||
| if (!frame) { | ||
| return; | ||
| } | ||
| buffer = buffer.subarray(frame.consumed); | ||
| const request = JSON.parse(frame.text) as { | ||
| id: string | number; | ||
| method?: string; | ||
| }; | ||
| assert.equal(request.method, "initialize"); | ||
| socket.write(encodeRawServerTextFrame(JSON.stringify({ | ||
| id: request.id, | ||
| result: { | ||
| userAgent: "fake-codex", | ||
| codexHome: directory, | ||
| platformFamily: "unix", | ||
| platformOs: "macos", | ||
| }, | ||
| })), () => { | ||
| socket.destroy(); | ||
| }); | ||
| }); | ||
| }); | ||
| t.after(() => { | ||
| server.close(); | ||
| rmSync(directory, { recursive: true, force: true }); | ||
| }); | ||
| await new Promise<void>((resolve, reject) => { | ||
| server.once("error", reject); | ||
| server.listen(socketPath, () => { | ||
| server.off("error", reject); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| const client = new CodexAppServerClient(`unix://${socketPath}`); | ||
| await client.connect(); | ||
| }); | ||
| test("Codex CLI thread reads use local rollout before app-server", async () => { | ||
@@ -190,2 +358,128 @@ const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| test("queued Codex desktop turn keeps queue mode when an image is attached", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| let queuedText: string | null = null; | ||
| let startTurnCalls = 0; | ||
| hooks.loadThreadDeliverySummary = async () => ({ | ||
| sourceKind: "vscode", | ||
| status: "running", | ||
| }); | ||
| hooks.readThreadDeliverySnapshot = async () => ({ | ||
| hasTurnData: true, | ||
| turnCount: 3, | ||
| matchingUserTextCount: 0, | ||
| updatedAt: 123_000, | ||
| threadStatus: "running", | ||
| activeTurnId: "turn-3", | ||
| }); | ||
| hooks.enqueueTurnViaCodexDesktopIpc = async (_threadId, text) => { | ||
| queuedText = text; | ||
| return { | ||
| ok: true, | ||
| mode: "codexDesktopIpcQueuedFollowUpBroadcast", | ||
| threadId: "thread-1", | ||
| }; | ||
| }; | ||
| hooks.startTurnViaCodexDesktopIpc = async () => { | ||
| startTurnCalls += 1; | ||
| throw new Error("queue with image must not start an immediate desktop turn"); | ||
| }; | ||
| const result = await client.startTurn("thread-1", "Use the screenshot", { | ||
| deliveryMode: "queue", | ||
| imageAttachments: [ | ||
| { | ||
| path: "/tmp/helm-mobile/camera-roll-1.jpg", | ||
| filename: "camera-roll-1.jpg", | ||
| mimeType: "image/jpeg", | ||
| }, | ||
| ], | ||
| }); | ||
| assert.equal(startTurnCalls, 0); | ||
| assert.match(queuedText ?? "", /Use the screenshot/); | ||
| assert.match(queuedText ?? "", /camera-roll-1\.jpg/); | ||
| assert.match(queuedText ?? "", /\/tmp\/helm-mobile\/camera-roll-1\.jpg/); | ||
| assert.deepEqual(result, { | ||
| ok: true, | ||
| mode: "codexDesktopIpcQueuedFollowUpBroadcast", | ||
| threadId: "thread-1", | ||
| }); | ||
| }); | ||
| test("Codex desktop model update uses direct IPC for shared desktop threads", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| let ipcCall: { threadId: string; model: string; reasoningEffort: string | null } | null = null; | ||
| hooks.loadThreadDeliverySummary = async () => ({ | ||
| sourceKind: "vscode", | ||
| status: "idle", | ||
| }); | ||
| hooks.setModelAndReasoningViaCodexDesktopIpc = async (threadId, model, reasoningEffort) => { | ||
| ipcCall = { threadId, model, reasoningEffort }; | ||
| return { ok: true }; | ||
| }; | ||
| const result = await client.setModelAndReasoning("thread-1", " gpt-5.5 ", " high "); | ||
| assert.deepEqual(ipcCall, { | ||
| threadId: "thread-1", | ||
| model: "gpt-5.5", | ||
| reasoningEffort: "high", | ||
| }); | ||
| assert.deepEqual(result, { ok: true }); | ||
| }); | ||
| test("Codex model update rejects non-shared desktop threads", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| hooks.loadThreadDeliverySummary = async () => ({ | ||
| sourceKind: "cli", | ||
| status: "idle", | ||
| }); | ||
| hooks.setModelAndReasoningViaCodexDesktopIpc = async () => { | ||
| throw new Error("direct IPC should not run for CLI threads"); | ||
| }; | ||
| await assert.rejects( | ||
| () => client.setModelAndReasoning("thread-1", "gpt-5.5", "high"), | ||
| /shared Codex app session/ | ||
| ); | ||
| }); | ||
| test("Codex desktop queued follow-up append coalesces immediate duplicate messages", () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| const message = (id: string, createdAt: number) => ({ | ||
| id, | ||
| text: "queued from mobile", | ||
| context: { | ||
| prompt: "queued from mobile", | ||
| addedFiles: [], | ||
| fileAttachments: [], | ||
| commentAttachments: [], | ||
| ideContext: null, | ||
| imageAttachments: [], | ||
| workspaceRoots: ["/Users/devlin/GitHub/helm-dev"], | ||
| collaborationMode: null, | ||
| }, | ||
| cwd: "/Users/devlin/GitHub/helm-dev", | ||
| createdAt, | ||
| }); | ||
| const first = message("first", 1_000); | ||
| const duplicate = message("duplicate", 1_500); | ||
| const laterRepeat = message("later", 8_000); | ||
| const afterDuplicate = hooks.codexDesktopQueuedFollowUpsWithAppendedMessage([first], duplicate); | ||
| const afterLaterRepeat = hooks.codexDesktopQueuedFollowUpsWithAppendedMessage(afterDuplicate, laterRepeat); | ||
| assert.deepEqual(afterDuplicate.map((entry) => entry.id), ["first"]); | ||
| assert.deepEqual(afterLaterRepeat.map((entry) => entry.id), ["first", "later"]); | ||
| }); | ||
| test("Codex CLI prompt draft extraction ignores styled placeholder text", () => { | ||
@@ -356,2 +650,3 @@ const tail = [ | ||
| cwd: "/tmp/project", | ||
| projectName: "Codex Project", | ||
| status: { type: "running" }, | ||
@@ -377,2 +672,3 @@ updatedAt: 456_000, | ||
| assert.equal(threads[0]?.preview, "Waiting for output..."); | ||
| assert.equal(threads[0]?.projectName, "Codex Project"); | ||
| assert.equal(threads[1]?.preview, "No activity yet."); | ||
@@ -382,2 +678,28 @@ assert.equal(threads[0]?.sourceKind, "vscode"); | ||
| test("Codex project names resolve from desktop workspace-root labels", () => { | ||
| const previousPath = process.env.CODEX_GLOBAL_STATE_PATH; | ||
| const folder = mkdtempSync(path.join(tmpdir(), "helm-codex-project-")); | ||
| const statePath = path.join(folder, ".codex-global-state.json"); | ||
| process.env.CODEX_GLOBAL_STATE_PATH = statePath; | ||
| writeFileSync( | ||
| statePath, | ||
| JSON.stringify({ | ||
| "electron-workspace-root-labels": { | ||
| "/Users/devlin/Documents/New project": "Wedding", | ||
| }, | ||
| }), | ||
| "utf8" | ||
| ); | ||
| try { | ||
| assert.equal(codexProjectNameForPath("/Users/devlin/Documents/New project"), "Wedding"); | ||
| } finally { | ||
| if (previousPath === undefined) { | ||
| delete process.env.CODEX_GLOBAL_STATE_PATH; | ||
| } else { | ||
| process.env.CODEX_GLOBAL_STATE_PATH = previousPath; | ||
| } | ||
| } | ||
| }); | ||
| test("recent title-only unknown app-server sessions stay idle", async () => { | ||
@@ -384,0 +706,0 @@ const client = new CodexAppServerClient("ws://127.0.0.1:0"); |
@@ -109,2 +109,10 @@ import { AgentBackend, type StartThreadInput } from "./agentBackend.js"; | ||
| async setModelAndReasoning( | ||
| threadId: string, | ||
| model: string, | ||
| reasoningEffort: string | null = null | ||
| ): Promise<JSONValue | undefined> { | ||
| return await this.transport.setModelAndReasoning(threadId, model, reasoningEffort); | ||
| } | ||
| async interruptTurn(threadId: string): Promise<JSONValue | undefined> { | ||
@@ -111,0 +119,0 @@ return await this.transport.interruptTurn(threadId); |
@@ -18,2 +18,3 @@ import { randomUUID } from "node:crypto"; | ||
| ["thread-follower-interrupt-turn", 1], | ||
| ["thread-follower-set-model-and-reasoning", 1], | ||
| ["thread-follower-set-queued-follow-ups-state", 1], | ||
@@ -175,2 +176,14 @@ ["thread-queued-followups-changed", 1], | ||
| async setModelAndReasoning( | ||
| threadId: string, | ||
| model: string, | ||
| reasoningEffort: string | null | ||
| ): Promise<JSONValue | undefined> { | ||
| return await this.request("thread-follower-set-model-and-reasoning", { | ||
| conversationId: threadId, | ||
| model, | ||
| reasoningEffort, | ||
| }); | ||
| } | ||
| dispose(): void { | ||
@@ -177,0 +190,0 @@ this.rejectPendingResponses(new Error("Codex Desktop IPC client disposed")); |
@@ -86,3 +86,10 @@ import test from "node:test"; | ||
| const turn = turns[0] as { items?: Array<{ type?: string; tool?: string; contentItems?: string }> } | undefined; | ||
| const turn = turns[0] as { | ||
| items?: Array<{ | ||
| type?: string; | ||
| tool?: string; | ||
| contentItems?: string; | ||
| imageAttachments?: Array<{ path?: string; mimeType?: string; filename?: string }>; | ||
| }>; | ||
| } | undefined; | ||
| const item = turn?.items?.[0]; | ||
@@ -92,4 +99,40 @@ assert.equal(item?.type, "dynamicToolCall"); | ||
| assert.equal(item?.contentItems, imagePath); | ||
| assert.equal(item?.imageAttachments?.[0]?.path, imagePath); | ||
| assert.equal(item?.imageAttachments?.[0]?.mimeType, "image/jpeg"); | ||
| assert.equal(item?.imageAttachments?.[0]?.filename, "dropped-image-1.jpg"); | ||
| }); | ||
| test("local rollout fallback emits generated image attachments", () => { | ||
| const imagePath = "/Users/devlin/.codex/generated_images/thread-1/ig_result.png"; | ||
| const turns = parseCodexRolloutTurns( | ||
| [ | ||
| rolloutLine({ type: "task_started", turn_id: "turn-1" }), | ||
| rolloutLine({ | ||
| type: "image_generation_end", | ||
| turn_id: "turn-1", | ||
| saved_path: imagePath, | ||
| revised_prompt: "A Helm app icon.", | ||
| }), | ||
| ].join("\n"), | ||
| "thread-1" | ||
| ); | ||
| const turn = turns[0] as { | ||
| items?: Array<{ | ||
| type?: string; | ||
| tool?: string; | ||
| contentItems?: string; | ||
| imageAttachments?: Array<{ path?: string; mimeType?: string; filename?: string; source?: string }>; | ||
| }>; | ||
| } | undefined; | ||
| const item = turn?.items?.[0]; | ||
| assert.equal(item?.type, "dynamicToolCall"); | ||
| assert.equal(item?.tool, "Generated Image"); | ||
| assert.equal(item?.contentItems, imagePath); | ||
| assert.equal(item?.imageAttachments?.[0]?.path, imagePath); | ||
| assert.equal(item?.imageAttachments?.[0]?.mimeType, "image/png"); | ||
| assert.equal(item?.imageAttachments?.[0]?.filename, "ig_result.png"); | ||
| assert.equal(item?.imageAttachments?.[0]?.source, "image_generation"); | ||
| }); | ||
| test("local rollout fallback emits called MCP tool calls", () => { | ||
@@ -165,2 +208,39 @@ const turns = parseCodexRolloutTurns( | ||
| test("local rollout fallback assigns tailed response plans to turn context", () => { | ||
| const turns = parseCodexRolloutTurns( | ||
| [ | ||
| JSON.stringify({ | ||
| type: "turn_context", | ||
| payload: { turn_id: "turn-1" }, | ||
| }), | ||
| responseLine({ | ||
| type: "function_call", | ||
| name: "update_plan", | ||
| arguments: JSON.stringify({ | ||
| plan: [ | ||
| { step: "Rank replay blockers", status: "completed" }, | ||
| { step: "Patch passive completion hold gate", status: "in_progress" }, | ||
| { step: "Verify parity metrics", status: "pending" }, | ||
| ], | ||
| }), | ||
| }), | ||
| rolloutLine({ | ||
| type: "exec_command_end", | ||
| turn_id: "turn-1", | ||
| command: "pytest", | ||
| status: "completed", | ||
| exit_code: 0, | ||
| }), | ||
| ].join("\n"), | ||
| "thread-1" | ||
| ) as Array<{ id: string; items: Array<{ type?: string; text?: string; command?: string }> }>; | ||
| assert.equal(turns[0]?.id, "turn-1"); | ||
| assert.deepEqual( | ||
| turns[0]?.items.map((item) => item.type), | ||
| ["plan", "commandExecution"] | ||
| ); | ||
| assert.match(turns[0]?.items[0]?.text ?? "", /Patch passive completion hold gate/); | ||
| }); | ||
| test("local rollout fallback orders turns by most recent activity", () => { | ||
@@ -167,0 +247,0 @@ const turns = parseCodexRolloutTurns( |
@@ -18,3 +18,3 @@ import { execFile } from "node:child_process"; | ||
| import { findMatchingLaunchByCWD, findMatchingLaunchByThreadID } from "./runtimeLaunchRegistry.js"; | ||
| import type { JSONValue, ThreadSummary } from "./types.js"; | ||
| import type { JSONValue, ThreadImageAttachment, ThreadSummary } from "./types.js"; | ||
@@ -26,2 +26,4 @@ const execFileAsync = promisify(execFile); | ||
| const LOCAL_ROLLOUT_TAIL_READ_BYTES = 12 * 1024 * 1024; | ||
| const LOCAL_IMAGE_PATH_RE = | ||
| /(?:^|[\s("'`])((?:\/Users\/|\/private\/var\/|\/var\/folders\/|\/tmp\/)[^\s"'`)<]+?\.(?:png|jpe?g|webp|gif|heic|heif))(?:$|[\s"')>`])/giu; | ||
@@ -452,2 +454,11 @@ type CodexThreadRow = { | ||
| const payload = objectValue(record?.payload); | ||
| if (record?.type === "turn_context") { | ||
| const contextTurnId = stringValue(payload?.turn_id); | ||
| if (contextTurnId) { | ||
| currentTurnId = contextTurnId; | ||
| getTurn(currentTurnId, index).status = "running"; | ||
| } | ||
| continue; | ||
| } | ||
| const payloadType = stringValue(payload?.type); | ||
@@ -627,2 +638,5 @@ if (!record || !payload || !payloadType) { | ||
| const imagePath = boundedHeadText(stringValue(payload.path)); | ||
| const imageAttachments = imagePath | ||
| ? imageAttachmentsFromPaths([imagePath], `local-view-image-${index}`, "view_image") | ||
| : []; | ||
| return { | ||
@@ -634,4 +648,26 @@ id: `local-view-image-${index}`, | ||
| status: "completed", | ||
| ...(imageAttachments.length > 0 ? { imageAttachments } : {}), | ||
| }; | ||
| } | ||
| case "image_generation_end": { | ||
| const imagePath = boundedHeadText( | ||
| stringValue(payload.saved_path) ?? | ||
| stringValue(payload.path) ?? | ||
| stringValue(payload.output_path) | ||
| ); | ||
| if (!imagePath) { | ||
| return null; | ||
| } | ||
| const imageAttachments = imageAttachmentsFromPaths([imagePath], `local-generated-image-${index}`, "image_generation"); | ||
| return { | ||
| id: `local-generated-image-${index}`, | ||
| type: "dynamicToolCall", | ||
| tool: "Generated Image", | ||
| contentItems: imagePath, | ||
| status: "completed", | ||
| metadataSummary: boundedHeadText(stringValue(payload.revised_prompt), 280), | ||
| ...(imageAttachments.length > 0 ? { imageAttachments } : {}), | ||
| }; | ||
| } | ||
| case "mcp_tool_call_end": { | ||
@@ -680,2 +716,15 @@ return { | ||
| } | ||
| case "dynamicToolCall": { | ||
| const tool = comparableRolloutText(stringValue(itemObject.tool)); | ||
| const contentItems = comparableRolloutText(stringValue(itemObject.contentItems)); | ||
| const imageAttachmentText = Array.isArray(itemObject.imageAttachments) | ||
| ? itemObject.imageAttachments | ||
| .map((entry) => stringValue(objectValue(entry)?.path)) | ||
| .filter((entry): entry is string => Boolean(entry)) | ||
| .join(",") | ||
| : ""; | ||
| return tool || contentItems || imageAttachmentText | ||
| ? `tool:${tool ?? ""}:${contentItems ?? ""}:${imageAttachmentText}` | ||
| : null; | ||
| } | ||
| case "plan": { | ||
@@ -760,2 +809,3 @@ const text = comparableRolloutText(stringValue(itemObject.text)); | ||
| if (outputText) { | ||
| const imageAttachments = imageAttachmentsFromText(outputText, `local-agent-response-${index}`, "message"); | ||
| return { | ||
@@ -766,2 +816,3 @@ id: `local-agent-response-${index}`, | ||
| phase: "response_item", | ||
| ...(imageAttachments.length > 0 ? { imageAttachments } : {}), | ||
| }; | ||
@@ -773,2 +824,67 @@ } | ||
| function imageAttachmentsFromText( | ||
| text: string, | ||
| idPrefix: string, | ||
| source: string | ||
| ): ThreadImageAttachment[] { | ||
| const paths: string[] = []; | ||
| LOCAL_IMAGE_PATH_RE.lastIndex = 0; | ||
| for (const match of text.matchAll(LOCAL_IMAGE_PATH_RE)) { | ||
| if (match[1]) { | ||
| paths.push(match[1]); | ||
| } | ||
| } | ||
| return imageAttachmentsFromPaths(paths, idPrefix, source); | ||
| } | ||
| function imageAttachmentsFromPaths( | ||
| imagePaths: string[], | ||
| idPrefix: string, | ||
| source: string | ||
| ): ThreadImageAttachment[] { | ||
| const seen = new Set<string>(); | ||
| const attachments: ThreadImageAttachment[] = []; | ||
| for (const rawPath of imagePaths) { | ||
| const imagePath = rawPath.trim(); | ||
| if (!imagePath || seen.has(imagePath)) { | ||
| continue; | ||
| } | ||
| const mimeType = imageMimeTypeForPath(imagePath); | ||
| if (!mimeType) { | ||
| continue; | ||
| } | ||
| seen.add(imagePath); | ||
| attachments.push({ | ||
| id: `${idPrefix}-image-${attachments.length + 1}`, | ||
| path: imagePath, | ||
| mimeType, | ||
| filename: path.basename(imagePath), | ||
| source, | ||
| }); | ||
| } | ||
| return attachments; | ||
| } | ||
| function imageMimeTypeForPath(imagePath: string): string | null { | ||
| switch (path.extname(imagePath).toLowerCase()) { | ||
| case ".png": | ||
| return "image/png"; | ||
| case ".jpg": | ||
| case ".jpeg": | ||
| return "image/jpeg"; | ||
| case ".webp": | ||
| return "image/webp"; | ||
| case ".gif": | ||
| return "image/gif"; | ||
| case ".heic": | ||
| return "image/heic"; | ||
| case ".heif": | ||
| return "image/heif"; | ||
| default: | ||
| return null; | ||
| } | ||
| } | ||
| function responseContentText( | ||
@@ -775,0 +891,0 @@ value: JSONValue | undefined, |
@@ -66,2 +66,7 @@ import { CodexAppServerClient } from "./codexAppServerClient.js"; | ||
| ): Promise<JSONValue | undefined>; | ||
| setModelAndReasoning( | ||
| threadId: string, | ||
| model: string, | ||
| reasoningEffort?: string | null | ||
| ): Promise<JSONValue | undefined>; | ||
| interruptTurn(threadId: string): Promise<JSONValue | undefined>; | ||
@@ -148,2 +153,10 @@ sendInput(threadId: string, input: string): Promise<JSONValue | undefined>; | ||
| async setModelAndReasoning( | ||
| threadId: string, | ||
| model: string, | ||
| reasoningEffort: string | null = null | ||
| ): Promise<JSONValue | undefined> { | ||
| return await this.client.setModelAndReasoning(threadId, model, reasoningEffort); | ||
| } | ||
| async interruptTurn(threadId: string): Promise<JSONValue | undefined> { | ||
@@ -150,0 +163,0 @@ return await this.client.interruptTurn(threadId); |
@@ -24,3 +24,6 @@ import dotenv from "dotenv"; | ||
| bridgePreferredURL: process.env.BRIDGE_PREFERRED_URL?.trim() || null, | ||
| codexAppServerUrl: optionalEnv("CODEX_APP_SERVER_URL", "ws://127.0.0.1:6060"), | ||
| codexAppServerUrl: optionalEnv( | ||
| "CODEX_APP_SERVER_URL", | ||
| `unix://${join(homedir(), ".local", "share", "helm", "codex-app-server.sock")}` | ||
| ), | ||
| bridgePairingFile: optionalEnv( | ||
@@ -27,0 +30,0 @@ "BRIDGE_PAIRING_FILE", |
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { parseBootedSimulators } from "./simulatorMirror.js"; | ||
| import { | ||
| parseBootedSimulators, | ||
| parseSimulatorAccessibilitySnapshot, | ||
| } from "./simulatorMirror.js"; | ||
@@ -33,1 +36,45 @@ test("parseBootedSimulators returns only booted devices with runtime labels", () => { | ||
| }); | ||
| test("parseSimulatorAccessibilitySnapshot normalizes visible simulator elements", () => { | ||
| const snapshot = parseSimulatorAccessibilitySnapshot([ | ||
| "AXGroup\t\tgroup\t100\t200\t400\t800", | ||
| "AXButton\t\tCollapse task list\t120\t250\t80\t40", | ||
| "AXStaticText\t\tPlan Gabagool22 parity\t180\t220\t220\t30", | ||
| "AXButton\t\tOutside\t20\t20\t10\t10", | ||
| ].join("\n")); | ||
| assert.deepEqual(snapshot.screenFrame, { | ||
| x: 100, | ||
| y: 200, | ||
| width: 400, | ||
| height: 800, | ||
| }); | ||
| const button = snapshot.elements.find((element) => element.description === "Collapse task list"); | ||
| assert.equal(snapshot.elements.length, 2); | ||
| assert.deepEqual(button?.normalizedFrame, { | ||
| x: 0.05, | ||
| y: 0.0625, | ||
| width: 0.2, | ||
| height: 0.05, | ||
| }); | ||
| }); | ||
| test("parseSimulatorAccessibilitySnapshot suppresses duplicate large action targets", () => { | ||
| const taskLabel = "4 out of 7 tasks completed, " + "task ".repeat(30); | ||
| const snapshot = parseSimulatorAccessibilitySnapshot([ | ||
| "AXGroup\t\tgroup\t100\t200\t400\t800", | ||
| `AXButton\t\t${taskLabel}\t100\t200\t400\t200`, | ||
| `AXButton\t\t${taskLabel}\t110\t450\t380\t180`, | ||
| "AXButton\t\tSend to Codex\t450\t900\t40\t40", | ||
| "AXButton\t\tSend to Codex\t450\t950\t40\t40", | ||
| ].join("\n")); | ||
| assert.equal( | ||
| snapshot.elements.filter((element) => element.description === taskLabel.trim()).length, | ||
| 1 | ||
| ); | ||
| assert.equal( | ||
| snapshot.elements.filter((element) => element.description === "Send to Codex").length, | ||
| 2 | ||
| ); | ||
| }); |
@@ -16,2 +16,23 @@ import { execFile } from "node:child_process"; | ||
| export type SimulatorAccessibilityFrame = { | ||
| x: number; | ||
| y: number; | ||
| width: number; | ||
| height: number; | ||
| }; | ||
| export type SimulatorAccessibilityElement = { | ||
| id: string; | ||
| role: string; | ||
| name: string | null; | ||
| description: string | null; | ||
| frame: SimulatorAccessibilityFrame; | ||
| normalizedFrame: SimulatorAccessibilityFrame; | ||
| }; | ||
| export type SimulatorAccessibilitySnapshot = { | ||
| screenFrame: SimulatorAccessibilityFrame; | ||
| elements: SimulatorAccessibilityElement[]; | ||
| }; | ||
| export function parseBootedSimulators(output: string): BootedSimulator[] { | ||
@@ -73,1 +94,297 @@ const simulators: BootedSimulator[] = []; | ||
| } | ||
| export async function listSimulatorAccessibilityElements(): Promise<SimulatorAccessibilitySnapshot> { | ||
| const { stdout } = await execFileAsync( | ||
| "swift", | ||
| ["-e", SIMULATOR_ACCESSIBILITY_SWIFT], | ||
| { maxBuffer: 4 * 1024 * 1024 } | ||
| ); | ||
| return parseSimulatorAccessibilitySnapshot(stdout); | ||
| } | ||
| export function parseSimulatorAccessibilitySnapshot(output: string): SimulatorAccessibilitySnapshot { | ||
| const rawElements = output | ||
| .split(/\r?\n/) | ||
| .map((line) => line.trimEnd()) | ||
| .filter(Boolean) | ||
| .map(parseAccessibilityLine) | ||
| .filter((element): element is Omit<SimulatorAccessibilityElement, "id" | "normalizedFrame"> => element !== null); | ||
| const screenFrame = simulatorScreenFrame(rawElements); | ||
| const seen = new Set<string>(); | ||
| const seenLargeActionLabels = new Set<string>(); | ||
| const elements: SimulatorAccessibilityElement[] = []; | ||
| for (const element of rawElements) { | ||
| if (!isSelectableAccessibilityElement(element)) { | ||
| continue; | ||
| } | ||
| const normalizedFrame = normalizeFrame(element.frame, screenFrame); | ||
| if (!normalizedFrame) { | ||
| continue; | ||
| } | ||
| const label = element.description ?? element.name ?? ""; | ||
| const largeActionKey = largeActionDuplicateKey(element.role, label, normalizedFrame); | ||
| if (largeActionKey) { | ||
| if (seenLargeActionLabels.has(largeActionKey)) { | ||
| continue; | ||
| } | ||
| seenLargeActionLabels.add(largeActionKey); | ||
| } | ||
| const identity = [ | ||
| element.role, | ||
| label, | ||
| Math.round(normalizedFrame.x * 1000), | ||
| Math.round(normalizedFrame.y * 1000), | ||
| Math.round(normalizedFrame.width * 1000), | ||
| Math.round(normalizedFrame.height * 1000), | ||
| ].join("|"); | ||
| if (seen.has(identity)) { | ||
| continue; | ||
| } | ||
| seen.add(identity); | ||
| elements.push({ | ||
| ...element, | ||
| id: `ax-${elements.length + 1}`, | ||
| normalizedFrame, | ||
| }); | ||
| } | ||
| return { | ||
| screenFrame, | ||
| elements: elements | ||
| .sort((left, right) => frameArea(right.frame) - frameArea(left.frame)) | ||
| .slice(0, 180), | ||
| }; | ||
| } | ||
| function largeActionDuplicateKey( | ||
| role: string, | ||
| label: string, | ||
| frame: SimulatorAccessibilityFrame | ||
| ): string | null { | ||
| if (role !== "AXButton") { | ||
| return null; | ||
| } | ||
| if (label.length < 80) { | ||
| return null; | ||
| } | ||
| if (frame.width < 0.5 || frame.height < 0.08) { | ||
| return null; | ||
| } | ||
| return `${role}|${label.replace(/\s+/g, " ").trim()}`; | ||
| } | ||
| function parseAccessibilityLine(line: string): Omit<SimulatorAccessibilityElement, "id" | "normalizedFrame"> | null { | ||
| const fields = line.split("\t"); | ||
| if (fields.length !== 7) { | ||
| return null; | ||
| } | ||
| const [role, name, description, xText, yText, widthText, heightText] = fields; | ||
| const x = Number(xText); | ||
| const y = Number(yText); | ||
| const width = Number(widthText); | ||
| const height = Number(heightText); | ||
| if ( | ||
| !role || | ||
| !Number.isFinite(x) || | ||
| !Number.isFinite(y) || | ||
| !Number.isFinite(width) || | ||
| !Number.isFinite(height) || | ||
| width <= 0 || | ||
| height <= 0 | ||
| ) { | ||
| return null; | ||
| } | ||
| return { | ||
| role, | ||
| name: emptyToNull(name), | ||
| description: emptyToNull(description), | ||
| frame: { x, y, width, height }, | ||
| }; | ||
| } | ||
| function simulatorScreenFrame( | ||
| elements: Array<Omit<SimulatorAccessibilityElement, "id" | "normalizedFrame">> | ||
| ): SimulatorAccessibilityFrame { | ||
| const candidates = elements | ||
| .filter((element) => { | ||
| const ratio = element.frame.width / element.frame.height; | ||
| return element.role === "AXGroup" && | ||
| element.frame.width >= 250 && | ||
| element.frame.height >= 500 && | ||
| ratio >= 0.35 && | ||
| ratio <= 0.65; | ||
| }) | ||
| .sort((left, right) => frameArea(right.frame) - frameArea(left.frame)); | ||
| return candidates[0]?.frame ?? { x: 0, y: 0, width: 1, height: 1 }; | ||
| } | ||
| function isSelectableAccessibilityElement( | ||
| element: Omit<SimulatorAccessibilityElement, "id" | "normalizedFrame"> | ||
| ): boolean { | ||
| if (frameArea(element.frame) < 36) { | ||
| return false; | ||
| } | ||
| if (element.description === "group" || element.description === "toolbar") { | ||
| return false; | ||
| } | ||
| return [ | ||
| "AXButton", | ||
| "AXTextField", | ||
| "AXStaticText", | ||
| "AXHeading", | ||
| "AXImage", | ||
| "AXGenericElement", | ||
| "AXSlider", | ||
| "AXScrollArea", | ||
| ].includes(element.role); | ||
| } | ||
| function normalizeFrame( | ||
| frame: SimulatorAccessibilityFrame, | ||
| screenFrame: SimulatorAccessibilityFrame | ||
| ): SimulatorAccessibilityFrame | null { | ||
| const left = Math.max(frame.x, screenFrame.x); | ||
| const top = Math.max(frame.y, screenFrame.y); | ||
| const right = Math.min(frame.x + frame.width, screenFrame.x + screenFrame.width); | ||
| const bottom = Math.min(frame.y + frame.height, screenFrame.y + screenFrame.height); | ||
| const width = right - left; | ||
| const height = bottom - top; | ||
| if (width <= 0 || height <= 0) { | ||
| return null; | ||
| } | ||
| return { | ||
| x: (left - screenFrame.x) / screenFrame.width, | ||
| y: (top - screenFrame.y) / screenFrame.height, | ||
| width: width / screenFrame.width, | ||
| height: height / screenFrame.height, | ||
| }; | ||
| } | ||
| function frameArea(frame: SimulatorAccessibilityFrame): number { | ||
| return frame.width * frame.height; | ||
| } | ||
| function emptyToNull(value: string | undefined): string | null { | ||
| const trimmed = value?.trim() ?? ""; | ||
| return trimmed.length > 0 ? trimmed : null; | ||
| } | ||
| const SIMULATOR_ACCESSIBILITY_SWIFT = String.raw` | ||
| import AppKit | ||
| import ApplicationServices | ||
| struct ElementRecord { | ||
| let role: String | ||
| let title: String | ||
| let description: String | ||
| let frame: CGRect | ||
| } | ||
| func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String { | ||
| var value: CFTypeRef? | ||
| guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { | ||
| return "" | ||
| } | ||
| return value as? String ?? "" | ||
| } | ||
| func elementFrame(_ element: AXUIElement) -> CGRect? { | ||
| var positionRef: CFTypeRef? | ||
| var sizeRef: CFTypeRef? | ||
| guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success, | ||
| AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success, | ||
| let positionValue = positionRef, | ||
| let sizeValue = sizeRef, | ||
| CFGetTypeID(positionValue) == AXValueGetTypeID(), | ||
| CFGetTypeID(sizeValue) == AXValueGetTypeID() | ||
| else { | ||
| return nil | ||
| } | ||
| var position = CGPoint.zero | ||
| var size = CGSize.zero | ||
| AXValueGetValue((positionValue as! AXValue), .cgPoint, &position) | ||
| AXValueGetValue((sizeValue as! AXValue), .cgSize, &size) | ||
| guard size.width > 0, size.height > 0 else { return nil } | ||
| return CGRect(origin: position, size: size) | ||
| } | ||
| func childElements(_ element: AXUIElement) -> [AXUIElement] { | ||
| for attribute in [kAXVisibleChildrenAttribute as CFString, kAXChildrenAttribute as CFString] { | ||
| var value: CFTypeRef? | ||
| if AXUIElementCopyAttributeValue(element, attribute, &value) == .success, | ||
| let children = value as? [AXUIElement] { | ||
| return children | ||
| } | ||
| } | ||
| return [] | ||
| } | ||
| func clean(_ value: String) -> String { | ||
| value | ||
| .replacingOccurrences(of: "\t", with: " ") | ||
| .replacingOccurrences(of: "\n", with: " ") | ||
| .replacingOccurrences(of: "\r", with: " ") | ||
| } | ||
| guard let simulator = NSWorkspace.shared.runningApplications.first(where: { | ||
| $0.bundleIdentifier == "com.apple.iphonesimulator" || $0.localizedName == "Simulator" | ||
| }) else { | ||
| exit(2) | ||
| } | ||
| let application = AXUIElementCreateApplication(simulator.processIdentifier) | ||
| var windowsRef: CFTypeRef? | ||
| guard AXUIElementCopyAttributeValue(application, kAXWindowsAttribute as CFString, &windowsRef) == .success, | ||
| let windows = windowsRef as? [AXUIElement], | ||
| let window = windows.first | ||
| else { | ||
| exit(3) | ||
| } | ||
| var records: [ElementRecord] = [] | ||
| var visitedCount = 0 | ||
| func walk(_ element: AXUIElement, depth: Int) { | ||
| guard visitedCount <= 700, depth <= 10 else { return } | ||
| visitedCount += 1 | ||
| let role = stringAttribute(element, kAXRoleAttribute as CFString) | ||
| let title = stringAttribute(element, kAXTitleAttribute as CFString) | ||
| let description = stringAttribute(element, kAXDescriptionAttribute as CFString) | ||
| if let frame = elementFrame(element) { | ||
| records.append(ElementRecord(role: role, title: title, description: description, frame: frame)) | ||
| } | ||
| for child in childElements(element) { | ||
| walk(child, depth: depth + 1) | ||
| } | ||
| } | ||
| walk(window, depth: 0) | ||
| for record in records { | ||
| print([ | ||
| clean(record.role), | ||
| clean(record.title), | ||
| clean(record.description), | ||
| String(Int(record.frame.minX.rounded())), | ||
| String(Int(record.frame.minY.rounded())), | ||
| String(Int(record.frame.width.rounded())), | ||
| String(Int(record.frame.height.rounded())), | ||
| ].joined(separator: "\t")) | ||
| } | ||
| `; |
+41
-0
@@ -66,2 +66,3 @@ export type JSONValue = | ||
| workspacePath?: string | null; | ||
| projectName?: string | null; | ||
| status: string; | ||
@@ -135,2 +136,32 @@ updatedAt: number; | ||
| export type CodexAutomationSummary = { | ||
| id: string; | ||
| name: string; | ||
| kind: string; | ||
| status: string; | ||
| schedule: string | null; | ||
| scheduleSummary: string; | ||
| model: string | null; | ||
| reasoningEffort: string | null; | ||
| executionEnvironment: string | null; | ||
| cwd: string | null; | ||
| cwds: string[]; | ||
| prompt: string; | ||
| promptPreview: string; | ||
| createdAt: number | null; | ||
| updatedAt: number | null; | ||
| sourcePath: string; | ||
| }; | ||
| export type CreateCodexAutomationRequest = { | ||
| name: string; | ||
| prompt: string; | ||
| rrule: string; | ||
| model?: string | null; | ||
| reasoningEffort?: string | null; | ||
| executionEnvironment?: string | null; | ||
| cwd?: string | null; | ||
| status?: string | null; | ||
| }; | ||
| export type ConversationEvent = { | ||
@@ -154,2 +185,10 @@ method: string; | ||
| export type ThreadImageAttachment = { | ||
| id: string; | ||
| path: string; | ||
| mimeType: string | null; | ||
| filename: string | null; | ||
| source: string | null; | ||
| }; | ||
| export type RuntimePhase = "idle" | "running" | "waitingApproval" | "blocked" | "completed" | "unknown"; | ||
@@ -207,2 +246,3 @@ | ||
| exitCode: number | null; | ||
| imageAttachments?: ThreadImageAttachment[]; | ||
| }; | ||
@@ -222,2 +262,3 @@ | ||
| workspacePath?: string | null; | ||
| projectName?: string | null; | ||
| status: string; | ||
@@ -224,0 +265,0 @@ updatedAt: number; |
+1
-1
| { | ||
| "name": "@devlln/helm", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "private": false, | ||
@@ -5,0 +5,0 @@ "description": "Helm CLI bridge installer and runtime helpers.", |
+4
-0
@@ -0,1 +1,5 @@ | ||
| <p align="center"> | ||
| <img src="https://raw.githubusercontent.com/DEVLlN/helm/main/docs/brand/helm-icon-v1/helm-app-icon-v1-1024.png" alt="Helm app icon" width="128"> | ||
| </p> | ||
| # Helm | ||
@@ -2,0 +6,0 @@ |
@@ -6,3 +6,21 @@ #!/usr/bin/env bash | ||
| RUNTIME_DIR="${HELM_PROTOTYPE_RUNTIME_DIR:-$ROOT_DIR/.runtime/prototype}" | ||
| BRIDGE_DIR="$ROOT_DIR/bridge" | ||
| if [[ -f "$BRIDGE_DIR/.env" ]]; then | ||
| set -a | ||
| # shellcheck disable=SC1091 | ||
| source "$BRIDGE_DIR/.env" | ||
| set +a | ||
| fi | ||
| : "${CODEX_APP_SERVER_SOCKET:=$RUNTIME_DIR/codex-app-server.sock}" | ||
| LEGACY_CODEX_APP_SERVER_URL="ws://127.0.0.1:6060" | ||
| if [[ -z "${CODEX_APP_SERVER_URL:-}" ]] || { | ||
| [[ "${CODEX_APP_SERVER_URL:-}" == "$LEGACY_CODEX_APP_SERVER_URL" ]] \ | ||
| && [[ "${HELM_FORCE_LEGACY_CODEX_TCP:-0}" != "1" ]] | ||
| }; then | ||
| CODEX_APP_SERVER_URL="unix://$CODEX_APP_SERVER_SOCKET" | ||
| fi | ||
| export CODEX_APP_SERVER_URL | ||
| stop_pid_file() { | ||
@@ -30,2 +48,7 @@ local label="$1" | ||
| APP_SERVER_SOCKET_PATH="$(python3 -c 'from urllib.parse import urlparse; import os; parsed=urlparse(os.environ.get("CODEX_APP_SERVER_URL", "")); print(parsed.path or parsed.netloc if parsed.scheme == "unix" else "")')" | ||
| if [[ -n "$APP_SERVER_SOCKET_PATH" && "$APP_SERVER_SOCKET_PATH" == "$RUNTIME_DIR/"* ]]; then | ||
| rm -f "$APP_SERVER_SOCKET_PATH" | ||
| fi | ||
| echo "[prototype] Local helm prototype processes stopped." |
@@ -49,2 +49,3 @@ #!/usr/bin/env bash | ||
| print(f"Local bridge: {local_bridge_url}") | ||
| print(f"Codex app-server: {health.get('codexEndpoint', 'unknown')}") | ||
| print(f"Default backend: {health.get('defaultBackendId', 'unknown')}") | ||
@@ -51,0 +52,0 @@ print(f"Pairing token hint: {pairing.get('tokenHint', 'unknown')}") |
+163
-27
@@ -48,5 +48,13 @@ #!/usr/bin/env bash | ||
| : "${BRIDGE_PORT:=8787}" | ||
| : "${CODEX_APP_SERVER_URL:=ws://127.0.0.1:6060}" | ||
| : "${CODEX_APP_SERVER_SOCKET:=$RUNTIME_DIR/codex-app-server.sock}" | ||
| : "${HELM_RUNTIME_CAPTURE_FILE:=${HOME}/.config/helm/runtime-binary-capture.json}" | ||
| LEGACY_CODEX_APP_SERVER_URL="ws://127.0.0.1:6060" | ||
| if [[ -z "${CODEX_APP_SERVER_URL:-}" ]] || { | ||
| [[ "${CODEX_APP_SERVER_URL:-}" == "$LEGACY_CODEX_APP_SERVER_URL" ]] \ | ||
| && [[ "${HELM_FORCE_LEGACY_CODEX_TCP:-0}" != "1" ]] | ||
| }; then | ||
| CODEX_APP_SERVER_URL="unix://$CODEX_APP_SERVER_SOCKET" | ||
| fi | ||
| if command -v tailscale >/dev/null 2>&1; then | ||
@@ -69,3 +77,3 @@ TAILSCALE_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)" | ||
| export BRIDGE_HOST BRIDGE_PORT CODEX_APP_SERVER_URL | ||
| export BRIDGE_HOST BRIDGE_PORT CODEX_APP_SERVER_URL CODEX_APP_SERVER_SOCKET | ||
@@ -77,4 +85,16 @@ if [[ "$LAN_MODE" -eq 1 ]]; then | ||
| LOCAL_BRIDGE_URL="http://127.0.0.1:${BRIDGE_PORT}" | ||
| APP_SERVER_HOST="$(python3 -c 'from urllib.parse import urlparse; import os; print(urlparse(os.environ["CODEX_APP_SERVER_URL"]).hostname or "127.0.0.1")')" | ||
| APP_SERVER_PORT="$(python3 -c 'from urllib.parse import urlparse; import os; parsed=urlparse(os.environ["CODEX_APP_SERVER_URL"]); print(parsed.port or 80)')" | ||
| APP_SERVER_SCHEME="$(python3 -c 'from urllib.parse import urlparse; import os; print(urlparse(os.environ["CODEX_APP_SERVER_URL"]).scheme)')" | ||
| APP_SERVER_SOCKET_PATH="" | ||
| APP_SERVER_HOST="" | ||
| APP_SERVER_PORT="" | ||
| if [[ "$APP_SERVER_SCHEME" == "unix" ]]; then | ||
| APP_SERVER_SOCKET_PATH="$(python3 -c 'from urllib.parse import urlparse; import os; parsed=urlparse(os.environ["CODEX_APP_SERVER_URL"]); print(parsed.path or parsed.netloc)')" | ||
| if [[ -z "$APP_SERVER_SOCKET_PATH" ]]; then | ||
| echo "CODEX_APP_SERVER_URL uses unix:// but does not include a socket path." >&2 | ||
| exit 1 | ||
| fi | ||
| else | ||
| APP_SERVER_HOST="$(python3 -c 'from urllib.parse import urlparse; import os; print(urlparse(os.environ["CODEX_APP_SERVER_URL"]).hostname or "127.0.0.1")')" | ||
| APP_SERVER_PORT="$(python3 -c 'from urllib.parse import urlparse; import os; parsed=urlparse(os.environ["CODEX_APP_SERVER_URL"]); print(parsed.port or 80)')" | ||
| fi | ||
@@ -101,2 +121,24 @@ port_open() { | ||
| unix_socket_open() { | ||
| local socket_path="$1" | ||
| if [[ ! -S "$socket_path" ]]; then | ||
| return 1 | ||
| fi | ||
| python3 - "$socket_path" <<'PY' | ||
| import socket | ||
| import sys | ||
| socket_path = sys.argv[1] | ||
| sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||
| sock.settimeout(0.5) | ||
| try: | ||
| sock.connect(socket_path) | ||
| except OSError: | ||
| sys.exit(1) | ||
| finally: | ||
| sock.close() | ||
| PY | ||
| } | ||
| require_cmd() { | ||
@@ -243,5 +285,19 @@ if ! command -v "$1" >/dev/null 2>&1; then | ||
| start_app_server() { | ||
| if port_open "$APP_SERVER_HOST" "$APP_SERVER_PORT"; then | ||
| echo "[prototype] Codex app-server already listening at $CODEX_APP_SERVER_URL" | ||
| return | ||
| if [[ "$APP_SERVER_SCHEME" == "unix" ]]; then | ||
| if unix_socket_open "$APP_SERVER_SOCKET_PATH"; then | ||
| echo "[prototype] Codex app-server already listening at $CODEX_APP_SERVER_URL" | ||
| return | ||
| fi | ||
| if [[ -e "$APP_SERVER_SOCKET_PATH" ]]; then | ||
| echo "[prototype] Removing stale Codex app-server socket at $APP_SERVER_SOCKET_PATH" | ||
| rm -f "$APP_SERVER_SOCKET_PATH" | ||
| fi | ||
| mkdir -p "$(dirname "$APP_SERVER_SOCKET_PATH")" | ||
| else | ||
| if port_open "$APP_SERVER_HOST" "$APP_SERVER_PORT"; then | ||
| echo "[prototype] Codex app-server already listening at $CODEX_APP_SERVER_URL" | ||
| return | ||
| fi | ||
| fi | ||
@@ -257,5 +313,81 @@ | ||
| current_bridge_codex_endpoint() { | ||
| curl -sf "$LOCAL_BRIDGE_URL/health" \ | ||
| | python3 -c 'import json,sys; print(json.load(sys.stdin).get("codexEndpoint", ""))' \ | ||
| 2>/dev/null || true | ||
| } | ||
| wait_for_bridge_to_stop() { | ||
| for _ in $(seq 1 10); do | ||
| if ! curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| return 0 | ||
| fi | ||
| sleep 0.2 | ||
| done | ||
| return 1 | ||
| } | ||
| stop_repo_bridge_process_on_port() { | ||
| local pids | ||
| pids="$(lsof -nP -tiTCP:"${BRIDGE_PORT}" -sTCP:LISTEN 2>/dev/null || true)" | ||
| if [[ -z "$pids" ]]; then | ||
| return 1 | ||
| fi | ||
| local stopped=1 | ||
| local pid | ||
| for pid in $pids; do | ||
| local command | ||
| command="$(ps -p "$pid" -o command= 2>/dev/null || true)" | ||
| local cwd | ||
| cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" | ||
| if [[ "$command" == *"$BRIDGE_DIR/dist/index.js"* ]] \ | ||
| || [[ "$command" == *"dist/index.js"* && "$cwd" == "$BRIDGE_DIR" ]]; then | ||
| echo "[prototype] Stopping helm bridge process on port ${BRIDGE_PORT} ($pid)" | ||
| kill "$pid" >/dev/null 2>&1 || true | ||
| stopped=0 | ||
| fi | ||
| done | ||
| return "$stopped" | ||
| } | ||
| restart_managed_bridge_or_exit() { | ||
| local reason="$1" | ||
| local stopped=1 | ||
| if stop_pid_file "helm bridge" "$RUNTIME_DIR/helm-bridge.pid"; then | ||
| stopped=0 | ||
| fi | ||
| if wait_for_bridge_to_stop; then | ||
| return | ||
| fi | ||
| if stop_repo_bridge_process_on_port; then | ||
| stopped=0 | ||
| if wait_for_bridge_to_stop; then | ||
| return | ||
| fi | ||
| fi | ||
| if [[ "$stopped" -eq 0 ]]; then | ||
| echo "[prototype] Existing bridge on port ${BRIDGE_PORT} did not stop; cannot restart for ${reason}." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "[prototype] helm bridge is already reachable at $LOCAL_BRIDGE_URL but was not started by this prototype launcher." >&2 | ||
| echo "[prototype] Stop the existing bridge on port ${BRIDGE_PORT}, then rerun scripts/prototype-up.sh so helm can use ${reason}." >&2 | ||
| exit 1 | ||
| } | ||
| start_bridge() { | ||
| if curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| if [[ "$TAILSCALE_ACTIVE" -eq 1 ]] && [[ "$BRIDGE_HOST" == "0.0.0.0" ]]; then | ||
| local running_codex_endpoint | ||
| running_codex_endpoint="$(current_bridge_codex_endpoint)" | ||
| if [[ -n "$running_codex_endpoint" && "$running_codex_endpoint" != "$CODEX_APP_SERVER_URL" ]]; then | ||
| echo "[prototype] Restarting helm bridge to use Codex app-server $CODEX_APP_SERVER_URL" | ||
| restart_managed_bridge_or_exit "Codex app-server endpoint $CODEX_APP_SERVER_URL" | ||
| elif [[ "$TAILSCALE_ACTIVE" -eq 1 ]] && [[ "$BRIDGE_HOST" == "0.0.0.0" ]]; then | ||
| local tailscale_bridge_url="http://${TAILSCALE_IP}:${BRIDGE_PORT}" | ||
@@ -267,19 +399,3 @@ if curl -sf --connect-timeout 2 "$tailscale_bridge_url/health" >/dev/null 2>&1; then | ||
| if stop_pid_file "helm bridge" "$RUNTIME_DIR/helm-bridge.pid"; then | ||
| for _ in $(seq 1 10); do | ||
| if ! curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| break | ||
| fi | ||
| sleep 0.2 | ||
| done | ||
| if curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| echo "[prototype] Existing bridge on port ${BRIDGE_PORT} did not stop; cannot restart for Tailscale binding." >&2 | ||
| exit 1 | ||
| fi | ||
| else | ||
| echo "[prototype] helm bridge is only reachable locally at $LOCAL_BRIDGE_URL." >&2 | ||
| echo "[prototype] Stop the existing bridge on port ${BRIDGE_PORT}, then rerun scripts/prototype-up.sh so helm can bind for Tailscale." >&2 | ||
| exit 1 | ||
| fi | ||
| restart_managed_bridge_or_exit "Tailscale binding" | ||
| else | ||
@@ -292,4 +408,11 @@ echo "[prototype] helm bridge already available at $LOCAL_BRIDGE_URL" | ||
| if curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| echo "[prototype] helm bridge already available at $LOCAL_BRIDGE_URL" | ||
| return | ||
| local running_codex_endpoint | ||
| running_codex_endpoint="$(current_bridge_codex_endpoint)" | ||
| if [[ -z "$running_codex_endpoint" || "$running_codex_endpoint" == "$CODEX_APP_SERVER_URL" ]]; then | ||
| echo "[prototype] helm bridge already available at $LOCAL_BRIDGE_URL" | ||
| return | ||
| fi | ||
| echo "[prototype] Restarting helm bridge to use Codex app-server $CODEX_APP_SERVER_URL" | ||
| restart_managed_bridge_or_exit "Codex app-server endpoint $CODEX_APP_SERVER_URL" | ||
| fi | ||
@@ -303,2 +426,15 @@ | ||
| ) | ||
| if curl -sf "$LOCAL_BRIDGE_URL/health" >/dev/null 2>&1; then | ||
| local running_codex_endpoint | ||
| running_codex_endpoint="$(current_bridge_codex_endpoint)" | ||
| if [[ -z "$running_codex_endpoint" || "$running_codex_endpoint" == "$CODEX_APP_SERVER_URL" ]]; then | ||
| echo "[prototype] helm bridge became available at $LOCAL_BRIDGE_URL" | ||
| return | ||
| fi | ||
| echo "[prototype] Restarting helm bridge to use Codex app-server $CODEX_APP_SERVER_URL" | ||
| restart_managed_bridge_or_exit "Codex app-server endpoint $CODEX_APP_SERVER_URL" | ||
| fi | ||
| launch_detached \ | ||
@@ -305,0 +441,0 @@ "$RUNTIME_DIR/helm-bridge.pid" \ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
834608
11.07%71
2.9%21817
11.62%191
2.14%55
37.5%