service.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. /**
  2. * ClientSessions: root sessions service — list snapshot store (manager
  3. * projection; carries `current`, the persisted selection every
  4. * session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
  5. * Fiber + ctx.extend scope tag; one scope per session, agent id === session
  6. * id), stable SessionBinding cache, breadcrumb-route projection.
  7. *
  8. * Scope lifecycle is stage-driven: a scope is minted lazily on first
  9. * resolution (pure — resolution has no side effects and is render-safe);
  10. * the event window and deferred teardown key off the STAGED session, which
  11. * follows `list.current` exactly. Staging is the open signal: the window
  12. * opens ⟺ the session is on stage (the stage is `current`; the staged
  13. * state can widen to a multi-pane list later). A session leaving the list
  14. * tears its scope down immediately unless it is the staged one, whose scope
  15. * survives frozen (read-only view) until the stage moves on.
  16. */
  17. import type { Context, Fiber } from '@deepseek-ai/cordis'
  18. import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
  19. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  20. import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
  21. import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
  22. import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
  23. import type { SessionJob as JobView } from '../../types.ts'
  24. import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
  25. import {
  26. createSnapshotStore, type SnapshotStore,
  27. } from '@deepseek-ai/dsh-client-store'
  28. import type { ClientFailure, ClientResult } from '../contract/result.ts'
  29. import type { SessionEventSource } from '../contract/events.ts'
  30. import type { SessionFace } from '../contract/session.ts'
  31. import type { AgentContext, ISessions } from '../contract/sessions.ts'
  32. import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
  33. import { SessionManager } from './manager.ts'
  34. import type { SessionRemotes } from './remotes.ts'
  35. import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
  36. import type { Session } from './session.ts'
  37. /** Session list row projected from the host list RPC plus live stream increments. */
  38. export interface SessionSummary {
  39. id: SessionId
  40. /** Latest durable log-backed title, absent until the host projects one. */
  41. title?: string
  42. /** Human-facing label: durable title, project basename, then session id. */
  43. displayTitle: string
  44. cwd?: string
  45. parentId?: SessionId
  46. /** Coarse durable origin for navigation filtering; not a continuation capability. */
  47. origin?: 'subagent'
  48. running: boolean
  49. /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
  50. completed?: boolean
  51. /**
  52. * Empty-log bit (host summary derivation mirror). New Session reuses a blank
  53. * one targeting the same workspace. Filtering stays with the consumer: the
  54. * store carries every row, while the Workspace browser shows only the
  55. * selected blank entry.
  56. */
  57. blank: boolean
  58. updatedAt: number
  59. /** Current host-computed projection values retained by the object layer. */
  60. projectionValues?: Readonly<Partial<SessionProjectionMap>>
  61. }
  62. /**
  63. * Session list store shape. `current` rides the same snapshot (arbitrated:
  64. * the single useSessions standard hook reads list and selection together —
  65. * sidebar highlighting and current-session consumers share one fact source).
  66. */
  67. export interface SessionListState {
  68. /** Host-list order; addressed breadcrumb-only rows are excluded. */
  69. ids: SessionId[]
  70. /** Host rows plus the current addressed subagent route used by navigation. */
  71. byId: Record<SessionId, SessionSummary>
  72. current: SessionId | undefined
  73. /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
  74. phase: SessionListPhase
  75. /** Direct durable catalogs keyed by their selected parent address. */
  76. subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
  77. /**
  78. * Background jobs each session can see, mirrored last-wins from Session
  79. * Controller's control baseline and `jobs` frames. A missing key is an empty
  80. * set, so consumers read absence rather than a sentinel.
  81. */
  82. jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
  83. /** Current session's catalog-derived address, absent on ordinary navigation. */
  84. currentAddress: SubagentAddress | undefined
  85. }
  86. /** Persisted navigation cell: address survives refresh for correct history routing. */
  87. interface SessionSelection {
  88. sessionId?: SessionId
  89. subagentAddress?: SubagentAddress
  90. }
  91. /** Structured session-create failure. */
  92. export class SessionCreateError extends Error {
  93. override readonly name = 'SessionCreateError'
  94. /**
  95. * @param rpcError - Host business or folded transport error.
  96. * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
  97. */
  98. constructor(
  99. readonly rpcError: ClientFailure,
  100. readonly requestedSessionId: SessionId | undefined,
  101. ) {
  102. super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
  103. }
  104. }
  105. /** Structured session-fork failure. */
  106. export class SessionForkError extends Error {
  107. override readonly name = 'SessionForkError'
  108. /**
  109. * @param rpcError - Host business or folded transport error.
  110. * @param sourceSessionId - the session the fork was cut from.
  111. */
  112. constructor(
  113. readonly rpcError: ClientFailure,
  114. readonly sourceSessionId: SessionId,
  115. ) {
  116. super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
  117. }
  118. }
  119. /** Identity-stable logical binding for one materialized Client Session. */
  120. export interface SessionBinding {
  121. readonly sessionId: SessionId
  122. /** The outward session face only — feature code never sees the concrete class. */
  123. readonly session: SessionFace
  124. /** Contiguous event window reserved for Conversation assembly. */
  125. readonly eventSource: SessionEventSource
  126. readonly ctx: AgentContext
  127. }
  128. // Scope primitives live in ../scope.ts (the client mirror of host
  129. // dsh-scope, keyed by Agent identity); re-exported here so existing
  130. // consumers keep their import site.
  131. export { scopeOf } from '../scope.ts'
  132. /**
  133. * Display title projection: durable title, project directory basename, then
  134. * the raw id.
  135. */
  136. function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
  137. if (title !== undefined) return title
  138. if (cwd !== undefined && cwd !== '') {
  139. const base = workspaceTitleOf(cwd)
  140. if (base !== '') return base
  141. }
  142. return id
  143. }
  144. /**
  145. * Increment a trailing fork number while preserving its half-width or
  146. * full-width parentheses; an unnumbered title starts with ` (1)`.
  147. * @param title - source session's durable title.
  148. * @returns the title assigned to the fork child.
  149. */
  150. function increasedForkTitle(title: string): string {
  151. const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
  152. if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
  153. return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
  154. }
  155. const fullWidth = /^(.*?)((\d+))$/u.exec(title)
  156. if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
  157. return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
  158. }
  159. return `${title} (1)`
  160. }
  161. interface ScopeRecord {
  162. fiber: Fiber
  163. ctx: AgentContext
  164. binding: SessionBinding
  165. /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
  166. session: Session
  167. }
  168. /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
  169. export class ClientSessions implements ISessions {
  170. /**
  171. * The wire schema's own result bound, re-exposed for presentation plugins as
  172. * injected data. Not per-connection state: the `session.search` response
  173. * schema caps `items` at this constant, so every transport (fixture included)
  174. * reports the same number.
  175. */
  176. readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
  177. /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
  178. readonly list: SnapshotStore<SessionListState>
  179. /** The object-layer instance cluster and frame dispatch entry. */
  180. private readonly manager: SessionManager
  181. /**
  182. * Persisted selection cell (the durable half of `list.current`). Private on
  183. * purpose: reads go through the list snapshot; writes through {@link
  184. * ClientSessions.open} / {@link ClientSessions.clear}. Projection
  185. * validates it against the live list instead of destructively pruning, so a
  186. * selection survives transient list states (reconnect re-pull) and
  187. * resurfaces when its session returns.
  188. */
  189. private readonly selection: SnapshotStore<SessionSelection>
  190. private readonly scopes = new Map<SessionId, ScopeRecord>()
  191. /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
  192. private readonly scopeDrops = new Set<Promise<void>>()
  193. /**
  194. * The staged session id — follows `list.current` exactly, holding its last
  195. * defined value across masked gaps (a transiently absent selection blanks
  196. * `current` without moving the stage, so reconnect re-pulls and removals
  197. * keep the staged scope's frozen view alive until the stage moves on).
  198. */
  199. private watched: SessionId | undefined
  200. /** Removed-while-staged sessions whose teardown waits for the stage to move away. */
  201. private readonly deferredRemovals = new Set<SessionId>()
  202. /**
  203. * @param ctx - client root context (scope fibers mount under it).
  204. * @param remote - generated Remote namespaces shared with every Session.
  205. */
  206. constructor(
  207. private readonly rootCtx: Context,
  208. remote: SessionRemotes,
  209. ) {
  210. this.selection = createSnapshotStore<SessionSelection>(
  211. {},
  212. { persist: { name: 'dsh.sessions.current' } })
  213. const restored = this.selection.getSnapshot()
  214. this.manager = new SessionManager(
  215. remote,
  216. restored.sessionId,
  217. restored.subagentAddress,
  218. )
  219. this.list = createSnapshotStore<SessionListState>({
  220. ids: [], byId: {}, current: undefined, phase: 'pending',
  221. subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
  222. })
  223. // The manager owns wire truth; the store is its projection. Manager
  224. // notifications are already microtask-batched.
  225. const disposeManagerProjection = this.manager.subscribe(() => {
  226. this.projectList()
  227. })
  228. // Stage follower: every current write (open() and projection alike)
  229. // re-evaluates staging, so startup restore (persisted selection validated
  230. // by the projection) and reconnect resurfacing open their window with no
  231. // dedicated code path. Safe to run synchronously inside the store notify:
  232. // the follower writes no list state — session.open()'s synchronous prefix
  233. // touches only session-side state and its own microtask-batched notifier.
  234. const disposeStageFollower = this.list.subscribe(() => {
  235. this.followCurrent()
  236. })
  237. rootCtx.effect(() => async () => {
  238. disposeStageFollower()
  239. disposeManagerProjection()
  240. const scopes = [...this.scopes]
  241. this.scopes.clear()
  242. this.deferredRemovals.clear()
  243. this.watched = undefined
  244. for (const [id, record] of scopes) this.startScopeDrop(id, record)
  245. await this.drainScopeDrops()
  246. await this.manager.dispose()
  247. }, 'session-controller.client.sessions')
  248. rootCtx.reflect.provide('sessions', this, undefined)
  249. }
  250. /**
  251. * Select a listed or retained catalog-addressed session as current.
  252. * @param id - listed or addressed session id.
  253. */
  254. open(id: SessionId): void {
  255. this.manager.select(id)
  256. }
  257. /**
  258. * Open a healthy catalog child through its direct-parent address.
  259. * @param address - catalog-derived parent and child ids.
  260. */
  261. openSubagent(address: SubagentAddress): void {
  262. this.manager.selectSubagent(address)
  263. }
  264. /**
  265. * Resolve an already discovered direct-parent address without opening it.
  266. * Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
  267. * @param id - possible addressed child id.
  268. * @returns The retained address, when present.
  269. */
  270. subagentAddress(id: SessionId): SubagentAddress | undefined {
  271. return this.manager.subagentAddress(id)
  272. }
  273. /**
  274. * Inform the Session Controller whether a catalog menu is consuming membership updates.
  275. * @param parentSessionId - selected parent.
  276. * @param open - menu state.
  277. */
  278. setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
  279. this.manager.setSubagentCatalogOpen(parentSessionId, open)
  280. }
  281. /**
  282. * Refresh one direct-child catalog.
  283. * @param parentSessionId - catalog owner.
  284. */
  285. refreshSubagents(parentSessionId: SessionId): Promise<void> {
  286. return this.manager.refreshSubagents(parentSessionId)
  287. }
  288. /**
  289. * Clear the current selection so the layout shows the no-session empty
  290. * state (new-session affordance and the workspace preselection flow).
  291. * Wipes the persisted selection too — a reload stays on empty until the
  292. * user opens or starts a session. The staged scope keeps its frozen view
  293. * per the masked-gap contract until the next open() moves the stage.
  294. */
  295. clear(): void {
  296. this.manager.clearSelection()
  297. }
  298. /**
  299. * Refresh the real Session baseline, reusing an in-flight pull.
  300. * @returns completion of the current or newly started baseline pull.
  301. */
  302. refresh(): Promise<void> {
  303. return this.manager.refreshList()
  304. }
  305. /**
  306. * Search the Host's visible message-content index. Results stay
  307. * request-local; the list snapshot remains the metadata authority.
  308. * @param query - non-blank literal phrase.
  309. * @param signal - cancellation for a superseded search.
  310. * @returns bounded results or a business/transport error.
  311. */
  312. search(
  313. query: string,
  314. signal: AbortSignal,
  315. ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
  316. return this.manager.search(query, signal)
  317. }
  318. /**
  319. * Apply one Session Controller live-control frame.
  320. * @param frame - baseline or live control replacement.
  321. */
  322. handleControlFrame(frame: Parameters<SessionManager['handleControlFrame']>[0]): void {
  323. this.manager.handleControlFrame(frame)
  324. }
  325. /**
  326. * Apply one remotely forwarded Session-list addition.
  327. * @param summary - current Host summary for the added Session.
  328. */
  329. handleSessionAdded(summary: Parameters<SessionManager['handleSessionAdded']>[0]): void {
  330. this.manager.handleSessionAdded(summary)
  331. }
  332. /**
  333. * Apply one remotely forwarded Session removal.
  334. * @param sessionId - removed Session identity.
  335. */
  336. handleSessionRemoved(sessionId: Parameters<SessionManager['handleSessionRemoved']>[0]): void {
  337. this.manager.handleSessionRemoved(sessionId)
  338. }
  339. /**
  340. * Apply one remotely forwarded running-state change.
  341. * @param args - Session identity and current Agent running state.
  342. */
  343. handleSessionStatus(...args: Parameters<SessionManager['handleSessionStatus']>): void {
  344. this.manager.handleSessionStatus(...args)
  345. }
  346. /**
  347. * Apply one remotely forwarded list-activity change.
  348. * @param args - Session identity and durable activity timestamp.
  349. */
  350. handleSessionActivity(...args: Parameters<SessionManager['handleSessionActivity']>): void {
  351. this.manager.handleSessionActivity(...args)
  352. }
  353. /**
  354. * Apply one remotely forwarded Agent failure.
  355. * @param args - Session identity and caller-visible failure description.
  356. */
  357. handleSessionError(...args: Parameters<SessionManager['handleSessionError']>): void {
  358. this.manager.handleSessionError(...args)
  359. }
  360. /** Rebuild the Session baseline and every opened window after connection. */
  361. handleConnected(): void {
  362. this.manager.handleConnected()
  363. }
  364. /**
  365. * Create a session on the host. Resolution guarantee: by the time the
  366. * promise resolves, the created session is in the list store and
  367. * {@link ClientSessions.binding} resolves it — callers (New Session
  368. * draft hand-off) may address the scope synchronously, without waiting a
  369. * notifier flush. The synchronous projection below makes this structural
  370. * rather than an accident of microtask ordering.
  371. * @param opts - target workspace or directory and an optional preallocated id.
  372. * @returns the new session id.
  373. * @throws {SessionCreateError} with the requested id.
  374. */
  375. async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
  376. const result = await this.manager.create(opts)
  377. if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
  378. this.projectList()
  379. return result.value.sessionId
  380. }
  381. /**
  382. * Fork a session from a completed-turn prefix of the source (same
  383. * synchronous-addressability guarantee as {@link ClientSessions.create}:
  384. * on resolution the child is in the list store and open() can target it).
  385. * @param opts - source session id, the optional event seq anchoring the
  386. * cut (the boundary is the first turn/end at or after it; an in-log
  387. * anchor in an open turn is unavailable rather than clipped backward),
  388. * and whether to increment an inherited durable title before resolving.
  389. * A fractional anchor floors to a real event seq: the frozen nodes of an
  390. * interrupted turn carry flow-ordering seqs between two events, and the
  391. * wire takes integers only.
  392. * @returns the child session id.
  393. * @throws {SessionForkError} with the source id.
  394. * @throws {Error} when a requested child-title rename fails after creation.
  395. */
  396. async fork(opts: {
  397. sessionId: SessionId
  398. atSeq?: number
  399. increaseTitle?: boolean
  400. }): Promise<SessionId> {
  401. const sourceTitle = opts.increaseTitle
  402. ? this.list.getSnapshot().byId[opts.sessionId]?.title
  403. : undefined
  404. const result = await this.manager.fork({
  405. sessionId: opts.sessionId,
  406. // Flooring lands inside the anchor's own turn (every turn opens with a
  407. // turn/start), so the host's first-turn/end-at-or-after cut still ends
  408. // on that turn — never clipped back to the previous one.
  409. ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
  410. })
  411. if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
  412. this.projectList()
  413. const childId = result.value.sessionId
  414. if (sourceTitle !== undefined) {
  415. const child = this.binding(childId)?.session
  416. if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
  417. const renamed = await child.rename(increasedForkTitle(sourceTitle))
  418. if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
  419. }
  420. return childId
  421. }
  422. /**
  423. * Resolve an Agent-scoped context view (use-and-discard).
  424. * @param id - session id (the agent identity — 1:1 same axis).
  425. * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
  426. */
  427. scope(id: SessionId): AgentContext | undefined {
  428. return this.resolve(id)?.ctx
  429. }
  430. /**
  431. * Materialize the Agent scope named by a validated Host Remote Event.
  432. * The first successful Session-list baseline becomes authoritative for its
  433. * lifetime; until then, transport streams may address the scope in either
  434. * arrival order.
  435. * @param id - Host-projected Agent identity (the matching Session id).
  436. * @returns the identity-stable Agent Context.
  437. */
  438. resolveAgentScope(id: SessionId): AgentContext {
  439. return (this.scopes.get(id) ?? this.materializeScope(id)).ctx
  440. }
  441. /**
  442. * Read the Agent scope tag off a context. Service-method boundary: fetch
  443. * bundles must reach scope resolution through ctx.sessions — a cross-bundle
  444. * value import of the standalone helper would inline a second module
  445. * instance whose private tag Symbol never matches.
  446. * @param ctx - any client context.
  447. * @returns the session id, or undefined on root contexts.
  448. */
  449. scopeOf(ctx: Context): SessionId | undefined {
  450. return scopeTagOf(ctx)
  451. }
  452. /**
  453. * Resolve the business Session behind an Agent-scoped context — the one
  454. * hop every scoped consumer (event listeners, per-session controllers)
  455. * takes from ctx-space into object-space (the client mirror of host
  456. * `agent.session`). Same service-method boundary as
  457. * {@link ClientSessions.scopeOf}.
  458. * @param ctx - an Agent-scoped context.
  459. * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
  460. */
  461. sessionOf(ctx: Context): SessionFace | undefined {
  462. const id = scopeTagOf(ctx)
  463. if (id === undefined) return undefined
  464. return this.scopes.get(id)?.binding.session
  465. }
  466. /**
  467. * Resolve the stable session binding (scope-addressed assembly feed). Pure
  468. * resolution — no staging, no window side effects.
  469. * @param id - session id.
  470. * @returns binding, or undefined for a session neither listed nor already scoped.
  471. */
  472. binding(id: SessionId): SessionBinding | undefined {
  473. return this.resolve(id)?.binding
  474. }
  475. /**
  476. * Move the stage to the list's current session: sweep teardowns deferred
  477. * behind the previous occupant and pull the new occupant's history window.
  478. * Staging IS the open signal — the window opens ⟺ the session is on stage
  479. * — and open() is idempotent (an in-flight or completed open no-ops; a
  480. * failed one retries the next time current is touched).
  481. */
  482. private followCurrent(): void {
  483. const snapshot = this.list.getSnapshot()
  484. const current = snapshot.current
  485. // A masked gap (current blanked while the selection's session is
  486. // transiently absent) holds the stage: tearing down on the gap would
  487. // destroy exactly the frozen scope the mask exists to preserve.
  488. if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
  489. this.watched = current
  490. this.sweepDeferred()
  491. const record = this.resolve(current)
  492. /* v8 ignore next 3 -- defensive: current is always a listed id (open()
  493. * validates and the projection masks absent selections), so resolve
  494. * cannot miss; kept so a future current writer cannot crash the notify. */
  495. if (record !== undefined) {
  496. void record.session.open()
  497. void this.manager.refreshSubagents(current)
  498. }
  499. }
  500. /**
  501. * Lazily mint the scope + binding for an eligible session. Eligibility and
  502. * prune share one predicate: listed on the host or selected
  503. * through a retained subagent address. Breadcrumb-only ancestors remain
  504. * summary data and do not keep scopes alive.
  505. */
  506. private resolve(id: SessionId): ScopeRecord | undefined {
  507. const existing = this.scopes.get(id)
  508. if (existing !== undefined) return existing
  509. if (!this.eligible(id)) return undefined
  510. return this.materializeScope(id)
  511. }
  512. /** Materialize one scope after its caller establishes that the id may be addressed. */
  513. private materializeScope(id: SessionId): ScopeRecord {
  514. const { fiber, ctx } = createScope(this.rootCtx, id)
  515. const session = this.manager.get(id)
  516. // The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
  517. // mint and bind are one step so a live scope record implies a bound actx.
  518. session.bindScope(ctx)
  519. const binding: SessionBinding = { sessionId: id, session, eventSource: session.eventSource, ctx }
  520. const record: ScopeRecord = {
  521. fiber,
  522. ctx,
  523. binding,
  524. session,
  525. }
  526. this.scopes.set(id, record)
  527. return record
  528. }
  529. /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
  530. private eligible(id: SessionId): boolean {
  531. const { ids, current } = this.list.getSnapshot()
  532. return current === id || ids.includes(id)
  533. }
  534. /** Project the manager's list snapshot into the store (title derivation is display-only). */
  535. private projectList(): void {
  536. const {
  537. items, current, phase, subagentsByParent, jobsBySession, currentAddress,
  538. } = this.manager.getListSnapshot()
  539. const ids: SessionId[] = []
  540. const byId: Record<SessionId, SessionSummary> = {}
  541. for (const entry of items) {
  542. ids.push(entry.sessionId)
  543. byId[entry.sessionId] = {
  544. id: entry.sessionId,
  545. displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
  546. running: entry.running,
  547. ...(entry.completed ? { completed: true } : {}),
  548. blank: entry.blank,
  549. updatedAt: entry.updatedAt,
  550. ...(entry.projectionValues === undefined
  551. ? {}
  552. : { projectionValues: entry.projectionValues }),
  553. ...(entry.title !== undefined ? { title: entry.title } : {}),
  554. ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
  555. ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
  556. ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
  557. }
  558. }
  559. if (current !== undefined && currentAddress !== undefined) {
  560. const seen = new Set<SessionId>()
  561. let address: SubagentAddress | undefined = currentAddress
  562. while (address !== undefined && !seen.has(address.childSessionId)) {
  563. const childId = address.childSessionId
  564. seen.add(childId)
  565. const child = subagentsByParent[address.parentSessionId]?.entries
  566. .find(entry => entry.kind === 'child' && entry.id === childId)
  567. if (child?.kind !== 'child') break
  568. const displayTitle = child.label ?? childId
  569. const summary = byId[childId]
  570. if (summary === undefined) {
  571. byId[childId] = {
  572. id: childId,
  573. displayTitle,
  574. parentId: address.parentSessionId,
  575. origin: 'subagent',
  576. running: child.activity === 'running',
  577. blank: false,
  578. updatedAt: 0,
  579. }
  580. } else if (summary.displayTitle !== displayTitle) {
  581. byId[childId] = { ...summary, displayTitle }
  582. }
  583. const parent = byId[address.parentSessionId]
  584. if (parent !== undefined && parent.origin !== 'subagent') break
  585. address = this.manager.navigationAddress(address.parentSessionId)
  586. }
  587. }
  588. const persisted = this.selection.getSnapshot().sessionId
  589. // No current (cleared, or masked gap) wipes the persisted cell — a reload
  590. // stays on empty; the in-memory selection still resurfaces a masked id.
  591. if (current === undefined) {
  592. if (persisted !== undefined) this.selection.set({})
  593. } else if (byId[current] !== undefined
  594. && (persisted !== current
  595. || this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
  596. || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
  597. || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
  598. this.selection.set({
  599. sessionId: current,
  600. ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
  601. })
  602. }
  603. this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
  604. this.pruneScopes()
  605. }
  606. /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
  607. private pruneScopes(): void {
  608. if (this.list.getSnapshot().phase === 'pending') return
  609. for (const [id, record] of this.scopes) {
  610. if (this.eligible(id)) continue
  611. if (id === this.watched) {
  612. this.deferredRemovals.add(id)
  613. continue
  614. }
  615. this.scopes.delete(id)
  616. this.deferredRemovals.delete(id)
  617. this.startScopeDrop(id, record)
  618. }
  619. }
  620. private startScopeDrop(id: SessionId, record: ScopeRecord): void {
  621. const drop = this.dropScope(id, record)
  622. this.scopeDrops.add(drop)
  623. void drop.then(
  624. () => { this.scopeDrops.delete(drop) },
  625. () => { this.scopeDrops.delete(drop) },
  626. )
  627. }
  628. private async drainScopeDrops(): Promise<void> {
  629. while (this.scopeDrops.size > 0) {
  630. await Promise.allSettled([...this.scopeDrops])
  631. }
  632. }
  633. /**
  634. * One teardown for the whole per-session axis: the scope
  635. * fiber (cascading every actx-registered effect: input shell, slash
  636. * controller, popup, plugin stores, listeners), the session-keyed slot
  637. * registrations and the Session instance itself — the host session log is the
  638. * durable truth, a reopen lazily rebuilds and backfills via open().
  639. */
  640. private async dropScope(id: SessionId, record: ScopeRecord): Promise<void> {
  641. // Release the Session's dispatch point with the scope it belongs to (a
  642. // surviving instance — the live Intent — rebinds when resolve re-mints).
  643. record.session.unbindScope()
  644. await Promise.allSettled([
  645. record.fiber.dispose(),
  646. this.manager.drop(id),
  647. ])
  648. }
  649. /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
  650. private sweepDeferred(): void {
  651. for (const id of [...this.deferredRemovals]) {
  652. /* v8 ignore next -- defensive: only the staged id ever defers, and every
  653. * stage move sweeps first, so the set cannot contain the id the stage just
  654. * moved to; kept as a guard against future extra sweep call sites. */
  655. if (id === this.watched) continue
  656. // Eligible again? (A re-added id cancels the deferred teardown.)
  657. if (this.eligible(id)) {
  658. this.deferredRemovals.delete(id)
  659. continue
  660. }
  661. const record = this.scopes.get(id)
  662. this.deferredRemovals.delete(id)
  663. /* v8 ignore next -- defensive: prune deletes a scope and its deferral
  664. * together, so a deferred id always still owns its record; kept so a
  665. * future teardown path cannot double-dispose. */
  666. if (record !== undefined) {
  667. this.scopes.delete(id)
  668. this.startScopeDrop(id, record)
  669. }
  670. }
  671. }
  672. }