788 lines
33 KiB
TypeScript
788 lines
33 KiB
TypeScript
/**
|
||
* SessionsService: root sessions service — list snapshot store (manager
|
||
* projection; carries `current`, the persisted selection every
|
||
* session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
|
||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||
* id), stable SessionBinding cache, breadcrumb-route projection.
|
||
*
|
||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||
* resolution (pure — resolution has no side effects and is render-safe);
|
||
* the event window and deferred teardown key off the STAGED session, which
|
||
* follows `list.current` exactly. Staging is the open signal: the window
|
||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||
* state can widen to a multi-pane list later). A session leaving the list
|
||
* tears its scope down immediately unless it is the staged one, whose scope
|
||
* survives frozen (read-only view) until the stage moves on.
|
||
*/
|
||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||
import type {
|
||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||
} from '@deepseek-ai/dsh-client-connection/client'
|
||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||
// plugin-to-plugin value imports are a bundle purity error.
|
||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||
import type {
|
||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||
import type { SnapshotStore } from '../contract/store.ts'
|
||
import { createSnapshotStore } from '../contract/store.ts'
|
||
import type { SessionFace } from '../contract/session.ts'
|
||
import type { AgentContext, ISessions } from '../contract/sessions.ts'
|
||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||
import type { ConversationRuntime } from './conversation-assembler.ts'
|
||
import { SessionManager } from './manager.ts'
|
||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||
import type { PendingInteractionStatus } from './pending.ts'
|
||
import { SessionProvideChannel } from './provide.ts'
|
||
import type { Session } from './session.ts'
|
||
|
||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||
export interface SessionSummary {
|
||
id: SessionId
|
||
/** Latest durable log-backed title, absent until the host projects one. */
|
||
title?: string
|
||
/** Human-facing label: durable title, project basename, then session id. */
|
||
displayTitle: string
|
||
cwd?: string
|
||
/**
|
||
* Agent preset this session's agent was composed from; absent when the
|
||
* deployment composes no presets. The session header labels what the
|
||
* session actually runs rather than the deployment's current default.
|
||
*/
|
||
agentPreset?: string
|
||
parentId?: SessionId
|
||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||
origin?: 'subagent'
|
||
running: boolean
|
||
/** User interaction currently blocking this session (sidebar amber-dot state). */
|
||
pendingInteraction?: PendingInteractionStatus
|
||
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
|
||
completed?: boolean
|
||
/**
|
||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||
* store carries every row, while the Workspace browser shows only the
|
||
* selected blank entry.
|
||
*/
|
||
blank: boolean
|
||
updatedAt: number
|
||
/** Current host-computed projection values retained by the object layer. */
|
||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||
}
|
||
|
||
/**
|
||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||
* the single useSessions standard hook reads list and selection together —
|
||
* sidebar highlighting and SessionProvider share one fact source).
|
||
*/
|
||
export interface SessionListState {
|
||
/** Host-list order; addressed breadcrumb-only rows are excluded. */
|
||
ids: SessionId[]
|
||
/** Host rows plus the current addressed subagent route used by navigation. */
|
||
byId: Record<SessionId, SessionSummary>
|
||
current: SessionId | undefined
|
||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||
phase: SessionListPhase
|
||
/** Direct durable catalogs keyed by their selected parent address. */
|
||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||
/**
|
||
* Background tasks each session can see, mirrored last-wins from
|
||
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
|
||
* for a session without tasks — so consumers read absence, never a sentinel.
|
||
*/
|
||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||
currentAddress: SubagentAddress | undefined
|
||
}
|
||
|
||
/** Persisted navigation cell: address survives refresh for correct history routing. */
|
||
interface SessionSelection {
|
||
sessionId?: SessionId
|
||
subagentAddress?: SubagentAddress
|
||
}
|
||
|
||
/** Structured session-create failure. */
|
||
export class SessionCreateError extends Error {
|
||
override readonly name = 'SessionCreateError'
|
||
|
||
/**
|
||
* @param rpcError - Host business or folded transport error.
|
||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||
*/
|
||
constructor(
|
||
readonly rpcError: RpcError,
|
||
readonly requestedSessionId: SessionId | undefined,
|
||
) {
|
||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||
}
|
||
}
|
||
|
||
/** Structured session-fork failure. */
|
||
export class SessionForkError extends Error {
|
||
override readonly name = 'SessionForkError'
|
||
|
||
/**
|
||
* @param rpcError - Host business or folded transport error.
|
||
* @param sourceSessionId - the session the fork was cut from.
|
||
*/
|
||
constructor(
|
||
readonly rpcError: RpcError,
|
||
readonly sourceSessionId: SessionId,
|
||
) {
|
||
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
|
||
}
|
||
}
|
||
|
||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||
export interface SessionBinding {
|
||
readonly sessionId: SessionId
|
||
/** The outward session face only — feature code never sees the concrete class. */
|
||
readonly session: SessionFace
|
||
readonly ctx: AgentContext
|
||
}
|
||
|
||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||
// consumers keep their import site.
|
||
export { scopeOf } from '../agents/scope.ts'
|
||
|
||
/**
|
||
* Workspace display title of a session cwd: the path's last non-empty
|
||
* segment (both separators accepted; trailing separators ignored), or ''
|
||
* for separator-only paths — callers own their fallback (session id, raw
|
||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||
* calls this instead of re-splitting paths.
|
||
* @param cwd - workspace directory path.
|
||
* @returns basename title, or '' when no non-empty segment exists.
|
||
*/
|
||
export function workspaceTitleOf(cwd: string): string {
|
||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||
}
|
||
|
||
/**
|
||
* Display title projection: durable title, project directory basename, then
|
||
* the raw id.
|
||
*/
|
||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||
if (title !== undefined) return title
|
||
if (cwd !== undefined && cwd !== '') {
|
||
const base = workspaceTitleOf(cwd)
|
||
if (base !== '') return base
|
||
}
|
||
return id
|
||
}
|
||
|
||
/**
|
||
* Increment a trailing fork number while preserving its half-width or
|
||
* full-width parentheses; an unnumbered title starts with ` (1)`.
|
||
* @param title - source session's durable title.
|
||
* @returns the title assigned to the fork child.
|
||
*/
|
||
function increasedForkTitle(title: string): string {
|
||
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
|
||
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
|
||
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
|
||
}
|
||
const fullWidth = /^(.*?)((\d+))$/u.exec(title)
|
||
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
|
||
return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
|
||
}
|
||
return `${title} (1)`
|
||
}
|
||
|
||
interface ScopeRecord {
|
||
fiber: Fiber
|
||
ctx: AgentContext
|
||
binding: SessionBinding
|
||
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
|
||
session: Session
|
||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||
provideInfo: SessionProvideInfo
|
||
}
|
||
|
||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||
export interface SessionProvideContribution {
|
||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||
hooks?: Record<string, HostObservable<unknown>>
|
||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||
props?: Record<string, unknown>
|
||
}
|
||
|
||
/**
|
||
* Static declaration plus per-session resolver for one standard-kit
|
||
* contribution. The declared names let the renderer construct the same hook
|
||
* and prop surface while no session is current.
|
||
*/
|
||
export interface SessionProvideDescriptor {
|
||
/** Hook base names (`input` becomes `useInput`). */
|
||
hooks?: readonly string[]
|
||
/** Plain standard-prop names. */
|
||
props?: readonly string[]
|
||
/** Resolve every declared member for one definite session. */
|
||
resolve(binding: SessionBinding): SessionProvideContribution
|
||
}
|
||
|
||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
|
||
export class SessionsService implements ISessions {
|
||
/**
|
||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||
* injected data. Not per-connection state: the `session.search` response
|
||
* schema caps `items` at this constant, so every transport (fixture included)
|
||
* reports the same number.
|
||
*/
|
||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||
readonly list: SnapshotStore<SessionListState>
|
||
/** The object-layer instance cluster and frame dispatch entry. */
|
||
private readonly manager: SessionManager
|
||
/**
|
||
* Atomic current-session provide projection: selection changes and
|
||
* provider-roster changes publish through this one source (the renderer
|
||
* host's `sessions.provide` feed), so a roster change under a stable
|
||
* current id republishes the bundle instead of stranding mounted entries.
|
||
*/
|
||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||
|
||
/**
|
||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||
* purpose: reads go through the list snapshot; writes through {@link
|
||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||
* validates it against the live list instead of destructively pruning, so a
|
||
* selection survives transient list states (reconnect re-pull) and
|
||
* resurfaces when its session returns.
|
||
*/
|
||
private readonly selection: SnapshotStore<SessionSelection>
|
||
|
||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
|
||
private readonly provideChannel: SessionProvideChannel
|
||
/**
|
||
* The staged session id — follows `list.current` exactly, holding its last
|
||
* defined value across masked gaps (a transiently absent selection blanks
|
||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||
* keep the staged scope's frozen view alive until the stage moves on).
|
||
*/
|
||
private watched: SessionId | undefined
|
||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||
private readonly deferredRemovals = new Set<SessionId>()
|
||
|
||
/**
|
||
* @param ctx - client root context (scope fibers mount under it).
|
||
* @param api - wire client shared with every Session.
|
||
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
|
||
*/
|
||
constructor(
|
||
private readonly rootCtx: Context,
|
||
api: IApiClient,
|
||
conversationRuntime?: ConversationRuntime,
|
||
) {
|
||
this.selection = createSnapshotStore<SessionSelection>(
|
||
{},
|
||
{ persist: { name: 'dsh.sessions.current' } })
|
||
const restored = this.selection.getSnapshot()
|
||
const conversationEvents = rootCtx.get('conversationEvents')
|
||
const conversationViews = rootCtx.get('conversationViews')
|
||
const conversation = conversationRuntime ?? (
|
||
conversationEvents === undefined || conversationViews === undefined
|
||
? undefined
|
||
: { events: conversationEvents, views: conversationViews }
|
||
)
|
||
this.manager = new SessionManager(
|
||
api,
|
||
restored.sessionId,
|
||
restored.subagentAddress,
|
||
conversation,
|
||
)
|
||
this.list = createSnapshotStore<SessionListState>({
|
||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||
})
|
||
// The manager owns wire truth; the store is its projection. Manager
|
||
// notifications are already microtask-batched.
|
||
this.manager.subscribe(() => { this.projectList() })
|
||
// Stage follower: every current write (open() and projection alike)
|
||
// re-evaluates staging, so startup restore (persisted selection validated
|
||
// by the projection) and reconnect resurfacing open their window with no
|
||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||
// the follower writes no list state — session.open()'s synchronous prefix
|
||
// touches only session-side state and its own microtask-batched notifier.
|
||
// The current-provide projection follows the same current writes.
|
||
this.list.subscribe(() => {
|
||
this.followCurrent()
|
||
this.provideChannel.publishCurrent()
|
||
})
|
||
this.provideChannel = new SessionProvideChannel({
|
||
rebuildBundles: () => {
|
||
for (const record of this.scopes.values()) {
|
||
record.provideInfo = this.provideChannel.materializeInfo(record.binding)
|
||
}
|
||
},
|
||
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
|
||
})
|
||
this.currentProvideInfo = this.provideChannel.currentProvideInfo
|
||
let registryRebuildQueued = false
|
||
const scheduleRegistryRebuild = (): void => {
|
||
if (registryRebuildQueued) return
|
||
registryRebuildQueued = true
|
||
queueMicrotask(() => {
|
||
registryRebuildQueued = false
|
||
this.manager.rebuildConversationRegistry()
|
||
})
|
||
}
|
||
if (conversation !== undefined) {
|
||
rootCtx.effect(() => {
|
||
const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild)
|
||
const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild)
|
||
return () => {
|
||
disposeEvents()
|
||
disposeViews()
|
||
}
|
||
}, 'sessions: conversation registry rebuild')
|
||
}
|
||
rootCtx.reflect.provide('sessions', this, undefined)
|
||
}
|
||
|
||
/**
|
||
* Register a per-session standard-props provider: every session-scope slot
|
||
* component receives the contributed members as standard props (`hooks`
|
||
* sources become `use<Name>` selector hooks on the render side; `props`
|
||
* spread verbatim). Contributions materialize lazily with the session's
|
||
* scope record and die with it. Registration order is resolution order;
|
||
* duplicate member names fail loud at materialization.
|
||
* @param descriptor - static member roster plus per-session resolver.
|
||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||
*/
|
||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||
// Scopes may already exist (boot order: the list lands and resolves
|
||
// scopes before later plugins register) — the channel rebuilds their
|
||
// bundles through the host hooks so every provider lands by first render.
|
||
return this.provideChannel.provide(descriptor)
|
||
}
|
||
|
||
/**
|
||
* Select a listed or retained catalog-addressed session as current.
|
||
* @param id - listed or addressed session id.
|
||
*/
|
||
open(id: SessionId): void {
|
||
this.manager.select(id)
|
||
}
|
||
|
||
/**
|
||
* Open a healthy catalog child through its direct-parent address.
|
||
* @param address - catalog-derived parent and child ids.
|
||
*/
|
||
openSubagent(address: SubagentAddress): void {
|
||
this.manager.selectSubagent(address)
|
||
}
|
||
|
||
/**
|
||
* Resolve an already discovered direct-parent address without opening it.
|
||
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
|
||
* @param id - possible addressed child id.
|
||
* @returns The retained address, when present.
|
||
*/
|
||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||
return this.manager.subagentAddress(id)
|
||
}
|
||
|
||
/**
|
||
* Inform the runtime whether a catalog menu is consuming membership updates.
|
||
* @param parentSessionId - selected parent.
|
||
* @param open - menu state.
|
||
*/
|
||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||
this.manager.setSubagentCatalogOpen(parentSessionId, open)
|
||
}
|
||
|
||
/**
|
||
* Refresh one direct-child catalog.
|
||
* @param parentSessionId - catalog owner.
|
||
*/
|
||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||
return this.manager.refreshSubagents(parentSessionId)
|
||
}
|
||
|
||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||
this.manager.noteAgentPreset(sessionId, agentPreset)
|
||
}
|
||
|
||
/**
|
||
* Clear the current selection so the layout shows the no-session empty
|
||
* state (new-session affordance and the workspace preselection flow).
|
||
* Wipes the persisted selection too — a reload stays on empty until the
|
||
* user opens or starts a session. The staged scope keeps its frozen view
|
||
* per the masked-gap contract until the next open() moves the stage.
|
||
*/
|
||
clear(): void {
|
||
this.manager.clearSelection()
|
||
}
|
||
|
||
/**
|
||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||
* @returns completion of the current or newly started baseline pull.
|
||
*/
|
||
refresh(): Promise<void> {
|
||
return this.manager.refreshList()
|
||
}
|
||
|
||
/**
|
||
* Search the Host's visible message-content index. Results stay
|
||
* request-local; the list snapshot remains the metadata authority.
|
||
* @param query - non-blank literal phrase.
|
||
* @param signal - cancellation for a superseded search.
|
||
* @returns bounded results or a business/transport error.
|
||
*/
|
||
search(
|
||
query: string,
|
||
signal: AbortSignal,
|
||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||
return this.manager.search(query, signal)
|
||
}
|
||
|
||
/**
|
||
* Route a mux stream envelope into the Session object layer.
|
||
* @param envelope - validated mux stream envelope.
|
||
*/
|
||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||
this.manager.handleMuxEnvelope(envelope)
|
||
}
|
||
|
||
/**
|
||
* Route a Host stream envelope into the Session object layer.
|
||
* @param envelope - validated Host stream envelope.
|
||
*/
|
||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||
this.manager.handleHostEnvelope(envelope)
|
||
}
|
||
|
||
/** Rebuild the Session baseline and every opened window after connection. */
|
||
handleConnected(): void {
|
||
this.manager.handleConnected()
|
||
}
|
||
|
||
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
|
||
handleDisconnected(): void {
|
||
this.manager.handleDisconnected()
|
||
}
|
||
|
||
/**
|
||
* Create a session on the host. Resolution guarantee: by the time the
|
||
* promise resolves, the created session is in the list store and
|
||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||
* draft hand-off) may address the scope synchronously, without waiting a
|
||
* notifier flush. The synchronous projection below makes this structural
|
||
* rather than an accident of microtask ordering.
|
||
* @param opts - target workspace or directory and an optional preallocated id.
|
||
* @returns the new session id.
|
||
* @throws {SessionCreateError} with the requested id.
|
||
*/
|
||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||
const result = await this.manager.create(opts)
|
||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||
this.projectList()
|
||
return result.value.sessionId
|
||
}
|
||
|
||
/**
|
||
* Fork a session from a completed-turn prefix of the source (same
|
||
* synchronous-addressability guarantee as {@link SessionsService.create}:
|
||
* on resolution the child is in the list store and open() can target it).
|
||
* @param opts - source session id, the optional event seq anchoring the
|
||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||
* anchor in an open turn is unavailable rather than clipped backward),
|
||
* and whether to increment an inherited durable title before resolving.
|
||
* A fractional anchor floors to a real event seq: the frozen nodes of an
|
||
* interrupted turn carry flow-ordering seqs between two events, and the
|
||
* wire takes integers only.
|
||
* @returns the child session id.
|
||
* @throws {SessionForkError} with the source id.
|
||
* @throws {Error} when a requested child-title rename fails after creation.
|
||
*/
|
||
async fork(opts: {
|
||
sessionId: SessionId
|
||
atSeq?: number
|
||
increaseTitle?: boolean
|
||
}): Promise<SessionId> {
|
||
const sourceTitle = opts.increaseTitle
|
||
? this.list.getSnapshot().byId[opts.sessionId]?.title
|
||
: undefined
|
||
const result = await this.manager.fork({
|
||
sessionId: opts.sessionId,
|
||
// Flooring lands inside the anchor's own turn (every turn opens with a
|
||
// turn/start), so the host's first-turn/end-at-or-after cut still ends
|
||
// on that turn — never clipped back to the previous one.
|
||
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
|
||
})
|
||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||
this.projectList()
|
||
const childId = result.value.sessionId
|
||
if (sourceTitle !== undefined) {
|
||
const child = this.binding(childId)?.session
|
||
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
|
||
const renamed = await child.rename(increasedForkTitle(sourceTitle))
|
||
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
|
||
}
|
||
return childId
|
||
}
|
||
|
||
/**
|
||
* Resolve an Agent-scoped context view (use-and-discard).
|
||
* @param id - session id (the agent identity — 1:1 same axis).
|
||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||
*/
|
||
scope(id: SessionId): AgentContext | undefined {
|
||
return this.resolve(id)?.ctx
|
||
}
|
||
|
||
/**
|
||
* Read the Agent scope tag off a context. Service-method boundary: fetch
|
||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||
* value import of the standalone helper would inline a second module
|
||
* instance whose private tag Symbol never matches.
|
||
* @param ctx - any client context.
|
||
* @returns the session id, or undefined on root contexts.
|
||
*/
|
||
scopeOf(ctx: Context): SessionId | undefined {
|
||
return scopeTagOf(ctx)
|
||
}
|
||
|
||
/**
|
||
* Resolve the business Session behind an Agent-scoped context — the one
|
||
* hop every scoped consumer (event listeners, per-session controllers)
|
||
* takes from ctx-space into object-space (the client mirror of host
|
||
* `agent.session`). Same service-method boundary as
|
||
* {@link SessionsService.scopeOf}.
|
||
* @param ctx - an Agent-scoped context.
|
||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||
*/
|
||
sessionOf(ctx: Context): SessionFace | undefined {
|
||
const id = scopeTagOf(ctx)
|
||
if (id === undefined) return undefined
|
||
return this.scopes.get(id)?.binding.session
|
||
}
|
||
|
||
/**
|
||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||
* resolution — no staging, no window side effects.
|
||
* @param id - session id.
|
||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||
*/
|
||
binding(id: SessionId): SessionBinding | undefined {
|
||
return this.resolve(id)?.binding
|
||
}
|
||
|
||
/**
|
||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||
* enters the render layer; the renderer subscribes to
|
||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||
* no staging, no window side effects (StrictMode double-invokes and
|
||
* concurrent discarded passes must stay free).
|
||
*/
|
||
private provideInfo(id: string): SessionProvideInfo | undefined {
|
||
return this.resolve(id as SessionId)?.provideInfo
|
||
}
|
||
|
||
/**
|
||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||
* return the static no-session projection rather than removing hook props.
|
||
*/
|
||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
|
||
}
|
||
|
||
/**
|
||
* Move the stage to the list's current session: sweep teardowns deferred
|
||
* behind the previous occupant and pull the new occupant's history window.
|
||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||
* failed one retries the next time current is touched).
|
||
*/
|
||
private followCurrent(): void {
|
||
const snapshot = this.list.getSnapshot()
|
||
const current = snapshot.current
|
||
// A masked gap (current blanked while the selection's session is
|
||
// transiently absent) holds the stage: tearing down on the gap would
|
||
// destroy exactly the frozen scope the mask exists to preserve.
|
||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||
this.watched = current
|
||
this.sweepDeferred()
|
||
const record = this.resolve(current)
|
||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||
* validates and the projection masks absent selections), so resolve
|
||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||
if (record !== undefined) {
|
||
void record.session.open()
|
||
void this.manager.refreshSubagents(current)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||
* prune share one predicate: listed on the host or selected
|
||
* through a retained subagent address. Breadcrumb-only ancestors remain
|
||
* summary data and do not keep scopes alive.
|
||
*/
|
||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||
const existing = this.scopes.get(id)
|
||
if (existing !== undefined) return existing
|
||
if (!this.eligible(id)) return undefined
|
||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||
const session = this.manager.get(id)
|
||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||
// mint and bind are one step so a live scope record implies a bound actx.
|
||
session.bindScope(ctx)
|
||
const binding: SessionBinding = { sessionId: id, session, ctx }
|
||
const record: ScopeRecord = {
|
||
fiber,
|
||
ctx,
|
||
binding,
|
||
session,
|
||
// Sources are bare observables; React binds selector hooks at its own boundary.
|
||
provideInfo: this.provideChannel.materializeInfo(binding),
|
||
}
|
||
this.scopes.set(id, record)
|
||
return record
|
||
}
|
||
|
||
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
|
||
private eligible(id: SessionId): boolean {
|
||
const { ids, current } = this.list.getSnapshot()
|
||
return current === id || ids.includes(id)
|
||
}
|
||
|
||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||
private projectList(): void {
|
||
const {
|
||
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
|
||
} = this.manager.getListSnapshot()
|
||
const ids: SessionId[] = []
|
||
const byId: Record<SessionId, SessionSummary> = {}
|
||
for (const entry of items) {
|
||
ids.push(entry.sessionId)
|
||
byId[entry.sessionId] = {
|
||
id: entry.sessionId,
|
||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||
running: entry.running,
|
||
...(entry.completed ? { completed: true } : {}),
|
||
blank: entry.blank,
|
||
updatedAt: entry.updatedAt,
|
||
...(entry.pendingInteraction === undefined
|
||
? {}
|
||
: { pendingInteraction: entry.pendingInteraction }),
|
||
...(entry.projectionValues === undefined
|
||
? {}
|
||
: { projectionValues: entry.projectionValues }),
|
||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
|
||
}
|
||
}
|
||
if (current !== undefined && currentAddress !== undefined) {
|
||
const seen = new Set<SessionId>()
|
||
let address: SubagentAddress | undefined = currentAddress
|
||
while (address !== undefined && !seen.has(address.childSessionId)) {
|
||
const childId = address.childSessionId
|
||
seen.add(childId)
|
||
const child = subagentsByParent[address.parentSessionId]?.entries
|
||
.find(entry => entry.kind === 'child' && entry.id === childId)
|
||
if (child?.kind !== 'child') break
|
||
const displayTitle = child.label ?? childId
|
||
const summary = byId[childId]
|
||
if (summary === undefined) {
|
||
byId[childId] = {
|
||
id: childId,
|
||
displayTitle,
|
||
parentId: address.parentSessionId,
|
||
origin: 'subagent',
|
||
running: child.activity === 'running',
|
||
blank: false,
|
||
updatedAt: 0,
|
||
}
|
||
} else if (summary.displayTitle !== displayTitle) {
|
||
byId[childId] = { ...summary, displayTitle }
|
||
}
|
||
const parent = byId[address.parentSessionId]
|
||
if (parent !== undefined && parent.origin !== 'subagent') break
|
||
address = this.manager.navigationAddress(address.parentSessionId)
|
||
}
|
||
}
|
||
const persisted = this.selection.getSnapshot().sessionId
|
||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||
if (current === undefined) {
|
||
if (persisted !== undefined) this.selection.set({})
|
||
} else if (byId[current] !== undefined
|
||
&& (persisted !== current
|
||
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
||
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
||
this.selection.set({
|
||
sessionId: current,
|
||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||
})
|
||
}
|
||
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
|
||
this.pruneScopes()
|
||
}
|
||
|
||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||
private pruneScopes(): void {
|
||
for (const [id, record] of this.scopes) {
|
||
if (this.eligible(id)) continue
|
||
if (id === this.watched) {
|
||
this.deferredRemovals.add(id)
|
||
continue
|
||
}
|
||
this.scopes.delete(id)
|
||
this.deferredRemovals.delete(id)
|
||
this.dropScope(id, record)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One teardown for the whole per-session axis: the scope
|
||
* fiber (cascading every actx-registered effect: input shell, slash
|
||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||
* stores, and the Session instance itself — the host session log is the
|
||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||
*/
|
||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||
void record.fiber.dispose()
|
||
// Release the Session's dispatch point with the scope it belongs to (a
|
||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||
record.session.unbindScope()
|
||
// Optional lookup: slots and sessions are sibling services with no
|
||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||
this.manager.drop(id)
|
||
}
|
||
|
||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||
private sweepDeferred(): void {
|
||
for (const id of [...this.deferredRemovals]) {
|
||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||
* moved to; kept as a guard against future extra sweep call sites. */
|
||
if (id === this.watched) continue
|
||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||
if (this.eligible(id)) {
|
||
this.deferredRemovals.delete(id)
|
||
continue
|
||
}
|
||
const record = this.scopes.get(id)
|
||
this.deferredRemovals.delete(id)
|
||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||
* together, so a deferred id always still owns its record; kept so a
|
||
* future teardown path cannot double-dispose. */
|
||
if (record !== undefined) {
|
||
this.scopes.delete(id)
|
||
this.dropScope(id, record)
|
||
}
|
||
}
|
||
}
|
||
}
|