Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ file is the human-readable summary of what changed and why.

### Sessions

- **Sessions from before August 14, 2026 answer again, with messages in the
right order** — in any session started before that date, a new message
appeared at the top of the conversation instead of the bottom, and the
agent never replied at all: the session looked dead no matter what you
typed. Message IDs carry a timestamp that silently wraps around every ~26
months, and August 14 was a wrap: IDs minted since then sort before IDs
minted before. The app ordered messages by ID, so new bubbles landed on
top — and the server picked "the newest message" by ID too, so it decided
your fresh question had already been answered by a reply from before the
wrap and quietly did nothing. Both now go by when messages were actually
created, the way the message store itself already did, so old sessions
display correctly, respond again, and the next wrap (October 2028) changes
nothing.

- **CoDev uploads your sessions for analysis on its own — no hub required** —
session transcripts used to reach the CoDev analysis backend only on
machines with CoDev Hub installed, because the hub's launch wrapper did the
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ from the squash message first, or use a plain merge.

| Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. |
| Prompt permission mode: `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/context/permission.tsx`, `packages/app/src/pages/session/use-session-commands.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es,vi}.ts` | prompt-input-v2.tsx: the `modelControl` slot now carries a fragment — upstream's `<PromptInputV2ModelControl>` plus the fork-only `<PromptInputV2PermissionControl>`, an Auto/Manual select built on session-ui's exported `PromptInputV2Select` (riding the model slot keeps it immediately right of the model select without a new slot in session-ui). It reads and writes the same client-side auto-accept state the `permissions.autoaccept` command toggles: a session takes an explicit override, a draft steers the directory default (`src/components/prompt-input/permission-mode.ts`, fork-only, holds that decision). A directory whose server config says `permission: "allow"` renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: `enableConfiguredDirectory` gains an `autoByDefault` parameter, passed as `sideForServerKey(activeServer()) === "work"`, so Work directories seed to auto-accept, and exposes `side()` so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the `permissions.autoaccept` command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await `useAutoAcceptConsent()` (fork-only `src/components/dialog-auto-accept.tsx`): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's `enableAutoApprove`; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the `prompt.permission.*` and `dialog.autoAccept.*` families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (`permission: "allow"`, dropped in `packages/desktop/src/main/sandbox/egress.ts` — fork-only, untracked here): a server told to allow everything never emits `permission.asked`, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the `<PromptInputV2ModelControl>` inside it. The three enable paths each gained one `await consent(...)` line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. `enableConfiguredDirectory`'s extra parameter is additive — if upstream rewrites that function, keep the `autoByDefault` branch or the Work side loses its auto default. |
| Message order across the ID wrap: `packages/app/src/context/{server-session,sync}.ts(x)`, `packages/app/src/context/global-sync/event-reducer.ts`, `packages/opencode/src/session/{message-v2,prompt}.ts` (+ their tests) | Session message arrays are kept sorted by `(time.created, id)` — the fork-only `src/utils/message-order.ts` holds the comparator (`compareMessages`), a matching string key for `Binary.search` inserts (`messageOrderKey`), and a linear `messageIndexByID` for the membership/removal lookups that only have an ID in hand. `merge`/`reconcileFetched` gained an optional comparator (message call sites pass it; sessions/parts keep ID order); the local `cmpMessage` was replaced by the shared comparator. Server side, `MessageV2.latest` derives its user/assistant/finished bindings via a new exported `chronological(a, b)` (time.created, then ID) instead of max ID, its `tasks` filter compares the same way, and the prompt loop's already-answered exit (`lastUser.id < lastAssistant.id`) uses the comparator. | Upstream's ID encoder keeps only 48 bits of `timestamp*4096`, so the time prefix wraps every 2^36 ms — last on 2026-08-14T11:19:55Z. Every ID minted before a wrap sorts lexically after every ID minted since, and the arrays were ID-ordered: an old session rendered each new bubble at the top, and every "latest message" read (`.at(-1)`) answered with its oldest — and worse, such sessions were DEAD: `MessageV2.latest` picked the pre-wrap turn as newest, so the V1 prompt loop promoted the new user message and then exited believing it was already answered, never running a turn. The server already pages by `(time_created, id)` (message-v2.ts); this aligns the client with it and stays correct across the next wrap (2028-10-18). Parts stay ID-ordered — one message's parts are minted together, on one side of a wrap. | Keep the comparator-driven ordering and the linear ID lookups; take upstream's surrounding changes. If upstream fixes the identifier encoding itself, keep this anyway — pre-wrap IDs are stored in every existing database, so ID order alone can never be trusted again. Known siblings left alone: the TUI's `src/context/sync.tsx` still inserts by bare ID, and `revert.ts`/`session.ts` still compare revert/copy boundary markers by bare ID (they only have an ID in hand). |

## Known rebrand gaps

Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,44 @@ describe("applyDirectoryEvent", () => {
expect(store.part.msg_2).toBeUndefined()
})

// The ID time field wraps every 2^36 ms (last on 2026-08-14), so a session
// can hold messages whose IDs sort against their age: the older message's
// ID here is lexically greater. Insertion must follow creation time, not ID.
test("inserts a new message after an older one whose wrapped ID sorts greater", () => {
const sessionID = "ses_1"
const older = {
...userMessage("msg_ff9d36308001Jyuhn6zym7JpS5", sessionID),
time: { created: 100 },
} as Message
const newer = {
...userMessage("msg_036951f3d001nUpvUTuLP49eTl", sessionID),
time: { created: 200 },
} as Message
const [store, setStore] = createStore(baseState({ message: { [sessionID]: [older] } }))

applyDirectoryEvent({
event: { type: "message.updated", properties: { info: newer } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})

expect(store.message[sessionID]?.map((x) => x.id)).toEqual([older.id, newer.id])

applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: older.id } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})

expect(store.message[sessionID]?.map((x) => x.id)).toEqual([newer.id])
})

test("upserts and prunes message parts", () => {
const sessionID = "ses_1"
const messageID = "msg_1"
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { messageIndexByID, messageOrderKey } from "@/utils/message-order"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
Message,
Expand Down Expand Up @@ -209,16 +210,16 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
const index = messageIndexByID(messages, info.id)
if (index >= 0) {
input.setStore("message", info.sessionID, index, reconcile(info))
break
}
input.setStore(
"message",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
draft.splice(Binary.search(draft, messageOrderKey(info), messageOrderKey).index, 0, info)
}),
)
break
Expand All @@ -229,8 +230,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
const index = messageIndexByID(messages, props.messageID)
if (index >= 0) messages.splice(index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/context/server-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1332,7 +1332,10 @@ describe("server session", () => {

await store.sync("child", { force: true })

expect(store.data.message.child).toEqual([boundary, older])
// Time-ordered: older (created 1) sorts before boundary (created 2) even
// though its ID (msg_z) is lexically greater — the array follows the same
// creation-time order the refresh boundary uses.
expect(store.data.message.child).toEqual([older, boundary])
})

test("preserves a part update for a message being loaded from history", async () => {
Expand Down
Loading
Loading