diff --git a/packages/core/src/__tests__/opencode-session-id.test.ts b/packages/core/src/__tests__/opencode-session-id.test.ts new file mode 100644 index 000000000..6ae33ab23 --- /dev/null +++ b/packages/core/src/__tests__/opencode-session-id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { asValidOpenCodeSessionId } from "../opencode-session-id.js"; + +describe("asValidOpenCodeSessionId", () => { + it("accepts valid OpenCode session ids", () => { + expect(asValidOpenCodeSessionId("ses_abc123")).toBe("ses_abc123"); + expect(asValidOpenCodeSessionId(" ses_ABC-123_xyz ")).toBe("ses_ABC-123_xyz"); + }); + + it("rejects invalid OpenCode session ids", () => { + expect(asValidOpenCodeSessionId("")).toBeUndefined(); + expect(asValidOpenCodeSessionId("ses bad")).toBeUndefined(); + expect(asValidOpenCodeSessionId("abc123")).toBeUndefined(); + expect(asValidOpenCodeSessionId(123)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 2063820f1..ea0a3a550 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -1969,6 +1969,34 @@ describe("remap", () => { const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_discovered"); }); + + it("prefers most recently updated title match when remapping", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-remap-ordering.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { id: "ses_old", title: "AO:app-1", updated: "2025-01-01T00:00:00.000Z" }, + { id: "ses_new", title: "AO:app-1", updated: 1_772_777_328_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1"); + + expect(mapped).toBe("ses_new"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_new"); + }); }); describe("spawnOrchestrator", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2f504fbdc..ea62eec0a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,7 @@ export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; // Shared utilities export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js"; +export { asValidOpenCodeSessionId } from "./opencode-session-id.js"; // Path utilities — hash-based directory structure export { diff --git a/packages/core/src/opencode-session-id.ts b/packages/core/src/opencode-session-id.ts new file mode 100644 index 000000000..f8a08034a --- /dev/null +++ b/packages/core/src/opencode-session-id.ts @@ -0,0 +1,8 @@ +const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/; + +export function asValidOpenCodeSessionId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined; +} diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 33fa1274a..51cef878e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -60,17 +60,19 @@ import { generateConfigHash, validateAndStoreOrigin, } from "./paths.js"; +import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; const execFileAsync = promisify(execFile); const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000; const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; -const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/; -function asValidOpenCodeSessionId(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - if (trimmed.length === 0) return undefined; - return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined; +function scoreOpenCodeUpdated(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return -1; } function errorIncludesSessionNotFound(err: unknown): boolean { @@ -115,11 +117,13 @@ async function discoverOpenCodeSessionIdsByTitle( const parsed = safeJsonParse>>(stdout); if (!parsed) return []; const title = `AO:${sessionId}`; - const candidates = parsed.filter((entry) => { - const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : ""; - const candidateId = typeof entry["id"] === "string" ? entry["id"] : ""; - return candidateTitle === title && candidateId.length > 0; - }); + const candidates = parsed + .filter((entry) => { + const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : ""; + const candidateId = typeof entry["id"] === "string" ? entry["id"] : ""; + return candidateTitle === title && candidateId.length > 0; + }) + .sort((a, b) => scoreOpenCodeUpdated(b["updated"]) - scoreOpenCodeUpdated(a["updated"])); return candidates .map((entry) => asValidOpenCodeSessionId(entry["id"])) diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index ebe60fdc8..f60384123 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -1,5 +1,6 @@ import { shellEscape, + asValidOpenCodeSessionId, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -20,15 +21,6 @@ interface OpenCodeSessionListEntry { updated?: string; } -const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/; - -function asValidOpenCodeSessionId(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - if (trimmed.length === 0) return undefined; - return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined; -} - function parseSessionList(raw: string): OpenCodeSessionListEntry[] { let parsed: unknown; try { diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index a4cd2a7c7..48ae326a4 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -56,6 +56,7 @@ export function DirectTerminal({ const [status, setStatus] = useState<"connecting" | "connected" | "error">("connecting"); const [error, setError] = useState(null); const [reloading, setReloading] = useState(false); + const [reloadError, setReloadError] = useState(null); const [resolvedReloadCommand, setResolvedReloadCommand] = useState(null); // Update URL when fullscreen changes @@ -74,6 +75,7 @@ export function DirectTerminal({ async function handleReload(): Promise { if (!isOpenCodeSession || reloading) return; + setReloadError(null); setReloading(true); try { let commandToSend = resolvedReloadCommand ?? reloadCommand; @@ -96,11 +98,16 @@ export function DirectTerminal({ setResolvedReloadCommand(commandToSend); } - await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { + const sendRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: commandToSend }), }); + if (!sendRes.ok) { + throw new Error(`Failed to send reload command: ${sendRes.status}`); + } + } catch (err) { + setReloadError(err instanceof Error ? err.message : "Failed to reload OpenCode session"); } finally { setReloading(false); } @@ -590,6 +597,14 @@ export function DirectTerminal({ )} ) : null} + {reloadError ? ( + + {reloadError} + + ) : null}