@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.31

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 (41) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +56 -83
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-inner.js +9 -2
  9. package/client/client.js +183 -0
  10. package/cordis.gotry-patch.yml +2 -0
  11. package/dist/capabilities/artifacts.js +41 -4
  12. package/dist/capabilities/doctor.js +7 -15
  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/artifact-client-contract-tests.js +4 -3
  16. package/dist/scripts/artifact-delivery-tests.js +31 -0
  17. package/dist/scripts/bootstrap-tests.js +19 -4
  18. package/dist/scripts/doctor-tests.js +36 -7
  19. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  20. package/dist/scripts/flyai-setup-tests.js +75 -2
  21. package/dist/scripts/gotry-web-api-tests.js +114 -0
  22. package/dist/scripts/gotry-web-client-tests.js +28 -0
  23. package/dist/scripts/issue-271-liveness-tests.js +9 -1
  24. package/dist/scripts/turn-handoff-collect-tests.js +7 -1
  25. package/dist/scripts/turn-handoff-collect.js +68 -20
  26. package/dist/scripts/turn-handoff-job-tests.js +222 -0
  27. package/dist/scripts/turn-handoff-web-e2e.js +240 -0
  28. package/dist/src/artifact-delivery.js +26 -0
  29. package/dist/src/gotry-web-api.js +157 -0
  30. package/dist/src/index.js +30 -8
  31. package/dist/src/turn-deadline.js +113 -16
  32. package/dist/src/turn-handoff-job.js +124 -0
  33. package/extension/manifest.json +2 -2
  34. package/package.json +8 -2
  35. package/ts/capabilities/artifacts.ts +40 -5
  36. package/ts/scripts/turn-handoff-collect.ts +63 -21
  37. package/ts/src/artifact-delivery.ts +25 -0
  38. package/ts/src/gotry-web-api.ts +140 -0
  39. package/ts/src/index.ts +30 -8
  40. package/ts/src/turn-deadline.ts +111 -18
  41. package/ts/src/turn-handoff-job.ts +120 -0
package/client/client.js CHANGED
@@ -12,6 +12,174 @@ window.__ModuleLoader__.load({
12
12
  var module = { exports: {} }
13
13
  var exports = module.exports
14
14
  var React = require('react')
15
+ var UI = require('@deepseek-ai/dsh-client-ui-primitives')
16
+
17
+ var webLocale = {
18
+ en: {
19
+ title: 'GoTry', description: 'Travel data sources and saved itineraries', flyai: 'FlyAI',
20
+ loading: 'Loading…', unavailable: 'Configuration is unavailable. Retry to read its current state.',
21
+ readOnly: 'The current key comes from the launch environment, or this platform cannot safely store it here.',
22
+ key: 'API Key', keyHint: 'Paste the key from the FlyAI console. It never enters the conversation.',
23
+ configured: 'A key is configured', anonymous: 'Anonymous trial · shared quota', verified: 'Current configuration verified',
24
+ unverified: 'Configured · verification required', debug: 'Debug endpoint', console: 'Open FlyAI console',
25
+ save: 'Verify and save', saving: 'Verifying…', saveFailed: 'The new key was not saved; the previous configuration is retained.',
26
+ saved: 'Verified and saved.', clear: 'Clear local key', cleared: 'Local key cleared. Anonymous trial is now active.',
27
+ retry: 'Refresh status', source: 'Source', config: 'Local configuration', env: 'Environment variable',
28
+ 'env-debug': 'Debug environment variable', none: 'Shared trial',
29
+ artifacts: 'GoTry artifacts', artifactsHint: 'Browse saved itineraries and planning deliveries',
30
+ search: 'Search artifacts', searchPlaceholder: 'Title or filename', refresh: 'Refresh',
31
+ empty: 'No saved artifacts in this workspace.', noMatches: 'No matching artifacts.', noSession: 'Open a conversation to browse its workspace.',
32
+ previous: 'Previous', next: 'Next', html: 'Open HTML preview', open: 'Open preview',
33
+ htmlHint: 'HTML previews may run the document’s scripts.', failed: 'Could not read the current state. Please retry.',
34
+ },
35
+ zh: {
36
+ title: 'GoTry', description: '旅行数据源与已保存行程', flyai: 'FlyAI(飞猪)',
37
+ loading: '正在读取…', unavailable: '配置暂时不可用,请重新读取当前状态。',
38
+ readOnly: '当前 Key 来自启动环境变量,或此平台无法在这里安全保存。',
39
+ key: 'API Key', keyHint: '粘贴飞猪控制台中的 Key,凭据不会进入对话。',
40
+ configured: '已配置 Key', anonymous: '匿名试用 · 共享额度', verified: '当前配置已验证',
41
+ unverified: '已配置 · 尚未验证通过', debug: '调试服务地址', console: '打开飞猪控制台',
42
+ save: '验证并保存', saving: '正在验证…', saveFailed: '新 Key 未保存,旧配置已保留。',
43
+ saved: '验证通过,已保存。', clear: '清除本机 Key', cleared: '本机 Key 已清除,已恢复匿名试用。',
44
+ retry: '重新读取状态', source: '来源', config: '本机配置', env: '环境变量',
45
+ 'env-debug': '调试环境变量', none: '共享试用',
46
+ artifacts: 'GoTry 产物', artifactsHint: '查看已保存行程和规划交付物',
47
+ search: '搜索产物', searchPlaceholder: '标题或文件名', refresh: '刷新',
48
+ empty: '这个工作区尚无已保存产物。', noMatches: '没有匹配的产物。', noSession: '打开一个对话后即可查看其工作区产物。',
49
+ previous: '上一页', next: '下一页', html: '打开 HTML 预览', open: '打开预览',
50
+ htmlHint: 'HTML 预览可能运行文档中的脚本。', failed: '无法读取当前状态,请重试。',
51
+ },
52
+ }
53
+
54
+ async function webRequest(path, options) {
55
+ var response = await fetch('api/gotry/' + path, Object.assign({ credentials: 'same-origin' }, options))
56
+ var value = await response.json()
57
+ if (!value || typeof value !== 'object') throw new Error('Invalid Web response')
58
+ if (!response.ok || value.ok === false) throw new Error(value.error || 'Request failed')
59
+ return value
60
+ }
61
+
62
+ function FlyaiSettings(props) {
63
+ var t = props.t
64
+ var state = React.useState(null), status = state[0], setStatus = state[1]
65
+ var draft = React.useState(''), key = draft[0], setKey = draft[1]
66
+ var noticeState = React.useState(''), notice = noticeState[0], setNotice = noticeState[1]
67
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
68
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
69
+ var reloadState = React.useState(0), reload = reloadState[0], setReload = reloadState[1]
70
+ var lifetime = React.useRef(null), activeRequest = React.useRef(false)
71
+ React.useEffect(function () {
72
+ var controller = new AbortController()
73
+ lifetime.current = controller
74
+ setError('')
75
+ webRequest('flyai', { signal: controller.signal }).then(function (value) {
76
+ if (!controller.signal.aborted) setStatus(value)
77
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
78
+ return function () { controller.abort(); lifetime.current = null }
79
+ }, [reload])
80
+ async function write(action) {
81
+ if (activeRequest.current || !lifetime.current || !status?.writable) return
82
+ var controller = lifetime.current
83
+ activeRequest.current = true
84
+ setBusy(true); setError(''); setNotice('')
85
+ var candidate = key
86
+ setKey('')
87
+ try {
88
+ var value = await webRequest('flyai/write', { method: 'POST', signal: controller.signal,
89
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(action === 'save' ? { action: action, key: candidate } : { action: action }) })
90
+ if (!controller.signal.aborted) { setStatus(value); setNotice(t(action === 'save' ? 'saved' : 'cleared')) }
91
+ } catch (failure) {
92
+ if (!controller.signal.aborted) setError(failure.message || t('saveFailed'))
93
+ } finally {
94
+ activeRequest.current = false
95
+ if (!controller.signal.aborted) setBusy(false)
96
+ }
97
+ }
98
+ if (props.view === 'summary') return t('description')
99
+ var disabled = busy || !status?.writable
100
+ var stateLabel = !status ? t('loading') : !status.configured ? t('anonymous') : status.verified ? t('verified') : t('unverified')
101
+ return element('section', { 'data-gotry-flyai-settings': '', style: { display: 'grid', gap: '16px', maxWidth: '640px' } }, [
102
+ element('h3', { key: 'heading', style: { margin: 0 } }, t('flyai')),
103
+ element('div', { key: 'status', role: 'status', 'data-gotry-flyai-status': '', style: { display: 'grid', gap: '6px' } }, [
104
+ element(UI.Tag, { key: 'badge', tone: status?.verified ? 'success' : 'neutral' }, stateLabel),
105
+ status ? element('span', { key: 'source', style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('source') + ':' + t(status.source)) : null,
106
+ status?.endpointDebug ? element('span', { key: 'debug' }, t('debug') + ':' + status.endpoint) : null,
107
+ ]),
108
+ element('a', { key: 'console', href: 'https://flyai.open.fliggy.com/console', target: '_blank', rel: 'noopener noreferrer' }, t('console')),
109
+ element(UI.SettingsForm, { key: 'form', labels: { unavailable: t('loading'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') },
110
+ state: { available: Boolean(status), writable: Boolean(status?.writable), dirty: key.trim().length > 0, invalid: false, saving: busy, failed: false },
111
+ onSave: function () { void write('save') }, onDiscard: function () { setKey('') } }, [
112
+ element(UI.SettingsSecretField, { key: 'key', id: 'gotry-flyai-key', label: t('key'), hint: t('keyHint'), text: key,
113
+ configured: Boolean(status?.configured), stateLabel: t(status?.configured ? 'configured' : 'anonymous'), disabled: disabled,
114
+ onEdit: function (value) { setKey(value); setError(''); setNotice('') } }),
115
+ ]),
116
+ error ? element('p', { key: 'error', role: 'alert', style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
117
+ notice ? element('p', { key: 'notice', role: 'status', style: { margin: 0 } }, notice) : null,
118
+ element('div', { key: 'actions', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } }, [
119
+ element(UI.Button, { key: 'retry', type: 'button', variant: 'outline', disabled: busy,
120
+ onClick: function () { setReload(function (value) { return value + 1 }) } }, t('retry')),
121
+ status?.configured && status.writable ? element(UI.Button, { key: 'clear', type: 'button', disabled: busy,
122
+ onClick: function () { void write('clear') } }, t('clear')) : null,
123
+ ]),
124
+ ])
125
+ }
126
+
127
+ function ArtifactBrowser(props) {
128
+ var t = props.t, info = props.useTabInfo(), sessionId = props.sessionId
129
+ var cwd = props.useSessions(function (sessions) { return sessions.byId[sessionId]?.cwd })
130
+ var state = React.useState(null), result = state[0], setResult = state[1]
131
+ var queryState = React.useState(''), search = queryState[0], setSearch = queryState[1]
132
+ var pageState = React.useState(0), offset = pageState[0], setOffset = pageState[1]
133
+ var refreshState = React.useState(0), refresh = refreshState[0], setRefresh = refreshState[1]
134
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
135
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
136
+ React.useEffect(function () {
137
+ if (!sessionId || !info.tab.visible) return
138
+ var controller = new AbortController()
139
+ setBusy(true); setError('')
140
+ var query = new URLSearchParams({ sessionId: sessionId, search: search, offset: String(offset), limit: '20' })
141
+ webRequest('artifacts?' + query, { signal: controller.signal }).then(function (value) {
142
+ if (!controller.signal.aborted) setResult(value)
143
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
144
+ .finally(function () { if (!controller.signal.aborted) setBusy(false) })
145
+ return function () { controller.abort() }
146
+ }, [sessionId, info.tab.visible, search, offset, refresh])
147
+ function open(path) {
148
+ var normalized = String(path).replace(/\\/g, '/')
149
+ var root = String(cwd || '').replace(/\\/g, '/').replace(/\/$/, '')
150
+ var relative = root && normalized.startsWith(root + '/') ? normalized.slice(root.length + 1) : normalized
151
+ info.tab.actions.openResource('dsh-resource://file/session/' + encodeURIComponent(sessionId) + '/' + relative.split('/').map(encodeURIComponent).join('/'))
152
+ }
153
+ var entries = result?.artifacts || []
154
+ return element('section', { 'data-gotry-artifacts-browser': '', style: { padding: '16px', display: 'grid', alignContent: 'start', gap: '12px', height: '100%', overflow: 'auto', boxSizing: 'border-box', minWidth: 0 } }, [
155
+ element('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 } }, [
156
+ element(UI.Input, { key: 'search', 'aria-label': t('search'), placeholder: t('searchPlaceholder'), value: search,
157
+ style: { minWidth: 0, width: '100%' }, onChange: function (event) { setSearch(event.target.value); setOffset(0) } }),
158
+ element(UI.Button, { key: 'refresh', size: 'sm', disabled: busy || !sessionId,
159
+ onClick: function () { setRefresh(function (value) { return value + 1 }) } }, busy ? t('loading') : t('refresh')),
160
+ ]),
161
+ error ? element('p', { key: 'error', role: 'alert' }, error) : null,
162
+ !sessionId ? element('p', { key: 'no-session' }, t('noSession')) : !busy && entries.length === 0 ? element('p', { key: 'empty' }, t(search ? 'noMatches' : 'empty')) : null,
163
+ element('div', { key: 'list', 'aria-busy': busy, style: { display: 'grid', gap: '8px' } }, entries.map(function (entry) {
164
+ var html = /\.html?$/i.test(entry.path)
165
+ return element(UI.Button, { key: entry.path, variant: 'outline', 'data-gotry-artifact-entry': entry.path,
166
+ title: html ? t('htmlHint') : entry.path, onClick: function () { open(entry.path) },
167
+ style: { width: '100%', height: 'auto', padding: '12px', textAlign: 'left', justifyContent: 'flex-start', minWidth: 0 } }, [
168
+ element(UI.FileTypeIcon, { key: 'icon', path: entry.path }),
169
+ element('span', { key: 'body', style: { display: 'grid', gap: '4px', minWidth: 0, flex: 1 } }, [
170
+ element('span', { key: 'title', style: { overflowWrap: 'anywhere' } }, entry.title),
171
+ element('span', { key: 'meta', style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', overflowWrap: 'anywhere' } },
172
+ [entry.status, entry.updated ? new Date(entry.updated).toLocaleString() : '', html ? t('html') : t('open')].filter(Boolean).join(' · ')),
173
+ ]),
174
+ ])
175
+ })),
176
+ result?.total > 0 ? element('div', { key: 'pagination', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } }, [
177
+ element(UI.Button, { key: 'previous', size: 'sm', disabled: busy || offset === 0, onClick: function () { setOffset(Math.max(0, offset - 20)) } }, t('previous')),
178
+ element('span', { key: 'count', role: 'status', style: { fontSize: '12px' } }, (entries.length ? offset + 1 : 0) + '–' + (offset + entries.length) + ' / ' + result.total),
179
+ element(UI.Button, { key: 'next', size: 'sm', disabled: busy || !result.truncated, onClick: function () { setOffset(result.nextOffset) } }, t('next')),
180
+ ]) : null,
181
+ ])
182
+ }
15
183
 
16
184
  function element(type, props, children) {
17
185
  return React.createElement(type, props, ...(Array.isArray(children) ? children : [children]))
@@ -194,6 +362,21 @@ window.__ModuleLoader__.load({
194
362
  return scope.slots.register({ name: 'tool.call.toolview', key: 'gotry_artifacts_read' }, ArtifactToolView)
195
363
  })
196
364
  })
365
+ ctx.inject(['slots', 'locale'], function (scope) {
366
+ var ns = 'gotry.web'
367
+ var t = scope.locale.bind(ns)
368
+ scope.effect(function () { return scope.locale.register(ns, webLocale) })
369
+ scope.slots.inject('plugins.item', function () {
370
+ return scope.slots.register({ name: 'plugins.item', id: 'gotry', order: 50, label: function () { return t('title') }, locale: ns }, FlyaiSettings)
371
+ })
372
+ scope.inject(['sidebarRightTabs'], function (sidebar) {
373
+ sidebar.effect(function () { return sidebar.sidebarRightTabs.register({ id: '@danceiny/gotry:artifacts', kind: 'gotry-artifacts',
374
+ title: function () { return t('artifacts') }, guide: [{ id: 'gotry-artifacts', order: 15, title: function () { return t('artifacts') }, description: function () { return t('artifactsHint') } }] }) })
375
+ sidebar.slots.inject('sidebar.right.pane.tab', function () {
376
+ return sidebar.slots.register({ name: 'sidebar.right.pane.tab', key: '@danceiny/gotry:artifacts', locale: ns }, ArtifactBrowser)
377
+ })
378
+ })
379
+ })
197
380
  }
198
381
 
199
382
  exports.apply = apply
@@ -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
@@ -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();
@@ -19,6 +19,7 @@ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { createRequire } from 'node:module';
20
20
  import { homedir } from 'node:os';
21
21
  import { join, resolve } from 'node:path';
22
+ import { checkExtensionStatus } from '../../bin/gotry-extension-status.js';
22
23
  import { readLatestChannelEvents } from './channel-health.js';
23
24
  import { displayEndpoint, endpointFingerprint, readFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, } from './flyai-config.js';
24
25
  /** hbcli 已知安装位(与 capabilities/hbcli.ts hbcliBinCandidates 同清单) */
@@ -124,17 +125,8 @@ export async function runDoctorChecks(opts = {}) {
124
125
  items.push(nodeOk(nodeVersion)
125
126
  ? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
126
127
  : { id: 'node', label: 'Node 运行时', status: 'missing', detail: `Node ${nodeVersion} 过旧(gotry 需 ≥22.15)`, fix: '升级 Node.js 至 22.15+(https://nodejs.org)' });
127
- // 1. Stai Travel Bridge 扩展(账号会话通道的传输层;装在哪由 bootstrap setup 管理)
128
- // D-24 自适应(#117):离线只能探测本地 unpacked 通道;商店版无本地文件面——
129
- // missing detail 明示「商店版用户可忽略本项」,不再把商店版用户误导成本地通道缺失
130
- const extManifest = join(home, '.gotry', 'extension', 'manifest.json');
131
- items.push(existsSync(extManifest)
132
- ? { id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
133
- : {
134
- id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing',
135
- detail: '本地通道未落位(~/.gotry/extension/manifest.json 不存在)。影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。若你已从 Chrome 商店安装(自动更新,商店版 ID oeajpicc…),本项可忽略——会话检索可用性以运行时为准(gotry_session_search 的 verdict)。若未安装:应用商店一键装即可',
136
- fix: '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd',
137
- });
128
+ // 1. CLI 与工具层共用只读检测:安装、落位、连接是不同证据。
129
+ items.push({ id: 'extension', label: 'Stai Travel Bridge 扩展', ...await checkExtensionStatus({ homeDir: home, env, ports: opts.extensionPorts }) });
138
130
  // 2. agent-reach(网页/社媒读取;.venv 装在包内,与 agent-reach.ts venvPython 同位)
139
131
  const venvPython = join(repoRoot, '.venv/bin/python');
140
132
  const reachBin = join(repoRoot, '.venv/bin/agent-reach');
@@ -229,7 +221,7 @@ export async function runDoctorChecks(opts = {}) {
229
221
  items.push({
230
222
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
231
223
  detail: `未配置 key——匿名试用中(共享额度易达限;达限报 Trial limit reached)${flyaiQuotaNote}。影响面:共享池额度小,频繁会话易触顶;达限本会话内 gotry_flyai_search(机票/酒店/票务/AI/万豪 8 类)失败,改走 gotry_session_search 账号会话通道。`,
232
- fix: '本机运行 `gotry setup flyai`(隐藏输入,候选 key 先 scrub-env 验证后保存);打开 https://flyai.open.fliggy.com/console,登录后复制 API Key',
224
+ fix: 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`:① 打开 https://flyai.open.fliggy.com/console 复制 API Key;② 在终端隐藏粘贴;③ 验证后保存。也可运行 `npx @danceiny/gotry doctor --fix` 按提示配置',
233
225
  });
234
226
  }
235
227
  else {
@@ -254,7 +246,7 @@ export async function runDoctorChecks(opts = {}) {
254
246
  items.push({
255
247
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
256
248
  detail: `${reason}(${sourceText};endpoint ${displayEndpoint(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})——非空不等于鉴权通过`,
257
- fix: '本机运行 `gotry setup flyai` 重新验证后保存;`gotry setup flyai --clear` 回退匿名',
249
+ fix: 'Web 打开「插件 → GoTry → FlyAI」重新验证;或运行 `npx @danceiny/gotry setup flyai`;`npx @danceiny/gotry setup flyai --clear` 回退匿名',
258
250
  });
259
251
  }
260
252
  }
@@ -264,7 +256,7 @@ export async function runDoctorChecks(opts = {}) {
264
256
  ? { id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'ok', detail: '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' }
265
257
  : {
266
258
  id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'missing',
267
- detail: '未安装——dsh web 无右侧工作台,产物与 doctor 报告只能在对话里看(gotry_artifacts_list)',
259
+ detail: '未安装此增强组件——Web 自带 GoTry 产物页与原生预览;可选补装以增强文件浏览',
268
260
  fix: 'npx @danceiny/gotry doctor --fix',
269
261
  });
270
262
  // 6. dsh-calendar(patch 分发面宿主插件;D-9 拍板:默认不挂载)
@@ -371,7 +363,7 @@ export async function runDoctorChecks(opts = {}) {
371
363
  const broken = items.filter(i => i.status !== 'ok' && i.id !== 'llm-key');
372
364
  const summary = broken.length === 0
373
365
  ? `体检通过:${items.length} 项全部就绪(可选依赖齐,LLM key 归 dsh 宿主管)。`
374
- : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
366
+ : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : i.id === 'extension' ? '待确认' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
375
367
  return { ok: broken.length === 0, items, summary };
376
368
  }
377
369
  /** 修复指引入表:命令类才加反引号(prose 类如「到控制台申请 key」原样) */
@@ -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)');
@@ -36,13 +36,14 @@ const fakeReact = {
36
36
  useState(initial) { return [initial, () => { }]; },
37
37
  };
38
38
  const clientExports = registrations[0].factory(name => {
39
- assert.equal(name, 'react');
40
- return fakeReact;
39
+ assert.ok(['react', '@deepseek-ai/dsh-client-ui-primitives'].includes(name));
40
+ return name === 'react' ? fakeReact : {};
41
41
  });
42
42
  assert.deepEqual(Object.keys(clientExports), ['apply', 'inject']);
43
43
  const slotRegistrations = [];
44
44
  const registrationContext = {
45
- inject(_deps, callback) { callback(registrationContext); },
45
+ inject(deps, callback) { if (deps.every(name => name in registrationContext))
46
+ callback(registrationContext); },
46
47
  slots: {
47
48
  inject(_slot, callback) { callback(); },
48
49
  register(definition, view) {
@@ -0,0 +1,31 @@
1
+ /** Successful bounded renders produce native delivery events; failures and escaped paths never do. */
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ const moduleUrl = new URL('../src/artifact-delivery.js', import.meta.url).href;
7
+ const delivery = await import(moduleUrl).catch(() => undefined);
8
+ assert.equal(typeof delivery?.registerArtifactDeliveries, 'function', 'bounded render tools must publish native file deliveries');
9
+ const root = mkdtempSync(join(tmpdir(), 'gotry-artifact-delivery-'));
10
+ try {
11
+ const path = join(root, 'trip.html');
12
+ writeFileSync(path, '<h1>Trip</h1>');
13
+ let observe = () => { throw new Error('observer absent'); };
14
+ const events = [];
15
+ const session = { header: { cwd: root }, view: () => ({ getSnapshot: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
16
+ append: (name, value) => events.push({ name, value }) };
17
+ delivery.registerArtifactDeliveries({ get: () => ({ stateOf: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
18
+ on: (name, fn) => { assert.equal(name, 'tools/result'); observe = fn; } });
19
+ const exec = { name: 'gotry_itinerary_render', callId: 'render-1', agent: { session }, signal: new AbortController().signal };
20
+ observe(exec, { isError: false, value: { ok: true, path } });
21
+ assert.deepEqual(events, [{ name: 'deliverables/presented', value: { turn: 7, callId: 'render-1', files: [{ path, description: 'GoTry itinerary' }] } }]);
22
+ for (const value of [{ ok: false, path }, { ok: true, path: join(root, 'missing.html') }, { ok: true, path: join(root, '..', 'private.html') }])
23
+ observe(exec, { isError: false, value });
24
+ observe({ ...exec, name: 'gotry_artifacts_read' }, { isError: false, value: { ok: true, path } });
25
+ observe(exec, { isError: true, value: { ok: true, path } });
26
+ assert.equal(events.length, 1);
27
+ console.log('GoTry native artifact deliveries: real bounded render only PASS');
28
+ }
29
+ finally {
30
+ rmSync(root, { recursive: true, force: true });
31
+ }