@danceiny/gotry 0.0.1-rc.12 → 0.0.1-rc.14

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.
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { writeFile, mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { flyaiSearch } from '../capabilities/flyai.js';
6
+ const tmp = await mkdtemp(join(tmpdir(), 'flyai-test-'));
7
+ async function fakeCli(name, code, payload) {
8
+ const p = join(tmp, name);
9
+ await writeFile(p, `#!/bin/sh\necho '${payload}'\nexit ${code}\n`, {
10
+ mode: 0o755
11
+ });
12
+ return p;
13
+ }
14
+ const base = {
15
+ kind: 'flight',
16
+ origin: '上海',
17
+ destination: '丽江',
18
+ depDate: '2026-10-01'
19
+ };
20
+ const sentinelBin = await fakeCli('flyai-sentinel', 0, '{"message":"SentinelBlockException: flow control"}');
21
+ const s = await flyaiSearch({
22
+ ...base,
23
+ cliBin: sentinelBin,
24
+ timeoutMs: 5000
25
+ });
26
+ assert.equal(s.ok, false, 'Sentinel 形状应 ok=false');
27
+ assert.equal(s.verdict, 'error', `Sentinel 形状应判 error,实际 ${s.verdict}`);
28
+ assert.match(s.error ?? '', /sentinel/i, `error 应保留 sentinel 字样(供上层限流识别),实际 ${s.error}`);
29
+ assert.match(s.evidence, /\[实时API:flyai@error@/, 'error 证据链标注');
30
+ console.log('1. Sentinel 非业务形状 → error(非静默 miss)OK');
31
+ const missBin = await fakeCli('flyai-miss', 0, '{"data":{"itemList":[]}}');
32
+ const m = await flyaiSearch({
33
+ ...base,
34
+ cliBin: missBin,
35
+ timeoutMs: 5000
36
+ });
37
+ assert.equal(m.ok, true, '业务空形状 ok=true');
38
+ assert.equal(m.verdict, 'miss', `空 itemList 应判 miss,实际 ${m.verdict}`);
39
+ assert.match(m.evidence, /0\/0 flight options/, 'miss 证据链 0/0');
40
+ console.log('2. 业务空形状 → miss(0/0)OK');
41
+ const hitPayload = JSON.stringify({
42
+ data: {
43
+ itemList: [
44
+ {
45
+ journeys: [
46
+ {
47
+ segments: [
48
+ {
49
+ marketingTransportNo: '9C6617',
50
+ marketingTransportName: '吉祥航空',
51
+ depDateTime: '2026-10-01 07:55',
52
+ arrDateTime: '2026-10-01 11:20',
53
+ depStationName: '浦东T2',
54
+ arrStationName: '丽江三义',
55
+ duration: 205
56
+ }
57
+ ]
58
+ }
59
+ ],
60
+ ticketPrice: '580',
61
+ jumpUrl: 'https://www.fliggy.com/demo'
62
+ }
63
+ ]
64
+ }
65
+ });
66
+ const hitBin = await fakeCli('flyai-hit', 0, hitPayload);
67
+ const h = await flyaiSearch({
68
+ ...base,
69
+ cliBin: hitBin,
70
+ timeoutMs: 5000
71
+ });
72
+ assert.equal(h.verdict, 'hit', `业务条目应判 hit,实际 ${h.verdict}`);
73
+ assert.equal(h.options?.length, 1, '1 个选项');
74
+ assert.equal(h.options[0].no, '9C6617');
75
+ assert.equal(h.options[0].price, 580, '价格数值解析');
76
+ assert.equal(h.options[0].depStation, '浦东T2');
77
+ assert.match(h.evidence, /1\/1 flight options/, 'hit 证据链 1/1');
78
+ console.log('3. 业务命中 → hit(9C6617 ¥580)OK');
79
+ const failBin = await fakeCli('flyai-fail', 1, '');
80
+ const f = await flyaiSearch({
81
+ ...base,
82
+ cliBin: failBin,
83
+ timeoutMs: 5000
84
+ });
85
+ assert.equal(f.ok, false);
86
+ assert.equal(f.verdict, 'error', '非零退出应判 error');
87
+ console.log('4. 非零退出 → error OK');
88
+ await rm(tmp, {
89
+ recursive: true,
90
+ force: true
91
+ });
92
+ console.log('FLYAI TESTS: 4/4 OK(离线假 CLI:Sentinel→error / 空 itemList→miss / 命中→hit / exit≠0→error)');
93
+
94
+
95
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/flyai-tests.ts
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { writeFile, mkdtemp, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { callHbcliJson, searchHotels } from '../capabilities/hbcli.js';
5
+ import { callHbcliJson, searchHotels, hbcliBinCandidates } from '../capabilities/hbcli.js';
6
6
  const tmp = await mkdtemp(join(tmpdir(), 'hbcli-test-'));
7
7
  async function fakeBin(name, code, payload) {
8
8
  const p = join(tmp, name);
@@ -112,11 +112,36 @@ const r5 = await searchHotels({
112
112
  });
113
113
  assert.equal(r5.hotels ?? null, null, '无目的地命中时 hotels 为 null(不整包倾倒)');
114
114
  assert.match(r5.summary, /无「巴黎」住宿数据/, 'summary 明示静态包无该目的地');
115
- await rm(tmp, {
115
+ const r6 = await searchHotels({
116
+ destination: '普吉'
117
+ }, {
118
+ hbcliBin: '/nope/hbcli',
119
+ fallbackPath: fallback
120
+ });
121
+ assert.equal(r6.via, 'hbcli-error', 'no-binary path');
122
+ assert.ok(r6.summary.includes('未安装 hbcli'), `summary 应人话化 ENOENT,实际 ${r6.summary}`);
123
+ assert.ok(r6.summary.includes('gotry setup'), '应带 gotry setup 安装指引');
124
+ assert.ok(r6.summary.includes('降级到静态包'), '人话化后仍指明降级到静态包');
125
+ console.log(`6. ENOENT 人话化 OK:${r6.summary}`);
126
+ {
127
+ const home = '/home/t';
128
+ assert.deepEqual(hbcliBinCandidates('hbcli', home), [
129
+ 'hbcli',
130
+ '/home/t/.local/bin/hbcli',
131
+ '/home/t/.staicli/current/hbcli'
132
+ ], '默认名应带安装位候选');
133
+ assert.deepEqual(hbcliBinCandidates('hbcli-not-on-path', home), [
134
+ 'hbcli-not-on-path'
135
+ ], '自定义名单候选(测试确定性)');
136
+ assert.deepEqual(hbcliBinCandidates('/nope/hbcli', home), [
137
+ '/nope/hbcli'
138
+ ], '绝对路径单候选');
139
+ console.log('7. hbcliBinCandidates(~/.local/bin + ~/.staicli/current 回退)OK');
140
+ }await rm(tmp, {
116
141
  recursive: true,
117
142
  force: true
118
143
  });
119
- console.log('HBCLI TESTS: 6/6 OK (happy / error / no-binary / fallback-filter / fallback-no-match / v0.3.0 旗标回归)');
144
+ console.log('HBCLI TESTS: 8/8 OK (happy / error / no-binary / fallback-filter / fallback-no-match / v0.3.0 旗标回归 / ENOENT 人话化 / 候选路径)');
120
145
 
121
146
 
122
147
  //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/hbcli-tests.ts
@@ -387,6 +387,8 @@ console.log('H. flyaiSearch hotel(飞猪官方 search-hotel)');
387
387
  });
388
388
  if (lr.verdict === 'needs-attach') {
389
389
  assert(lr.ok === false && /chrome:\/\/inspect/.test(lr.error ?? ''), 'live:Chrome 未开调试 → needs-attach + 一次性指引', lr);
390
+ } else if (lr.verdict === 'logged-in') {
391
+ assert(lr.ok === true && (lr.tickets?.length ?? 0) > 0, 'live:已登录自动检测 → logged-in(零弹窗,票据名级)', lr);
390
392
  } else {
391
393
  assert(lr.ok === true && lr.verdict === 'pending', 'live:attach 成功不交互 → pending(入口已开,等人登录)', lr);
392
394
  const evidenceTagRe = /\[会话:ctrip-flight-login@/;
package/dist/src/index.js CHANGED
@@ -585,10 +585,14 @@ export function apply(ctx, config) {
585
585
  }),
586
586
  presentResult: (_args, value)=>{
587
587
  const r = value;
588
- const n = Array.isArray(r.hotels) ? r.hotels.length : 0;
588
+ const h = r.hotels;
589
+ const liveCount = Array.isArray(h) ? h.length : 0;
590
+ const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
591
+ const staticCount = Array.isArray(stays) ? stays.length : 0;
592
+ const tag = r.via === 'hbcli-realtime' ? liveCount ? `实时 ${liveCount} 家` : '实时' : staticCount ? `静态包 ${staticCount} 块` : liveCount ? `${liveCount} 家` : '无结果';
589
593
  return {
590
594
  card: 'generic',
591
- title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
595
+ title: `酒店:${r.destination ?? ''} ${tag}`,
592
596
  content: [
593
597
  {
594
598
  type: 'text',
@@ -875,7 +879,8 @@ export function apply(ctx, config) {
875
879
  depDate: q.date
876
880
  });
877
881
  const top = (r.options ?? []).slice(0, 8).map((o)=>`${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
878
- const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
882
+ const label = kind === 'flight' ? '机票' : '火车票';
883
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}` : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`;
879
884
  return JSON.parse(JSON.stringify({
880
885
  ...r,
881
886
  kind,
@@ -906,7 +911,7 @@ export function apply(ctx, config) {
906
911
  }));
907
912
  registerGuarded(defineTool({
908
913
  name: 'gotry_session_login',
909
- description: 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. ' + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). ' + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). ' + 'Evidence [会话:<site>-login@ts].',
914
+ description: 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. ' + 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. ' + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). ' + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). ' + 'Evidence [会话:<site>-login@ts].',
910
915
  parameters: {
911
916
  query: {
912
917
  type: 'json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.12",
3
+ "version": "0.0.1-rc.14",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -47,6 +47,7 @@
47
47
  "ts/package.json",
48
48
  "cordis.gotry-patch.yml",
49
49
  "README.md",
50
+ "README.zh-CN.md",
50
51
  "LICENSE",
51
52
  "ts/capabilities/session-consent.ts",
52
53
  "ts/capabilities/session-login.ts"
@@ -13,9 +13,11 @@
13
13
  */
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
16
18
 
17
19
  export interface HbcliCallOptions {
18
- /** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
20
+ /** hbcli 二进制路径(默认 'hbcli',依赖 PATH;~/.local/bin 等已知安装位自动回退) */
19
21
  hbcliBin?: string
20
22
  /** 超时(ms) */
21
23
  timeoutMs?: number
@@ -43,23 +45,29 @@ export interface HbcliCallResult {
43
45
  error?: string
44
46
  }
45
47
 
46
- /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
47
- export async function callHbcliJson(
48
+ /**
49
+ * hbcli 二进制候选路径(gotry setup 按官方脚本装到 ~/.local/bin/hbcli,
50
+ * symlink 指向 ~/.staicli/current/hbcli——当 PATH 不含 ~/.local/bin 时裸名
51
+ * spawn 仍 ENOENT,按已知安装位回退)。仅对默认名 'hbcli' 扩展;显式自定义
52
+ * 名(如测试注入的不存在路径)不扩展,保持配置即所用的可测性。
53
+ */
54
+ export function hbcliBinCandidates(bin: string, homeDir: string = homedir()): string[] {
55
+ if (bin !== 'hbcli') return [bin]
56
+ return [bin, join(homeDir, '.local/bin/hbcli'), join(homeDir, '.staicli/current/hbcli')]
57
+ }
58
+
59
+ /** 单个候选的一次 spawn 封装:失败不抛,返回降级结果(spawnError 标记 ENOENT 类失败供上层换候选) */
60
+ function attemptHbcli(
61
+ bin: string,
48
62
  args: string[],
49
- opts: HbcliCallOptions = {},
50
- ): Promise<HbcliCallResult> {
63
+ opts: Required<Pick<HbcliCallOptions, 'timeoutMs' | 'env'>> & { envVars: Record<string, string> },
64
+ ): Promise<HbcliCallResult & { spawnError?: boolean }> {
51
65
  const started = Date.now()
52
- const bin = opts.hbcliBin ?? 'hbcli'
53
- const timeoutMs = opts.timeoutMs ?? 15_000
54
- const env = opts.env ?? 'uat'
55
- const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
56
- if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
57
-
58
66
  return new Promise((resolve) => {
59
67
  let stdout = ''
60
68
  let stderr = ''
61
69
  let settled = false
62
- const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
70
+ const child = spawn(bin, args, { env: { ...process.env, ...opts.envVars } })
63
71
  const timer = setTimeout(() => {
64
72
  if (!settled) {
65
73
  settled = true
@@ -67,10 +75,10 @@ export async function callHbcliJson(
67
75
  resolve({
68
76
  via: 'hbcli-error', exitCode: -1, result: null,
69
77
  evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
70
- latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
78
+ latencyMs: Date.now() - started, error: `timeout after ${opts.timeoutMs}ms`,
71
79
  })
72
80
  }
73
- }, timeoutMs)
81
+ }, opts.timeoutMs)
74
82
  child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
75
83
  child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
76
84
  child.on('close', (code) => {
@@ -105,16 +113,35 @@ export async function callHbcliJson(
105
113
  if (settled) return
106
114
  settled = true
107
115
  clearTimeout(timer)
108
- // ENOENT (二进制不存在) 等也走降级路径
116
+ // ENOENT (二进制不存在) 等也走降级路径;spawnError 供上层按候选路径重试
109
117
  resolve({
110
118
  via: 'hbcli-error', exitCode: -1, result: null,
111
119
  evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
112
- latencyMs: Date.now() - started, error: (e as Error).message,
120
+ latencyMs: Date.now() - started, error: (e as Error).message, spawnError: true,
113
121
  })
114
122
  })
115
123
  })
116
124
  }
117
125
 
126
+ /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
127
+ export async function callHbcliJson(
128
+ args: string[],
129
+ opts: HbcliCallOptions = {},
130
+ ): Promise<HbcliCallResult> {
131
+ const env = opts.env ?? 'uat'
132
+ const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
133
+ if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
134
+ const callOpts = { timeoutMs: opts.timeoutMs ?? 15_000, env, envVars }
135
+ const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli')
136
+ let last: HbcliCallResult & { spawnError?: boolean } | undefined
137
+ for (const bin of candidates) {
138
+ last = await attemptHbcli(bin, args, callOpts)
139
+ // spawn 级失败(ENOENT 等)且还有候选 → 换下一个已知安装位;其余失败(退码/超时)无重试意义
140
+ if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last
141
+ }
142
+ return last!
143
+ }
144
+
118
145
  /** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
119
146
  export async function searchHotels(
120
147
  query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
@@ -129,6 +156,11 @@ export async function searchHotels(
129
156
  if (live.via === 'hbcli-realtime') {
130
157
  return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
131
158
  }
159
+ // 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
160
+ // 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
161
+ // 裸 "spawn hbcli ENOENT" 读起来像工具坏了——实际静态包降级是设计行为
162
+ const rawReason = live.error ?? live.via
163
+ const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason
132
164
  // 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
133
165
  // (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
134
166
  // 「无数据」而不是伪装成可用结果。
@@ -144,17 +176,17 @@ export async function searchHotels(
144
176
  return {
145
177
  ...live,
146
178
  hotels: { stays: matched },
147
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`,
179
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
148
180
  }
149
181
  }
150
182
  return {
151
183
  ...live,
152
184
  hotels: null,
153
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
185
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
154
186
  }
155
187
  } catch { /* 静态包读不到也优雅降级 */ }
156
188
  }
157
- return { ...live, summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)` }
189
+ return { ...live, summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)` }
158
190
  }
159
191
 
160
192
  /** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */
@@ -57,6 +57,14 @@ export interface TransportOptions {
57
57
  * 凭证流的中间(隐私+可靠性双输)。该形态下不发起任何检索导航、不落审计。
58
58
  */
59
59
  guard?: boolean
60
+ /**
61
+ * 开**自己的新标签页**(默认 false=沿用既有首页——那是用户的页面!)。
62
+ * 人机共治纪律(2026-08-29 founder:「我根本就看不到登录页面」):登录与检索一律
63
+ * newPage 开自己的页,绝不劫持用户已有标签页;closeOwnPage 控制收尾是否关掉自己开的页。
64
+ */
65
+ newPage?: boolean
66
+ /** newPage:true 时,close() 是否连自己开的标签页一起关(默认 true;登录引导保持 false——把登录页留给用户) */
67
+ closeOwnPage?: boolean
60
68
  }
61
69
 
62
70
  function devtoolsWsEndpoint(): { ws: string } | { err: string } {
@@ -111,14 +119,27 @@ export async function openSession(opts: TransportOptions = {}): Promise<SessionT
111
119
  return { ok: false, summary: `read-guard attach failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
112
120
  }
113
121
  }
114
- const page = (await browser.pages())[0] ?? (await browser.newPage())
122
+ // 标签页纪律:ownPage=自己开的新页(newPage:true)——用它,收尾按 closeOwnPage 关;
123
+ // 否则沿用既有首页(仅 persistent 兼容形态;cdp 检索/登录一律 newPage)
124
+ let ownPage: boolean = false
125
+ let page
126
+ if (opts.newPage) {
127
+ page = await browser.newPage()
128
+ ownPage = true
129
+ } else {
130
+ page = (await browser.pages())[0] ?? await browser.newPage()
131
+ }
115
132
  return {
116
133
  ok: true,
117
134
  browser,
118
135
  page,
119
136
  guard: guard ?? { blockedCount: () => 0, requestCount: () => 0 },
120
137
  close: async () => {
138
+ // 自己开的标签页按 closeOwnPage 收尾(登录引导保持 false——登录页留给用户);
121
139
  // cdp:只断开连接,绝不关用户浏览器;persistent:关自己拉起的实例
140
+ if (ownPage && (opts.closeOwnPage ?? true)) {
141
+ await (page as { close(): Promise<void> }).close().catch(() => { /* ignore */ })
142
+ }
122
143
  await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
123
144
  },
124
145
  }
@@ -84,6 +84,8 @@ export async function sessionLogin(q: SessionLoginQuery = {}): Promise<SessionLo
84
84
  if (!target) {
85
85
  return err(site, 'error', `未知站点 ${site}(可选 ${Object.keys(LOGIN_TARGETS).join('/')})`, started, ts)
86
86
  }
87
+ // 人机共治纪律:开自己的新标签页并置前台——用户必须看见登录页(2026-08-29 founder:
88
+ // 「我根本就看不到登录页面」,此前误劫持用户既有标签页);closeOwnPage=false 把登录页留给用户
87
89
  const t = await openSession({ mode: 'cdp', guard: false })
88
90
  if (!t.ok) {
89
91
  const needsAttach = /chrome:\/\/inspect|DevToolsActivePort|cdp attach 失败/.test(t.summary)
@@ -98,11 +100,25 @@ export async function sessionLogin(q: SessionLoginQuery = {}): Promise<SessionLo
98
100
  }
99
101
  const evidenceTag = `[会话:${site}-login@${ts}]`
100
102
  try {
103
+ // 自动检测优先(2026-08-29 founder「UI 里有自动检测吗」):先只读票据名——
104
+ // 已登录则**零弹窗**直接确认,不打开任何页面;登录状态由用户在携程官网自然留存
105
+ const pre = await pollTicketNames(t.browser as unknown as { cookies(): Promise<CookieLike[]> }, target)
106
+ if (pre.length > 0) {
107
+ await t.close()
108
+ return {
109
+ ok: true, via: 'session-login', latencyMs: Date.now() - started, verdict: 'logged-in', site, tickets: pre,
110
+ evidence: `${evidenceTag} 自动检测:票据 cookie 已在(先前登录已生效)——[${pre.join(', ')}](只读名字,0 网页交互)`,
111
+ }
112
+ }
113
+ // 未检出 → 开自己的新标签页并置前台(绝不劫持用户既有页面),等待用户在官网完成登录
114
+ const page = await t.browser.newPage()
115
+ await (page as unknown as { bringToFront(): Promise<void> }).bringToFront().catch(() => { /* 某些环境不可置前 */ })
101
116
  try {
102
- await t.page.goto(target.entryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 })
117
+ await page.goto(target.entryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 })
103
118
  } catch {
104
119
  // 站点慢不致命:入口标签已打开,用户手动登录即可
105
120
  }
121
+ console.log(`[session-login] ${target.label} 登录入口已在新标签页置前打开`)
106
122
  const waitMs = Math.min(Math.max(q.waitMs ?? 90_000, 0), 300_000)
107
123
  const pollMs = Math.min(Math.max(q.pollMs ?? 3_000, 500), 10_000)
108
124
  const deadline = Date.now() + waitMs
@@ -74,7 +74,8 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
74
74
  return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`)
75
75
  }
76
76
 
77
- const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: q.profileDir ? 'persistent' : 'cdp' })
77
+ // 人机共治纪律:检索一律开自己的新标签页(绝不劫持用户已有页面),用完关自己的页
78
+ const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: q.profileDir ? 'persistent' : 'cdp', newPage: true })
78
79
  if (!t.ok) {
79
80
  // cdp 未开端口或握手失败 → needs-attach(一次性用户动作);persistent 启动失败仍走 error。
80
81
  // transport 的“端口未开”在连接前返回,文案不含 `cdp attach 失败`,两种形态都要归入同一用户门禁。
package/ts/src/index.ts CHANGED
@@ -516,11 +516,18 @@ export function apply(ctx: Context, config: Config): void {
516
516
  },
517
517
  presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
518
518
  presentResult: (_args, value) => {
519
- const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
520
- const n = Array.isArray(r.hotels) ? r.hotels.length : 0
519
+ const r = value as { hotels?: unknown; via?: string; destination?: string; summary?: string }
520
+ const h = r.hotels
521
+ const liveCount = Array.isArray(h) ? h.length : 0
522
+ // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
523
+ const stays = !Array.isArray(h) && h && typeof h === 'object' ? (h as { stays?: unknown[] }).stays : undefined
524
+ const staticCount = Array.isArray(stays) ? stays.length : 0
525
+ const tag = r.via === 'hbcli-realtime'
526
+ ? (liveCount ? `实时 ${liveCount} 家` : '实时')
527
+ : staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果')
521
528
  return {
522
529
  card: 'generic',
523
- title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
530
+ title: `酒店:${r.destination ?? ''} ${tag}`,
524
531
  content: [{ type: 'text', text: String(r.summary ?? '') }],
525
532
  }
526
533
  },
@@ -719,9 +726,14 @@ export function apply(ctx: Context, config: Config): void {
719
726
  }
720
727
  const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
721
728
  const top = (r.options ?? []).slice(0, 8).map(o => `${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
729
+ // issue #24:miss(上游正常返回 0 条)与 error(限流/网络)分开陈述,不再混写「无结果或失败」
730
+ // (过去日期已被上方预校验拦下,此处不会出现过期查询)
731
+ const label = kind === 'flight' ? '机票' : '火车票'
722
732
  const summary = r.verdict === 'hit'
723
- ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
724
- : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
733
+ ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
734
+ : r.verdict === 'miss'
735
+ ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}`
736
+ : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`
725
737
  return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
726
738
  },
727
739
  presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
@@ -736,7 +748,7 @@ export function apply(ctx: Context, config: Config): void {
736
748
  registerGuarded(defineTool({
737
749
  name: 'gotry_session_login',
738
750
  description:
739
- 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). '
751
+ 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
740
752
  + 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. '
741
753
  + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
742
754
  + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). '