From 7639f4cb68e32102dce67a7caf30260cf5ff104f Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 29 Jul 2026 14:12:01 +0800
Subject: [PATCH 01/18] feat(web): answerable ask_user_question flow with
toolview verdict row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pending question now owns exactly two surfaces: the redesigned
QuestionComposer takeover (footer pager, checkbox multi-select,
always-visible custom input, locale-injected bilingual chrome) collects
the answers, and a dedicated ask_user_question toolview row reports the
interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED),
or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows
to approval waits only. Toolview leading icons and the hover chevron
unify on the tertiary label color, the checklist glyph matches the
14px figma extract, and dev-watch registers CSS modules so css-only
edits rebuild.
---
...29-ask-question-web-presentation.i18n.yaml | 6 +
...026-07-29-ask-question-web-presentation.md | 45 +++
...-07-29-ask-question-web-presentation.zh.md | 45 +++
docs/event-producer-consumer.md | 2 +-
packages/client/tsdown.client.ts | 5 +-
.../ui-conversation/src/client/apply.ts | 4 +
.../src/client/chat/ChatView.tsx | 5 +-
.../src/client/chat/PendingCard.tsx | 27 +-
.../src/client/chat/ToolRow.module.css | 10 -
.../src/client/chat/ToolRow.tsx | 5 +-
.../src/client/toolviews/ask-question-row.tsx | 94 ++++++
.../src/client/toolviews/todo-row.module.css | 58 ----
.../src/client/toolviews/todo-row.tsx | 63 ++--
.../tests/ask-question-row.spec.tsx | 130 ++++++++
.../ui-conversation/tests/chat-apply.spec.tsx | 6 +-
.../tests/coverage-tails.spec.tsx | 14 +-
.../ui-conversation/tests/todo-panel.spec.tsx | 32 +-
.../client/ui-primitives/src/icons/index.tsx | 39 ++-
.../client/ui-primitives/tests/icons.spec.tsx | 4 +-
packages/client/ui-question/README.i18n.yaml | 6 +-
packages/client/ui-question/README.md | 2 +
packages/client/ui-question/README.zh.md | 2 +
packages/client/ui-question/package.json | 4 +-
.../src/client/QuestionComposer.module.css | 280 ++++++++++--------
.../src/client/QuestionComposer.tsx | 206 +++++++------
.../ui-question/src/client/contract/slots.ts | 30 +-
.../client/ui-question/src/client/index.ts | 50 +++-
.../client/ui-question/src/client/locales.ts | 39 +++
.../ui-question/tests/browser-plugin.spec.ts | 52 +++-
.../tests/question-composer.spec.tsx | 48 +--
packages/client/ui-question/tsconfig.json | 3 +
pnpm-lock.yaml | 3 +
32 files changed, 869 insertions(+), 450 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
create mode 100644 packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
delete mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
create mode 100644 packages/client/ui-conversation/tests/ask-question-row.spec.tsx
create mode 100644 packages/client/ui-question/src/client/locales.ts
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
new file mode 100644
index 0000000000..6954c289bd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
+2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55
+2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
new file mode 100644
index 0000000000..90eeb3cdcc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
@@ -0,0 +1,45 @@
+# Agent Note: Ask-question Web presentation
+
+Status: implemented
+
+English | [中文](2026-07-29-ask-question-web-presentation.zh.md)
+
+## Problem
+
+The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`.
+
+Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text.
+
+## Decision
+
+A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
+
+The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim.
+
+Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim.
+
+Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher.
+
+## Alternatives considered
+
+**Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface.
+
+**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list.
+
+**Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere.
+
+**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale.
+
+**Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel.
+
+## Consequences
+
+`ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone.
+
+The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships.
+
+`ui-question` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`.
+
+## Verification
+
+`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-question` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths.
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
new file mode 100644
index 0000000000..5bb19d3a68
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
@@ -0,0 +1,45 @@
+# Agent Note:Ask-question Web 呈现
+
+Status: implemented
+
+[English](2026-07-29-ask-question-web-presentation.md) | 中文
+
+## 问题
+
+Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON,因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。
+
+另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。
+
+## 决定
+
+一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
+
+输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。
+
+输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。
+
+两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。
+
+## 曾考虑的替代方案
+
+**继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。
+
+**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。
+
+**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。
+
+**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。
+
+**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。
+
+## 后果
+
+`ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。
+
+行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。
+
+`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其契约(`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`。
+
+## 验证
+
+`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e9e5bb6e6a..2ab220305c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` |
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 9b93feae8b..6b004c80b4 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
- async load(virtualId: string) {
+ async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
+ // Virtual modules hide the real file from the watcher; register it so
+ // dev-web rebuilds on a css-only edit.
+ this.addWatchFile?.(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 62801ca0b8..48dee62787 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
+import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -187,6 +188,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
+ // The ask_user_question row: waiting/answered/cancelled interaction outcome.
+ ctx.plugin(askQuestionToolview)
+
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index deb7f09f6c..e5d80d52c6 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
)}
- {pending.map(item => )}
+ {/* Approval waits only: a pending question already shows as the
+ ask_user_question row (waiting state) plus the composer takeover. */}
+ {pending.filter(item => item.kind === 'approval')
+ .map(item => )}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && }
diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
index b6825aed9a..5a2076fe85 100644
--- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
@@ -1,31 +1,22 @@
-// PendingCard: approval/question placeholder card (visible, not answerable —
-// the composer-takeover approval panel is a P-II item; wire pending semantics
-// already exist so the flow must show them).
+// PendingCard: approval placeholder card (visible, not answerable — the
+// composer-takeover approval panel is a P-II item; wire pending semantics
+// already exist so the flow must show them). Question waits render through
+// the ask_user_question toolview row + the composer takeover instead.
import { memo } from 'react'
-import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
- item: PendingInteraction
+ item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
- {item.kind === 'approval' ? (
- <>
-
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
}
- >
- ) : (
- <>
-
等待回答({item.payload.questions.length} 题)
-
- >
- )}
-
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
+
请在原客户端处理(web 端审批后续里程碑提供)
)
})
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 018529961f..c18bbefb01 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -62,12 +62,6 @@
color: var(--dsw-alias-label-tertiary);
}
-/* The others-variant sparkle glyph is one gray step darker than the icon
- family in the source design. */
-.root[data-variant='others'] .leading {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -87,10 +81,6 @@ button.leading {
cursor: pointer;
}
-.chevron {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 5c5d059292..6abca0d739 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -7,7 +7,6 @@
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
-import clsx from 'clsx'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -74,12 +73,12 @@ export function ToolRow({
? (
<>
{icon}
-
+
>
)
: icon
const leading = open
- ?
+ ?
: leadingFor(state, collapsedIcon)
return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
new file mode 100644
index 0000000000..3ba94cc438
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
@@ -0,0 +1,94 @@
+// ask_user_question toolview: question-flavored summary row replacing the
+// generic "Tool call" card, registered into the keyed
+// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
+// (chrome, running sweep, leading expansion) and swaps in the interaction
+// outcome — `waiting` while pending, answered-count once settled, `cancelled`
+// when the user dismissed the whole set — because the questions themselves
+// render in the composer takeover.
+
+import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { Context } from 'cordis'
+import type { ToolRowProps } from '../contract/slots.ts'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
+
+/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
+interface AnswerEntry { selected?: unknown; custom?: unknown }
+
+function isAnswer(value: unknown): value is AnswerEntry {
+ return typeof value === 'object' && value !== null
+}
+
+/** `${answered}/${total} answered` off the result JSON (a skipped question has
+ * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
+function answeredSummary(text: string): string | null {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ return null
+ }
+ if (typeof parsed !== 'object' || parsed === null) return null
+ const answers = (parsed as { answers?: unknown }).answers
+ if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
+ const answered = answers.filter(a =>
+ (Array.isArray(a.selected) && a.selected.length > 0)
+ || (typeof a.custom === 'string' && a.custom !== '')).length
+ return `${answered}/${answers.length} answered`
+}
+
+/** One-line question-interaction row (row click opens details; leading toggle
+ * expands the raw args). */
+export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) {
+ const model = toolRowModel(toolName, block)
+ // Composer verdicts settle the call as specific UserInteractionErrors
+ // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
+ // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
+ // question was pending. Both name their verdict instead of the generic
+ // failed shape, and the abort keeps the shared stopped (amber) semantics of
+ // any other interrupted tool call.
+ const code = 'kind' in block ? block.error?.code : undefined
+ let summary = model.summary
+ let state = model.state
+ if (code === 'ASK_CANCELLED') {
+ summary = 'cancelled'
+ } else if (code === 'ASK_ABORTED') {
+ summary = 'interrupted'
+ state = 'stopped'
+ } else if (model.state === 'running') {
+ summary = 'waiting'
+ } else if ('kind' in block && model.state === 'ok') {
+ const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
+ summary = answeredSummary(text) ?? model.summary
+ }
+ return (
+ }
+ title="Ask question"
+ summary={summary}
+ body={model.body}
+ state={state}
+ onOpenDetails={openDetails}
+ />
+ )
+}
+
+/**
+ * The ask-question row as a plain registrant plugin, riding the same
+ * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
+ * chat entry (and with it the 'conversation.chat.toolview' declaration) is on
+ * the ledger.
+ */
+export const askQuestionToolview = {
+ name: 'ask-question-toolview',
+ inject: ['slots', 'conversation'],
+ /**
+ * Register the ask-question row into the chat view's keyed toolview hole.
+ * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+ */
+ apply(ctx: Context): void {
+ ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
+ },
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
deleted file mode 100644
index 1a1b142b3a..0000000000
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
+++ /dev/null
@@ -1,58 +0,0 @@
-/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
- [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
-
-.row {
- display: flex;
- align-items: center;
- height: 24px;
- min-width: 0;
- cursor: pointer;
- border-radius: 6px;
-}
-
-.leading {
- flex: none;
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-right: 6px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.title {
- flex: none;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500; /* figma wt510, rendered 500 */
- color: var(--dsw-alias-label-primary-dimmed);
-}
-
-.sep {
- flex: none;
- width: 2px;
- height: 2px;
- border-radius: 1px;
- margin: 0 8px;
- background: var(--dsw-alias-label-caption);
-}
-
-.summary {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 14px;
- line-height: 24px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.err {
- flex: none;
- margin-left: 8px;
- color: var(--dsw-alias-state-error-primary);
- font-size: 11px;
- line-height: 16px;
-}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
index a47322b614..2d72cfc700 100644
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
@@ -1,16 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
-// summarizes the written list (counts + active item) from the call args; the
+// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
+// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
-// row stays one line. Chrome matches ToolRow (figma 780:53675).
+// row stays one line.
-import type { KeyboardEvent } from 'react'
+import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
-import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
-import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
-import css from './todo-row.module.css'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null {
: head
}
-/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
- * state semantic while running or failed; ok keeps the checklist glyph. */
-function leadingFor(state: ToolRowState) {
- switch (state) {
- case 'running': return
- case 'error': return
- case 'stopped': return
- default: return
- }
-}
-
-/** One-line plan update row (click opens the raw args in details). Non-ok
- * execution states keep the generic row's dot semantics — a cancelled call
- * wrote no todo/write, so it must not read as a completed update. */
+/** One-line plan update row (row click opens details; leading toggle expands
+ * the raw args). Non-ok execution states keep the shared row's dot semantics
+ * — a cancelled call wrote no todo/write, so it must not read as a completed
+ * update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
- // Button semantics, not a
)
@@ -270,18 +277,8 @@ function QuestionFlow({ pending, t, useLocale }: {
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
- onChange={(event) => {
- const value = event.target.value
- updateDraft(current => ({
- ...current, selected: [], custom: value, skipped: false,
- }))
- }}
- onKeyDown={(event) => {
- if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
- event.preventDefault()
- continueFlow()
- }
- }}
+ onChange={draftCustom}
+ onKeyDown={continueFromCustom}
/>
)}
From b3411556524aadb23391f174e228f86606503652 Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 11:38:51 +0800
Subject: [PATCH 03/18] feat(session): project the inherited-history boundary
into the log
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
---
...0-session-inherited-log-boundary.i18n.yaml | 6 ++
...26-07-30-session-inherited-log-boundary.md | 53 +++++++++
...07-30-session-inherited-log-boundary.zh.md | 53 +++++++++
...7-29-durable-last-activity-index.i18n.yaml | 6 ++
.../2026-07-29-durable-last-activity-index.md | 66 ++++++++++++
...26-07-29-durable-last-activity-index.zh.md | 66 ++++++++++++
...-29-session-resumed-log-boundary.i18n.yaml | 6 ++
...2026-07-29-session-resumed-log-boundary.md | 51 +++++++++
...6-07-29-session-resumed-log-boundary.zh.md | 51 +++++++++
docs/cordis-catalog/services.md | 4 +-
docs/core-data-structures/session.i18n.yaml | 4 +-
docs/core-data-structures/session.md | 36 ++++++-
docs/core-data-structures/session.zh.md | 36 ++++++-
docs/persistence-catalog.md | 29 ++++-
.../cordis-inspect-jsdoc/session.jsonl | 2 +-
.../snapshots/subagent-fork/session.1.jsonl | 35 +++---
.../snapshots/subagent-mixed/session.2.jsonl | 35 +++---
.../session.expected.jsonl | 25 ++---
.../parent-override/parent.expected.jsonl | 47 ++++----
.../cordis/tool-cordis/src/api-catalog.ts | 2 +-
packages/core/agent-loop/tests/loop.spec.ts | 5 +-
packages/core/agent-loop/tests/resume.spec.ts | 8 +-
packages/core/session/src/index.ts | 17 ++-
packages/core/session/src/repair.ts | 47 +++++++-
packages/core/session/src/types.ts | 20 ++++
packages/core/session/tests/fork.spec.ts | 18 +++-
.../core/session/tests/properties.spec.ts | 14 ++-
packages/core/session/tests/repair.spec.ts | 101 +++++++++++++++++-
packages/core/session/tests/session.spec.ts | 19 ++--
packages/host/apiproxy/src/api-proxy.ts | 5 +-
.../apiproxy/tests/api-proxy-cold.spec.ts | 37 +++++++
.../llm/token-meter/tests/token-meter.spec.ts | 11 +-
.../tests/jsonl.spec.ts | 4 +-
.../tests/coordinator-contract.ts | 23 ++--
.../session-query-sqlite/tests/sqlite.spec.ts | 3 +-
.../tests/service-contracts.spec.ts | 2 +
.../tests/inheritance.spec.ts | 3 +-
.../session-telemetry/tests/telemetry.spec.ts | 12 ++-
packages/ui/tui/src/components/dialogs.ts | 4 +-
packages/ui/tui/src/index.ts | 3 +-
packages/ui/tui/tests/tui.snapshot.ts | 3 +
41 files changed, 850 insertions(+), 122 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
new file mode 100644
index 0000000000..99695985b8
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
+2026-07-30-session-inherited-log-boundary.md: 027281a7555214122b4e2d1f8457d3554a751921
+2026-07-30-session-inherited-log-boundary.zh.md: 3c169e16f0b409f125ccc29226412f239a58c6aa
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
new file mode 100644
index 0000000000..027281a755
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
@@ -0,0 +1,53 @@
+# Agent Note: the inherited-history log boundary
+
+Status: implemented
+
+English | [中文](2026-07-30-session-inherited-log-boundary.zh.md)
+
+## Problem
+
+A plugin that owns a standalone open/close bracket in the session log cannot tell a dead marker from a live one. `compact/start` … `compact/end` is the shipped case: on picking up a log whose last compaction event is an unmatched `compact/start`, "the previous writer died mid-compaction" and "a compaction is running right now" are byte-identical stored history. The owner must either refuse to compact a log that is actually free (wedging the session) or proceed over one that is genuinely busy.
+
+Nothing in the log marked where inherited history ended. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals, not log events; `agent/session-start` is emit-only. `Session.firstLiveSeq` already held the answer exactly — the seq of this lifecycle's first own write — but only in memory, so a consumer reading stored bytes could not see it.
+
+Crash repair does not close the gap and must not: `interruptedTurnClosers` synthesizes turn, step, and tool boundaries because core owns that vocabulary, and `compact/*` belongs to the compaction seam. A core repair pass that closed plugin brackets would put every plugin's bracket semantics in core.
+
+## Decision
+
+`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
+
+`isInheritedSeq(events, seq)` (exported from `dsh-session`) is the predicate a bracket owner calls on an unmatched opening marker. True means the marker belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin.
+
+The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case the predicate must classify. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
+
+Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
+
+## Persistence needs no changes
+
+The marker is in `session.events` before the coordinator captures its creation seed, so it persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. No load-path write, no revision bump at load, no durable mark on a rejected `append`, and a read-only store still serves loads.
+
+Being a live event, it reaches disk through the write-behind drain (`session/event` → `live.pending` → `scheduleDrain`) rather than a synchronous commit, so a crash can lose it. That costs nothing: `pending` drains in order, so a lost boundary means every live event above it is lost too, and the next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
+
+## Scope of the guarantee
+
+The predicate holds for a bracket *this* session inherited, not as a liveness signal about other writers. A concurrently live session may hold an open bracket over the same stored history while its own boundary sits elsewhere. A consumer that must tolerate concurrent writers needs a liveness signal beyond the log and cannot omit it on the strength of this event.
+
+## Alternatives considered
+
+**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.
+
+**A boundary appended at loop start.** The loop is one call above `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
+
+**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.
+
+**Crash repair closing `compact/*` alongside turn boundaries.** Rejected: it moves every plugin's bracket semantics into core's repair pass, and core cannot know what closing another package's bracket should record.
+
+## Consequences
+
+Bought: one predicate, in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary.
+
+Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property.
+
+`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited.
+
+Not built here: no plugin consumes `isInheritedSeq` yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary, and it belongs with that seam's own tests.
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
new file mode 100644
index 0000000000..3c169e16f0
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
@@ -0,0 +1,53 @@
+# Agent Note: 继承历史日志边界
+
+Status: implemented
+
+[English](2026-07-30-session-inherited-log-boundary.md) | 中文
+
+## Problem
+
+拥有独立开/闭括号的插件无法区分一个已死的标记和一个存活的标记。`compact/start` … `compact/end` 就是已发布的实例:当接手一份日志、而它最后的压缩事件是一个未配对的 `compact/start` 时,"上一个写入方在压缩中途死掉了"与"此刻正有一次压缩在运行"在存储历史中是逐字节相同的。所有方只能二选一:拒绝压缩一份其实空闲的日志(把会话卡死),或者在一份确实繁忙的日志上继续压缩。
+
+日志中没有任何东西标出继承历史在哪里结束。`session/created`、`session/disposed` 与 `session/flush` 是 cordis 运行时信号,不是日志事件;`agent/session-start` 只发射不落盘。`Session.firstLiveSeq` 本来就精确地持有这个答案——本生命周期第一次自有写入的 seq——但只存在于内存中,因此读取存储字节的消费方看不到它。
+
+崩溃修复既没有填上这个缺口,也不应该去填:`interruptedTurnClosers` 合成轮次、步骤与工具边界,是因为核心拥有那套词汇表,而 `compact/*` 属于压缩 seam。一个会关闭插件括号的核心修复流程,等于把每个插件的括号语义都搬进核心。
+
+## Decision
+
+`Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
+
+`isInheritedSeq(events, seq)`(由 `dsh-session` 导出)是括号所有方在一个未配对开启标记上调用的谓词。为真意味着该标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件。
+
+选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是该谓词必须判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
+
+两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
+
+## 持久化无需任何改动
+
+协调器捕获创建种子时该标记已在 `session.events` 中,因此它通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。没有加载路径写入、加载时没有 revision 递增、被拒绝的 `append` 不留下持久标记,只读存储依然可以服务加载。
+
+作为实时事件,它经由后写式 drain(`session/event` → `live.pending` → `scheduleDrain`)而不是同步提交到达磁盘,因此崩溃可能丢掉它。这没有代价:`pending` 按序 drain,所以丢掉一个边界意味着它上面的每个实时事件也一起丢掉,而下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
+
+## 保证的适用范围
+
+该谓词对*本*会话继承的括号成立,而不是关于其他写入方的存活信号。一个并发存活的会话可能在同一段存储历史上持有开放括号,而它自己的边界在别处。必须容忍并发写入方的消费方需要日志之外的存活信号,不能仅凭这个事件就省掉它。
+
+## Alternatives considered
+
+**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
+
+**在 loop 启动时追加边界。** loop 位于 `resumeWith` 上一层,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
+
+**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
+
+**让崩溃修复连同轮次边界一起关闭 `compact/*`。** 否决:这会把每个插件的括号语义搬进核心的修复流程,而核心无法知道关闭另一个包的括号应该记录什么。
+
+## Consequences
+
+买到的:一个谓词,位于一处,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
+
+代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。
+
+`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。
+
+此处未做:还没有任何插件消费 `isInheritedSeq`。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作,应当与那个 seam 自己的测试一起完成。
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
new file mode 100644
index 0000000000..643238c384
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
+2026-07-29-durable-last-activity-index.md: 7d8a83e96d9339054b0d15b2331284c8aecfe65d
+2026-07-29-durable-last-activity-index.zh.md: b081aa91cfa51ef8bb87e93f4091431f2d6ddce1
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
new file mode 100644
index 0000000000..7d8a83e96d
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
@@ -0,0 +1,66 @@
+# Agent Note: Record last activity in the session index
+
+Status: proposed
+
+English | [中文](2026-07-29-durable-last-activity-index.zh.md)
+
+## Problem
+
+A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
+
+mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/inherited` boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
+
+The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
+
+The boundary change raised the frequency of this defect and documented it as a known limitation on `dsh-host-apiproxy`. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
+
+## Proposal
+
+Store last-activity time where a listing already reads — the session index — so `summarizeCold()` can serve it without opening the log. The coordinator computes the value, because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, and keeps one definition of "activity" shared with the in-log `lastActivityTime()`.
+
+The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them:
+
+- **SQLite** gets a column on `sessions`, written in the same transaction as `appendBatch`, at the cost of a monotonic `SCHEMA_VERSION` bump.
+- **JSONL cannot host a mutable header field.** The header is line 1, written once during materialization, and the log is opened for append forever after; `jsonl.spec.ts` pins that committed bytes are never rewritten. A per-append header field would violate an asserted durability invariant, not merely complicate the writer. A per-session sidecar file is the shape to compare against leaving JSONL approximate.
+
+Three questions must be answered before implementation, and none of them is settled here:
+
+**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/inherited`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
+
+**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
+
+**Is a sidecar acceptable for JSONL?** It reintroduces a second file per session that can disagree with the log, which the single-artifact design avoided.
+
+## Alternatives considered
+
+**Read the log on the cold path.** Correct by construction and needs no format change, but it defeats the header-only listing: `list()` would scale with total log size, and the web session tree fans out over every session in the store. This is the option the mtime approximation exists to avoid.
+
+**Keep mtime and exclude boundary writes from it.** Rejected as impossible rather than undesirable: mtime is the filesystem's, not the backend's. Nothing short of restoring the timestamp after every boundary write would preserve it, and that races any concurrent reader and lies about the artifact.
+
+**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
+
+**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition.
+
+## Acceptance criteria
+
+- `SessionSummary.updatedAt` for a cold session equals the same value the attached projection reports for that session, verified by resuming, quitting without a turn, and asserting the order is unchanged across both paths.
+- A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone.
+- The activity rule has one definition: a test proves the stored field and `lastActivityTime()` agree over a log containing boundaries, closers, and a plain turn.
+- Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted.
+- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance.
+
+## Risks
+
+**Two definitions of activity drift.** The stored field is computed per batch, the projection over a whole log. A new event type classified one way at write time and the other at read time yields a session whose cold and attached orderings disagree — a bug that only appears after a restart, which is where it is hardest to notice.
+
+**A JSONL sidecar can disagree with its log.** A crash between the log append and the sidecar write leaves a stale value with no torn-tail marker to repair it. Every consumer would need to treat the sidecar as a hint, which is close to what mtime already is.
+
+**The fallback reorders existing sessions.** Whichever fallback is chosen, users with existing logs see their picker and tree reorder once on upgrade. `createdAt` makes that reordering large.
+
+**Cost may exceed the defect.** The defect is a misordering of abandoned sessions. If the honest answer for JSONL is "keep the approximation", this note's outcome may be documenting that decision rather than implementing a field — and that is an acceptable outcome.
+
+## Related
+
+- [The inherited-history log boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
+- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
+- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
new file mode 100644
index 0000000000..b081aa91cf
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
@@ -0,0 +1,66 @@
+# Agent Note: 在会话索引中记录最后活动
+
+Status: proposed
+
+[English](2026-07-29-durable-last-activity-index.md) | 中文
+
+## 问题
+
+一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。
+
+mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/inherited` 边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。
+
+已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
+
+边界那次变更提高了这个缺陷的出现频率,并把它作为一项已知限制记录在 `dsh-host-apiproxy` 上。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
+
+## 提案
+
+把最后活动时间存到列举本就会读取的地方,也就是会话索引,这样 `summarizeCold()` 无需打开日志就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 契约中新增的一个要素,而不是各后端本地的账目,同时让「活动」只保留一个定义,与日志内的 `lastActivityTime()` 共用。
+
+两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理:
+
+- **SQLite** 在 `sessions` 表上得到一列,与 `appendBatch` 在同一个事务中写入,代价是一次单调的 `SCHEMA_VERSION` 递增。
+- **JSONL 无法承载一个可变的 header 字段。** header 就是第 1 行,在物化时一次写就,此后这份日志永远以追加方式打开;`jsonl.spec.ts` 钉住了「已提交的字节绝不重写」。一个每次追加都要改的 header 字段,违反的是一条被断言的持久性不变式,而不只是让写入方变复杂。要与「让 JSONL 保持近似」相比较的形态,是每会话一个伴随文件。
+
+实现之前必须回答三个问题,本文对它们都没有定论:
+
+**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/inherited` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
+
+**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
+
+**对 JSONL 来说伴随文件可以接受吗?** 它重新引入了每会话第二个文件,而该文件可能与日志不一致,这正是单产物设计所避开的。
+
+## 考虑过的替代方案
+
+**在冷路径上读取日志。** 它按构造就是正确的,也不需要改动格式,但会让只读 header 的列举失去意义:`list()` 的开销将随日志总体量增长,而 web 会话树会扇出到存储中的每一个会话。mtime 近似的存在,正是为了避开这个选项。
+
+**保留 mtime,但把边界的写入排除在它之外。** 否决的理由是做不到,而不是不合意:mtime 属于文件系统,不属于后端。除了在每次边界写入之后把时间戳复原,没有别的办法能保住它,而那样做会与任何并发读取方产生竞态,也会对这份产物撒谎。
+
+**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
+
+**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。
+
+## 验收标准
+
+- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值;验证方式是恢复、不跑轮次就退出,并断言两条路径上的顺序都没有变化。
+- 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。
+- 活动规则只有一个定义:一个测试证明,在一份同时包含边界、closer 和一个普通轮次的日志上,已存储字段与 `lastActivityTime()` 的结果一致。
+- 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。
+- 按本仓库不做迁移的立场,SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。
+
+## 风险
+
+**「活动」的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新事件类型若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生一个冷排序与已附加排序彼此矛盾的会话;这个缺陷只在重启之后才显现,而那正是最难被注意到的地方。
+
+**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃,会留下一个陈旧的值,而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待,而这与 mtime 今天的地位已经很接近了。
+
+**回退方案会让既有会话重新排序。** 无论选定哪种回退,持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。
+
+**代价可能超过这个缺陷本身。** 该缺陷是被弃置会话的排序出错。如果对 JSONL 来说诚实的答案是「保留这套近似」,那么本文的结局可能是记录下这个决定,而不是实现一个字段,而这也是一个可以接受的结局。
+
+## 相关
+
+- [继承历史日志边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
+- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
+- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
new file mode 100644
index 0000000000..bcfbebf293
--- /dev/null
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
+2026-07-29-session-resumed-log-boundary.md: 6dbe44cbd8354369e8ed56d8cc838e9771dbaa52
+2026-07-29-session-resumed-log-boundary.zh.md: f035773b896131ca87a70b58b19cfafb280601e4
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
new file mode 100644
index 0000000000..6dbe44cbd8
--- /dev/null
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
@@ -0,0 +1,51 @@
+# Agent Note: Record the resume process boundary in the session log
+
+Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the inherited-history boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)
+
+English | [中文](2026-07-29-session-resumed-log-boundary.zh.md)
+
+## Problem
+
+A session's durable log gave no evidence that it had changed processes. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals rather than `SessionEventMap` members, and `agent/session-start` carries a `SessionStartSource` but is emit-only and never logged. Reading a stored log therefore gave no hint that anything had been resumed.
+
+That gap makes one class of question unanswerable. A plugin that owns a standalone open/close pair in the log — compaction's `compact/start` … `compact/end` is the only one today — must distinguish an unmatched opening marker left by a process that died mid-operation from one an operation is holding right now. Those two states are **byte-identical in stored history**. Without a boundary the owner has to choose between refusing forever (an unmatched marker wedges the operation permanently, and because automatic compaction failure is warn-and-continue the user-visible result is that compaction silently stops working until the context window overflows) and proceeding always (which defeats the point of holding a lock).
+
+The pressure to fix this is immediate: moving `compact/start` to its real time point, before summarization, widens the crash window from a few microseconds of synchronous appends to the length of a whole model call, so orphaned brackets go from rare to routine.
+
+## Proposal
+
+`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event below the boundary was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one.
+
+The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live.
+
+`time` is `Date.now()` floored at the log's greatest `time`, deliberately unlike the synthetic closers, which reuse the last real event's timestamp so repair output stays a deterministic function of stored history. The wall clock is not monotonic — an NTP step, a VM restore, or a log copied from a machine that was ahead can put it behind events already stored — so the floor keeps every cross-boundary duration non-negative. The floor is durable, because the clamped boundary is stored and joins the log's maximum: one future-dated event pins every later boundary in that log to the same instant until wall time passes it.
+
+**The predicate distinguishes process succession, not concurrent writers.** `load()`'s liveness guard is `ctx.sessions.get(id)`, which only sees sessions live in *this* runtime, and no backend takes a cross-process per-session lock. So process B cold-loading a session A currently owns writes a boundary after A's still-open bracket. A consumer that must tolerate concurrent writers still needs a liveness signal beyond the log.
+
+## Why this was rejected
+
+Two reasons, found while reviewing where the marker belonged.
+
+**It covers no fork.** `sessions.fork()` and a subagent fork child construct a seeded session without touching persistence, so neither gets a boundary. A forked child inherits its parent's prefix verbatim — including an open `compact/start` the parent is still holding — which is the one case where the inherited bracket's owner is demonstrably alive. The predicate was unavailable exactly where it was most needed.
+
+**Minting the marker at load made a read path a durable write.** Every consequence the review surfaced traced to that: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, the durable time floor above, a load that fails against a read-only store, and a marked log after a resume the caller then cancelled. None of these are wrong given the placement; they are the placement's cost.
+
+The successor keeps the problem statement and the concurrent-writer scope limit unchanged, and moves the write to `Session`'s constructor — the single waist all six seeded-start paths pass through, fork included. Because the marker then rides the ordinary seed-persistence path, the whole durable-write surface above disappears.
+
+## Alternatives considered
+
+**Use `Session.firstLiveSeq` as the staleness predicate.** Dismissed here on the grounds that it is documented as deliberately not persisted, so the same stored log yields different answers in different processes and a read-only reader cannot evaluate it at all. That reasoning was sound about the field and wrong about the conclusion: the fix is to persist a projection of it rather than to compute the boundary somewhere else. This is the alternative that became the successor.
+
+**Declare the event in core (`dsh-session`).** Rejected here because "the constructor cannot distinguish resume from fork or replay." That is true and turned out not to matter — the distinction is not needed, since inherited history is dead history in all three cases.
+
+**Teach `interruptedTurnClosers` to close `compact/*`.** Rejected: `compact/*` is plugin-owned vocabulary and core must not know it. Core closes turn, step, and tool boundaries — the relations it owns. The successor keeps this rejection.
+
+**Lazy self-repair: the owner appends a synthetic closing marker when it finds an orphan.** A write inside a read-shaped check, and it needs an invariant exception for a numbered owner whose turn has already closed.
+
+**A merge-extensible repair-contributor registry in core.** The right shape once a second consumer exists; with one consumer today, `packages/AGENTS.md` says not to split a seam preemptively.
+
+**Write the boundary only when repair actually occurred.** Rejected: the predicate must hold for an orderly restart too, where there is nothing to repair. The successor keeps this rejection.
+
+## Related
+
+The cold-session `updatedAt` skew this proposal documented is scoped in [the last-activity-index Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). That defect predates this proposal and survives its rejection: it is caused by mtime counting every durable write, not by any one boundary.
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
new file mode 100644
index 0000000000..f035773b89
--- /dev/null
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
@@ -0,0 +1,51 @@
+# Agent Note: 在会话日志中记录恢复的进程边界
+
+Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[继承历史边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)取代
+
+[English](2026-07-29-session-resumed-log-boundary.md) | 中文
+
+## Problem
+
+会话的持久日志此前无法证明它换过进程。`session/created`、`session/disposed` 和 `session/flush` 是 cordis 运行时信号,而不是 `SessionEventMap` 成员;`agent/session-start` 虽然携带 `SessionStartSource`,却只用于 emit,从不记录。因此,读取一份已存储日志得不到任何关于「曾经发生过恢复」的线索。
+
+这一空缺让一类问题无法回答。在日志中拥有独立开始/结束事件对的插件必须区分两种未匹配的起始标记:一种由某个在操作中途死亡的进程留下,另一种正被当前某项操作持有;今天符合这一形态的只有压缩的 `compact/start` … `compact/end`。这两种状态**在已存储历史中逐字节相同**。没有边界,所有方只能在两种做法之间选择:永远拒绝(一个未匹配的标记会永久卡住该操作,而自动压缩失败采取警告并继续的策略,因此用户可见的结果是压缩静默停止工作,直到上下文窗口溢出),或者始终继续(这让持有锁失去了意义)。
+
+修复它的压力是即刻的:把 `compact/start` 移到摘要生成之前这个真实的时间点,会把崩溃窗口从几微秒的同步追加扩大为一整次模型调用的时长,孤儿括号也就从罕见变为常态。
+
+## Proposal
+
+`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之下的每个事件都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。
+
+括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。
+
+`time` 取 `Date.now()` 并以日志的最大 `time` 为下限,刻意区别于合成 closers——后者复用最后一个真实事件的时间戳,以便修复输出始终是已存储历史的确定性函数。挂钟并非单调:一次 NTP 跳变、一次虚拟机恢复,或一份从走快的机器上拷来的日志,都可能让它落在已存储事件之后,因此这个下限让跨边界的时长都非负。该下限是持久的,因为被钳制的边界本身会被存储并加入日志的最大值:一个未来时间的事件会把该日志中之后的每个边界都钉在同一时刻,直到挂钟时间越过它。
+
+**该谓词区分的是进程接替,不是并发写入方。** `load()` 的存活性守卫是 `ctx.sessions.get(id)`,它只看到*本*运行时中存活的会话,而且没有任何后端会取跨进程的按会话锁。因此,进程 B 冷加载一个 A 当前拥有的会话时,会在 A 仍然开放的括号之后写入一个边界。必须容忍并发写入方的消费方仍然需要日志之外的存活信号。
+
+## 为什么被否决
+
+两个原因,都是在复审标记应当落在何处时发现的。
+
+**它完全覆盖不到 fork。** `sessions.fork()` 与子代理 fork 子会话在不触及持久化的情况下构造带种子会话,因此两者都拿不到边界。fork 子会话会逐字节继承父会话的前缀——包括父会话仍然持有的开放 `compact/start`——而这恰恰是继承括号的所有方明显还活着的唯一情形。谓词偏偏在最需要它的地方不可用。
+
+**在加载时铸造标记,把读取路径变成了持久写入。** 复审暴露出的每一项后果都源于此:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、上文那个持久时间下限、加载在只读存储上会失败,以及调用方随后取消的恢复也已留下标记。这些在该放置方式下都不算错,它们就是该放置方式的成本。
+
+取代方案保留问题陈述与并发写入方的适用范围限制不变,并把写入移到 `Session` 的构造函数——全部六条带种子启动路径(含 fork)必经的唯一收窄处。由于标记随后走普通的种子持久化路径,上述整个持久写入面就消失了。
+
+## Alternatives considered
+
+**用 `Session.firstLiveSeq` 作为陈旧性谓词。** 此处以「文档明确它有意不做持久化,因此同一份已存储日志在不同进程中会给出不同答案,而只读读取方根本无法对它求值」为理由否决。这个推理对字段本身是成立的,但结论错了:正确的修法是持久化它的一个投影,而不是把边界挪到别处去算。这条替代方案正是后来的取代方案。
+
+**在核心(`dsh-session`)中声明该事件。** 此处以「构造函数无法把恢复与 fork 或回放区分开」为理由否决。这句话是对的,但事实证明它无关紧要——并不需要这种区分,因为在这三种情形下继承历史都是死历史。
+
+**教 `interruptedTurnClosers` 关闭 `compact/*`。** 否决:`compact/*` 是插件所属词汇,核心不得知道它。核心只关闭轮次、步骤和工具边界,也就是它自己拥有的关系。取代方案保留这条否决。
+
+**惰性自修复:所有方发现孤儿时自行追加一条合成的关闭标记。** 这是在一次形似读取的检查中执行写入,而且需要为一个带轮次编号、其轮次却已经关闭的所有方开一个不变式例外。
+
+**在核心中建一个可合并扩展的修复贡献方注册表。** 一旦出现第二个消费方,这就是正确的形状;今天只有一个消费方,而 `packages/AGENTS.md` 要求不要预先拆分 seam。
+
+**仅在确实发生了修复时才写入边界。** 否决:该谓词对有序重启同样必须成立,而那时没有任何东西需要修复。取代方案保留这条否决。
+
+## 相关
+
+本提案记录过的冷会话 `updatedAt` 偏斜,范围界定在[最后活动索引 Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)。该缺陷早于本提案存在,并且在本提案被否决后依然存在:它的成因是 mtime 会计入每一次持久写入,而不是某一个边界。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 93e7821075..316c5a9830 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -2197,7 +2197,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
-Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:248`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 3e35c3a051..257e879eee 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6
-session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223
+session.md: 20942722c2c1f86bf9a2b6007ad42c9e97d43b91
+session.zh.md: 87ca7dc44ad4ac19456679ecbffd08cfd3023296
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index fd8285eebd..20942722c2 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -91,6 +91,26 @@ interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+ /**
+ * The log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * BELOW it was inherited through a constructor seed — resume, fork, or replay
+ * — and no writer in this session's lifecycle produced it. Appended as the
+ * first live event of every seeded session.
+ *
+ * A plugin owning a standalone open/close bracket (`compact/start` …
+ * `compact/end`) needs it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below this boundary
+ * belongs to an ended lifecycle, so it is dead whether the writer crashed,
+ * the process succeeded it, or the events were forked out of a parent that is
+ * still running. Read it through `isInheritedSeq`.
+ *
+ * NOT a liveness signal about other writers: a concurrently live session may
+ * hold an open bracket over the same stored history with its own boundary
+ * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ *
+ * The payload is empty by design — position and `time` carry the meaning.
+ */
+ 'session/inherited': Record
}
```
@@ -326,8 +346,12 @@ declare class Session {
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact
- * and is deliberately not persisted.
+ * the original fork value — this field is the in-process construction fact.
+ *
+ * Not persisted itself: a nonzero value is projected into the log as the
+ * `session/inherited` event at this seq, which is what a consumer reading
+ * STORED history reads. Prefer this field in-process — it is exact before
+ * the marker's write reaches storage.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -507,6 +531,14 @@ A turn encloses one model-loop execution, not the whole session log. Idle inject
The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md).
+## The inherited-history boundary: `session/inherited`
+
+A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open.
+
+It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. `isInheritedSeq(events, seq)` is the predicate a bracket owner calls — true means the opening marker belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent). It classifies only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
+
+Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top.
+
## Plugin-contributed log-only events
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 1033bfda11..87ca7dc44a 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -91,6 +91,26 @@ interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+ /**
+ * The log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * BELOW it was inherited through a constructor seed — resume, fork, or replay
+ * — and no writer in this session's lifecycle produced it. Appended as the
+ * first live event of every seeded session.
+ *
+ * A plugin owning a standalone open/close bracket (`compact/start` …
+ * `compact/end`) needs it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below this boundary
+ * belongs to an ended lifecycle, so it is dead whether the writer crashed,
+ * the process succeeded it, or the events were forked out of a parent that is
+ * still running. Read it through `isInheritedSeq`.
+ *
+ * NOT a liveness signal about other writers: a concurrently live session may
+ * hold an open bracket over the same stored history with its own boundary
+ * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ *
+ * The payload is empty by design — position and `time` carry the meaning.
+ */
+ 'session/inherited': Record
}
```
@@ -328,8 +348,12 @@ declare class Session {
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact
- * and is deliberately not persisted.
+ * the original fork value — this field is the in-process construction fact.
+ *
+ * Not persisted itself: a nonzero value is projected into the log as the
+ * `session/inherited` event at this seq, which is what a consumer reading
+ * STORED history reads. Prefer this field in-process — it is exact before
+ * the marker's write reaches storage.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -511,6 +535,14 @@ interface TurnEndReasonMap {
可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。
+## 继承历史边界:`session/inherited`
+
+带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。
+
+它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。`isInheritedSeq(events, seq)` 就是括号所有方调用的谓词——为真意味着该开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来)。它只判定*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
+
+活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。
+
## 插件贡献的仅日志事件
插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index dea3c4816b..5e1c6526ce 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts)
## Events
@@ -404,6 +404,33 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
### `session/*`
+#### `session/inherited` — log-only
+
+```ts persistence-catalog
+/**
+ * The log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * BELOW it was inherited through a constructor seed — resume, fork, or replay
+ * — and no writer in this session's lifecycle produced it. Appended as the
+ * first live event of every seeded session.
+ *
+ * A plugin owning a standalone open/close bracket (`compact/start` …
+ * `compact/end`) needs it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below this boundary
+ * belongs to an ended lifecycle, so it is dead whether the writer crashed,
+ * the process succeeded it, or the events were forked out of a parent that is
+ * still running. Read it through `isInheritedSeq`.
+ *
+ * NOT a liveness signal about other writers: a concurrently live session may
+ * hold an open bracket over the same stored history with its own boundary
+ * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ *
+ * The payload is empty by design — position and `time` carry the meaning.
+ */
+'session/inherited': Record
+```
+
+Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts)
+
#### `session/title` — log-only
```ts persistence-catalog
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index 042753f1a7..c019a1da1d 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
index 6d5b0d9163..2dbb507f36 100644
--- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"4fbb7fab-f45a-4e3c-95e0-454a638cf4b3"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,21 +12,22 @@
{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}}
{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5927ef74-0269-4474-a6c0-45c09c1adac5"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
+{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4dd423ef-963c-409e-8238-551c4b10273b"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
{"type":"step/end","seq":36,"time":1783352135773,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":37,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"turn/start","seq":38,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":39,"time":1783352137163,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"840f1fca-2577-47c1-acee-c47125098882"},"surfaceOp":"append"}
-{"type":"step/start","seq":40,"time":1783352137163,"data":{"turn":2,"step":1}}
-{"type":"request/header","seq":41,"time":1785142305260,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
-{"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","seq0":43,"time0":1783352137783,"data":{"turn":2,"step":1,"index":0,"dt":[178,28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}}
-{"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
-{"type":"text-chunks","seq0":78,"time0":1783352138275,"data":{"turn":2,"step":1,"index":1,"dt":[0,0,30],"texts":["M","ARM","AL","ADE"]}}
-{"type":"assistant/chunk","seq":82,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}}
-{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}}
-{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}}
-{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":86,"time":1785142305270,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0376771a-3af4-41ec-9ee6-750ba6d65b25"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"}
-{"type":"step/end","seq":87,"time":1785142305270,"data":{"turn":2,"step":1}}
-{"type":"turn/end","seq":88,"time":1785142305270,"data":{"turn":2,"reason":{"kind":"completed"}}}
+{"type":"session/inherited","seq":38,"time":1785381572223,"data":{}}
+{"type":"turn/start","seq":39,"time":1785381572224,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8cfd615d-f500-4de4-b751-d19e27d23016"},"surfaceOp":"append"}
+{"type":"step/start","seq":41,"time":1785381572240,"data":{"turn":2,"step":1}}
+{"type":"request/header","seq":42,"time":1785381572241,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
+{"type":"assistant/chunk","seq":43,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":44,"time0":1783352137961,"data":{"turn":2,"step":1,"index":0,"dt":[28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}}
+{"type":"assistant/chunk","seq":78,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":79,"time0":1783352138275,"data":{"turn":2,"step":1,"index":1,"dt":[0,30,2],"texts":["M","ARM","AL","ADE"]}}
+{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}}
+{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}}
+{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}}
+{"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3dc1dc0d-40e0-4cd1-a111-1bcf40a1001e"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"}
+{"type":"step/end","seq":88,"time":1785381572250,"data":{"turn":2,"step":1}}
+{"type":"turn/end","seq":89,"time":1785381572251,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
index 1b9127889d..9277f70139 100644
--- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5c2a4880-9a66-4e38-b8d2-a2f891ff283f"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,21 +12,22 @@
{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5dc3014f-f57f-4686-bbe9-8b89079c0b18"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"132af698-2789-4aaa-aca0-cffd1c58720a"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"step/end","seq":30,"time":1783352143771,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":31,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"turn/start","seq":32,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":33,"time":1783352147509,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9809d0e2-3997-4c6c-83ea-f28538b83ad9"},"surfaceOp":"append"}
-{"type":"step/start","seq":34,"time":1783352147509,"data":{"turn":2,"step":1}}
-{"type":"request/header","seq":35,"time":1785142306299,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
-{"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","seq0":37,"time0":1783352147925,"data":{"turn":2,"step":1,"index":0,"dt":[94,29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}}
-{"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
-{"type":"text-chunks","seq0":69,"time0":1783352148313,"data":{"turn":2,"step":1,"index":1,"dt":[0,31],"texts":["SA","FF","RON"]}}
-{"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}}
-{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}}
-{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}}
-{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":76,"time":1785142306309,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7c0c4a97-79fe-4429-a963-8e24633e6335"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"}
-{"type":"step/end","seq":77,"time":1785142306309,"data":{"turn":2,"step":1}}
-{"type":"turn/end","seq":78,"time":1785142306309,"data":{"turn":2,"reason":{"kind":"completed"}}}
+{"type":"session/inherited","seq":32,"time":1785381573525,"data":{}}
+{"type":"turn/start","seq":33,"time":1785381573526,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f85229b8-dcf8-4d8c-9d61-8ef879ed5974"},"surfaceOp":"append"}
+{"type":"step/start","seq":35,"time":1785381573543,"data":{"turn":2,"step":1}}
+{"type":"request/header","seq":36,"time":1785381573543,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
+{"type":"assistant/chunk","seq":37,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":38,"time0":1783352148019,"data":{"turn":2,"step":1,"index":0,"dt":[29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}}
+{"type":"assistant/chunk","seq":69,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":70,"time0":1783352148313,"data":{"turn":2,"step":1,"index":1,"dt":[31,1],"texts":["SA","FF","RON"]}}
+{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}}
+{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}}
+{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}}
+{"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5d0e4a32-f5ee-4992-8223-51cdb14d1db3"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"}
+{"type":"step/end","seq":78,"time":1785381573553,"data":{"turn":2,"step":1}}
+{"type":"turn/end","seq":79,"time":1785381573553,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
index 04c81635bd..ea07941fd5 100644
--- a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
+++ b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
@@ -7,15 +7,16 @@
{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}}
-{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}}
-{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
-{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}
-{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
-{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"}
-{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}}
-{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
+{"type":"session/inherited","seq":8,"time":0,"data":{}}
+{"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"session/title","seq":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":12,"time":0,"data":{"turn":2,"step":1}}
+{"type":"request/header","seq":13,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}
+{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
+{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"}
+{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}}
+{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
index d5bfb405de..7123ff0eb4 100644
--- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
+++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
@@ -3,26 +3,27 @@
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}}
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"turn/start","seq":4,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","seq":6,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}}
-{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
-{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}
-{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}}
-{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
-{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":14,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
-{"type":"tool/call","seq":15,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
-{"type":"tool/result","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
-{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}}
-{"type":"step/start","seq":18,"time":0,"data":{"turn":2,"step":2}}
-{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
-{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}
-{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}}
-{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
-{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":24,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
-{"type":"step/end","seq":25,"time":0,"data":{"turn":2,"step":2}}
-{"type":"turn/end","seq":26,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
+{"type":"session/inherited","seq":4,"time":0,"data":{}}
+{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}}
+{"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}
+{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}}
+{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":15,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}
+{"type":"tool/call","seq":16,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
+{"type":"tool/result","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
+{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}}
+{"type":"step/start","seq":19,"time":0,"data":{"turn":2,"step":2}}
+{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}
+{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}}
+{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":25,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"}
+{"type":"step/end","seq":26,"time":0,"data":{"turn":2,"step":2}}
+{"type":"turn/end","seq":27,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 9f7d6dc28d..b476158040 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -2158,7 +2158,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
- declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
+ declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record;\n}',
},
{
name: 'SessionEventMetadataFilter',
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index e051642dfd..3e9895ec4c 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -1207,8 +1207,9 @@ describe('agent loop', () => {
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
- // event-by-event identity of types
- expect(replayed.events.map(e => e.type)).toEqual(
+ // event-by-event identity of types over the inherited prefix
+ expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
agent.session.events.map(e => e.type))
+ expect(replayed.events.at(-1)?.type).toBe('session/inherited')
})
})
diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts
index f133f1979c..969c7b9f5e 100644
--- a/packages/core/agent-loop/tests/resume.spec.ts
+++ b/packages/core/agent-loop/tests/resume.spec.ts
@@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(sessionId)
- expect(agentCtx.agent?.session.events).toHaveLength(2)
+ // The two persisted events plus the inherited-history boundary.
+ expect(agentCtx.agent?.session.events).toHaveLength(3)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
@@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
- expect(a2.session.events.length).toBe(events1.length)
+ // …below one boundary marking all of it inherited.
+ expect(a2.session.events.length).toBe(events1.length + 1)
+ expect(a2.session.firstLiveSeq).toBe(events1.length)
+ expect(a2.session.events.at(-1)?.type).toBe('session/inherited')
const replay = new Session(SessionId('replay'), events1)
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 52c37dc15b..13d30dcd66 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -23,7 +23,7 @@ export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
-export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
+export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
@@ -388,8 +388,12 @@ export class Session {
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact
- * and is deliberately not persisted.
+ * the original fork value — this field is the in-process construction fact.
+ *
+ * Not persisted itself: a nonzero value is projected into the log as the
+ * `session/inherited` event at this seq, which is what a consumer reading
+ * STORED history reads. Prefer this field in-process — it is exact before
+ * the marker's write reaches storage.
*/
readonly firstLiveSeq: number
@@ -427,6 +431,13 @@ export class Session {
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
+ // Appended here so the marker is already in `events` when a backend
+ // captures the creation seed: no load-time write. Re-marking is skipped
+ // because a cold session is resumed on first touch, so repeatedly opening
+ // one must not grow its log per open.
+ if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') {
+ this.append('session/inherited', {})
+ }
}
/** Cached immutable public snapshot of the private append-only log. */
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index c5d0a74a7c..5d841f9005 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -1,7 +1,8 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
- * needed to resume with a provider-valid transcript.
+ * needed to resume with a provider-valid transcript, plus the inherited-history
+ * boundary a plugin-owned bracket reads to tell dead history from live work.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -9,6 +10,50 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
+/**
+ * Whether the event at `seq` was inherited rather than written by the lifecycle
+ * that owns `events` — the stored-history reading of `Session.firstLiveSeq`.
+ *
+ * An owner of a standalone open/close bracket calls this on an unmatched
+ * opening marker: `true` means the operation cannot still be running, because
+ * the lifecycle that opened it has ended (a crashed writer, a succeeding
+ * process, or a parent the events were forked out of). `false` means it belongs
+ * to the current lifecycle and must be treated as live.
+ *
+ * Reads the log rather than a `Session`, so it serves a consumer holding only
+ * loaded events; in-process, compare against `session.firstLiveSeq` instead.
+ * @param events - the log to scan, contiguous from seq 0.
+ * @param seq - the event seq to classify.
+ * @returns true when a `session/inherited` boundary sits at or above `seq`.
+ */
+export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean {
+ // Tail-first: an unmarked log costs no full scan, and bracket queries are
+ // usually about recent events.
+ for (let index = events.length - 1; index >= 0; index -= 1) {
+ const event = events[index]
+ /* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */
+ if (event === undefined) continue
+ if (event.seq < seq) return false
+ if (event.type === 'session/inherited') return true
+ }
+ return false
+}
+
+/**
+ * The `time` of the log's last event that represents actual work, skipping the
+ * `session/inherited` boundary.
+ *
+ * Picking a session up is not activity, and lazy resume means browsing writes a
+ * boundary, so activity ordering (a resume picker, a session list) must skip it
+ * or every opened session sorts as freshly worked in.
+ * @param events - the log to scan, in seq order.
+ * @returns the latest non-boundary event's `time`, or undefined when the log has
+ * no such event (empty, or nothing but boundaries).
+ */
+export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
+ return events.findLast(event => event.type !== 'session/inherited')?.time
+}
+
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 7f3a12b54e..46b3ad0ed2 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -250,6 +250,26 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+ /**
+ * The log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * BELOW it was inherited through a constructor seed — resume, fork, or replay
+ * — and no writer in this session's lifecycle produced it. Appended as the
+ * first live event of every seeded session.
+ *
+ * A plugin owning a standalone open/close bracket (`compact/start` …
+ * `compact/end`) needs it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below this boundary
+ * belongs to an ended lifecycle, so it is dead whether the writer crashed,
+ * the process succeeded it, or the events were forked out of a parent that is
+ * still running. Read it through `isInheritedSeq`.
+ *
+ * NOT a liveness signal about other writers: a concurrently live session may
+ * hold an open bracket over the same stored history with its own boundary
+ * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ *
+ * The payload is empty by design — position and `time` carry the meaning.
+ */
+ 'session/inherited': Record
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts
index a317ec565b..fcfbd229cf 100644
--- a/packages/core/session/tests/fork.spec.ts
+++ b/packages/core/session/tests/fork.spec.ts
@@ -50,6 +50,14 @@ function lastSeq(session: Session): number {
return event.seq
}
+/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
+function inherited(session: Session): readonly SessionEvent[] {
+ const events = session.events
+ const last = events.at(-1)
+ if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary')
+ return events.slice(0, -1)
+}
+
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
@@ -73,7 +81,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
- expect(child.events).toEqual(source.events)
+ expect(inherited(child)).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
expect(() => {
@@ -97,8 +105,8 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
- expect(child.events).toEqual(source.events)
- expect(child.events.at(-1)).toMatchObject({
+ expect(inherited(child)).toEqual(source.events)
+ expect(inherited(child).at(-1)).toMatchObject({
type: 'test/log-only',
data: { value: 'after execution' },
})
@@ -114,7 +122,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
- expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
+ expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
@@ -141,7 +149,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
- expect(child.events.at(-1)?.type).toBe('turn/end')
+ expect(inherited(child).at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
}
})
diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts
index 1449f3684e..c4372fe377 100644
--- a/packages/core/session/tests/properties.spec.ts
+++ b/packages/core/session/tests/properties.spec.ts
@@ -112,7 +112,19 @@ describe('Session properties', () => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
- expect(replayed.seq).toBe(original.seq)
+ // A non-empty replay grows by exactly one log-only boundary.
+ expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
+ expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1)
+ }))
+ })
+
+ it('replaying an already-inherited log adds no further boundary', () => {
+ fc.assert(fc.property(logArb, (events) => {
+ const original = build(events)
+ const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
+ const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
+ // Lazy resume makes browsing a pickup, so this must not grow per open.
+ expect(twice.events).toEqual(once.events)
}))
})
diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts
index 645d7d6921..b01f73a220 100644
--- a/packages/core/session/tests/repair.spec.ts
+++ b/packages/core/session/tests/repair.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
-import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
+import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -273,3 +273,102 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
+
+/**
+ * The stored-history reading of the inherited boundary. A bracket owner calls
+ * this on an unmatched opening marker to decide whether the operation can still
+ * be running, so the classification of the marker's own seq — and of the
+ * boundary seq itself — is the contract.
+ */
+describe('isInheritedSeq', () => {
+ const inheritedAt = (seq: number): SessionEvent =>
+ ({ type: 'session/inherited', seq, time: seq, data: {} })
+
+ it('classifies nothing as inherited in a log without a boundary', () => {
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
+ ]
+ expect(isInheritedSeq(events, 0)).toBe(false)
+ expect(isInheritedSeq(events, 1)).toBe(false)
+ })
+
+ it('treats an empty log as owning nothing', () => {
+ expect(isInheritedSeq([], 0)).toBe(false)
+ })
+
+ it('splits the log at the boundary', () => {
+ // seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle.
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
+ inheritedAt(2),
+ userTurnStart(2, 3),
+ ]
+ expect(isInheritedSeq(events, 0)).toBe(true)
+ expect(isInheritedSeq(events, 1)).toBe(true)
+ // The boundary's own seq counts as inherited: it belongs to the pickup.
+ expect(isInheritedSeq(events, 2)).toBe(true)
+ expect(isInheritedSeq(events, 3)).toBe(false)
+ })
+
+ it('reports inherited for an event below a later boundary', () => {
+ // Two pickups in turn: the tail scan must not stop at the nearer boundary.
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ inheritedAt(1),
+ userTurnStart(2, 2),
+ inheritedAt(3),
+ userTurnStart(3, 4),
+ ]
+ expect(isInheritedSeq(events, 0)).toBe(true)
+ expect(isInheritedSeq(events, 2)).toBe(true)
+ expect(isInheritedSeq(events, 4)).toBe(false)
+ })
+})
+
+/**
+ * Activity ordering excludes the pickup boundary. A resume picker or session
+ * list sorting by log tail would otherwise promote every session the user
+ * merely opened above the ones they actually worked in.
+ */
+describe('lastActivityTime', () => {
+ const inheritedAt = (seq: number, time: number): SessionEvent =>
+ ({ type: 'session/inherited', seq, time, data: {} })
+
+ it('has no answer for an empty log', () => {
+ expect(lastActivityTime([])).toBeUndefined()
+ })
+
+ it('reports the log tail when no boundary is present', () => {
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
+ ]
+ expect(lastActivityTime(events)).toBe(500)
+ })
+
+ it('skips a trailing boundary in favour of the last real work', () => {
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
+ inheritedAt(2, 9_000),
+ ]
+ // Resumed long after the work, but never worked in again.
+ expect(lastActivityTime(events)).toBe(500)
+ })
+
+ it('reports work done above a boundary', () => {
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ inheritedAt(1, 9_000),
+ { type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
+ ]
+ expect(lastActivityTime(events)).toBe(9_500)
+ })
+
+ it('has no answer for a log of nothing but boundaries', () => {
+ // Unreachable via the constructor, but the projection is a pure function.
+ expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined()
+ })
+})
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index 7152dd5d41..4c551bede0 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -112,7 +112,7 @@ describe('Session', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
- expect(replayed.events).toEqual(session.events)
+ expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
@@ -188,7 +188,10 @@ describe('Session', () => {
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
- expect(replayed.seq).toBe(original.seq)
+ // The seed verbatim, plus the boundary the constructor appends over it.
+ expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
+ expect(replayed.seq).toBe(original.seq + 1)
+ expect(replayed.firstLiveSeq).toBe(original.seq)
})
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
@@ -217,7 +220,7 @@ describe('Session', () => {
const unrelatedPrimitiveData = {
type: 'plugin/event', seq: 0, time: 1, data: null,
} as unknown as SessionEvent
- expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
+ expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
.toEqual([unrelatedPrimitiveData])
})
@@ -522,7 +525,8 @@ describe('Session', () => {
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
- expect(session.events).toHaveLength(3)
+ expect(session.events.slice(0, 3)).toEqual(goodSeed)
+ expect(session.firstLiveSeq).toBe(3)
})
it('reads each seed array entry once so validation and storage use the same event', () => {
@@ -546,7 +550,7 @@ describe('Session', () => {
const session = new Session(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
- expect(session.events).toEqual([accepted])
+ expect(session.events.slice(0, 1)).toEqual([accepted])
})
it('reads a nested seed-data getter once and stores its first JSON value', () => {
@@ -624,7 +628,7 @@ describe('Session', () => {
const session = new Session(SessionId('seed-null-prototype'), [event])
- expect(session.events).toEqual([{ ...event }])
+ expect(session.events.slice(0, 1)).toEqual([{ ...event }])
})
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
@@ -1647,6 +1651,7 @@ describe('todo/write event', () => {
const replayed = new Session(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
- expect(replayed.seq).toBe(original.seq)
+ expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
+ expect(replayed.firstLiveSeq).toBe(original.seq)
})
})
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 81d16c8919..078d448dd7 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -14,6 +14,7 @@ import type {
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
+import { lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -164,7 +165,9 @@ function sessionBlank(session: Session): boolean {
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
- updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
+ // Excludes the inherited-history boundary: a resumed-but-untouched session
+ // must not sort as freshly worked in.
+ updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
index 3e7f09a8df..d994444df1 100644
--- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
@@ -78,6 +78,43 @@ describe('sessions.list cold merge', () => {
})
})
+describe('attached updatedAt excludes the inherited-history boundary', () => {
+ it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(UserInteractionService)
+ await ctx.plugin(AgentRegistry)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ // Old work, resumed just now: the boundary's time is far above the work's,
+ // so reading the log tail would report the pickup as activity.
+ const worked = 1_000_000
+ const resumed = ctx.sessions.create(sid('resumed-untouched'), {
+ seed: [
+ { type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
+ { type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
+ ],
+ meta: { cwd: '/proj', createdAt: 500 },
+ })
+ ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
+ const boundary = resumed.events.at(-1)
+ expect(boundary?.type).toBe('session/inherited')
+ expect(boundary?.time).toBeGreaterThan(worked)
+
+ const listed = await api.sessions.list(request({}))
+ if (!listed.result.ok) throw new Error('list failed')
+ const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
+ expect(summary?.updatedAt).toBe(worked)
+
+ // Real work above the boundary does move it.
+ resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+ const after = await api.sessions.list(request({}))
+ if (!after.result.ok) throw new Error('list failed')
+ const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
+ expect(moved?.updatedAt).toBeGreaterThan(worked)
+ })
+})
+
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
const ctx = new Context()
diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts
index 30342e81ec..3d63ad19db 100644
--- a/packages/llm/token-meter/tests/token-meter.spec.ts
+++ b/packages/llm/token-meter/tests/token-meter.spec.ts
@@ -417,7 +417,8 @@ describe('replay anchors and surface folds', () => {
expect(after.surfaceDeltaTokens).toBeLessThan(0)
expectSurfaceTotal(after)
expect(before.nodes).toHaveLength(2)
- expect(before.logRevision).toBe(original.events.length)
+ // The earlier snapshot still reports the log it measured: seed + boundary.
+ expect(before.logRevision).toBe(original.events.length + 1)
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
})
@@ -677,13 +678,15 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
- expect(revisions).toEqual([2])
- expect(activeMeter.measure(session).logRevision).toBe(2)
+ // Seed, constructor boundary, then the append above. Only the last
+ // published: the boundary predates store attachment, like the seed.
+ expect(revisions).toEqual([3])
+ expect(activeMeter.measure(session).logRevision).toBe(3)
await firstFiber.dispose()
const secondFiber = await ctx.plugin(TokenMeterService)
activeMeter = ctx.tokenMeter
- expect(activeMeter.measure(session).logRevision).toBe(2)
+ expect(activeMeter.measure(session).logRevision).toBe(3)
await secondFiber.dispose()
})
})
diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
index 5171bb8585..15e1ccc319 100644
--- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
+++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
@@ -378,7 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await ctx.sessions.flush(child)
const loaded = await ctx.sessionPersistence.load(child.id)
- expect(loaded.events).toEqual(source.events)
+ // The inherited prefix reaches disk verbatim, then the child's boundary.
+ expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length })
expect(loaded.meta).toMatchObject({
id: SessionId('persist-child'),
cwd: '/workspace',
diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
index 1de20528b2..e34a37745b 100644
--- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
+++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
@@ -218,7 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(live)
const loaded = await ctx.sessionPersistence.load(id)
- expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
+ // The seeded constructor's boundary persisted between the stored
+ // turn/start and the turn/end appended live.
+ expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -477,11 +479,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
await ctx.sessions.flush(forked) // onCreated persisted the seed
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
- expect(loaded.events).toEqual(seed)
+ // Fork is where the marker earns its keep: the inherited prefix may
+ // carry a bracket the still-running parent owns.
+ expect(loaded.events.slice(0, seed.length)).toEqual(seed)
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length })
// A flush with no NEW events must not double-write.
await ctx.sessions.flush(forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
- expect(reloaded.events).toEqual(seed)
+ expect(reloaded.events).toEqual(loaded.events)
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -510,7 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+ // 0-5 the resumed seed, 6 the boundary, 7-8 the new turn.
+ expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
+ expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' })
} finally {
await second.fiber.dispose()
await fix.cleanup()
@@ -791,7 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
+ // Seeded 0-5 plus the constructor's boundary at 6.
+ expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -844,7 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}, { inject: ['sessions'] }))
await ctx.sessions.flush(cont)
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+ // 6-7 the claimed suffix; 8 the boundary over the whole seed.
+ expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
expect(loaded.meta).toEqual(durableMeta)
expect(loaded.meta.createdAt).toBe(1000)
diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
index 689bbad618..cab9271614 100644
--- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
@@ -1077,8 +1077,9 @@ describe('SQLite reconciliation and source lifecycle', () => {
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
db.exec('PRAGMA query_only = OFF')
+ // seq 2: one-event seed, its boundary, then the message appended above.
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
- .resolves.toMatchObject({ items: [{ seq: 1 }] })
+ .resolves.toMatchObject({ items: [{ seq: 2 }] })
})
})
diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts
index f481d8b02e..254f76ef21 100644
--- a/packages/session-title/session-title/tests/service-contracts.spec.ts
+++ b/packages/session-title/session-title/tests/service-contracts.spec.ts
@@ -185,6 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
'turn/start',
'user/message',
'turn/end',
+ // The seeded constructor's inherited-history boundary.
+ 'session/inherited',
'session/title',
])
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
index fb6ba97b32..90ae134918 100644
--- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
+++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
@@ -126,9 +126,10 @@ describe('in-process policy inheritance', () => {
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
+ // seq 1 is the inherited-history boundary.
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
- { seq: 1, data: { mode: 'read-only', source: 'delegation' } },
+ { seq: 2, data: { mode: 'read-only', source: 'delegation' } },
])
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
index a449a4053d..a429efb789 100644
--- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
+++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
@@ -186,7 +186,9 @@ describe('TelemetryCoordinator adoption', () => {
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
- expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]])
+ // 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while
+ // inherited 0-1 stay with the parent stream.
+ expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
})
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
@@ -205,14 +207,16 @@ describe('TelemetryCoordinator adoption', () => {
const ofResumed = () => backend.ledger()
.filter(r => r.attributes['session.id'] === 'resumed')
.map(r => r.attributes['event.seq'])
- expect(ofResumed()).toEqual([])
+ // Nothing inherited is re-exported; seq 2 is this session's own first
+ // write — the boundary its constructor appended over the seed.
+ expect(ofResumed()).toEqual([2])
// The seed fed the projection: the (turn 1, step 1) first chunk already
// shipped from the original process, so its continuation is re-dropped…
resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } })
- expect(ofResumed()).toEqual([])
+ expect(ofResumed()).toEqual([2])
// …while a new step's first chunk exports normally.
resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } })
- expect(ofResumed()).toEqual([3])
+ expect(ofResumed()).toEqual([2, 4])
})
it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts
index 5e9237574a..7456f3772b 100644
--- a/packages/ui/tui/src/components/dialogs.ts
+++ b/packages/ui/tui/src/components/dialogs.ts
@@ -23,6 +23,7 @@ import {
type AgentLlmTarget,
} from '@deepseek-ai/dsh-agent'
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { lastActivityTime } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
@@ -512,7 +513,8 @@ export function summarizeResumeCandidate(
return {
record,
title,
- lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
+ // Excludes a prior pickup's boundary, or every browsed session floats up.
+ lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt,
lastTurn: resumeTurnLabel(snapshot),
currentWorkspace: record.header.cwd === cwd,
workspaceLabel: formatWorkspace(record.header.cwd),
diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts
index 078332d617..de86b29833 100644
--- a/packages/ui/tui/src/index.ts
+++ b/packages/ui/tui/src/index.ts
@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
+ lastActivityTime,
SessionId,
type SessionEvent,
type UserMessage,
@@ -985,7 +986,7 @@ export function createTuiChat(
const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)'
const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)'
const events = agent.session.events
- const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt
+ const latestActivity = lastActivityTime(events) ?? agent.session.header.createdAt
const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens))
let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown`
const contextWindow = modelController.contextWindow()
diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts
index adf1c071bd..bb870ec004 100644
--- a/packages/ui/tui/tests/tui.snapshot.ts
+++ b/packages/ui/tui/tests/tui.snapshot.ts
@@ -837,6 +837,9 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
+ // A prior pickup, dated well after the work: the picker must still
+ // show the work's date, not the pickup's.
+ { type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
],
})
const harness = await setupSnapshot({
From a00786362fa415ca5778374f5e6c935985f71910 Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 12:01:45 +0800
Subject: [PATCH 04/18] refactor(session): drop the unused predicate helper and
tighten the docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`isInheritedSeq` had no production caller — only its own tests — so it was
a public core export shaped by nothing. A bracket owner reads the boundary
positionally; the helper belongs with the compaction seam, where a real
consumer decides its signature.
Also condense the `session/inherited` and `lastActivityTime` docs.
---
...0-session-inherited-log-boundary.i18n.yaml | 4 +-
...26-07-30-session-inherited-log-boundary.md | 8 +--
...07-30-session-inherited-log-boundary.zh.md | 8 +--
docs/core-data-structures/session.i18n.yaml | 4 +-
docs/core-data-structures/session.md | 26 ++++----
docs/core-data-structures/session.zh.md | 26 ++++----
docs/persistence-catalog.md | 28 ++++-----
packages/core/session/src/index.ts | 2 +-
packages/core/session/src/repair.ts | 41 ++-----------
packages/core/session/src/types.ts | 24 +++-----
packages/core/session/tests/repair.spec.ts | 60 +------------------
11 files changed, 58 insertions(+), 173 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
index 99695985b8..77e99f140f 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
-2026-07-30-session-inherited-log-boundary.md: 027281a7555214122b4e2d1f8457d3554a751921
-2026-07-30-session-inherited-log-boundary.zh.md: 3c169e16f0b409f125ccc29226412f239a58c6aa
+2026-07-30-session-inherited-log-boundary.md: 3b9cb09d1d1a16d30fd212b439a97e3d9296bd6d
+2026-07-30-session-inherited-log-boundary.zh.md: de048bc994ca97f937f74783b9055fbeb171314a
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
index 027281a755..3b9cb09d1d 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
@@ -16,9 +16,9 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth
`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
-`isInheritedSeq(events, seq)` (exported from `dsh-session`) is the predicate a bracket owner calls on an unmatched opening marker. True means the marker belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin.
+A bracket owner reads it positionally: an unmatched opening marker below the boundary belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
-The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case the predicate must classify. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
+The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
@@ -44,10 +44,10 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
## Consequences
-Bought: one predicate, in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary.
+Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary.
Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property.
`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited.
-Not built here: no plugin consumes `isInheritedSeq` yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary, and it belongs with that seam's own tests.
+Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one.
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
index 3c169e16f0..de048bc994 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
@@ -16,9 +16,9 @@ Status: implemented
`Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
-`isInheritedSeq(events, seq)`(由 `dsh-session` 导出)是括号所有方在一个未配对开启标记上调用的谓词。为真意味着该标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件。
+括号所有方按位置读取它:位于边界之下的未配对开启标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
-选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是该谓词必须判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
+选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
@@ -44,10 +44,10 @@ Status: implemented
## Consequences
-买到的:一个谓词,位于一处,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
+买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。
`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。
-此处未做:还没有任何插件消费 `isInheritedSeq`。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作,应当与那个 seam 自己的测试一起完成。
+此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 257e879eee..77d44ee3ac 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: 20942722c2c1f86bf9a2b6007ad42c9e97d43b91
-session.zh.md: 87ca7dc44ad4ac19456679ecbffd08cfd3023296
+session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52
+session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 20942722c2..25fb5471e6 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -92,23 +92,17 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * The log-only durable projection of {@link Session.firstLiveSeq}: everything
- * BELOW it was inherited through a constructor seed — resume, fork, or replay
- * — and no writer in this session's lifecycle produced it. Appended as the
- * first live event of every seeded session.
+ * Log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * below it was inherited through a constructor seed (resume, fork, or replay)
+ * and no writer in this lifecycle produced it. Payload is empty — position
+ * and `time` carry the meaning.
*
- * A plugin owning a standalone open/close bracket (`compact/start` …
- * `compact/end`) needs it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below this boundary
- * belongs to an ended lifecycle, so it is dead whether the writer crashed,
- * the process succeeded it, or the events were forked out of a parent that is
- * still running. Read it through `isInheritedSeq`.
- *
- * NOT a liveness signal about other writers: a concurrently live session may
- * hold an open bracket over the same stored history with its own boundary
+ * An owner of a standalone open/close bracket (`compact/start` …
+ * `compact/end`) reads it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below the boundary
+ * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
+ * about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
- *
- * The payload is empty by design — position and `time` carry the meaning.
*/
'session/inherited': Record
}
@@ -535,7 +529,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open.
-It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. `isInheritedSeq(events, seq)` is the predicate a bracket owner calls — true means the opening marker belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent). It classifies only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
+It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top.
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 87ca7dc44a..9cf8c61d3c 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -92,23 +92,17 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * The log-only durable projection of {@link Session.firstLiveSeq}: everything
- * BELOW it was inherited through a constructor seed — resume, fork, or replay
- * — and no writer in this session's lifecycle produced it. Appended as the
- * first live event of every seeded session.
+ * Log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * below it was inherited through a constructor seed (resume, fork, or replay)
+ * and no writer in this lifecycle produced it. Payload is empty — position
+ * and `time` carry the meaning.
*
- * A plugin owning a standalone open/close bracket (`compact/start` …
- * `compact/end`) needs it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below this boundary
- * belongs to an ended lifecycle, so it is dead whether the writer crashed,
- * the process succeeded it, or the events were forked out of a parent that is
- * still running. Read it through `isInheritedSeq`.
- *
- * NOT a liveness signal about other writers: a concurrently live session may
- * hold an open bracket over the same stored history with its own boundary
+ * An owner of a standalone open/close bracket (`compact/start` …
+ * `compact/end`) reads it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below the boundary
+ * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
+ * about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
- *
- * The payload is empty by design — position and `time` carry the meaning.
*/
'session/inherited': Record
}
@@ -539,7 +533,7 @@ interface TurnEndReasonMap {
带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。
-它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。`isInheritedSeq(events, seq)` 就是括号所有方调用的谓词——为真意味着该开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来)。它只判定*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
+它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 5e1c6526ce..3718a5770e 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
## Events
@@ -408,28 +408,22 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
```ts persistence-catalog
/**
- * The log-only durable projection of {@link Session.firstLiveSeq}: everything
- * BELOW it was inherited through a constructor seed — resume, fork, or replay
- * — and no writer in this session's lifecycle produced it. Appended as the
- * first live event of every seeded session.
+ * Log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * below it was inherited through a constructor seed (resume, fork, or replay)
+ * and no writer in this lifecycle produced it. Payload is empty — position
+ * and `time` carry the meaning.
*
- * A plugin owning a standalone open/close bracket (`compact/start` …
- * `compact/end`) needs it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below this boundary
- * belongs to an ended lifecycle, so it is dead whether the writer crashed,
- * the process succeeded it, or the events were forked out of a parent that is
- * still running. Read it through `isInheritedSeq`.
- *
- * NOT a liveness signal about other writers: a concurrently live session may
- * hold an open bracket over the same stored history with its own boundary
+ * An owner of a standalone open/close bracket (`compact/start` …
+ * `compact/end`) reads it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below the boundary
+ * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
+ * about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
- *
- * The payload is empty by design — position and `time` carry the meaning.
*/
'session/inherited': Record
```
-Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 13d30dcd66..ba27261902 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -23,7 +23,7 @@ export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
-export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
+export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index 5d841f9005..d8cf2db3c2 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -11,44 +11,11 @@ import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
- * Whether the event at `seq` was inherited rather than written by the lifecycle
- * that owns `events` — the stored-history reading of `Session.firstLiveSeq`.
- *
- * An owner of a standalone open/close bracket calls this on an unmatched
- * opening marker: `true` means the operation cannot still be running, because
- * the lifecycle that opened it has ended (a crashed writer, a succeeding
- * process, or a parent the events were forked out of). `false` means it belongs
- * to the current lifecycle and must be treated as live.
- *
- * Reads the log rather than a `Session`, so it serves a consumer holding only
- * loaded events; in-process, compare against `session.firstLiveSeq` instead.
- * @param events - the log to scan, contiguous from seq 0.
- * @param seq - the event seq to classify.
- * @returns true when a `session/inherited` boundary sits at or above `seq`.
- */
-export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean {
- // Tail-first: an unmarked log costs no full scan, and bracket queries are
- // usually about recent events.
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const event = events[index]
- /* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */
- if (event === undefined) continue
- if (event.seq < seq) return false
- if (event.type === 'session/inherited') return true
- }
- return false
-}
-
-/**
- * The `time` of the log's last event that represents actual work, skipping the
- * `session/inherited` boundary.
- *
- * Picking a session up is not activity, and lazy resume means browsing writes a
- * boundary, so activity ordering (a resume picker, a session list) must skip it
- * or every opened session sorts as freshly worked in.
+ * The `time` of the log's last event representing actual work, skipping the
+ * `session/inherited` boundary — picking a session up is not activity, so
+ * activity ordering must exclude it.
* @param events - the log to scan, in seq order.
- * @returns the latest non-boundary event's `time`, or undefined when the log has
- * no such event (empty, or nothing but boundaries).
+ * @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/inherited')?.time
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 46b3ad0ed2..149af02468 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -251,23 +251,17 @@ export interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * The log-only durable projection of {@link Session.firstLiveSeq}: everything
- * BELOW it was inherited through a constructor seed — resume, fork, or replay
- * — and no writer in this session's lifecycle produced it. Appended as the
- * first live event of every seeded session.
+ * Log-only durable projection of {@link Session.firstLiveSeq}: everything
+ * below it was inherited through a constructor seed (resume, fork, or replay)
+ * and no writer in this lifecycle produced it. Payload is empty — position
+ * and `time` carry the meaning.
*
- * A plugin owning a standalone open/close bracket (`compact/start` …
- * `compact/end`) needs it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below this boundary
- * belongs to an ended lifecycle, so it is dead whether the writer crashed,
- * the process succeeded it, or the events were forked out of a parent that is
- * still running. Read it through `isInheritedSeq`.
- *
- * NOT a liveness signal about other writers: a concurrently live session may
- * hold an open bracket over the same stored history with its own boundary
+ * An owner of a standalone open/close bracket (`compact/start` …
+ * `compact/end`) reads it because inherited history and live work are
+ * otherwise byte-identical: an unmatched opening marker below the boundary
+ * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
+ * about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
- *
- * The payload is empty by design — position and `time` carry the meaning.
*/
'session/inherited': Record
}
diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts
index b01f73a220..cb2a0d7b50 100644
--- a/packages/core/session/tests/repair.spec.ts
+++ b/packages/core/session/tests/repair.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
-import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
+import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -274,64 +274,6 @@ describe('interruptedTurnClosers', () => {
})
})
-/**
- * The stored-history reading of the inherited boundary. A bracket owner calls
- * this on an unmatched opening marker to decide whether the operation can still
- * be running, so the classification of the marker's own seq — and of the
- * boundary seq itself — is the contract.
- */
-describe('isInheritedSeq', () => {
- const inheritedAt = (seq: number): SessionEvent =>
- ({ type: 'session/inherited', seq, time: seq, data: {} })
-
- it('classifies nothing as inherited in a log without a boundary', () => {
- const events: SessionEvent[] = [
- userTurnStart(1, 0),
- { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- expect(isInheritedSeq(events, 0)).toBe(false)
- expect(isInheritedSeq(events, 1)).toBe(false)
- })
-
- it('treats an empty log as owning nothing', () => {
- expect(isInheritedSeq([], 0)).toBe(false)
- })
-
- it('splits the log at the boundary', () => {
- // seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle.
- const events: SessionEvent[] = [
- userTurnStart(1, 0),
- { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
- inheritedAt(2),
- userTurnStart(2, 3),
- ]
- expect(isInheritedSeq(events, 0)).toBe(true)
- expect(isInheritedSeq(events, 1)).toBe(true)
- // The boundary's own seq counts as inherited: it belongs to the pickup.
- expect(isInheritedSeq(events, 2)).toBe(true)
- expect(isInheritedSeq(events, 3)).toBe(false)
- })
-
- it('reports inherited for an event below a later boundary', () => {
- // Two pickups in turn: the tail scan must not stop at the nearer boundary.
- const events: SessionEvent[] = [
- userTurnStart(1, 0),
- inheritedAt(1),
- userTurnStart(2, 2),
- inheritedAt(3),
- userTurnStart(3, 4),
- ]
- expect(isInheritedSeq(events, 0)).toBe(true)
- expect(isInheritedSeq(events, 2)).toBe(true)
- expect(isInheritedSeq(events, 4)).toBe(false)
- })
-})
-
-/**
- * Activity ordering excludes the pickup boundary. A resume picker or session
- * list sorting by log tail would otherwise promote every session the user
- * merely opened above the ones they actually worked in.
- */
describe('lastActivityTime', () => {
const inheritedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/inherited', seq, time, data: {} })
From 39ebd8f7457851848ade8dbc6b0b181fe0d957a4 Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 13:59:08 +0800
Subject: [PATCH 05/18] fix(session): close the review gaps the boundary opened
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime",
which stopped being true for attached sessions.
- The core invariant let `session/inherited` fall through the merge-extensible
default. It is core-owned, so it gets an explicit case; an unbalanced seed
legally places it inside an open turn, which the relation permits.
- The Agent Note claimed the boundary reaches disk via `live.pending`/
`scheduleDrain`. Verified false: the constructor append precedes `enter()`,
so it never publishes on `session/event` and rides the creation seed instead.
Attaching is therefore a write where none happened before — recorded, since
only `load()` stays a pure read.
- The deferred-index proposal asserted this change documented the cold-mtime
skew on `dsh-host-apiproxy`. It did not; the README entry now exists.
- `firstLiveSeq`'s firehose gap runs through its own seq, not below it.
- The boundary is not always at `firstLiveSeq` (the idempotence guard), so
consumers scan for the last one.
- `lastActivityTime` excludes by type, so a pickup time still leaks onto a
synthetic closer when a boundary ends an open turn. Documented.
- Pin the fork claim end-to-end: a child inherits a still-running parent's
open bracket below its own boundary, while the parent has none. Fails if the
write moves back to the load path.
- Fix the telemetry title that contradicted its own assertions.
The `/status` call site cannot be pinned the way the other two are: the
command appends its own `command/run` before rendering, so the boundary is
never the log tail there. Its fixture now at least renders over a
boundary-bearing log.
---
...0-session-inherited-log-boundary.i18n.yaml | 4 ++--
...26-07-30-session-inherited-log-boundary.md | 6 +++--
...07-30-session-inherited-log-boundary.zh.md | 6 +++--
...7-29-durable-last-activity-index.i18n.yaml | 4 ++--
.../2026-07-29-durable-last-activity-index.md | 2 +-
...26-07-29-durable-last-activity-index.zh.md | 2 +-
docs/cordis-catalog/services.md | 2 +-
docs/core-data-structures/session.i18n.yaml | 4 ++--
docs/core-data-structures/session.md | 8 +++++++
docs/core-data-structures/session.zh.md | 8 +++++++
docs/persistence-catalog.md | 8 +++++--
packages/core/session/src/index.ts | 4 ++++
packages/core/session/src/invariant.ts | 3 +++
packages/core/session/src/repair.ts | 5 ++++
packages/core/session/src/types.ts | 4 ++++
packages/core/session/tests/fork.spec.ts | 23 +++++++++++++++++++
packages/core/session/tests/invariant.spec.ts | 18 +++++++++++++++
packages/host/apiproxy/README.i18n.yaml | 4 ++--
packages/host/apiproxy/README.md | 1 +
packages/host/apiproxy/README.zh.md | 1 +
packages/host/apiproxy/src/api/sessions.ts | 6 ++++-
.../apiproxy/tests/api-proxy-cold.spec.ts | 3 +--
.../session-telemetry/tests/telemetry.spec.ts | 2 +-
.../status-diagnostics-narrow.expected.txt | 2 +-
.../snapshots/status-diagnostics.expected.txt | 2 +-
packages/ui/tui/tests/tui.snapshot.ts | 6 +++++
26 files changed, 115 insertions(+), 23 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
index 77e99f140f..7ead9ed6b4 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
-2026-07-30-session-inherited-log-boundary.md: 3b9cb09d1d1a16d30fd212b439a97e3d9296bd6d
-2026-07-30-session-inherited-log-boundary.zh.md: de048bc994ca97f937f74783b9055fbeb171314a
+2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323
+2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
index 3b9cb09d1d..b5ed959807 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
@@ -24,9 +24,11 @@ Two guards keep the marker from becoming noise. An empty seed writes nothing: a
## Persistence needs no changes
-The marker is in `session.events` before the coordinator captures its creation seed, so it persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. No load-path write, no revision bump at load, no durable mark on a rejected `append`, and a read-only store still serves loads.
+The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.
-Being a live event, it reaches disk through the write-behind drain (`session/event` → `live.pending` → `scheduleDrain`) rather than a synchronous commit, so a crash can lose it. That costs nothing: `pending` drains in order, so a lost boundary means every live event above it is lost too, and the next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
+Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself).
+
+A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
## Scope of the guarantee
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
index de048bc994..83d3ce9ae8 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
@@ -24,9 +24,11 @@ Status: implemented
## 持久化无需任何改动
-协调器捕获创建种子时该标记已在 `session.events` 中,因此它通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。没有加载路径写入、加载时没有 revision 递增、被拒绝的 `append` 不留下持久标记,只读存储依然可以服务加载。
+构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
-作为实时事件,它经由后写式 drain(`session/event` → `live.pending` → `scheduleDrain`)而不是同步提交到达磁盘,因此崩溃可能丢掉它。这没有代价:`pending` 按序 drain,所以丢掉一个边界意味着它上面的每个实时事件也一起丢掉,而下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
+对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。
+
+若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
## 保证的适用范围
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
index 643238c384..fb497a66e5 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
-2026-07-29-durable-last-activity-index.md: 7d8a83e96d9339054b0d15b2331284c8aecfe65d
-2026-07-29-durable-last-activity-index.zh.md: b081aa91cfa51ef8bb87e93f4091431f2d6ddce1
+2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98
+2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
index 7d8a83e96d..2569ce8dcd 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
@@ -12,7 +12,7 @@ mtime answers a different question: when the artifact was last written. Every du
The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
-The boundary change raised the frequency of this defect and documented it as a known limitation on `dsh-host-apiproxy`. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
+The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
## Proposal
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
index b081aa91cf..64078982bb 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
@@ -12,7 +12,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
-边界那次变更提高了这个缺陷的出现频率,并把它作为一项已知限制记录在 `dsh-host-apiproxy` 上。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
+[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
## 提案
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 316c5a9830..1ddfba85ec 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 77d44ee3ac..9ffcfd8772 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52
-session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e
+session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9
+session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 25fb5471e6..03a1a3ee33 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -97,6 +97,10 @@ interface SessionEventMap {
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
+ * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
+ * ending in a boundary is not re-marked, so reopening an untouched session
+ * does not grow its log per pickup.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -346,6 +350,10 @@ declare class Session {
* `session/inherited` event at this seq, which is what a consumer reading
* STORED history reads. Prefer this field in-process — it is exact before
* the marker's write reaches storage.
+ *
+ * The marker is appended before the store attaches, so when one exists the
+ * event AT this seq did not publish either: the firehose gap runs through
+ * `firstLiveSeq`, not just below it.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 9cf8c61d3c..d1c676f1c9 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -97,6 +97,10 @@ interface SessionEventMap {
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
+ * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
+ * ending in a boundary is not re-marked, so reopening an untouched session
+ * does not grow its log per pickup.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -348,6 +352,10 @@ declare class Session {
* `session/inherited` event at this seq, which is what a consumer reading
* STORED history reads. Prefer this field in-process — it is exact before
* the marker's write reaches storage.
+ *
+ * The marker is appended before the store attaches, so when one exists the
+ * event AT this seq did not publish either: the firehose gap runs through
+ * `firstLiveSeq`, not just below it.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 3718a5770e..7498adce79 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
## Events
@@ -413,6 +413,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
+ * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
+ * ending in a boundary is not re-marked, so reopening an untouched session
+ * does not grow its log per pickup.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -423,7 +427,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
'session/inherited': Record
```
-Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index ba27261902..318224ff30 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -394,6 +394,10 @@ export class Session {
* `session/inherited` event at this seq, which is what a consumer reading
* STORED history reads. Prefer this field in-process — it is exact before
* the marker's write reaches storage.
+ *
+ * The marker is appended before the store attaches, so when one exists the
+ * event AT this seq did not publish either: the firehose gap runs through
+ * `firstLiveSeq`, not just below it.
*/
readonly firstLiveSeq: number
diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts
index e57db4fe1a..5a119f3297 100644
--- a/packages/core/session/src/invariant.ts
+++ b/packages/core/session/src/invariant.ts
@@ -144,6 +144,9 @@ function validateEvent(
}
case 'user/message':
break
+ case 'session/inherited':
+ // Unconstrained: an unbalanced seed legally puts it inside an open turn.
+ break
case 'steering/message':
case 'todo/write':
case 'request/header': {
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index d8cf2db3c2..1a76b2d4a8 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -14,6 +14,11 @@ import type { SessionEvent } from './types.ts'
* The `time` of the log's last event representing actual work, skipping the
* `session/inherited` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
+ *
+ * Excluded by type, so a pickup time still leaks when a boundary is the last
+ * event of an open turn: {@link interruptedTurnClosers} copies it onto the
+ * synthetic `turn/end`, which this counts as work. Reachable only by seeding an
+ * unbalanced log directly — `load()` balances first.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 149af02468..35bd493e1f 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -256,6 +256,10 @@ export interface SessionEventMap {
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
+ * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
+ * ending in a boundary is not re-marked, so reopening an untouched session
+ * does not grow its log per pickup.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts
index fcfbd229cf..e32191a36b 100644
--- a/packages/core/session/tests/fork.spec.ts
+++ b/packages/core/session/tests/fork.spec.ts
@@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
+ /** Stands in for a plugin's open/close bracket (`compact/start`). */
+ 'test/bracket-open': { id: string }
}
}
@@ -154,6 +156,27 @@ describe('SessionStore.fork', () => {
}
})
+ it('marks a bracket the child inherited from a still-running parent', async () => {
+ // The constructor placement's central claim, unreachable from the
+ // persistence load path.
+ const { ctx, sessions } = await setup()
+ const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } })
+ appendClosedTurn(parent, 1, 'work')
+ const open = parent.append('test/bracket-open', { id: 'op-1' })
+
+ const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
+
+ // Parent: nothing above the bracket, so its owner must treat it as live.
+ expect(parent.events.at(-1)).toBe(open)
+ expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false)
+ // Child: the same bracket sits below its boundary, so it is dead history.
+ const boundary = child.events.at(-1)
+ expect(boundary).toMatchObject({ type: 'session/inherited' })
+ expect(boundary!.seq).toBeGreaterThan(open.seq)
+ expect(child.firstLiveSeq).toBe(open.seq + 1)
+ expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
+ })
+
it('rejects invalid boundaries before creating a child', async () => {
const { ctx, sessions } = await setup()
const empty = ctx.sessions.create(SessionId('empty'))
diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts
index aa0cd5bd64..281ba50f4c 100644
--- a/packages/core/session/tests/invariant.spec.ts
+++ b/packages/core/session/tests/invariant.spec.ts
@@ -382,6 +382,24 @@ describe('session-log invariants', () => {
.toThrow(/turn 1 is still open/)
})
+ it('accepts the inherited boundary whether or not a turn is open', async () => {
+ const { ctx } = await setup()
+ // Balanced seed: between turns.
+ expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
+ { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+ ] })).not.toThrow()
+ // Unbalanced seed: inside the open turn, which the relation permits.
+ const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
+ ] })
+ expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited'])
+ // Still open afterwards: the boundary moves no cursor.
+ expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
+ .toThrow(/turn 1 is still open/)
+ expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
+ })
+
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index 18202ed036..95438c076e 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 1f0daedc54888a1951bc83c474f83287aaf42307
-README.zh.md: abf5417cdbe93f1199c621ac101249986969da93
+README.md: 3123f64d19a77e0082b8d0ed6f93d279da0eb065
+README.zh.md: 0bc2886aae3c3492d8361acabfec163a10eb4b30
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index 1f0daedc54..3123f64d19 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -42,3 +42,4 @@ None; this package neither assembles nor sends a provider request.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
+- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index abf5417cdb..0bc2886aae 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -42,3 +42,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
+- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts
index 3a824c8261..bdb3598d1d 100644
--- a/packages/host/apiproxy/src/api/sessions.ts
+++ b/packages/host/apiproxy/src/api/sessions.ts
@@ -127,7 +127,11 @@ export interface SessionModels {
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
- /** Persisted file mtime. */
+ /**
+ * Last activity. Attached: the last non-`session/inherited` event, since a
+ * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
+ * with no per-session file (README Known Limitations covers the skew).
+ */
updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean
diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
index d994444df1..2b37bc3cb9 100644
--- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
@@ -86,8 +86,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
- // Old work, resumed just now: the boundary's time is far above the work's,
- // so reading the log tail would report the pickup as activity.
+ // Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
index a429efb789..58228179d5 100644
--- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
+++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
@@ -191,7 +191,7 @@ describe('TelemetryCoordinator adoption', () => {
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
})
- it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
+ it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => {
const backend = new FakeBackend()
const ctx = new Context()
await ctx.plugin(SessionStore)
diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
index 58cc124b75..b35a53ed0d 100644
--- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
+++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
@@ -48,7 +48,7 @@ buffer
17| "│ │"
style 0-0 dim
style 55-55 dim
-18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
+18| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
index b5f776918f..70ec6a3a04 100644
--- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
+++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
@@ -45,7 +45,7 @@ buffer
16| "│ │"
style 0-0 dim
style 81-81 dim
-17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │"
+17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts
index bb870ec004..912febae70 100644
--- a/packages/ui/tui/tests/tui.snapshot.ts
+++ b/packages/ui/tui/tests/tui.snapshot.ts
@@ -909,6 +909,12 @@ describe('TUI terminal-state snapshots', () => {
messageSeqs: [1],
source: { kind: 'fallback' },
})
+ // Renders over a boundary-bearing log. It cannot pin the exclusion:
+ // `/status` appends its own `command/run` first, so the boundary is
+ // never the tail here. The other two call sites pin it.
+ dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z'))
+ session.append('session/inherited', {})
+ dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z'))
},
}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
From 3e57bd3d0beb0392c800c08d13f0350ecf58aef6 Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 14:21:04 +0800
Subject: [PATCH 06/18] chore(session): re-record the pairing and catalog
fixture after the master merge
---
.../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +-
packages/host/apiproxy/README.i18n.yaml | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index ea8dad9a96..972b9d7e67 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index 4664ba28df..d48fe81928 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99
-README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905
+README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728
+README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2
From b257ed5e8a8e8b64d2bdadef9a8e426aac35e6fa Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 14:46:38 +0800
Subject: [PATCH 07/18] docs(session): make the boundary's position and
ownership conditional
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Documentation only; no behavior change.
`firstLiveSeq`'s JSDoc still stated the boundary sits at that seq
unconditionally. Verified reachable on a plain production path: pick up a log,
do no work, pick it up again — the seed already ends in a boundary, so it is
not re-marked and `events[firstLiveSeq]` is undefined. Both that claim and the
firehose-gap sentence are now conditional, with the locate rule ("scan for the
last boundary") matching what types.ts already said.
`repair.ts`'s header claimed this module supplies the boundary. It does not —
the constructor does; this module supplies the activity read that must skip it.
Reworded, and it now names the closer timestamp leak, which is the one real
coupling that justifies `lastActivityTime` living beside the repair synthesis.
Recorded that `Session`'s constructor is the boundary's only legitimate writer,
since the invariant companion constrains nothing and a plugin-side append would
silently turn live brackets below it into dead history.
---
docs/cordis-catalog/services.md | 2 +-
docs/core-data-structures/session.i18n.yaml | 4 ++--
docs/core-data-structures/session.md | 25 ++++++++++++++-------
docs/core-data-structures/session.zh.md | 25 ++++++++++++++-------
docs/persistence-catalog.md | 8 +++++--
packages/core/session/src/index.ts | 17 ++++++++------
packages/core/session/src/repair.ts | 6 +++--
packages/core/session/src/types.ts | 4 ++++
8 files changed, 61 insertions(+), 30 deletions(-)
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 2df12320f2..8970d7ae1c 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 9ffcfd8772..95b2754409 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9
-session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52
+session.md: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb
+session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 03a1a3ee33..fd9dcf6c6c 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -101,6 +101,10 @@ interface SessionEventMap {
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
+ * `Session`'s constructor is the only legitimate writer. The invariant
+ * companion deliberately constrains nothing here, so a plugin appending one
+ * would silently turn every live bracket below it into dead history.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -346,14 +350,17 @@ declare class Session {
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact.
*
- * Not persisted itself: a nonzero value is projected into the log as the
- * `session/inherited` event at this seq, which is what a consumer reading
- * STORED history reads. Prefer this field in-process — it is exact before
- * the marker's write reaches storage.
+ * Not persisted itself: a seeded session projects it into the log as the
+ * `session/inherited` event, which is what a consumer reading STORED history
+ * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * seed already ending in one is not re-marked, so reopening an untouched
+ * session leaves the boundary below `firstLiveSeq`. Prefer this field
+ * in-process: it is exact before the marker's write reaches storage.
*
- * The marker is appended before the store attaches, so when one exists the
- * event AT this seq did not publish either: the firehose gap runs through
- * `firstLiveSeq`, not just below it.
+ * When this lifecycle did append a boundary it sits at this seq, appended
+ * before the store attached, so that event did not publish either — the
+ * firehose gap then runs through `firstLiveSeq` rather than stopping below
+ * it. Otherwise this seq holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -535,7 +542,9 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
## The inherited-history boundary: `session/inherited`
-A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open.
+A seeded session — resume, fork, or replay — appends this log-only event as its first live write. It is the durable projection of `firstLiveSeq`: that field answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
+
+An empty seed writes nothing, and a seed already ending in a boundary is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the boundary as the log's LAST one rather than at `firstLiveSeq`: after a pickup with no work, the next one leaves it below that seq.
It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index d1c676f1c9..23202e2b4c 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -101,6 +101,10 @@ interface SessionEventMap {
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
+ * `Session`'s constructor is the only legitimate writer. The invariant
+ * companion deliberately constrains nothing here, so a plugin appending one
+ * would silently turn every live bracket below it into dead history.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -348,14 +352,17 @@ declare class Session {
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact.
*
- * Not persisted itself: a nonzero value is projected into the log as the
- * `session/inherited` event at this seq, which is what a consumer reading
- * STORED history reads. Prefer this field in-process — it is exact before
- * the marker's write reaches storage.
+ * Not persisted itself: a seeded session projects it into the log as the
+ * `session/inherited` event, which is what a consumer reading STORED history
+ * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * seed already ending in one is not re-marked, so reopening an untouched
+ * session leaves the boundary below `firstLiveSeq`. Prefer this field
+ * in-process: it is exact before the marker's write reaches storage.
*
- * The marker is appended before the store attaches, so when one exists the
- * event AT this seq did not publish either: the firehose gap runs through
- * `firstLiveSeq`, not just below it.
+ * When this lifecycle did append a boundary it sits at this seq, appended
+ * before the store attached, so that event did not publish either — the
+ * firehose gap then runs through `firstLiveSeq` rather than stopping below
+ * it. Otherwise this seq holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -539,7 +546,9 @@ interface TurnEndReasonMap {
## 继承历史边界:`session/inherited`
-带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。
+带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
+
+空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是读 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下。
它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 7498adce79..6acc650789 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:346`](../packages/core/session/src/types.ts)
## Events
@@ -417,6 +417,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
+ * `Session`'s constructor is the only legitimate writer. The invariant
+ * companion deliberately constrains nothing here, so a plugin appending one
+ * would silently turn every live bracket below it into dead history.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -427,7 +431,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
'session/inherited': Record
```
-Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 318224ff30..e4786e7222 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -390,14 +390,17 @@ export class Session {
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact.
*
- * Not persisted itself: a nonzero value is projected into the log as the
- * `session/inherited` event at this seq, which is what a consumer reading
- * STORED history reads. Prefer this field in-process — it is exact before
- * the marker's write reaches storage.
+ * Not persisted itself: a seeded session projects it into the log as the
+ * `session/inherited` event, which is what a consumer reading STORED history
+ * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * seed already ending in one is not re-marked, so reopening an untouched
+ * session leaves the boundary below `firstLiveSeq`. Prefer this field
+ * in-process: it is exact before the marker's write reaches storage.
*
- * The marker is appended before the store attaches, so when one exists the
- * event AT this seq did not publish either: the firehose gap runs through
- * `firstLiveSeq`, not just below it.
+ * When this lifecycle did append a boundary it sits at this seq, appended
+ * before the store attached, so that event did not publish either — the
+ * firehose gap then runs through `firstLiveSeq` rather than stopping below
+ * it. Otherwise this seq holds an ordinary published write.
*/
readonly firstLiveSeq: number
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index 1a76b2d4a8..a4e82dabfb 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -1,8 +1,10 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
- * needed to resume with a provider-valid transcript, plus the inherited-history
- * boundary a plugin-owned bracket reads to tell dead history from live work.
+ * needed to resume with a provider-valid transcript, plus the activity-time
+ * read that must skip the inherited-history boundary — which this module does
+ * not write (`Session`'s constructor does) but whose synthetic closers can
+ * inherit that boundary's timestamp, the one real coupling between the two.
* @module @deepseek-ai/dsh-session/repair
*/
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 35bd493e1f..aca980ccfe 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -260,6 +260,10 @@ export interface SessionEventMap {
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
+ * `Session`'s constructor is the only legitimate writer. The invariant
+ * companion deliberately constrains nothing here, so a plugin appending one
+ * would silently turn every live bracket below it into dead history.
+ *
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
From ecd07abdc02b1dcec87aac6fb1c4f1b948f1aefb Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:07:31 +0800
Subject: [PATCH 08/18] test(web): refresh question-composer goldens for the
merged composer
The master-side refresh batch recorded these goldens against master's
composer (customTrigger row, header pager); the merge keeps this
branch's redesigned composer (always-visible custom input, footer
pager) with copy on the standard locale seat, so the waiting aria and
the answered transcript re-record against the merged UI. The answered
golden also picks up the ask_user_question toolview row this branch
introduces.
---
.../question-composer/answered.expected.md | 2 +-
.../question-composer/ui.expected.md | 22 +++++++------------
2 files changed, 9 insertions(+), 15 deletions(-)
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 5ba842114e..ac9f26298e 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -18,7 +18,7 @@
- button:
- img
- img
-- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
+- text: Ask question 1/1 answered
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md
index a1c9dfac83..894f84d9ba 100644
--- a/apps/web/tests/snapshots/question-composer/ui.expected.md
+++ b/apps/web/tests/snapshots/question-composer/ui.expected.md
@@ -1,23 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- - text: 1 / 1
- - button "Previous question" [disabled]:
- - img
- - button "Next question" [disabled]:
- - img
- button "Dismiss all questions":
- img
- radiogroup:
- - radio "Blue":
- - text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- - img
- - radio "Green":
- - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- - img
- - button "Other — enter a custom answer":
- - img
- - text: Other — enter a custom answer
+ - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
+ - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
+ - textbox "Type your answer"
+ - button "Previous question" [disabled]:
+ - img
+ - text: 1 / 1
+ - button "Next question" [disabled]:
+ - img
- status
- button "Skip this question"
- button "Submit" [disabled]
From 6ea1238714cc6f952edf2b93390f54360bed3d61 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 21:06:27 +0800
Subject: [PATCH 09/18] docs(web): the composer bar is one session-maybe slot,
not a swapped pair
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix(web): keep one composer bar DOM across the no-workspace transition
The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.
test(web): add the composer DOM-continuity acceptance probe
Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.
docs(web): session-maybe identity is adoption, not hold-forever
fix(web): session-maybe entries adopt the first session, then remount like strict entries
A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.
chore: knip knows the root acceptance probe's playwright dependency
scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
---
...ession-scope-and-provide-channel.i18n.yaml | 4 +-
...lient-session-scope-and-provide-channel.md | 4 +-
...nt-session-scope-and-provide-channel.zh.md | 4 +-
...input-machine-and-slash-pipeline.i18n.yaml | 4 +-
...25-web-input-machine-and-slash-pipeline.md | 2 +-
...web-input-machine-and-slash-pipeline.zh.md | 2 +-
knip.json | 3 +
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
.../ui-conversation/src/client/apply.ts | 30 ++++++-
.../src/client/contract/slots.ts | 39 +++++++---
.../src/client/skeleton/ConversationRoot.tsx | 38 ++++-----
.../src/client/skeleton/DisabledInputBar.tsx | 40 ----------
.../src/client/skeleton/InputBar.tsx | 54 ++++++++-----
.../tests/apply-inject.spec.tsx | 15 +++-
.../client/web-react/src/scoped-slots.tsx | 60 +++++++++++++-
.../client/web-react/src/session-provider.tsx | 7 +-
.../web-react/tests/scoped-slots.spec.tsx | 69 +++++++++++++++-
scripts/hero-composer-dom-continuity.mjs | 78 +++++++++++++++++++
20 files changed, 346 insertions(+), 115 deletions(-)
delete mode 100644 packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
create mode 100644 scripts/hero-composer-dom-continuity.mjs
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
index 4783b79a6a..6b8c6de32d 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151
+2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1f88dd2065eaba7282ae3ed9e82d6872fdfb8497
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
index 4496e3786e..d19b256b83 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
@@ -90,10 +90,10 @@ The sole provisioning path by which session slot components fetch their own sess
Slot scope is the closed set `root | session-maybe | session`:
- `root` receives only the global standard kit, with no session identity or provisioning.
-- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
+- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
-`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.
+`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
index 768dada95a..1f88dd2065 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
@@ -90,10 +90,10 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
slot scope 是闭集 `root | session-maybe | session`:
- `root` 只拿全局标准件,不接收 session 身份或供数。
-- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
+- `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
-`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。
+`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
index 7abc19ba70..98423a8c7d 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: f446f42c9e202afcb404c7a551a4f715228bb8e5
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 8f5e449bb878811b70bc5bc29e4a09bbc1a33bfa
+2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
index f446f42c9e..92bb91c3e8 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
@@ -70,7 +70,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation.
- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame.
-- With no session the shell renders the presentation-only `DisabledInputBar`; once `connectWorkspace` returns a blank session, only the input body is swapped for the strict-session InputBar. The textarea may be rebuilt here, while `ConversationRoot`, the Hero, and the layout skeleton hold; blank → engaging/active stays the same session-bound InputBar, with the textarea never rebuilt on a phase flip.
+- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt {mode:'queue'|'steer'}`; backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
- When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
index 8f5e449bb8..5bee6df52f 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
@@ -70,7 +70,7 @@ occurrence 表与 chip 三投影:
- hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。
- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。
-- 无 session 时外壳渲染纯展示的 `DisabledInputBar`;`connectWorkspace` 返回 blank session 后,仅输入体换成严格 session 的 InputBar。这里允许 textarea 重建,`ConversationRoot`、Hero 与布局骨架保持;blank → engaging/active 仍是同一 session-bound InputBar,textarea 不因 phase 翻转而重建。
+- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
diff --git a/knip.json b/knip.json
index e5b7110957..6585e1f628 100644
--- a/knip.json
+++ b/knip.json
@@ -25,6 +25,9 @@
"project": [
"scripts/**/*.ts",
"scripts/**/*.mjs"
+ ],
+ "ignoreDependencies": [
+ "playwright"
]
},
"examples": {
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 654722b589..9158acd4ca 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
-README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
+README.md: 06fd6b963639390164582e70476b6a80f189bc61
+README.zh.md: 41047d563f5541aaddc87bc7b448f678538ef91a
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 3973c14f2b..06fd6b9636 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -20,7 +20,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
-The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
+The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index a6bb15c4cd..41047d563f 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
-输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
+输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染(machine face 缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index d261b26a3e..c6b597ae79 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -10,6 +10,7 @@ import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
+import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
@@ -31,6 +32,19 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
+// Static no-session sources for the composer-bar hooks compartment: module
+// constants so the render side's per-source hook cache (observableHook) keeps
+// one identity across every no-session render.
+const ABSENT_NOTICES = {
+ getSnapshot: (): InputNotice | null => null,
+ subscribe: () => () => {},
+}
+const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
+const ABSENT_LEXICON = {
+ getSnapshot: () => EMPTY_LEXICON,
+ subscribe: () => () => {},
+}
+
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@@ -121,7 +135,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
- 'conversation.composer.bar': { kind: 'single', scope: 'session' },
+ 'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
@@ -166,6 +180,9 @@ export function apply(ctx: Context): void {
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
+ // Session-maybe: with no current session the machine faces are absent and
+ // the hooks compartment binds static empty sources (module constants, so
+ // observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan beside the
@@ -175,7 +192,16 @@ export function apply(ctx: Context): void {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
- inject: (sessionId: SessionId): ComposerBarInjected => {
+ inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
+ if (sessionId === undefined) {
+ return {
+ keyboard: undefined,
+ stop: undefined,
+ command: undefined,
+ translateHint,
+ hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
+ }
+ }
const shell = inputHub.shell(sessionId)
return {
keyboard: shell,
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 38de7675a7..1684e616b2 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -77,11 +77,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The default composer body: a single slot rendered as the composer
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
* takeover election hides rather than unmounts it and the textarea DOM
- * survives). InputBar registers here from this package's apply; its
- * machine state arrives through the standard provide channel (useInput +
- * inputActions), the keyboard command face through its own inject.
+ * survives). Session-maybe: the bar stays mounted across the
+ * no-session/session transition — the no-workspace hero renders the SAME
+ * textarea DOM disabled instead of a parallel inert tree — with the
+ * machine hooks absent until a session is current. InputBar registers
+ * here from this package's apply; its machine state arrives through the
+ * standard provide channel (useInput + inputActions), the keyboard
+ * command face through its own inject.
*/
- 'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
+ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
@@ -244,6 +248,12 @@ export interface ConversationSessionInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
+ /**
+ * Inert no-workspace state: the bar renders its normal DOM fully disabled
+ * (textarea, add, send) so the workspace pick transitions in place instead
+ * of swapping component trees.
+ */
+ disabled?: boolean
placeholder?: string
/** Optional content rendered above the textarea. */
accessory?: ReactNode
@@ -261,19 +271,24 @@ export interface ComposerBarOwnerProps {
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
- /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
- keyboard: ComposerKeyboard
- /** Cancel the in-flight turn. */
- stop: () => void
+ /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
+ keyboard: ComposerKeyboard | undefined
+ /** Cancel the in-flight turn; absent with the session. */
+ stop: (() => void) | undefined
/**
* Submit one slash-command line against this session's agent (the chrome
- * controls' write path — the permission chip submits `/permission `).
+ * controls' write path — the permission chip submits `/permission `);
+ * absent with the session.
* Resolves admission: false = rejected/unmatched/transport failure.
*/
- command: (line: string) => Promise
- /** Locale-aware hint translator for claimed command placeholders. */
+ command: ((line: string) => Promise) | undefined
+ /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
translateHint: (key: string) => string
- /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
+ /**
+ * Registrant hooks compartment: the renderer binds these to
+ * useNotices/useLexicon (static absent sources without a session — hook
+ * order stays constant).
+ */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index bec3bb1fde..994351e8e6 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -1,13 +1,12 @@
-// Resident conversation skeleton. Hero chrome, composer positioning, and the
-// chain stay mounted across no-session/session transitions. Only the inert
-// input body swaps for the strict session InputBar.
+// Resident conversation skeleton. Hero chrome, composer positioning, the
+// chain, AND the composer bar (session-maybe slot) stay mounted across
+// no-session/session transitions — the bar renders inert via owner props.
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
-import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props composed from the slot contract. */
@@ -113,20 +112,23 @@ export function ConversationRoot({
)
// The placeholder chip ("Choose workspace") and the inert input travel
- // together: a blank session whose workspace vanished (deleted from the
- // sidebar) reverts to the same disabled bar as the initial no-session state.
- const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
- ?
- : renderSlot('conversation.composer.bar', {
- variant: hero ? 'hero' : 'composer',
- ...(hero ? { placeholder: 'Describe what you want to build' } : {}),
- overlay: renderSlot('conversation.input.overlay', {}),
- leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
- rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
- // Stats band under the card, inside the bar's width column so both
- // share one constraint (composer.dock = stats-line family).
- footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
- })
+ // together: no workspace picked yet (cold start, no session at all), or a
+ // blank session whose workspace vanished (deleted from the sidebar). The
+ // bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
+ // not a different tree, so the textarea DOM survives the transition.
+ const inert = sessionId === undefined || (hero && chipTitle === undefined)
+ const inputBar = renderSlot('conversation.composer.bar', {
+ variant: hero ? 'hero' : 'composer',
+ ...(inert
+ ? { disabled: true, placeholder: 'Choose a workspace to start' }
+ : hero ? { placeholder: 'Describe what you want to build' } : {}),
+ overlay: renderSlot('conversation.input.overlay', {}),
+ leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
+ rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
+ // Stats band under the card, inside the bar's width column so both
+ // share one constraint (composer.dock = stats-line family).
+ footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
+ })
const composerBar = (
diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
deleted file mode 100644
index bfaa0b8d02..0000000000
--- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-/** Inert no-session input body; the resident Hero shell renders around it. */
-
-import clsx from 'clsx'
-import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import css from './InputBar.module.css'
-
-/** Disabled visual twin of the session-bound InputBar. */
-export function DisabledInputBar() {
- return (
-
-
-
-
-
{'\n'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index 0f922b5930..77747e0ae2 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -17,9 +17,13 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
+import type { DraftDecorations } from '../input/decorations.ts'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
+/** Decoration product of the no-session state (no machine, empty draft). */
+const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
+
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -29,15 +33,16 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
- useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
- variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
+ useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
+ useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
+ onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
- const promptError = useSession(s => s.promptError)
- const running = useSession(s => s.running)
- const disabled = useSession(s => s.removed)
+ const promptError = useSession(s => s.promptError) ?? null
+ const running = useSession(s => s.running) ?? false
+ const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
@@ -49,7 +54,10 @@ export function InputBar({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
- const draft = input.draft
+ // Session-maybe: the machine faces are absent together while no session is
+ // current; the bar renders the same DOM inert instead of a parallel tree.
+ const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
+ const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -68,16 +76,18 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
- // Queue cut 1: running input stays free; locked = session disabled only.
- // The transient machine locks (adjudicating pending / submitting) render
+ // Queue cut 1: running input stays free; locked = session removed, the
+ // inert no-workspace state, or the machine faces absent (no session). The
+ // transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
+ const disabled = removed || inert || !live
const locked = disabled
- const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
+ const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
- }, [locked])
+ }, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
@@ -101,6 +111,9 @@ export function InputBar({
}, [])
const onKeyDown = (e: KeyboardEvent): void => {
+ // Absent machine (no session): the textarea is disabled so events cannot
+ // fire; the guard narrows the faces for the paths below.
+ if (keyboard === undefined || inputActions === undefined) return
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
@@ -162,6 +175,7 @@ export function InputBar({
}
const onChange = (e: ChangeEvent): void => {
+ if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
@@ -187,6 +201,7 @@ export function InputBar({
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => {
+ if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
@@ -211,6 +226,7 @@ export function InputBar({
}
const onPaste = (e: React.ClipboardEvent): void => {
+ if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
@@ -230,7 +246,7 @@ export function InputBar({
const onSelect = (e: React.SyntheticEvent): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
- if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
+ if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
@@ -242,6 +258,7 @@ export function InputBar({
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
+ if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
stop()
return
@@ -251,16 +268,17 @@ export function InputBar({
}
// The Access seat: the projection-fed permission chip (renders nothing
- // while the permissions key is absent — permission-less host or Draft).
- const accessSelect: ReactNode = (
-
- )
+ // while the permissions key is absent — permission-less host or Draft —
+ // or while the command face is absent with the session).
+ const accessSelect: ReactNode = command === undefined
+ ? null
+ :
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
- const deco = deriveDecorations(input, lexicon)
+ const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -322,7 +340,7 @@ export function InputBar({
pushPlain(draft.length)
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
- const commandName = input.claim?.token.slice(1).trim() ?? ''
+ const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
@@ -356,7 +374,7 @@ export function InputBar({
value={draft}
disabled={locked}
readOnly={machineBusy}
- data-phase={input.phase}
+ data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
index e73f959341..87ec0dfde7 100644
--- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
@@ -163,7 +163,7 @@ describe('conversation slot inject surface', () => {
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
- b.composerSurface(ROOT).stop()
+ b.composerSurface(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
@@ -172,12 +172,19 @@ describe('conversation slot inject surface', () => {
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation.composer.bar')
- const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
+ const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
// Unknown session: the keyboard face's binding resolution answers nothing.
- expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
+ expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
+ // No session (session-maybe absent side): machine faces absent, static
+ // hooks compartment still present so the render side's hook order holds.
+ const absent = injectFn(undefined)
+ expect(absent.keyboard).toBeUndefined()
+ expect(absent.stop).toBeUndefined()
+ expect(absent.hooks.notices.getSnapshot()).toBeNull()
+ expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
- const stop = injectFn(ROOT).stop
+ const stop = injectFn(ROOT).stop!
await b.feature.dispose()
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
await b.runtime.dispose()
diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx
index 950dfd4a1f..9c67da18d2 100644
--- a/packages/client/web-react/src/scoped-slots.tsx
+++ b/packages/client/web-react/src/scoped-slots.tsx
@@ -2,7 +2,7 @@
* React renderer for declarative slots. Per-entry bindings enforce child
* authorization, and entry boundaries contain registrant failures.
*/
-import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
+import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
@@ -366,15 +366,69 @@ function SessionEntry({ entry, ownerProps, info }: {
return
}
-function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
+function SessionMaybeEntryBody({ entry, ownerProps, info }: {
+ entry: StoredEntry
+ ownerProps: object
+ info: SessionMaybeProvideInfo
+}) {
const host = useHost()
- const info = useSessionMaybeProvideInfo()
const Comp = entry.component as FC
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
const injected = cachedSessionMaybeInject(entry, info, actions)
return
}
+/**
+ * Session-maybe identity: adoption — the ONLY behavior (there is no
+ * hold-identity-forever mode). An incarnation born session-less ADOPTS the
+ * first session that arrives: identity holds across that one transition
+ * (undefined → first id), so a blank shell's DOM survives the moment a
+ * session appears. From then on the entry behaves exactly like a strict
+ * session entry: switching to a DIFFERENT session remounts (component-local
+ * state must not leak between sessions), and dropping back to no-session
+ * remounts into a fresh blank incarnation, which will adopt again.
+ * Component-local per-session state therefore clears by construction; state
+ * that must SURVIVE a switch belongs in session-bound sources (machine,
+ * store, hooks) — the existing layering rule, now load-bearing.
+ */
+function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
+ const info = useSessionMaybeProvideInfo()
+ // The child key is an incarnation counter, NOT the session id: adoption
+ // must keep the key constant across undefined → first id. Bookkeeping
+ // lives in this stable (unkeyed) wrapper via the render-phase setState
+ // form (React's sanctioned derived-state pattern: setState during render
+ // of the same component re-renders once before children mount, and the
+ // guard conditions make it convergent — StrictMode-safe).
+ const [state, setState] = useState(FIRST_INCARNATION)
+ let { adopted, epoch } = state
+ if (info.sessionId !== undefined && adopted === undefined) {
+ // Adoption: same epoch — no remount.
+ adopted = info.sessionId
+ setState({ adopted, epoch })
+ } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
+ // Post-adoption session switch: next incarnation, born already adopted.
+ adopted = info.sessionId
+ epoch += 1
+ setState({ adopted, epoch })
+ } else if (adopted !== undefined && info.sessionId === undefined) {
+ // Back to no-session: next incarnation, born blank (adopts anew later).
+ adopted = undefined
+ epoch += 1
+ setState({ adopted, epoch })
+ }
+ return
+}
+
+/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
+interface MaybeIncarnation {
+ /** Session this incarnation adopted; undefined while born blank and unadopted. */
+ readonly adopted: string | undefined
+ /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
+ readonly epoch: number
+}
+
+const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
+
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC
diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx
index 152bc793cb..865e4fc081 100644
--- a/packages/client/web-react/src/session-provider.tsx
+++ b/packages/client/web-react/src/session-provider.tsx
@@ -117,9 +117,10 @@ const projectionHookCache = new WeakMap unknown>()
/**
- * Root-level binding provider. It follows current selection without a key, so
- * session-maybe entries retain their React identity while the context value
- * moves between absent and definite session bundles.
+ * Root-level binding provider. It follows current selection without a key;
+ * per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
+ * a blank-born incarnation adopts the first session without remounting, and
+ * every later transition (switch or loss) remounts like a strict entry.
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx
index 971a6ad060..16334ff7c7 100644
--- a/packages/client/web-react/tests/scoped-slots.spec.tsx
+++ b/packages/client/web-react/tests/scoped-slots.spec.tsx
@@ -10,7 +10,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
-import { useEffect, type ReactNode } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -863,3 +863,70 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(props['shared']).toBe('owner') // owner overrides inject
})
})
+
+describe('session-maybe adoption identity', () => {
+ const SINGLE_MAYBE: DeclaredSpec = { kind: 'single', scope: 'session-maybe' }
+
+ /** Mount a maybe entry that records its mount count and local state. */
+ function mountMaybeCounter(h: Fake) {
+ let mounts = 0
+ const seen: { sessionId: string | undefined; mount: number }[] = []
+ h.declare('k.maybe', SINGLE_MAYBE)
+ h.add('k.maybe', {
+ component: ({ sessionId }: { sessionId?: string }) => {
+ // Local mount marker: useState initializer runs once per incarnation.
+ const [mount] = useState(() => ++mounts)
+ seen.push({ sessionId, mount })
+ return {`${sessionId ?? 'blank'}#${mount}`}
+ },
+ })
+ const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot => renderSlot('k.maybe', {}))
+ return { view, seen }
+ }
+
+ it('adopts the first session: blank → first id keeps the incarnation (no remount)', () => {
+ const h = makeHost()
+ h.addSession('s1')
+ const { view } = mountMaybeCounter(h)
+ expect(view.container.textContent).toBe('blank#1')
+ act(() => { h.current.set('s1') })
+ // Same incarnation (#1): the blank shell adopted s1.
+ expect(view.container.textContent).toBe('s1#1')
+ })
+
+ it('remounts on a post-adoption session switch (local state must not leak across sessions)', () => {
+ const h = makeHost()
+ h.addSession('s1')
+ h.addSession('s2')
+ const { view } = mountMaybeCounter(h)
+ act(() => { h.current.set('s1') })
+ expect(view.container.textContent).toBe('s1#1')
+ act(() => { h.current.set('s2') })
+ // New incarnation (#2): strict-session behavior after adoption.
+ expect(view.container.textContent).toBe('s2#2')
+ })
+
+ it('remounts into a fresh blank incarnation on session loss, then adopts anew', () => {
+ const h = makeHost()
+ h.addSession('s1')
+ h.addSession('s2')
+ const { view } = mountMaybeCounter(h)
+ act(() => { h.current.set('s1') })
+ expect(view.container.textContent).toBe('s1#1')
+ act(() => { h.current.set(undefined) })
+ // The adopted incarnation dies with its session; blank state is fresh.
+ expect(view.container.textContent).toBe('blank#2')
+ act(() => { h.current.set('s2') })
+ // The fresh blank adopts again — still incarnation #2, no flash.
+ expect(view.container.textContent).toBe('s2#2')
+ })
+
+ it('keeps the incarnation across a no-op republish of the same session', () => {
+ const h = makeHost()
+ h.addSession('s1')
+ const { view } = mountMaybeCounter(h)
+ act(() => { h.current.set('s1') })
+ act(() => { h.current.set('s1') })
+ expect(view.container.textContent).toBe('s1#1')
+ })
+})
diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs
new file mode 100644
index 0000000000..ef39052b1b
--- /dev/null
+++ b/scripts/hero-composer-dom-continuity.mjs
@@ -0,0 +1,78 @@
+// Regression drive for the unified hero composer (0729-0357-hero-unify):
+// cold start with zero workspaces -> create a workspace -> type. Asserts the
+// composer textarea is the SAME DOM node across the disabled->live flip (a
+// remount drops the __heroMark marker property) — the session-maybe
+// composer.bar contract.
+//
+// Prereqs: `pnpm run build`, then a fresh server against empty state:
+// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \
+// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \
+// --workspace-root $(mktemp -d)
+// Run: node scripts/hero-composer-dom-continuity.mjs
+// (BASE_URL overrides the target; screenshots land in .artifacts/.)
+import { createRequire } from 'node:module'
+
+// playwright is a devDependency of apps/web only — resolve through its tree.
+const require = createRequire(new URL('../apps/web/package.json', import.meta.url))
+const { chromium } = require('playwright')
+
+const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285'
+const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
+page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) })
+page.on('pageerror', err => { console.log('[pageerror]', err.message) })
+
+await page.goto(BASE)
+await page.waitForSelector('textarea', { timeout: 20000 })
+await page.screenshot({ path: SHOTS + '01-cold-start.png' })
+
+const initial = await page.evaluate(() => {
+ const boxes = [...document.querySelectorAll('textarea')]
+ boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i })
+ return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder }))
+})
+console.log('cold-start textareas:', JSON.stringify(initial))
+
+// Open the picker and create a workspace by name (typed-input flow). The name
+// must be unique per registry; keystrokes go through pressSequentially so the
+// dialog's React onChange enables the submit button.
+await page.getByRole('button', { name: 'Choose workspace' }).click()
+await page.getByText('Create a new workspace').click()
+await page.screenshot({ path: SHOTS + '03-create-form.png' })
+const nameBox = page.getByPlaceholder('Workspace name')
+await nameBox.click()
+const wsName = 'proj-' + Date.now().toString(36)
+await nameBox.pressSequentially(wsName, { delay: 30 })
+await page.locator('button:text-is("Create workspace")').click()
+
+// Wait for the composer to go live (placeholder flips, textarea enabled).
+await page.waitForFunction(() => {
+ const box = document.querySelector('textarea')
+ return box !== null && !box.disabled
+}, { timeout: 20000 })
+await page.screenshot({ path: SHOTS + '04-live.png' })
+
+const after = await page.evaluate(() => {
+ const boxes = [...document.querySelectorAll('textarea')]
+ return boxes.map(b => ({
+ mark: b.__heroMark ?? 'REMOUNTED',
+ disabled: b.disabled,
+ placeholder: b.placeholder,
+ }))
+})
+console.log('post-pick textareas:', JSON.stringify(after))
+
+// Type into the live composer.
+await page.locator('textarea').first().fill('hello from acceptance run')
+const typed = await page.evaluate(() => document.querySelector('textarea')?.value)
+console.log('typed value:', JSON.stringify(typed))
+await page.screenshot({ path: SHOTS + '05-typed.png' })
+
+const survived = after.length === 1 && after[0].mark === 'alive-0'
+console.log(survived
+ ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)'
+ : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after))
+await browser.close()
+process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1)
From 4c33ca8becae4272e79bd8a085a131927d5e9163 Mon Sep 17 00:00:00 2001
From: imccyu
Date: Thu, 30 Jul 2026 15:25:50 +0800
Subject: [PATCH 10/18] fix: hero layout when no-workspace
---
.../ui-conversation/src/client/skeleton/ConversationRoot.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index 994351e8e6..3f9635bb48 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -178,7 +178,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
- {sessionId === undefined ? composerSeat : null}
+ {sessionId === undefined ? wrapActiveBody(null) : null}
)
}
From 245aeb414ca73932dd56643fbae7ccac7bb9e853 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:00:12 +0800
Subject: [PATCH 11/18] feat(web): workspace header hover card with cwd and
creation time
---
.../src/client/rows/Rows.module.css | 7 ++++
.../ui-workspace/src/client/rows/Rows.tsx | 35 ++++++++++++++++---
.../client/ui-workspace/src/client/tree.ts | 13 +++++--
.../client/ui-workspace/tests/rows.spec.tsx | 6 ++--
4 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css
index 7b19284b66..5a5cac914e 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.module.css
+++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css
@@ -192,6 +192,13 @@
overflow-wrap: break-word;
}
+.hoverPath {
+ font-size: 12px;
+ line-height: 16px;
+ color: #CFD3D6;
+ word-break: break-all;
+}
+
.hoverTime {
font-size: 12px;
line-height: 16px;
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index d75fabdd8b..a507ac2991 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -2,8 +2,8 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
- * except workspace Rename/Delete and session Rename; the session hover card is
- * suppressed while a menu is open.
+ * except workspace Rename/Delete and session Rename; the session and workspace
+ * hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -30,10 +30,26 @@ const WORKSPACE_MENU_ITEMS = [
{ id: 'delete', label: 'Delete workspace', icon: , danger: true },
]
+/** Hover-card body: workspace title, full directory path, absolute creation time. */
+function WorkspaceHoverContent({ label, cwd, createdAt }: {
+ label: string
+ cwd: string | undefined
+ createdAt: number
+}) {
+ return (
+
+ )
+}
+
/**
* Project (workspace) header row: 54px, folder + title + session count;
- * hover reveals the chevron and create button. `containsCurrent` arrives on
- * the node (derivation fact, no renderer scan).
+ * hover reveals the chevron and create button, and dwelling on a real
+ * Workspace shows its hover card (the ungrouped bucket has none).
+ * `containsCurrent` arrives on the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
@@ -50,7 +66,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const [menuOpen, setMenuOpen] = useState(false)
- return (
+ const ownRow = (
)
+ // The ungrouped bucket has no backing Workspace: no card to show.
+ if (row.createdAt === undefined) return ownRow
+ return (
+ }
+ disabled={menuOpen}
+ />
+ )
}
/**
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index c0adfadd6f..de47454d84 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -31,6 +31,8 @@ export interface GroupNode {
/** Backing Workspace id; absent only for the ungrouped bucket. */
workspaceId: WorkspaceId | undefined
cwd: string | undefined
+ /** Workspace creation time (epoch ms); absent only for the ungrouped bucket. */
+ createdAt: number | undefined
label: string
/** Total visible sessions in the group. */
sessionCount: number
@@ -52,6 +54,7 @@ interface Group {
key: string
workspaceId: WorkspaceId | undefined
cwd: string | undefined
+ createdAt: number | undefined
label: string
summaries: Map
roots: SessionId[]
@@ -91,6 +94,7 @@ function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
cwd: string | undefined,
+ createdAt: number | undefined,
label: string,
members: readonly SessionSummary[],
order: 'account' | 'recency',
@@ -142,7 +146,7 @@ function buildGroup(
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
- return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
+ return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
}
/**
@@ -163,7 +167,8 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
members.push(summary)
}
groups.push(buildGroup(
- workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
+ workspace.workspaceId, workspace.workspaceId, workspace.path,
+ Date.parse(workspace.createdAt), workspace.title, members, 'account',
))
}
const stray = list.ids
@@ -171,7 +176,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
if (stray.length > 0) {
- groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
+ groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
@@ -267,6 +272,7 @@ export function deriveGroups(
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
+ createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded,
@@ -280,6 +286,7 @@ export function deriveGroups(
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
+ createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,
diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx
index bfaa8a36dd..31f1d0ceec 100644
--- a/packages/client/ui-workspace/tests/rows.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.spec.tsx
@@ -42,7 +42,7 @@ describe('workspace browser rows', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
+ key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
}
render()
@@ -103,7 +103,7 @@ describe('workspace browser rows', () => {
const onDelete = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
+ key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render( {
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
- key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
+ key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render()
From 8527137230564fb78c2a3f0eb6c2f728ac92e23f Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 15:32:06 +0800
Subject: [PATCH 12/18] refactor(session): rename the seed boundary to end-seed
---
...0-session-end-seed-log-boundary.i18n.yaml} | 6 +-
...26-07-30-session-end-seed-log-boundary.md} | 18 ++---
...07-30-session-end-seed-log-boundary.zh.md} | 18 ++---
...7-29-durable-last-activity-index.i18n.yaml | 4 +-
.../2026-07-29-durable-last-activity-index.md | 10 +--
...26-07-29-durable-last-activity-index.zh.md | 10 +--
...-29-session-resumed-log-boundary.i18n.yaml | 4 +-
...2026-07-29-session-resumed-log-boundary.md | 4 +-
...6-07-29-session-resumed-log-boundary.zh.md | 4 +-
docs/core-data-structures/session.i18n.yaml | 4 +-
docs/core-data-structures/session.md | 67 ++++++++++---------
docs/core-data-structures/session.zh.md | 67 ++++++++++---------
docs/persistence-catalog.md | 35 +++++-----
.../cordis-inspect-jsdoc/session.jsonl | 2 +-
.../snapshots/subagent-fork/session.1.jsonl | 10 +--
.../snapshots/subagent-mixed/session.2.jsonl | 10 +--
.../session.expected.jsonl | 2 +-
.../parent-override/parent.expected.jsonl | 2 +-
.../cordis/tool-cordis/src/api-catalog.ts | 2 +-
packages/core/agent-loop/tests/loop.spec.ts | 2 +-
packages/core/agent-loop/tests/resume.spec.ts | 6 +-
packages/core/session/src/index.ts | 34 +++++-----
packages/core/session/src/invariant.ts | 2 +-
packages/core/session/src/repair.ts | 6 +-
packages/core/session/src/types.ts | 29 ++++----
packages/core/session/tests/fork.spec.ts | 12 ++--
packages/core/session/tests/invariant.spec.ts | 4 +-
.../core/session/tests/properties.spec.ts | 2 +-
packages/core/session/tests/repair.spec.ts | 12 ++--
packages/core/session/tests/session.spec.ts | 2 +-
packages/host/apiproxy/README.i18n.yaml | 4 +-
packages/host/apiproxy/README.md | 2 +-
packages/host/apiproxy/README.zh.md | 2 +-
packages/host/apiproxy/src/api-proxy.ts | 2 +-
packages/host/apiproxy/src/api/sessions.ts | 2 +-
.../apiproxy/tests/api-proxy-cold.spec.ts | 6 +-
.../llm/token-meter/tests/token-meter.spec.ts | 4 +-
.../tests/jsonl.spec.ts | 4 +-
.../tests/coordinator-contract.ts | 18 ++---
.../session-query-sqlite/tests/sqlite.spec.ts | 2 +-
.../tests/service-contracts.spec.ts | 4 +-
.../tests/inheritance.spec.ts | 2 +-
.../session-telemetry/tests/telemetry.spec.ts | 6 +-
packages/ui/tui/tests/tui.snapshot.ts | 4 +-
44 files changed, 228 insertions(+), 224 deletions(-)
rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.i18n.yaml => 2026-07-30-session-end-seed-log-boundary.i18n.yaml} (55%)
rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.md => 2026-07-30-session-end-seed-log-boundary.md} (70%)
rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.zh.md => 2026-07-30-session-end-seed-log-boundary.zh.md} (73%)
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
similarity index 55%
rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
index 7ead9ed6b4..d0d8d7b82f 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
-2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323
-2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
+2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54
+2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
similarity index 70%
rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
index b5ed959807..837531ba0b 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
@@ -1,8 +1,8 @@
-# Agent Note: the inherited-history log boundary
+# Agent Note: the end-seed log boundary
Status: implemented
-English | [中文](2026-07-30-session-inherited-log-boundary.zh.md)
+English | [中文](2026-07-30-session-end-seed-log-boundary.zh.md)
## Problem
@@ -14,21 +14,21 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth
## Decision
-`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
+`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
-A bracket owner reads it positionally: an unmatched opening marker below the boundary belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
+A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
-Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
+Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
## Persistence needs no changes
-The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.
+The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events before it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.
Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself).
-A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
+A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event after it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
## Scope of the guarantee
@@ -38,7 +38,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.
-**A boundary appended at loop start.** The loop is one call above `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
+**A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.
@@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat
Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property.
-`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited.
+`session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history.
Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one.
diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md
similarity index 73%
rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md
index 83d3ce9ae8..33680c1845 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md
@@ -1,8 +1,8 @@
-# Agent Note: 继承历史日志边界
+# Agent Note: 种子结束日志边界
Status: implemented
-[English](2026-07-30-session-inherited-log-boundary.md) | 中文
+[English](2026-07-30-session-end-seed-log-boundary.md) | 中文
## Problem
@@ -14,21 +14,21 @@ Status: implemented
## Decision
-`Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
+`Session` 的构造函数紧接非空构造种子之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
-括号所有方按位置读取它:位于边界之下的未配对开启标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
+括号所有方按位置读取它:在 `session/end-seed` 之前的未配对开启标记具有更小的 seq,来自构造种子,并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
-两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
+两条守卫让这个标记不至于变成噪声。空种子不写入任何内容,因为没有种子需要结束。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
## 持久化无需任何改动
-构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
+构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它之前的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。
-若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
+若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它之后的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
## 保证的适用范围
@@ -38,7 +38,7 @@ Status: implemented
**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
-**在 loop 启动时追加边界。** loop 位于 `resumeWith` 上一层,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
+**在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
@@ -50,6 +50,6 @@ Status: implemented
代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。
-`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。
+`session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。
此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
index fb497a66e5..a8dde62381 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
-2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98
-2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f
+2026-07-29-durable-last-activity-index.md: 0938e7ea2aa817818ed552fd8b702795840b547a
+2026-07-29-durable-last-activity-index.zh.md: 647bce817444c77ac3cd6f51385472fff745212f
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
index 2569ce8dcd..0938e7ea2a 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
@@ -8,11 +8,11 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md)
A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
-mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/inherited` boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
+mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
-The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
+The [boundary change](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
## Proposal
@@ -25,7 +25,7 @@ The two shipped backends have opposite constraints, and the proposal is delibera
Three questions must be answered before implementation, and none of them is settled here:
-**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/inherited`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
+**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/end-seed`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
@@ -37,7 +37,7 @@ Three questions must be answered before implementation, and none of them is sett
**Keep mtime and exclude boundary writes from it.** Rejected as impossible rather than undesirable: mtime is the filesystem's, not the backend's. Nothing short of restoring the timestamp after every boundary write would preserve it, and that races any concurrent reader and lies about the artifact.
-**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
+**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition.
@@ -61,6 +61,6 @@ Three questions must be answered before implementation, and none of them is sett
## Related
-- [The inherited-history log boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
+- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
index 64078982bb..647bce8174 100644
--- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
@@ -8,11 +8,11 @@ Status: proposed
一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。
-mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/inherited` 边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。
+mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。
已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
-[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
+[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
## 提案
@@ -25,7 +25,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
实现之前必须回答三个问题,本文对它们都没有定论:
-**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/inherited` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
+**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
@@ -37,7 +37,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
**保留 mtime,但把边界的写入排除在它之外。** 否决的理由是做不到,而不是不合意:mtime 属于文件系统,不属于后端。除了在每次边界写入之后把时间戳复原,没有别的办法能保住它,而那样做会与任何并发读取方产生竞态,也会对这份产物撒谎。
-**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
+**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。
@@ -61,6 +61,6 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
## 相关
-- [继承历史日志边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
+- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
index bcfbebf293..ec95910194 100644
--- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
-2026-07-29-session-resumed-log-boundary.md: 6dbe44cbd8354369e8ed56d8cc838e9771dbaa52
-2026-07-29-session-resumed-log-boundary.zh.md: f035773b896131ca87a70b58b19cfafb280601e4
+2026-07-29-session-resumed-log-boundary.md: 877b0c780f4c92983d2762243fac4e26d945887a
+2026-07-29-session-resumed-log-boundary.zh.md: a6f6ecc0d3f3a349f1a438eec0a84f27f8f649b2
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
index 6dbe44cbd8..877b0c780f 100644
--- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
@@ -1,6 +1,6 @@
# Agent Note: Record the resume process boundary in the session log
-Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the inherited-history boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)
+Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the end-seed boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)
English | [中文](2026-07-29-session-resumed-log-boundary.zh.md)
@@ -14,7 +14,7 @@ The pressure to fix this is immediate: moving `compact/start` to its real time p
## Proposal
-`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event below the boundary was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one.
+`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event before the boundary has a smaller seq and was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one.
The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live.
diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
index f035773b89..a6f6ecc0d3 100644
--- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
+++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
@@ -1,6 +1,6 @@
# Agent Note: 在会话日志中记录恢复的进程边界
-Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[继承历史边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)取代
+Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代
[English](2026-07-29-session-resumed-log-boundary.md) | 中文
@@ -14,7 +14,7 @@ Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,
## Proposal
-`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之下的每个事件都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。
+`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之前的每个事件都有更小的 seq,并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。
括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 95b2754409..7c99825d0a 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb
-session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679
+session.md: 769d5db301e3e81664c732ab1685c859a00cceb2
+session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index fd9dcf6c6c..769d5db301 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -92,27 +92,28 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * Log-only durable projection of {@link Session.firstLiveSeq}: everything
- * below it was inherited through a constructor seed (resume, fork, or replay)
- * and no writer in this lifecycle produced it. Payload is empty — position
- * and `time` carry the meaning.
+ * Marks the end of a constructor seed. Events before it have smaller seq
+ * values and came from the seed (resume, fork, or replay); this lifecycle
+ * produced none of them. This log-only event is the durable projection of
+ * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
+ * carry the meaning.
*
- * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
- * ending in a boundary is not re-marked, so reopening an untouched session
- * does not grow its log per pickup.
+ * Locate the LAST one in stored history. A seed already ending in one is not
+ * re-marked, so reopening an untouched session does not grow its log per
+ * pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
- * would silently turn every live bracket below it into dead history.
+ * would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
- * `compact/end`) reads it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below the boundary
- * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
- * about other writers — a concurrently live session holds its own boundary
- * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ * `compact/end`) reads it because seed history and live work are otherwise
+ * byte-identical: an unmatched opening marker before this event belongs to
+ * an ended lifecycle, whatever ended it. NOT a liveness signal about other
+ * writers — a concurrently live session holds its own boundary elsewhere,
+ * so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/inherited': Record
+ 'session/end-seed': Record
}
```
@@ -342,25 +343,25 @@ declare class Session {
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
- * seed (0 without one). Events below it entered through construction —
- * replay, fork, or resume — and were never published on the `session/event`
- * firehose (constructor seeds do not emit), so consumers that replay the
- * log as a publication substitute (telemetry adoption) start here. Distinct
- * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
- * session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact.
+ * seed (0 without one). Events with smaller seq values entered through
+ * construction — replay, fork, or resume — and were never published on the
+ * `session/event` firehose (constructor seeds do not emit), so consumers
+ * that replay the log as a publication substitute (telemetry adoption)
+ * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
+ * boundary: a resumed session's constructor seed is its full stored log,
+ * while its header keeps the original fork value — this field is the
+ * in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
- * `session/inherited` event, which is what a consumer reading STORED history
- * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * `session/end-seed` event, which is what a consumer reading STORED history
+ * reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
- * session leaves the boundary below `firstLiveSeq`. Prefer this field
- * in-process: it is exact before the marker's write reaches storage.
+ * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
+ * this field in-process: it is exact before the marker reaches storage.
*
- * When this lifecycle did append a boundary it sits at this seq, appended
- * before the store attached, so that event did not publish either — the
- * firehose gap then runs through `firstLiveSeq` rather than stopping below
- * it. Otherwise this seq holds an ordinary published write.
+ * When this lifecycle appends the marker, it occupies this seq before the
+ * store attaches and therefore does not publish either. Otherwise this seq
+ * holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -540,13 +541,13 @@ A turn encloses one model-loop execution, not the whole session log. Idle inject
The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md).
-## The inherited-history boundary: `session/inherited`
+## The end-seed boundary: `session/end-seed`
-A seeded session — resume, fork, or replay — appends this log-only event as its first live write. It is the durable projection of `firstLiveSeq`: that field answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
+A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
-An empty seed writes nothing, and a seed already ending in a boundary is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the boundary as the log's LAST one rather than at `firstLiveSeq`: after a pickup with no work, the next one leaves it below that seq.
+An empty seed writes nothing, and a seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
-It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
+It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top.
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 23202e2b4c..7af459949e 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -92,27 +92,28 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * Log-only durable projection of {@link Session.firstLiveSeq}: everything
- * below it was inherited through a constructor seed (resume, fork, or replay)
- * and no writer in this lifecycle produced it. Payload is empty — position
- * and `time` carry the meaning.
+ * Marks the end of a constructor seed. Events before it have smaller seq
+ * values and came from the seed (resume, fork, or replay); this lifecycle
+ * produced none of them. This log-only event is the durable projection of
+ * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
+ * carry the meaning.
*
- * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
- * ending in a boundary is not re-marked, so reopening an untouched session
- * does not grow its log per pickup.
+ * Locate the LAST one in stored history. A seed already ending in one is not
+ * re-marked, so reopening an untouched session does not grow its log per
+ * pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
- * would silently turn every live bracket below it into dead history.
+ * would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
- * `compact/end`) reads it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below the boundary
- * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
- * about other writers — a concurrently live session holds its own boundary
- * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ * `compact/end`) reads it because seed history and live work are otherwise
+ * byte-identical: an unmatched opening marker before this event belongs to
+ * an ended lifecycle, whatever ended it. NOT a liveness signal about other
+ * writers — a concurrently live session holds its own boundary elsewhere,
+ * so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/inherited': Record
+ 'session/end-seed': Record
}
```
@@ -344,25 +345,25 @@ declare class Session {
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
- * seed (0 without one). Events below it entered through construction —
- * replay, fork, or resume — and were never published on the `session/event`
- * firehose (constructor seeds do not emit), so consumers that replay the
- * log as a publication substitute (telemetry adoption) start here. Distinct
- * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
- * session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact.
+ * seed (0 without one). Events with smaller seq values entered through
+ * construction — replay, fork, or resume — and were never published on the
+ * `session/event` firehose (constructor seeds do not emit), so consumers
+ * that replay the log as a publication substitute (telemetry adoption)
+ * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
+ * boundary: a resumed session's constructor seed is its full stored log,
+ * while its header keeps the original fork value — this field is the
+ * in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
- * `session/inherited` event, which is what a consumer reading STORED history
- * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * `session/end-seed` event, which is what a consumer reading STORED history
+ * reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
- * session leaves the boundary below `firstLiveSeq`. Prefer this field
- * in-process: it is exact before the marker's write reaches storage.
+ * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
+ * this field in-process: it is exact before the marker reaches storage.
*
- * When this lifecycle did append a boundary it sits at this seq, appended
- * before the store attached, so that event did not publish either — the
- * firehose gap then runs through `firstLiveSeq` rather than stopping below
- * it. Otherwise this seq holds an ordinary published write.
+ * When this lifecycle appends the marker, it occupies this seq before the
+ * store attaches and therefore does not publish either. Otherwise this seq
+ * holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -544,13 +545,13 @@ interface TurnEndReasonMap {
可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。
-## 继承历史边界:`session/inherited`
+## 种子结束边界:`session/end-seed`
-带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
+带种子的会话(恢复、fork 或回放)紧接构造种子之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
-空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是读 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下。
+空种子不写入任何内容;种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
-它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
+它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 6acc650789..524ba6ffd7 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:346`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts)
## Events
@@ -404,34 +404,35 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
### `session/*`
-#### `session/inherited` — log-only
+#### `session/end-seed` — log-only
```ts persistence-catalog
/**
- * Log-only durable projection of {@link Session.firstLiveSeq}: everything
- * below it was inherited through a constructor seed (resume, fork, or replay)
- * and no writer in this lifecycle produced it. Payload is empty — position
- * and `time` carry the meaning.
+ * Marks the end of a constructor seed. Events before it have smaller seq
+ * values and came from the seed (resume, fork, or replay); this lifecycle
+ * produced none of them. This log-only event is the durable projection of
+ * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
+ * carry the meaning.
*
- * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
- * ending in a boundary is not re-marked, so reopening an untouched session
- * does not grow its log per pickup.
+ * Locate the LAST one in stored history. A seed already ending in one is not
+ * re-marked, so reopening an untouched session does not grow its log per
+ * pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
- * would silently turn every live bracket below it into dead history.
+ * would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
- * `compact/end`) reads it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below the boundary
- * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
- * about other writers — a concurrently live session holds its own boundary
- * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ * `compact/end`) reads it because seed history and live work are otherwise
+ * byte-identical: an unmatched opening marker before this event belongs to
+ * an ended lifecycle, whatever ended it. NOT a liveness signal about other
+ * writers — a concurrently live session holds its own boundary elsewhere,
+ * so tolerating concurrent writers needs a signal beyond the log.
*/
-'session/inherited': Record
+'session/end-seed': Record
```
-Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index 972b9d7e67..c47cb8c89f 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
index 2dbb507f36..9ce1073349 100644
--- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"4fbb7fab-f45a-4e3c-95e0-454a638cf4b3"},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ecede90b-f918-4b3c-81cc-aefcc375d269"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,12 +12,12 @@
{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}}
{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4dd423ef-963c-409e-8238-551c4b10273b"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
+{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"d6c3a4bf-20e0-459f-9bc9-945f6650b5f1"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
{"type":"step/end","seq":36,"time":1783352135773,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":37,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"session/inherited","seq":38,"time":1785381572223,"data":{}}
+{"type":"session/end-seed","seq":38,"time":1785396256785,"data":{}}
{"type":"turn/start","seq":39,"time":1785381572224,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8cfd615d-f500-4de4-b751-d19e27d23016"},"surfaceOp":"append"}
+{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6f050d06-7445-4651-9958-345b6410f3d7"},"surfaceOp":"append"}
{"type":"step/start","seq":41,"time":1785381572240,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":42,"time":1785381572241,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
{"type":"assistant/chunk","seq":43,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -28,6 +28,6 @@
{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}}
{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}}
{"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3dc1dc0d-40e0-4cd1-a111-1bcf40a1001e"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"}
+{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db43685f-dd37-4558-926d-7a758305a84d"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"}
{"type":"step/end","seq":88,"time":1785381572250,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":89,"time":1785381572251,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
index 9277f70139..b7af05fb61 100644
--- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5c2a4880-9a66-4e38-b8d2-a2f891ff283f"},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"9f3b1367-3a0e-4793-9ecf-ae67a79f24d2"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,12 +12,12 @@
{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"132af698-2789-4aaa-aca0-cffd1c58720a"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"59fa3190-4060-40db-a0a5-97f2fa4172f3"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"step/end","seq":30,"time":1783352143771,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":31,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"session/inherited","seq":32,"time":1785381573525,"data":{}}
+{"type":"session/end-seed","seq":32,"time":1785396258235,"data":{}}
{"type":"turn/start","seq":33,"time":1785381573526,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f85229b8-dcf8-4d8c-9d61-8ef879ed5974"},"surfaceOp":"append"}
+{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9f252dc0-3b24-4607-b761-30711b726edb"},"surfaceOp":"append"}
{"type":"step/start","seq":35,"time":1785381573543,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":36,"time":1785381573543,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
{"type":"assistant/chunk","seq":37,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -28,6 +28,6 @@
{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}}
{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5d0e4a32-f5ee-4992-8223-51cdb14d1db3"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"}
+{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a39affbc-097b-4106-912a-99538d18eff8"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"}
{"type":"step/end","seq":78,"time":1785381573553,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":79,"time":1785381573553,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
index ea07941fd5..85e60621e0 100644
--- a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
+++ b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
@@ -7,7 +7,7 @@
{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}}
-{"type":"session/inherited","seq":8,"time":0,"data":{}}
+{"type":"session/end-seed","seq":8,"time":0,"data":{}}
{"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
index 7123ff0eb4..796f41aee8 100644
--- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
+++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}}
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
-{"type":"session/inherited","seq":4,"time":0,"data":{}}
+{"type":"session/end-seed","seq":4,"time":0,"data":{}}
{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 57092fd842..d7cdc4e253 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -2177,7 +2177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
- declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record;\n}',
+ declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/end-seed\': Record;\n}',
},
{
name: 'SessionEventMetadataFilter',
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index 159d17342c..20046f766e 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -1224,6 +1224,6 @@ describe('agent loop', () => {
// event-by-event identity of types over the inherited prefix
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
agent.session.events.map(e => e.type))
- expect(replayed.events.at(-1)?.type).toBe('session/inherited')
+ expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
})
})
diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts
index 969c7b9f5e..7844aebd73 100644
--- a/packages/core/agent-loop/tests/resume.spec.ts
+++ b/packages/core/agent-loop/tests/resume.spec.ts
@@ -283,7 +283,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(sessionId)
- // The two persisted events plus the inherited-history boundary.
+ // The two persisted events plus the end-seed marker.
expect(agentCtx.agent?.session.events).toHaveLength(3)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
@@ -586,10 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
- // …below one boundary marking all of it inherited.
+ // …followed by one end-seed event marking the constructor seed.
expect(a2.session.events.length).toBe(events1.length + 1)
expect(a2.session.firstLiveSeq).toBe(events1.length)
- expect(a2.session.events.at(-1)?.type).toBe('session/inherited')
+ expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
const replay = new Session(SessionId('replay'), events1)
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index e4786e7222..a403e11df1 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -382,25 +382,25 @@ export class Session {
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
- * seed (0 without one). Events below it entered through construction —
- * replay, fork, or resume — and were never published on the `session/event`
- * firehose (constructor seeds do not emit), so consumers that replay the
- * log as a publication substitute (telemetry adoption) start here. Distinct
- * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
- * session's constructor seed is its full stored log, while its header keeps
- * the original fork value — this field is the in-process construction fact.
+ * seed (0 without one). Events with smaller seq values entered through
+ * construction — replay, fork, or resume — and were never published on the
+ * `session/event` firehose (constructor seeds do not emit), so consumers
+ * that replay the log as a publication substitute (telemetry adoption)
+ * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
+ * boundary: a resumed session's constructor seed is its full stored log,
+ * while its header keeps the original fork value — this field is the
+ * in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
- * `session/inherited` event, which is what a consumer reading STORED history
- * reads. Locate that event as the log's LAST boundary, not at this seq — a
+ * `session/end-seed` event, which is what a consumer reading STORED history
+ * reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
- * session leaves the boundary below `firstLiveSeq`. Prefer this field
- * in-process: it is exact before the marker's write reaches storage.
+ * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
+ * this field in-process: it is exact before the marker reaches storage.
*
- * When this lifecycle did append a boundary it sits at this seq, appended
- * before the store attached, so that event did not publish either — the
- * firehose gap then runs through `firstLiveSeq` rather than stopping below
- * it. Otherwise this seq holds an ordinary published write.
+ * When this lifecycle appends the marker, it occupies this seq before the
+ * store attaches and therefore does not publish either. Otherwise this seq
+ * holds an ordinary published write.
*/
readonly firstLiveSeq: number
@@ -442,8 +442,8 @@ export class Session {
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
- if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') {
- this.append('session/inherited', {})
+ if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
+ this.append('session/end-seed', {})
}
}
diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts
index 5a119f3297..c454ad6533 100644
--- a/packages/core/session/src/invariant.ts
+++ b/packages/core/session/src/invariant.ts
@@ -144,7 +144,7 @@ function validateEvent(
}
case 'user/message':
break
- case 'session/inherited':
+ case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'steering/message':
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index a4e82dabfb..1114156c2e 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -2,7 +2,7 @@
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript, plus the activity-time
- * read that must skip the inherited-history boundary — which this module does
+ * read that must skip the end-seed boundary — which this module does
* not write (`Session`'s constructor does) but whose synthetic closers can
* inherit that boundary's timestamp, the one real coupling between the two.
* @module @deepseek-ai/dsh-session/repair
@@ -14,7 +14,7 @@ import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
- * `session/inherited` boundary — picking a session up is not activity, so
+ * `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
@@ -25,7 +25,7 @@ import type { SessionEvent } from './types.ts'
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
- return events.findLast(event => event.type !== 'session/inherited')?.time
+ return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index aca980ccfe..ebebb95c7c 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -251,27 +251,28 @@ export interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
- * Log-only durable projection of {@link Session.firstLiveSeq}: everything
- * below it was inherited through a constructor seed (resume, fork, or replay)
- * and no writer in this lifecycle produced it. Payload is empty — position
- * and `time` carry the meaning.
+ * Marks the end of a constructor seed. Events before it have smaller seq
+ * values and came from the seed (resume, fork, or replay); this lifecycle
+ * produced none of them. This log-only event is the durable projection of
+ * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
+ * carry the meaning.
*
- * Locate the LAST one rather than reading `firstLiveSeq`: a seed already
- * ending in a boundary is not re-marked, so reopening an untouched session
- * does not grow its log per pickup.
+ * Locate the LAST one in stored history. A seed already ending in one is not
+ * re-marked, so reopening an untouched session does not grow its log per
+ * pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
- * would silently turn every live bracket below it into dead history.
+ * would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
- * `compact/end`) reads it because inherited history and live work are
- * otherwise byte-identical: an unmatched opening marker below the boundary
- * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
- * about other writers — a concurrently live session holds its own boundary
- * elsewhere, so tolerating concurrent writers needs a signal beyond the log.
+ * `compact/end`) reads it because seed history and live work are otherwise
+ * byte-identical: an unmatched opening marker before this event belongs to
+ * an ended lifecycle, whatever ended it. NOT a liveness signal about other
+ * writers — a concurrently live session holds its own boundary elsewhere,
+ * so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/inherited': Record
+ 'session/end-seed': Record
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts
index e32191a36b..b04d08e9ab 100644
--- a/packages/core/session/tests/fork.spec.ts
+++ b/packages/core/session/tests/fork.spec.ts
@@ -52,11 +52,11 @@ function lastSeq(session: Session): number {
return event.seq
}
-/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
+/** A seeded child's constructor seed: its log minus the end-seed marker. */
function inherited(session: Session): readonly SessionEvent[] {
const events = session.events
const last = events.at(-1)
- if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary')
+ if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
return events.slice(0, -1)
}
@@ -166,12 +166,12 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
- // Parent: nothing above the bracket, so its owner must treat it as live.
+ // Parent: no end-seed event follows the bracket, so its owner treats it as live.
expect(parent.events.at(-1)).toBe(open)
- expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false)
- // Child: the same bracket sits below its boundary, so it is dead history.
+ expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
+ // Child: the same bracket is before end-seed, so it belongs to the seed.
const boundary = child.events.at(-1)
- expect(boundary).toMatchObject({ type: 'session/inherited' })
+ expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
expect(child.firstLiveSeq).toBe(open.seq + 1)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts
index 281ba50f4c..5e075f1749 100644
--- a/packages/core/session/tests/invariant.spec.ts
+++ b/packages/core/session/tests/invariant.spec.ts
@@ -382,7 +382,7 @@ describe('session-log invariants', () => {
.toThrow(/turn 1 is still open/)
})
- it('accepts the inherited boundary whether or not a turn is open', async () => {
+ it('accepts end-seed whether or not a turn is open', async () => {
const { ctx } = await setup()
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
@@ -393,7 +393,7 @@ describe('session-log invariants', () => {
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
] })
- expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited'])
+ expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts
index c4372fe377..398bd69d35 100644
--- a/packages/core/session/tests/properties.spec.ts
+++ b/packages/core/session/tests/properties.spec.ts
@@ -118,7 +118,7 @@ describe('Session properties', () => {
}))
})
- it('replaying an already-inherited log adds no further boundary', () => {
+ it('replaying a log that already ends in end-seed adds no further marker', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts
index cb2a0d7b50..0e51e52c55 100644
--- a/packages/core/session/tests/repair.spec.ts
+++ b/packages/core/session/tests/repair.spec.ts
@@ -275,8 +275,8 @@ describe('interruptedTurnClosers', () => {
})
describe('lastActivityTime', () => {
- const inheritedAt = (seq: number, time: number): SessionEvent =>
- ({ type: 'session/inherited', seq, time, data: {} })
+ const endSeedAt = (seq: number, time: number): SessionEvent =>
+ ({ type: 'session/end-seed', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
@@ -294,16 +294,16 @@ describe('lastActivityTime', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
- inheritedAt(2, 9_000),
+ endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
- it('reports work done above a boundary', () => {
+ it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
- inheritedAt(1, 9_000),
+ endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
@@ -311,6 +311,6 @@ describe('lastActivityTime', () => {
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
- expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined()
+ expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index 4c551bede0..7b7e2fae28 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -188,7 +188,7 @@ describe('Session', () => {
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
- // The seed verbatim, plus the boundary the constructor appends over it.
+ // The seed verbatim, plus the end-seed event the constructor appends.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq + 1)
expect(replayed.firstLiveSeq).toBe(original.seq)
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index d48fe81928..f37d2831a0 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728
-README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2
+README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
+README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index c745dd6fb1..bf5d0d7623 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -44,4 +44,4 @@ None; this package neither assembles nor sends a provider request.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
-- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
+- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index 7ddd7a37f7..06c2e37d25 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -44,4 +44,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
-- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
+- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index f8e66c33e5..89495175f7 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -165,7 +165,7 @@ function sessionBlank(session: Session): boolean {
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
- // Excludes the inherited-history boundary: a resumed-but-untouched session
+ // Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts
index 4730605f97..50838779c9 100644
--- a/packages/host/apiproxy/src/api/sessions.ts
+++ b/packages/host/apiproxy/src/api/sessions.ts
@@ -134,7 +134,7 @@ export type QueueAction =
export interface SessionSummary {
sessionId: SessionId
/**
- * Last activity. Attached: the last non-`session/inherited` event, since a
+ * Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
* with no per-session file (README Known Limitations covers the skew).
*/
diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
index 2b37bc3cb9..b9d5018975 100644
--- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
@@ -78,7 +78,7 @@ describe('sessions.list cold merge', () => {
})
})
-describe('attached updatedAt excludes the inherited-history boundary', () => {
+describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -97,7 +97,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
})
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
const boundary = resumed.events.at(-1)
- expect(boundary?.type).toBe('session/inherited')
+ expect(boundary?.type).toBe('session/end-seed')
expect(boundary?.time).toBeGreaterThan(worked)
const listed = await api.sessions.list(request({}))
@@ -105,7 +105,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
- // Real work above the boundary does move it.
+ // Real work appended after end-seed does move it.
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts
index 3d63ad19db..1a4a4a5680 100644
--- a/packages/llm/token-meter/tests/token-meter.spec.ts
+++ b/packages/llm/token-meter/tests/token-meter.spec.ts
@@ -678,8 +678,8 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
- // Seed, constructor boundary, then the append above. Only the last
- // published: the boundary predates store attachment, like the seed.
+ // Seed, end-seed, then one live append. Only the last event published:
+ // end-seed predates store attachment, like the seed.
expect(revisions).toEqual([3])
expect(activeMeter.measure(session).logRevision).toBe(3)
diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
index 15e1ccc319..4eeeabc67b 100644
--- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
+++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
@@ -378,9 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await ctx.sessions.flush(child)
const loaded = await ctx.sessionPersistence.load(child.id)
- // The inherited prefix reaches disk verbatim, then the child's boundary.
+ // The constructor seed reaches disk verbatim, then the child's end-seed.
expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length })
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length })
expect(loaded.meta).toMatchObject({
id: SessionId('persist-child'),
cwd: '/workspace',
diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
index e34a37745b..d8ee35f4b7 100644
--- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
+++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts
@@ -218,9 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(live)
const loaded = await ctx.sessionPersistence.load(id)
- // The seeded constructor's boundary persisted between the stored
+ // The constructor's end-seed event persisted between the stored
// turn/start and the turn/end appended live.
- expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end'])
+ expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -482,7 +482,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Fork is where the marker earns its keep: the inherited prefix may
// carry a bracket the still-running parent owns.
expect(loaded.events.slice(0, seed.length)).toEqual(seed)
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length })
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length })
// A flush with no NEW events must not double-write.
await ctx.sessions.flush(forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
@@ -515,9 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
- // 0-5 the resumed seed, 6 the boundary, 7-8 the new turn.
+ // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
- expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' })
+ expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' })
} finally {
await second.fiber.dispose()
await fix.cleanup()
@@ -798,9 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
- // Seeded 0-5 plus the constructor's boundary at 6.
+ // Seeded 0-5 plus the constructor's end-seed event at 6.
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -853,9 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}, { inject: ['sessions'] }))
await ctx.sessions.flush(cont)
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
- // 6-7 the claimed suffix; 8 the boundary over the whole seed.
+ // 6-7 the claimed suffix; 8 end-seed after the whole seed.
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
+ expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
expect(loaded.meta).toEqual(durableMeta)
expect(loaded.meta.createdAt).toBe(1000)
diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
index cab9271614..ac8f7f91a8 100644
--- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
@@ -1077,7 +1077,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
db.exec('PRAGMA query_only = OFF')
- // seq 2: one-event seed, its boundary, then the message appended above.
+ // seq 2: one-event seed, end-seed, then the live message.
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.resolves.toMatchObject({ items: [{ seq: 2 }] })
})
diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts
index 254f76ef21..1b426ac94c 100644
--- a/packages/session-title/session-title/tests/service-contracts.spec.ts
+++ b/packages/session-title/session-title/tests/service-contracts.spec.ts
@@ -185,8 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
'turn/start',
'user/message',
'turn/end',
- // The seeded constructor's inherited-history boundary.
- 'session/inherited',
+ // The seeded constructor's end-seed marker.
+ 'session/end-seed',
'session/title',
])
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
index 90ae134918..c77a174ee7 100644
--- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
+++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
@@ -126,7 +126,7 @@ describe('in-process policy inheritance', () => {
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
- // seq 1 is the inherited-history boundary.
+ // seq 1 is the constructor's end-seed marker.
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 2, data: { mode: 'read-only', source: 'delegation' } },
diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
index 58228179d5..f56f433a64 100644
--- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
+++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
@@ -186,12 +186,12 @@ describe('TelemetryCoordinator adoption', () => {
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
- // 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while
+ // 2 end-seed, 3 turn/end: both this lifecycle's own writes, while
// inherited 0-1 stay with the parent stream.
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
})
- it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => {
+ it('resume shape: a full-log seed exports only its own end-seed and rebuilds the chunk projection', async () => {
const backend = new FakeBackend()
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -208,7 +208,7 @@ describe('TelemetryCoordinator adoption', () => {
.filter(r => r.attributes['session.id'] === 'resumed')
.map(r => r.attributes['event.seq'])
// Nothing inherited is re-exported; seq 2 is this session's own first
- // write — the boundary its constructor appended over the seed.
+ // write — the end-seed event its constructor appended after the seed.
expect(ofResumed()).toEqual([2])
// The seed fed the projection: the (turn 1, step 1) first chunk already
// shipped from the original process, so its continuation is re-dropped…
diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts
index 912febae70..aea3ca2037 100644
--- a/packages/ui/tui/tests/tui.snapshot.ts
+++ b/packages/ui/tui/tests/tui.snapshot.ts
@@ -839,7 +839,7 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
// A prior pickup, dated well after the work: the picker must still
// show the work's date, not the pickup's.
- { type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
+ { type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
],
})
const harness = await setupSnapshot({
@@ -913,7 +913,7 @@ describe('TUI terminal-state snapshots', () => {
// `/status` appends its own `command/run` first, so the boundary is
// never the tail here. The other two call sites pin it.
dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z'))
- session.append('session/inherited', {})
+ session.append('session/end-seed', {})
dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z'))
},
}, { columns: 92, rows: 32 })
From 9d268151264eb042778dd68fe6f5643159702183 Mon Sep 17 00:00:00 2001
From: Hypatia May
Date: Thu, 30 Jul 2026 15:32:17 +0800
Subject: [PATCH 13/18] test(web): wait for the driven session before
snapshotting
---
apps/web/tests/live-interactions.e2e.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index ca05e46a22..6d8929c9c6 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -167,6 +167,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// "no crash, composer recovers, turn logged as error".
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
+ // The blank workspace also has an enabled composer. Wait for the driven
+ // session's only visible message before capturing its no-error-copy state.
+ await expect.poll(() => page.getByText(PROMPT, { exact: true }).first().isVisible(), { timeout: 10_000 }).toBe(true)
// Golden of the same gap: the prompt bubble alone, no error copy in the
// tree — the diff that changes when web-error-surface lands.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
From 925cb0b315d6e11ca25d119643a982e2cccb64fc Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:12:10 +0800
Subject: [PATCH 14/18] feat(web): plan chip as an always-visible pressed-state
toggle
fix: plan button add label
---
.../src/client/PlanModeControl.module.css | 26 ++---
.../ui-plan/src/client/PlanModeControl.tsx | 59 +++++-----
packages/client/ui-plan/src/client/index.ts | 24 +++--
.../ui-plan/tests/browser-plugin.spec.ts | 16 +--
.../ui-plan/tests/plan-mode-control.spec.tsx | 101 +++++++++++-------
5 files changed, 123 insertions(+), 103 deletions(-)
diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css
index 9e7e11d828..f79e9073db 100644
--- a/packages/client/ui-plan/src/client/PlanModeControl.module.css
+++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css
@@ -1,5 +1,5 @@
-/* Read-only plan status badge: quiet chip; the × affordance appears on
- hover/focus and the whole chip is the /plan off button. */
+/* Plan-mode toggle chip: quiet while off; the pressed state takes the
+ business accent pair (same token pairing as the trajectory user badge). */
.wrap {
display: inline-flex;
@@ -10,8 +10,7 @@
.chip {
display: inline-flex;
align-items: center;
- gap: 4px;
- padding: 6px 8px;
+ padding: 4px 8px;
border: none;
border-radius: 8px;
background: transparent;
@@ -25,6 +24,14 @@
background: var(--dsw-alias-interactive-bg-hover);
}
+/* Hovering keeps the pressed accent: the higher-specificity hover rule above
+ would otherwise swap it back to the neutral hover wash. */
+.chip[aria-pressed='true'],
+.chip[aria-pressed='true']:hover:not(:disabled) {
+ color: var(--dsw-alias-state-business-primary);
+ background: var(--dsw-alias-state-business-tertiary);
+}
+
.chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary);
outline-offset: 2px;
@@ -35,17 +42,6 @@
cursor: default;
}
-.close {
- display: inline-flex;
- align-items: center;
- color: var(--dsw-alias-label-caption);
-}
-
-.chip:hover .close,
-.chip:focus-visible .close {
- color: var(--dsw-alias-label-secondary);
-}
-
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx
index b7945789a0..961d7435ef 100644
--- a/packages/client/ui-plan/src/client/PlanModeControl.tsx
+++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx
@@ -11,16 +11,16 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace
/**
- * Read-only status badge over the host-computed `plan` projection. Plan mode
- * is entered through the /plan command only; the chip appears while the
- * effective target is plan mode and its hover × executes /plan off. The
- * displayed state follows the target (`pending ? !active : active`) — a
- * folded host value, not client optimism, so an arriving frame corrects it.
+ * Plan-mode toggle over the host-computed `plan` projection. The chip renders
+ * whenever the capability is present and reflects the effective target as its
+ * pressed state (`pending ? !active : active` — a folded host value, not
+ * client optimism, so an arriving frame corrects it). Clicking executes
+ * /plan or /plan off toward the opposite target.
*/
-export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
+export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
const plan = useProjection('plan')
- const [leaving, setLeaving] = useState(false)
- const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const aliveRef = useRef(true)
useEffect(() => {
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
}
}, [])
- // Absent capability (no plan-mode host plugin / no session yet) or the
- // default mode: no seat content.
+ // Absent capability (no plan-mode host plugin / no session yet): no seat
+ // content — without the capability there is nothing to toggle.
if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active
- if (!target) return null
- const off = (): void => {
- // No leaving/locked guard: both disable the button, so no click arrives.
- setLeaving(true)
+ const toggle = (): void => {
+ // No busy/locked guard: both disable the button, so no click arrives.
+ const on = !target
+ const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
+ setBusy(true)
setError(null)
- void exitPlanMode().then((failure) => {
+ void setPlanMode(on).then((failure) => {
if (!aliveRef.current) return
- setLeaving(false)
- setError(failure)
+ setBusy(false)
+ setError(failure === null ? null : { text: failText, detail: failure })
}, (reason: unknown) => {
if (!aliveRef.current) return
- setLeaving(false)
- setError(reason instanceof Error ? reason.message : String(reason))
+ setBusy(false)
+ setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
})
}
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
- Plan
-
-
-
-
-
+ Plan { target ? 'on' : 'off' }
- {error !== null && 退出 plan mode 失败}
+ {error !== null && {error.text}}
)
}
diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts
index 2780d66a09..77779efb02 100644
--- a/packages/client/ui-plan/src/client/index.ts
+++ b/packages/client/ui-plan/src/client/index.ts
@@ -1,11 +1,11 @@
/**
* Plan control plugin, browser half: occupies the composer's named
- * `conversation.input.plan` seat with a read-only status chip. Plan mode is
- * entered through the /plan command only; while the projection's effective
- * target is plan mode the chip renders (hover × executes /plan off through
- * `command.execute`), otherwise the seat stays empty. Reads ride the generic
- * projection pair through the standard-kit `useProjection` (an absent key is
- * capability absence); zero client-side plan state.
+ * `conversation.input.plan` seat with a plan-mode toggle chip. While the
+ * `plan` projection is present the chip renders in both states and executes
+ * /plan or /plan off through `command.execute` toward the opposite target;
+ * an absent projection (no capability) leaves the seat empty. Reads ride the
+ * generic projection pair through the standard-kit `useProjection` (an absent
+ * key is capability absence); zero client-side plan state.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,10 +18,11 @@ import { PlanChip } from './PlanModeControl.tsx'
/** Injected business face of the composer plan seat. */
export interface PlanChipInjected {
/**
- * Leave plan mode by executing /plan off.
+ * Switch plan mode by executing /plan (on) or /plan off.
+ * @param on - desired target: true enters plan mode, false leaves it.
* @returns null on admitted execution; a user-visible failure line otherwise.
*/
- exitPlanMode: () => Promise
+ setPlanMode: (on: boolean) => Promise
}
/**
@@ -38,11 +39,12 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.slots.register({
name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({
- exitPlanMode: async () => {
+ setPlanMode: async (on) => {
+ const line = on ? '/plan' : '/plan off'
const connection = ctx.get('connection') as ConnectionHandle
- const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
+ const { result } = await connection.api.commands.execute({ sessionId, line })
if (!result.ok) return `${result.error.message}(${result.error.code})`
- if (!result.value.matched) return '未知命令:/plan off'
+ if (!result.value.matched) return `未知命令:${line}`
return null
},
}),
diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts
index 865ff67042..1051d52777 100644
--- a/packages/client/ui-plan/tests/browser-plugin.spec.ts
+++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts
@@ -1,9 +1,9 @@
/**
* ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan
- * status chip; the injected face executes /plan off and folds admission
- * outcomes into null (admitted) or a user-visible failure line; teardown
- * empties the seat (HMR safety).
+ * toggle chip; the injected face executes /plan or /plan off by direction and
+ * folds admission outcomes into null (admitted) or a user-visible failure
+ * line; teardown empties the seat (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
})
- it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
+ it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
- await expect(injected.exitPlanMode()).resolves.toBeNull()
+ await expect(injected.setPlanMode(false)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
+ await expect(injected.setPlanMode(true)).resolves.toBeNull()
+ expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never)
- await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)')
+ await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)')
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } },
} as never)
- await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
+ await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx
index 44c17e5837..63f6bd7a6e 100644
--- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx
+++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
/**
* PlanChip over the `plan` projection: nothing renders while the capability
- * is absent or the effective target is the default mode; the chip renders
- * while the target is plan mode (pending follows the target — /plan shows it
- * immediately, /plan off hides it immediately); the chip button executes
- * /plan off and surfaces failures without hiding until the projection says so.
+ * is absent; with the capability present the chip renders in both states with
+ * aria-pressed following the effective target (pending folds — /plan shows
+ * pressed immediately, /plan off unpressed immediately); clicking executes
+ * the command toward the opposite target and surfaces direction-specific
+ * failures while the projection still owns the displayed state.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -17,78 +18,98 @@ afterEach(cleanup)
function setup(
plan: PlanProjection | undefined,
- exitPlanMode = vi.fn(() => Promise.resolve(null)),
+ setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)),
locked = false,
) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
- const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
+ const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
const view = render()
- return { store, exitPlanMode, view }
+ return { store, setPlanMode, view }
}
-const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
+const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
+const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
describe('PlanChip', () => {
- it('renders nothing for absent capability or the default mode', () => {
+ it('renders nothing while the capability is absent', () => {
const absent = setup(undefined)
expect(absent.view.container.innerHTML).toBe('')
- cleanup()
- const inactive = setup({ active: false, pending: false })
- expect(inactive.view.container.innerHTML).toBe('')
- cleanup()
- // Active with a pending exit: the target is default — chip already gone.
- const leaving = setup({ active: true, pending: true })
- expect(leaving.view.container.innerHTML).toBe('')
})
- it('renders while the effective target is plan mode, including the pending entry window', () => {
+ it('reflects the effective target as the pressed state, folding pending', () => {
+ setup({ active: false, pending: false })
+ expect(offChip().getAttribute('aria-pressed')).toBe('false')
+ cleanup()
setup({ active: true, pending: false })
- expect(chip()).toBeTruthy()
+ expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true })
- expect(chip()).toBeTruthy()
+ expect(onChip().getAttribute('aria-pressed')).toBe('true')
+ cleanup()
+ // Active with a pending exit: the target is default — already unpressed.
+ setup({ active: true, pending: true })
+ expect(offChip().getAttribute('aria-pressed')).toBe('false')
})
- it('the chip executes /plan off once and follows the projection down', async () => {
+ it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
let resolve!: (value: string | null) => void
- const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done }))
- const { store } = setup({ active: true, pending: false }, exitPlanMode)
- fireEvent.click(chip())
- expect(exitPlanMode).toHaveBeenCalledTimes(1)
+ const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done }))
+ const { store } = setup({ active: false, pending: false }, setPlanMode)
+ fireEvent.click(offChip())
+ expect(setPlanMode).toHaveBeenCalledTimes(1)
+ expect(setPlanMode).toHaveBeenLastCalledWith(true)
// Busy while its own call is in flight.
- fireEvent.click(chip())
- expect(exitPlanMode).toHaveBeenCalledTimes(1)
+ fireEvent.click(offChip())
+ expect(setPlanMode).toHaveBeenCalledTimes(1)
resolve(null)
- // The off command's run record folds: target flips, the chip unmounts.
+ // The command's run record folds: target flips, the chip presses.
+ store.set({ value: { active: false, pending: true } })
+ await waitFor(() => {
+ expect(onChip().getAttribute('aria-pressed')).toBe('true')
+ })
+ })
+
+ it('pressed chip executes /plan off and follows the projection down', async () => {
+ const setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null))
+ const { store } = setup({ active: true, pending: false }, setPlanMode)
+ fireEvent.click(onChip())
+ expect(setPlanMode).toHaveBeenLastCalledWith(false)
store.set({ value: { active: true, pending: true } })
await waitFor(() => {
- expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
+ expect(offChip().getAttribute('aria-pressed')).toBe('false')
})
})
it('disables under the locked owner prop', () => {
setup({ active: true, pending: false }, vi.fn(), true)
- expect((chip() as HTMLButtonElement).disabled).toBe(true)
+ expect((onChip() as HTMLButtonElement).disabled).toBe(true)
})
- it('surfaces admission and transport failures while staying visible', async () => {
- const exitPlanMode = vi.fn()
+ it('surfaces direction-specific admission and transport failures while staying visible', async () => {
+ const exitFailing = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
- setup({ active: true, pending: false }, exitPlanMode)
- fireEvent.click(chip())
+ setup({ active: true, pending: false }, exitFailing)
+ fireEvent.click(onChip())
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
- expect(chip()).toBeTruthy()
+ expect(onChip()).toBeTruthy()
- fireEvent.click(chip())
+ fireEvent.click(onChip())
expect(await screen.findByTitle('network down')).toBeTruthy()
- fireEvent.click(chip())
+ fireEvent.click(onChip())
expect(await screen.findByTitle('socket closed')).toBeTruthy()
+ cleanup()
+
+ const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
+ setup({ active: false, pending: false }, enterFailing)
+ fireEvent.click(offChip())
+ expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
+ expect(offChip()).toBeTruthy()
})
it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -97,14 +118,14 @@ describe('PlanChip', () => {
{ active: true, pending: false },
vi.fn(() => new Promise((done) => { resolve = done })),
)
- fireEvent.click(chip())
+ fireEvent.click(onChip())
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
- const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail }))
- const { view } = setup({ active: true, pending: false }, exitPlanMode)
- fireEvent.click(chip())
+ const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail }))
+ const { view } = setup({ active: true, pending: false }, setPlanMode)
+ fireEvent.click(onChip())
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})
From ee1d06e23d5bf764b4c546791655587a0d03ce7a Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:41:53 +0800
Subject: [PATCH 15/18] docs: regenerate the event matrix for the locale-seat
question composer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ui-question no longer listens to locale/change directly — copy rides the
framework locale seat since the standard-seat merge — so the generated
matrix row drops it. gen-doc-graphs output, verified with --check.
---
docs/event-producer-consumer.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 8d61d2b0af..b9538b89d5 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
| `slash/input-begin-command` | - | `ui-conversation` |
| `slash/input-consume-token` | - | `ui-conversation` |
| `slash/input-insert-reference` | - | `ui-conversation` |
From d34bc5bfa4cf757cd9c1a480530285ae577dc738 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:44:53 +0800
Subject: [PATCH 16/18] test(web): pick up the plan chip in the answered
composer golden
The always-visible plan-mode toggle (fe9191934) adds one composer-row
button; the answered transcript golden re-records with it.
---
apps/web/tests/snapshots/question-composer/answered.expected.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index ac9f26298e..36752c783a 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
From 34c124585aeb9190bbdb3513862d4decff0f2f82 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:46:50 +0800
Subject: [PATCH 17/18] fix: test
---
.../snapshots/code-mode-round/ui.expected.md | 1 +
.../cordis-tool-round/ui.expected.md | 1 +
.../snapshots/fresh-round-trip/ui.expected.md | 1 +
.../lifecycle-chrome/hero.expected.md | 1 +
.../lifecycle-chrome/reloaded.expected.md | 1 +
.../live-interactions/cancel.expected.md | 1 +
.../live-interactions/error-auth.expected.md | 1 +
.../live-interactions/retry.expected.md | 1 +
.../snapshots/message-actions/ui.expected.md | 1 +
.../queue-actions/editing.expected.md | 1 +
.../snapshots/queue-actions/ui.expected.md | 1 +
.../snapshots/seeded-history/ui.expected.md | 1 +
.../snapshots/steering/mid-steer.expected.md | 24 +++++++------------
.../snapshots/steering/settled.expected.md | 3 ++-
.../client/ui-workspace/tests/rows.spec.tsx | 19 +++++++++++++++
15 files changed, 42 insertions(+), 16 deletions(-)
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index 391d07fc2d..0282a16f80 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -36,6 +36,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index faf22422a5..5b51e47cf4 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -50,6 +50,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index d908af87af..49c7958292 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index 0fff304821..65abda0dba 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -29,6 +29,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index e4a14a77a8..45e3514fa4 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -25,6 +25,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index ac9a1ad53c..4323c94285 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -22,6 +22,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index 274b2b3132..1d78e91c73 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -15,6 +15,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 48afc76970..6a9c808342 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -25,6 +25,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 1d14d00143..19ba02d99d 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -36,6 +36,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md
index 311c961450..2594f18294 100644
--- a/apps/web/tests/snapshots/queue-actions/editing.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md
@@ -29,6 +29,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md
index f56d4e1970..919617bdab 100644
--- a/apps/web/tests/snapshots/queue-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md
@@ -23,6 +23,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 428fa5b526..e1581ba986 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -35,6 +35,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
index 59328ee1e7..28127fd73b 100644
--- a/apps/web/tests/snapshots/steering/mid-steer.expected.md
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -18,27 +18,21 @@
- button:
- img
- img
-- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
+- text: Ask question waiting
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- - text: 1 / 1
- - button "Previous question" [disabled]:
- - img
- - button "Next question" [disabled]:
- - img
- button "Dismiss all questions":
- img
- radiogroup:
- - radio "Yes":
- - text: 1 Yes
- - img
- - radio "No":
- - text: 2 No
- - img
- - button "Other — enter a custom answer":
- - img
- - text: Other — enter a custom answer
+ - radio "Yes": 1 Yes
+ - radio "No": 2 No
+ - textbox "Type your answer"
+ - button "Previous question" [disabled]:
+ - img
+ - text: 1 / 1
+ - button "Next question" [disabled]:
+ - img
- status
- button "Skip this question"
- button "Submit" [disabled]
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index e0f6df36fb..5efbcf385d 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -18,7 +18,7 @@
- button:
- img
- img
-- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
+- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx
index 31f1d0ceec..c4b440578a 100644
--- a/packages/client/ui-workspace/tests/rows.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.spec.tsx
@@ -128,6 +128,25 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
+ it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
+ vi.useFakeTimers()
+ try {
+ const group: GroupNode = {
+ key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
+ sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
+ }
+ render()
+ fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+ act(() => { vi.advanceTimersByTime(500) })
+ // Card body: full title + cwd + absolute creation time.
+ expect(screen.getAllByText('Project')).toHaveLength(2)
+ expect(screen.getByText('/projects/project')).toBeTruthy()
+ expect(screen.getByText(/^Created /)).toBeTruthy()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
From f87692a5c36f1a100b92bc953f5ada9512023861 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:54:43 +0800
Subject: [PATCH 18/18] test(web): anchor the group section above the HoverCard
wrapper
The workspace header row is now wrapped by its HoverCard anchor span
(0b4a6196f), so the row's immediate parent no longer holds the session
subtree; the delete scenario anchors on the groupSection ancestor
instead. The flat-view and dwell failures were downstream of this one
(shared page state).
---
apps/web/tests/workspace-management.e2e.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
index 0e58f5188c..e6a7f31f18 100644
--- a/apps/web/tests/workspace-management.e2e.ts
+++ b/apps/web/tests/workspace-management.e2e.ts
@@ -180,7 +180,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// current selection while it moves into Ungrouped.
const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
await groupRow.waitFor({ timeout: 10_000 })
- const groupSection = groupRow.locator('..')
+ // The header row is wrapped by its HoverCard anchor span, so the section
+ // is the nearest groupSection ancestor, not the immediate parent.
+ const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),