@tea-agent/loop-agent 0.34.3 → 0.34.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +8 -3
  2. package/CHANGELOG.md +66 -22
  3. package/README.md +2 -2
  4. package/dist/shared/operator/capabilities.js +0 -7
  5. package/dist/worker/cli.js +21 -27
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/chat-event-store.js +134 -4
  8. package/dist/worker/console/chat/pi-runtime.js +308 -63
  9. package/dist/worker/console/chat/resource-preferences-store.js +152 -0
  10. package/dist/worker/console/chat/routes.js +425 -19
  11. package/dist/worker/console/chat/shortcuts.js +208 -9
  12. package/dist/worker/console/chat/tool-preview.js +162 -5
  13. package/dist/worker/console/chat/turn-execution-registry.js +82 -0
  14. package/dist/worker/console/dag-execution-receipt.js +14 -1
  15. package/dist/worker/console/doctor.js +1 -1
  16. package/dist/worker/console/index.js +1 -0
  17. package/dist/worker/console/open-browser.js +134 -0
  18. package/dist/worker/console/recovery-cta.js +1 -1
  19. package/dist/worker/console/server.js +5 -1
  20. package/dist/worker/console/static/assets/index-qpkysQYW.css +1 -0
  21. package/dist/worker/console/static/assets/index-y980PqtP.js +56 -0
  22. package/dist/worker/console/static/index.html +2 -2
  23. package/dist/worker/console/static-src/chat-markdown-security.js +38 -0
  24. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +1 -3
  25. package/dist/worker/console/static-src/operator-chat/format.js +2 -2
  26. package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
  27. package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
  28. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
  29. package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
  31. package/dist/worker/console/static-src/operator-chat/useChatThread.js +11 -5
  32. package/dist/worker/console/static-src/operator-chat/useComposer.js +81 -3
  33. package/dist/workflows/dag/init-hybrid.js +2 -0
  34. package/docs/architecture/evolution.md +1 -1
  35. package/docs/architecture/system-overview.md +1 -1
  36. package/docs/architecture/worker-and-feature.md +1 -1
  37. package/docs/operations/local-development-environment.md +4 -2
  38. package/docs/templates/branch-merge-report.md +9 -0
  39. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  40. package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
  41. package/docs/templates/init-managed-agents.md +2 -2
  42. package/package.json +6 -2
  43. package/skills/agent-worker/SKILL.md +1 -1
  44. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  45. package/skills/loop-agent/references/command-reference.md +2 -3
  46. package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
  47. package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
package/AGENTS.md CHANGED
@@ -38,7 +38,7 @@
38
38
  4. 涉及测试纪律/验证声明/调试时继续读:`docs/governance/harness-methodology-*.md`。
39
39
  5. 查看最近提交、相关 plan/progress/report;`git status --short --branch`;跑最小基线验证。
40
40
  6. 后端/接口/pytest → `taskKind: "backend-test"`(不是 `--profile`);知识回写 `knowledge-sync`;图谱开荒 `knowledge-graph-bootstrap`。`--profile` 仅 `auto|minimal|standard|reviewed|supervised`。
41
- 7. 看板/observe → `agent-worker console`(默认 repo=当前目录、port=8790;兼容入口 `agent-worker console serve --repo . --port 8790`)(`/inspect/` 只读);`observe serve` 已下线(`OBSERVE_SERVE_REMOVED` + exit 2);`observe snapshot` 仍可用。
41
+ 7. 看板/observe → `agent-worker console`(默认 repo=当前目录、port=8790;listen 成功后默认打开系统浏览器,`--no-open` 禁止)(`/inspect/` 只读);`observe serve` 已下线(`OBSERVE_SERVE_REMOVED` + exit 2);`observe snapshot` 仍可用。
42
42
  8. 分支合并 → 先读 `docs/operations/branch-merge-guideline.md`。
43
43
 
44
44
  ## 会话协议
@@ -74,7 +74,9 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
74
74
 
75
75
  权威源:`docs/governance/verification-matrix.md`。
76
76
 
77
- 默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push` `bash scripts/ci.sh`(全量 typecheck + `npm test`)。每个 clone 安装一次 hooks:`bash scripts/install-git-hooks.sh`。
77
+ 默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push` `node scripts/pre-push-verify.mjs` 在 receipt 命中时复用、miss 时跑 full `bash scripts/ci.sh`(全量 typecheck + `npm test`)。每个 clone 安装一次 hooks:`bash scripts/install-git-hooks.sh`。
78
+
79
+ 连续 source→target 交付遵循**单一最终树全量验证**:同一 Git tree、相同环境与命令合同在 receipt TTL 内只选一个本地 full authority(有效 receipt / target pre-push / 可选 `npm run verify:tree`),不按分支名策略化,也不机械双跑 full CI。细节见 verification-matrix 与 branch-merge-guideline。
78
80
 
79
81
  常用:
80
82
 
@@ -83,7 +85,10 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
83
85
  npm run typecheck
84
86
  npx vitest run <相关测试路径>
85
87
 
86
- # push / 交付前(或依赖 pre-push
88
+ # 可选:提前验证当前 clean HEAD 并写 receipt(随后同 tree push 可复用)
89
+ npm run verify:tree
90
+
91
+ # push / 交付前(或依赖 pre-push;receipt miss 时安全回退 full)
87
92
  bash scripts/ci.sh
88
93
  npm run build
89
94
  node bin/loop-agent.js --help
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.5] - 2026-08-13
6
+
7
+ ### 重点更新
8
+
9
+ - 优化 Operator Chat 对话页体验,包括隐藏重复标题、升级 Markdown 渲染、改进工具调用展示,并合并了 `/` 命令面板
10
+ - 控制台启动后默认自动打开系统浏览器看板
11
+
12
+ ### 改进
13
+
14
+ - 优化 Operator Chat 对话页(UI-01 至 UI-11):隐藏重复标题、补齐品牌空态与真实注入上下文侧栏、改进工具调用摘要与结果展示、修复遮罩 hover 形变、为截断会话标题提供 tooltip、升级 Markdown/GFM 渲染,并增加用户主动打开的调用记录入口与合并后的 `/` 命令面板
15
+ - 会话列表操作改为按需显示 Remix Icon,同时保留键盘 focus 与触屏可访问路径
16
+ - 输入区将图片上传收敛为图标按钮,并避免“全部工具 · N · 状态”歧义
17
+ - 控制台在服务监听成功后默认打开系统浏览器
18
+
19
+ ## [0.34.4] - 2026-08-12
20
+
21
+ ### 重点更新
22
+
23
+ - 裸 `agent-worker console` 收敛为唯一看板入口,监听成功后默认打开系统浏览器,并删除 `console serve`
24
+ - 本地连续交付改为按精确 Git tree 复用验证结果,避免提交、推送和分支合并之间机械重复全量测试,同时保持不同 tree、不同环境和远程 CI 的独立质量门槛
25
+ - 全面加固 Operator Chat 的 Turn 所有权、停止与异常恢复流程,解决浏览器与服务端状态错位时的重复提交、停止失效和错误提示问题
26
+ - Operator Chat 新增按仓库保存的 Skills 自动使用偏好,用户可控制技能是否进入模型自动发现范围,同时保留手动调用能力
27
+ - 修复控制台任务导航无法自主执行后端测试与前端测试 DAG 的问题,现可正确识别 pytest、playwright 等多种测试框架的验证命令
28
+
29
+ ### 新增
30
+
31
+ - 新增可选的 `npm run verify:tree`,对当前严格 clean HEAD 执行完整验证并写入 receipt,随后推送同一 tree 时可直接复用
32
+ - 新增只读的当前 tree receipt 诊断,可区分匹配、缺失、过期、损坏和工作区不合格等状态,不执行测试或写入 receipt
33
+ - Operator Chat 引入完整的 Turn ownership 协议和服务端真实中止接口,并在状态接口中提供 active/latest Turn 与 runtime busy 事实
34
+ - Operator Chat Skills 新增「允许自动使用」开关,偏好按仓库持久化;关闭后仍可通过 `/skill:<name>` 手动调用
35
+
36
+ ### 改进
37
+
38
+ - 裸 `agent-worker console` 成为唯一 Console 启动入口:本地图形交互环境在服务监听成功后默认打开系统浏览器,`--no-open` 可明确禁止;删除 `console serve` 兼容子命令
39
+ - source→target 连续交付统一选择一个本地全量验证权威;默认 commit/push 不增加步骤,远程 CI 与发布环境不复用本地 receipt
40
+ - pre-push receipt 诊断和执行日志改用有界命令摘要,并严格校验 tree、环境、命令合同、有效期及 receipt 内容一致性
41
+ - Skills 运行时快照改为使用服务端资源标识和独立的自动调用状态,资源变更仅接受当前 inventory 中的合法条目
42
+ - Turn 已占用时返回可恢复的中文状态提示和真实 active Turn,不再向用户暴露裸露的 `chat turn already active` 错误
43
+
44
+ ### 修复
45
+
46
+ - 修复浏览器已认为对话结束、服务端却仍持有 active Turn 时的重复提交竞态;提交现在支持幂等请求标识、boot epoch orphan 隔离和 durable Turn/runtime busy 联合对账
47
+ - 修复创建 Turn 的响应不确定时状态被过早释放的问题;客户端会保留 ambiguous 状态,通过 `/state` 绑定或释放真实 ownership,不会重新生成请求标识
48
+ - 修复停止请求在 Turn 尚未绑定时丢失的问题;绑定成功后只发送一次真实 abort,且服务端缺少 abort 能力时不会通过 dispose 会话伪造停止
49
+ - 修复 active bind、terminal SSE 与 React streaming 状态交错时提交锁未释放的问题,并统一 adopt、停止中和 abort-pending 为非致命提示
50
+ - 修复控制台任务导航执行 backend-test 和 frontend-test DAG 时,因结构化验证门禁未识别 pytest/playwright 等命令而始终被阻断的问题
51
+
5
52
  ## [0.34.3] - 2026-08-12
6
53
 
7
54
  ### 重点更新
@@ -85,36 +132,33 @@
85
132
 
86
133
  ## [0.33.6] - 2026-08-11
87
134
 
135
+ ### 重点更新
136
+
137
+ - 加固 PRD 导入与 Semantic Intake,在结构化解析不足时提供安全回退,并增强 writeSet 审查与运行结果摘要
138
+ - Operator Chat 接入单一事件流、启动 epoch 隔离、实时运行时控制与开放资源加载,提升长会话恢复和自主 DAG 监督能力
139
+ - 统一 Windows 等环境下只读 Git 命令的有界恢复机制,同时保留用户自有的 Pi 模型路由配置
140
+
88
141
  ### 新增
89
142
 
90
- - stabilize PRD intake + add Semantic Intake fallback
91
- - Operator Chat 单事件流 + boot epoch fence(Task C)
92
- - gate writeSet delta + run summary(操作面增强)
93
- - supervise DAG runs autonomously
94
- - ChatEventStore 事件持久化压缩(coalescing + maxBytes 双限制)
95
- - reconcile(cursor_expired) 自动恢复而非全局致命错误
96
- - 接入 Operator Chat 实时运行时控制
97
- - open operator chat resource loading
98
- - enrich execution report and URL resolution
143
+ - task advance 新增 writeSet delta run summary,直接展示边界扩张来源、节点结果和下一步操作
144
+ - Operator Chat 引入单一事件流与 boot epoch fence,支持 cursor 过期后的自动对账恢复,避免将可恢复问题升级为全局错误
145
+ - ChatEventStore 增加事件合并与容量上限双重压缩,控制长期会话持久化体积
146
+ - Operator Chat 新增实时模型、thinking 和运行时资源控制,并按开放资源加载规则解析 Skills、Extensions 与 Packages
147
+ - Operator Chat 可对安全的 bounded DAG 进行持续监督和恢复,执行报告同步补充运行细节与目标 URL 来源
99
148
 
100
149
  ### 改进
101
150
 
102
- - style(task-advance): biome autoformat 对齐(无功能变化)
103
- - style(console): apply biome formatting from pi-lens autoformat
151
+ - 只读 Git 查询统一使用共享韧性执行器,在 Windows 启动型故障下有界恢复,不扩大 mutation 命令权限
152
+ - 初始化与升级流程保留用户自行配置的 Pi 模型路由,不再因默认配置变化机械覆盖
153
+ - Packages 面板区分配置状态与真实安装元数据,并统一使用品牌图标
104
154
 
105
155
  ### 修复
106
156
 
107
- - unify resilient read-only command execution
108
- - preserve user-owned Pi model routing
109
- - show installed package metadata
110
- - escape preflight backtick matcher
111
- - align chat streaming reliability with pi-web
112
- - split rerun defaults and repair selector escaping
113
- - ignore delimiters inside pytest strings
114
- - resolve target URL from runtime context
115
- - reject priority-only module stems
116
- - stop desiredSha256 mechanical checks from blocking generated-content upgrades
117
- - use mobius header brand icon
157
+ - 修复 Operator Chat 流式状态与 pi-web 可靠性机制未完全对齐的问题,提升重连和终态收敛稳定性
158
+ - 修复前端测试环境预检中的反引号转义错误,以及重跑默认值和 repair 选择器转义不一致的问题
159
+ - 修复后端测试完整性检查误判字符串内部未配对分隔符,以及按优先级生成非法单模块文件的问题
160
+ - 修复前端测试目标 URL 未从运行时上下文解析的问题,执行时现使用可审计的真实地址来源
161
+ - 修复 generated init 内容因 `desiredSha256` 机械比较而阻断安全升级的问题
118
162
 
119
163
  ## [0.33.5] - 2026-08-11
120
164
 
package/README.md CHANGED
@@ -104,8 +104,8 @@ loop-agent dag report --run-id <run-id>
104
104
 
105
105
  ```bash
106
106
  # 终端 A:Console(任务/运行操作面;canonical mutation 只经 sibling loop-agent)
107
- agent-worker console # 默认 repo=当前目录、port=8790
108
- agent-worker console serve --repo . # 兼容入口,等价于裸入口
107
+ agent-worker console # 默认 repo=当前目录、port=8790;本地图形环境默认打开浏览器
108
+ agent-worker console --no-open # 只启动服务,不打开浏览器
109
109
  agent-worker console doctor --repo .
110
110
 
111
111
  ```
@@ -2299,13 +2299,6 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
2299
2299
  source: "agent-worker",
2300
2300
  exclusionReason: "Not exposed to Operator Chat in M5.",
2301
2301
  },
2302
- {
2303
- command: "agent-worker console serve",
2304
- coverage: "excluded",
2305
- action: null,
2306
- source: "agent-worker",
2307
- exclusionReason: "Not exposed to Operator Chat in M5.",
2308
- },
2309
2302
  {
2310
2303
  command: "agent-worker console doctor",
2311
2304
  coverage: "excluded",
@@ -712,37 +712,22 @@ export function buildAgentWorkerProgram() {
712
712
  process.stdout.write(`${JSON.stringify(snapshot)}\n`);
713
713
  });
714
714
  consoleCmd
715
- .description("Loop Operator Console (local); bare `agent-worker console` starts it directly (repo defaults to current working directory, port defaults to 8790)")
716
- .enablePositionalOptions()
717
- .passThroughOptions()
715
+ .description("Loop Operator Console (local); bare `agent-worker console` starts it directly (repo defaults to current working directory, port defaults to 8790). Opens the system browser after listen unless --no-open or a non-graphical environment.")
718
716
  .option("--repo <repo-root>", "Target repo root (default: current working directory)", process.cwd())
719
717
  .option("--port <port>", "HTTP port", "8790")
720
718
  .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
721
719
  .option("--debug", "Log request source/method/path/status to stderr")
722
720
  .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
723
721
  .option("--default-timeout-ms <ms>", "LoopAgentClient hard timeout for Console operations (ms; default 14400000 / 4h; 0 disables)")
724
- .action(async (options) => {
725
- await runConsoleServe({
726
- repoRoot: path.resolve(options.repo),
727
- host: options.host,
728
- port: Number.parseInt(options.port, 10),
729
- debug: options.debug === true,
730
- appDataRoot: options.appData
731
- ? path.resolve(options.appData)
732
- : undefined,
733
- defaultTimeoutMsRaw: options.defaultTimeoutMs,
734
- });
735
- });
736
- consoleCmd
737
- .command("serve")
738
- .option("--repo <repo-root>", "Target repo root (default: current working directory)", process.cwd())
739
- .option("--port <port>", "HTTP port", "8790")
740
- .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
741
- .option("--debug", "Log request source/method/path/status to stderr")
742
- .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
743
- .option("--default-timeout-ms <ms>", "LoopAgentClient hard timeout for Console operations (ms; default 14400000 / 4h; 0 disables)")
744
- .description("Start Loop Operator Console HTTP server (compat entry; equivalent to bare `agent-worker console`)")
745
- .action(async (options) => {
722
+ .option("--no-open", "Do not open the system browser after the Console listens")
723
+ .action(async (options, command) => {
724
+ // Reject unknown tokens such as legacy `console serve` instead of starting HTTP.
725
+ const extras = command.args.filter((arg) => arg.length > 0);
726
+ if (extras.length > 0) {
727
+ process.stderr.write(`error: unknown command '${extras[0]}' for agent-worker console\n`);
728
+ process.exitCode = 1;
729
+ return;
730
+ }
746
731
  await runConsoleServe({
747
732
  repoRoot: path.resolve(options.repo),
748
733
  host: options.host,
@@ -752,6 +737,8 @@ export function buildAgentWorkerProgram() {
752
737
  ? path.resolve(options.appData)
753
738
  : undefined,
754
739
  defaultTimeoutMsRaw: options.defaultTimeoutMs,
740
+ // Commander maps --no-open to open:false; default remains open:true.
741
+ noOpen: options.open === false,
755
742
  });
756
743
  });
757
744
  consoleCmd
@@ -850,9 +837,10 @@ function parseOptionalTimeoutMs(raw) {
850
837
  }
851
838
  return value;
852
839
  }
853
- /** Shared console serve entry used by both bare `console` and `console serve`. */
840
+ /** Console HTTP entry for bare `agent-worker console` (listen first, then optional browser open). */
854
841
  async function runConsoleServe(options) {
855
842
  const { createConsoleServer } = await import("./console/server.js");
843
+ const { maybeOpenConsoleBrowser } = await import("./console/open-browser.js");
856
844
  try {
857
845
  const server = await createConsoleServer({
858
846
  repoRoot: options.repoRoot,
@@ -862,7 +850,13 @@ async function runConsoleServe(options) {
862
850
  appDataRoot: options.appDataRoot,
863
851
  defaultTimeoutMs: parseOptionalTimeoutMs(options.defaultTimeoutMsRaw),
864
852
  });
865
- process.stdout.write(`${server.url}\n`);
853
+ // Keep stdout bind URL contract (may include 0.0.0.0 / ::); browser URL is normalized separately.
854
+ process.stdout.write(`${server.url}
855
+ `);
856
+ await maybeOpenConsoleBrowser({
857
+ noOpen: options.noOpen === true,
858
+ serverUrl: server.url,
859
+ });
866
860
  await new Promise((resolve) => {
867
861
  const shutdown = () => resolve();
868
862
  process.once("SIGINT", shutdown);
@@ -43,6 +43,7 @@ export function openConsoleAppData(options) {
43
43
  chats: path.join(repoNamespace, "chats"),
44
44
  chatSessions: path.join(repoNamespace, "chat-sessions"),
45
45
  chatEvents: path.join(repoNamespace, "chat-events"),
46
+ resourcePreferences: path.join(repoNamespace, "resource-preferences.json"),
46
47
  };
47
48
  for (const dir of [
48
49
  paths.drafts,
@@ -55,6 +56,7 @@ export function openConsoleAppData(options) {
55
56
  paths.chats,
56
57
  paths.chatSessions,
57
58
  paths.chatEvents,
59
+ path.dirname(paths.resourcePreferences),
58
60
  ]) {
59
61
  ensureSecureDir(dir);
60
62
  }
@@ -129,9 +129,56 @@ function boundedRedactedJson(value) {
129
129
  previewHash: createHash("sha256").update(preview).digest("hex"),
130
130
  };
131
131
  }
132
+ /** Prefer readable Pi ToolResult text content; fall back to JSON shell. */
133
+ function projectToolResultPreview(value) {
134
+ if (typeof value === "string") {
135
+ const preview = boundedRedactedText(value) ?? "";
136
+ return {
137
+ preview,
138
+ previewHash: createHash("sha256").update(preview).digest("hex"),
139
+ };
140
+ }
141
+ if (value && typeof value === "object" && !Array.isArray(value)) {
142
+ const record = value;
143
+ const content = record.content;
144
+ if (Array.isArray(content)) {
145
+ const texts = [];
146
+ for (const block of content) {
147
+ if (!block || typeof block !== "object")
148
+ continue;
149
+ const item = block;
150
+ if (typeof item.text === "string")
151
+ texts.push(item.text);
152
+ }
153
+ if (texts.length > 0) {
154
+ const joined = texts.join("\n");
155
+ const preview = boundedRedactedText(joined) ?? "";
156
+ return {
157
+ preview,
158
+ previewHash: createHash("sha256").update(preview).digest("hex"),
159
+ };
160
+ }
161
+ }
162
+ if (typeof record.text === "string") {
163
+ const preview = boundedRedactedText(record.text) ?? "";
164
+ return {
165
+ preview,
166
+ previewHash: createHash("sha256").update(preview).digest("hex"),
167
+ };
168
+ }
169
+ if (typeof record.output === "string") {
170
+ const preview = boundedRedactedText(record.output) ?? "";
171
+ return {
172
+ preview,
173
+ previewHash: createHash("sha256").update(preview).digest("hex"),
174
+ };
175
+ }
176
+ }
177
+ return boundedRedactedJson(value);
178
+ }
132
179
  export function projectChatToolEventPayload(input) {
133
- const { preview, previewHash } = boundedRedactedJson(input.value);
134
180
  if (input.kind === "tool_call") {
181
+ const { preview, previewHash } = boundedRedactedJson(input.value);
135
182
  return {
136
183
  toolCallId: input.toolCallId,
137
184
  toolName: input.toolName,
@@ -139,6 +186,7 @@ export function projectChatToolEventPayload(input) {
139
186
  previewHash,
140
187
  };
141
188
  }
189
+ const { preview, previewHash } = projectToolResultPreview(input.value);
142
190
  return {
143
191
  toolCallId: input.toolCallId,
144
192
  toolName: input.toolName,
@@ -221,6 +269,20 @@ function shouldCoalesce(ring, turnId, partial) {
221
269
  return false;
222
270
  return nowMs - Date.parse(tail.at) <= ring.coalesceWindowMs;
223
271
  }
272
+ /** Stable hash of the normalized prompt payload for clientRequestId binding. */
273
+ export function hashChatTurnPayload(input) {
274
+ const normalized = {
275
+ text: input.text,
276
+ images: (input.images ?? []).map((image) => ({
277
+ type: image.type ?? "image",
278
+ mimeType: image.mimeType ?? "",
279
+ data: image.data ?? "",
280
+ })),
281
+ };
282
+ return createHash("sha256")
283
+ .update(JSON.stringify(normalized))
284
+ .digest("hex");
285
+ }
224
286
  const DEFAULT_MAX_EVENTS = 2000;
225
287
  /** Mirrors pi-web's bounded-ring orientation (2000 events / 10 MiB). */
226
288
  const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
@@ -399,6 +461,24 @@ export function createChatEventStore(options) {
399
461
  eventId: eventIdFor(sessionId, event.seq),
400
462
  }));
401
463
  const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
464
+ // New Console epoch: orphan any queued/running turn whose ownerEpoch is
465
+ // missing or mismatched. Releases the active lease without pretending the
466
+ // user aborted (CHAT_TURN_OWNER_LOST fence).
467
+ let orphaned = false;
468
+ const now = new Date().toISOString();
469
+ for (const turn of turns) {
470
+ if (turn.state !== "queued" && turn.state !== "running")
471
+ continue;
472
+ if (turn.ownerEpoch === eventEpoch)
473
+ continue;
474
+ turn.state = "failed";
475
+ turn.finishedAt = now;
476
+ turn.error = {
477
+ code: "CHAT_TURN_OWNER_LOST",
478
+ message: "turn ownership lost after Console restart; previous execution is no longer owned",
479
+ };
480
+ orphaned = true;
481
+ }
402
482
  turnCounts.set(sessionId, parsed.turnCount ?? 0);
403
483
  const ring = {
404
484
  events,
@@ -415,6 +495,8 @@ export function createChatEventStore(options) {
415
495
  // R6: re-check the bounds after a restart — a persisted file may exceed the
416
496
  // current limits if it was written by an older config or hand-edited.
417
497
  enforceBounds(sessionId, ring);
498
+ if (orphaned)
499
+ persist(sessionId, ring);
418
500
  return ring;
419
501
  }
420
502
  catch (error) {
@@ -521,11 +603,35 @@ export function createChatEventStore(options) {
521
603
  }
522
604
  return { copiedCount };
523
605
  },
524
- createTurn(sessionId) {
606
+ createTurn(sessionId, input) {
525
607
  const ring = ringFor(sessionId);
608
+ const clientRequestId = input?.clientRequestId?.trim() || undefined;
609
+ const payloadHash = input?.payloadHash?.trim() || undefined;
610
+ if (clientRequestId) {
611
+ const existing = ring.turns.find((turn) => turn.clientRequestId === clientRequestId);
612
+ if (existing) {
613
+ if (payloadHash &&
614
+ existing.payloadHash &&
615
+ existing.payloadHash !== payloadHash) {
616
+ return {
617
+ ok: false,
618
+ code: "REQUEST_ID_REUSE_CONFLICT",
619
+ message: `clientRequestId ${clientRequestId} was reused with a different payload`,
620
+ existingTurn: { ...existing },
621
+ };
622
+ }
623
+ // Same id + same (or missing) payload: idempotent return, no new lease.
624
+ return {
625
+ ok: true,
626
+ turn: { ...existing },
627
+ created: false,
628
+ idempotent: true,
629
+ };
630
+ }
631
+ }
526
632
  const active = ring.turns.find((turn) => turn.state === "queued" || turn.state === "running");
527
633
  if (active)
528
- return { ok: false, code: "TURN_ACTIVE", activeTurn: active };
634
+ return { ok: false, code: "TURN_ACTIVE", activeTurn: { ...active } };
529
635
  const ordinal = (turnCounts.get(sessionId) ?? 0) + 1;
530
636
  turnCounts.set(sessionId, ordinal);
531
637
  const now = new Date().toISOString();
@@ -536,11 +642,14 @@ export function createChatEventStore(options) {
536
642
  ordinal,
537
643
  state: "queued",
538
644
  createdAt: now,
645
+ ownerEpoch: eventEpoch,
646
+ ...(clientRequestId ? { clientRequestId } : {}),
647
+ ...(payloadHash ? { payloadHash } : {}),
539
648
  };
540
649
  ring.turns.push(turn);
541
650
  ring.approxBytes += byteHint(turn);
542
651
  persist(sessionId, ring);
543
- return { ok: true, turn };
652
+ return { ok: true, turn: { ...turn }, created: true, idempotent: false };
544
653
  },
545
654
  setTurnState(sessionId, turnId, state, error) {
546
655
  const ring = ringFor(sessionId);
@@ -561,6 +670,27 @@ export function createChatEventStore(options) {
561
670
  persist(sessionId, ring);
562
671
  return { ...turn };
563
672
  },
673
+ markAbortRequested(sessionId, turnId, at) {
674
+ const ring = ringFor(sessionId);
675
+ const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
676
+ if (!turn)
677
+ return undefined;
678
+ const before = JSON.stringify(turn).length;
679
+ if (!turn.abortRequestedAt)
680
+ turn.abortRequestedAt = at ?? new Date().toISOString();
681
+ ring.approxBytes +=
682
+ (JSON.stringify(turn).length - before) * BYTE_GATE_SCALE;
683
+ persist(sessionId, ring);
684
+ return { ...turn };
685
+ },
686
+ findTurnByClientRequestId(sessionId, clientRequestId) {
687
+ const id = clientRequestId.trim();
688
+ if (!id)
689
+ return undefined;
690
+ const ring = ringFor(sessionId);
691
+ const turn = ring.turns.find((candidate) => candidate.clientRequestId === id);
692
+ return turn ? { ...turn } : undefined;
693
+ },
564
694
  getTurn(sessionId, turnId) {
565
695
  const ring = ringFor(sessionId);
566
696
  const turn = ring.turns.find((candidate) => candidate.turnId === turnId);