@deepseek-ai/dsh-api-session-controller 0.2.1-alpha.2 → 0.2.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -15,8 +15,8 @@
15
15
  en: 1fdf707322fb6996
16
16
  zh: 0204f7e9d4aa3cce
17
17
  /session-controller/use-this-package:
18
- en: d26a58a8f2406cd5
19
- zh: 82135eceb9adb628
18
+ en: f9e4042fae88f801
19
+ zh: f379f78966d57c2e
20
20
  /session-controller/client-references:
21
21
  en: 41bdda1ac32df558
22
22
  zh: aa5d0e325924066b
package/README.md CHANGED
@@ -33,13 +33,15 @@ The Client journal validates current Session event envelopes before publishing f
33
33
 
34
34
  Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
35
35
 
36
+ Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
37
+
36
38
  List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections.
37
39
 
38
40
  Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints.
39
41
 
40
42
  Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
41
43
 
42
- Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies.
44
+ `session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies.
43
45
 
44
46
  Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children.
45
47
 
package/README.zh.md CHANGED
@@ -33,13 +33,15 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当
33
33
 
34
34
  每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
35
35
 
36
+ 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
37
+
36
38
  列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
37
39
 
38
40
  Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
39
41
 
40
42
  Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
41
43
 
42
- 显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
44
+ `session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
43
45
 
44
46
  Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。
45
47
 
package/lib/client.js CHANGED
@@ -157,6 +157,8 @@ window.__ModuleLoader__.load({
157
157
  "hook/invoked",
158
158
  "hook/result",
159
159
  "image/offload",
160
+ "job/registered",
161
+ "job/settled",
160
162
  "llm/retry",
161
163
  "llm/retry-started",
162
164
  "model/selection",
@@ -2359,7 +2361,7 @@ window.__ModuleLoader__.load({
2359
2361
  else {
2360
2362
  const routedContent = content;
2361
2363
  const routed = await this.remote.subagents.prompt({
2362
- requestId: randomUUID(),
2364
+ requestId: requestId ?? randomUUID(),
2363
2365
  parentSessionId: this.address.parentSessionId,
2364
2366
  childSessionId: this.address.childSessionId,
2365
2367
  mode: "continuable",
@@ -3313,11 +3315,14 @@ window.__ModuleLoader__.load({
3313
3315
  * Contract session.create; on success merge into summaries immediately (no
3314
3316
  * wait for the next refresh). A created session is blank by definition
3315
3317
  * (entity birth precedes the first message).
3316
- * @param opts - target workspace or working directory, plus an optional caller-owned id.
3317
- * @returns the create result.
3318
+ * @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset.
3319
+ * @returns the create result, naming the Session the Host created or reused.
3318
3320
  */
3319
3321
  async create(opts = {}) {
3320
- const shared = opts.sessionId === void 0 ? {} : { sessionId: opts.sessionId };
3322
+ const shared = {
3323
+ ...opts.sessionId === void 0 ? {} : { sessionId: opts.sessionId },
3324
+ ...opts.agentPreset === void 0 ? {} : { agentPreset: opts.agentPreset }
3325
+ };
3321
3326
  const payload = opts.workspaceId !== void 0 ? {
3322
3327
  workspaceId: opts.workspaceId,
3323
3328
  ...shared
@@ -3960,8 +3965,8 @@ window.__ModuleLoader__.load({
3960
3965
  /**
3961
3966
  * Create a Host Session and publish its catalog row before resolving.
3962
3967
  * Callers retain the returned identity before borrowing its binding.
3963
- * @param opts - target workspace or directory and an optional preallocated id.
3964
- * @returns the new session id.
3968
+ * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset.
3969
+ * @returns the created or reused session id.
3965
3970
  * @throws {SessionCreateError} with the requested id.
3966
3971
  */
3967
3972
  async create(opts = {}) {
package/lib/index.js CHANGED
@@ -4,18 +4,18 @@ import z from "@deepseek-ai/schemastery";
4
4
  import { AssistantStreamAccumulator, ReasoningEffortId, assistantStreamChunks, createUserMessage, errorChain, freezeMessage } from "@deepseek-ai/dsh-llm";
5
5
  import { canOpenNativePath, nativeFileApplications, nativeFileManager, openNativeAssociatedPath, openNativeFileApplication, revealNativePath } from "@deepseek-ai/dsh-native-command";
6
6
  import { SessionQueryError } from "@deepseek-ai/dsh-session-query";
7
- import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
7
+ import { Remote, RemoteError, TypertRemoteService, principalOf, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
8
+ import { canonicalClientTimeZone } from "@deepseek-ai/dsh-util-time";
9
+ import { assertNever } from "@deepseek-ai/dsh-util-values";
10
+ import { randomUUID } from "node:crypto";
8
11
  import { mkdir } from "node:fs/promises";
9
12
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
10
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
11
- import { randomUUID } from "node:crypto";
12
13
  import { brandString } from "@deepseek-ai/dsh-brand";
14
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
13
15
  import { AttachmentError } from "@deepseek-ai/dsh-attachment";
14
16
  import { buildForkSeed } from "@deepseek-ai/dsh-session/fork";
15
17
  import { SessionLogOffset, SessionSeq, isAppendSurfaceEvent } from "@deepseek-ai/dsh-session";
16
18
  import { SessionTitleInvalidError } from "@deepseek-ai/dsh-session-title";
17
- import { canonicalClientTimeZone } from "@deepseek-ai/dsh-util-time";
18
- import { assertNever } from "@deepseek-ai/dsh-util-values";
19
19
  import { Deque } from "@deepseek-ai/dsh-deque";
20
20
  import { performance } from "node:perf_hooks";
21
21
  import { scheduler } from "node:timers/promises";
@@ -106,18 +106,18 @@ var ApiSessionCwdConflict = class extends Error {
106
106
  this.existingCwd = existingCwd;
107
107
  }
108
108
  };
109
- /** Explicit-id creation attempted to adopt a Session under another preset. */
109
+ /** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */
110
110
  var ApiSessionPresetConflict = class extends Error {
111
111
  sessionId;
112
112
  requestedPreset;
113
- existingPreset;
114
- constructor(sessionId, requestedPreset, existingPreset) {
115
- super(existingPreset === void 0 ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`);
113
+ constructor(sessionId, requestedPreset) {
114
+ super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`);
116
115
  this.sessionId = sessionId;
117
116
  this.requestedPreset = requestedPreset;
118
- this.existingPreset = existingPreset;
119
117
  }
120
118
  };
119
+ /** A blank Session chosen for reuse started a turn before this Host acquired its writer. */
120
+ var ApiSessionStartedSinceObserved = class extends Error {};
121
121
  /**
122
122
  * Test whether generic Session routing must leave an identity to subagent routing.
123
123
  * @param ctx - Host context carrying the Agent ownership registry.
@@ -180,7 +180,7 @@ async function inspectApiSession(ctx, sessionId, signal) {
180
180
  var ApiSessionAgentController = class {
181
181
  ctx;
182
182
  resumes = /* @__PURE__ */ new Map();
183
- creations = /* @__PURE__ */ new Map();
183
+ claims = /* @__PURE__ */ new Map();
184
184
  selections = /* @__PURE__ */ new WeakMap();
185
185
  imageAdmissionChains = /* @__PURE__ */ new WeakMap();
186
186
  /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
@@ -245,35 +245,26 @@ var ApiSessionAgentController = class {
245
245
  }
246
246
  }
247
247
  /**
248
- * Resolve one requested identity, creating or resuming it once.
249
- * @param sessionId - requested Session identity.
250
- * @param cwd - directory the Session must own.
251
- * @param checkPersistedIdentity - whether to inspect a cold identity before creation.
252
- * @param presetId - optional Agent preset the Session must own.
253
- * @returns the matching live ordinary Agent.
248
+ * Create a Session for a new conversation, reusing the named blank Session
249
+ * while it is still reusable. A reused Session runs the requested preset, or
250
+ * the configured default when the request names none; the preset it recorded
251
+ * is not resolved, and a change is logged as `agent-preset/selected` before
252
+ * the Agent is published. A named Session that has started a turn, fails the
253
+ * caller's placement rule, or is written by another owner stays unchanged,
254
+ * and a new Session is created instead. Requests naming one identity run in
255
+ * arrival order, including their preset resolution, and check the named
256
+ * Session's directory and ownership before resolving the preset.
257
+ * @param request - optional Session to reuse, directory, preset, and placement rule.
258
+ * @returns the reused or created Session and the preset it ran when this request finished.
259
+ * @throws ApiSessionCwdConflict when the named Session belongs to another directory.
260
+ * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session.
261
+ * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset.
262
+ * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session.
254
263
  */
255
- async ensureSession(sessionId, cwd, checkPersistedIdentity, presetId) {
256
- let creation = this.creations.get(sessionId);
257
- if (creation === void 0) {
258
- creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId).catch((error) => {
259
- const live = this.ctx.agents.get(sessionId);
260
- if (live !== void 0) {
261
- if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) throw new ApiSessionSubagentOwnership(sessionId);
262
- return live;
263
- }
264
- const attached = this.ctx.sessions.get(sessionId);
265
- if (attached !== void 0 && hasApiSessionSubagentOwner(this.ctx, attached, void 0)) throw new ApiSessionSubagentOwnership(sessionId);
266
- throw error;
267
- }).finally(() => {
268
- this.creations.delete(sessionId);
269
- });
270
- this.creations.set(sessionId, creation);
271
- }
272
- const agent = await creation;
273
- if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) throw new ApiSessionSubagentOwnership(sessionId);
274
- if (presetId !== void 0) this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session));
275
- if (agent.session.header.cwd !== cwd) throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd);
276
- return agent;
264
+ async createSession(request) {
265
+ const named = request.sessionId;
266
+ if (named === void 0) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset)));
267
+ return this.inOrder(named, async () => this.created(await this.claim(named, request)));
277
268
  }
278
269
  /**
279
270
  * Install or return the Session-local model selection used by prompt assembly.
@@ -411,44 +402,118 @@ var ApiSessionAgentController = class {
411
402
  setup: composition.setup
412
403
  })).agent;
413
404
  }
414
- async createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) {
405
+ async inOrder(sessionId, operation) {
406
+ const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation);
407
+ const settled = result.then(() => void 0, () => void 0);
408
+ this.claims.set(sessionId, settled);
409
+ try {
410
+ return await result;
411
+ } finally {
412
+ if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId);
413
+ }
414
+ }
415
+ /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */
416
+ async claim(sessionId, request) {
415
417
  const attached = this.ctx.sessions.get(sessionId);
416
418
  const live = this.ctx.agents.get(sessionId);
417
419
  if (attached !== void 0 && hasApiSessionSubagentOwner(this.ctx, attached, live)) throw new ApiSessionSubagentOwnership(sessionId);
418
- if (live !== void 0) return live;
419
- if (checkPersistedIdentity) try {
420
+ if (live !== void 0) {
421
+ if (live.session.header.cwd !== request.cwd) throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd);
422
+ const composition = await this.composeAgent(request.agentPreset);
423
+ return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition);
424
+ }
425
+ let observation;
426
+ try {
427
+ observation = await this.ctx.sessionQuery.observeSession(sessionId);
428
+ } catch (error) {
429
+ if (!(error instanceof SessionQueryError) || error.code !== "SESSION_QUERY_SESSION_NOT_FOUND") throw error;
430
+ return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset));
431
+ }
432
+ let reusable;
433
+ {
420
434
  const env_3 = {
421
435
  stack: [],
422
436
  error: void 0,
423
437
  hasError: false
424
438
  };
425
439
  try {
426
- const observation = __addDisposableResource$4(env_3, await this.ctx.sessionQuery.observeSession(sessionId), false);
427
- if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, void 0)) throw new ApiSessionSubagentOwnership(sessionId);
428
- if (observation.header.cwd !== cwd) throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd);
429
- const storedPreset = this.presetForObservation(observation);
430
- this.assertPresetUnchanged(sessionId, presetId, storedPreset);
431
- const composition = await this.composeAgent(storedPreset);
432
- return (await this.ctx.agents.resume({
433
- resumeSessionId: sessionId,
434
- agentOptions: this.agentOptions(),
435
- setup: composition.setup
436
- })).agent;
440
+ const observed = __addDisposableResource$4(env_3, observation, false);
441
+ if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, void 0)) throw new ApiSessionSubagentOwnership(sessionId);
442
+ if (observed.header.cwd !== request.cwd) throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd);
443
+ reusable = request.reusable(sessionId) && !observed.events.some((event) => event.type === "turn/start");
437
444
  } catch (e_3) {
438
445
  env_3.error = e_3;
439
446
  env_3.hasError = true;
440
447
  } finally {
441
448
  __disposeResources$4(env_3);
442
449
  }
450
+ }
451
+ const composition = await this.composeAgent(request.agentPreset);
452
+ if (!reusable) return this.createMinted(request.cwd, composition);
453
+ this.assertPresetApplicable(sessionId, request.agentPreset);
454
+ try {
455
+ return (await this.ctx.agents.resume({
456
+ resumeSessionId: sessionId,
457
+ agentOptions: this.agentOptions(),
458
+ setup: this.reuseSetup(composition)
459
+ })).agent;
443
460
  } catch (error) {
444
- if (!(error instanceof SessionQueryError) || error.code !== "SESSION_QUERY_SESSION_NOT_FOUND") throw error;
461
+ if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition);
462
+ if (!(error instanceof Error) || error.name !== "SessionAlreadyOwnedError") throw error;
463
+ return this.ctx.agents.get(sessionId) === void 0 ? this.createMinted(request.cwd, composition) : this.claim(sessionId, request);
464
+ }
465
+ }
466
+ /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */
467
+ async reuseLive(live, request, composition) {
468
+ if (!request.reusable(live.id) || !this.isBlank(live.session)) return void 0;
469
+ this.assertPresetApplicable(live.id, request.agentPreset);
470
+ const current = this.presetForSession(live.session);
471
+ const presets = this.ctx.get("agentPresets");
472
+ const target = composition.agentPreset;
473
+ if (presets === void 0 || target === void 0 || target === current) return live;
474
+ try {
475
+ await presets.select(live, target);
476
+ } catch (error) {
477
+ if (remoteErrorOf(error)?.code !== "agent-preset/locked") throw error;
478
+ return;
445
479
  }
480
+ return live;
481
+ }
482
+ /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */
483
+ reuseSetup(composition) {
484
+ return async (agentCtx, agent) => {
485
+ if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved();
486
+ const commit = await composition.setup(agentCtx, agent);
487
+ const target = composition.agentPreset;
488
+ if (target !== void 0 && this.presetForSession(agent.session) !== target) agent.session.append("agent-preset/selected", { agentPreset: target });
489
+ return commit;
490
+ };
491
+ }
492
+ /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */
493
+ isBlank(session) {
494
+ const boundary = this.ctx.sessionProjections.stateOf(session, "turnBoundary");
495
+ return boundary === void 0 || boundary.openTurnStartSeq === null && boundary.lastTurn === 0;
496
+ }
497
+ /** Without a roster, reuse cannot apply a named preset. */
498
+ assertPresetApplicable(sessionId, requested) {
499
+ if (requested === void 0 || this.ctx.get("agentPresets") !== void 0) return;
500
+ throw new ApiSessionPresetConflict(sessionId, requested);
501
+ }
502
+ created(agent) {
503
+ return {
504
+ agent,
505
+ agentPreset: this.presetForSession(agent.session)
506
+ };
507
+ }
508
+ createMinted(cwd, composition) {
509
+ return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition);
510
+ }
511
+ async createAgent(sessionId, cwd, composition) {
446
512
  try {
447
513
  await mkdir(cwd, { recursive: true });
448
514
  } catch (error) {
449
515
  throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error });
450
516
  }
451
- const composition = await this.composeAgent(presetId);
452
517
  return (await this.ctx.agents.create({
453
518
  sessionId,
454
519
  agentOptions: this.agentOptions(),
@@ -478,10 +543,6 @@ var ApiSessionAgentController = class {
478
543
  if (observation.projections === void 0) throw new Error("api-session: Agent activation requires a projected Session observation");
479
544
  return observation.projections.values.agentPreset ?? void 0;
480
545
  }
481
- assertPresetUnchanged(sessionId, requested, existing) {
482
- if (requested === void 0 || requested === existing) return;
483
- throw new ApiSessionPresetConflict(sessionId, requested, existing);
484
- }
485
546
  };
486
547
  function agentModelSelection(selection) {
487
548
  return {
@@ -648,6 +709,11 @@ var __disposeResources$3 = (function(SuppressedError) {
648
709
  var e = new Error(message);
649
710
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
650
711
  });
712
+ /**
713
+ * Whether prompt content carries non-whitespace text or an attachment.
714
+ * @param content - submitted or edited prompt parts.
715
+ * @returns false for content the Agent must not receive.
716
+ */
651
717
  function hasPromptContent(content) {
652
718
  return content.some((part) => part.type !== "text" || part.text.trim().length > 0);
653
719
  }
@@ -681,25 +747,31 @@ var SessionCommandController = class {
681
747
  this.defaultCwd = defaultCwd;
682
748
  }
683
749
  /**
684
- * Create or idempotently adopt one ordinary Session.
685
- * @param request - requested identity, location, and Agent preset.
686
- * @returns the Session identity and resolved preset when configured.
750
+ * Create one ordinary Session for a new conversation, reusing the named
751
+ * blank Session while it is an unarchived member of the requested Workspace.
752
+ * @param request - optional blank Session to reuse, location, and Agent preset.
753
+ * @returns the created or reused Session identity and its preset when configured.
687
754
  */
688
755
  async create(request) {
689
756
  if (request.workspaceId !== void 0 && request.cwd !== void 0) throw new RemoteError("gateway/bad-request", "session.create accepts workspaceId or cwd, not both", {});
690
- const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`);
691
757
  let workspace;
692
758
  if (request.workspaceId !== void 0) {
693
759
  workspace = this.ctx.workspaceRegistry.get(request.workspaceId);
694
760
  if (workspace === void 0) throw new RemoteError("workspace/not-found", `workspace "${request.workspaceId}" not found`, { workspaceId: request.workspaceId });
695
761
  }
696
762
  const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd;
697
- let adopted;
763
+ let created;
698
764
  try {
699
- adopted = await this.agents.ensureSession(sessionId, cwd, request.sessionId !== void 0, request.agentPreset);
765
+ created = await this.agents.createSession({
766
+ ...request.sessionId === void 0 ? {} : { sessionId: request.sessionId },
767
+ cwd,
768
+ ...request.agentPreset === void 0 ? {} : { agentPreset: request.agentPreset },
769
+ reusable: (sessionId) => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) && (workspace === void 0 || workspace.sessionIds.includes(sessionId))
770
+ });
700
771
  } catch (error) {
701
- this.rejectCreation(sessionId, error);
772
+ this.rejectCreation(error);
702
773
  }
774
+ const sessionId = created.agent.id;
703
775
  if (workspace !== void 0) try {
704
776
  await workspace.attachSession(sessionId);
705
777
  } catch (error) {
@@ -708,10 +780,9 @@ var SessionCommandController = class {
708
780
  workspaceId: workspace.id
709
781
  });
710
782
  }
711
- const agentPreset = this.agents.presetForSession(adopted.session);
712
783
  return {
713
784
  sessionId,
714
- ...agentPreset === void 0 ? {} : { agentPreset }
785
+ ...created.agentPreset === void 0 ? {} : { agentPreset: created.agentPreset }
715
786
  };
716
787
  }
717
788
  /**
@@ -845,21 +916,15 @@ var SessionCommandController = class {
845
916
  }
846
917
  }
847
918
  /**
848
- * Reject empty content, then admit one prompt after Agent and attachment validation.
849
- * @param request - Session identity, prompt content, source metadata, and delivery mode.
919
+ * Admit one prompt after Agent and attachment validation; the Remote method
920
+ * has already validated the content and built the durable message source.
921
+ * @param request - Session identity, prompt content, and delivery mode.
922
+ * @param source - the `user-rpc` source recorded on the accepted message.
850
923
  * @returns acknowledgement that the Agent accepted the prompt.
851
924
  */
852
- async prompt(request) {
853
- if (!hasPromptContent(request.content)) throw new RemoteError("gateway/bad-request", "prompt content must include non-whitespace text or an attachment", {});
854
- const clientTimeZone = request.clientTimeZone === void 0 ? void 0 : canonicalClientTimeZone(request.clientTimeZone);
855
- if (request.clientTimeZone !== void 0 && clientTimeZone === void 0) throw new RemoteError("session/invalid-time-zone", "clientTimeZone must be UTC or a valid IANA Area/Location name", { value: request.clientTimeZone });
925
+ async prompt(request, source) {
856
926
  const agent = await this.resolveAgent(request.sessionId);
857
927
  if (hasPromptRequest(agent, request.requestId)) return { accepted: true };
858
- const source = {
859
- kind: "user",
860
- rpcId: request.requestId,
861
- ...clientTimeZone === void 0 ? {} : { clientTimeZone }
862
- };
863
928
  const hasImage = request.content.some((part) => part.type === "image");
864
929
  const admit = async () => {
865
930
  try {
@@ -1005,13 +1070,11 @@ var SessionCommandController = class {
1005
1070
  if ("error" in found) throw found.error;
1006
1071
  return found.agent;
1007
1072
  }
1008
- rejectCreation(sessionId, error) {
1073
+ rejectCreation(error) {
1009
1074
  if (remoteErrorOf(error) !== void 0) throw error;
1010
- if (error instanceof Error && error.name === "SessionAlreadyOwnedError") throw new RemoteError("session/writer-held", error.message, { sessionId });
1011
1075
  if (error instanceof ApiSessionPresetConflict) throw new RemoteError("agent-preset/conflict", error.message, {
1012
1076
  sessionId: error.sessionId,
1013
- requestedPreset: error.requestedPreset,
1014
- ...error.existingPreset === void 0 ? {} : { existingPreset: error.existingPreset }
1077
+ requestedPreset: error.requestedPreset
1015
1078
  });
1016
1079
  if (error instanceof ApiSessionCwdConflict) throw new RemoteError("session/conflict", error.message, {
1017
1080
  sessionId: error.sessionId,
@@ -1019,7 +1082,7 @@ var SessionCommandController = class {
1019
1082
  ...error.existingCwd === void 0 ? {} : { existingCwd: error.existingCwd }
1020
1083
  });
1021
1084
  if (error instanceof ApiSessionSubagentOwnership) throw apiSessionSubagentOwnershipError(error.sessionId);
1022
- throw new RemoteError("gateway/internal", `failed to create session "${sessionId}": ${String(error)}`, {});
1085
+ throw new RemoteError("gateway/internal", `failed to create session: ${String(error)}`, {});
1023
1086
  }
1024
1087
  async readSessionState(sessionId) {
1025
1088
  const attached = this.ctx.sessions.get(sessionId);
@@ -2575,6 +2638,15 @@ var __disposeResources = (function(SuppressedError) {
2575
2638
  var e = new Error(message);
2576
2639
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
2577
2640
  });
2641
+ /**
2642
+ * Read the Session resource a history address names: the Session itself, or
2643
+ * the child Session whose transcript a subagent address reads.
2644
+ * @param request - history request carrying the durable address.
2645
+ * @returns the Session whose access the read requires.
2646
+ */
2647
+ function addressedSession(request) {
2648
+ return request.address.kind === "session" ? request.address.sessionId : request.address.childSessionId;
2649
+ }
2578
2650
  /** Host service backing the generated `ctx.remote.session` namespace. */
2579
2651
  let SessionController = (() => {
2580
2652
  let _classSuper = TypertRemoteService;
@@ -2616,8 +2688,14 @@ let SessionController = (() => {
2616
2688
  _attachment_decorators = [Remote("attachment")];
2617
2689
  _updateQueue_decorators = [Remote("updateQueue")];
2618
2690
  _cancel_decorators = [Remote("cancel")];
2619
- _page_decorators = [Remote("page")];
2620
- _follow_decorators = [Remote({ mode: "stream" })];
2691
+ _page_decorators = [Remote({
2692
+ name: "page",
2693
+ resources: { session: addressedSession }
2694
+ })];
2695
+ _follow_decorators = [Remote({
2696
+ mode: "stream",
2697
+ resources: { session: addressedSession }
2698
+ })];
2621
2699
  _projections_decorators = [Remote("projections")];
2622
2700
  _control_decorators = [Remote({ mode: "stream" })];
2623
2701
  __esDecorate(this, null, _list_decorators, {
@@ -2843,6 +2921,7 @@ let SessionController = (() => {
2843
2921
  "fileUploads",
2844
2922
  "fs",
2845
2923
  "llm",
2924
+ "remoteAccess",
2846
2925
  "sessions",
2847
2926
  "sessionProjections",
2848
2927
  "sessionQuery",
@@ -2871,6 +2950,7 @@ let SessionController = (() => {
2871
2950
  */
2872
2951
  constructor(ctx, config, internals = {}) {
2873
2952
  super(ctx, "sessionController", { namespace: "session" });
2953
+ ctx.typert.resources.register("session", { field: "sessionId" });
2874
2954
  const resolved = SessionController.Config(config);
2875
2955
  installModelSelectionProjection(ctx);
2876
2956
  this.agents = new ApiSessionAgentController(ctx);
@@ -2979,21 +3059,33 @@ let SessionController = (() => {
2979
3059
  * @returns visible Session summaries ordered by activity.
2980
3060
  */
2981
3061
  async list(_request, signal) {
2982
- return { items: await this.listState.list(signal) };
3062
+ return { items: (await this.listState.list(signal)).filter((item) => this.ctx.remoteAccess.allows({
3063
+ kind: "session",
3064
+ id: item.sessionId
3065
+ })) };
2983
3066
  }
2984
3067
  /**
2985
- * Search visible Session content without resuming an Agent.
3068
+ * Search visible Session content without resuming an Agent. A non-owner
3069
+ * receives only the hits whose Session `allows()` admits; `hasMore`
3070
+ * describes the bounded result before that filtering.
2986
3071
  * @param request - literal message-content query.
2987
3072
  * @param signal - cancellation for list and search reads.
2988
3073
  * @returns authorized bounded Session search results.
2989
3074
  */
2990
- search(request, signal) {
2991
- return this.listState.search(request.query, signal);
3075
+ async search(request, signal) {
3076
+ const result = await this.listState.search(request.query, signal);
3077
+ return {
3078
+ ...result,
3079
+ items: result.items.filter((item) => this.ctx.remoteAccess.allows({
3080
+ kind: "session",
3081
+ id: item.sessionId
3082
+ }))
3083
+ };
2992
3084
  }
2993
3085
  /**
2994
- * Create or idempotently adopt one ordinary Session.
2995
- * @param request - requested identity, location, and Agent preset.
2996
- * @returns the Session identity and resolved preset when configured.
3086
+ * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable.
3087
+ * @param request - optional blank Session to reuse, location, and Agent preset.
3088
+ * @returns the created or reused Session identity and its preset when configured.
2997
3089
  */
2998
3090
  create(request) {
2999
3091
  return this.commands.create(request);
@@ -3123,9 +3215,19 @@ let SessionController = (() => {
3123
3215
  * @param signal - caller cancellation before prompt admission begins.
3124
3216
  * @returns acknowledgement that the Agent accepted the prompt.
3125
3217
  */
3126
- prompt(request, signal) {
3218
+ async prompt(request, signal) {
3127
3219
  signal.throwIfAborted();
3128
- return this.commands.prompt(request);
3220
+ if (!hasPromptContent(request.content)) throw new RemoteError("gateway/bad-request", "prompt content must include non-whitespace text or an attachment", {});
3221
+ const clientTimeZone = request.clientTimeZone === void 0 ? void 0 : canonicalClientTimeZone(request.clientTimeZone);
3222
+ if (request.clientTimeZone !== void 0 && clientTimeZone === void 0) throw new RemoteError("session/invalid-time-zone", "clientTimeZone must be UTC or a valid IANA Area/Location name", { value: request.clientTimeZone });
3223
+ const principal = principalOf(this.ctx.invocation);
3224
+ const source = {
3225
+ kind: "user",
3226
+ rpcId: request.requestId,
3227
+ ...clientTimeZone === void 0 ? {} : { clientTimeZone },
3228
+ ...principal === void 0 ? {} : { principal }
3229
+ };
3230
+ return this.commands.prompt(request, source);
3129
3231
  }
3130
3232
  /**
3131
3233
  * Read one image proven reachable from the addressed Session log.
@@ -3233,8 +3335,31 @@ let SessionController = (() => {
3233
3335
  * @param signal - cancellation owned by the Remote stream carrier.
3234
3336
  * @returns one complete baseline followed by live replacement frames.
3235
3337
  */
3236
- control(signal) {
3237
- return this.controlState.control(signal);
3338
+ async *control(signal) {
3339
+ for await (const frame of this.controlState.control(signal)) switch (frame.type) {
3340
+ case "baseline": {
3341
+ const projections = Object.fromEntries(Object.entries(frame.value.projections).filter(([sessionId]) => this.ctx.remoteAccess.allows({
3342
+ kind: "session",
3343
+ id: sessionId
3344
+ })));
3345
+ yield {
3346
+ type: "baseline",
3347
+ value: {
3348
+ ...frame.value,
3349
+ projections
3350
+ }
3351
+ };
3352
+ break;
3353
+ }
3354
+ case "projection":
3355
+ if (this.ctx.remoteAccess.allows({
3356
+ kind: "session",
3357
+ id: frame.sessionId
3358
+ })) yield frame;
3359
+ break;
3360
+ /* v8 ignore next -- closed control-frame union. */
3361
+ default: assertNever(frame);
3362
+ }
3238
3363
  }
3239
3364
  };
3240
3365
  })();