From c7c3879332f17b1745c4515789a70490d126d67c Mon Sep 17 00:00:00 2001 From: sameerr03 Date: Tue, 18 Aug 2026 19:15:30 +0530 Subject: [PATCH 01/10] feat(scripts): add Codex thread migration and repair tools - Import native Codex history into T3 threads - Restore missing projections from imported event streams --- scripts/migrate-codex-thread.mjs | 619 ++++++++++++++++++ scripts/migrate-codex-thread.test.mjs | 93 +++ scripts/repair-codex-thread-projections.mjs | 612 +++++++++++++++++ .../repair-codex-thread-projections.test.mjs | 172 +++++ 4 files changed, 1496 insertions(+) create mode 100644 scripts/migrate-codex-thread.mjs create mode 100644 scripts/migrate-codex-thread.test.mjs create mode 100644 scripts/repair-codex-thread-projections.mjs create mode 100644 scripts/repair-codex-thread-projections.test.mjs diff --git a/scripts/migrate-codex-thread.mjs b/scripts/migrate-codex-thread.mjs new file mode 100644 index 000000000000..728955413c43 --- /dev/null +++ b/scripts/migrate-codex-thread.mjs @@ -0,0 +1,619 @@ +#!/usr/bin/env node +/* oxlint-disable t3code/no-global-process-runtime -- Standalone migration utility intentionally has no Effect runtime. */ + +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +const DEFAULT_MODEL = "gpt-5.6-sol"; +const LOCAL_INTERACTIVE_SOURCES = new Set(["cli", "vscode", "appServer"]); + +function usage() { + return `Usage: + node scripts/migrate-codex-thread.mjs \\ + --thread \\ + --project \\ + [--db ] [--codex-bin ] [--codex-home ] \\ + [--provider-instance codex] [--model ${DEFAULT_MODEL}] [--write] + +The command is a dry run unless --write is supplied. T3 Code must be fully stopped before writing. +Fully quit the Codex app before continuing a migrated task in T3; Codex permits only one active writer. +The default database is ~/.t3/userdata/state.sqlite.`; +} + +export function parseArgs(argv) { + const parsed = { + db: NodePath.join(NodeOS.homedir(), ".t3", "userdata", "state.sqlite"), + providerInstance: "codex", + model: DEFAULT_MODEL, + write: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--write") { + parsed.write = true; + continue; + } + if (argument === "--help" || argument === "-h") { + parsed.help = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}.`); + index += 1; + switch (argument) { + case "--thread": + parsed.threadId = value; + break; + case "--project": + parsed.project = value; + break; + case "--db": + parsed.db = NodePath.resolve(value); + break; + case "--codex-bin": + parsed.codexBin = value; + break; + case "--codex-home": + parsed.codexHome = NodePath.resolve(value); + break; + case "--provider-instance": + parsed.providerInstance = value; + break; + case "--model": + parsed.model = value; + break; + default: + throw new Error(`Unknown argument '${argument}'.`); + } + } + if (!parsed.help && (!parsed.threadId || !parsed.project)) { + throw new Error("Both --thread and --project are required."); + } + return parsed; +} + +function resolveCodexExecutable(explicitPath) { + if (explicitPath) return explicitPath; + const lookup = NodeChildProcess.spawnSync( + process.platform === "win32" ? "where.exe" : "which", + ["codex"], + { encoding: "utf8" }, + ); + const matches = lookup.stdout + ?.split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const match = + process.platform === "win32" + ? (matches?.find((candidate) => /\.(?:cmd|bat)$/iu.test(candidate)) ?? + matches?.find((candidate) => /\.exe$/iu.test(candidate))) + : matches?.[0]; + if (!match) throw new Error("Could not find Codex. Pass --codex-bin with its executable path."); + return match; +} + +class JsonLineRpcClient { + constructor(child) { + this.child = child; + this.nextId = 1; + this.pending = new Map(); + this.stderr = ""; + NodeReadline.createInterface({ input: child.stdout }).on("line", (line) => + this.handleLine(line), + ); + child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + child.on("error", (cause) => { + const error = new Error(`Could not start Codex App Server: ${cause.message}`, { cause }); + for (const { reject } of this.pending.values()) reject(error); + this.pending.clear(); + }); + child.on("exit", (code) => { + if (code === 0 && this.pending.size === 0) return; + const error = new Error( + `Codex App Server exited with code ${String(code)}.${this.stderr ? `\n${this.stderr}` : ""}`, + ); + for (const { reject } of this.pending.values()) reject(error); + this.pending.clear(); + }); + } + + handleLine(line) { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.id === undefined) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) + pending.reject(new Error(message.error.message ?? JSON.stringify(message.error))); + else pending.resolve(message.result); + } + + request(method, params) { + const id = this.nextId++; + return new Promise((resolveRequest, reject) => { + this.pending.set(id, { resolve: resolveRequest, reject }); + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + } + + notify(method, params) { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); + } + + close() { + this.child.stdin.end(); + if (!this.child.killed) this.child.kill(); + } +} + +export async function readCodexThread(options) { + const executable = resolveCodexExecutable(options.codexBin); + const useShell = process.platform === "win32" && /\.(cmd|bat)$/iu.test(executable); + const child = NodeChildProcess.spawn(executable, ["app-server"], { + cwd: process.cwd(), + env: { + ...process.env, + ...(options.codexHome ? { CODEX_HOME: options.codexHome } : {}), + }, + shell: useShell, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const rpc = new JsonLineRpcClient(child); + try { + await rpc.request("initialize", { + clientInfo: { name: "t3-codex-migrator", title: "T3 Codex Migrator", version: "1" }, + capabilities: { experimentalApi: true }, + }); + rpc.notify("initialized"); + const response = await rpc.request("thread/read", { + threadId: options.threadId, + includeTurns: true, + }); + return response.thread; + } finally { + rpc.close(); + } +} + +export function classifyLocalThread(thread, workspaceExists = NodeFS.existsSync(thread.cwd)) { + if (thread.ephemeral) return { eligible: false, reason: "ephemeral" }; + if (typeof thread.source !== "string") return { eligible: false, reason: "subagent" }; + if (!LOCAL_INTERACTIVE_SOURCES.has(thread.source)) { + return { eligible: false, reason: "non-interactive-source" }; + } + if (thread.parentThreadId != null || thread.agentNickname != null || thread.agentRole != null) { + return { eligible: false, reason: "subagent" }; + } + if (!thread.cwd?.trim()) return { eligible: false, reason: "missing-cwd" }; + if (!workspaceExists) return { eligible: false, reason: "workspace-not-local" }; + return { eligible: true }; +} + +function userInputText(input) { + switch (input.type) { + case "text": + return input.text; + case "image": + return `[Image: ${input.url}]`; + case "localImage": + return `[Local image: ${input.path}]`; + case "audio": + return `[Audio: ${input.url}]`; + case "localAudio": + return `[Local audio: ${input.path}]`; + case "skill": + return `[Skill: ${input.name} (${input.path})]`; + case "mention": + return `[Mention: ${input.name} (${input.path})]`; + default: + return `[Codex input: ${input.type ?? "unknown"}]`; + } +} + +function summarizeItem(item) { + switch (item.type) { + case "plan": + return item.text?.trim() || "Plan"; + case "reasoning": + return item.summary?.join("\n").trim() || "Reasoning"; + case "commandExecution": + return item.command?.trim() || "Command execution"; + case "fileChange": + return `File changes (${item.changes?.length ?? 0})`; + case "mcpToolCall": + return `${item.server}/${item.tool}`; + case "dynamicToolCall": + return item.namespace ? `${item.namespace}/${item.tool}` : item.tool; + case "webSearch": + return item.query?.trim() || "Web search"; + case "imageView": + return `Viewed ${item.path}`; + case "sleep": + return `Waited ${item.durationMs}ms`; + case "imageGeneration": + return "Image generation"; + case "enteredReviewMode": + return "Entered review mode"; + case "exitedReviewMode": + return "Exited review mode"; + case "contextCompaction": + return "Context compacted"; + default: + return ( + item.title ?? + item.command ?? + item.query ?? + item.review ?? + String(item.type ?? "unknown item").replaceAll(/([a-z])([A-Z])/gu, "$1 $2") + ); + } +} + +function isoFromMs(milliseconds) { + return new Date(milliseconds).toISOString(); +} + +export function projectCodexThread(thread, options) { + const threadId = `codex-import:${thread.id}`; + const createdAtMs = Number.isFinite(thread.createdAt) ? thread.createdAt * 1_000 : 0; + let cursorMs = createdAtMs - 1; + const events = []; + const commandId = `codex-import:${thread.id}`; + const createdAt = isoFromMs(createdAtMs); + events.push({ + type: "thread.created", + occurredAt: createdAt, + payload: { + threadId, + projectId: options.projectId, + title: thread.name?.trim() || thread.preview?.trim() || "Imported Codex task", + modelSelection: { instanceId: options.providerInstance, model: options.model }, + runtimeMode: "full-access", + interactionMode: "default", + branch: thread.gitInfo?.branch?.trim() || null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + for (const turn of thread.turns) { + const turnStartedAtMs = Number.isFinite(turn.startedAt) ? turn.startedAt * 1_000 : cursorMs + 1; + for (const [itemIndex, item] of turn.items.entries()) { + cursorMs = Math.max(cursorMs + 1, turnStartedAtMs + itemIndex); + const itemCreatedAt = isoFromMs(cursorMs); + if (item.type === "userMessage" || item.type === "agentMessage") { + events.push({ + type: "thread.message-sent", + occurredAt: itemCreatedAt, + payload: { + threadId, + messageId: `codex-import:${thread.id}:${item.id}`, + role: item.type === "userMessage" ? "user" : "assistant", + text: + item.type === "userMessage" ? item.content.map(userInputText).join("\n") : item.text, + turnId: turn.id, + streaming: false, + createdAt: itemCreatedAt, + updatedAt: itemCreatedAt, + }, + }); + } else { + events.push({ + type: "thread.activity-appended", + occurredAt: itemCreatedAt, + payload: { + threadId, + activity: { + id: `codex-import:${thread.id}:${item.id}`, + tone: item.type === "plan" || item.type === "reasoning" ? "info" : "tool", + kind: + item.type === "plan" || item.type === "reasoning" + ? `codex.${item.type}` + : "tool.completed", + summary: summarizeItem(item), + payload: { importedFrom: "codex", itemType: item.type, data: { item } }, + turnId: turn.id, + createdAt: itemCreatedAt, + }, + }, + }); + } + } + } + return { threadId, commandId, events }; +} + +function normalizedPath(value) { + const result = NodePath.normalize(NodePath.isAbsolute(value) ? value : NodePath.resolve(value)); + return process.platform === "win32" ? result.toLowerCase() : result; +} + +export function resolveProject(projects, identifier) { + const exactId = projects.find((project) => project.project_id === identifier); + if (exactId) return exactId; + const candidatePath = normalizedPath(identifier); + return projects.find((project) => normalizedPath(project.workspace_root) === candidatePath); +} + +function requireTable(db, table) { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table); + if (!row) throw new Error(`The selected database is missing required table '${table}'.`); +} + +function tableColumns(db, table) { + return new Set( + db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => row.name), + ); +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function assertServerStopped(dbPath) { + const runtimePath = NodePath.join(NodePath.dirname(dbPath), "server-runtime.json"); + if (!NodeFS.existsSync(runtimePath)) return; + let runtimeState; + try { + runtimeState = JSON.parse(NodeFS.readFileSync(runtimePath, "utf8")); + } catch { + return; + } + const pid = Number(runtimeState.pid); + if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { + throw new Error(`T3 Code is still running as process ${pid}. Fully quit it before importing.`); + } + const { origin } = runtimeState; + if (typeof origin !== "string") return; + try { + await fetch(origin, { signal: AbortSignal.timeout(750) }); + throw new Error(`T3 Code is still running at ${origin}. Fully quit it before importing.`); + } catch (error) { + if (error instanceof Error && error.message.startsWith("T3 Code is still running")) throw error; + } +} + +function ensureDatabaseExclusive(db) { + try { + db.exec("PRAGMA busy_timeout = 0"); + db.exec("BEGIN IMMEDIATE"); + db.exec("ROLLBACK"); + } catch (error) { + throw new Error("The T3 database is write-locked. Fully quit T3 Code before importing.", { + cause: error, + }); + } + const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get(); + if (Number(checkpoint?.busy ?? 0) !== 0) { + throw new Error("The T3 database WAL is still held by another process. Fully quit T3 Code."); + } +} + +function quoteSqlString(value) { + return `'${value.replaceAll("'", "''")}'`; +} + +function createBackup(db, dbPath) { + const timestamp = new Date().toISOString().replaceAll(/[:.]/gu, "-"); + const backupPath = `${dbPath}.backup-${timestamp}`; + db.exec(`VACUUM INTO ${quoteSqlString(backupPath)}`); + return backupPath; +} + +function appendEvents(db, projection) { + const latest = db + .prepare( + "SELECT MAX(stream_version) AS version FROM orchestration_events WHERE aggregate_kind = 'thread' AND stream_id = ?", + ) + .get(projection.threadId); + let streamVersion = Number(latest?.version ?? -1) + 1; + const insert = db.prepare(` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json + ) VALUES (?, 'thread', ?, ?, ?, ?, ?, NULL, ?, 'client', ?, '{}') + `); + for (const event of projection.events) { + insert.run( + NodeCrypto.randomUUID(), + projection.threadId, + streamVersion++, + event.type, + event.occurredAt, + projection.commandId, + projection.commandId, + JSON.stringify(event.payload), + ); + } +} + +function upsertResumeBinding(db, thread, projection, options, columns) { + const importedAt = new Date().toISOString(); + const runtimePayload = JSON.stringify({ + cwd: thread.cwd, + modelSelection: { instanceId: options.providerInstance, model: options.model }, + codexImport: { + sourceThreadId: thread.id, + sourceUpdatedAt: isoFromMs(thread.updatedAt * 1_000), + importedAt, + }, + }); + const values = { + threadId: projection.threadId, + providerName: "codex", + providerInstanceId: options.providerInstance, + adapterKey: "codex", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: importedAt, + resumeCursor: JSON.stringify({ threadId: thread.id }), + runtimePayload, + }; + if (columns.has("provider_instance_id")) { + db.prepare(` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(thread_id) DO UPDATE SET + provider_name=excluded.provider_name, + provider_instance_id=excluded.provider_instance_id, + adapter_key=excluded.adapter_key, + runtime_mode=excluded.runtime_mode, + status=excluded.status, + last_seen_at=excluded.last_seen_at, + resume_cursor_json=excluded.resume_cursor_json, + runtime_payload_json=excluded.runtime_payload_json + `).run(...Object.values(values)); + return; + } + db.prepare(` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(thread_id) DO UPDATE SET + provider_name=excluded.provider_name, + adapter_key=excluded.adapter_key, + runtime_mode=excluded.runtime_mode, + status=excluded.status, + last_seen_at=excluded.last_seen_at, + resume_cursor_json=excluded.resume_cursor_json, + runtime_payload_json=excluded.runtime_payload_json + `).run( + values.threadId, + values.providerName, + values.adapterKey, + values.runtimeMode, + values.status, + values.lastSeenAt, + values.resumeCursor, + values.runtimePayload, + ); +} + +export async function migrateCodexThread(options) { + if (!NodeFS.existsSync(options.db)) { + throw new Error(`T3 database not found at '${options.db}'.`); + } + const thread = await readCodexThread(options); + const eligibility = classifyLocalThread(thread); + if (!eligibility.eligible) { + throw new Error(`Codex task '${thread.id}' is not local-importable (${eligibility.reason}).`); + } + const partialTurns = thread.turns.filter( + (turn) => turn.itemsView !== undefined && turn.itemsView !== "full", + ); + if (partialTurns.length > 0) { + throw new Error(`Codex returned partial history for ${partialTurns.length} turn(s).`); + } + if (options.write) await assertServerStopped(options.db); + const db = new NodeSqlite.DatabaseSync(options.db, { readOnly: !options.write }); + try { + requireTable(db, "orchestration_events"); + requireTable(db, "projection_projects"); + requireTable(db, "projection_threads"); + requireTable(db, "provider_session_runtime"); + const projects = db + .prepare( + "SELECT project_id, title, workspace_root FROM projection_projects WHERE deleted_at IS NULL ORDER BY created_at", + ) + .all(); + const project = resolveProject(projects, options.project); + if (!project) throw new Error(`No active T3 project matches '${options.project}'.`); + const projection = projectCodexThread(thread, { + projectId: project.project_id, + providerInstance: options.providerInstance, + model: options.model, + }); + const alreadyImported = Boolean( + db + .prepare( + "SELECT 1 FROM orchestration_events WHERE aggregate_kind = 'thread' AND stream_id = ? AND event_type = 'thread.created' LIMIT 1", + ) + .get(projection.threadId), + ); + const summary = { + sourceThreadId: thread.id, + destinationThreadId: projection.threadId, + title: projection.events[0].payload.title, + projectId: project.project_id, + projectTitle: project.title, + messageCount: projection.events.filter((event) => event.type === "thread.message-sent") + .length, + activityCount: projection.events.filter((event) => event.type === "thread.activity-appended") + .length, + alreadyImported, + write: options.write, + resumeNote: "Fully quit the Codex app before continuing this task in T3 Code.", + }; + if (!options.write) return summary; + ensureDatabaseExclusive(db); + const backupPath = createBackup(db, options.db); + await assertServerStopped(options.db); + ensureDatabaseExclusive(db); + db.exec("BEGIN IMMEDIATE"); + try { + if (!alreadyImported) appendEvents(db, projection); + upsertResumeBinding( + db, + thread, + projection, + options, + tableColumns(db, "provider_session_runtime"), + ); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + return { ...summary, backupPath }; + } finally { + db.close(); + } +} + +async function main() { + try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + const result = await migrateCodexThread(options); + console.log(JSON.stringify(result, null, 2)); + if (!options.write) + console.log("Dry run only. Re-run with --write after fully quitting T3 Code."); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error("\n" + usage()); + process.exitCode = 1; + } +} + +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) + await main(); diff --git a/scripts/migrate-codex-thread.test.mjs b/scripts/migrate-codex-thread.test.mjs new file mode 100644 index 000000000000..9de1d979a808 --- /dev/null +++ b/scripts/migrate-codex-thread.test.mjs @@ -0,0 +1,93 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; + +import { + classifyLocalThread, + parseArgs, + projectCodexThread, + resolveProject, +} from "./migrate-codex-thread.mjs"; + +const thread = { + id: "source-thread", + name: "Migration proof", + preview: "Preview", + source: "vscode", + ephemeral: false, + cwd: "C:\\Code\\repo", + createdAt: 1_765_699_200, + updatedAt: 1_765_699_300, + gitInfo: { branch: "main" }, + turns: [ + { + id: "source-turn", + startedAt: 1_765_699_201, + items: [ + { + id: "user-item", + type: "userMessage", + content: [ + { type: "text", text: "Inspect this" }, + { type: "localImage", path: "C:\\tmp\\proof.png" }, + ], + }, + { id: "reasoning-item", type: "reasoning", summary: ["Checked the task"] }, + { id: "assistant-item", type: "agentMessage", text: "MIGRATION_SOURCE_OK" }, + ], + }, + ], +}; + +NodeTest.test("parses a dry-run command by default", () => { + const parsed = parseArgs(["--thread", "source", "--project", "C:\\Code\\repo"]); + NodeAssert.equal(parsed.threadId, "source"); + NodeAssert.equal(parsed.project, "C:\\Code\\repo"); + NodeAssert.equal(parsed.write, false); +}); + +NodeTest.test("rejects non-local, ephemeral, and parent-agent tasks", () => { + NodeAssert.deepEqual(classifyLocalThread(thread, true), { eligible: true }); + NodeAssert.deepEqual(classifyLocalThread({ ...thread, cwd: "C:\\missing" }, false), { + eligible: false, + reason: "workspace-not-local", + }); + NodeAssert.deepEqual(classifyLocalThread({ ...thread, source: "exec" }, true), { + eligible: false, + reason: "non-interactive-source", + }); + NodeAssert.deepEqual(classifyLocalThread({ ...thread, source: { subAgent: "review" } }, true), { + eligible: false, + reason: "subagent", + }); + NodeAssert.deepEqual(classifyLocalThread({ ...thread, threadSource: "subagent" }, true), { + eligible: true, + }); +}); + +NodeTest.test("projects native history in exact item order with visible fallbacks", () => { + const projection = projectCodexThread(thread, { + projectId: "project-1", + providerInstance: "codex", + model: "gpt-5.6-sol", + }); + NodeAssert.equal(projection.threadId, "codex-import:source-thread"); + NodeAssert.deepEqual( + projection.events.map((event) => event.type), + ["thread.created", "thread.message-sent", "thread.activity-appended", "thread.message-sent"], + ); + NodeAssert.equal( + projection.events[1].payload.text, + "Inspect this\n[Local image: C:\\tmp\\proof.png]", + ); + NodeAssert.equal(projection.events[2].payload.activity.summary, "Checked the task"); + NodeAssert.equal(projection.events[3].payload.text, "MIGRATION_SOURCE_OK"); + NodeAssert.ok( + Date.parse(projection.events[1].occurredAt) < Date.parse(projection.events[2].occurredAt), + ); +}); + +NodeTest.test("resolves destination projects by id or normalized workspace path", () => { + const projects = [{ project_id: "project-1", title: "Repo", workspace_root: "C:\\Code\\repo" }]; + NodeAssert.equal(resolveProject(projects, "project-1")?.project_id, "project-1"); + NodeAssert.equal(resolveProject(projects, "C:\\Code\\repo")?.project_id, "project-1"); +}); diff --git a/scripts/repair-codex-thread-projections.mjs b/scripts/repair-codex-thread-projections.mjs new file mode 100644 index 000000000000..0c84971ebf2e --- /dev/null +++ b/scripts/repair-codex-thread-projections.mjs @@ -0,0 +1,612 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; + +const IMPORT_STREAM_PREFIX = "codex-import:"; +const IMPORT_EVENT_TYPES = new Set([ + "thread.created", + "thread.message-sent", + "thread.activity-appended", +]); +const IMPORT_ACTIVITY_KINDS = new Set(["codex.plan", "codex.reasoning", "tool.completed"]); + +function usage() { + return `Usage: + node scripts/repair-codex-thread-projections.mjs [--db ] [--write] + +The command is a dry run unless --write is supplied. Fully quit T3 Code before writing. +The repair discovers events written by migrate-codex-thread.mjs, restores only missing +projection rows, and leaves orchestration events and Codex runtime bindings unchanged.`; +} + +export function parseRepairArgs(argv) { + const options = { + db: NodePath.join(NodeOS.homedir(), ".t3", "userdata", "state.sqlite"), + write: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + continue; + } + if (argument === "--help" || argument === "-h") { + options.help = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}.`); + index += 1; + if (argument === "--db") options.db = NodePath.resolve(value); + else throw new Error(`Unknown argument '${argument}'.`); + } + return options; +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function assertServerStopped(dbPath) { + const runtimePath = NodePath.join(NodePath.dirname(dbPath), "server-runtime.json"); + if (!NodeFS.existsSync(runtimePath)) return; + let runtimeState; + try { + runtimeState = JSON.parse(NodeFS.readFileSync(runtimePath, "utf8")); + } catch { + return; + } + const pid = Number(runtimeState.pid); + if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { + throw new Error(`T3 Code is still running as process ${pid}. Fully quit it before repairing.`); + } + if (typeof runtimeState.origin !== "string") return; + try { + await fetch(runtimeState.origin, { signal: AbortSignal.timeout(750) }); + throw new Error(`T3 Code is still running at ${runtimeState.origin}. Fully quit it first.`); + } catch (error) { + if (error instanceof Error && error.message.startsWith("T3 Code is still running")) { + throw error; + } + } +} + +function ensureDatabaseExclusive(db) { + try { + db.exec("PRAGMA busy_timeout = 0"); + db.exec("BEGIN IMMEDIATE"); + db.exec("ROLLBACK"); + } catch (error) { + throw new Error("The T3 database is write-locked. Fully quit T3 Code before repairing.", { + cause: error, + }); + } + const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get(); + if (Number(checkpoint?.busy ?? 0) !== 0) { + throw new Error("The T3 database WAL is still held by another process. Fully quit T3 Code."); + } +} + +function quoteSqlString(value) { + return `'${value.replaceAll("'", "''")}'`; +} + +function createBackup(db, dbPath) { + const timestamp = new Date().toISOString().replaceAll(/[:.]/gu, "-"); + const backupPath = `${dbPath}.backup-repair-${timestamp}`; + db.exec(`VACUUM INTO ${quoteSqlString(backupPath)}`); + return backupPath; +} + +function parseJson(value, description) { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid JSON in ${description}.`, { cause: error }); + } +} + +function assertString(value, description) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Expected a non-empty string for ${description}.`); + } + return value; +} + +function readFingerprint(db) { + return { + eventCount: db.prepare("SELECT COUNT(*) AS count FROM orchestration_events").get().count, + runtimeCount: db.prepare("SELECT COUNT(*) AS count FROM provider_session_runtime").get().count, + projectionState: db + .prepare( + "SELECT projector, last_applied_sequence, updated_at FROM projection_state ORDER BY projector", + ) + .all(), + projectionCounts: db + .prepare(` + SELECT + (SELECT COUNT(*) FROM projection_threads) AS threads, + (SELECT COUNT(*) FROM projection_thread_messages) AS messages, + (SELECT COUNT(*) FROM projection_thread_activities) AS activities, + (SELECT COUNT(*) FROM projection_turns) AS turns + `) + .get(), + }; +} + +function sameValues(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function buildImportProjection(rows) { + if (rows.length === 0) throw new Error("Cannot build a projection from an empty import stream."); + const streamId = rows[0].stream_id; + let expectedVersion = 0; + const events = rows.map((row) => { + if (row.stream_id !== streamId) throw new Error("Import rows contain multiple streams."); + if (row.command_id !== streamId || row.correlation_id !== streamId) { + throw new Error(`Stream '${streamId}' contains a row outside its original import batch.`); + } + if (row.stream_version !== expectedVersion) { + throw new Error( + `Import stream '${streamId}' expected version ${expectedVersion}, received ${row.stream_version}.`, + ); + } + expectedVersion += 1; + if (!IMPORT_EVENT_TYPES.has(row.event_type)) { + throw new Error(`Unexpected import event '${row.event_type}' in '${streamId}'.`); + } + return { + type: row.event_type, + occurredAt: row.occurred_at, + payload: parseJson(row.payload_json, `event '${row.event_id}' payload`), + }; + }); + const createdEvents = events.filter(({ type }) => type === "thread.created"); + if (createdEvents.length !== 1 || events[0].type !== "thread.created") { + throw new Error(`Import stream '${streamId}' must contain one leading thread.created event.`); + } + const created = createdEvents[0].payload; + if (created.threadId !== streamId) { + throw new Error(`Import stream '${streamId}' has a mismatched thread.created payload.`); + } + + const messages = []; + const activities = []; + const messageIds = new Set(); + const activityIds = new Set(); + const turnsById = new Map(); + let updatedAt = created.updatedAt; + let latestUserMessageAt = null; + + for (const event of events.slice(1)) { + updatedAt = event.occurredAt; + if (event.type === "thread.message-sent") { + const message = event.payload; + const messageId = assertString(message.messageId, `${streamId} messageId`); + if ( + message.threadId !== streamId || + message.streaming !== false || + message.attachments !== undefined || + messageIds.has(messageId) + ) { + throw new Error(`Import stream '${streamId}' has an unexpected message shape.`); + } + messageIds.add(messageId); + messages.push(message); + if (message.role === "user") { + if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { + latestUserMessageAt = message.createdAt; + } + } else if (message.role === "assistant" && message.turnId !== null) { + const existing = turnsById.get(message.turnId); + const assistantMessageIds = existing?.assistantMessageIds ?? new Set(); + assistantMessageIds.add(message.messageId); + turnsById.set(message.turnId, { + threadId: streamId, + turnId: message.turnId, + assistantMessageId: message.messageId, + assistantMessageIds, + requestedAt: existing?.requestedAt ?? message.createdAt, + startedAt: existing?.startedAt ?? message.createdAt, + completedAt: existing?.completedAt ?? message.updatedAt, + }); + } else if (message.role !== "assistant") { + throw new Error(`Import stream '${streamId}' has unsupported role '${message.role}'.`); + } + continue; + } + if (event.type === "thread.activity-appended") { + const activity = event.payload.activity; + const activityId = assertString(activity?.id, `${streamId} activity id`); + if ( + event.payload.threadId !== streamId || + !IMPORT_ACTIVITY_KINDS.has(activity.kind) || + activityIds.has(activityId) + ) { + throw new Error(`Import stream '${streamId}' has an unexpected activity shape.`); + } + activityIds.add(activityId); + activities.push(activity); + } + } + + return { + thread: { + threadId: streamId, + projectId: assertString(created.projectId, `${streamId} projectId`), + title: assertString(created.title, `${streamId} title`), + modelSelectionJson: JSON.stringify(created.modelSelection), + runtimeMode: created.runtimeMode, + interactionMode: created.interactionMode, + branch: created.branch ?? null, + worktreePath: created.worktreePath ?? null, + createdAt: created.createdAt, + updatedAt, + latestUserMessageAt, + }, + messages, + activities, + turns: [...turnsById.values()], + }; +} + +export function prepareRuntimeBindingQuery(db) { + const columns = new Set( + db + .prepare("PRAGMA table_info(provider_session_runtime)") + .all() + .map(({ name }) => name), + ); + const providerInstanceColumn = columns.has("provider_instance_id") ? "provider_instance_id," : ""; + return { + hasProviderInstanceId: columns.has("provider_instance_id"), + query: db.prepare(` + SELECT provider_name, ${providerInstanceColumn} resume_cursor_json, runtime_payload_json + FROM provider_session_runtime + WHERE thread_id = ? + `), + }; +} + +export function validateRuntimeBinding(runtime, sourceThreadId, hasProviderInstanceId, streamId) { + const resumeCursor = parseJson(runtime.resume_cursor_json, `${streamId} resume cursor`); + const runtimePayload = parseJson(runtime.runtime_payload_json, `${streamId} runtime payload`); + const importedInstanceId = assertString( + runtimePayload.modelSelection?.instanceId, + `${streamId} provider instance`, + ); + if ( + runtime.provider_name !== "codex" || + resumeCursor.threadId !== sourceThreadId || + runtimePayload.codexImport?.sourceThreadId !== sourceThreadId || + (hasProviderInstanceId && runtime.provider_instance_id !== importedInstanceId) + ) { + throw new Error(`Import stream '${streamId}' has an unexpected runtime binding.`); + } +} + +export function inspectRepair(db) { + const streamRows = db + .prepare(` + SELECT DISTINCT stream_id + FROM orchestration_events + WHERE stream_id LIKE 'codex-import:%' + AND command_id = stream_id + AND correlation_id = stream_id + ORDER BY stream_id + `) + .all(); + if (streamRows.length === 0) throw new Error("No Codex migration streams were found."); + + const importEventsQuery = db.prepare(` + SELECT sequence, event_id, stream_id, stream_version, event_type, occurred_at, + command_id, correlation_id, payload_json + FROM orchestration_events + WHERE stream_id = ? + AND command_id = stream_id + AND correlation_id = stream_id + ORDER BY stream_version + `); + const projectQuery = db.prepare( + "SELECT project_id FROM projection_projects WHERE project_id = ? AND deleted_at IS NULL", + ); + const { hasProviderInstanceId, query: runtimeQuery } = prepareRuntimeBindingQuery(db); + const threadQuery = db.prepare("SELECT * FROM projection_threads WHERE thread_id = ?"); + const messagesQuery = db.prepare( + "SELECT message_id FROM projection_thread_messages WHERE thread_id = ?", + ); + const activitiesQuery = db.prepare( + "SELECT activity_id FROM projection_thread_activities WHERE thread_id = ?", + ); + const turnsQuery = db.prepare("SELECT * FROM projection_turns WHERE thread_id = ?"); + const imported = []; + + for (const { stream_id: streamId } of streamRows) { + const projection = buildImportProjection(importEventsQuery.all(streamId)); + if (projectQuery.all(projection.thread.projectId).length !== 1) { + throw new Error(`Import stream '${streamId}' points at a missing or deleted T3 project.`); + } + const sourceThreadId = streamId.slice(IMPORT_STREAM_PREFIX.length); + const runtimeRows = runtimeQuery.all(streamId); + if (runtimeRows.length !== 1) { + throw new Error(`Import stream '${streamId}' does not have one runtime binding.`); + } + validateRuntimeBinding(runtimeRows[0], sourceThreadId, hasProviderInstanceId, streamId); + + const threadRows = threadQuery.all(streamId); + if (threadRows.length > 1) + throw new Error(`Import stream '${streamId}' has duplicate threads.`); + if (threadRows.length === 1 && threadRows[0].project_id !== projection.thread.projectId) { + throw new Error(`Import stream '${streamId}' is projected into the wrong T3 project.`); + } + const existingMessageIds = new Set(messagesQuery.all(streamId).map((row) => row.message_id)); + const existingActivityIds = new Set( + activitiesQuery.all(streamId).map((row) => row.activity_id), + ); + const existingTurnsById = new Map(turnsQuery.all(streamId).map((turn) => [turn.turn_id, turn])); + const missingMessages = projection.messages.filter( + ({ messageId }) => !existingMessageIds.has(messageId), + ); + const missingActivities = projection.activities.filter( + ({ id }) => !existingActivityIds.has(id), + ); + const missingTurns = projection.turns.filter(({ turnId }) => !existingTurnsById.has(turnId)); + const turnAssistantUpdates = projection.turns.filter((turn) => { + const existing = existingTurnsById.get(turn.turnId); + if (!existing || existing.assistant_message_id === turn.assistantMessageId) return false; + return ( + existing.assistant_message_id === null || + turn.assistantMessageIds.has(existing.assistant_message_id) + ); + }); + imported.push({ + streamId, + projection, + missingThread: threadRows.length === 0, + missingMessages, + missingActivities, + missingTurns, + turnAssistantUpdates, + }); + } + + const incomplete = imported.filter( + ({ missingThread, missingMessages, missingActivities, missingTurns, turnAssistantUpdates }) => + missingThread || + missingMessages.length > 0 || + missingActivities.length > 0 || + missingTurns.length > 0 || + turnAssistantUpdates.length > 0, + ); + return { imported, incomplete, fingerprint: readFingerprint(db) }; +} + +function writeRepair(db, incomplete) { + const insertThread = db.prepare(` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, + settled_override, settled_at, snoozed_until, snoozed_at, pinned_at, pin_order_key, + title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, ?, 0, 0, 0, NULL) + `); + const refreshThreadDates = db.prepare(` + UPDATE projection_threads + SET updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END, + latest_user_message_at = CASE + WHEN ? IS NULL THEN latest_user_message_at + WHEN latest_user_message_at IS NULL OR latest_user_message_at < ? THEN ? + ELSE latest_user_message_at + END + WHERE thread_id = ? + `); + const insertMessage = db.prepare(` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, is_streaming, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?) + `); + const insertActivity = db.prepare(` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertTurn = db.prepare(` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, source_proposed_plan_thread_id, + source_proposed_plan_id, assistant_message_id, state, requested_at, started_at, + completed_at, checkpoint_turn_count, checkpoint_ref, checkpoint_status, + checkpoint_files_json + ) VALUES (?, ?, NULL, NULL, NULL, ?, 'completed', ?, ?, ?, NULL, NULL, NULL, '[]') + `); + const updateTurnAssistant = db.prepare(` + UPDATE projection_turns + SET assistant_message_id = ? + WHERE thread_id = ? AND turn_id = ? + `); + + for (const item of incomplete) { + const { thread } = item.projection; + if (item.missingThread) { + insertThread.run( + thread.threadId, + thread.projectId, + thread.title, + thread.modelSelectionJson, + thread.runtimeMode, + thread.interactionMode, + thread.branch, + thread.worktreePath, + thread.createdAt, + thread.updatedAt, + thread.latestUserMessageAt, + ); + } + for (const message of item.missingMessages) { + insertMessage.run( + message.messageId, + message.threadId, + message.turnId, + message.role, + message.text, + message.createdAt, + message.updatedAt, + ); + } + for (const activity of item.missingActivities) { + insertActivity.run( + activity.id, + thread.threadId, + activity.turnId, + activity.tone, + activity.kind, + activity.summary, + JSON.stringify(activity.payload), + activity.sequence ?? null, + activity.createdAt, + ); + } + for (const turn of item.missingTurns) { + insertTurn.run( + turn.threadId, + turn.turnId, + turn.assistantMessageId, + turn.requestedAt, + turn.startedAt, + turn.completedAt, + ); + } + for (const turn of item.turnAssistantUpdates) { + updateTurnAssistant.run(turn.assistantMessageId, turn.threadId, turn.turnId); + } + if (!item.missingThread) { + refreshThreadDates.run( + thread.updatedAt, + thread.updatedAt, + thread.latestUserMessageAt, + thread.latestUserMessageAt, + thread.latestUserMessageAt, + thread.threadId, + ); + } + } +} + +function sum(items, select) { + return items.reduce((total, item) => total + select(item), 0); +} + +function repairCounts(incomplete) { + return { + insertedThreads: incomplete.filter(({ missingThread }) => missingThread).length, + insertedMessages: sum(incomplete, ({ missingMessages }) => missingMessages.length), + insertedActivities: sum(incomplete, ({ missingActivities }) => missingActivities.length), + insertedTurns: sum(incomplete, ({ missingTurns }) => missingTurns.length), + updatedTurns: sum(incomplete, ({ turnAssistantUpdates }) => turnAssistantUpdates.length), + }; +} + +function verifyRepair(db, before, expected) { + const after = inspectRepair(db); + if (after.incomplete.length !== 0) throw new Error("The projection repair remained incomplete."); + const expectedProjectionCounts = { + threads: before.projectionCounts.threads + expected.insertedThreads, + messages: before.projectionCounts.messages + expected.insertedMessages, + activities: before.projectionCounts.activities + expected.insertedActivities, + turns: before.projectionCounts.turns + expected.insertedTurns, + }; + if ( + after.fingerprint.eventCount !== before.eventCount || + after.fingerprint.runtimeCount !== before.runtimeCount || + !sameValues(after.fingerprint.projectionState, before.projectionState) || + !sameValues(after.fingerprint.projectionCounts, expectedProjectionCounts) + ) { + throw new Error("Protected database state or post-repair counts did not match expectations."); + } + if (db.prepare("PRAGMA foreign_key_check").all().length > 0) { + throw new Error("The repaired database failed foreign_key_check."); + } +} + +export async function repairCodexThreadProjections(options) { + if (!NodeFS.existsSync(options.db)) throw new Error(`Database not found at '${options.db}'.`); + if (options.write) await assertServerStopped(options.db); + const db = new NodeSqlite.DatabaseSync(options.db, { readOnly: !options.write }); + try { + const inspection = inspectRepair(db); + if (inspection.incomplete.length === 0) { + return { alreadyRepaired: true, importedThreadCount: inspection.imported.length }; + } + const counts = repairCounts(inspection.incomplete); + const summary = { + alreadyRepaired: false, + affectedThreadCount: inspection.incomplete.length, + insertedThreadCount: counts.insertedThreads, + insertedMessageCount: counts.insertedMessages, + insertedActivityCount: counts.insertedActivities, + insertedTurnCount: counts.insertedTurns, + updatedTurnCount: counts.updatedTurns, + affectedThreads: inspection.incomplete.map(({ streamId }) => streamId), + }; + if (!options.write) return summary; + + ensureDatabaseExclusive(db); + const backupPath = createBackup(db, options.db); + await assertServerStopped(options.db); + ensureDatabaseExclusive(db); + db.exec("BEGIN IMMEDIATE"); + try { + const lockedInspection = inspectRepair(db); + if (!sameValues(lockedInspection.fingerprint, inspection.fingerprint)) { + throw new Error("The T3 database changed after preflight. No repair was applied."); + } + const lockedCounts = repairCounts(lockedInspection.incomplete); + if (!sameValues(lockedCounts, counts)) { + throw new Error("The required repair changed after preflight. No repair was applied."); + } + writeRepair(db, lockedInspection.incomplete); + verifyRepair(db, inspection.fingerprint, counts); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + return { ...summary, backupPath }; + } finally { + db.close(); + } +} + +async function main() { + try { + const options = parseRepairArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + const result = await repairCodexThreadProjections(options); + console.log(JSON.stringify(result, null, 2)); + if (!options.write && !result.alreadyRepaired) { + console.log("Dry run only. Re-run with --write after fully quitting T3 Code."); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error("\n" + usage()); + process.exitCode = 1; + } +} + +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { + await main(); +} diff --git a/scripts/repair-codex-thread-projections.test.mjs b/scripts/repair-codex-thread-projections.test.mjs new file mode 100644 index 000000000000..4395f0849c66 --- /dev/null +++ b/scripts/repair-codex-thread-projections.test.mjs @@ -0,0 +1,172 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTest from "node:test"; + +import { + buildImportProjection, + parseRepairArgs, + prepareRuntimeBindingQuery, + validateRuntimeBinding, +} from "./repair-codex-thread-projections.mjs"; + +const streamId = "codex-import:source-thread"; + +function row(streamVersion, eventType, payload, occurredAt) { + return { + sequence: streamVersion + 1, + event_id: `event-${streamVersion}`, + stream_id: streamId, + stream_version: streamVersion, + event_type: eventType, + occurred_at: occurredAt, + command_id: streamId, + correlation_id: streamId, + payload_json: JSON.stringify(payload), + }; +} + +NodeTest.test("parses a dry-run repair by default", () => { + const options = parseRepairArgs(["--db", "C:\\tmp\\state.sqlite"]); + NodeAssert.equal(options.write, false); + NodeAssert.equal(options.db, "C:\\tmp\\state.sqlite"); +}); + +NodeTest.test("builds the imported message, activity, and turn projections", () => { + const projection = buildImportProjection([ + row( + 0, + "thread.created", + { + threadId: streamId, + projectId: "project-1", + title: "Imported task", + modelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + "2026-08-01T00:00:00.000Z", + ), + row( + 1, + "thread.message-sent", + { + messageId: "user-1", + threadId: streamId, + turnId: "turn-1", + role: "user", + text: "Hello", + streaming: false, + createdAt: "2026-08-01T00:00:01.000Z", + updatedAt: "2026-08-01T00:00:01.000Z", + }, + "2026-08-01T00:00:01.000Z", + ), + row( + 2, + "thread.activity-appended", + { + threadId: streamId, + activity: { + id: "activity-1", + turnId: "turn-1", + tone: "info", + kind: "codex.reasoning", + summary: "Thinking", + payload: {}, + createdAt: "2026-08-01T00:00:02.000Z", + }, + }, + "2026-08-01T00:00:02.000Z", + ), + row( + 3, + "thread.message-sent", + { + messageId: "assistant-1", + threadId: streamId, + turnId: "turn-1", + role: "assistant", + text: "Hi", + streaming: false, + createdAt: "2026-08-01T00:00:03.000Z", + updatedAt: "2026-08-01T00:00:03.000Z", + }, + "2026-08-01T00:00:03.000Z", + ), + ]); + NodeAssert.equal(projection.messages.length, 2); + NodeAssert.equal(projection.activities.length, 1); + NodeAssert.equal(projection.turns.length, 1); + NodeAssert.equal(projection.turns[0].assistantMessageId, "assistant-1"); + NodeAssert.equal(projection.thread.latestUserMessageAt, "2026-08-01T00:00:01.000Z"); + NodeAssert.equal(projection.thread.updatedAt, "2026-08-01T00:00:03.000Z"); +}); + +NodeTest.test("rejects rows that are not part of the original import command", () => { + const rows = [ + row( + 0, + "thread.created", + { + threadId: streamId, + projectId: "project-1", + title: "Imported task", + modelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + "2026-08-01T00:00:00.000Z", + ), + ]; + rows[0].command_id = "later-live-command"; + NodeAssert.throws(() => buildImportProjection(rows), /outside its original import batch/u); +}); + +NodeTest.test("reads legacy runtime bindings without provider_instance_id", () => { + const db = new NodeSqlite.DatabaseSync(":memory:"); + try { + db.exec(` + CREATE TABLE provider_session_runtime ( + thread_id TEXT PRIMARY KEY, + provider_name TEXT NOT NULL, + resume_cursor_json TEXT NOT NULL, + runtime_payload_json TEXT NOT NULL + ); + INSERT INTO provider_session_runtime VALUES ( + 'codex-import:source-thread', + 'codex', + '{"threadId":"source-thread"}', + '{"modelSelection":{"instanceId":"custom-codex"},"codexImport":{"sourceThreadId":"source-thread"}}' + ); + `); + const prepared = prepareRuntimeBindingQuery(db); + NodeAssert.equal(prepared.hasProviderInstanceId, false); + const runtime = prepared.query.get("codex-import:source-thread"); + NodeAssert.doesNotThrow(() => + validateRuntimeBinding(runtime, "source-thread", false, "codex-import:source-thread"), + ); + } finally { + db.close(); + } +}); + +NodeTest.test("accepts a matching custom provider instance", () => { + const runtime = { + provider_name: "codex", + provider_instance_id: "work-codex", + resume_cursor_json: JSON.stringify({ threadId: "source-thread" }), + runtime_payload_json: JSON.stringify({ + modelSelection: { instanceId: "work-codex" }, + codexImport: { sourceThreadId: "source-thread" }, + }), + }; + NodeAssert.doesNotThrow(() => + validateRuntimeBinding(runtime, "source-thread", true, "codex-import:source-thread"), + ); +}); From e01d417e3b8ad7cfc9797aa73dc0983e2c5bbe31 Mon Sep 17 00:00:00 2001 From: aoright <102943475+aoright@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:38:55 +0800 Subject: [PATCH 02/10] refactor(server): simplify error transformation with Effect.mapError in GitHubPullRequestCli (#7385) Signed-off-by: aoright <102943475+aoright@users.noreply.github.com> --- apps/server/src/pullRequest/GitHubPullRequestCli.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 6272737d4a82..2084a50d0206 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1451,8 +1451,7 @@ export const make = Effect.gen(function* () { // the page. Narrowed to a command that ran and was refused: a missing `gh` or a // signed-out one fails the same way for every request. Effect.catchTags({ - GitHubCliCommandError: (error) => - filesPage(1).pipe(Effect.catch(() => Effect.fail(error))), + GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), }), ); }, From 22879bc8a964d4d623f6c676f620f18ee2095f8d Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Tue, 18 Aug 2026 19:39:08 +0200 Subject: [PATCH 03/10] fix(preview): open local environment ports on localhost (#7300) --- apps/web/src/browser/browserTargetResolver.test.ts | 14 +++++++++++++- apps/web/src/browser/browserTargetResolver.ts | 10 +++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 558924b63da6..cbce157f9a05 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -173,7 +173,19 @@ describe("browser target resolver", () => { kind: "environment-port", port: 5173, }).resolvedUrl, - ).toBe("http://[::1]:5173/"); + ).toBe("http://localhost:5173/"); + }); + + it("maps local IPv4 environment ports onto localhost for dual-stack guests", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/app", + }).resolvedUrl, + ).toBe("http://localhost:5173/app"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 149248d17609..684247e28022 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -178,9 +178,13 @@ const resolveEnvironmentPortTarget = ( const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); - const resolvedHost = normalizedEnvironmentHost.includes(":") - ? `[${normalizedEnvironmentHost}]` - : normalizedEnvironmentHost; + // Local loopback environments should advertise `localhost` so Chromium + // dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1. + const resolvedHost = isLocalLoopbackHost(normalizedEnvironmentHost) + ? "localhost" + : normalizedEnvironmentHost.includes(":") + ? `[${normalizedEnvironmentHost}]` + : normalizedEnvironmentHost; const resolved = sourceUrl ? new URL(sourceUrl) : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); From 0ae7a3dc8f5ba99038ea8b5cf094d40783f70544 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:41:07 +0300 Subject: [PATCH 04/10] fix(desktop): prevent quit shortcut spillover (#7397) --- apps/desktop/src/window/QuitHold.test.ts | 30 ++++++++++++---- apps/desktop/src/window/QuitHold.ts | 46 +++++++++++++++++------- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index c900a865439e..75fed4b08f21 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -77,17 +77,32 @@ describe("makeQuitHoldHandler", () => { expect(harness.notifications).toEqual(["down", "up"]); }); - it("quits once the shortcut auto-repeats past the hold duration", async () => { + it("quits after a completed hold is released", async () => { const harness = makeHarness(); await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); expect(harness.quit).not.toHaveBeenCalled(); - await harness.holdFor(400); + vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - // Exactly one hint cycle for the whole hold. expect(harness.notifications).toEqual(["down", "up"]); }); + it("waits for Q release when Cmd is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + harness.preventDefault.mockClear(); + await harness.send(makeInput({ meta: false, isAutoRepeat: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 2); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.send(makeInput({ type: "keyUp", meta: false })); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + it("does not quit when the hold stops before the duration", async () => { const harness = makeHarness(); await harness.send(makeInput({})); @@ -107,12 +122,11 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits immediately on a single press when disabled", async () => { + it("quits without showing a hint when hold-to-quit is disabled", async () => { const harness = makeHarness({ enabled: false }); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); - // The hint is dismissed in case the quit gets cancelled downstream. - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([]); }); it("discards a stale isEnabled resolution from a superseded press", async () => { @@ -138,6 +152,7 @@ describe("makeQuitHoldHandler", () => { // Press #2 resolves enabled and completes a full hold. resolvers[1]?.(true); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); @@ -196,6 +211,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({ meta: false, control: true })); expect(harness.preventDefault).toHaveBeenCalledTimes(1); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); expect(harness.quit).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index ea2fc7854ac5..885770accfa2 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -2,7 +2,8 @@ // Chrome-style hold-to-quit. The quit accelerator is intercepted in // before-input-event (which runs before the native menu accelerator), and the -// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS +// and released. // A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap // within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application // menu itself is untouched and quits immediately. @@ -10,11 +11,11 @@ export const QUIT_HOLD_DURATION_MS = 1200; // A second quick tap of the shortcut is the user insisting: quit immediately. export const QUIT_DOUBLE_TAP_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a -// release: macOS suppresses a letter's keyUp while the command key is down, so -// a tap's release can go completely unseen and a release-based timer would -// quit anyway. The press is treated as released once no key event has arrived -// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with -// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +// release: macOS suppresses a letter keyUp while the command key is down, so a +// tap release can go completely unseen and a release-based timer would quit +// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after +// modifier keyUp so repeats cannot reach the next app. Keyboards with +// auto-repeat disabled fall back to the application menu Quit action. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; export type QuitHoldState = "down" | "up"; @@ -42,8 +43,9 @@ export function makeQuitHoldHandler( const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; - // Set once isEnabled resolves true; auto-repeats may only quit when armed. + // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. let armed = false; + let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; // Incremented on every new press and every release/quit so a pending @@ -60,14 +62,16 @@ export function makeQuitHoldHandler( const release = () => { if (!holding) return; + const shouldNotify = armed || quitOnRelease; generation += 1; holding = false; armed = false; + quitOnRelease = false; clearWatchdog(); - options.notify("up"); + if (shouldNotify) options.notify("up"); }; - // Dismisses the overlay first: if the quit is cancelled downstream the + // Dismisses any overlay first: if the quit is cancelled downstream the // renderer must not be left with a stuck "Hold to Quit" hint. const quitNow = () => { release(); @@ -77,11 +81,27 @@ export function makeQuitHoldHandler( return (event, input) => { const key = input.key.toLowerCase(); if (input.type === "keyUp") { - if (key === "q" || key === modifierKey) release(); + if (key === "q") { + const shouldQuit = quitOnRelease; + release(); + if (shouldQuit) options.quit(); + } else if (key === modifierKey) { + if (!quitOnRelease) { + release(); + } else { + watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); + } + } return; } if (input.type !== "keyDown") return; + if (quitOnRelease && input.isAutoRepeat && key === "q") { + event.preventDefault(); + clearWatchdog(); + return; + } + const modifierDown = options.platform === "darwin" ? input.meta : input.control; if (!modifierDown || input.alt || input.shift || key !== "q") { // Any other key (or an extra modifier) pressed mid-hold breaks the @@ -101,7 +121,9 @@ export function makeQuitHoldHandler( if (input.isAutoRepeat) { if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { - quitNow(); + armed = false; + quitOnRelease = true; + clearWatchdog(); } return; } @@ -121,7 +143,6 @@ export function makeQuitHoldHandler( const pressGeneration = generation; holding = true; heldSince = now; - options.notify("down"); void options.isEnabled().then( (enabled) => { if (generation !== pressGeneration) return; @@ -131,6 +152,7 @@ export function makeQuitHoldHandler( return; } armed = true; + options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { From c7171650df88a9db96fd7488cb69135b285bf1ed Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:13:18 +0530 Subject: [PATCH 05/10] fix(desktop): stop overwriting a custom dock icon on launch (#7125) --- .../src/app/DesktopAppIdentity.test.ts | 28 ++++++++++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 5 +++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index da767a0370ca..5c39ff304b3b 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -199,7 +199,9 @@ describe("DesktopAppIdentity", () => { assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); - assert.deepEqual(calls.setDockIcon, ["/icon.png"]); + // Packaged: the bundle's own icon stands, so a custom one the user + // attached survives. + assert.deepEqual(calls.setDockIcon, []); }), { calls, @@ -212,4 +214,28 @@ describe("DesktopAppIdentity", () => { }, ); }); + + it.effect("sets the dock icon only when running unpackaged", () => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + // Electron shows a generic icon for an unpackaged run, which is the + // reason this call exists at all. + assert.deepEqual(calls.setDockIcon, ["/icon.png"]); + }), + { + calls, + environment: { isPackaged: false }, + pngIconPath: Option.some("/icon.png"), + }, + ); + }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 0be55d633e61..c5adb8574a53 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -134,7 +134,10 @@ export const make = Effect.gen(function* () { yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); } - if (environment.platform === "darwin") { + // Unpackaged runs only. A packaged bundle already carries its icon in + // Info.plist, so setting the dock tile again changes nothing except to + // overwrite a custom icon the user attached to the app themselves. + if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { onNone: () => Effect.void, From 64b577905b671585291f27ab5b33a624f72b9598 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:33:04 +0200 Subject: [PATCH 06/10] feat(web): show project location in new thread picker (#7392) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/components/CommandPalette.logic.ts | 3 +- apps/web/src/components/CommandPalette.tsx | 59 ++++++++++++++++++- .../src/components/ThreadCommandSubtitle.tsx | 16 ++--- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ed758830f4a1..1fddb4f92f4a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -150,6 +150,7 @@ export function buildProjectActionItems(input: { icon: (project: Project) => ReactNode; runProject: (project: Project) => Promise; searchTerms?: (project: Project) => ReadonlyArray; + renderDescription?: (project: Project) => ReactNode; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -157,7 +158,7 @@ export function buildProjectActionItems(input: { value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], title: project.title, - description: project.workspaceRoot, + description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 410be73b420a..4a90f1a50343 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -41,6 +41,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + ServerIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -131,7 +132,11 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; -import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; +import { + COMMAND_PALETTE_META_ICON_CLASS, + CommandPaletteMetaDot, + ThreadCommandSubtitle, +} from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { @@ -657,6 +662,27 @@ function OpenCommandPaletteDialog(props: { ), [environments], ); + const projectEnvironmentLocationById = useMemo( + () => + new Map( + environments.map((environment) => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target); + return [ + environment.environmentId, + { + kind: isLocal ? "local" : "remote", + label: isPrimary + ? "Local" + : isLocal + ? `${environment.label} (Local)` + : environment.label, + }, + ] as const; + }), + ), + [environments], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1011,8 +1037,29 @@ function OpenCommandPaletteDialog(props: { valuePrefix: "new-thread-in", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => { + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote", + label: "Remote", + }; return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} + + + {project.workspaceRoot} + ); }, icon: projectFavicon, @@ -1033,7 +1080,13 @@ function OpenCommandPaletteDialog(props: { }, }), ), - [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], + [ + contextualProjectRef, + handleNewThread, + pickerProjects, + projectEnvironmentLocationById, + projectGroupByTargetKey, + ], ); const allThreadItems = useMemo( diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index b190384a6fa8..015b15c5ea04 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -18,20 +18,20 @@ export type ThreadCommandSubtitleVariant = export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = "favicon-workspace-harness"; -const META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; +export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; -function Dot() { +export function CommandPaletteMetaDot() { return ยท; } function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktree: boolean }) { if (props.isWorktree) { - return ; + return ; } if (props.variant === "favicon-branch-harness") { - return ; + return ; } - return ; + return ; } export function ThreadCommandSubtitle(props: { @@ -82,7 +82,7 @@ export function ThreadCommandSubtitle(props: { {branchLabel ? ( <> - {projectLabel ? : null} + {projectLabel ? : null} {branchLabel} @@ -92,7 +92,7 @@ export function ThreadCommandSubtitle(props: { {showHarness && props.driverKind ? ( <> - {projectLabel || branchLabel ? : null} + {projectLabel || branchLabel ? : null} - {projectLabel || branchLabel || showHarness ? : null} + {projectLabel || branchLabel || showHarness ? : null} Current thread ) : null} From 0c7d821b10ab17375a522b2b3711c05453e696e4 Mon Sep 17 00:00:00 2001 From: Augie Date: Tue, 18 Aug 2026 13:51:55 -0500 Subject: [PATCH 07/10] fix(packaging): install AUR launcher icons where icon themes look (#7421) --- packaging/aur/scripts/release.sh | 6 ------ packaging/aur/t3code-bin/PKGBUILD | 11 +++++++---- packaging/aur/t3code-nightly-bin/PKGBUILD | 11 +++++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packaging/aur/scripts/release.sh b/packaging/aur/scripts/release.sh index 427ca698ad1a..be07391db6e7 100755 --- a/packaging/aur/scripts/release.sh +++ b/packaging/aur/scripts/release.sh @@ -8,10 +8,8 @@ pkgrel="${PKGREL:-1}" if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then pkgname='t3code-bin' - icon_path='assets/prod/black-universal-1024.png' elif [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then pkgname='t3code-nightly-bin' - icon_path='assets/nightly/nightly-universal-1024.png' else echo "Release $tag does not publish an AUR package." exit 0 @@ -32,11 +30,8 @@ fi work_dir="$(mktemp -d)" trap 'rm -rf -- "$work_dir"' EXIT -gh api -H 'Accept: application/vnd.github.raw' \ - "repos/$repo/contents/$icon_path?ref=$tag" > "$work_dir/icon.png" gh api -H 'Accept: application/vnd.github.raw' \ "repos/$repo/contents/LICENSE?ref=$tag" > "$work_dir/LICENSE" -icon_sha256="$(sha256sum "$work_dir/icon.png" | awk '{print $1}')" license_sha256="$(sha256sum "$work_dir/LICENSE" | awk '{print $1}')" package_dir="$repo_root/packaging/aur/$pkgname" @@ -45,7 +40,6 @@ sed -Ei \ -e "s/^pkgver=.*/pkgver=$pkgver/" \ -e "s/^pkgrel=.*/pkgrel=$pkgrel/" \ -e "/# AppImage$/s/'[0-9a-f]{64}'/'$appimage_sha256'/" \ - -e "/# icon$/s/'[0-9a-f]{64}'/'$icon_sha256'/" \ -e "/# upstream license$/s/'[0-9a-f]{64}'/'$license_sha256'/" \ PKGBUILD diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD index 0f3d76284139..c5219666bf08 100644 --- a/packaging/aur/t3code-bin/PKGBUILD +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -46,12 +46,10 @@ options=('!debug' '!strip') _appimage="T3-Code-${pkgver}-x86_64.AppImage" source=( "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/$_appimage" - "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/assets/prod/black-universal-1024.png" "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/LICENSE" ) sha256sums=( '415c8648f43c3d22d572f27f2c50fdc8c310ea7fcde9537b903e1e2f1c8775a1' # AppImage - '403e874556ffbecee8d1b2b5d612a874303fac791212a261bb3bd1b71d83e78d' # icon '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license ) @@ -79,8 +77,13 @@ exec /opt/t3code-bin/AppRun "$@" EOF ln -s t3code "$pkgdir/usr/bin/t3-code-desktop" - install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ - "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code.png" + # Icon lookup only sees sizes registered in hicolor's index.theme (max 512x512). + local icon size_dir + for icon in "$srcdir"/squashfs-root/usr/share/icons/hicolor/*/apps/t3code.png; do + size_dir="${icon%/apps/t3code.png}" + install -Dm644 "$icon" \ + "$pkgdir/usr/share/icons/hicolor/${size_dir##*/}/apps/t3code.png" + done install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' [Desktop Entry] diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD index 76704be5ef5c..f3b61c7d5223 100644 --- a/packaging/aur/t3code-nightly-bin/PKGBUILD +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -47,12 +47,10 @@ _upstream_version="${pkgver/_nightly./-nightly.}" _appimage="T3-Code-${_upstream_version}-x86_64.AppImage" source=( "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${_upstream_version}/$_appimage" - "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/assets/nightly/nightly-universal-1024.png" "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/LICENSE" ) sha256sums=( 'c4dea5bba9ed0b51b2f60f2d4a4867e61d62b57c50ea66f2792a73112e054566' # AppImage - '7e59b6394016ef83ed1e946847769e01bf36d4062c5c5af2577fd3e228285fd9' # icon '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license ) @@ -80,8 +78,13 @@ exec /opt/t3code-nightly-bin/AppRun "$@" EOF ln -s t3code-nightly "$pkgdir/usr/bin/t3-code-nightly-desktop" - install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ - "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code-nightly.png" + # Icon lookup only sees sizes registered in hicolor's index.theme (max 512x512). + local icon size_dir + for icon in "$srcdir"/squashfs-root/usr/share/icons/hicolor/*/apps/t3code.png; do + size_dir="${icon%/apps/t3code.png}" + install -Dm644 "$icon" \ + "$pkgdir/usr/share/icons/hicolor/${size_dir##*/}/apps/t3code-nightly.png" + done install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' [Desktop Entry] From 1915f474e868ff1c83977bfe46d8883c5efe12bf Mon Sep 17 00:00:00 2001 From: sameerr03 Date: Sun, 6 Sep 2026 23:52:37 +0530 Subject: [PATCH 08/10] fix(web): keep project favicon shape consistent across sizes The favicon used a fixed 6px radius, so the 12px tooltip icon rendered as a circle while the 16px sidebar icon rendered as a squircle. Use a proportional radius so every surface shows the same squircle. Made with Claude Fable 5.1 via Claude Code. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ProjectFavicon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..2ad221c84ecf 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -92,7 +92,7 @@ function ProjectFaviconImage({ handleLoadError(displayedSrc)} /> ) : null} From 4e2b75156e2fd3f2ad7479a6b32e638014f2ba77 Mon Sep 17 00:00:00 2001 From: sameerr03 Date: Mon, 7 Sep 2026 02:39:33 +0530 Subject: [PATCH 09/10] fix(web): match tooltip and breadcrumb favicon shape to sidebar row Keep the sidebar row favicon unchanged and give the thread tooltip and breadcrumb favicons the same radius-to-size ratio, so they read as the same squircle instead of circles. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ProjectFavicon.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 2ad221c84ecf..619bbf370018 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -92,7 +92,7 @@ function ProjectFaviconImage({ handleLoadError(displayedSrc)} /> ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d7285d7490e6..f23db70561bf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -300,7 +300,7 @@ function SidebarThreadTooltip({ environmentId={thread.environmentId} cwd={projectCwd ?? ""} faviconPath={projectFaviconPath} - className="size-3 shrink-0 stroke-muted-foreground" + className="size-3 shrink-0 rounded-[37.5%] stroke-muted-foreground" />
{projectTitle}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 08e0422dd255..4184b5fdd1bd 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -251,7 +251,7 @@ export const ChatHeader = memo(function ChatHeader({ environmentId={activeThreadEnvironmentId} cwd={activeProjectCwd ?? ""} faviconPath={activeProjectFaviconPath} - className="size-3.5" + className="size-3.5 rounded-[37.5%]" /> {activeProjectName} From e102e108a491379cc49cb76b9a307429a075e4d0 Mon Sep 17 00:00:00 2001 From: sameerr03 Date: Mon, 7 Sep 2026 15:56:48 +0530 Subject: [PATCH 10/10] fix(web): centralize proportional project favicon radius --- apps/web/src/components/ProjectFavicon.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..9b07633b579d 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -92,7 +92,7 @@ function ProjectFaviconImage({ handleLoadError(displayedSrc)} /> ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f23db70561bf..d7285d7490e6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -300,7 +300,7 @@ function SidebarThreadTooltip({ environmentId={thread.environmentId} cwd={projectCwd ?? ""} faviconPath={projectFaviconPath} - className="size-3 shrink-0 rounded-[37.5%] stroke-muted-foreground" + className="size-3 shrink-0 stroke-muted-foreground" />
{projectTitle}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 4184b5fdd1bd..08e0422dd255 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -251,7 +251,7 @@ export const ChatHeader = memo(function ChatHeader({ environmentId={activeThreadEnvironmentId} cwd={activeProjectCwd ?? ""} faviconPath={activeProjectFaviconPath} - className="size-3.5 rounded-[37.5%]" + className="size-3.5" /> {activeProjectName}