@danceiny/gotry 0.2.0-rc.28 → 0.2.0-rc.30

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 (44) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +9 -9
  4. package/bin/gotry-inner.js +7 -1
  5. package/cordis.gotry-patch.yml +2 -0
  6. package/data/yunnan-pack.json +30 -27
  7. package/dist/capabilities/artifacts.js +41 -4
  8. package/dist/capabilities/channel-registry.js +4 -4
  9. package/dist/capabilities/doctor.js +3 -3
  10. package/dist/capabilities/effect.js +1 -1
  11. package/dist/capabilities/session/extension-bridge.js +3 -3
  12. package/dist/capabilities/session-search.js +1 -1
  13. package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
  14. package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
  15. package/dist/scripts/bootstrap-tests.js +11 -6
  16. package/dist/scripts/doctor-tests.js +2 -2
  17. package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
  18. package/dist/scripts/issue-271-liveness-tests.js +6 -0
  19. package/dist/scripts/persona-sim-tests.js +19 -1
  20. package/dist/scripts/persona-sim.js +1 -1
  21. package/dist/scripts/session-dida-live-e2e.js +1 -1
  22. package/dist/scripts/turn-handoff-collect-tests.js +7 -1
  23. package/dist/scripts/turn-handoff-collect.js +68 -20
  24. package/dist/scripts/turn-handoff-job-tests.js +222 -0
  25. package/dist/scripts/turn-handoff-web-e2e.js +240 -0
  26. package/dist/scripts/unified-tests.js +71 -30
  27. package/dist/src/backend/modules/session-search.js +1 -1
  28. package/dist/src/index.js +30 -14
  29. package/dist/src/turn-deadline.js +113 -16
  30. package/dist/src/turn-handoff-job.js +124 -0
  31. package/extension/README.md +1 -1
  32. package/extension/README.zh-CN.md +1 -1
  33. package/extension/background.js +1 -1
  34. package/extension/content-bridge.js +1 -1
  35. package/extension/content-main.js +1 -1
  36. package/extension/manifest.json +2 -2
  37. package/package.json +2 -1
  38. package/ts/capabilities/artifacts.ts +40 -5
  39. package/ts/capabilities/session/extension-bridge.ts +3 -3
  40. package/ts/capabilities/session-search.ts +1 -1
  41. package/ts/scripts/turn-handoff-collect.ts +63 -21
  42. package/ts/src/index.ts +30 -14
  43. package/ts/src/turn-deadline.ts +111 -18
  44. package/ts/src/turn-handoff-job.ts +120 -0
package/README.md CHANGED
@@ -153,7 +153,7 @@ Trust is structural, not promised:
153
153
  ## Project Status and Roadmap
154
154
  Current boundary: deterministic planning and evidence-bound read capabilities are available; booking and real-user acceptance remain outside the shipped claim. See the [architecture](docs/architecture.md) and [roadmap](docs/roadmap.md).
155
155
 
156
- **v0.2.0-rc.27** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
156
+ **v0.2.0-rc.29** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
157
157
 
158
158
  **Working today**
159
159
 
@@ -209,7 +209,7 @@ Documents ship as bilingual pairs (`x.md` English + `x.zh-CN.md` 中文); diverg
209
209
 
210
210
  **Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
211
211
 
212
- **Version baseline: `v0.2.0-rc.27` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
212
+ **Version baseline: `v0.2.0-rc.29` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
213
213
 
214
214
  <a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
215
215
  <picture>
package/README.zh-CN.md CHANGED
@@ -155,7 +155,7 @@ npx @danceiny/gotry "我想从深圳休整两天,预算 3000" # headless 一
155
155
  ## 状态与路线图
156
156
  当前边界:确定性规划与有证据边界的只读能力已经可用;预订与真实用户验收仍不属于当前交付声明。见[架构权威文档](docs/architecture.zh-CN.md)与[路线图](docs/roadmap.zh-CN.md)。
157
157
 
158
- npm `latest`:**v0.2.0-rc.27**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
158
+ npm `latest`:**v0.2.0-rc.29**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
159
159
 
160
160
  **今天可用**
161
161
 
@@ -211,7 +211,7 @@ AI agent:[`AGENTS.md`](AGENTS.zh-CN.md) 是绑定契约——入场先清扫
211
211
 
212
212
  **Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
213
213
 
214
- **版本基线:`v0.2.0-rc.27`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
214
+ **版本基线:`v0.2.0-rc.29`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
215
215
 
216
216
  <a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
217
217
  <picture>
@@ -891,7 +891,7 @@ async function doctorChecks() {
891
891
  // 扩展
892
892
  const extManifest = join(homedir(), '.gotry', 'extension', 'manifest.json')
893
893
  const extOk = existsSync(extManifest)
894
- items.push({ label: 'GoTry Session Bridge 扩展', ok: extOk, level: extOk ? 'ok' : 'missing', detail: extOk ? `已就位(${extManifest})` : '未安装——影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响', fix: extOk ? undefined : '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd' })
894
+ items.push({ label: 'Stai Travel Bridge 扩展', ok: extOk, level: extOk ? 'ok' : 'missing', detail: extOk ? `已就位(${extManifest})` : '未安装——影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响', fix: extOk ? undefined : '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd' })
895
895
  // agent-reach(.venv 装在包内)
896
896
  const venvPython = join(repoRoot, '.venv/bin/python')
897
897
  const reachBin = join(repoRoot, '.venv/bin/agent-reach')
@@ -1164,7 +1164,7 @@ function classifyDoctorGap(item, opts = {}) {
1164
1164
  return installerEnabled('hbcli', env) && autoSupported ? 'auto' : 'unavailable'
1165
1165
  }
1166
1166
  if (label.startsWith('dsh-better-sidebar')) return installerEnabled('sidebar', env) && autoSupported ? 'auto' : 'unavailable'
1167
- if (label.startsWith('GoTry Session Bridge')) return 'user-action' // 浏览器商店一键装
1167
+ if (label.startsWith('Stai Travel Bridge')) return 'user-action' // 浏览器商店一键装
1168
1168
  if (label.startsWith('FlyAI')) return 'user-action' // 上游控制台申请 key
1169
1169
  if (label.startsWith('dsh-calendar')) return 'user-action' // profile cordis.patch.yml 配置
1170
1170
  if (label.startsWith('dsh-map-tools')) return 'unavailable' // 随包 vendor,缺失即重装 gotry
@@ -1443,7 +1443,7 @@ function copyExtensionDir(srcDir, dstDir) {
1443
1443
 
1444
1444
  /** 会话检索扩展(issue #21 方案 C):包内 extension/ → ~/.gotry/extension 幂等落位 + 一次性加载指引 */
1445
1445
  async function setupExtension() {
1446
- say('[gotry-setup] GoTry Session Bridge 扩展(会话检索数据面 issue #21;一次性安装,替代逐连接弹窗)')
1446
+ say('[gotry-setup] Stai Travel Bridge 扩展(会话检索数据面 issue #21;一次性安装,替代逐连接弹窗)')
1447
1447
  const srcDir = join(repoRoot, 'extension')
1448
1448
  const srcManifest = join(srcDir, 'manifest.json')
1449
1449
  if (!existsSync(srcManifest)) {
@@ -1465,7 +1465,7 @@ async function setupExtension() {
1465
1465
  return { ok: false }
1466
1466
  }
1467
1467
  say(` ✓ 已落位 ${dstDir}(v${srcVersion};manifest 带固定 key,unpacked 扩展 ID 恒为 olpgkofjhhiiiahdkkbcninhjmegghfe)`)
1468
- say(' 推荐(免下面三步):Chrome 应用商店一键安装 GoTry Session Bridge(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd')
1468
+ say(' 推荐(免下面三步):Chrome 应用商店一键安装 Stai Travel Bridge(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd')
1469
1469
  say(' 本地加载(每台浏览器一次,约 30 秒):Chrome 打开 chrome://extensions → 右上角开启「开发者模式」→「加载已解压的扩展程序」→ 选择 ~/.gotry/extension')
1470
1470
  say(' 获取/更新本地通道扩展可走 GitHub Releases:npx @danceiny/gotry setup --extension-from=github(自动下载校验落位;手动下载: github.com/Danceiny/gotry/releases 标签 ext-*)')
1471
1471
  say(' 装好即生效,零系统弹窗;扩展卡片开关=总闸(与 gotry 授权闸 sessionAccess 双重控制)')
@@ -1478,7 +1478,7 @@ async function setupExtension() {
1478
1478
  * 任何失败显式降级 bundled 包内副本——安装外部产物永远不挡 gotry 本体。
1479
1479
  */
1480
1480
  async function setupExtensionFromGithub() {
1481
- say('[gotry-setup] GoTry Session Bridge 扩展 · GitHub Releases 下载通道(dist-manifest → tar.gz → SHA256 → key 钉扎)')
1481
+ say('[gotry-setup] Stai Travel Bridge 扩展 · GitHub Releases 下载通道(dist-manifest → tar.gz → SHA256 → key 钉扎)')
1482
1482
  const destDir = join(homedir(), '.gotry', 'extension')
1483
1483
  const cliScript = join(repoRoot, 'ts', 'scripts', 'extension-distribution-cli.ts')
1484
1484
  const releaseBase = process.env.GOTRY_EXTENSION_RELEASE_BASE // 镜像/测试基址覆盖;缺省 GitHub 官方
@@ -1509,7 +1509,7 @@ async function setupExtensionFromGithub() {
1509
1509
  if (r && r.ok) {
1510
1510
  if (r.action === 'installed') {
1511
1511
  say(` ✓ 已从 GitHub Releases 落位 ${destDir}(v${r.version}${r.previousVersion ? `,旧 v${r.previousVersion}` : ''})`)
1512
- say(' 已装过旧版的浏览器:chrome://extensions → GoTry Session Bridge 卡片 → 「重新加载」一次即生效(新装跳过)。')
1512
+ say(' 已装过旧版的浏览器:chrome://extensions → Stai Travel Bridge 卡片 → 「重新加载」一次即生效(新装跳过)。')
1513
1513
  return { ok: true }
1514
1514
  }
1515
1515
  // up-to-date(check-only 下远端更新也会走到这里,只报告不落盘)
@@ -1728,7 +1728,7 @@ if (WIZARD) {
1728
1728
  }
1729
1729
  say('')
1730
1730
  say('[gotry-wizard] ────────────────────────────────')
1731
- say('[gotry-wizard] GoTry Session Bridge 是浏览器的事——在 Chrome 应用商店一键装:')
1731
+ say('[gotry-wizard] Stai Travel Bridge 是浏览器的事——在 Chrome 应用商店一键装:')
1732
1732
  say('[gotry-wizard] https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd')
1733
1733
  say('[gotry-wizard] 装好即生效;装完后我会自动检测到(下方探活等待,最长 120s)。')
1734
1734
  say('[gotry-wizard] 全过程 gotry 不动你的剪贴板、不开你的 Chrome、不弹任何面板。')
@@ -1749,7 +1749,7 @@ if (WIZARD) {
1749
1749
  }
1750
1750
 
1751
1751
  if (process.platform === 'win32') {
1752
- say('[gotry-setup] GoTry Session Bridge 扩展:推荐 Chrome 应用商店一键安装 https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd ;本地通道:手动把包内 extension/ 目录拷到 %USERPROFILE%\\.gotry\\extension,再在 chrome://extensions 开发者模式「加载已解压的扩展程序」')
1752
+ say('[gotry-setup] Stai Travel Bridge 扩展:推荐 Chrome 应用商店一键安装 https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd ;本地通道:手动把包内 extension/ 目录拷到 %USERPROFILE%\\.gotry\\extension,再在 chrome://extensions 开发者模式「加载已解压的扩展程序」')
1753
1753
  process.exit(AUTO ? 0 : 1)
1754
1754
  }
1755
1755
  if (AUTO && (process.env.CI || process.env.GOTRY_SETUP_SKIP === '1')) {
@@ -1760,7 +1760,7 @@ if (WIZARD) {
1760
1760
  const results = []
1761
1761
  // gotry 自留面只剩扩展是否就位;hbcli/agent-reach/dsh-better-sidebar 由各自宿主生态自管。
1762
1762
  if (process.env.GOTRY_SETUP_EXTENSION !== '0') results.push(await (EXTENSION_FROM === 'github' ? setupExtensionFromGithub() : setupExtension()))
1763
- else say('[gotry-setup] GoTry Session Bridge 扩展:GOTRY_SETUP_EXTENSION=0 跳过')
1763
+ else say('[gotry-setup] Stai Travel Bridge 扩展:GOTRY_SETUP_EXTENSION=0 跳过')
1764
1764
  const failed = results.filter((r) => !r.ok).length
1765
1765
  if (failed > 0) {
1766
1766
  say(`[gotry-setup] ${failed} 项未就绪——gotry 本体不受影响;可稍后重跑: npx @danceiny/gotry setup`)
@@ -133,7 +133,7 @@ const selectedDsh = selectDshRuntime({
133
133
  let dshBin = selectedDsh?.bin ?? ''
134
134
  const dshSource = selectedDsh?.source ?? ''
135
135
  const dshCwd = selectedDsh
136
- ? selectDshCwd({
136
+ ? process.env.GOTRY_HANDOFF_CHILD === '1' ? process.cwd() : selectDshCwd({
137
137
  repoRoot,
138
138
  invocationCwd: process.cwd(),
139
139
  sourceCheckoutMode,
@@ -430,6 +430,12 @@ if (!benchmarkEnvironmentConfig && (mode === 'web' || stdioAsk)) try {
430
430
 
431
431
  const unboundPatch = benchmarkPatchProjection ?? readFileSync(staticPatch, 'utf-8')
432
432
  let patchRaw = benchmarkPatchProjection ?? unboundPatch.replace(/(name:\s*)'[^']*ts\/src\/index\.ts'/, `$1'${pluginEntry}'`)
433
+ if (!benchmarkPatchProjection && patchRaw.includes("'placeholder/dsh-tool-present'")) {
434
+ // Native file delivery is separate from fs/write in the pinned DSH runtime.
435
+ // Resolve through DSH's declared closure (also works with strict pnpm).
436
+ const presentEntry = createRequire(dshBin).resolve('@deepseek-ai/dsh-tool-present')
437
+ patchRaw = patchRaw.replace("'placeholder/dsh-tool-present'", `'${presentEntry}'`)
438
+ }
433
439
  patchRaw = patchRaw.replace(/^\s*# \{ask-user-insert\}.*\n(\s*# .*\n)?/m, askUserInsert ? askUserInsert + '\n' : '')
434
440
 
435
441
  // Owner-local benchmark environment opt-in. Keep the path out of tool input
@@ -10,6 +10,8 @@
10
10
  stateRoot: '.'
11
11
  timeoutMs: 30000
12
12
  hbcliBin: 'hbcli'
13
+ - id: gotry-present
14
+ name: 'placeholder/dsh-tool-present'
13
15
  - id: dsh-map-tools
14
16
  name: 'placeholder/dsh-map-tools'
15
17
  - id: dsh-calendar
@@ -29,33 +29,6 @@
29
29
  ]
30
30
  },
31
31
  "legs": [
32
- {
33
- "id": "yn0",
34
- "note": "昆明→丽江(衔接 8.4 FD582 KMG 11:25 落地后,下午转飞丽江;M-1 工作窗口外)",
35
- "date": "8.4 下午",
36
- "services": [
37
- {
38
- "id": "MU5819",
39
- "dep": "14:30",
40
- "arr": "15:35",
41
- "price_cny": 520,
42
- "price_note": "东航,1h5m 直飞"
43
- },
44
- {
45
- "id": "8L9662",
46
- "dep": "16:40",
47
- "arr": "17:45",
48
- "price_cny": 480,
49
- "price_note": "祥鹏,1h5m 直飞"
50
- }
51
- ],
52
- "evidence": [
53
- {
54
- "tag": "[demo-reconcil:已]",
55
- "note": "8.4 FD582 → 11:25 KMG → 当天转飞丽江(已挖)"
56
- }
57
- ]
58
- },
59
32
  {
60
33
  "id": "yn1",
61
34
  "note": "深圳→丽江(直飞,丽江进大理出的开口程第一段)",
@@ -153,6 +126,36 @@
153
126
  "weekday": "any"
154
127
  }
155
128
  ],
129
+ "advisory_legs": [
130
+ {
131
+ "id": "yn0",
132
+ "advisory_reason": "本段缺 buffer_min / origin_transfer_min / dest_transfer_min 三个真值,待 founder 校准(flights_2026.json 的 f3 evidence 早已写明:yn0 衔接仍写,引擎层不挂接)。替它填数等于编造真实世界的事实(ADR-10 翻译不造数),所以只记录、不挂接引擎;三个整数补齐后把整段搬回 legs 即可参与求解(issue #635)。",
133
+ "note": "昆明→丽江(衔接 8.4 FD582 KMG 11:25 落地后,下午转飞丽江;M-1 工作窗口外)",
134
+ "date": "8.4 下午",
135
+ "services": [
136
+ {
137
+ "id": "MU5819",
138
+ "dep": "14:30",
139
+ "arr": "15:35",
140
+ "price_cny": 520,
141
+ "price_note": "东航,1h5m 直飞"
142
+ },
143
+ {
144
+ "id": "8L9662",
145
+ "dep": "16:40",
146
+ "arr": "17:45",
147
+ "price_cny": 480,
148
+ "price_note": "祥鹏,1h5m 直飞"
149
+ }
150
+ ],
151
+ "evidence": [
152
+ {
153
+ "tag": "[demo-reconcil:已]",
154
+ "note": "8.4 FD582 → 11:25 KMG → 当天转飞丽江(已挖)"
155
+ }
156
+ ]
157
+ }
158
+ ],
156
159
  "stays": [
157
160
  {
158
161
  "id": "yns1",
@@ -215,6 +215,32 @@ async function listCwdArtifacts(cwd) {
215
215
  }
216
216
  return entries;
217
217
  }
218
+ /** Durable handoff outputs live outside async/, including honest failure notes. */
219
+ async function listHandoffArtifacts(root) {
220
+ const dir = join(root, 'gotry-state', 'turn-handoffs');
221
+ const names = await readdir(dir).catch(() => []);
222
+ const entries = [];
223
+ for (const name of names) {
224
+ if (!name.endsWith('.json'))
225
+ continue;
226
+ try {
227
+ const ticket = JSON.parse(await readFile(join(dir, name), 'utf8'));
228
+ if (ticket.schema !== 'gotry_turn_handoff.v1' || name !== `${ticket.id}.json`
229
+ || !['settled', 'failed'].includes(ticket.status) || ticket.deliverableFile !== `${ticket.id}.deliverable.md`)
230
+ continue;
231
+ const path = await realpath(join(dir, ticket.deliverableFile));
232
+ if (!underRoot(path, root) || hasDeniedSegment(path))
233
+ continue;
234
+ const info = await stat(path);
235
+ if (!info.isFile())
236
+ continue;
237
+ entries.push({ source: 'turn-handoff', id: ticket.id, title: String(ticket.objective ?? ticket.id),
238
+ path, status: ticket.status, updated: new Date(info.mtimeMs).toISOString(), bytes: info.size });
239
+ }
240
+ catch { /* A corrupt or missing output cannot be advertised as a file. */ }
241
+ }
242
+ return entries;
243
+ }
218
244
  export async function listArtifacts(opts) {
219
245
  const limit = clampLimit(opts.limit);
220
246
  const offset = normalizeOffset(opts.offset, 'offset');
@@ -223,12 +249,16 @@ export async function listArtifacts(opts) {
223
249
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
224
250
  const canonicalRoot = await realpath(root).catch(() => root);
225
251
  const canonicalCwd = await realpath(cwd).catch(() => cwd);
252
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot);
253
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot);
226
254
  // 1) collect all eligible entries per source, without per-source limits —
227
255
  // pageSize is applied to the merged/deduped/sorted set so older cwd files
228
256
  // stay reachable across pages.
229
257
  const collected = [
230
258
  ...listRunsFromLedger(canonicalRoot, 'local'),
231
259
  ...(await listDeliverableFiles(canonicalRoot)),
260
+ ...(await listHandoffArtifacts(canonicalRoot)),
261
+ ...(canonicalHandoffRoot !== canonicalRoot ? await listHandoffArtifacts(canonicalHandoffRoot) : []),
232
262
  ...(await listCwdArtifacts(canonicalCwd)),
233
263
  ];
234
264
  // 2) canonical-path dedupe (keep ledger authority when both sources point
@@ -262,6 +292,8 @@ export async function listArtifacts(opts) {
262
292
  offset,
263
293
  roots: [canonicalRoot, canonicalCwd],
264
294
  };
295
+ if (!result.roots.includes(canonicalHandoffRoot))
296
+ result.roots.push(canonicalHandoffRoot);
265
297
  if (hasMore)
266
298
  result.nextOffset = start + page.length;
267
299
  if (needle !== '')
@@ -280,6 +312,8 @@ export async function readArtifact(opts) {
280
312
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
281
313
  const canonicalRoot = await realpath(root).catch(() => root);
282
314
  const canonicalCwd = await realpath(cwd).catch(() => cwd);
315
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot);
316
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot);
283
317
  const raw = String(opts.path ?? '').trim();
284
318
  if (!raw)
285
319
  return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' };
@@ -296,10 +330,13 @@ export async function readArtifact(opts) {
296
330
  filePath = asyncDeliverablePath(root, raw);
297
331
  }
298
332
  else {
299
- const p = asyncDeliverablePath(root, raw);
333
+ const handoff = /^th-[a-z0-9-]+$/i.test(raw)
334
+ ? [join(handoffRoot, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`),
335
+ join(root, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`)] : [];
336
+ const p = handoff.find(path => existsSync(path)) ?? asyncDeliverablePath(root, raw);
300
337
  const canonical = await realpath(p).catch(() => null);
301
338
  if (canonical) {
302
- if (!underRoot(canonical, canonicalRoot) || hasDeniedSegment(canonical)) {
339
+ if (!(underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
303
340
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` };
304
341
  }
305
342
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase();
@@ -325,7 +362,7 @@ export async function readArtifact(opts) {
325
362
  // 路径(realpath 后的 cwd),而 macOS 上 /var 与 /private/var 这类符号链接会让
326
363
  // canonical 路径不匹配字面 cwd,导致 list→read 串联断裂。边界权威仍是下方
327
364
  // realpath 后的 canonical 复检,这里放宽不改变可读集合。
328
- const inScope = (p) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot)) && !hasDeniedSegment(p);
365
+ const inScope = (p) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot) || underRoot(p, handoffRoot) || underRoot(p, canonicalHandoffRoot)) && !hasDeniedSegment(p);
329
366
  // Prefer an existing candidate so a cwd miss does not mask a valid state-root file.
330
367
  const allowed = candidates.find(p => inScope(p) && existsSync(p)) ?? candidates.find(inScope);
331
368
  if (!allowed) {
@@ -335,7 +372,7 @@ export async function readArtifact(opts) {
335
372
  if (!canonical) {
336
373
  return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' };
337
374
  }
338
- if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot)) || hasDeniedSegment(canonical)) {
375
+ if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
339
376
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` };
340
377
  }
341
378
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase();
@@ -52,7 +52,7 @@ export const CHANNELS = [
52
52
  evidenceTag: '[会话:ctrip-flight@ts]',
53
53
  tier: 'session',
54
54
  efficiencyRank: 2,
55
- setup: '一次性装 Session Bridge 扩展 + 授权卡 + (如需)官网登录',
55
+ setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + (如需)官网登录',
56
56
  hint: 'kind=flight',
57
57
  },
58
58
  {
@@ -64,7 +64,7 @@ export const CHANNELS = [
64
64
  evidenceTag: '[会话:ctrip-hotel@ts]',
65
65
  tier: 'session',
66
66
  efficiencyRank: 2,
67
- setup: '一次性装 Session Bridge 扩展 + 授权卡 + (如需)官网登录',
67
+ setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + (如需)官网登录',
68
68
  hint: 'kind=hotel',
69
69
  },
70
70
  {
@@ -76,7 +76,7 @@ export const CHANNELS = [
76
76
  evidenceTag: '[会话:dida-portal@ts]',
77
77
  tier: 'session',
78
78
  efficiencyRank: 2,
79
- setup: '一次性装 Session Bridge 扩展 + 授权卡 + hotel-be portal 自动登录跳板(dida 官网由人完成登录)',
79
+ setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + hotel-be portal 自动登录跳板(dida 官网由人完成登录)',
80
80
  hint: 'kind=dida',
81
81
  },
82
82
  {
@@ -88,7 +88,7 @@ export const CHANNELS = [
88
88
  evidenceTag: '[会话:train-12306@ts]',
89
89
  tier: 'session',
90
90
  efficiencyRank: 2,
91
- setup: '一次性装 Session Bridge 扩展(公开查询面,无需登录)',
91
+ setup: '一次性装 Stai Travel Bridge 扩展(公开查询面,无需登录)',
92
92
  hint: 'kind=train',
93
93
  },
94
94
  {
@@ -124,14 +124,14 @@ export async function runDoctorChecks(opts = {}) {
124
124
  items.push(nodeOk(nodeVersion)
125
125
  ? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
126
126
  : { id: 'node', label: 'Node 运行时', status: 'missing', detail: `Node ${nodeVersion} 过旧(gotry 需 ≥22.15)`, fix: '升级 Node.js 至 22.15+(https://nodejs.org)' });
127
- // 1. GoTry Session Bridge 扩展(账号会话通道的传输层;装在哪由 bootstrap setup 管理)
127
+ // 1. Stai Travel Bridge 扩展(账号会话通道的传输层;装在哪由 bootstrap setup 管理)
128
128
  // D-24 自适应(#117):离线只能探测本地 unpacked 通道;商店版无本地文件面——
129
129
  // missing detail 明示「商店版用户可忽略本项」,不再把商店版用户误导成本地通道缺失
130
130
  const extManifest = join(home, '.gotry', 'extension', 'manifest.json');
131
131
  items.push(existsSync(extManifest)
132
- ? { id: 'extension', label: 'GoTry Session Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
132
+ ? { id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
133
133
  : {
134
- id: 'extension', label: 'GoTry Session Bridge 扩展', status: 'missing',
134
+ id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing',
135
135
  detail: '本地通道未落位(~/.gotry/extension/manifest.json 不存在)。影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。若你已从 Chrome 商店安装(自动更新,商店版 ID oeajpicc…),本项可忽略——会话检索可用性以运行时为准(gotry_session_search 的 verdict)。若未安装:应用商店一键装即可',
136
136
  fix: '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd',
137
137
  });
@@ -9,7 +9,7 @@
9
9
  * 断路器,per-效应策略表)→ 渠道 handler(现能力层函数,零改写);
10
10
  * - mock 解译器 makeMockInterpreter:夹具回放(CI/本地无网确定性);
11
11
  * - 浏览器解译 = SESSION_* 效应(用户本人登录态的扩展桥车道,2026-08-29 起默认传输:
12
- * MV3 GoTry Session Bridge + 本地桥长轮询,零系统弹窗;ReadGuard+授权闸不变,
12
+ * MV3 Stai Travel Bridge + 本地桥长轮询,零系统弹窗;ReadGuard+授权闸不变,
13
13
  * 非 CUA 视觉点击——本仓已按零 Python 依赖与 a11y/DOM 优先判死后者,
14
14
  * docs/design/effect-interpreter.md §4)。
15
15
  *
@@ -58,17 +58,17 @@ export function localExtensionInstalled(home = homedir()) {
58
58
  * 影响面统一行:账号会话通道断 → 列表底部工具不提供;其余工具不受影响。
59
59
  * 纯函数(默认 HOME 可注入)。
60
60
  *
61
- * 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「Session Bridge 安装」段。
61
+ * 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「Stai Travel Bridge 安装」段。
62
62
  */
63
63
  const EXTENSION_IMPACT_LINE = '影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。';
64
64
  export function needsExtensionSummary(opts = {}) {
65
65
  const local = opts.localInstalled ?? localExtensionInstalled(opts.home);
66
66
  if (local) {
67
- return 'GoTry Session Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
67
+ return 'Stai Travel Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
68
68
  + `${EXTENSION_STORE_URL} ;或 npx @danceiny/gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension。`
69
69
  + EXTENSION_IMPACT_LINE;
70
70
  }
71
- return 'GoTry Session Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
71
+ return 'Stai Travel Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
72
72
  + `若尚未安装:应用商店一键装 ${EXTENSION_STORE_URL} (装完零弹窗,浏览器自己当安装器)。`
73
73
  + EXTENSION_IMPACT_LINE;
74
74
  }
@@ -2,7 +2,7 @@
2
2
  * 会话检索编排层(RFC §3.2):transport → adapter entry → networkHint 嗅探 → 解析 → ReadGuard → 证据链。
3
3
  *
4
4
  * 传输车道(2026-08-29 定案,RFC §2.2):**扩展桥为默认**——一次性安装的 MV3 扩展
5
- * (GoTry Session Bridge)在自己标签页被动嗅探 batchSearch,零 Chrome 系统弹窗;
5
+ * (Stai Travel Bridge)在自己标签页被动嗅探 batchSearch,零 Chrome 系统弹窗;
6
6
  * cdp(attach 日常 Chrome,Chrome 144+ 每连接弹权限框)降为显式后备
7
7
  * (`GOTRY_SESSION_TRANSPORT=cdp` opt-in,诊断/测试用);persistent 仅测试。
8
8
  *
@@ -206,6 +206,8 @@ async function main() {
206
206
  cwd: TS_DIR,
207
207
  env: {
208
208
  ...process.env,
209
+ HOME: dshHome,
210
+ DSH_HOME: dshHome,
209
211
  GOTRY_HANDOFF_PLANNER_BIN: configuredBin,
210
212
  LLM_API_KEY: 'synthetic-e2e-key',
211
213
  LLM_BASE_URL: 'http://127.0.0.1:' + relay.port + '/v1',
@@ -1,7 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { Context } from '@deepseek-ai/cordis';
6
+ import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools';
7
+ import { createScope } from '@deepseek-ai/dsh-scope';
5
8
  import { installTurnDeadline, TURN_DEADLINE_EXHAUSTED, TURN_DEADLINE_HANDOFF, TURN_HANDOFF_SCHEMA, writeTurnHandoffTicket, } from '../src/turn-deadline.js';
6
9
  const success = () => ({ isError: false, value: null, content: [] });
7
10
  const handlers = new Map();
@@ -129,7 +132,7 @@ const turnEvent = (id, type) => sessionEvent({ id }, { type });
129
132
  const text = result.content[0]?.text ?? '';
130
133
  assert.match(text, /TURN_DEADLINE_HANDOFF/);
131
134
  assert.match(text, /th-/, 'result names the persisted ticket id');
132
- assert.match(text, /约 1 小时/, 'ETA label mirrors the S5 promise');
135
+ assert.match(text, /queued|not started/i, 'a persisted ticket alone must not claim a running worker');
133
136
  }
134
137
  // 同步抑制已生效
135
138
  assert.deepEqual(restricted.at(-1), { agentId: agent.id, deny: ['gotry_alpha', 'gotry_beta'] });
@@ -181,5 +184,135 @@ function deepPlanningNoise() {
181
184
  rmSync(root, { recursive: true, force: true });
182
185
  }
183
186
  sessionDisposed({ id: 'agent-handoff' });
187
+ // A deadline must let the pending output write complete and keep native present
188
+ // available. Start exactly one tracked job even if the model makes another call.
189
+ {
190
+ const hooks = new Map();
191
+ let started = 0;
192
+ let denied = [];
193
+ const agent = { id: 'delivery',
194
+ session: { id: 'delivery', header: { cwd: handoffRoot }, snapshotEvents: () => [
195
+ { type: 'tool/call', data: { name: 'ask_user_question', callId: 'question' } },
196
+ { type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'question' }, content: [{ text: 'China working hours 9-18 CST' }] } } },
197
+ { type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'other' }, content: [{ text: 'large evidence '.repeat(3000) }] } } },
198
+ ] },
199
+ ctx: { tools: { restrict: (filter) => { denied = filter.deny; return () => { }; } } } };
200
+ const deliveryCtx = {
201
+ tools: { schemas: () => [{ name: 'write' }, { name: 'present' }, { name: 'search' }] },
202
+ on: (name, hook) => { hooks.set(name, hook); },
203
+ };
204
+ installTurnDeadline(deliveryCtx, {
205
+ stateRoot: handoffRoot, now: clock.now,
206
+ startHandoff: async (ticket) => {
207
+ assert.equal(written, true, 'output finishes before the worker starts');
208
+ assert.ok(ticket.context?.includes('9-18 CST'), 'retain early clarification answers despite large recent evidence');
209
+ started++;
210
+ return { ...ticket, status: 'running', jobId: 'gotry-planning-1' };
211
+ },
212
+ });
213
+ hooks.get('session/event')({ id: agent.id }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: deepMessageForDelivery() }] } });
214
+ clock.advance(240_001);
215
+ let written = false;
216
+ const draft = join(handoffRoot, 'exploration.md');
217
+ const pending = { token: Symbol(), name: 'write', agent };
218
+ const result = await hooks.get('tools/execute')(pending, async () => { writeFileSync(draft, '# Exploration'); written = true; return success(); });
219
+ const post = await hooks.get('tools/post-execute')(pending, result, async () => ({ kind: 'accept' }));
220
+ assert.equal(written, true, 'pending output write must really execute');
221
+ assert.equal(readFileSync(draft, 'utf8'), '# Exploration');
222
+ assert.equal(result.isError, false, 'write result must remain truthful');
223
+ assert.ok(!denied.includes('present'), 'native deliverable publication stays available');
224
+ assert.equal(started, 1, 'handoff starts a tracked job');
225
+ await hooks.get('tools/execute')({ token: Symbol(), name: 'present', agent }, async () => success());
226
+ assert.equal(started, 1, 'present must not queue another planner');
227
+ const text = JSON.stringify(post.additionalContexts);
228
+ assert.match(text, /gotry-planning-1/, 'handoff names the actual native job');
229
+ assert.match(text, /present/, 'successful writes must be presented before final reply');
230
+ let repeatedWrite = false;
231
+ await hooks.get('tools/execute')({ token: Symbol(), name: 'write', agent }, async () => { repeatedWrite = true; return success(); });
232
+ assert.equal(repeatedWrite, false, 'only the pending draft write gets the deadline exception');
233
+ assert.equal(started, 1, 'repeated finalization must not dispatch another worker');
234
+ }
235
+ function deepMessageForDelivery() { return '12月初有个阿联酋国庆,我打算借机 IRW远程办公(还有5天额度)+请假+放假 回国玩一趟,主要去东北,比如长春泡澡啊啥的,可以看看一些东北风光啥的(没去过),我女朋友从南京过去,请帮我探索一个可行的具体规划'; }
184
236
  rmSync(handoffRoot, { recursive: true, force: true });
237
+ // Real DSH registry coverage: inherited and agent-scoped output writes must
238
+ // finish before deadline restrictions hide inherited registrations.
239
+ for (const [registration, writeFailure] of [['global', false], ['scoped', false], ['global', true], ['scoped', true]]) {
240
+ const root = mkdtempSync(join(tmpdir(), 'gotry-write-registry-'));
241
+ const runtimeCtx = new Context();
242
+ runtimeCtx.provide('systemPrompt', { tools() { } });
243
+ const tools = new ToolRuntime(runtimeCtx);
244
+ let time = 0;
245
+ const mutableAgent = { id: registration, ctx: runtimeCtx, session: { id: registration, header: { cwd: root }, snapshotEvents: () => [] } };
246
+ const agent = mutableAgent;
247
+ const scope = createScope(runtimeCtx, agent);
248
+ mutableAgent.ctx = scope.ctx;
249
+ installTurnDeadline(runtimeCtx, { stateRoot: root, now: () => time, fixedPolicy: { softMs: 5, hardMs: 10, exit: 'handoff' } });
250
+ const draft = join(root, 'draft.md');
251
+ let writes = 0;
252
+ (registration === 'global' ? tools : scope.ctx.tools).register(defineTool({
253
+ name: 'write', description: 'Write the draft', parameters: {}, output: { schema: { type: 'json' }, render: () => [{ type: 'text', text: 'saved' }] },
254
+ async execute() { writes++; if (writeFailure)
255
+ throw new Error('disk write failed'); writeFileSync(draft, 'saved draft'); return { saved: true }; },
256
+ }));
257
+ try {
258
+ assert.ok(tools.get('write', agent), 'write is visible before deadline dispatch');
259
+ runtimeCtx.emit('session/event', agent.session, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'plan my trip' }] } });
260
+ time = 11;
261
+ const call = () => tools.execute({ name: 'write', agent, arguments: {}, callId: 'draft', signal: new AbortController().signal });
262
+ const result = await call();
263
+ assert.equal(result.isError, writeFailure, `${registration} pending write keeps its actual registry outcome: ${JSON.stringify(result)}`);
264
+ if (writeFailure) {
265
+ assert.equal(existsSync(draft), false);
266
+ assert.match(JSON.stringify(result), /disk write failed/);
267
+ }
268
+ else
269
+ assert.equal(readFileSync(draft, 'utf8'), 'saved draft');
270
+ assert.match(JSON.stringify(result.additionalContexts), /queued/, 'registry post-execute carries handoff guidance');
271
+ await call();
272
+ assert.equal(writes, 1, 'only the pending write receives the exception');
273
+ }
274
+ finally {
275
+ await scope.dispose();
276
+ await runtimeCtx.fiber.dispose();
277
+ rmSync(root, { recursive: true, force: true });
278
+ }
279
+ }
280
+ // Long earlier prose and recent large search output cannot evict a mandatory
281
+ // answer. If user input itself exceeds the bounded snapshot, fail visibly.
282
+ for (const overflow of [false, true]) {
283
+ const root = mkdtempSync(join(tmpdir(), 'gotry-handoff-context-'));
284
+ const hooks = new Map();
285
+ let captured;
286
+ const agent = { id: 'constraints', ctx: { tools: { restrict: () => () => { } } }, session: {
287
+ id: 'constraints', header: { cwd: root }, snapshotEvents: () => [
288
+ { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'earlier prose '.repeat(overflow ? 6000 : 600) }] } },
289
+ { type: 'tool/call', data: { name: 'ask_user_question', callId: 'access' } },
290
+ { type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'access' }, content: [{ type: 'text', text: 'MUST_KEEP_WHEELCHAIR_ACCESS_AND_NO_STAIRS' }] } } },
291
+ { type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'search' }, content: [{ type: 'text', text: 'recent search '.repeat(2000) }] } } },
292
+ ],
293
+ } };
294
+ const localCtx = { tools: { schemas: () => [] }, on: (name, hook) => hooks.set(name, hook) };
295
+ let time = 0;
296
+ installTurnDeadline(localCtx, { stateRoot: root, now: () => time, fixedPolicy: { softMs: 5, hardMs: 10, exit: 'handoff' },
297
+ startHandoff: async (ticket) => { captured = ticket; return ticket; } });
298
+ try {
299
+ hooks.get('session/event')({ id: agent.id }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'plan my trip' }] } });
300
+ time = 11;
301
+ await hooks.get('tools/execute')({ token: Symbol(), name: 'search', agent }, async () => success());
302
+ if (overflow) {
303
+ assert.equal(captured, undefined, 'do not start planning after silently losing user constraints');
304
+ const ticketFile = readdirSync(join(root, 'gotry-state', 'turn-handoffs')).find(name => name.endsWith('.json'));
305
+ const ticket = JSON.parse(readFileSync(join(root, 'gotry-state', 'turn-handoffs', ticketFile), 'utf8'));
306
+ assert.equal(ticket.status, 'failed');
307
+ assert.match(ticket.error, /context|answers|constraints/i);
308
+ }
309
+ else {
310
+ assert.ok(captured?.context?.includes('MUST_KEEP_WHEELCHAIR_ACCESS_AND_NO_STAIRS'), 'mandatory answer survives large surrounding output');
311
+ assert.ok(captured.context.length < 90_000, 'context remains bounded');
312
+ }
313
+ }
314
+ finally {
315
+ rmSync(root, { recursive: true, force: true });
316
+ }
317
+ }
185
318
  console.log('agent turn deadline tests: OK (routing→converge/handoff exits, ticket persistence, plugin-message discipline, lifecycle)');