@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
@@ -13,18 +13,47 @@
13
13
  * `gotry_turn_handoff.v1` 工单并指令模型告知用户 ETA 与回访方式。
14
14
  * - handoff 工单是**新的独立格式**,落在 `<stateRoot>/gotry-state/
15
15
  * turn-handoffs/`,不与 `async/` 的 loop 求解工单混用——async-collect
16
- * 对无 spec 的 state 会立即结算 failed(假失败),收集器是未来的
17
- * agent tick(loopx),不是 async-collect。
16
+ * 对无 spec 的 state 会立即结算 failed(假失败)。产品通过原生 jobs
17
+ * 启动独立收集器;仅落单不代表后台已开始。
18
18
  */
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
21
- import { join } from 'node:path';
21
+ import { join, resolve } from 'node:path';
22
22
  import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor } from './turn-policy.js';
23
23
  export const TURN_DEADLINE_SOFT = 'TURN_DEADLINE_SOFT';
24
24
  export const TURN_DEADLINE_EXHAUSTED = 'TURN_DEADLINE_EXHAUSTED';
25
25
  export const TURN_DEADLINE_HANDOFF = 'TURN_DEADLINE_HANDOFF';
26
26
  export const TURN_HANDOFF_SCHEMA = 'gotry_turn_handoff.v1';
27
- export async function writeTurnHandoffTicket(stateRoot, userMessage) {
27
+ function handoffContext(events) {
28
+ const questionIds = new Set(events.filter(event => event.type === 'tool/call' && event.data.name === 'ask_user_question')
29
+ .map(event => event.type === 'tool/call' ? event.data.callId : ''));
30
+ // Human constraints are lossless. Refuse dispatch if they exceed the input
31
+ // budget; silently cutting an accessibility/budget answer changes the task.
32
+ const answers = events.filter(event => (event.type === 'user/message' && event.data.source.kind === 'user')
33
+ || (event.type === 'tool/call' && questionIds.has(event.data.callId))
34
+ || (event.type === 'tool/result' && event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
35
+ .map(event => JSON.stringify(event.data)).join('\n');
36
+ if (answers.length > 64_000)
37
+ throw new Error('Prior user answers exceed the handoff context limit; planning paused without dropping constraints. Start a new session with the complete trip constraints.');
38
+ let remaining = 18_000;
39
+ const evidence = [];
40
+ for (const event of [...events].reverse()) {
41
+ if (event.type !== 'tool/result' || (event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
42
+ continue;
43
+ const row = JSON.stringify(event.data);
44
+ // Evidence may be excerpted, with an explicit marker and valid JSON.
45
+ const excerpt = row.length > remaining
46
+ ? JSON.stringify({ truncated: true, excerpt: row.slice(-Math.floor(remaining / 2)) }) : row;
47
+ if (excerpt.length + 1 > remaining)
48
+ break;
49
+ evidence.unshift(excerpt);
50
+ remaining -= excerpt.length + 1;
51
+ if (remaining < 100)
52
+ break;
53
+ }
54
+ return answers || evidence.length ? `${answers}\n${evidence.join('\n')}` : undefined;
55
+ }
56
+ export async function writeTurnHandoffTicket(stateRoot, userMessage, details = {}) {
28
57
  const ticket = {
29
58
  schema: TURN_HANDOFF_SCHEMA,
30
59
  id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
@@ -33,6 +62,7 @@ export async function writeTurnHandoffTicket(stateRoot, userMessage) {
33
62
  requestedAt: new Date().toISOString(),
34
63
  etaLabel: TURN_HANDOFF_ETA_LABEL,
35
64
  status: 'open',
65
+ ...details,
36
66
  };
37
67
  await writeTicketJson(stateRoot, ticket);
38
68
  return ticket;
@@ -41,7 +71,7 @@ function handoffDir(stateRoot) {
41
71
  const root = stateRoot === '.' ? process.cwd() : stateRoot;
42
72
  return join(root, 'gotry-state', 'turn-handoffs');
43
73
  }
44
- async function writeTicketJson(stateRoot, ticket) {
74
+ export async function writeTicketJson(stateRoot, ticket) {
45
75
  const dir = handoffDir(stateRoot);
46
76
  await mkdir(dir, { recursive: true });
47
77
  const path = join(dir, `${ticket.id}.json`);
@@ -95,12 +125,14 @@ export async function listTurnHandoffTickets(stateRoot) {
95
125
  objective: raw.objective,
96
126
  requestedAt: raw.requestedAt,
97
127
  etaLabel: raw.etaLabel,
128
+ ...(raw.jobId ? { jobId: raw.jobId } : {}),
98
129
  ...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
99
130
  ...(raw.error ? { error: raw.error } : {}),
100
131
  };
101
- if (raw.status !== 'open' && raw.deliverableFile) {
132
+ if ((raw.status === 'settled' || raw.status === 'failed') && raw.deliverableFile === `${raw.id}.deliverable.md`) {
102
133
  try {
103
134
  const text = await readFile(join(dir, raw.deliverableFile), 'utf-8');
135
+ view.deliverablePath = resolve(dir, raw.deliverableFile);
104
136
  view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text;
105
137
  }
106
138
  catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
@@ -140,7 +172,12 @@ function exhaustionResult(reason) {
140
172
  };
141
173
  }
142
174
  function handoffResult(ticket) {
143
- const instruction = `Turn handed off to background planning. Async ticket ${ticket.id} created (ETA ${ticket.etaLabel}). Stop dispatching tools and tell the user in one short message: the deep planning continues in the background, the expected time, and to check back later (mention ticket ${ticket.id}).`;
175
+ const state = ticket.status === 'running' && ticket.jobId
176
+ ? `Background planning started as native background job ${ticket.jobId} (estimated ${ticket.etaLabel}; not a guarantee). Track or cancel with job_list/job_output/job_kill.`
177
+ : ticket.status === 'failed'
178
+ ? `Background planning could not start: ${ticket.error ?? 'unknown failure'}. Tell the user it failed; do not promise later delivery.`
179
+ : 'The ticket is queued; the background worker has not started. Tell the user it is pending, not running; do not promise a completion time.';
180
+ const instruction = `Async ticket ${ticket.id} persisted. ${state} Stop research and answer from existing results. Never claim a file was created when its tool call failed or was refused. For any successfully written output, call present with its exact existing path before the final response. Mention ticket ${ticket.id}.`;
144
181
  return {
145
182
  isError: true,
146
183
  error: {
@@ -177,6 +214,7 @@ export function installTurnDeadline(ctx, options = {}) {
177
214
  };
178
215
  const stateByAgent = new Map();
179
216
  const softExecutions = new Map();
217
+ const handoffExecutions = new Map();
180
218
  const finalOnlyDisposers = new Map();
181
219
  const clearAgent = (agentId) => {
182
220
  stateByAgent.delete(agentId);
@@ -184,6 +222,10 @@ export function installTurnDeadline(ctx, options = {}) {
184
222
  if (owner.agentId === agentId)
185
223
  softExecutions.delete(token);
186
224
  }
225
+ for (const [token, owner] of handoffExecutions) {
226
+ if (owner.agentId === agentId)
227
+ handoffExecutions.delete(token);
228
+ }
187
229
  finalOnlyDisposers.get(agentId)?.();
188
230
  finalOnlyDisposers.delete(agentId);
189
231
  };
@@ -200,7 +242,7 @@ export function installTurnDeadline(ctx, options = {}) {
200
242
  }
201
243
  const inheritedNames = globalTools.schemas()
202
244
  .map(schema => schema.name)
203
- .filter(name => name !== 'run_code');
245
+ .filter(name => name !== 'run_code' && name !== 'present');
204
246
  if (inheritedNames.length === 0)
205
247
  return;
206
248
  finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }));
@@ -249,16 +291,68 @@ export function installTurnDeadline(ctx, options = {}) {
249
291
  const state = stateByAgent.get(agentId)
250
292
  ?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } };
251
293
  stateByAgent.set(agentId, state);
294
+ // Present validates files on disk and publishes native deliverable events;
295
+ // it must remain callable after the research window closes.
296
+ if (exec.name === 'present')
297
+ return next();
252
298
  const elapsed = now() - state.startedAt;
253
299
  if (elapsed > state.policy.hardMs) {
254
- // Synchronous schema suppression so same-step follow-up calls cannot
255
- // loop on refusal results (the 2026-09-02 trajectory failure).
256
- enterFinalOnly(exec.agent);
257
300
  if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
258
- return writeTurnHandoffTicket(options.stateRoot, state.userMessage)
259
- .then(ticket => handoffResult(ticket))
301
+ const root = options.stateRoot === '.'
302
+ ? (exec.agent.session?.header.cwd ?? process.cwd()) : resolve(options.stateRoot);
303
+ const written = exec.name === 'write' && !state.handoff ? Promise.resolve().then(() => next()) : undefined;
304
+ if (!state.handoff) {
305
+ const session = exec.agent.session;
306
+ // Reserve the handoff before executing the write body. Parallel or
307
+ // repeated calls see the same promise and cannot write/dispatch twice.
308
+ state.handoff = (async () => {
309
+ try {
310
+ await written;
311
+ }
312
+ finally {
313
+ enterFinalOnly(exec.agent);
314
+ }
315
+ let context;
316
+ let contextError;
317
+ try {
318
+ context = handoffContext(session?.snapshotEvents() ?? []);
319
+ }
320
+ catch (error) {
321
+ contextError = error instanceof Error ? error.message : 'handoff context unavailable';
322
+ }
323
+ const ticket = await writeTurnHandoffTicket(root, state.userMessage, {
324
+ workspaceCwd: session?.header.cwd,
325
+ ownerSessionId: session ? String(session.id) : undefined,
326
+ context,
327
+ });
328
+ if (contextError) {
329
+ await settleTurnHandoffTicket(root, ticket, 'failed', `# Planning paused\n\n${contextError}`, contextError);
330
+ return { ...ticket, status: 'failed', error: contextError };
331
+ }
332
+ return options.startHandoff ? options.startHandoff(ticket, exec.agent, root) : ticket;
333
+ })();
334
+ }
335
+ // The triggering call may be the output write itself. Execute it once,
336
+ // preserve its actual outcome, then require native present. Attach a
337
+ // rejection handler now even if the pending tool itself rejects.
338
+ const handoff = state.handoff.then(handoffResult, () => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: ticket persistence failed; no background worker was started`));
339
+ return (async () => {
340
+ const output = await written;
341
+ const guidance = await handoff;
342
+ if (output) {
343
+ handoffExecutions.set(exec.token, { agentId, contexts: guidance.additionalContexts ?? [] });
344
+ // Keep the registry's canonical result object. Rebuilding it after
345
+ // restricting inherited schemas would make DSH re-resolve a tool
346
+ // that has just been hidden and report UNKNOWN_TOOL after saving.
347
+ return output;
348
+ }
349
+ return guidance;
350
+ })()
260
351
  .catch(() => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`));
261
352
  }
353
+ // Suppress inherited research schemas immediately on refusal; DSH's
354
+ // scoped schemas remain visible but hit this same per-call guard.
355
+ enterFinalOnly(exec.agent);
262
356
  return Promise.resolve(exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`));
263
357
  }
264
358
  if (elapsed > state.policy.softMs) {
@@ -269,17 +363,20 @@ export function installTurnDeadline(ctx, options = {}) {
269
363
  ctx.on('tools/post-execute', (exec, _result, next) => {
270
364
  return next().then(decision => {
271
365
  const owner = softExecutions.get(exec.token);
272
- if (!owner)
366
+ const handoff = handoffExecutions.get(exec.token);
367
+ handoffExecutions.delete(exec.token);
368
+ if (!owner && !handoff)
273
369
  return decision;
274
370
  softExecutions.delete(exec.token);
275
- const text = owner.exit === 'handoff'
371
+ const text = owner?.exit === 'handoff'
276
372
  ? 'Turn has crossed the soft deadline of a deep-planning turn. Wrap up context gathering now; the turn will hand off to background planning at the hard deadline.'
277
373
  : 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.';
278
374
  return {
279
375
  ...decision,
280
376
  additionalContexts: [
281
377
  ...(decision.additionalContexts ?? []),
282
- convergenceContext(TURN_DEADLINE_SOFT, text),
378
+ ...(handoff?.contexts ?? []),
379
+ ...(owner ? [convergenceContext(TURN_DEADLINE_SOFT, text)] : []),
283
380
  ],
284
381
  };
285
382
  });
@@ -0,0 +1,124 @@
1
+ /** Connect durable planning tickets to DSH's owned jobs and managed processes. */
2
+ import { createRequire } from 'node:module';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { settleTurnHandoffTicket, writeTicketJson } from './turn-deadline.js';
6
+ const require = createRequire(import.meta.url);
7
+ export async function startTurnHandoffJob(ctx, ticket, owner, stateRoot) {
8
+ if (ticket.status !== 'open')
9
+ return ticket;
10
+ const jobs = ctx.get('jobs');
11
+ const subprocess = ctx.get('subprocess');
12
+ if (!jobs || !subprocess)
13
+ return ticket; // Queued, never announced as running.
14
+ let current = ticket;
15
+ let handle;
16
+ let cancelled = false;
17
+ let release;
18
+ const prepared = new Promise(resolve => { release = resolve; });
19
+ let announceStarted;
20
+ const started = new Promise(resolve => { announceStarted = resolve; });
21
+ const fail = async (reason) => {
22
+ await settleTurnHandoffTicket(stateRoot, current, 'failed', `# Planning failed\n\n${reason}\n\nNo completed plan was produced.`, reason);
23
+ current = { ...current, status: 'failed', error: reason };
24
+ };
25
+ try {
26
+ const jobId = jobs.start({
27
+ kind: 'gotry-planning', owner: owner.id, label: `${ticket.id}: ${ticket.objective.slice(0, 100)}`, outputLimitBytes: 12_000,
28
+ run() {
29
+ return {
30
+ cancel() { cancelled = true; handle?.terminate(); },
31
+ done: (async () => {
32
+ if (!await prepared) {
33
+ announceStarted();
34
+ return { status: 'failed', detail: 'ticket persistence failed' };
35
+ }
36
+ let timer;
37
+ try {
38
+ if (cancelled)
39
+ throw new Error('planning cancelled');
40
+ const extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
41
+ const collector = join(import.meta.dirname, '..', 'scripts', `turn-handoff-collect.${extension}`);
42
+ const argv = extension === 'ts'
43
+ ? [process.execPath, require.resolve('tsx/cli'), collector, ticket.id, stateRoot]
44
+ : [process.execPath, collector, ticket.id, stateRoot];
45
+ handle = subprocess.spawn({
46
+ argv, cwd: ticket.workspaceCwd ?? stateRoot,
47
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 16_000 }, stderr: { maxBytes: 16_000 } },
48
+ // Collector cleanup includes the wrapper's detached DSH group
49
+ // (5s+1s), then its own 8s+1s bound and durable settlement.
50
+ graceMs: 11_000,
51
+ // This dedicated planning child inherits the configured model
52
+ // environment explicitly; secrets never enter tickets or output.
53
+ env: { GOTRY_HANDOFF_JOB_ID: current.jobId,
54
+ GOTRY_HANDOFF_PLANNER_BIN: process.env.GOTRY_HANDOFF_PLANNER_BIN,
55
+ GOTRY_HANDOFF_PLANNER_TIMEOUT_MS: process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS,
56
+ DSH_HOME: process.env.DSH_HOME,
57
+ LLM_API_KEY: process.env.LLM_API_KEY, LLM_BASE_URL: process.env.LLM_BASE_URL,
58
+ LLM_MODEL: process.env.LLM_MODEL, GOTRY_LLM_MODEL: process.env.GOTRY_LLM_MODEL,
59
+ DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL },
60
+ });
61
+ announceStarted();
62
+ const plannerTimeout = Number(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS);
63
+ const timeout = Number.isSafeInteger(plannerTimeout) && plannerTimeout > 0 ? plannerTimeout : 900_000;
64
+ timer = setTimeout(() => handle.terminate(), timeout + 12_000);
65
+ const direct = await handle.done.then(outcome => ({ ok: true, outcome }), error => ({ ok: false, error }));
66
+ // Direct success or failure does not prove managed-range exit.
67
+ if (!direct.ok)
68
+ handle.terminate();
69
+ try {
70
+ await handle.waitForExit();
71
+ }
72
+ catch (cleanupError) {
73
+ handle.terminate();
74
+ const cleanupDiagnostic = cleanupError instanceof Error ? cleanupError.message : 'unknown cleanup error';
75
+ const cleanupReason = `managed-range cleanup could not be confirmed: ${cleanupDiagnostic}`;
76
+ if (!direct.ok) {
77
+ const diagnostic = direct.error instanceof Error ? direct.error.message : 'planning failed';
78
+ throw new AggregateError([direct.error, cleanupError], `${diagnostic}; ${cleanupReason}`);
79
+ }
80
+ throw new Error(cleanupReason, { cause: cleanupError });
81
+ }
82
+ if (!direct.ok)
83
+ throw direct.error;
84
+ const outcome = direct.outcome;
85
+ const path = join(stateRoot, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`);
86
+ const settled = JSON.parse(await readFile(path, 'utf8'));
87
+ if (cancelled)
88
+ throw new Error('planning cancelled');
89
+ if (outcome.exitCode !== 0 || settled.status !== 'settled' || settled.deliverableFile !== `${ticket.id}.deliverable.md`) {
90
+ throw new Error(settled.error ?? `planning collector failed (exit ${outcome.exitCode ?? outcome.signal})`);
91
+ }
92
+ const deliverablePath = join(stateRoot, 'gotry-state', 'turn-handoffs', settled.deliverableFile);
93
+ const content = await readFile(deliverablePath, 'utf8');
94
+ if (!content.trim())
95
+ throw new Error('planning collector produced an empty deliverable');
96
+ current = settled;
97
+ return { status: 'completed', detail: `ticket ${ticket.id} settled`, result: `Planning completed: ${ticket.id}. Deliverable: ${deliverablePath}\nCall present with this exact path, then report the result.\n\n${content.slice(0, 8_000)}` };
98
+ }
99
+ catch (error) {
100
+ const reason = error instanceof Error ? error.message : 'planning failed';
101
+ await fail(reason);
102
+ return { status: cancelled ? 'killed' : 'failed', detail: reason, result: `Ticket ${ticket.id} failed: ${reason}` };
103
+ }
104
+ finally {
105
+ if (timer)
106
+ clearTimeout(timer);
107
+ announceStarted();
108
+ }
109
+ })(),
110
+ };
111
+ },
112
+ });
113
+ current = { ...ticket, status: 'running', jobId };
114
+ await writeTicketJson(stateRoot, current);
115
+ release(true);
116
+ await started;
117
+ return current;
118
+ }
119
+ catch (error) {
120
+ release(false);
121
+ await fail(error instanceof Error ? error.message : 'background job registration failed');
122
+ return current;
123
+ }
124
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Stai browser extension
4
4
 
5
- Stai (formerly GoTry Session Bridge) connects supported travel-search pages to GoTry. The Chrome Web Store and unpacked builds have different extension IDs but use the same bridge contract.
5
+ Stai (formerly Stai Travel Bridge) connects supported travel-search pages to GoTry. The Chrome Web Store and unpacked builds have different extension IDs but use the same bridge contract.
6
6
 
7
7
  ## What it does
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Stai 浏览器扩展
4
4
 
5
- Stai(原名 GoTry Session Bridge)将已支持的旅行检索页面连接至 GoTry。商店版与本地加载版的扩展 ID 不同,但使用相同的桥接合同。
5
+ Stai(原名 Stai Travel Bridge)将已支持的旅行检索页面连接至 GoTry。商店版与本地加载版的扩展 ID 不同,但使用相同的桥接合同。
6
6
 
7
7
  ## 功能
8
8
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GoTry Session Bridge — MV3 Service Worker(RFC docs/rfc/user-session-data-rfc.md §2.2 通道 C,2026-08-29 定案为 PRIMARY 传输)。
2
+ * Stai Travel Bridge — MV3 Service Worker(RFC docs/rfc/user-session-data-rfc.md §2.2 通道 C,2026-08-29 定案为 PRIMARY 传输)。
3
3
  *
4
4
  * 职责(与 Node 侧 session/extension-bridge.ts 配对,零构建纯 JS):
5
5
  * - 长轮询取活:POST /jobs(桥最多 hold 20s;每次响应/失败都重置 SW 30s 生命周期,任务秒级触达);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GoTry Session Bridge — ISOLATED-world 桥(manifest 默认 world,document_start)。
2
+ * Stai Travel Bridge — ISOLATED-world 桥(manifest 默认 world,document_start)。
3
3
  *
4
4
  * MAIN world(content-main.js)拿不到 chrome API;本脚本监听页面里的
5
5
  * gotry-ctrip-sniff 自定义事件,把响应文本经 chrome.runtime.sendMessage 转发给
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GoTry Session Bridge — MAIN-world 网络嗅探(manifest world:"MAIN",document_start)。
2
+ * Stai Travel Bridge — MAIN-world 网络嗅探(manifest world:"MAIN",document_start)。
3
3
  *
4
4
  * MAIN world 没有 chrome.runtime,只负责:hook 站点自己的 fetch/XHR,把命中
5
5
  * 嗅探面的响应文本以 CustomEvent 派发到页面里;ISOLATED 侧的 content-bridge.js
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.28",
4
+ "version": "0.2.0.30",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.28"
59
+ "version_name": "0.2.0-rc.30"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.28",
3
+ "version": "0.2.0-rc.30",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -78,6 +78,7 @@
78
78
  "ts/src/artifact-gate.ts",
79
79
  "ts/src/tool-packet.ts",
80
80
  "ts/src/turn-deadline.ts",
81
+ "ts/src/turn-handoff-job.ts",
81
82
  "ts/src/turn-policy.ts",
82
83
  "ts/src/memory-capture.ts",
83
84
  "ts/src/memory-utility.ts",
@@ -31,7 +31,7 @@ import { isAbsolute, join, resolve, sep } from 'node:path'
31
31
  import { openLedgerIfExists } from '../src/state-ledger.ts'
32
32
 
33
33
  export interface ArtifactEntry {
34
- source: 'async-run' | 'cwd-file'
34
+ source: 'async-run' | 'cwd-file' | 'turn-handoff'
35
35
  id: string
36
36
  title: string
37
37
  path: string
@@ -56,6 +56,8 @@ export interface ArtifactReadView {
56
56
 
57
57
  export interface ListArtifactsOptions {
58
58
  stateRoot: string
59
+ /** Host-owned handoff storage can differ from the existing async state. */
60
+ handoffRoot?: string
59
61
  cwd?: string
60
62
  limit?: number
61
63
  /** Zero-based nonnegative integer; values outside the eligible set return an empty page with the known total. */
@@ -266,6 +268,28 @@ async function listCwdArtifacts(cwd: string): Promise<ArtifactEntry[]> {
266
268
  return entries
267
269
  }
268
270
 
271
+ /** Durable handoff outputs live outside async/, including honest failure notes. */
272
+ async function listHandoffArtifacts(root: string): Promise<ArtifactEntry[]> {
273
+ const dir = join(root, 'gotry-state', 'turn-handoffs')
274
+ const names = await readdir(dir).catch(() => [] as string[])
275
+ const entries: ArtifactEntry[] = []
276
+ for (const name of names) {
277
+ if (!name.endsWith('.json')) continue
278
+ try {
279
+ const ticket = JSON.parse(await readFile(join(dir, name), 'utf8'))
280
+ if (ticket.schema !== 'gotry_turn_handoff.v1' || name !== `${ticket.id}.json`
281
+ || !['settled', 'failed'].includes(ticket.status) || ticket.deliverableFile !== `${ticket.id}.deliverable.md`) continue
282
+ const path = await realpath(join(dir, ticket.deliverableFile))
283
+ if (!underRoot(path, root) || hasDeniedSegment(path)) continue
284
+ const info = await stat(path)
285
+ if (!info.isFile()) continue
286
+ entries.push({ source: 'turn-handoff', id: ticket.id, title: String(ticket.objective ?? ticket.id),
287
+ path, status: ticket.status, updated: new Date(info.mtimeMs).toISOString(), bytes: info.size })
288
+ } catch { /* A corrupt or missing output cannot be advertised as a file. */ }
289
+ }
290
+ return entries
291
+ }
292
+
269
293
  export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArtifactsResult> {
270
294
  const limit = clampLimit(opts.limit)
271
295
  const offset = normalizeOffset(opts.offset, 'offset')
@@ -274,6 +298,8 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
274
298
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
275
299
  const canonicalRoot = await realpath(root).catch(() => root)
276
300
  const canonicalCwd = await realpath(cwd).catch(() => cwd)
301
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot)
302
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot)
277
303
 
278
304
  // 1) collect all eligible entries per source, without per-source limits —
279
305
  // pageSize is applied to the merged/deduped/sorted set so older cwd files
@@ -281,6 +307,8 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
281
307
  const collected: ArtifactEntry[] = [
282
308
  ...listRunsFromLedger(canonicalRoot, 'local'),
283
309
  ...(await listDeliverableFiles(canonicalRoot)),
310
+ ...(await listHandoffArtifacts(canonicalRoot)),
311
+ ...(canonicalHandoffRoot !== canonicalRoot ? await listHandoffArtifacts(canonicalHandoffRoot) : []),
284
312
  ...(await listCwdArtifacts(canonicalCwd)),
285
313
  ]
286
314
 
@@ -318,6 +346,7 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
318
346
  offset,
319
347
  roots: [canonicalRoot, canonicalCwd],
320
348
  }
349
+ if (!result.roots.includes(canonicalHandoffRoot)) result.roots.push(canonicalHandoffRoot)
321
350
  if (hasMore) result.nextOffset = start + page.length
322
351
  if (needle !== '') result.search = needle
323
352
  return result
@@ -332,6 +361,7 @@ export async function listArtifacts(opts: ListArtifactsOptions): Promise<ListArt
332
361
  */
333
362
  export async function readArtifact(opts: {
334
363
  stateRoot: string
364
+ handoffRoot?: string
335
365
  cwd?: string
336
366
  path: string
337
367
  offset?: number
@@ -341,6 +371,8 @@ export async function readArtifact(opts: {
341
371
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
342
372
  const canonicalRoot = await realpath(root).catch(() => root)
343
373
  const canonicalCwd = await realpath(cwd).catch(() => cwd)
374
+ const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot)
375
+ const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot)
344
376
  const raw = String(opts.path ?? '').trim()
345
377
  if (!raw) return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' }
346
378
 
@@ -357,10 +389,13 @@ export async function readArtifact(opts: {
357
389
  text = run.deliverable
358
390
  filePath = asyncDeliverablePath(root, raw)
359
391
  } else {
360
- const p = asyncDeliverablePath(root, raw)
392
+ const handoff = /^th-[a-z0-9-]+$/i.test(raw)
393
+ ? [join(handoffRoot, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`),
394
+ join(root, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`)] : []
395
+ const p = handoff.find(path => existsSync(path)) ?? asyncDeliverablePath(root, raw)
361
396
  const canonical = await realpath(p).catch(() => null)
362
397
  if (canonical) {
363
- if (!underRoot(canonical, canonicalRoot) || hasDeniedSegment(canonical)) {
398
+ if (!(underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
364
399
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
365
400
  }
366
401
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase()
@@ -384,7 +419,7 @@ export async function readArtifact(opts: {
384
419
  // 路径(realpath 后的 cwd),而 macOS 上 /var 与 /private/var 这类符号链接会让
385
420
  // canonical 路径不匹配字面 cwd,导致 list→read 串联断裂。边界权威仍是下方
386
421
  // realpath 后的 canonical 复检,这里放宽不改变可读集合。
387
- const inScope = (p: string) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot)) && !hasDeniedSegment(p)
422
+ const inScope = (p: string) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot) || underRoot(p, handoffRoot) || underRoot(p, canonicalHandoffRoot)) && !hasDeniedSegment(p)
388
423
  // Prefer an existing candidate so a cwd miss does not mask a valid state-root file.
389
424
  const allowed = candidates.find(p => inScope(p) && existsSync(p)) ?? candidates.find(inScope)
390
425
  if (!allowed) {
@@ -394,7 +429,7 @@ export async function readArtifact(opts: {
394
429
  if (!canonical) {
395
430
  return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' }
396
431
  }
397
- if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot)) || hasDeniedSegment(canonical)) {
432
+ if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
398
433
  return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
399
434
  }
400
435
  const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase()
@@ -62,17 +62,17 @@ export function localExtensionInstalled(home = homedir()): boolean {
62
62
  * 影响面统一行:账号会话通道断 → 列表底部工具不提供;其余工具不受影响。
63
63
  * 纯函数(默认 HOME 可注入)。
64
64
  *
65
- * 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「Session Bridge 安装」段。
65
+ * 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「Stai Travel Bridge 安装」段。
66
66
  */
67
67
  const EXTENSION_IMPACT_LINE = '影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。'
68
68
  export function needsExtensionSummary(opts: { localInstalled?: boolean; home?: string } = {}): string {
69
69
  const local = opts.localInstalled ?? localExtensionInstalled(opts.home)
70
70
  if (local) {
71
- return 'GoTry Session Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
71
+ return 'Stai Travel Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
72
72
  + `${EXTENSION_STORE_URL} ;或 npx @danceiny/gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension。`
73
73
  + EXTENSION_IMPACT_LINE
74
74
  }
75
- return 'GoTry Session Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
75
+ return 'Stai Travel Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
76
76
  + `若尚未安装:应用商店一键装 ${EXTENSION_STORE_URL} (装完零弹窗,浏览器自己当安装器)。`
77
77
  + EXTENSION_IMPACT_LINE
78
78
  }
@@ -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
  *