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
51 changes: 48 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,41 @@ export function nextSessionStatus(status: SessionStatus) {
return reconcile(status)
}

// Pick the bucket the session view should render. `main` is the normal case; a
// peer child (spawn.ts) runs its turns under agentID == its own sessionID, so
// attaching to one lands on agentID "main" with an empty main bucket and must
// fall back to the self-id bucket. A session whose turns ran under an ACTOR id
// has neither key — its bucket is "build-1" / "compose-1" / "general-1" — so
// without the last arm it renders a blank pane over a full transcript.
//
// ⚠️Do not delete the last arm again. An earlier revision of this branch removed
// it on the reasoning that its only population was internal machinery. That
// inference is now backwards: the route refuses a machinery session BEFORE the
// transcript is selected (routes/session/index.tsx → session/visibility.ts), so
// this fallback can no longer be the thing that renders a checkpoint-writer
// transcript. Everything that still reaches it is a session the product has
// already decided to show. Measured on the live DB, the 1313 sessions this arm
// serves split 1302 checkpoint-writer hosts (refused upstream, never arrive
// here) and 11 `session ask` fork-query hosts whose buckets are build-1 ×7,
// compose-1 ×3, general-1 ×1 — those 11 are model-spawned read-only transcripts
// and a blank pane for them is the original bug (#1964). Those counts are one
// read-only local-DB snapshot and they drift — this arm's population grew
// 1294 → 1313 across this branch's own revisions — so trust the split's shape,
// not the absolute numbers.
export function selectMessages<M extends { id: string }>(
buckets: Record<string, M[]> | undefined,
agentID: string,
sessionID: string,
): M[] {
if (agentID !== "main" || buckets?.["main"]?.length) return buckets?.[agentID] ?? []
if (buckets?.[sessionID]?.length) return buckets[sessionID]
const newest = Object.entries(buckets ?? {})
.filter(([key, msgs]) => key !== "main" && msgs.length > 0)
.sort(([, a], [, b]) => (b.at(-1)?.id ?? "").localeCompare(a.at(-1)?.id ?? ""))
.at(0)
return newest?.[1] ?? []
}

export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
Expand Down Expand Up @@ -935,16 +970,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff, actors, task, children] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
// ⚠️`limit` is ONE budget shared across every agent bucket, not a
// per-bucket limit. A session whose real `main` history is crowded out
// of the newest 100 therefore arrives with an empty `main` and falls
// through to a non-main bucket in selectMessages above. Measured on the
// live DB: 1 of 4613 sessions with messages. Left as-is deliberately —
// a separate concern from the render prohibition — and no server work
// is needed to fix it, since this endpoint already returns up to 1000
// when `limit` is omitted.
sdk.client.session.messages({ sessionID, limit: 100, agent_id: "*" }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.actors({ sessionID }),
sdk.client.session.task({ sessionID }),
// children aren't in the root-only session list; fetch them so the
// session dialog can show the current session's child sessions.
// visible: true hides internal machinery children (checkpoint-writer
// hosts, ask-tool forks, workflow subagent sessions) — only peer
// sessions the user should see are returned.
// visible: true returns only peer children, dropping the two other
// kinds of child session that exist — the checkpoint-writer host
// (session/checkpoint.ts:851) and the `session ask` fork-query host
// (tool/session.ts:128). See Session.children for why "workflow
// subagent sessions" is not a third kind.
sdk.client.session.children({ sessionID, visible: true }).catch(() => undefined),
])
setStore(
Expand Down
47 changes: 36 additions & 11 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Dynamic } from "solid-js/web"
import path from "path"
import { useCurrentAgentID, useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { selectMessages, useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
Expand All @@ -34,6 +34,7 @@ import type {
} from "@mimo-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util"
import { verifySessionRenderable, type SessionActorInput } from "@/session/visibility"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
Expand Down Expand Up @@ -172,16 +173,9 @@ export function Session() {
const session = createMemo(() => sync.session.get(route.sessionID))
const currentAgentID = useCurrentAgentID()
const actors = createMemo(() => sync.data.actor[route.sessionID] ?? [])
const messages = createMemo(() => {
const buckets = sync.data.message[route.sessionID]
const agentID = currentAgentID()
// A peer child runs its own turns under agentID == its own sessionID
// (spawn.ts), so its messages bucket under [sessionID] not ["main"]. When
// attaching to such a child at "main", fall back to its own-id bucket so the
// full session renders instead of an empty "main" view.
if (agentID === "main" && !buckets?.["main"]?.length) return buckets?.[route.sessionID] ?? []
return buckets?.[agentID] ?? []
})
const messages = createMemo(() =>
selectMessages(sync.data.message[route.sessionID], currentAgentID(), route.sessionID),
)
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const questions = createMemo(() => sync.data.question[route.sessionID] ?? [])
const visible = createMemo(
Expand Down Expand Up @@ -260,6 +254,37 @@ export function Session() {
return
}

// The prohibition. Every way of reaching this route hands a raw session id
// straight to the renderer and bypasses both hiding layers: -s/--session
// (thread.ts → app.tsx), `attach --session`, POST /tui/select-session, POST
// /tui/event, the session tool's `switch`, MIMOCODE_ROUTE, plugin
// navigate("session", …) and the session-list dialog's child injection. This
// effect is the one point all of them must pass, so the refusal lives here
// rather than on any single entry point. What counts as forbidden lives in
// session/visibility.ts: a host for a RUNTIME-spawned agent, which today
// means the checkpoint writer. It reads the session's own actor rows, so no
// parent round-trip is needed.
const verdict = await verifySessionRenderable(result.data, (sessionID) =>
// `throwOnError` is load-bearing, not tidiness: without it this client
// RESOLVES `{ data: undefined }` on an HTTP error, which the classifier
// reads as "this session has no actor rows" and renders. The failure has to
// arrive as a rejection for the gate to see it as unverified rather than as
// verified-absent.
// SessionActorsResponses[200] is generated as `unknown`, so the shape is
// asserted here exactly as sync.tsx does for the same endpoint.
sdk.client.session
.actors({ sessionID }, { throwOnError: true })
.then((res) => res.data as SessionActorInput[] | undefined),
)
if (!verdict.renderable) {
toast.show({
message: `Cannot open session: ${verdict.reason}`,
variant: "error",
})
navigate({ type: "home" })
return
}

if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)

Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,22 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
if (!options?.visible) return rows.map(fromRow)
if (!rows.length) return []
// visible: only children a user should see in session lists. Peer actors
// register under the child session with actor_id === session id; internal
// machinery children (checkpoint-writer hosts, ask-tool forks, workflow
// subagent sessions) register as mode "subagent" or have no actor row at
// all — both are filtered out.
// register under the child session with actor_id === session id. Exactly
// three code paths create a child session, so the two dropped here are
// the checkpoint-writer host (session/checkpoint.ts:851, mode "subagent")
// and the `session ask` fork-query host (tool/session.ts:128, title
// `ask: …`, mode "subagent"); a pre-registry child with no actor row at
// all is dropped too.
//
// ⚠️This list used to also name "workflow subagent sessions". There is no
// such session: a workflow's agent() calls actor.spawn with
// sessionID = its OWN session (workflow/runtime.ts:814-816, :945-948), so
// it registers an actor, not a child session.
//
// ⚠️This filter is NOT the render prohibition. Being absent here means
// "not offered in a list"; what may never be RENDERED is narrower and
// lives in session/visibility.ts (runtime-spawned agent hosts only, so the
// ask fork above is listed nowhere but is still renderable).
const peerRows = yield* db((d) =>
d
.select({ session_id: ActorRegistryTable.session_id })
Expand Down
Loading
Loading