Skip to content

LAC-2281: Lash Upstream Sync — 2026-06-01 (256 commits)#26

Merged
lacymorrow merged 258 commits into
devfrom
LAC-2277/upstream-sync-2026-06-01
Jun 16, 2026
Merged

LAC-2281: Lash Upstream Sync — 2026-06-01 (256 commits)#26
lacymorrow merged 258 commits into
devfrom
LAC-2277/upstream-sync-2026-06-01

Conversation

@lacymorrow

Copy link
Copy Markdown
Owner

Summary

Merges upstream/dev (anomalyco/opencode) into lash dev through commit d7ba8b1125 fix(stats): center top models dot grid256 new commits.

Routine LAC-2277 escalated this sync as LAC-2281 because the auto-merge produced 25 conflicts.

Notable upstream changes

  • Bus refactor (refactor(core): move database schema ownership anomalyco/opencode#29068): packages/opencode/src/bus/ barrel collapsed to just bus/global.ts. Ambient publishers must call GlobalBus.emit("event", { directory, payload }); ambient subscribers use GlobalBus.on("event", handler). Event registration moves from BusEvent.define to EventV2.define({ type, schema }).
  • Provider schema relocation: ProviderID/ModelID for session payloads now come from @opencode-ai/core/provider (ProviderV2.ID, ModelID); the legacy packages/opencode/src/provider/schema is gone.
  • TUI worker / app render tree continued evolving (createCliRenderer, TuiPluginRuntime).
  • SDK generated types reorganized again — EventCwdUpdated preserved manually.

Lash adaptations layered on top

  • Migrated plugin/shell-mode/cwd.ts CwdEvent from BusEvent.define + Bus.publish to EventV2.define + GlobalBus.emit so the ambient (non-Effect) plugin keeps publishing cwd.updated events with the same payload shape consumers expect.
  • Migrated plugin/shell-mode/session-shell.ts idle-shell cleanup to filter session.idle payloads through GlobalBus.on rather than the removed Bus.subscribe.
  • Updated test/plugin/cwd.test.ts to subscribe via GlobalBus.
  • Pointed test/session/prompt-variant.test.ts at @opencode-ai/core/provider (ProviderV2.ID, ModelID).
  • Resolved complex conflicts per UPSTREAM_SYNC.md playbook:
    • packages/opencode/src/cli/cmd/tui/app.tsx — kept upstream render tree, layered lash ExecutionModeProvider + WorkingDirProvider.
    • packages/sdk/js/src/v2/gen/types.gen.ts — kept upstream regeneration, preserved EventCwdUpdated in 3 conflict regions.
    • packages/core/src/catalog.ts — took upstream's event-driven State.update, dropped lash Semaphore (no longer needed; state.update provides serialization).
    • packages/app/src/components/titlebar.tsx — kept lash's nextHref-before-navigate refactor, added upstream's void lint marker on startTransition.
    • packages/app/src/context/directory-sync.ts — combined lash's dir rename (fix for self-shadow bug) with upstream's { mcp: true } option.
    • packages/app/src/context/global-sync/child-store.ts — kept upstream fallback structure, added lash's cwd: "" field.

Invariants verified (per UPSTREAM_SYNC.md)

  • agent_cycle keybind still shift+tab (packages/opencode/src/cli/cmd/tui/config/keybind.ts:126).
  • EventCwdUpdated preserved in SDK event union and event type alias (packages/sdk/js/src/v2/gen/types.gen.ts:49, :1443, :3270).
  • getCwd() / setCwd() / shell-mode integration intact in packages/opencode/plugin/shell-mode/cwd.ts.
  • TUI prompt row-layout / double-left border not regressed.

Test plan

  • bun install clean — lockfile regenerated.
  • bun turbo typecheck — 21/21 packages clean.
  • Manual TUI smoke (cd / agent_cycle / prompt) — to be done by reviewer if desired.
  • CI pipeline.

Refs: LAC-2281

rekram1-node and others added 30 commits May 25, 2026 16:42
thdxr and others added 22 commits May 31, 2026 14:00
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
…30167)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.
…o#30145)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
…anomalyco#29710)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Merges anomalyco/opencode `dev` into lash through commit
d7ba8b1 fix(stats): center top models dot grid.

Notable upstream changes:
- refactor(core): move database schema ownership (anomalyco#29068) — removed
  packages/opencode/src/bus barrel, leaving only bus/global.ts.
- refactor: collapse bus barrel into bus/index.ts (anomalyco#22902).
- ProviderID/ModelID for session payloads now live in
  @opencode-ai/core/provider as ProviderV2.ID and ModelID.

Lash adaptations layered on top:
- Migrated plugin/shell-mode/cwd.ts CwdEvent from BusEvent.define +
  Bus.publish to EventV2.define + GlobalBus.emit so the ambient
  (non-Effect) plugin keeps publishing cwd.updated events with the
  same payload shape consumers expect.
- Migrated plugin/shell-mode/session-shell.ts to filter session.idle
  events through GlobalBus.on rather than the removed Bus.subscribe.
- Updated test/plugin/cwd.test.ts to subscribe via GlobalBus.
- Pointed test/session/prompt-variant.test.ts at
  @opencode-ai/core/provider for ProviderV2.ID / ModelID after the
  legacy src/provider/schema module was removed upstream.
- Kept invariants from UPSTREAM_SYNC.md: agent_cycle keybind stays
  shift+tab, EventCwdUpdated remains in the SDK event union, ExecutionMode/
  WorkingDir providers wrap <App />, titlebar nav refactor preserved with
  upstream void lint marker, child-store fallback adds cwd: "", and
  directory-sync.ts keeps lash's dir rename plus upstream { mcp: true }.

Refs: LAC-2281

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new AWS S3 Tables and Iceberg data lake infrastructure, a new CLI package for debugging agents, a support lookup tool, and a redesigned layout and composer UI. It also optimizes billing and usage tracking by batching updates in Redis for high-traffic workspaces and migrating rate-limiting state entirely to Redis. The review feedback highlights a critical billing bug where paid usage could be lost during flushes, unsafe navigation side-effects inside an Immer producer, a potential fetch TypeError for GET/HEAD requests in the stats proxy, a Redis memory leak due to missing TTLs, and style guide violations regarding star and aliased imports.


const workspaceDelta = balanceFlush.flush?.workspaceCost ?? cost
const userDelta = balanceFlush.flush?.userCost ?? cost
const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Billing Bug: Paid Usage Lost on Free/BYOK Flushes

When balanceFlush.batched is true, workspaceDelta represents the accumulated paid balance that needs to be deducted. However, if the current request that triggers the flush happens to be a free or byok request, balanceDelta is set to 0 instead of workspaceDelta:

const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta

This means any accumulated paid usage from previous requests in the batch is completely lost and never deducted from the workspace balance.

Since workspaceCost in Redis only accumulates paid costs (free/byok requests write 0 to workspaceCost on line 991), workspaceDelta is always the correct amount to deduct regardless of the current request's billing source.

Suggested change
const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta
const balanceDelta = balanceFlush.batched ? workspaceDelta : (billingSource === "free" || billingSource === "byok" ? 0 : cost)

Comment on lines +300 to +327
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")

for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}

if (!removedCurrent) return
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Side-Effects Inside Immer produce Callback

Calling navigate inside the produce callback is unsafe. Immer's produce should be a pure function that only mutates the draft state. Triggering routing transitions or other side-effects during the store update phase can lead to race conditions, double-renders, or inconsistent UI states because the store write has not yet committed.

Instead, determine the target nextHref inside the producer and perform the navigation side-effect outside of setStore.

                removeSessions: (input: SessionTabsRemovedDetail) => {
                  void startTransition(() => {
                    let nextHref: string | undefined
                    setStore(
                      produce((tabs) => {
                        const sessionIDs = new Set(input.sessionIDs)
                        const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
                        const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
                        const removedCurrent =
                          currentIndex !== -1 &&
                          tabs[currentIndex]?.dir === input.directory &&
                          sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")

                        for (let i = tabs.length - 1; i >= 0; i--) {
                          const tab = tabs[i]
                          if (!tab) continue
                          if (tab.dir !== input.directory) continue
                          if (!sessionIDs.has(tab.sessionId)) continue
                          tabs.splice(i, 1)
                        }

                        if (removedCurrent) {
                          const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
                          nextHref = nextTab ? nextTab.href : "/"
                        }
                      }),
                    )
                    if (nextHref !== undefined) {
                      navigate(nextHref)
                    }
                  })
                },

Comment on lines +15 to +19
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body: req.body,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

TypeError: GET/HEAD Requests Cannot Have a Body

Passing req.body to fetch when the method is GET or HEAD will throw a TypeError in standard Fetch environments (including Cloudflare Workers, Node.js, and Bun). Since statsProxy proxies all HTTP methods, GET requests will crash on this call.

Ensure body is set to undefined for GET and HEAD requests.

Suggested change
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body: req.body,
})
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
})

Comment on lines +14 to +19
export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) {
const redis = getRedis()
const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}`
const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}`

await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redis Memory Leak: Missing TTL on Usage Keys

Accumulating usage with redis.incrby without setting an expiration (TTL) will cause keys to persist indefinitely in Redis. Over time, as workspaces and users become inactive, this will lead to a memory leak.

Use a Redis pipeline to atomically increment the values and set a reasonable TTL (e.g., 30 days) to automatically clean up inactive keys.

Suggested change
export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) {
const redis = getRedis()
const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}`
const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}`
await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)])
export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) {
const redis = getRedis()
const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}`
const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}`
const pipeline = redis.pipeline()
pipeline.incrby(wKey, workspaceCost)
pipeline.expire(wKey, 30 * 24 * 60 * 60)
pipeline.incrby(uKey, userCost)
pipeline.expire(uKey, 30 * 24 * 60 * 60)
await pipeline.exec()

Comment on lines +134 to +136
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive Programming: Guard Against Null/Undefined Connections

If list contains any null or undefined elements, attempting to access conn.http will throw a TypeError. Adding a simple guard ensures robustness.

        list.map(async (conn) => {
          if (!conn) return
          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
        }),

Comment on lines +4 to +5
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Style Guide Violation: Star Imports

This import violates the repository style guide rule: 'Never use star imports. Do not use import * as Foo from "..." or import type * as Foo from "...".'

Import the named exports directly instead.

Suggested change
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Effect } from "effect"
import { Command } from "effect/unstable/cli/Command"
References
  1. Never use star imports. Do not use import * as Foo from "..." or import type * as Foo from "...". (link)

Comment on lines +3 to +19
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"

export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type Effect = typeof Effect.Type

export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
resource: Schema.String,
}) {}

export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
readonly hasStatements: () => boolean

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Style Guide Violation: Aliased Import

This import violates the repository style guide rule: 'Never alias imports. Do not use import { foo as bar } from "..." or renamed imports like resolve as pathResolve.'

To avoid the naming conflict with the local Effect schema/type, rename the local schema/type to PolicyEffect and import Effect directly from 'effect' without aliasing.

Suggested change
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type Effect = typeof Effect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
readonly hasStatements: () => boolean
import { Context, Effect, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
export const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type PolicyEffect = typeof PolicyEffect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: PolicyEffect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => Effect.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: PolicyEffect) => Effect.Effect<PolicyEffect>
References
  1. Never alias imports. Do not use import { foo as bar } from "..." or renamed imports like resolve as pathResolve. (link)

Gemini code review on PR #26 flagged that removeSessions ran
navigate() inside the Immer produce callback, which is unsafe — produce
should be pure mutation only, and triggering routing during the store
write phase can cause race conditions and double-renders. removeTab
already followed the correct pattern (compute nextHref inside produce,
navigate outside); mirror it for removeSessions.

Refs: LAC-2281
@lacymorrow

Copy link
Copy Markdown
Owner Author

Thanks @gemini-code-assist. Triage for this upstream sync PR:

Addressed

  • packages/app/src/components/titlebar.tsx — fixed. removeSessions now mirrors removeTab's pattern (compute nextHref inside produce, navigate outside the producer). Pushed as c04019d.

Deferred — pre-existing upstream code (out of scope for this sync)
This PR merges 256 upstream commits. The remaining findings are on files lash does not modify and originate from upstream commits, so they should be reported / fixed against anomalyco/opencode rather than blocking this sync:

If we patched these in this PR we'd be reverting upstream design choices without owning the broader context; better to track them as separate upstream-bug tickets if they bite us.

@gemini-code-assist

Copy link
Copy Markdown

Thanks for the update, @lacymorrow. I've reviewed the triage list and agree that tracking these as separate upstream-bug tickets is the right approach to maintain the integrity of this sync. The fix for packages/app/src/components/titlebar.tsx looks correct and aligns with the existing patterns. I'll mark this as ready for final review.

@lacymorrow lacymorrow merged commit 64998f4 into dev Jun 16, 2026
2 of 16 checks passed
@lacymorrow lacymorrow deleted the LAC-2277/upstream-sync-2026-06-01 branch June 16, 2026 20:51
lacymorrow added a commit that referenced this pull request Jun 16, 2026
Rebased on top of dev after PR #26 (LAC-2281, 2026-06-01 sync) merged.
Many original conflicts auto-resolved because dev already contained 256
of the 303 upstream commits. Remaining conflicts resolved per
UPSTREAM_SYNC.md playbook with all lash invariants preserved:

- agent_cycle = shift+tab in TUI keybind config
- EventCwdUpdated in SDK event union (incl. GlobalEvent.payload)
- getCwd()/setCwd() in shell-mode + prompt.ts cwd sentinel
- Double-left-border row layout in prompt/index.tsx
- ExecutionModeProvider + WorkingDirProvider wrap <App /> in app.tsx
- tabs.tsx removeSessions navigate() hoisted out of produce callback
  (mirrors c04019d pattern for titlebar.tsx)

Post-merge typecheck fixes included:
- which() import path (filesystem services consolidation)
- @/, @shell-mode, @tui-integration aliases in tui + cli tsconfigs
  (after upstream TUI extraction 106f8e9)
- *.wasm module declaration
- ModelID via ModelV2.ID after upstream provider/index.ts removal

Refs: LAC-2337
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.