dsh-mobile-plugin 0.0.0-stage → 0.1.57
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.
- package/README.md +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 端点表:旧 `apiProxy.<ns>.<method>` 的键 ⇄ 宿主 wire 名与参数形状。
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ 这张表是人肉对照宿主 descriptor 校验的,**没有任何机器可校验机制**,而它错一次的
|
|
7
|
+
* 后果是静默的:`sessions.projections` 曾少包一层 `request`,宿主每次都回 400,
|
|
8
|
+
* 而 App 侧把 400 catch 掉了,于是整个统计 UI 凭空消失。args 的键名必须**恰好**是
|
|
9
|
+
* descriptor 的 wire 名(多一个少一个都报 gateway/arguments-invalid,dropUnexpectedArgs
|
|
10
|
+
* 只摘多余的键,救不了「少一个」)。改动前请先在 test/host-bridge-endpoints.test.cjs
|
|
11
|
+
* 里补一条参数化用例。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { invoke, invokeEnvelope, ok, fail, pick, requestId, unsupported, warn } = require('./transport.js');
|
|
15
|
+
const { historyViaPage, modelCatalog, workspaceListViaFollow, subagentCatalog } = require('./shims.js');
|
|
16
|
+
const { createDirectory, hostServesBrowse, listDirectory, wireError } = require('../directory-browse.js');
|
|
17
|
+
|
|
18
|
+
// ── 端点表 ────────────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// inv(namespace, method, buildArgs):最常见的一档 —— 宿主方法只有一个 `request` 参数,
|
|
21
|
+
// payload 原样塞进去即可。少数几个要改名(sessionId → agentId)、要丢字段
|
|
22
|
+
// (fork 的 increaseTitle)、要补必填字段(prompt 的 requestId)。
|
|
23
|
+
|
|
24
|
+
function inv(namespace, method, buildArgs) {
|
|
25
|
+
return (ctx, payload, signal) => invokeEnvelope(ctx, namespace, method, (buildArgs || ((p) => ({ request: p })))(payload), signal);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ENDPOINTS = {
|
|
29
|
+
// sessions
|
|
30
|
+
// 会话列表:键名是 `_request` —— 宿主签名 `list(_request: SessionListRequest, signal)`
|
|
31
|
+
// 用下划线前缀表示「保留的空请求」,assertExactArguments 认的就是这个字面名。
|
|
32
|
+
'sessions.list': inv('session', 'list', (p) => ({ _request: p && Object.keys(p).length ? p : {} })),
|
|
33
|
+
'sessions.history': (ctx, payload, signal) => historyViaPage(ctx, payload, signal),
|
|
34
|
+
// 0.2.0 把投影从 session.page 的返回值里拆走了(page 只回 events/hasMore/cursor),
|
|
35
|
+
// 整帧 values 只能走这一个端点。
|
|
36
|
+
//
|
|
37
|
+
// args 形状由宿主 typertGateway 的 assertExactArguments 把关:键名必须**恰好**是
|
|
38
|
+
// descriptor 的 wire 名,多一个少一个都报 gateway/arguments-invalid(而
|
|
39
|
+
// dropUnexpectedArgs 只会摘多余的键,救不了「少一个」)。session/projections 的
|
|
40
|
+
// descriptor 只有一个参数,wire 名是 `request`,内层是 `object({ sessionId })`
|
|
41
|
+
// (app.asar 里 dsh_api_session_controller 的 session.projections parameter_0 schema)。
|
|
42
|
+
//
|
|
43
|
+
// 这里曾经写成 `pick(p, ['sessionId'])`(注释断言「没有 request 包层」,是错的):
|
|
44
|
+
// 少一层 request,宿主每次都回 `missing "request"` → /session/stats 恒 400。而
|
|
45
|
+
// App 侧把 400 catch 掉了(app/src/ChatScreen.tsx 的统计轮询 `catch { }`),于是
|
|
46
|
+
// **一整层 UI 静默消失**:输入框下方的统计行、ContextMeter 圆环、TodoDock、
|
|
47
|
+
// GoalBar、权限档、planMode、图片上限——它们全都来自这一个投影帧。
|
|
48
|
+
// 形状写错的代价是不报错的「功能不见了」,所以每个端点都要对着宿主 descriptor 核。
|
|
49
|
+
'sessions.projections': inv('session', 'projections', (p) => ({ request: pick(p, ['sessionId']) })),
|
|
50
|
+
'sessions.prompt': inv('session', 'prompt', (p) => ({
|
|
51
|
+
request: {
|
|
52
|
+
...pick(p, ['sessionId', 'mode', 'content', 'clientTimeZone']),
|
|
53
|
+
requestId: typeof (p && p.requestId) === 'string' && p.requestId ? p.requestId : requestId('mobile-prompt'),
|
|
54
|
+
},
|
|
55
|
+
})),
|
|
56
|
+
'sessions.cancel': inv('session', 'cancel'),
|
|
57
|
+
'sessions.rename': inv('session', 'rename'),
|
|
58
|
+
'sessions.create': inv('session', 'create'),
|
|
59
|
+
'sessions.search': inv('session', 'search'),
|
|
60
|
+
'sessions.selectModel': inv('session', 'selectModel'),
|
|
61
|
+
'sessions.attachment': inv('session', 'attachment'),
|
|
62
|
+
'sessions.updateQueue': inv('session', 'updateQueue'),
|
|
63
|
+
// 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
|
|
64
|
+
'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
|
|
65
|
+
// 宿主的模型目录是全局的(不再按 session 查),这里翻译成插件/移动端要的形状。
|
|
66
|
+
'sessions.models': (ctx, payload, signal) => modelCatalog(ctx, signal),
|
|
67
|
+
|
|
68
|
+
// agentPresets(namespace 是复数 agentPresets)
|
|
69
|
+
'agentPresets.list': inv('agentPresets', 'list', () => ({})),
|
|
70
|
+
'agentPresets.read': inv('agentPresets', 'read', (p) => ({ agentPreset: p && p.agentPreset })),
|
|
71
|
+
'agentPresets.select': (ctx, payload, signal) =>
|
|
72
|
+
invoke(ctx, 'agentPresets', 'select', { agentId: payload.sessionId, agentPreset: payload.agentPreset }, signal)
|
|
73
|
+
.then((r) => (r.error ? fail(r.error) : ok({ agentPreset: r.value }))),
|
|
74
|
+
'agentPresets.copy': () => unsupported('复制 agent preset 的接口'),
|
|
75
|
+
'agentPresets.remove': () => unsupported('删除 agent preset 的接口'),
|
|
76
|
+
|
|
77
|
+
// workspace
|
|
78
|
+
'workspace.list': (ctx, payload, signal) => workspaceListViaFollow(ctx, signal),
|
|
79
|
+
'workspace.create': inv('workspace', 'create'),
|
|
80
|
+
'workspace.rename': inv('workspace', 'rename'),
|
|
81
|
+
'workspace.delete': inv('workspace', 'delete'),
|
|
82
|
+
'workspace.archiveSession': inv('workspace', 'archiveSession'),
|
|
83
|
+
'workspace.unarchiveSession': inv('workspace', 'unarchiveSession'),
|
|
84
|
+
'workspace.pinSession': inv('workspace', 'pinSession'),
|
|
85
|
+
'workspace.unpinSession': inv('workspace', 'unpinSession'),
|
|
86
|
+
'workspace.insertSessionBefore': inv('workspace', 'insertSessionBefore'),
|
|
87
|
+
|
|
88
|
+
// host.* → directoryPicker.*(宿主服务 browse 时),否则回落到插件自带的 browse 原语。
|
|
89
|
+
//
|
|
90
|
+
// 为什么会有「否则」:宿主那套选择器由 directory-picker-auto 启动时二选一,只有
|
|
91
|
+
// 「web 服务绑到非回环」或「平台不是 darwin/win32」才挂 browse;无头移动宿主由本包的
|
|
92
|
+
// cordis.mobile-host.yml 钉成 browse,**桌面 profile 宿主**则解析成 native。于是
|
|
93
|
+
// 「桌面宿主 + 手机远程连」这一格没人盖 —— 手机点「添加工作区」只拿到
|
|
94
|
+
// `host.listDirectory needs the browse capability`,一个条目都列不出来。
|
|
95
|
+
// 判定与回落语义见 lib/mobile/directory-browse.js 的头注释:宿主服务 browse 就整条走
|
|
96
|
+
// 宿主(策略单一真源),宿主明确只服务 native(或查不到选择器)时用插件自己那份。
|
|
97
|
+
//
|
|
98
|
+
// 金标准(test/host-bridge-endpoints.test.cjs)钉的仍是「走宿主」那条路由:它的假 ctx
|
|
99
|
+
// 显式给了一个 browse 能力对象。
|
|
100
|
+
'host.listDirectory': (ctx, payload, signal) => (hostServesBrowse(ctx)
|
|
101
|
+
? invokeEnvelope(ctx, 'directoryPicker', 'list', pick(payload, ['path']), signal)
|
|
102
|
+
: localListDirectory(ctx, payload, signal)),
|
|
103
|
+
'host.createDirectory': (ctx, payload, signal) => (hostServesBrowse(ctx)
|
|
104
|
+
? invoke(ctx, 'directoryPicker', 'createDirectory', { path: payload.path, name: payload.name }, signal)
|
|
105
|
+
.then((r) => (r.error ? fail(r.error) : ok({ path: r.value })))
|
|
106
|
+
: localCreateDirectory(ctx, payload, signal)),
|
|
107
|
+
|
|
108
|
+
// skills / subagents
|
|
109
|
+
'skills.list': inv('skills', 'list'),
|
|
110
|
+
'subagents.prompt': inv('subagents', 'prompt', (p) => ({
|
|
111
|
+
request: {
|
|
112
|
+
...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
|
|
113
|
+
requestId: typeof (p && p.requestId) === 'string' && p.requestId ? p.requestId : requestId('mobile-subagent'),
|
|
114
|
+
delivery: (p && p.delivery) || 'queue',
|
|
115
|
+
},
|
|
116
|
+
})),
|
|
117
|
+
'subagents.interrupt': (ctx, payload, signal) =>
|
|
118
|
+
invoke(ctx, 'subagents', 'interruptByParent', {
|
|
119
|
+
childSessionId: payload.childSessionId,
|
|
120
|
+
parentSessionId: payload.parentSessionId,
|
|
121
|
+
mode: payload.mode || 'continuable',
|
|
122
|
+
}, signal).then((r) => (r.error ? fail(r.error) : ok(r.value))),
|
|
123
|
+
'subagents.list': (ctx, payload, signal) => subagentCatalog(ctx, payload, signal),
|
|
124
|
+
|
|
125
|
+
// goals:objective 被宿主挪进了 request 里
|
|
126
|
+
'goals.edit': (ctx, payload, signal) =>
|
|
127
|
+
invoke(ctx, 'goals', 'edit', {
|
|
128
|
+
agentId: payload.sessionId,
|
|
129
|
+
ref: payload.ref,
|
|
130
|
+
request: pick(payload, ['objective', 'maxGoalRounds']),
|
|
131
|
+
}, signal).then((r) => (r.error ? fail(r.error) : ok(r.value))),
|
|
132
|
+
'goals.pause': (ctx, payload, signal) => goalAction(ctx, 'pause', payload, signal),
|
|
133
|
+
'goals.resume': (ctx, payload, signal) => goalAction(ctx, 'resume', payload, signal),
|
|
134
|
+
'goals.complete': (ctx, payload, signal) => goalAction(ctx, 'complete', payload, signal),
|
|
135
|
+
'goals.clear': (ctx, payload, signal) => goalAction(ctx, 'clear', payload, signal),
|
|
136
|
+
|
|
137
|
+
// settings / llm / credentials
|
|
138
|
+
'settings.describe': inv('settings', 'describe', () => ({})),
|
|
139
|
+
'settings.mutate': inv('settings', 'mutate', (p) => pick(p, ['ns', 'ops', 'expectedRevision'])),
|
|
140
|
+
'llm.providers': (ctx, payload, signal) =>
|
|
141
|
+
invoke(ctx, 'llm', 'listProviders', {}, signal).then((r) => {
|
|
142
|
+
if (r.error) return fail(r.error);
|
|
143
|
+
const list = Array.isArray(r.value) ? r.value : [];
|
|
144
|
+
return ok({
|
|
145
|
+
providers: list.map((item) => ({
|
|
146
|
+
provider: item && item.id,
|
|
147
|
+
displayName: item && (item.name || item.id),
|
|
148
|
+
active: true,
|
|
149
|
+
})),
|
|
150
|
+
});
|
|
151
|
+
}),
|
|
152
|
+
'credentials.describe': inv('credentials', 'describe', (p) => ({ refs: p.refs })),
|
|
153
|
+
'credentials.set': inv('credentials', 'set', (p) => ({ ref: p.ref, value: p.value })),
|
|
154
|
+
'credentials.unset': inv('credentials', 'unset', (p) => ({ ref: p.ref })),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function goalAction(ctx, method, payload, signal) {
|
|
158
|
+
return invoke(ctx, 'goals', method, { agentId: payload.sessionId, ref: payload.ref }, signal)
|
|
159
|
+
.then((r) => (r.error ? fail(r.error) : ok(r.value)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 宿主只服务 native 选择器时的清单条(**不回落到网关**)。
|
|
164
|
+
*
|
|
165
|
+
* 这里的 code/message 与宿主 browse 后端那条路完全同名同形:本地实现的
|
|
166
|
+
* `directory-unreadable` 等翻成 `directory-picker/unreadable` 这一组 wire 码,App 侧的
|
|
167
|
+
* `formatDirectoryError` 已经按它们写好了文案,两条路对客户端应当不可区分。
|
|
168
|
+
*/
|
|
169
|
+
async function localListDirectory(ctx, payload, signal) {
|
|
170
|
+
const path = payload && typeof payload.path === 'string' && payload.path.trim() ? payload.path.trim() : undefined;
|
|
171
|
+
try {
|
|
172
|
+
return ok(await listDirectory(path, { signal }));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
warn(ctx, `宿主选择器不是 browse,listDirectory 走插件自带实现:${error && error.message}`);
|
|
175
|
+
return fail(wireError(error));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 宿主只服务 native 选择器时的建目录条;回创建出来的 path,与网关那条同形状。 */
|
|
180
|
+
async function localCreateDirectory(ctx, payload, signal) {
|
|
181
|
+
try {
|
|
182
|
+
return ok({ path: await createDirectory(payload.path, payload.name) });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
warn(ctx, `宿主选择器不是 browse,createDirectory 走插件自带实现:${error && error.message}`);
|
|
185
|
+
return fail(wireError(error));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { ENDPOINTS, goalAction, localCreateDirectory, localListDirectory };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 事件合成:旧设备级 `events.mux` 的帧流 ⇄ 0.2.0 拆开的三条流。
|
|
5
|
+
*
|
|
6
|
+
* 旧 mux 一次给出「这个设备正在发生的一切」,0.2.0 没有对应端点(事件已拆成
|
|
7
|
+
* session/follow、session/control、workspace/follow),这里按 payload 里有没有 sessionId
|
|
8
|
+
* 合成同形帧:带 sessionId → 该会话的 follow + job 整帧 + 投影整帧;不带 → 设备级的
|
|
9
|
+
* 工作区基线 + 全局投影整帧。调用方(stream.js / events.js / index.js 的 mirror)本来就
|
|
10
|
+
* 按 frame.payload 里的 type 分发,形状对上了它们一行都不用改。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { openStream, warn } = require('./transport.js');
|
|
14
|
+
|
|
15
|
+
// ── 事件合成 ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** 把 payload.sessionId 取出来;没有就按设备级处理。 */
|
|
18
|
+
function sessionHint(request) {
|
|
19
|
+
const payload = request && request.payload;
|
|
20
|
+
const sessionId = payload && payload.sessionId;
|
|
21
|
+
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 一条流跑不通不该拖垮其余流:这里把异常吃掉,只丢这一条源的帧。 */
|
|
25
|
+
async function* tolerate(frames, what, ctx) {
|
|
26
|
+
try {
|
|
27
|
+
for await (const frame of frames) yield frame;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
warn(ctx, `${what} 事件流中断:${error && error.message ? error.message : String(error)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 单会话事件流:follow 的 live 事件(跳过 snapshot,历史走 session.history)。 */
|
|
34
|
+
async function* sessionEventFrames(ctx, sessionId, signal) {
|
|
35
|
+
const frames = await openStream(ctx, 'session', 'follow', {
|
|
36
|
+
request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
|
|
37
|
+
}, signal);
|
|
38
|
+
for await (const frame of tolerate(frames, `session/${sessionId}`, ctx)) {
|
|
39
|
+
if (!frame || frame.type !== 'event' || !frame.event) continue;
|
|
40
|
+
yield { payload: { type: 'session/event', sessionId, event: frame.event } };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 后台任务整帧。宿主 job/list 是流,首帧即全量 rows,之后按变化推。 */
|
|
45
|
+
async function* sessionJobFrames(ctx, sessionId, signal) {
|
|
46
|
+
const frames = await openStream(ctx, 'job', 'list', { request: { sessionId } }, signal);
|
|
47
|
+
for await (const frame of tolerate(frames, `job/${sessionId}`, ctx)) {
|
|
48
|
+
if (!frame || frame.type !== 'rows') continue;
|
|
49
|
+
yield { payload: { type: 'session/jobs', sessionId, jobs: Array.isArray(frame.jobs) ? frame.jobs : [] } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 投影整帧(排队条、计划条、目标条、模型选择等全量快照)。
|
|
55
|
+
*
|
|
56
|
+
* 宿主 session.control 是设备级投影流:首帧 baseline 一次性给出
|
|
57
|
+
* { projections: { [sessionId]: { asOfSeq, values } } },之后是
|
|
58
|
+
* { type:'projection', sessionId, key, value, seq } 的单键更新。
|
|
59
|
+
* 旧帧是整帧(幂等全量),这里保持整帧语义 —— 更新帧就带上累积后的 values 重发。
|
|
60
|
+
*/
|
|
61
|
+
function projectionState() {
|
|
62
|
+
const values = new Map(); // sessionId -> { asOfSeq, values }
|
|
63
|
+
return {
|
|
64
|
+
applyBaseline(baseline) {
|
|
65
|
+
const projections = baseline && typeof baseline === 'object' ? baseline.projections : null;
|
|
66
|
+
if (!projections || typeof projections !== 'object') return [];
|
|
67
|
+
const touched = [];
|
|
68
|
+
for (const [sessionId, entry] of Object.entries(projections)) {
|
|
69
|
+
const next = {
|
|
70
|
+
asOfSeq: Number.isSafeInteger(entry && entry.asOfSeq) ? entry.asOfSeq : 0,
|
|
71
|
+
values: (entry && entry.values && typeof entry.values === 'object') ? entry.values : {},
|
|
72
|
+
};
|
|
73
|
+
values.set(sessionId, next);
|
|
74
|
+
touched.push(sessionId);
|
|
75
|
+
}
|
|
76
|
+
return touched;
|
|
77
|
+
},
|
|
78
|
+
applyUpdate(update) {
|
|
79
|
+
const sessionId = update && update.sessionId;
|
|
80
|
+
if (typeof sessionId !== 'string') return [];
|
|
81
|
+
const current = values.get(sessionId) || { asOfSeq: 0, values: {} };
|
|
82
|
+
if (typeof update.key === 'string') current.values = { ...current.values, [update.key]: update.value };
|
|
83
|
+
if (Number.isSafeInteger(update.seq)) current.asOfSeq = update.seq;
|
|
84
|
+
values.set(sessionId, current);
|
|
85
|
+
return [sessionId];
|
|
86
|
+
},
|
|
87
|
+
snapshot(sessionId) {
|
|
88
|
+
return values.get(sessionId) || null;
|
|
89
|
+
},
|
|
90
|
+
/** 全量快照的平表(sessionId → values),只读用途:投影基线要一次看全部会话。 */
|
|
91
|
+
all() {
|
|
92
|
+
return new Map(values);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 宿主投影 `inbox` → 移动端 `session/queue` 帧的**平铺 items**。
|
|
99
|
+
*
|
|
100
|
+
* 形状差是这条链上唯一的坑:宿主把 inbox 定义成按键分组的对象
|
|
101
|
+
* `{ 'next-turn': Message[], 'next-step': Message[] }`(桌面 bundle 里
|
|
102
|
+
* `@deepseek-ai/dsh-agent-loop/inbox` 的 `key: 'inbox'` + `wire.view: (state) => state`,
|
|
103
|
+
* 桌面 QueueDock 读的也正是 `inbox['next-turn']`),而移动端帧的约定是数组
|
|
104
|
+
* `[{ id, placement, message }]`(`app/src/types/chatStream.ts` 的 QueueItem、
|
|
105
|
+
* `app/src/hooks/streamFrame.ts` 的 `session/queue` 分支)。原样转发对象时 App 的
|
|
106
|
+
* `Array.isArray(items)` 判定失败、整帧被静默丢弃 —— 排队条一次都不显示。
|
|
107
|
+
*
|
|
108
|
+
* `next-turn`(等下一轮才开始的消息)→ `placement: 'queued'`,QueueDock 只认这一个值。
|
|
109
|
+
* `next-step`(本步末尾注入的插话)暂不合成 `steering`:手机发起的插话已有本地待发气泡
|
|
110
|
+
* (`useChatStream` 的 `pending-<clientMessageId>`)与落定后的正文行,多推一份会在同一处
|
|
111
|
+
* 多画一条用户气泡。`message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
|
|
112
|
+
*/
|
|
113
|
+
function queueItems(inbox, ctx) {
|
|
114
|
+
const rows = inbox && Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : [];
|
|
115
|
+
const items = [];
|
|
116
|
+
let dropped = 0;
|
|
117
|
+
for (const message of rows) {
|
|
118
|
+
if (!message || typeof message !== 'object') continue;
|
|
119
|
+
const id = typeof message.id === 'string' && message.id ? message.id : '';
|
|
120
|
+
if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
|
|
121
|
+
items.push({ id, placement: 'queued', message: { id, content: message.content } });
|
|
122
|
+
}
|
|
123
|
+
// 丢弃必须留痕:旧实现是裸 continue,手机上少一条待发消息而宿主侧一行日志都没有,
|
|
124
|
+
// 只能靠人对着桌面端数气泡。凭空造 id 更糟——App 的 session.updateQueue 按 id 定位,
|
|
125
|
+
// 假 id 会让「已落定」和「待发」两条记录对不上。
|
|
126
|
+
if (dropped > 0) warn(ctx, `session/queue 丢了 ${dropped} 条没有 id 的待发行(App 无法定位,手机上会少这几条)`);
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function* projectionFrames(ctx, sessionId, signal) {
|
|
131
|
+
const frames = await openStream(ctx, 'session', 'control', {}, signal);
|
|
132
|
+
const state = projectionState();
|
|
133
|
+
for await (const frame of tolerate(frames, 'session/control', ctx)) {
|
|
134
|
+
if (!frame) continue;
|
|
135
|
+
let touched = [];
|
|
136
|
+
if (frame.type === 'baseline') touched = state.applyBaseline(frame.value);
|
|
137
|
+
else if (frame.type === 'projection') touched = state.applyUpdate(frame);
|
|
138
|
+
for (const id of touched) {
|
|
139
|
+
if (sessionId && id !== sessionId) continue;
|
|
140
|
+
const current = state.snapshot(id);
|
|
141
|
+
yield { payload: { type: 'session/projection', sessionId: id, seq: current.asOfSeq, values: current.values } };
|
|
142
|
+
// 排队条按旧约定走独立的 session/queue 整帧(移动端 QueueDock 读这个类型),
|
|
143
|
+
// 宿主侧的对应投影是 inbox。
|
|
144
|
+
if (current.values && current.values.inbox !== undefined) {
|
|
145
|
+
yield { payload: { type: 'session/queue', sessionId: id, items: queueItems(current.values.inbox, ctx) } };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 设备级:工作区基线 + 全局投影(没有 sessionId 时能给的就这些)。 */
|
|
152
|
+
async function* deviceFrames(ctx, signal) {
|
|
153
|
+
const workspace = await openStream(ctx, 'workspace', 'follow', {}, signal).catch(() => null);
|
|
154
|
+
if (workspace) {
|
|
155
|
+
for await (const frame of tolerate(workspace, 'workspace/follow', ctx)) {
|
|
156
|
+
if (!frame) continue;
|
|
157
|
+
if (frame.type === 'baseline' && frame.value) {
|
|
158
|
+
yield { payload: { type: 'host/workspace', ...frame.value } };
|
|
159
|
+
} else if (frame.type === 'increment' || frame.type === 'change') {
|
|
160
|
+
yield { payload: { type: 'host/workspace', ...(frame.value && typeof frame.value === 'object' ? frame.value : { change: frame }) } };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
yield* projectionFrames(ctx, null, signal);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 旧 `events.mux`:设备级帧流,带 sessionId 时收窄成单会话视角。 */
|
|
168
|
+
function muxFrames(ctx, request, signal) {
|
|
169
|
+
const sessionId = sessionHint(request);
|
|
170
|
+
return (async function* run() {
|
|
171
|
+
if (!sessionId) {
|
|
172
|
+
yield* deviceFrames(ctx, signal);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
|
|
176
|
+
const jobs = openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx));
|
|
177
|
+
const follow = openStream(ctx, 'session', 'follow', {
|
|
178
|
+
request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
|
|
179
|
+
}, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
|
|
180
|
+
const projections = projectionState();
|
|
181
|
+
const sources = await Promise.all([control, jobs, follow].map((promise) => promise.then(async function* wrap(iterable) {
|
|
182
|
+
for await (const frame of iterable) {
|
|
183
|
+
if (frame && frame.type === 'rows') {
|
|
184
|
+
yield { payload: { type: 'session/jobs', sessionId, jobs: Array.isArray(frame.jobs) ? frame.jobs : [] } };
|
|
185
|
+
} else if (frame && frame.type === 'baseline') {
|
|
186
|
+
yield* applyBaseline(frame, projections, sessionId, ctx);
|
|
187
|
+
} else if (frame && frame.type === 'projection') {
|
|
188
|
+
yield* applyUpdate(frame, projections, sessionId, ctx);
|
|
189
|
+
} else if (frame && frame.type === 'event' && frame.event) {
|
|
190
|
+
yield { payload: { type: 'session/event', sessionId, event: frame.event } };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}).catch((error) => {
|
|
194
|
+
warn(ctx, `事件流中断:${error && error.message ? error.message : String(error)}`);
|
|
195
|
+
return (async function* empty() {})();
|
|
196
|
+
})));
|
|
197
|
+
const pending = new Map(sources.map((source, index) => [index, source.next()]));
|
|
198
|
+
while (pending.size > 0) {
|
|
199
|
+
const winner = await Promise.race([...pending.entries()].map(async ([index, step]) => ({ index, step: await step })));
|
|
200
|
+
if (winner.step.done) {
|
|
201
|
+
pending.delete(winner.index);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
pending.set(winner.index, sources[winner.index].next());
|
|
205
|
+
yield winner.step.value;
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 单会话视角下的投影合并:整条 mux 生命周期共用一个 state,否则增量帧会把
|
|
211
|
+
// baseline 里的 values 冲掉(移动端把 session/projection 当整帧消费)。
|
|
212
|
+
function applyBaseline(frame, state, sessionId, ctx) {
|
|
213
|
+
return projectionFramesFrom(ctx, frame, sessionId, state);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function applyUpdate(frame, state, sessionId, ctx) {
|
|
217
|
+
return projectionFramesFrom(ctx, frame, sessionId, state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function* projectionFramesFrom(ctx, frame, sessionId, state) {
|
|
221
|
+
const touched = frame.type === 'baseline' ? state.applyBaseline(frame.value) : state.applyUpdate(frame);
|
|
222
|
+
for (const id of touched) {
|
|
223
|
+
if (id !== sessionId) continue;
|
|
224
|
+
const current = state.snapshot(id);
|
|
225
|
+
yield { payload: { type: 'session/projection', sessionId: id, seq: current.asOfSeq, values: current.values } };
|
|
226
|
+
if (current.values && current.values.inbox !== undefined) {
|
|
227
|
+
yield { payload: { type: 'session/queue', sessionId: id, items: queueItems(current.values.inbox, ctx) } };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { muxFrames };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 没有 1:1 端点、需要跨端点合成或翻译的几处(翻页游标、模型目录、跟随流、投影基线)。 */
|
|
3
|
+
|
|
4
|
+
const { invoke, invokeEnvelope, ok, fail, pick, warn, deadline, openStream, SYNTHETIC_DEADLINE_MS } = require('./transport.js');
|
|
5
|
+
|
|
6
|
+
// ── bespoke:没有 1:1 端点、需要合成或翻译的几处 ──────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `session.history` → `session/page`。
|
|
10
|
+
*
|
|
11
|
+
* 宿主的 page 请求体多了个必填的 throughSeq(“读到哪一条为止”),没有默认值,必须
|
|
12
|
+
* 先知道会话当前游标 —— follow 流首帧的 snapshot.cursor 就是它。所以这里开一条
|
|
13
|
+
* follow 拿游标(读完首帧立刻 break,网关会取消这条流),再翻一页历史。
|
|
14
|
+
* 返回值把 records 拆回旧形状的 events(history-derive.js 只认 events/hasMore)。
|
|
15
|
+
*/
|
|
16
|
+
async function historyViaPage(ctx, payload, signal) {
|
|
17
|
+
const sessionId = payload && payload.sessionId;
|
|
18
|
+
if (typeof sessionId !== 'string' || !sessionId) return fail({ code: 'bad-request', message: '缺少 sessionId' }, 'bad-request');
|
|
19
|
+
let throughSeq = -1;
|
|
20
|
+
try {
|
|
21
|
+
const frames = await openStream(ctx, 'session', 'follow', {
|
|
22
|
+
request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
|
|
23
|
+
}, deadline(signal, SYNTHETIC_DEADLINE_MS));
|
|
24
|
+
for await (const frame of frames) {
|
|
25
|
+
if (frame && frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
|
|
26
|
+
throughSeq = frame.cursor;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return fail(error);
|
|
32
|
+
}
|
|
33
|
+
const request = {
|
|
34
|
+
address: { kind: 'session', sessionId },
|
|
35
|
+
throughSeq,
|
|
36
|
+
...pick(payload, ['maxMessages', 'beforeSeq', 'turnWindow']),
|
|
37
|
+
};
|
|
38
|
+
const result = await invoke(ctx, 'session', 'page', { request }, signal);
|
|
39
|
+
if (result.error) return fail(result.error);
|
|
40
|
+
const page = result.value && typeof result.value === 'object' ? result.value : {};
|
|
41
|
+
const records = Array.isArray(page.records) ? page.records : [];
|
|
42
|
+
return ok({
|
|
43
|
+
events: records.map((record) => (record && record.type === 'event' ? record.event : record)).filter(Boolean),
|
|
44
|
+
hasMore: page.hasMore === true,
|
|
45
|
+
cursor: throughSeq,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `session.models` → `session.modelCatalog`。
|
|
51
|
+
*
|
|
52
|
+
* 宿主改成全局目录了(args 为空),字段也换了名:default → current、
|
|
53
|
+
* routableProviders 变成「能不能用这个 provider」。移动端要的 groups 结构两边一致,
|
|
54
|
+
* 原样带过去即可。
|
|
55
|
+
*/
|
|
56
|
+
async function modelCatalog(ctx, signal) {
|
|
57
|
+
const result = await invoke(ctx, 'session', 'modelCatalog', {}, signal);
|
|
58
|
+
if (result.error) return fail(result.error);
|
|
59
|
+
const catalog = result.value && typeof result.value === 'object' ? result.value : {};
|
|
60
|
+
const current = catalog.default && typeof catalog.default === 'object'
|
|
61
|
+
? pick(catalog.default, ['provider', 'model', 'reasoningEffort'])
|
|
62
|
+
: null;
|
|
63
|
+
const routable = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
|
|
64
|
+
return ok({
|
|
65
|
+
...(current ? { current } : {}),
|
|
66
|
+
routable: Boolean(current && routable.includes(current.provider)),
|
|
67
|
+
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `workspace.list` → `workspace.follow` 的首帧 baseline。
|
|
73
|
+
*
|
|
74
|
+
* 0.2.0 的 workspace 只有 follow(流),没有一次性 list;但它的 baseline 值恰好就是
|
|
75
|
+
* 旧 list 的返回体(items + archivedSessionIds + pinnedSessionIds),所以取首帧即可。
|
|
76
|
+
*/
|
|
77
|
+
async function workspaceListViaFollow(ctx, signal) {
|
|
78
|
+
try {
|
|
79
|
+
const frames = await openStream(ctx, 'workspace', 'follow', {}, deadline(signal, SYNTHETIC_DEADLINE_MS));
|
|
80
|
+
for await (const frame of frames) {
|
|
81
|
+
if (frame && frame.type === 'baseline' && frame.value) return ok(frame.value);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return fail(error);
|
|
85
|
+
}
|
|
86
|
+
// 流「正常结束却没吐 baseline」不是「你没有工作区」,而是没取到。原先这里返回
|
|
87
|
+
// ok({ items: [] }),与真成功在下游完全无法区分:宿主正在初始化 / 正忙 / 网关给了
|
|
88
|
+
// 空流时都会走到这里,于是 { ok: true, items: [] } 一路畅通地通过 lib/index.js 的
|
|
89
|
+
// 镜像守卫、被当作权威清单推给中转,手机侧表现为工作区整行消失、会话全塌进
|
|
90
|
+
// 「未分组」,且中转只会附一个 App 根本不看的 stale 标记,不会有任何报错。
|
|
91
|
+
// 拿不到就是拿不到,如实走错误通道,让调用方去重试或保留上一次结果。
|
|
92
|
+
warn(
|
|
93
|
+
ctx,
|
|
94
|
+
'workspace.follow 流结束前没有给出 baseline 帧,无法确定工作区清单(宿主可能仍在初始化或正忙),' +
|
|
95
|
+
'本次按失败处理而不是谎报空列表',
|
|
96
|
+
);
|
|
97
|
+
return fail({
|
|
98
|
+
code: 'host-workspace-baseline-missing',
|
|
99
|
+
message: 'workspace.follow 流结束前没有给出 baseline 帧,无法确定工作区列表',
|
|
100
|
+
}, 'host-call-failed');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 会话摘要内联投影 `session.list items[].projections.values`(没有就是空对象)。 */
|
|
104
|
+
function projectionValues(item) {
|
|
105
|
+
const projections = item && typeof item === 'object' ? item.projections : null;
|
|
106
|
+
const values = projections && typeof projections === 'object' ? projections.values : null;
|
|
107
|
+
return values && typeof values === 'object' ? values : {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 子代理目录的投影基线预算,比通用合成流(SYNTHETIC_DEADLINE_MS)更紧。
|
|
112
|
+
*
|
|
113
|
+
* 手机端一次请求的总预算只有 8 秒(app/src/api.ts 的 REQUEST_TIMEOUT_MS),走中转还要
|
|
114
|
+
* 多一个 RTT。基线开不出来并不致命 —— session.list 摘要内联的是同一份 values,退回它
|
|
115
|
+
* 就行,所以宁可慢的时候少等几秒,也别把整次 subagents.list 拖成客户端超时。
|
|
116
|
+
*/
|
|
117
|
+
const SUBAGENT_BASELINE_DEADLINE_MS = 3000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 开一条 session.control 只读它的首帧 baseline,取回全设备投影快照
|
|
121
|
+
* `{ [sessionId]: values }`,读完立刻 break(网关随之取消这条流),
|
|
122
|
+
* 与 workspaceListViaFollow 同一手法。
|
|
123
|
+
*
|
|
124
|
+
* 开不成流不算致命:调用方还能退回 session.list 摘要内联的投影,所以这里吞掉异常返回
|
|
125
|
+
* 空表,而不是把整次调用判失败。
|
|
126
|
+
*/
|
|
127
|
+
async function projectionBaseline(ctx, signal) {
|
|
128
|
+
// 这里只读首帧 baseline,读完就 break,所以不需要 event-synthesis.js 那份逐帧累积器
|
|
129
|
+
// (依赖方向也不允许 shims 反过来 require 它),直接取首帧的 projections 即可。
|
|
130
|
+
let projections = null;
|
|
131
|
+
try {
|
|
132
|
+
const frames = await openStream(ctx, 'session', 'control', {}, deadline(signal, SUBAGENT_BASELINE_DEADLINE_MS));
|
|
133
|
+
for await (const frame of frames) {
|
|
134
|
+
if (frame && frame.type === 'baseline') {
|
|
135
|
+
projections = frame.value && typeof frame.value === 'object' ? frame.value.projections : null;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
warn(ctx, `子代理目录:session/control 基线不可用(${error && error.message ? error.message : String(error)}),改用会话列表内联投影`);
|
|
141
|
+
}
|
|
142
|
+
const out = new Map();
|
|
143
|
+
if (projections && typeof projections === 'object') {
|
|
144
|
+
for (const [sessionId, entry] of Object.entries(projections)) {
|
|
145
|
+
out.set(sessionId, entry && entry.values && typeof entry.values === 'object' ? entry.values : {});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `subagents.list` → `session.control` 投影基线 + `session.list`。
|
|
153
|
+
*
|
|
154
|
+
* 0.2.0 撤掉了 subagent.list 这个 RPC,但目录数据一条没少:它变成了会话投影里的
|
|
155
|
+
* `subagentCatalog` 键,桌面端 @deepseek-ai/dsh-client-ui-subagent 的 CatalogDropdown
|
|
156
|
+
* 就是读 `state.projectionsBySession` 渲染的。投影基线一次给出**全部**会话的 values,
|
|
157
|
+
* 父子两层的目录在同一帧里 —— hasChildren 因此不需要按子会话再发 N 次投影查询。
|
|
158
|
+
*
|
|
159
|
+
* 投影里没有的是运行时状态:跑没在跑、父会话在不在线。桌面端也是拿 sessions.list 的
|
|
160
|
+
* summaries 合成 activity 的,这里同口径再取一次会话列表,两边判定完全一致。
|
|
161
|
+
*/
|
|
162
|
+
async function subagentCatalog(ctx, payload, signal) {
|
|
163
|
+
const parentSessionId = payload && payload.parentSessionId;
|
|
164
|
+
if (typeof parentSessionId !== 'string' || !parentSessionId) {
|
|
165
|
+
return fail({ code: 'bad-request', message: '缺少 parentSessionId' });
|
|
166
|
+
}
|
|
167
|
+
const list = await invoke(ctx, 'session', 'list', { _request: {} }, signal);
|
|
168
|
+
if (list.error) return fail(list.error);
|
|
169
|
+
const items = list.value && Array.isArray(list.value.items) ? list.value.items : [];
|
|
170
|
+
const byId = new Map();
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
if (item && typeof item.sessionId === 'string' && item.sessionId) byId.set(item.sessionId, item);
|
|
173
|
+
}
|
|
174
|
+
// 桌面端 ClientSessions.agentAvailable() 的口径:在会话列表里找父会话,读它的
|
|
175
|
+
// agentAvailable;列表已就绪还找不到就是 false(它那个 undefined 是「列表还没读完」,
|
|
176
|
+
// 这里 list 是刚 await 回来的,早就就绪了)。
|
|
177
|
+
//
|
|
178
|
+
// 这一条是整套里唯一没法离线证实的口径,所以留一级退路:宿主万一不填
|
|
179
|
+
// agentAvailable(schema 里有,但没实证它一定被赋值),就退成「父会话还在列表里 =
|
|
180
|
+
// 还在这台宿主上」。宁可偶尔多给一个输入框,也不要整片目录一律只读 —— 后者用户
|
|
181
|
+
// 点进去发现子代理明明能续话却打不了字,比误判严重得多。
|
|
182
|
+
const parent = byId.get(parentSessionId);
|
|
183
|
+
const parentAvailable = !parent
|
|
184
|
+
? false
|
|
185
|
+
: typeof parent.agentAvailable === 'boolean' ? parent.agentAvailable : true;
|
|
186
|
+
|
|
187
|
+
const baseline = await projectionBaseline(ctx, signal);
|
|
188
|
+
// 基线优先:它是桌面端渲染目录的同一份数据。基线里没有这个会话、或者它的
|
|
189
|
+
// subagentCatalog 还没算出来,就退回列表摘要内联的那份投影;两处都没有就当
|
|
190
|
+
// 它没有派出过子代理。
|
|
191
|
+
const catalogOf = (sessionId) => {
|
|
192
|
+
const fromBaseline = baseline.get(sessionId);
|
|
193
|
+
if (fromBaseline && Array.isArray(fromBaseline.subagentCatalog)) return fromBaseline.subagentCatalog;
|
|
194
|
+
const inline = projectionValues(byId.get(sessionId));
|
|
195
|
+
return Array.isArray(inline.subagentCatalog) ? inline.subagentCatalog : [];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const entries = [];
|
|
199
|
+
for (const item of catalogOf(parentSessionId)) {
|
|
200
|
+
if (!item || typeof item.id !== 'string' || !item.id) continue;
|
|
201
|
+
if (item.mode !== 'one-shot' && item.mode !== 'continuable' && item.mode !== 'unknown') continue;
|
|
202
|
+
const child = byId.get(item.id);
|
|
203
|
+
entries.push({
|
|
204
|
+
kind: 'child',
|
|
205
|
+
id: item.id,
|
|
206
|
+
mode: item.mode,
|
|
207
|
+
activity: child && child.running === true ? 'running' : 'inactive',
|
|
208
|
+
// 子会话不在列表里(已归档/已删)时保守报 false:手机端契约只有布尔值,
|
|
209
|
+
// 桌面端 isKnownLeaf 那种「目录未加载先留个展开箭头」的三态没法表达,
|
|
210
|
+
// 而且假箭头点开是空面板,比不显示更糟。
|
|
211
|
+
hasChildren: catalogOf(item.id).length > 0,
|
|
212
|
+
...(typeof item.label === 'string' && item.label ? { label: item.label } : {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return ok({ entries, parentAvailable });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { historyViaPage, modelCatalog, workspaceListViaFollow, projectionBaseline, subagentCatalog };
|