dsh-mobile-plugin 0.5.8 → 0.5.9

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.
@@ -311,11 +311,41 @@ function lookupService(ctx, name) {
311
311
  }
312
312
 
313
313
  /**
314
- * POST /api/mobile/v1/channels/discover(token){ protocol, baseUrl, apiKey? }
314
+ * 宿主 `llm.discoverModels` 的失败 → 中文一句(宿主的英文原话留在 `details.hostMessage`)。
315
+ *
316
+ * 宿主的原话是英文(`could not reach …`、`… answered 401; check the API key`),而 App 那边
317
+ * 规定英文原话不上界面(`services/http` 的 `unwrapResult` 会整句换成「电脑那边出了错,请重试」),
318
+ * 「连不上地址」与「Key 不对」在手机上就分不出来。所以在这一层按已经见到的几种形状换成中文:
319
+ * App 拿到的原话是中文就照原样显示,用户读得到下一步该干什么。
320
+ */
321
+ function discoverFailureMessage(caught) {
322
+ const raw = caught && typeof caught.message === 'string' ? caught.message : String(caught == null ? '' : caught);
323
+ const code = caught && typeof caught.code === 'string' ? caught.code : '';
324
+ if (/could not reach/i.test(raw)) return '连不上这个地址:确认地址没写错,也确认这台电脑访问得到它';
325
+ const answered = /answered (\d{3})/i.exec(raw);
326
+ if (answered) {
327
+ const status = answered[1];
328
+ if (status === '401' || status === '403') return '这个地址要求鉴权:Key 不对或者没填';
329
+ return `这个地址没有模型清单这个接口(HTTP ${status}),检查地址有没有多写路径`;
330
+ }
331
+ if (/did not answer with JSON/i.test(raw)) return '这个地址回的不是模型清单,可能填成了网页地址';
332
+ if (/no model listing this build can read/i.test(raw)) return '这条协议取不到模型清单,手动填模型 id 吧';
333
+ if (/no credential for provider route/i.test(raw) || /API key is blank/i.test(raw)) {
334
+ return '这条渠道在这台电脑上没有可用的 Key,填一次 Key 再获取';
335
+ }
336
+ return `取模型失败${code ? `(${code})` : ''}`;
337
+ }
338
+
339
+ /**
340
+ * POST /api/mobile/v1/channels/discover(token){ id?, protocol, baseUrl, apiKey? }
315
341
  * → { ok, models: [{id,name,contextWindow?,maxTokens?,vision?}], baseUrl }
316
342
  *
317
343
  * `baseUrl` 回的是**问通的那个**:`/v1` 回退成功时保存要写它,否则聊天请求还会打到
318
344
  * 刚才失败的那个地址上。只读不落盘,用户确认清单之后再调 `/channels/save`。
345
+ *
346
+ * `id` 是编辑中的那条渠道:宿主按路由名就能在「请求没带 Key」时自己去凭据库取原来那把
347
+ * (`discoverModels` 的 `provider`),所以表单里 Key 留空也能取模型。新建时没有 id,
348
+ * 沿用当场填的那把。
319
349
  */
320
350
  async function handleChannelDiscover(ctx, req, res) {
321
351
  const body = await readJsonBody(req, 64 * 1024);
@@ -325,6 +355,10 @@ async function handleChannelDiscover(ctx, req, res) {
325
355
  }
326
356
  const base = normalizeBaseUrl(body ? body.baseUrl : undefined);
327
357
  if (base.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: base.error } });
358
+ const editingId = body && typeof body.id === 'string' ? body.id.trim() : '';
359
+ if (editingId !== '' && !CHANNEL_ID_PATTERN.test(editingId)) {
360
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道 id 不合法' } });
361
+ }
328
362
  const apiKey = body && typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
329
363
  if (apiKey.length > MAX_KEY_CHARS) {
330
364
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 太长' } });
@@ -342,6 +376,7 @@ async function handleChannelDiscover(ctx, req, res) {
342
376
  for (const baseURL of discoverCandidates(base.url, protocol)) {
343
377
  try {
344
378
  const found = await llm.discoverModels(PI_AI_NS, {
379
+ ...(editingId === '' ? {} : { provider: editingId }),
345
380
  baseURL,
346
381
  api: protocol,
347
382
  ...(apiKey === '' ? {} : { apiKey }),
@@ -368,10 +403,16 @@ async function handleChannelDiscover(ctx, req, res) {
368
403
  lastError = caught;
369
404
  }
370
405
  }
371
- // 协议层 / 渠道包的话本身就是给用户看的(「answered 401; check the API key」),原样透出。
406
+ // 失败说人话:宿主那几句英文摆到手机的英文过滤后面会变成「电脑那边出了错」,原文留在 details。
407
+ const hostMessage = String((lastError && lastError.message) || lastError).slice(0, 300);
408
+ const hostCode = lastError && typeof lastError.code === 'string' ? lastError.code : '';
372
409
  sendJson(res, 422, {
373
410
  ok: false,
374
- error: { code: 'discover-failed', message: String((lastError && lastError.message) || lastError).slice(0, 300) },
411
+ error: {
412
+ code: 'discover-failed',
413
+ message: discoverFailureMessage(lastError),
414
+ details: { hostMessage, ...(hostCode === '' ? {} : { hostCode }) },
415
+ },
375
416
  });
376
417
  }
377
418
 
@@ -16,7 +16,7 @@ const { createFrameSealer } = require('./stream.js');
16
16
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
17
17
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
18
18
  const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
19
- const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
19
+ const { writeSse: sharedWriteSse, stableEventId: sharedEventId, waitForDrain } = require('../shared/sse-write.js');
20
20
 
21
21
  const DEDUP_WINDOW = 2048;
22
22
  /** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
@@ -153,10 +153,35 @@ async function handleDeviceStream(ctx, req, res) {
153
153
  // shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
154
154
  if (result.shed) return false;
155
155
  framesSent += 1;
156
- // 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
157
- // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
156
+ // 这几处调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
157
+ // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。开场那一批补发
158
+ // 不走这里,走下面的 `writeFrameAwaiting`:那批是一口气几十上百帧,必然会撞上写缓冲。
158
159
  return !result.needDrain;
159
160
  };
161
+ /**
162
+ * 补发快照专用:写一帧,写不动就等缓冲吐出去再接着写。
163
+ *
164
+ * 与 `writeFrame` 只差一件事:`needDrain` 不是失败,是「慢」。开场补发是每台被跟踪的
165
+ * 会话各一帧的水位快照,会话多的机器一上来就超出一条连接的写缓冲(Node 的高水位默认
166
+ * 16KB),把它当失败就等于**每次连上补完就自己掐掉**,手机重连再补一遍,永远建立不起来;
167
+ * 顺带连「积压到软水位才 shed」那道闸都轮不到。会话流(`stream.js`)的 live 循环就是
168
+ * 等 drain 再继续,这里照同一套来。
169
+ *
170
+ * @returns {Promise<boolean>} 这一帧写成功(或等到了 drain)→ true;该收尾 → false。
171
+ */
172
+ const writeFrameAwaiting = async (devicePayload) => {
173
+ const result = writeSse(res, {
174
+ type: 'device/event',
175
+ version: 1,
176
+ payload: devicePayload,
177
+ }, null, sealFrame);
178
+ if (!result.ok) return false;
179
+ // shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
180
+ if (result.shed) return false;
181
+ framesSent += 1;
182
+ if (result.needDrain && !(await waitForDrain(res, ac.signal))) return false;
183
+ return true;
184
+ };
160
185
  /**
161
186
  * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
162
187
  *
@@ -208,26 +233,27 @@ async function handleDeviceStream(ctx, req, res) {
208
233
  * 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
209
234
  * App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
210
235
  */
211
- const replayKnownSignals = () => {
236
+ const replayKnownSignals = async () => {
212
237
  const list = sharedListSignal.snapshot();
213
238
  if (list && list.revision) {
214
- if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
239
+ if (!await writeFrameAwaiting({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
215
240
  }
216
241
  // 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
217
242
  // 所以补发它不会改变老客户端的任何行为。
218
243
  const view = sharedDesktopViewSignal.snapshot();
219
244
  if (view && view.updatedAt > 0) {
220
- if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
245
+ if (!await writeFrameAwaiting({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
221
246
  }
222
247
  for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
223
248
  if (!(seq > 0)) continue;
224
- if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
249
+ if (!await writeFrameAwaiting({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
225
250
  }
226
251
  return true;
227
252
  };
228
- // 补发失败说明连接已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
229
- // 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。
230
- if (!replayKnownSignals()) ac.abort();
253
+ // 补发失败说明连接真的已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
254
+ // 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。写缓冲满了不算失败,见
255
+ // `writeFrameAwaiting`。
256
+ if (!await replayKnownSignals()) ac.abort();
231
257
  const keepAlive = setInterval(() => {
232
258
  try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
233
259
  // 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
@@ -273,7 +299,11 @@ async function handleDeviceStream(ctx, req, res) {
273
299
  // Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
274
300
  const sent = writeSse(res, envelope, frame, sealFrame);
275
301
  if (sent.shed) { clientGone = true; break; }
276
- if (!sent.ok || sent.needDrain) { clientGone = true; break; }
302
+ if (!sent.ok) { clientGone = true; break; }
303
+ // 写不动就等一次 drain 再接着读:这条循环本来就是 async,等一等比断掉重连便宜得多
304
+ // (重连要补发整份快照,手机那边还要重拉一遍清单与正文)。等不到(超时或流被中断)
305
+ // 才收尾。
306
+ if (sent.needDrain && !(await waitForDrain(res, ac.signal))) { clientGone = true; break; }
277
307
  framesSent += 1;
278
308
  }
279
309
  if (!clientGone) loopEnded = true;
@@ -159,6 +159,33 @@ function hostRequest(ctx, namespace, method, args, signal) {
159
159
  return request;
160
160
  }
161
161
 
162
+ /**
163
+ * 撤掉网关还挂着的那一发远程转发(桌面窗口那张卡挂在它上面)。
164
+ *
165
+ * 网关按**请求对象本身**登记挂起的转发,所以按 `requestRef` 认回那一发、调它自己的撤回接口:
166
+ * 窗口收到 `cancel` 帧后按 abort 处理,卡跟着撤,不会冒成错误提示。认不到就什么都不做 ——
167
+ * 老宿主没有 `pendingRemoteEvents` / `cancelRemoteEvent`,「桌面那边没卡可收」本来就是对的。
168
+ *
169
+ * 放在这里而不是调用方:调用方(提问桥)只知道「这一问的那一发撤掉」,两个成员名与迭代方式
170
+ * 跟 invoke / stream 一样属于传输层 —— 别在别处再摸一次 `typertGateway`。
171
+ *
172
+ * @returns {number} 真的撤掉了几发
173
+ */
174
+ function cancelForwardedRequests(ctx, requestRef, reason) {
175
+ const gateway = gatewayOf(ctx);
176
+ const pending = gateway && gateway.pendingRemoteEvents;
177
+ if (!pending || typeof pending.values !== 'function' || typeof gateway.cancelRemoteEvent !== 'function') return 0;
178
+ let cancelled = 0;
179
+ for (const item of [...pending.values()]) {
180
+ if (!item || !item.source || item.source.request !== requestRef) continue;
181
+ try {
182
+ gateway.cancelRemoteEvent(item, reason);
183
+ cancelled += 1;
184
+ } catch { /* 那一发已经走了(别人先收掉):没什么可撤的 */ }
185
+ }
186
+ return cancelled;
187
+ }
188
+
162
189
  async function invoke(ctx, namespace, method, args, signal) {
163
190
  const gateway = gatewayOf(ctx);
164
191
  if (!gateway) return { error: { ...UNAVAILABLE } };
@@ -222,4 +249,4 @@ async function invokeEnvelope(ctx, namespace, method, args, signal) {
222
249
  return ok(result.value);
223
250
  }
224
251
 
225
- module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
252
+ module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, cancelForwardedRequests, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
@@ -16,6 +16,7 @@
16
16
 
17
17
  const { randomUUID } = require('node:crypto');
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
+ const { cancelForwardedRequests } = require('./host-bridge/transport.js');
19
20
 
20
21
  /** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
21
22
  const watchers = new Map();
@@ -208,6 +209,8 @@ function normalizeAnswers(questions, answers) {
208
209
  /** 绑上钩子请求:从此这条挂起项「答得动」,并接上宿主的取消信号。 */
209
210
  function bindRequest(entry, request) {
210
211
  entry.bound = true;
212
+ // 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
213
+ entry.requestRef = request;
211
214
  pushCard(entry);
212
215
  const signal = request.signal;
213
216
  if (!signal || typeof signal.addEventListener !== 'function') return;
@@ -233,10 +236,10 @@ function noAnswerer(error) {
233
236
  * 挂起这条请求直到有人回答:桌面的转发与手机的卡**同时进行**,谁先答就用谁的答案。
234
237
  *
235
238
  * 桌面先答:下游给出答案,结算挂起项(手机那张卡由结算帧收掉)。
236
- * 手机先答:把手机的回答交回钩子。桌面那张卡由宿主自己收尾 —— 这一问落定后宿主的限时
237
- * 等待随之结束,桌面客户端把「被转发的请求」delegate 回来,卡跟着撤掉,会话投影也不再
238
- * 列这个 call。那一下会让 `downstream` 再落一次;上面的 race 已经挂着它的处理函数,迟到
239
- * 的那次不会冒成未处理异常,`settle` 本身也是幂等的,收卡与回答都不受影响。
239
+ * 手机先答:把手机的回答交回钩子,桌面那张卡由 `cancelForwardedCards` 主动收掉 —— 宿主
240
+ * 只在「限时提问」里给这条转发做了收尾,本桥跑的这版宿主是阻塞式提问,没人替桌面窗口
241
+ * 撤回那一发,卡会一直悬着直到人手动点。那一发被撤时 `downstream` 会再落一次;上面的
242
+ * race 已经挂着它的处理函数,迟到的那次不会冒成未处理异常,`settle` 本身也是幂等的。
240
243
  *
241
244
  * `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)不是结论:手机正在看这个会话时
242
245
  * 卡还在手机上等着,继续等手机答;没人看时照旧把失败交回去,纯桌面表现一字不改。
@@ -260,6 +263,22 @@ function holdForPhone(entry, next) {
260
263
  return Promise.race([fromPhone, downstream]);
261
264
  }
262
265
 
266
+ /**
267
+ * 收掉桌面那张卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
268
+ *
269
+ * 桌面那张卡不是本桥画的:宿主把提问转发给桌面窗口之后,窗口自己按那一发长出一张卡,
270
+ * 只有三种情况会让它消失 —— 桌面自己答、这一轮被取消、宿主主动撤回那一发。「两边同时
271
+ * 出卡」靠的就是转发,而宿主只在「限时提问」路径上做了落定后的撤回;这版宿主的
272
+ * `ask_user_question` 跑的是阻塞式,手机先答之后没人去收,卡就一直悬着。
273
+ *
274
+ * 认哪一发、怎么撤都由传输层做(网关按请求对象本身登记这些挂起的转发):这里只把「这一问
275
+ * 的请求对象」交出去。认不到(老宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
276
+ */
277
+ function cancelForwardedCards(ctx, entry) {
278
+ if (!entry.requestRef) return;
279
+ cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条提问'));
280
+ }
281
+
263
282
  function onQuestionRequest(request, next) {
264
283
  const sessionId = sessionIdOfRequest(request);
265
284
  if (!sessionId) return next();
@@ -301,6 +320,7 @@ async function handleQuestionRespond(ctx, req, res) {
301
320
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 必须每题恰好一条、含合法 selected' } });
302
321
  }
303
322
  settle(entry, answers);
323
+ cancelForwardedCards(ctx, entry);
304
324
  return sendJson(res, 200, { ok: true, accepted: true });
305
325
  }
306
326
  const receipt = await respondViaLegacyProxy(ctx, {
@@ -8,10 +8,17 @@
8
8
  *
9
9
  * 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
10
10
  * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
11
- * 开关有两层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
11
+ * 开关有三层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
12
12
  * (一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
13
- * lockfile,别的插件刚发也会连累我们),再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
14
- * pnpm 12 的原生 CLI 会静默忽略这两个 --config 参数,环境变量那一份还认。
13
+ * lockfile,别的插件刚发也会连累我们)、ignore-workspace-root-check=true(profile 自己带
14
+ * `pnpm-workspace.yaml`,在 pnpm 眼里就是 workspace 根;pnpm 10.4 及以前会拿
15
+ * `ERR_PNPM_ADDING_TO_ROOT` 把这一句 add 直接拒掉,机械地退回 PATH 上那个老 pnpm 的机器全中),
16
+ * 再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
17
+ * pnpm 12 的原生 CLI 会静默忽略这几个 --config 参数,环境变量那一份还认。
18
+ *
19
+ * 失败要**说出原因**:`failure.message` 是手机、面板与日志唯一显示的那句话,曾经只写「跑过的
20
+ * 命令 + 退出码」—— 排错的人拿着一张只有命令的截图,包管理器自己的报错(ERR_PNPM_* 那一行)
21
+ * 被丢在了 `output` 里没人看。现在报错正文跟着附上。
15
22
  *
16
23
  * 装完要**登记**:这个版本是被我们绕过晾置期装进去的,不写进 profile 的 minimumReleaseAgeExclude,
17
24
  * 它就会留在 lockfile 里,之后 pnpm 校验整份 lockfile 时把这台机器上所有安装都拒掉
@@ -102,7 +109,7 @@ function spawnEnv(dirs, env = process.env) {
102
109
  }
103
110
 
104
111
  /**
105
- * 每次调 pnpm 都带上的那两格策略开关,**环境变量这一份不是备份,是另一半**。
112
+ * 每次调 pnpm 都带上的那几格开关,**环境变量这一份不是备份,是另一半**。
106
113
  *
107
114
  * pnpm 11 两种写法都认;pnpm 12 的原生 CLI 会静默忽略部分 `--config.*`(市场在 #600 上实测
108
115
  * 过,一次装失败一次白等),但同一个键走 `PNPM_CONFIG_*` 它照读。命令行与环境变量各带一份,
@@ -111,6 +118,7 @@ function spawnEnv(dirs, env = process.env) {
111
118
  const POLICY_ENV = {
112
119
  PNPM_CONFIG_AUTO_INSTALL_PEERS: 'false',
113
120
  PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0',
121
+ PNPM_CONFIG_IGNORE_WORKSPACE_ROOT_CHECK: 'true',
114
122
  };
115
123
 
116
124
  /** 「配置里这个包用的是哪个注册表」缓存多久:查更新两分钟一轮,源不常换。 */
@@ -428,6 +436,26 @@ function runningAgentIds(agents) {
428
436
  return ids;
429
437
  }
430
438
 
439
+ /**
440
+ * 给一句失败结论附上包管理器自己说的原因。
441
+ *
442
+ * `failure.message` 是手机、面板与日志**唯一**会显示的那句话,所以它必须自带原因:只写
443
+ * 「跑过的命令 + 退出码」时,排错的人拿着一张只有命令的截图,一点办法都没有。pnpm 的诊断
444
+ * 都在 `ERR_PNPM_*` / `ERROR` 那几行里,优先挑它;一行都没有(被 kill、旧版 pnpm 换了措辞)
445
+ * 就退回最后一行有内容的输出 —— 进度条那种噪声先滤掉。挑不满就不硬凑,也别把整份输出塞进
446
+ * 一句话里:完整的那份一直留在 `output` 里。
447
+ */
448
+ function withReason(message, output) {
449
+ const lines = String(output || '')
450
+ .split('\n')
451
+ .map(line => line.trimEnd())
452
+ .filter(line => line.trim() !== '' && !/^Progress:/.test(line.trim()));
453
+ if (lines.length === 0) return message;
454
+ const marked = lines.filter(line => /ERR_PNPM|ERROR/.test(line));
455
+ const reason = (marked.length > 0 ? marked : lines.slice(-1)).slice(0, 3).join('\n').slice(0, 600);
456
+ return reason === '' ? message : `${message}\n${reason}`;
457
+ }
458
+
431
459
  /** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
432
460
  function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
433
461
  return new Promise(resolve => {
@@ -558,7 +586,7 @@ async function installLatest({
558
586
  const spec = `${name}@${version}`;
559
587
  // 一律带上:自动更新是「一检测到就装」,手动点的那一下同样是「现在就要」,没理由先让这道
560
588
  // 策略挡一次、再靠失败重试绕过来。命令行与环境变量各带一份,pnpm 12 只认后者(见 POLICY_ENV)。
561
- const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0'];
589
+ const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0', '--config.ignore-workspace-root-check=true'];
562
590
  const args = picked.manager === 'pnpm'
563
591
  ? ['add', spec, ...policyArgs]
564
592
  : ['install', spec, '--no-audit', '--no-fund'];
@@ -622,7 +650,10 @@ async function installLatest({
622
650
  // 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
623
651
  failure: {
624
652
  code: result.code === null ? 'INSTALL_ABORTED' : 'INSTALL_FAILED',
625
- message: `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
653
+ message: withReason(
654
+ `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
655
+ output,
656
+ ),
626
657
  },
627
658
  };
628
659
  }
@@ -643,7 +674,10 @@ async function installLatest({
643
674
  output,
644
675
  failure: changed
645
676
  ? null
646
- : { code: 'VERSION_UNCHANGED', message: `${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数` },
677
+ : {
678
+ code: 'VERSION_UNCHANGED',
679
+ message: withReason(`${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数`, output),
680
+ },
647
681
  };
648
682
  }
649
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",