From 7ce11b8cb25a22512668adae3b5a7da715964e78 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 1 Jul 2026 07:57:08 +0000 Subject: [PATCH 01/14] feat(session-ui): add message metadata formatters Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/components/message-actions.ts | 8 ++ .../src/components/message-part.test.ts | 105 ++++++++++++++++++ .../session-ui/src/components/tool-meta.ts | 33 ++++++ 3 files changed, 146 insertions(+) create mode 100644 packages/session-ui/src/components/message-actions.ts create mode 100644 packages/session-ui/src/components/tool-meta.ts diff --git a/packages/session-ui/src/components/message-actions.ts b/packages/session-ui/src/components/message-actions.ts new file mode 100644 index 000000000000..ded302e0a9c1 --- /dev/null +++ b/packages/session-ui/src/components/message-actions.ts @@ -0,0 +1,8 @@ +export function handleCopyResponseClick(event: Pick, copy: () => void) { + event.stopPropagation() + copy() +} + +export function formatMessageStamp(locale: string, value: number) { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(value) +} diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 25dcbae6b404..2aa19e8fe24e 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,5 +1,19 @@ import { describe, expect, test } from "bun:test" +import type { UiI18n } from "@opencode-ai/ui/context/i18n" +import { formatMessageStamp, handleCopyResponseClick } from "./message-actions" import { readPartText } from "./message-part-text" +import { toolMeta } from "./tool-meta" + +const i18n: UiI18n = { + locale: () => "en-US", + t: (key, values) => { + if (key === "ui.message.duration.seconds") return `${values?.count}s` + if (key === "ui.message.duration.minutesSeconds") return `${values?.minutes}m ${values?.seconds}s` + if (key === "ui.message.interrupted") return "Interrupted" + if (key === "ui.toolErrorCard.failed") return "Failed" + return key + }, +} describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { @@ -26,3 +40,94 @@ describe("readPartText", () => { expect(readPartText(undefined, { id: "part_1", text: "\n body \n" })).toBe("body") }) }) + +describe("toolMeta", () => { + test("formats completed tool start time and duration", () => { + expect( + toolMeta(i18n, { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }), + ).toBe("2:03 PM · 5s") + }) + + test("formats running tool start time and live duration", () => { + expect( + toolMeta(i18n, { + status: "running", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }), + ).toBe("2:03 PM · 7s") + }) + + test("shows interrupted state without duration", () => { + expect( + toolMeta(i18n, { + status: "interrupted", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }), + ).toBe("2:03 PM · Interrupted") + }) + + test("returns empty meta for pending tool without start time", () => { + expect( + toolMeta(i18n, { + status: "pending", + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }), + ).toBe("") + }) + + test("formats error tool start time and failed state", () => { + expect( + toolMeta(i18n, { + status: "error", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }), + ).toBe("2:03 PM · Failed") + }) + + test("formats minute durations", () => { + expect( + toolMeta(i18n, { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 4, 5), + now: Date.UTC(2026, 5, 28, 14, 4, 8), + }), + ).toBe("2:03 PM · 1m 5s") + }) +}) + +describe("handleCopyResponseClick", () => { + test("stops timeline click propagation before copying the response", () => { + let stopped = false + let copied = false + + handleCopyResponseClick( + { + stopPropagation: () => { + stopped = true + }, + }, + () => { + copied = stopped + }, + ) + + expect(stopped).toBe(true) + expect(copied).toBe(true) + }) +}) + +describe("formatMessageStamp", () => { + test("formats message date and time", () => { + expect(formatMessageStamp("en-US", Date.UTC(2026, 5, 28, 14, 3, 0))).toBe("Jun 28, 2026, 2:03 PM") + }) +}) diff --git a/packages/session-ui/src/components/tool-meta.ts b/packages/session-ui/src/components/tool-meta.ts new file mode 100644 index 000000000000..69e6e572dee2 --- /dev/null +++ b/packages/session-ui/src/components/tool-meta.ts @@ -0,0 +1,33 @@ +import type { UiI18n } from "@opencode-ai/ui/context/i18n" + +export type ToolMetaInput = { + status: "pending" | "running" | "completed" | "error" | "interrupted" + start?: number + end?: number + now?: number +} + +export function toolMeta(i18n: UiI18n, input: ToolMetaInput) { + const stamp = typeof input.start === "number" ? formatTime(i18n.locale(), input.start) : "" + if (input.status === "interrupted") return [stamp, i18n.t("ui.message.interrupted")].filter(Boolean).join(" · ") + if (input.status === "error") return [stamp, i18n.t("ui.toolErrorCard.failed")].filter(Boolean).join(" · ") + + const end = input.status === "completed" ? input.end : input.now + const duration = typeof input.start === "number" && typeof end === "number" ? formatDuration(i18n, end - input.start) : "" + return [stamp, duration].filter(Boolean).join(" · ") +} + +function formatTime(locale: string, value: number) { + return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(value) +} + +function formatDuration(i18n: UiI18n, value: number) { + if (value < 0) return "" + const total = Math.round(value / 1000) + if (total < 60) return i18n.t("ui.message.duration.seconds", { count: String(total) }) + + return i18n.t("ui.message.duration.minutesSeconds", { + minutes: String(Math.floor(total / 60)), + seconds: String(total % 60), + }) +} From 7ade51893c88293b0758b142c1f06fd292a38a27 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 1 Jul 2026 08:20:22 +0000 Subject: [PATCH 02/14] feat(session-ui): expose tool metadata slots Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session-ui/src/components/basic-tool.css | 16 +++++++++++++++- .../session-ui/src/components/basic-tool.tsx | 6 ++++++ .../src/components/tool-error-card.tsx | 7 +++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 7df483afe654..bda6aa1d9abd 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -163,6 +163,19 @@ color: var(--v2-text-text-muted); } + [data-slot="basic-tool-tool-meta"] { + flex-shrink: 0; + margin-left: auto; + font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; + font-size: 12px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-small); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-weak); + } + [data-slot="basic-tool-tool-action"] { display: inline-flex; align-items: center; @@ -260,7 +273,8 @@ body:not([data-new-layout]) { [data-slot="basic-tool-tool-subtitle"], [data-slot="basic-tool-tool-subtitle"].clickable:hover, - [data-slot="basic-tool-tool-arg"] { + [data-slot="basic-tool-tool-arg"], + [data-slot="basic-tool-tool-meta"] { color: var(--text-base); } } diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index 4f2a4ced6f8a..1bb8e5d88581 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -25,6 +25,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { export interface BasicToolProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) + meta?: string children?: JSX.Element status?: string hideDetails?: boolean @@ -244,6 +245,11 @@ export function BasicTool(props: BasicToolProps) { {props.trigger as JSX.Element} + + + {props.meta} + + diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a275313..e351a185db9d 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit, "c onOpenChange?: (open: boolean) => void subtitle?: string href?: string + meta?: string } export function ToolErrorCard(props: ToolErrorCardProps) { @@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { "onOpenChange", "subtitle", "href", + "meta", ]) const setOpen = (value: boolean) => { if (props.open === undefined) setState("open", value) @@ -119,6 +121,11 @@ export function ToolErrorCard(props: ToolErrorCardProps) { + + + {split.meta} + + From 149eefc191b5004a28560310d6e5b942cb574041 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 1 Jul 2026 08:20:48 +0000 Subject: [PATCH 03/14] feat(session-ui): render message timeline metadata Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/components/message-part.tsx | 78 ++++++++++++++++--- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 4af05c2584a8..074824a79474 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -58,6 +58,49 @@ import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +import { toolMeta, type ToolMetaInput } from "./tool-meta" +import { formatMessageStamp, handleCopyResponseClick } from "./message-actions" + +function toolMetaInput(part: ToolPart, now: number): ToolMetaInput { + const state = part.state + if (state.status === "pending") return { status: state.status, now } + if (state.status === "running") return { status: state.status, start: state.time.start, now } + if (state.status === "completed") return { status: state.status, start: state.time.start, end: state.time.end, now } + if (state.metadata?.interrupted === true) return { status: "interrupted" as const, start: state.time.start, now } + return { status: state.status, start: state.time.start, end: state.time.end, now } +} + +function toolPartMetadata(part: ToolPart, fallback: Record) { + const state = part.state + if (state.status === "pending") return fallback + return state.metadata ?? fallback +} + +function toolPartError(part: ToolPart) { + const state = part.state + if (state.status !== "error") return undefined + return state.error +} + +function toolPartOutput(part: ToolPart) { + const state = part.state + if (state.status !== "completed") return undefined + return state.output +} + +function createToolMeta(part: () => ToolPart, i18n: UiI18n) { + const [now, setNow] = createSignal(Date.now()) + + createEffect(() => { + const status = part().state.status + if (status !== "pending" && status !== "running") return + setNow(Date.now()) + const interval = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(interval)) + }) + + return createMemo(() => toolMeta(i18n, toolMetaInput(part(), now()))) +} async function writeClipboard(text: string): Promise { const body = typeof document === "undefined" ? undefined : document.body @@ -1016,6 +1059,7 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS {(partAccessor) => { const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) + const meta = createToolMeta(partAccessor, i18n) const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", ) @@ -1040,6 +1084,14 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS + + + {meta()} + + @@ -1084,12 +1136,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const match = data.store.provider?.all?.get(providerID) return match?.models?.[modelID]?.name ?? modelID }) - const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" })) - const stamp = createMemo(() => { const created = props.message.time?.created if (typeof created !== "number") return "" - return timefmt().format(created) + return formatMessageStamp(i18n.locale(), created) }) const metaHead = createMemo(() => { @@ -1307,6 +1357,7 @@ export interface ToolProps { onContentRendered?: () => void forceOpen?: boolean locked?: boolean + meta?: string } export type ToolComponent = Component @@ -1383,8 +1434,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const emptyMetadata: Record = {} const input = () => part().state?.input ?? emptyInput - // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => toolPartMetadata(part(), emptyMetadata) const taskId = createMemo(() => { if (part().tool !== "task") return const value = partMetadata().sessionId @@ -1404,12 +1454,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) + const meta = createToolMeta(part, i18n) return (
- + {(error) => { const cleaned = error().replace("Error: ", "") if (part().tool === "question" && cleaned.includes("dismissed this question")) { @@ -1431,6 +1482,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} subtitle={taskSubtitle()} href={taskHref()} + meta={meta()} /> ) }} @@ -1442,8 +1494,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { tool={part().tool} sessionID={part().sessionID} metadata={partMetadata()} - // @ts-expect-error - output={part().state.output} + output={toolPartOutput(part())} status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} @@ -1452,6 +1503,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { deferContent={props.deferToolContent} virtualizeDiff={props.virtualizeDiff} onContentRendered={props.onContentRendered} + meta={meta()} /> @@ -1519,9 +1571,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const meta = createMemo(() => { if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent + const message = props.message as AssistantMessage const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", + formatMessageStamp(i18n.locale(), message.time.created), + message.agent ? message.agent[0]?.toUpperCase() + message.agent.slice(1) : "", model(), duration(), interrupted() ? i18n.t("ui.message.interrupted") : "", @@ -1576,7 +1629,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { size="normal" variant="ghost" onMouseDown={(e) => e.preventDefault()} - onClick={handleCopy} + onClick={(event) => handleCopyResponseClick(event, () => void handleCopy())} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} /> @@ -1874,6 +1927,7 @@ ToolRegistry.register({ triggerHref={href()} clickable={clickable()} onTriggerClick={navigate} + meta={props.meta} /> ) }, @@ -2431,6 +2485,6 @@ ToolRegistry.register({
) - return + return }, }) From 502b693e1a0521520a9af934cdb3b3e1b4b73f48 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Mon, 20 Jul 2026 06:30:48 +0000 Subject: [PATCH 04/14] feat(session-ui): add metadata display options Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/session-ui/package.json | 1 + .../src/components/message-options.ts | 10 ++ .../src/components/message-part.test.ts | 118 ++++++++++++++---- .../src/components/message-part.tsx | 45 +++++-- .../session-ui/src/components/tool-meta.ts | 18 ++- 5 files changed, 150 insertions(+), 42 deletions(-) create mode 100644 packages/session-ui/src/components/message-options.ts diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index f6c70cf5ae7d..542b23ef3652 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -8,6 +8,7 @@ "./*": "./src/components/*.tsx", "./session-diff": "./src/components/session-diff.ts", "./message-file": "./src/components/message-file.ts", + "./message-options": "./src/components/message-options.ts", "./message-part-text": "./src/components/message-part-text.ts", "./markdown-stream": "./src/components/markdown-stream.ts", "./markdown-cache": "./src/components/markdown-cache.tsx", diff --git a/packages/session-ui/src/components/message-options.ts b/packages/session-ui/src/components/message-options.ts new file mode 100644 index 000000000000..b9eab1e672e5 --- /dev/null +++ b/packages/session-ui/src/components/message-options.ts @@ -0,0 +1,10 @@ +export type MessageMetadataDisplayOptions = { + readonly messageTimestamp?: boolean + readonly toolTimestamp?: boolean + readonly toolDuration?: boolean + readonly toolStatus?: boolean +} + +export type MessageDisplayOptions = { + readonly metadata?: MessageMetadataDisplayOptions +} diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 2aa19e8fe24e..5aab2a7f1a8d 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -42,7 +42,7 @@ describe("readPartText", () => { }) describe("toolMeta", () => { - test("formats completed tool start time and duration", () => { + test("returns empty meta when display options are omitted", () => { expect( toolMeta(i18n, { status: "completed", @@ -50,57 +50,121 @@ describe("toolMeta", () => { end: Date.UTC(2026, 5, 28, 14, 3, 5), now: Date.UTC(2026, 5, 28, 14, 3, 8), }), + ).toBe("") + }) + + test("formats completed tool start time and duration", () => { + expect( + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolTimestamp: true, toolDuration: true }, + ), ).toBe("2:03 PM · 5s") }) + test("formats completed tool duration without start time when only duration is enabled", () => { + expect( + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolDuration: true }, + ), + ).toBe("5s") + }) + test("formats running tool start time and live duration", () => { expect( - toolMeta(i18n, { - status: "running", - start: Date.UTC(2026, 5, 28, 14, 3, 0), - now: Date.UTC(2026, 5, 28, 14, 3, 7), - }), + toolMeta( + i18n, + { + status: "running", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolDuration: true }, + ), ).toBe("2:03 PM · 7s") }) test("shows interrupted state without duration", () => { expect( - toolMeta(i18n, { - status: "interrupted", - start: Date.UTC(2026, 5, 28, 14, 3, 0), - now: Date.UTC(2026, 5, 28, 14, 3, 7), - }), + toolMeta( + i18n, + { + status: "interrupted", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolStatus: true }, + ), ).toBe("2:03 PM · Interrupted") }) + test("hides interrupted state when status display is disabled", () => { + expect( + toolMeta( + i18n, + { + status: "interrupted", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true }, + ), + ).toBe("2:03 PM") + }) + test("returns empty meta for pending tool without start time", () => { expect( - toolMeta(i18n, { - status: "pending", - now: Date.UTC(2026, 5, 28, 14, 3, 7), - }), + toolMeta( + i18n, + { + status: "pending", + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolDuration: true, toolStatus: true }, + ), ).toBe("") }) test("formats error tool start time and failed state", () => { expect( - toolMeta(i18n, { - status: "error", - start: Date.UTC(2026, 5, 28, 14, 3, 0), - end: Date.UTC(2026, 5, 28, 14, 3, 5), - now: Date.UTC(2026, 5, 28, 14, 3, 8), - }), + toolMeta( + i18n, + { + status: "error", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolTimestamp: true, toolStatus: true }, + ), ).toBe("2:03 PM · Failed") }) test("formats minute durations", () => { expect( - toolMeta(i18n, { - status: "completed", - start: Date.UTC(2026, 5, 28, 14, 3, 0), - end: Date.UTC(2026, 5, 28, 14, 4, 5), - now: Date.UTC(2026, 5, 28, 14, 4, 8), - }), + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 4, 5), + now: Date.UTC(2026, 5, 28, 14, 4, 8), + }, + { toolTimestamp: true, toolDuration: true }, + ), ).toBe("2:03 PM · 1m 5s") }) }) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 074824a79474..e897afe65ddf 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -60,6 +60,7 @@ import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" import { toolMeta, type ToolMetaInput } from "./tool-meta" import { formatMessageStamp, handleCopyResponseClick } from "./message-actions" +import type { MessageDisplayOptions } from "./message-options" function toolMetaInput(part: ToolPart, now: number): ToolMetaInput { const state = part.state @@ -88,7 +89,7 @@ function toolPartOutput(part: ToolPart) { return state.output } -function createToolMeta(part: () => ToolPart, i18n: UiI18n) { +function createToolMeta(part: () => ToolPart, i18n: UiI18n, displayOptions?: () => MessageDisplayOptions | undefined) { const [now, setNow] = createSignal(Date.now()) createEffect(() => { @@ -99,7 +100,7 @@ function createToolMeta(part: () => ToolPart, i18n: UiI18n) { onCleanup(() => clearInterval(interval)) }) - return createMemo(() => toolMeta(i18n, toolMetaInput(part(), now()))) + return createMemo(() => toolMeta(i18n, toolMetaInput(part(), now()), displayOptions?.()?.metadata)) } async function writeClipboard(text: string): Promise { @@ -204,6 +205,7 @@ export interface MessageProps { actions?: UserActions showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean + displayOptions?: MessageDisplayOptions } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -225,6 +227,7 @@ export interface MessagePartProps { onContentRendered?: () => void showAssistantCopyPartID?: string | null turnDurationMs?: number + displayOptions?: MessageDisplayOptions } export type PartComponent = Component @@ -687,6 +690,7 @@ export function AssistantParts(props: { showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + displayOptions?: MessageDisplayOptions }) { const data = useData() const emptyParts: PartType[] = [] @@ -741,7 +745,7 @@ export function AssistantParts(props: { return ( 0}> - + ) })()} @@ -767,6 +771,7 @@ export function AssistantParts(props: { message={message()!} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + displayOptions={props.displayOptions} defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} />
@@ -894,7 +899,12 @@ export function Message(props: MessageProps) { {(userMessage) => ( - + )} @@ -904,6 +914,7 @@ export function Message(props: MessageProps) { parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} showReasoningSummaries={props.showReasoningSummaries} + displayOptions={props.displayOptions} /> )} @@ -916,6 +927,7 @@ export function AssistantMessageDisplay(props: { parts: PartType[] showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean + displayOptions?: MessageDisplayOptions }) { const emptyTools: ToolPart[] = [] const part = createMemo(() => index(props.parts)) @@ -956,7 +968,7 @@ export function AssistantMessageDisplay(props: { return ( 0}> - + ) })()} @@ -975,6 +987,7 @@ export function AssistantMessageDisplay(props: { part={item()!} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} + displayOptions={props.displayOptions} />
) @@ -987,7 +1000,12 @@ export function AssistantMessageDisplay(props: { ) } -export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) { +export function ContextToolGroup(props: { + parts: ToolPart[] + busy?: boolean + onSizeChange?: () => void + displayOptions?: MessageDisplayOptions +}) { const i18n = useI18n() const [open, setOpen] = createSignal(false) const pending = createMemo( @@ -1059,7 +1077,7 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS {(partAccessor) => { const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const meta = createToolMeta(partAccessor, i18n) + const meta = createToolMeta(partAccessor, i18n, () => props.displayOptions) const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", ) @@ -1104,7 +1122,12 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS ) } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: UserActions }) { +export function UserMessageDisplay(props: { + message: UserMessage + parts: PartType[] + actions?: UserActions + displayOptions?: MessageDisplayOptions +}) { const data = useData() const dialog = useDialog() const i18n = useI18n() @@ -1137,6 +1160,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp return match?.models?.[modelID]?.name ?? modelID }) const stamp = createMemo(() => { + if (!props.displayOptions?.metadata?.messageTimestamp) return "" const created = props.message.time?.created if (typeof created !== "number") return "" return formatMessageStamp(i18n.locale(), created) @@ -1336,6 +1360,7 @@ export function Part(props: MessagePartProps) { onContentRendered={props.onContentRendered} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + displayOptions={props.displayOptions} /> ) @@ -1454,7 +1479,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) - const meta = createToolMeta(part, i18n) + const meta = createToolMeta(part, i18n, () => props.displayOptions) return ( @@ -1573,7 +1598,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (props.message.role !== "assistant") return "" const message = props.message as AssistantMessage const items = [ - formatMessageStamp(i18n.locale(), message.time.created), + props.displayOptions?.metadata?.messageTimestamp ? formatMessageStamp(i18n.locale(), message.time.created) : "", message.agent ? message.agent[0]?.toUpperCase() + message.agent.slice(1) : "", model(), duration(), diff --git a/packages/session-ui/src/components/tool-meta.ts b/packages/session-ui/src/components/tool-meta.ts index 69e6e572dee2..eaed19b3a938 100644 --- a/packages/session-ui/src/components/tool-meta.ts +++ b/packages/session-ui/src/components/tool-meta.ts @@ -1,4 +1,5 @@ import type { UiI18n } from "@opencode-ai/ui/context/i18n" +import type { MessageMetadataDisplayOptions } from "./message-options" export type ToolMetaInput = { status: "pending" | "running" | "completed" | "error" | "interrupted" @@ -7,13 +8,20 @@ export type ToolMetaInput = { now?: number } -export function toolMeta(i18n: UiI18n, input: ToolMetaInput) { - const stamp = typeof input.start === "number" ? formatTime(i18n.locale(), input.start) : "" - if (input.status === "interrupted") return [stamp, i18n.t("ui.message.interrupted")].filter(Boolean).join(" · ") - if (input.status === "error") return [stamp, i18n.t("ui.toolErrorCard.failed")].filter(Boolean).join(" · ") +export function toolMeta(i18n: UiI18n, input: ToolMetaInput, options?: MessageMetadataDisplayOptions) { + const stamp = options?.toolTimestamp && typeof input.start === "number" ? formatTime(i18n.locale(), input.start) : "" + if (input.status === "interrupted") { + return [stamp, options?.toolStatus ? i18n.t("ui.message.interrupted") : ""].filter(Boolean).join(" · ") + } + if (input.status === "error") { + return [stamp, options?.toolStatus ? i18n.t("ui.toolErrorCard.failed") : ""].filter(Boolean).join(" · ") + } const end = input.status === "completed" ? input.end : input.now - const duration = typeof input.start === "number" && typeof end === "number" ? formatDuration(i18n, end - input.start) : "" + const duration = + options?.toolDuration && typeof input.start === "number" && typeof end === "number" + ? formatDuration(i18n, end - input.start) + : "" return [stamp, duration].filter(Boolean).join(" · ") } From 9caf162482fd87989df77fc5c0112a03d4061b36 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Mon, 20 Jul 2026 06:31:26 +0000 Subject: [PATCH 05/14] feat(app): enable session metadata display Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../app/src/pages/session/timeline/message-timeline.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 42712732bf0d..953a3576be18 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -82,6 +82,14 @@ type TimelineRowByTag = Extract }>() +const messageDisplayOptions = { + metadata: { + messageTimestamp: true, + toolTimestamp: true, + toolDuration: true, + toolStatus: true, + }, +} as const const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return @@ -960,6 +968,7 @@ export function MessageTimeline(props: { message={message()} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)} + displayOptions={messageDisplayOptions} defaultOpen={defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()} onToolOpenChange={(open) => setToolOpen(part().id, open)} From 05bfbe886380d86eec477d5606cb483eb20c3365 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 13:21:24 +0000 Subject: [PATCH 06/14] docs: design pre-push concurrency limit --- ...t-pre-push-typecheck-concurrency-design.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md diff --git a/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md b/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md new file mode 100644 index 000000000000..9a575d0da6c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md @@ -0,0 +1,31 @@ +# Limit pre-push typecheck concurrency + +## Problem + +The repository's Husky `pre-push` hook runs `bun typecheck`, which expands to `bun turbo typecheck`. On the two-CPU, 3.8 GiB server, Turbo launched ten `tsgo` workers while checking 35 packages. Those workers exhausted RAM and swap and triggered the OOM killer. + +## Decision + +Change only `.husky/pre-push` to invoke Turbo directly with a concurrency limit: + +```sh +bun turbo typecheck --concurrency=3 +``` + +This keeps the existing typecheck task graph while allowing at most three parallel tasks during a push. The global `typecheck` package script remains unchanged, so local and CI callers retain their current behavior. + +## Alternatives considered + +- Add `--concurrency=3` to the root `typecheck` package script. Rejected because it changes every caller, not only the problematic push hook. +- Set `TURBO_CONCURRENCY=3` in the hook environment. Rejected because the effective limit is less visible than an explicit CLI argument. + +## Validation + +1. Before editing, verify the hook does not contain the required limited command. +2. After editing, verify the hook contains exactly `bun turbo typecheck --concurrency=3` and no unrestricted `bun typecheck` invocation. +3. Run the hook through `sh -n`. +4. Run Turbo with `--dry` and `--concurrency=3` to validate argument parsing without starting package typechecks. + +## Out of scope + +This change does not alter systemd services, OpenCode memory limits, CI behavior, or the root `typecheck` script. From 1ee35ecec844f3c2c42389c6b21894eb59e01614 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 13:22:57 +0000 Subject: [PATCH 07/14] docs: plan pre-push concurrency limit --- ...mit-pre-push-typecheck-concurrency-plan.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md diff --git a/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md b/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md new file mode 100644 index 000000000000..1e8de3e5ab7e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md @@ -0,0 +1,79 @@ +# Limit Pre-Push Typecheck Concurrency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Limit the repository's Husky pre-push typecheck to three parallel Turbo tasks. + +**Architecture:** Modify only the pre-push hook to call the existing Turbo `typecheck` task graph with an explicit `--concurrency=3` argument. Keep the root package script and CI behavior unchanged. + +**Tech Stack:** POSIX shell, Husky, Bun, Turborepo 2.8.13 + +## Global Constraints + +- The concurrency value is exactly `3`. +- Only `.husky/pre-push` changes during implementation. +- The root `typecheck` package script remains unchanged. +- Validation must not execute the full typecheck workload. + +--- + +### Task 1: Limit the pre-push typecheck + +**Files:** +- Modify: `.husky/pre-push:20` +- Test: static shell assertions and Turbo dry-run + +**Interfaces:** +- Consumes: the existing Turborepo `typecheck` task graph. +- Produces: a pre-push hook that schedules at most three Turbo tasks concurrently. + +- [ ] **Step 1: Run the failing static assertion** + +```bash +grep -Fx 'bun turbo typecheck --concurrency=3' .husky/pre-push +``` + +Expected: exit status `1` because the hook currently contains `bun typecheck`. + +- [ ] **Step 2: Apply the minimal implementation** + +Replace: + +```sh +bun typecheck +``` + +with: + +```sh +bun turbo typecheck --concurrency=3 +``` + +- [ ] **Step 3: Run the passing static assertions** + +```bash +grep -Fx 'bun turbo typecheck --concurrency=3' .husky/pre-push +! grep -Fx 'bun typecheck' .husky/pre-push +``` + +Expected: both assertions exit with status `0`. + +- [ ] **Step 4: Validate shell syntax and Turbo argument parsing** + +```bash +sh -n .husky/pre-push +bun turbo typecheck --concurrency=3 --dry=json >/tmp/opencode-typecheck-dry-run.json +jq -e '.tasks | type == "array"' /tmp/opencode-typecheck-dry-run.json +rm -f /tmp/opencode-typecheck-dry-run.json +``` + +Expected: all commands exit with status `0`; no `tsgo` typecheck workers are launched. + +- [ ] **Step 5: Commit the hook change** + +```bash +git add -- .husky/pre-push +git commit -m "fix: limit pre-push typecheck concurrency" -- .husky/pre-push +``` + +Expected: one commit containing only `.husky/pre-push`. From f81cd0ca3b71cc7bde23edd48d35fc5501f1c984 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 13:28:08 +0000 Subject: [PATCH 08/14] fix: limit pre-push typecheck concurrency --- .husky/pre-push | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-push b/.husky/pre-push index 5d3cc53411be..79db8d3e17c2 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) { console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`); } ' -bun typecheck +bun turbo typecheck --concurrency=3 From b2e4404ccb8c5ede008577f9a1ba0af5de542084 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 14:06:21 +0000 Subject: [PATCH 09/14] feat(app): add jump to last user message Add a session timeline control that scrolls directly to the last user message and provide English and Russian labels. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/ru.ts | 1 + .../session/timeline/message-timeline.tsx | 47 ++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3872d01ae78b..3a7ace5b0221 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -630,6 +630,7 @@ export const dict = { "session.messages.loadEarlier": "Load earlier messages", "session.messages.loading": "Loading messages...", "session.messages.jumpToLatest": "Jump to latest", + "session.messages.jumpToLatestUser": "Jump to last user message", "session.context.addToContext": "Add {{selection}} to context", "session.todo.title": "Todos", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index b2f58b101b40..4c71d106dc41 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -527,6 +527,7 @@ export const dict = { "session.messages.loadEarlier": "Загрузить предыдущие сообщения", "session.messages.loading": "Загрузка сообщений...", "session.messages.jumpToLatest": "Перейти к последнему", + "session.messages.jumpToLatestUser": "Перейти к последнему сообщению пользователя", "session.context.addToContext": "Добавить {{selection}} в контекст", "session.todo.title": "Задачи", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 953a3576be18..b04d8ed38e4d 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -472,6 +472,15 @@ export function MessageTimeline(props: { () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) + const handleScrollToLastUser = () => { + const lastUser = props.userMessages.at(-1) + if (!lastUser) return + const index = messageRowIndex().get(lastUser.id) + if (index !== undefined) { + virtualizer.scrollToIndex(index, { align: "start" }) + } + } + createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) @@ -1233,7 +1242,7 @@ export function MessageTimeline(props: { return (
+ 0}> + +
+ +
+ + } + > + +
+
Date: Thu, 23 Jul 2026 06:27:46 +0000 Subject: [PATCH 10/14] chore(opencode): add local update script Add a dry-run-first production update helper that rebases local fork branches, builds the OpenCode binary, backs up session data, stops the stack, replaces the binary, and starts the stack again when invoked with --execute. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/update-local-opencode.sh | 197 ++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100755 script/update-local-opencode.sh diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh new file mode 100755 index 000000000000..241b5f649e16 --- /dev/null +++ b/script/update-local-opencode.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: script/update-local-opencode.sh [--execute] + +Updates the local production OpenCode binary from this fork stack: + 1. fetch upstream and fork remotes + 2. rebase the base patch branch onto upstream dev + 3. rebase the feature branch onto the base patch branch + 4. install dependencies and build the current-platform binary + 5. back up OpenCode session data before stopping the stack + 6. stop the stack, make a final session backup, replace the binary, start the stack + +Default mode is dry-run. Pass --execute to stop/start prod and replace the binary. + +Environment overrides: + REPO_DIR default: /var/opt/opencode-upstream + UPSTREAM_REMOTE default: origin + UPSTREAM_BRANCH default: dev + FORK_REMOTE default: fork + BASE_PATCH_BRANCH default: tool-timestamp-metadata + FEATURE_BRANCH default: jump-user-message + STACK_SCRIPT default: /var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh + TARGET_BIN default: /root/.opencode/bin/opencode + SESSION_DIR default: /root/.local/share/opencode + BACKUP_ROOT default: /root/.local/share/opencode/backups + ALLOW_DIRTY default: 0; set 1 to allow tracked worktree changes +EOF +} + +EXECUTE=0 +case "${1:-}" in + "") ;; + --execute) EXECUTE=1 ;; + -h|--help|help) usage; exit 0 ;; + *) usage; exit 2 ;; +esac + +REPO_DIR="${REPO_DIR:-/var/opt/opencode-upstream}" +UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-origin}" +UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-dev}" +FORK_REMOTE="${FORK_REMOTE:-fork}" +BASE_PATCH_BRANCH="${BASE_PATCH_BRANCH:-tool-timestamp-metadata}" +FEATURE_BRANCH="${FEATURE_BRANCH:-jump-user-message}" +STACK_SCRIPT="${STACK_SCRIPT:-/var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh}" +TARGET_BIN="${TARGET_BIN:-/root/.opencode/bin/opencode}" +SESSION_DIR="${SESSION_DIR:-/root/.local/share/opencode}" +BACKUP_ROOT="${BACKUP_ROOT:-/root/.local/share/opencode/backups}" +ALLOW_DIRTY="${ALLOW_DIRTY:-0}" + +timestamp() { + date -u +%Y%m%dT%H%M%SZ +} + +log() { + printf '[%s] %s\n' "$(timestamp)" "$*" +} + +run() { + log "+ $*" + if (( EXECUTE == 1 )); then + "$@" + fi +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + log "missing command: $1" + exit 1 + } +} + +require_path() { + [[ -e "$1" ]] || { + log "missing path: $1" + exit 1 + } +} + +git_run() { + run env GIT_MASTER=1 git "$@" +} + +git_capture() { + env GIT_MASTER=1 git "$@" +} + +require_clean_tracked_tree() { + if [[ "$ALLOW_DIRTY" == "1" ]]; then + log "ALLOW_DIRTY=1 set; tracked worktree cleanliness check skipped" + return 0 + fi + + local status + status="$(git_capture status --porcelain --untracked-files=no)" + [[ -z "$status" ]] || { + log "tracked worktree has changes; commit/stash them or set ALLOW_DIRTY=1" + printf '%s\n' "$status" + exit 1 + } +} + +backup_sessions() { + local label="$1" + local backup_file="$BACKUP_ROOT/opencode-sessions-$label-$(timestamp).tar.gz" + + require_path "$SESSION_DIR" + run mkdir -p "$BACKUP_ROOT" + log "session backup target: $backup_file" + if (( EXECUTE == 1 )); then + tar --warning=no-file-changed --ignore-failed-read -C "$(dirname "$SESSION_DIR")" -czf "$backup_file" "$(basename "$SESSION_DIR")" + fi +} + +wait_for_port() { + local port="$1" + for _ in {1..180}; do + if ss -ltn | grep -qE "(^|:)${port}[[:space:]]"; then + log "port $port is listening" + return 0 + fi + sleep 1 + done + log "port $port did not open in time" + return 1 +} + +main() { + require_cmd bash + require_cmd bun + require_cmd git + require_cmd ss + require_cmd tar + require_path "$REPO_DIR/.git" + require_path "$STACK_SCRIPT" + + cd "$REPO_DIR" + + if (( EXECUTE == 0 )); then + log "dry-run mode; pass --execute to perform changes" + fi + + require_clean_tracked_tree + + git_run fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" + git_run fetch "$FORK_REMOTE" "$BASE_PATCH_BRANCH" "$FEATURE_BRANCH" + git_run checkout "$BASE_PATCH_BRANCH" + git_run rebase "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + git_run checkout "$FEATURE_BRANCH" + git_run rebase "$BASE_PATCH_BRANCH" + + run bun install + run bun run --cwd packages/opencode build --single + + local built_bin="$REPO_DIR/packages/opencode/dist/opencode-linux-x64/bin/opencode" + if (( EXECUTE == 1 )); then + require_path "$built_bin" + "$built_bin" --version + else + log "would verify built binary: $built_bin --version" + fi + + backup_sessions "pre-stop" + + run "$STACK_SCRIPT" stop + backup_sessions "post-stop" + + local binary_backup="$TARGET_BIN.$(timestamp).bak" + require_path "$(dirname "$TARGET_BIN")" + if (( EXECUTE == 1 )); then + [[ -x "$built_bin" ]] || { + log "built binary is not executable: $built_bin" + exit 1 + } + if [[ -e "$TARGET_BIN" ]]; then + cp -a "$TARGET_BIN" "$binary_backup" + log "current binary backup: $binary_backup" + fi + install -m 755 "$built_bin" "$TARGET_BIN.new" + mv -f "$TARGET_BIN.new" "$TARGET_BIN" + else + log "would back up current binary to: $binary_backup" + log "would install built binary to: $TARGET_BIN" + fi + + run "$STACK_SCRIPT" start + if (( EXECUTE == 1 )); then + wait_for_port 4416 + "$TARGET_BIN" --version + else + log "would wait for port 4416 and verify: $TARGET_BIN --version" + fi +} + +main "$@" From 00e00ee98e00d53eda9c484ed4e1c25de9145f7f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 08:04:14 +0000 Subject: [PATCH 11/14] chore(opencode): bootstrap local update script Let the production update helper clone the source checkout when REPO_DIR is missing, preserve non-git REPO_DIR contents by moving them aside, configure origin/fork remotes, and create local patch branches from fork refs before rebasing. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/update-local-opencode.sh | 82 +++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh index 241b5f649e16..7c6ce3c30a86 100755 --- a/script/update-local-opencode.sh +++ b/script/update-local-opencode.sh @@ -6,17 +6,22 @@ usage() { Usage: script/update-local-opencode.sh [--execute] Updates the local production OpenCode binary from this fork stack: - 1. fetch upstream and fork remotes - 2. rebase the base patch branch onto upstream dev - 3. rebase the feature branch onto the base patch branch - 4. install dependencies and build the current-platform binary - 5. back up OpenCode session data before stopping the stack - 6. stop the stack, make a final session backup, replace the binary, start the stack + 1. clone the source checkout if REPO_DIR does not exist yet + 2. fetch upstream and fork remotes + 3. rebase the base patch branch onto upstream dev + 4. rebase the feature branch onto the base patch branch + 5. install dependencies and build the current-platform binary + 6. back up OpenCode session data before stopping the stack + 7. stop the stack, make a final session backup, replace the binary, start the stack Default mode is dry-run. Pass --execute to stop/start prod and replace the binary. +If REPO_DIR exists but is not a git checkout, --execute moves it aside to +REPO_DIR.bootstrap-backup. before cloning a fresh checkout. Environment overrides: REPO_DIR default: /var/opt/opencode-upstream + UPSTREAM_URL default: https://github.com/sst/opencode.git + FORK_URL default: https://github.com/CryptoAlchemik/opencode.git UPSTREAM_REMOTE default: origin UPSTREAM_BRANCH default: dev FORK_REMOTE default: fork @@ -39,6 +44,8 @@ case "${1:-}" in esac REPO_DIR="${REPO_DIR:-/var/opt/opencode-upstream}" +UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/sst/opencode.git}" +FORK_URL="${FORK_URL:-https://github.com/CryptoAlchemik/opencode.git}" UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-origin}" UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-dev}" FORK_REMOTE="${FORK_REMOTE:-fork}" @@ -87,6 +94,61 @@ git_capture() { env GIT_MASTER=1 git "$@" } +ensure_repo() { + if [[ -d "$REPO_DIR/.git" ]]; then + log "using existing checkout: $REPO_DIR" + return 0 + fi + + if [[ -e "$REPO_DIR" ]]; then + local backup_dir="$REPO_DIR.bootstrap-backup.$(timestamp)" + if (( EXECUTE == 1 )); then + mv "$REPO_DIR" "$backup_dir" + log "moved non-git REPO_DIR aside: $backup_dir" + else + log "+ mv $REPO_DIR $backup_dir" + log "dry-run bootstrap stops here because REPO_DIR exists but is not a git checkout" + exit 0 + fi + fi + + local parent + parent="$(dirname "$REPO_DIR")" + if (( EXECUTE == 1 )); then + mkdir -p "$parent" + env GIT_MASTER=1 git clone --origin "$UPSTREAM_REMOTE" --branch "$UPSTREAM_BRANCH" "$UPSTREAM_URL" "$REPO_DIR" + else + log "+ mkdir -p $parent" + log "+ env GIT_MASTER=1 git clone --origin $UPSTREAM_REMOTE --branch $UPSTREAM_BRANCH $UPSTREAM_URL $REPO_DIR" + log "dry-run bootstrap stops here because REPO_DIR does not exist yet" + exit 0 + fi +} + +ensure_remote() { + local name="$1" + local url="$2" + + if git_capture remote get-url "$name" >/dev/null 2>&1; then + git_run remote set-url "$name" "$url" + return 0 + fi + + git_run remote add "$name" "$url" +} + +checkout_branch_from_remote() { + local branch="$1" + local remote="$2" + + if git_capture show-ref --verify --quiet "refs/heads/$branch"; then + git_run checkout "$branch" + return 0 + fi + + git_run checkout -b "$branch" "$remote/$branch" +} + require_clean_tracked_tree() { if [[ "$ALLOW_DIRTY" == "1" ]]; then log "ALLOW_DIRTY=1 set; tracked worktree cleanliness check skipped" @@ -133,9 +195,9 @@ main() { require_cmd git require_cmd ss require_cmd tar - require_path "$REPO_DIR/.git" require_path "$STACK_SCRIPT" + ensure_repo cd "$REPO_DIR" if (( EXECUTE == 0 )); then @@ -144,11 +206,13 @@ main() { require_clean_tracked_tree + ensure_remote "$UPSTREAM_REMOTE" "$UPSTREAM_URL" + ensure_remote "$FORK_REMOTE" "$FORK_URL" git_run fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" git_run fetch "$FORK_REMOTE" "$BASE_PATCH_BRANCH" "$FEATURE_BRANCH" - git_run checkout "$BASE_PATCH_BRANCH" + checkout_branch_from_remote "$BASE_PATCH_BRANCH" "$FORK_REMOTE" git_run rebase "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" - git_run checkout "$FEATURE_BRANCH" + checkout_branch_from_remote "$FEATURE_BRANCH" "$FORK_REMOTE" git_run rebase "$BASE_PATCH_BRANCH" run bun install From 9048ed9724905b0191bdf38437bd68fe6738ac60 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 08:25:56 +0000 Subject: [PATCH 12/14] chore(opencode): add update rollback safeguards Keep the previous production binary as a rollback backup, validate the new binary and health endpoint after restart, restore the old binary through the stack helper on startup or health failures, and delete rollback backups only after an interactive confirmation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/update-local-opencode.sh | 142 +++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 20 deletions(-) diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh index 7c6ce3c30a86..caede60cccba 100755 --- a/script/update-local-opencode.sh +++ b/script/update-local-opencode.sh @@ -29,6 +29,8 @@ Environment overrides: FEATURE_BRANCH default: jump-user-message STACK_SCRIPT default: /var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh TARGET_BIN default: /root/.opencode/bin/opencode + HEALTH_URL default: http://127.0.0.1:4416/global/health + OPENCODE_SERVER_PASSWORD used for authenticated health checks when set SESSION_DIR default: /root/.local/share/opencode BACKUP_ROOT default: /root/.local/share/opencode/backups ALLOW_DIRTY default: 0; set 1 to allow tracked worktree changes @@ -53,6 +55,7 @@ BASE_PATCH_BRANCH="${BASE_PATCH_BRANCH:-tool-timestamp-metadata}" FEATURE_BRANCH="${FEATURE_BRANCH:-jump-user-message}" STACK_SCRIPT="${STACK_SCRIPT:-/var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh}" TARGET_BIN="${TARGET_BIN:-/root/.opencode/bin/opencode}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4416/global/health}" SESSION_DIR="${SESSION_DIR:-/root/.local/share/opencode}" BACKUP_ROOT="${BACKUP_ROOT:-/root/.local/share/opencode/backups}" ALLOW_DIRTY="${ALLOW_DIRTY:-0}" @@ -189,9 +192,94 @@ wait_for_port() { return 1 } +verify_binary() { + local binary="$1" + [[ -f "$binary" && -x "$binary" && ! -L "$binary" ]] || { + log "binary is missing, not executable, or is a symlink: $binary" + return 1 + } + + "$binary" --version >/dev/null +} + +verify_stack_health() { + wait_for_port 4416 || return 1 + verify_binary "$TARGET_BIN" || return 1 + if [[ -n "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + curl -fsS --max-time 5 -u "opencode:${OPENCODE_SERVER_PASSWORD}" "$HEALTH_URL" >/dev/null + return $? + fi + curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null +} + +backup_binary() { + local source="$1" destination="$2" + + [[ -f "$source" && -x "$source" && ! -L "$source" ]] || { + log "cannot create rollback backup; target binary is missing, not executable, or is a symlink: $source" + return 1 + } + [[ ! -e "$destination" ]] || { + log "refusing to overwrite existing binary backup: $destination" + return 1 + } + + cp -a "$source" "$destination" + [[ -f "$destination" && -x "$destination" ]] || { + log "binary backup validation failed: $destination" + return 1 + } + log "current binary backup: $destination" +} + +install_binary() { + local source="$1" destination="$2" + + install -m 755 "$source" "$destination.new" + mv -f "$destination.new" "$destination" +} + +rollback_binary() { + local binary_backup="$1" + + log "rolling back $TARGET_BIN from $binary_backup" + [[ -f "$binary_backup" && -x "$binary_backup" ]] || { + log "rollback backup is unavailable or invalid: $binary_backup" + return 1 + } + + "$STACK_SCRIPT" stop || true + install_binary "$binary_backup" "$TARGET_BIN" + "$STACK_SCRIPT" start + verify_stack_health + log "rollback completed; backup retained: $binary_backup" +} + +confirm_backup_removal() { + local binary_backup="$1" answer="" + + if [[ ! -t 0 ]]; then + log "non-interactive session; retaining rollback backup: $binary_backup" + return 0 + fi + + printf 'Confirm production was manually checked and remove rollback backup %s? [y/N] ' "$binary_backup" + read -r answer || answer="" + case "${answer,,}" in + y|yes) + rm -f -- "$binary_backup" + log "removed confirmed binary backup: $binary_backup" + ;; + *) + log "retaining rollback backup: $binary_backup" + ;; + esac +} + main() { require_cmd bash require_cmd bun + require_cmd curl require_cmd git require_cmd ss require_cmd tar @@ -221,40 +309,54 @@ main() { local built_bin="$REPO_DIR/packages/opencode/dist/opencode-linux-x64/bin/opencode" if (( EXECUTE == 1 )); then require_path "$built_bin" - "$built_bin" --version + verify_binary "$built_bin" else log "would verify built binary: $built_bin --version" fi backup_sessions "pre-stop" - run "$STACK_SCRIPT" stop - backup_sessions "post-stop" - local binary_backup="$TARGET_BIN.$(timestamp).bak" require_path "$(dirname "$TARGET_BIN")" if (( EXECUTE == 1 )); then - [[ -x "$built_bin" ]] || { - log "built binary is not executable: $built_bin" + backup_binary "$TARGET_BIN" "$binary_backup" + + "$STACK_SCRIPT" stop + if ! backup_sessions "post-stop"; then + log "post-stop session backup failed; restarting old binary" + "$STACK_SCRIPT" start || true + exit 1 + fi + + if ! install_binary "$built_bin" "$TARGET_BIN"; then + log "new binary install failed" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" exit 1 - } - if [[ -e "$TARGET_BIN" ]]; then - cp -a "$TARGET_BIN" "$binary_backup" - log "current binary backup: $binary_backup" fi - install -m 755 "$built_bin" "$TARGET_BIN.new" - mv -f "$TARGET_BIN.new" "$TARGET_BIN" + + if ! "$STACK_SCRIPT" start; then + log "new binary failed during stack start" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" + exit 1 + fi + + if ! verify_stack_health; then + log "new binary failed health check" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" + exit 1 + fi + + log "new binary passed startup and health checks" + confirm_backup_removal "$binary_backup" else log "would back up current binary to: $binary_backup" + log "would stop stack with: $STACK_SCRIPT stop" + log "would create post-stop session backup" log "would install built binary to: $TARGET_BIN" - fi - - run "$STACK_SCRIPT" start - if (( EXECUTE == 1 )); then - wait_for_port 4416 - "$TARGET_BIN" --version - else - log "would wait for port 4416 and verify: $TARGET_BIN --version" + log "would start stack with: $STACK_SCRIPT start" + log "would roll back from $binary_backup if stack start or health check fails" + log "would retain rollback backup unless an interactive user confirms removal" + log "would wait for port 4416, verify $TARGET_BIN --version, and check $HEALTH_URL" fi } From 90f3ba2724309d9bc6f507507f9b4b7631bd00bb Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 08:52:13 +0000 Subject: [PATCH 13/14] chore(opencode): prompt for health password Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/update-local-opencode.sh | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh index caede60cccba..d27e91e4c49c 100755 --- a/script/update-local-opencode.sh +++ b/script/update-local-opencode.sh @@ -30,7 +30,8 @@ Environment overrides: STACK_SCRIPT default: /var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh TARGET_BIN default: /root/.opencode/bin/opencode HEALTH_URL default: http://127.0.0.1:4416/global/health - OPENCODE_SERVER_PASSWORD used for authenticated health checks when set + OPENCODE_SERVER_PASSWORD used for authenticated health checks when set; + prompts in interactive sessions when unset SESSION_DIR default: /root/.local/share/opencode BACKUP_ROOT default: /root/.local/share/opencode/backups ALLOW_DIRTY default: 0; set 1 to allow tracked worktree changes @@ -167,6 +168,31 @@ require_clean_tracked_tree() { } } +load_health_password() { + if [[ -n "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + log "using OPENCODE_SERVER_PASSWORD from environment" + return 0 + fi + + if [[ -t 0 ]]; then + local password="" + printf 'OPENCODE_SERVER_PASSWORD is unset; enter password for authenticated health checks, or leave blank to skip auth: ' + read -rs password || password="" + printf '\n' + + if [[ -n "$password" ]]; then + export OPENCODE_SERVER_PASSWORD="$password" + log "using OPENCODE_SERVER_PASSWORD from interactive prompt" + return 0 + fi + + log "no health-check password entered; health check will run without auth" + return 0 + fi + + log "OPENCODE_SERVER_PASSWORD is not set and session is non-interactive; health check will run without auth" +} + backup_sessions() { local label="$1" local backup_file="$BACKUP_ROOT/opencode-sessions-$label-$(timestamp).tar.gz" @@ -292,6 +318,8 @@ main() { log "dry-run mode; pass --execute to perform changes" fi + load_health_password + require_clean_tracked_tree ensure_remote "$UPSTREAM_REMOTE" "$UPSTREAM_URL" From 06e77fbca4d8dbf875b7347ea8447a3508c8ac33 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 09:39:43 +0000 Subject: [PATCH 14/14] chore(opencode): load update env file Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/.env.example | 20 ++++++++++++++++++++ script/update-local-opencode.sh | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 script/.env.example diff --git a/script/.env.example b/script/.env.example new file mode 100644 index 000000000000..e256a6f122ee --- /dev/null +++ b/script/.env.example @@ -0,0 +1,20 @@ +OPENCODE_SERVER_PASSWORD= +PATH=/root/.nvm/versions/node/v24.15.0/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin +GIT_AUTHOR_NAME=Sisyphus +GIT_AUTHOR_EMAIL=clio-agent@sisyphuslabs.ai +GIT_COMMITTER_NAME=Sisyphus +GIT_COMMITTER_EMAIL=clio-agent@sisyphuslabs.ai +REPO_DIR=/var/opt/opencode-upstream +UPSTREAM_URL=https://github.com/sst/opencode.git +FORK_URL=https://github.com/CryptoAlchemik/opencode.git +UPSTREAM_REMOTE=origin +UPSTREAM_BRANCH=dev +FORK_REMOTE=fork +BASE_PATCH_BRANCH=tool-timestamp-metadata +FEATURE_BRANCH=jump-user-message +STACK_SCRIPT=/var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh +TARGET_BIN=/root/.opencode/bin/opencode +HEALTH_URL=http://127.0.0.1:4416/global/health +SESSION_DIR=/root/.local/share/opencode +BACKUP_ROOT=/root/.local/share/opencode/backups +ALLOW_DIRTY=0 diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh index d27e91e4c49c..3ac13ba6f7f6 100755 --- a/script/update-local-opencode.sh +++ b/script/update-local-opencode.sh @@ -19,6 +19,7 @@ If REPO_DIR exists but is not a git checkout, --execute moves it aside to REPO_DIR.bootstrap-backup. before cloning a fresh checkout. Environment overrides: + ENV_FILE default: .env next to this script; sourced when present REPO_DIR default: /var/opt/opencode-upstream UPSTREAM_URL default: https://github.com/sst/opencode.git FORK_URL default: https://github.com/CryptoAlchemik/opencode.git @@ -46,6 +47,22 @@ case "${1:-}" in *) usage; exit 2 ;; esac +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" + +load_env_file() { + if [[ ! -f "$ENV_FILE" ]]; then + return 0 + fi + + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +} + +load_env_file + REPO_DIR="${REPO_DIR:-/var/opt/opencode-upstream}" UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/sst/opencode.git}" FORK_URL="${FORK_URL:-https://github.com/CryptoAlchemik/opencode.git}" @@ -69,6 +86,10 @@ log() { printf '[%s] %s\n' "$(timestamp)" "$*" } +if [[ -f "$ENV_FILE" ]]; then + log "loaded env file: $ENV_FILE" +fi + run() { log "+ $*" if (( EXECUTE == 1 )); then