|
@@ -2,8 +2,9 @@
|
|
|
* SessionsService: root sessions service — list snapshot store (manager
|
|
* SessionsService: root sessions service — list snapshot store (manager
|
|
|
* projection; carries `current`, the persisted selection every
|
|
* projection; carries `current`, the persisted selection every
|
|
|
* session-scoped surface keys off — migrated here from ui-layout per the
|
|
* session-scoped surface keys off — migrated here from ui-layout per the
|
|
|
- * slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
|
|
|
|
- * Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
|
|
|
|
|
|
+ * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
|
|
|
|
+ * Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
|
|
|
|
+ * id), stable SessionBinding cache, ancestry walk.
|
|
|
*
|
|
*
|
|
|
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
|
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
|
|
* resolution (pure — resolution has no side effects and is render-safe);
|
|
* resolution (pure — resolution has no side effects and is render-safe);
|
|
@@ -16,15 +17,15 @@
|
|
|
*/
|
|
*/
|
|
|
import type { Context, Fiber } from 'cordis'
|
|
import type { Context, Fiber } from 'cordis'
|
|
|
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
|
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
-import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
|
|
|
|
+import type {
|
|
|
|
|
+ HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
|
|
|
|
+} from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import type { SnapshotStore } from '../contract/store.ts'
|
|
import type { SnapshotStore } from '../contract/store.ts'
|
|
|
import { createSnapshotStore } from '../contract/store.ts'
|
|
import { createSnapshotStore } from '../contract/store.ts'
|
|
|
|
|
+import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
|
|
import { SessionManager } from './manager.ts'
|
|
import { SessionManager } from './manager.ts'
|
|
|
-import type {
|
|
|
|
|
- SessionIntentListSnapshot, SessionListPhase,
|
|
|
|
|
-} from './manager.ts'
|
|
|
|
|
|
|
+import type { SessionListPhase } from './manager.ts'
|
|
|
import type { Session } from './session.ts'
|
|
import type { Session } from './session.ts'
|
|
|
-import type { SessionIntentTarget } from './conversation.ts'
|
|
|
|
|
|
|
|
|
|
/** Session list row projected from the host list RPC plus live stream increments. */
|
|
/** Session list row projected from the host list RPC plus live stream increments. */
|
|
|
export interface SessionSummary {
|
|
export interface SessionSummary {
|
|
@@ -36,6 +37,13 @@ export interface SessionSummary {
|
|
|
cwd?: string
|
|
cwd?: string
|
|
|
parentId?: SessionId
|
|
parentId?: SessionId
|
|
|
running: boolean
|
|
running: 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
|
|
updatedAt: number
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -48,17 +56,13 @@ export interface SessionListState {
|
|
|
ids: SessionId[]
|
|
ids: SessionId[]
|
|
|
byId: Record<SessionId, SessionSummary>
|
|
byId: Record<SessionId, SessionSummary>
|
|
|
current: SessionId | undefined
|
|
current: SessionId | undefined
|
|
|
- /** Frontend Session Intent projected from its owning Session object. */
|
|
|
|
|
- intent: SessionIntentListSnapshot | undefined
|
|
|
|
|
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
|
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
|
|
phase: SessionListPhase
|
|
phase: SessionListPhase
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Structured session-create failure preserving partial publication identity. */
|
|
|
|
|
|
|
+/** Structured session-create failure. */
|
|
|
export class SessionCreateError extends Error {
|
|
export class SessionCreateError extends Error {
|
|
|
override readonly name = 'SessionCreateError'
|
|
override readonly name = 'SessionCreateError'
|
|
|
- /** Definitely published by Host before Workspace attachment failed. */
|
|
|
|
|
- readonly publishedSessionId: SessionId | undefined
|
|
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* @param rpcError - Host business or folded transport error.
|
|
* @param rpcError - Host business or folded transport error.
|
|
@@ -69,9 +73,6 @@ export class SessionCreateError extends Error {
|
|
|
readonly requestedSessionId: SessionId | undefined,
|
|
readonly requestedSessionId: SessionId | undefined,
|
|
|
) {
|
|
) {
|
|
|
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
|
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
|
|
- this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
|
|
|
|
|
- ? rpcError.details.sessionId
|
|
|
|
|
- : undefined
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -82,20 +83,10 @@ export interface SessionBinding {
|
|
|
readonly ctx: Context
|
|
readonly ctx: Context
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Scope tag key (client counterpart of the host dsh-scope pattern). */
|
|
|
|
|
-const kScope = Symbol('dsh.client.scope')
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * Read the session scope tag off a context.
|
|
|
|
|
- * @param ctx - any client context.
|
|
|
|
|
- * @returns the session id, or undefined on root contexts.
|
|
|
|
|
- */
|
|
|
|
|
-export function scopeOf(ctx: Context): SessionId | undefined {
|
|
|
|
|
- return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/** Shared no-op plugin backing each session scope fiber. */
|
|
|
|
|
-function sessionScope(): void {}
|
|
|
|
|
|
|
+// 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
|
|
* Workspace display title of a session cwd: the path's last non-empty
|
|
@@ -128,8 +119,30 @@ interface ScopeRecord {
|
|
|
fiber: Fiber
|
|
fiber: Fiber
|
|
|
ctx: Context
|
|
ctx: Context
|
|
|
binding: SessionBinding
|
|
binding: SessionBinding
|
|
|
- /** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
|
|
|
|
|
- cell: SessionCell
|
|
|
|
|
|
|
+ /** 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, ancestry. */
|
|
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
|
@@ -150,6 +163,10 @@ export class SessionsService {
|
|
|
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
|
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
|
|
|
|
|
|
|
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
|
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
|
|
|
|
+ /** Registered per-session standard-props providers, in registration order. */
|
|
|
|
|
+ private readonly providers: SessionProvideDescriptor[] = []
|
|
|
|
|
+ /** Static no-session projection, rebuilt only when the provider roster changes. */
|
|
|
|
|
+ private maybeInfo: SessionMaybeProvideInfo
|
|
|
/**
|
|
/**
|
|
|
* The staged session id — follows `list.current` exactly, holding its last
|
|
* The staged session id — follows `list.current` exactly, holding its last
|
|
|
* defined value across masked gaps (a transiently absent selection blanks
|
|
* defined value across masked gaps (a transiently absent selection blanks
|
|
@@ -170,7 +187,7 @@ export class SessionsService {
|
|
|
{ persist: { name: 'dsh.sessions.current' } })
|
|
{ persist: { name: 'dsh.sessions.current' } })
|
|
|
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
|
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
|
|
this.list = createSnapshotStore<SessionListState>({
|
|
this.list = createSnapshotStore<SessionListState>({
|
|
|
- ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
|
|
|
|
|
|
|
+ ids: [], byId: {}, current: undefined, phase: 'pending',
|
|
|
})
|
|
})
|
|
|
// The manager owns wire truth; the store is its projection. Manager
|
|
// The manager owns wire truth; the store is its projection. Manager
|
|
|
// notifications are already microtask-batched.
|
|
// notifications are already microtask-batched.
|
|
@@ -182,9 +199,97 @@ export class SessionsService {
|
|
|
// the follower writes no list state — session.open()'s synchronous prefix
|
|
// the follower writes no list state — session.open()'s synchronous prefix
|
|
|
// touches only session-side state and its own microtask-batched notifier.
|
|
// touches only session-side state and its own microtask-batched notifier.
|
|
|
this.list.subscribe(() => { this.followCurrent() })
|
|
this.list.subscribe(() => { this.followCurrent() })
|
|
|
|
|
+ // The runtime's own contribution comes first: useSession rides the same
|
|
|
|
|
+ // provide channel every plugin uses (no renderer special case).
|
|
|
|
|
+ this.providers.push({
|
|
|
|
|
+ hooks: ['session'],
|
|
|
|
|
+ resolve: binding => ({ hooks: { session: binding.session } }),
|
|
|
|
|
+ })
|
|
|
|
|
+ this.maybeInfo = this.materializeMaybeProvideInfo()
|
|
|
rootCtx.reflect.provide('sessions', this, undefined)
|
|
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 {
|
|
|
|
|
+ this.providers.push(descriptor)
|
|
|
|
|
+ // Scopes may already exist (boot order: the list lands and resolves
|
|
|
|
|
+ // scopes before later plugins register) — their bundles must include
|
|
|
|
|
+ // every provider by first render, so re-materialize on roster change.
|
|
|
|
|
+ this.rematerializeProvideBundles()
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ const at = this.providers.indexOf(descriptor)
|
|
|
|
|
+ if (at >= 0) this.providers.splice(at, 1)
|
|
|
|
|
+ this.rematerializeProvideBundles()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
|
|
|
|
+ private rematerializeProvideBundles(): void {
|
|
|
|
|
+ this.maybeInfo = this.materializeMaybeProvideInfo()
|
|
|
|
|
+ for (const record of this.scopes.values()) {
|
|
|
|
|
+ record.provideInfo = this.materializeProvideInfo(record.binding)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Build the static no-session kit and reject duplicate declared names. */
|
|
|
|
|
+ private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
|
|
|
|
+ const hooks: Record<string, undefined> = {}
|
|
|
|
|
+ const props: Record<string, undefined> = {}
|
|
|
|
|
+ for (const descriptor of this.providers) {
|
|
|
|
|
+ for (const name of descriptor.hooks ?? []) {
|
|
|
|
|
+ if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
|
|
|
|
+ hooks[name] = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of descriptor.props ?? []) {
|
|
|
|
|
+ if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
|
|
|
|
+ props[name] = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { sessionId: undefined, hooks, props }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
|
|
|
|
+ private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
|
|
|
|
+ const hooks: Record<string, HostObservable<unknown>> = {}
|
|
|
|
|
+ const props: Record<string, unknown> = {}
|
|
|
|
|
+ for (const descriptor of this.providers) {
|
|
|
|
|
+ const contribution = descriptor.resolve(binding)
|
|
|
|
|
+ const contributedHooks = contribution.hooks ?? {}
|
|
|
|
|
+ const contributedProps = contribution.props ?? {}
|
|
|
|
|
+ for (const name of Object.keys(contributedHooks)) {
|
|
|
|
|
+ if (!(descriptor.hooks ?? []).includes(name)) {
|
|
|
|
|
+ throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of Object.keys(contributedProps)) {
|
|
|
|
|
+ if (!(descriptor.props ?? []).includes(name)) {
|
|
|
|
|
+ throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of descriptor.hooks ?? []) {
|
|
|
|
|
+ const source = contributedHooks[name]
|
|
|
|
|
+ if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
|
|
|
|
+ if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
|
|
|
|
+ hooks[name] = source
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of descriptor.props ?? []) {
|
|
|
|
|
+ if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
|
|
|
|
+ if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
|
|
|
|
+ props[name] = contributedProps[name]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { sessionId: binding.sessionId, hooks, props }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Select a session as current. Unknown ids fail loud instead of navigating
|
|
* Select a session as current. Unknown ids fail loud instead of navigating
|
|
|
* nowhere.
|
|
* nowhere.
|
|
@@ -205,32 +310,6 @@ export class SessionsService {
|
|
|
this.manager.clearSelection()
|
|
this.manager.clearSelection()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * Start or retarget the sole client-local Session intent.
|
|
|
|
|
- * @param target - resolved real or frontend-only Workspace target.
|
|
|
|
|
- * @param prompt - optional prompt retained across retargeting.
|
|
|
|
|
- * @returns the frontend Session object that owns the Intent.
|
|
|
|
|
- */
|
|
|
|
|
- startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
|
|
|
|
- return this.manager.startIntent(target, prompt)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * Resolve the active frontend Session Intent.
|
|
|
|
|
- * @returns the active frontend Session object, if one exists.
|
|
|
|
|
- */
|
|
|
|
|
- intent(): Session | undefined {
|
|
|
|
|
- return this.manager.getIntent()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * Update the retained prompt of the active frontend Session.
|
|
|
|
|
- * @param text - exact controlled-input value for the current Session Intent.
|
|
|
|
|
- */
|
|
|
|
|
- updateIntent(text: string): void {
|
|
|
|
|
- this.manager.updateIntent(text)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
/**
|
|
/**
|
|
|
* Refresh the real Session baseline, reusing an in-flight pull.
|
|
* Refresh the real Session baseline, reusing an in-flight pull.
|
|
|
* @returns completion of the current or newly started baseline pull.
|
|
* @returns completion of the current or newly started baseline pull.
|
|
@@ -261,21 +340,26 @@ export class SessionsService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Create a session on the host.
|
|
|
|
|
|
|
+ * 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.
|
|
* @param opts - target workspace or directory and an optional preallocated id.
|
|
|
* @returns the new session id.
|
|
* @returns the new session id.
|
|
|
- * @throws {SessionCreateError} with the requested id and, after an attach
|
|
|
|
|
- * failure, the definitely published id.
|
|
|
|
|
|
|
+ * @throws {SessionCreateError} with the requested id.
|
|
|
*/
|
|
*/
|
|
|
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
|
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
|
|
const result = await this.manager.create(opts)
|
|
const result = await this.manager.create(opts)
|
|
|
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
|
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
|
|
|
|
+ this.projectList()
|
|
|
return result.value.sessionId
|
|
return result.value.sessionId
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Resolve a session-scoped context view (use-and-discard).
|
|
|
|
|
- * @param id - session id.
|
|
|
|
|
|
|
+ * 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.
|
|
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
|
|
*/
|
|
*/
|
|
|
scope(id: SessionId): Context | undefined {
|
|
scope(id: SessionId): Context | undefined {
|
|
@@ -283,7 +367,7 @@ export class SessionsService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Read the session scope tag off a context. Service-method seam: fetch
|
|
|
|
|
|
|
+ * Read the Agent scope tag off a context. Service-method seam: fetch
|
|
|
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
|
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
|
|
* value import of the standalone helper would inline a second module
|
|
* value import of the standalone helper would inline a second module
|
|
|
* instance whose private tag Symbol never matches.
|
|
* instance whose private tag Symbol never matches.
|
|
@@ -291,7 +375,22 @@ export class SessionsService {
|
|
|
* @returns the session id, or undefined on root contexts.
|
|
* @returns the session id, or undefined on root contexts.
|
|
|
*/
|
|
*/
|
|
|
scopeOf(ctx: Context): SessionId | undefined {
|
|
scopeOf(ctx: Context): SessionId | undefined {
|
|
|
- return scopeOf(ctx)
|
|
|
|
|
|
|
+ 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 seam as
|
|
|
|
|
+ * {@link SessionsService.scopeOf}.
|
|
|
|
|
+ * @param ctx - an Agent-scoped context.
|
|
|
|
|
+ * @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
|
|
|
|
+ */
|
|
|
|
|
+ sessionOf(ctx: Context): Session | undefined {
|
|
|
|
|
+ const id = scopeTagOf(ctx)
|
|
|
|
|
+ if (id === undefined) return undefined
|
|
|
|
|
+ return this.scopes.get(id)?.binding.session
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -305,16 +404,26 @@ export class SessionsService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Resolve the render-layer session cell (SessionProvider's feed through
|
|
|
|
|
- * the renderer host; ctx never enters the render layer). Pure resolution —
|
|
|
|
|
- * render-safe: SessionProvider calls this during render, so no staging, no
|
|
|
|
|
- * window side effects (StrictMode double-invokes and concurrent discarded
|
|
|
|
|
- * passes must stay free).
|
|
|
|
|
|
|
+ * Resolve the render-layer standard-props bundle (SessionProvider's feed
|
|
|
|
|
+ * through the renderer host; ctx never enters the render layer). Pure
|
|
|
|
|
+ * resolution — render-safe: SessionProvider calls this during render, so no
|
|
|
|
|
+ * staging, no window side effects (StrictMode double-invokes and concurrent
|
|
|
|
|
+ * discarded passes must stay free).
|
|
|
* @param id - session id.
|
|
* @param id - session id.
|
|
|
- * @returns cell, or undefined for a session neither listed nor already scoped.
|
|
|
|
|
|
|
+ * @returns the provide info, or undefined for a session neither listed nor already scoped.
|
|
|
*/
|
|
*/
|
|
|
- cell(id: string): SessionCell | undefined {
|
|
|
|
|
- return this.resolve(id as SessionId)?.cell
|
|
|
|
|
|
|
+ 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.
|
|
|
|
|
+ * @param id - current session id, when selected.
|
|
|
|
|
+ * @returns a definite or no-session provide bundle.
|
|
|
|
|
+ */
|
|
|
|
|
+ maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
|
|
|
|
+ return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -360,29 +469,42 @@ export class SessionsService {
|
|
|
return chain
|
|
return chain
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** Lazily mint the scope + binding for a listed (or already-scoped) session. */
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Lazily mint the scope + binding for an eligible session. Eligibility and
|
|
|
|
|
+ * prune share one predicate (decision 12): listed on the host — a scope is
|
|
|
|
|
+ * born when its session enters the client's view (list mirror row from the
|
|
|
|
|
+ * baseline pull, a create() echo, or the session-added frame) and dies with
|
|
|
|
|
+ * the prune when the row leaves.
|
|
|
|
|
+ */
|
|
|
private resolve(id: SessionId): ScopeRecord | undefined {
|
|
private resolve(id: SessionId): ScopeRecord | undefined {
|
|
|
const existing = this.scopes.get(id)
|
|
const existing = this.scopes.get(id)
|
|
|
if (existing !== undefined) return existing
|
|
if (existing !== undefined) return existing
|
|
|
- // Frozen scopes outlive the list; new scopes are only minted for listed sessions.
|
|
|
|
|
- if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
|
|
|
|
- const fiber = this.rootCtx.plugin(sessionScope)
|
|
|
|
|
- const ctx = fiber.ctx.extend({ [kScope]: id })
|
|
|
|
|
|
|
+ if (!this.eligible(id)) return undefined
|
|
|
|
|
+ const { fiber, ctx } = createScope(this.rootCtx, id)
|
|
|
const session = this.manager.get(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 = {
|
|
const record: ScopeRecord = {
|
|
|
fiber,
|
|
fiber,
|
|
|
ctx,
|
|
ctx,
|
|
|
- binding: { sessionId: id, session, ctx },
|
|
|
|
|
- // Session is the observable; React binds a selector hook at its own seam.
|
|
|
|
|
- cell: { sessionId: id, session },
|
|
|
|
|
|
|
+ binding,
|
|
|
|
|
+ // Sources are bare observables; React binds selector hooks at its own seam.
|
|
|
|
|
+ provideInfo: this.materializeProvideInfo(binding),
|
|
|
}
|
|
}
|
|
|
this.scopes.set(id, record)
|
|
this.scopes.set(id, record)
|
|
|
return record
|
|
return record
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
|
|
|
|
+ private eligible(id: SessionId): boolean {
|
|
|
|
|
+ return this.list.getSnapshot().byId[id] !== undefined
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
|
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
|
|
private projectList(): void {
|
|
private projectList(): void {
|
|
|
- const { items, current, intent, phase } = this.manager.getListSnapshot()
|
|
|
|
|
|
|
+ const { items, current, phase } = this.manager.getListSnapshot()
|
|
|
const ids: SessionId[] = []
|
|
const ids: SessionId[] = []
|
|
|
const byId: Record<SessionId, SessionSummary> = {}
|
|
const byId: Record<SessionId, SessionSummary> = {}
|
|
|
for (const entry of items) {
|
|
for (const entry of items) {
|
|
@@ -391,6 +513,7 @@ export class SessionsService {
|
|
|
id: entry.sessionId,
|
|
id: entry.sessionId,
|
|
|
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
|
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
|
|
running: entry.running,
|
|
running: entry.running,
|
|
|
|
|
+ blank: entry.blank,
|
|
|
updatedAt: entry.updatedAt,
|
|
updatedAt: entry.updatedAt,
|
|
|
...(entry.title !== undefined ? { title: entry.title } : {}),
|
|
...(entry.title !== undefined ? { title: entry.title } : {}),
|
|
|
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
|
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
|
@@ -398,19 +521,22 @@ export class SessionsService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
const persisted = this.selection.getSnapshot().sessionId
|
|
const persisted = this.selection.getSnapshot().sessionId
|
|
|
- if (intent?.sessionId === current) {
|
|
|
|
|
|
|
+ // 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({})
|
|
if (persisted !== undefined) this.selection.set({})
|
|
|
- } else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
|
|
|
|
|
|
|
+ } else if (byId[current] !== undefined && persisted !== current) {
|
|
|
this.selection.set({ sessionId: current })
|
|
this.selection.set({ sessionId: current })
|
|
|
}
|
|
}
|
|
|
- this.list.set({ ids, byId, current, intent, phase })
|
|
|
|
|
|
|
+ this.list.set({ ids, byId, current, phase })
|
|
|
this.pruneScopes(byId)
|
|
this.pruneScopes(byId)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
|
|
|
|
|
|
|
+ /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
|
|
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
|
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
|
|
|
|
+ void byId
|
|
|
for (const [id, record] of this.scopes) {
|
|
for (const [id, record] of this.scopes) {
|
|
|
- if (byId[id] !== undefined) continue
|
|
|
|
|
|
|
+ if (this.eligible(id)) continue
|
|
|
if (id === this.watched) {
|
|
if (id === this.watched) {
|
|
|
this.deferredRemovals.add(id)
|
|
this.deferredRemovals.add(id)
|
|
|
continue
|
|
continue
|
|
@@ -421,12 +547,22 @@ export class SessionsService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * One teardown for the whole per-session axis (decision 12): 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 {
|
|
private dropScope(id: SessionId, record: ScopeRecord): void {
|
|
|
void record.fiber.dispose()
|
|
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.binding.session.unbindScope()
|
|
|
// Optional lookup: slots and sessions are sibling services with no
|
|
// Optional lookup: slots and sessions are sibling services with no
|
|
|
// declared dependency; a slots-less boot (object-layer tests) skips.
|
|
// declared dependency; a slots-less boot (object-layer tests) skips.
|
|
|
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
|
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
|
|
|
|
+ this.manager.drop(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
|
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
|
@@ -436,8 +572,8 @@ export class SessionsService {
|
|
|
* stage move sweeps first, so the set cannot contain the id the stage just
|
|
* 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. */
|
|
* moved to; kept as a guard against future extra sweep call sites. */
|
|
|
if (id === this.watched) continue
|
|
if (id === this.watched) continue
|
|
|
- // Still absent from the list? (A re-added id cancels the deferred teardown.)
|
|
|
|
|
- if (this.list.getSnapshot().byId[id] !== undefined) {
|
|
|
|
|
|
|
+ // Eligible again? (A re-added id cancels the deferred teardown.)
|
|
|
|
|
+ if (this.eligible(id)) {
|
|
|
this.deferredRemovals.delete(id)
|
|
this.deferredRemovals.delete(id)
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|