pi-web-ui 0.86.2 → 0.87.0

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 (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -132,6 +132,35 @@ function agentOf(server, sessionId) {
132
132
  * 官方 SDK server 的 goal 扩展:goal/* 方法直连 ctx.goals 服务动词。
133
133
  */
134
134
  class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
135
+ /**
136
+ * 官方构造器订阅 session.event / session.status / subagent.*;这里再加**直播流**:
137
+ * 新版 DSH 运行时(0.1.1-rc.2 之后)把逐 chunk 的模型输出发成 agent scope 的
138
+ * `agent/assistant-stream` 帧(持久的 `assistant/chunk` 事件已取消),host 侧的
139
+ * jsonrpc 面收不到 → pi-web-ui 既没有 streamingMessage / 实时速率,也拿不到
140
+ * 逐次调用 usage(底栏上下文占用 / 缓存命中)。
141
+ *
142
+ * 转成 `assistant.stream` 通知:`{ sessionId, frame }`,frame 是运行时的
143
+ * start / chunk / end 三态;chunk 形状与旧 `assistant/chunk.data.chunk` 同构。
144
+ *
145
+ * `{ global: true }` 是必需的:该事件在 agent scope 派发,host 上注册的观察者
146
+ * 不加 global 永远收不到(同 dsh-api-session-controller 的写法)。
147
+ */
148
+ constructor(ctx, transport, options) {
149
+ super(ctx, transport, options);
150
+ this.disposers.push(
151
+ ctx.on(
152
+ "agent/assistant-stream",
153
+ ({ agent, frame }) => {
154
+ this.transport.notify("assistant.stream", {
155
+ sessionId: String(agent.session.id),
156
+ frame,
157
+ });
158
+ },
159
+ { global: true },
160
+ ),
161
+ );
162
+ }
163
+
135
164
  /**
136
165
  * 创建(或替换已完成的)目标并 arm —— round-driver 会在 agent idle 时
137
166
  * 自动续第一轮,不需要额外 prompt。
@@ -194,6 +223,321 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
194
223
  return viewPayload(this.ctx.goals.edit(agent, { id: view.id, revision: view.revision }, request));
195
224
  }
196
225
 
226
+ // -----------------------------------------------------------------------
227
+ // Agent 预设(dsh-web 四模式:standard/ptc/minimal/cordis + 用户自建)。
228
+ //
229
+ // roster 挂了 preset-plane patch 才存在(见 server/dsh/preset-clones.ts 生成的
230
+ // preset-plane.patch.yml);legacy 单组合(无 patch)下全部回退:list 报 unavailable,
231
+ // assign/select 透过,创建走官方原逻辑,per-agent 钩子照装(问卷 waterfall 修法
232
+ // 本就要求 agent scope 注册,与预设有无无关)。
233
+ //
234
+ // 约定:Node 侧(dsh-agent-service)是 preset 真源 —— 新会话首个 prompt 前调
235
+ // preset/assign 登记,会话创建时消费;空白会话切换走 preset/select(首轮后锁,
236
+ // 与官方 agent-preset/locked 语义一致)。RPC 错误不抛 domain 码(transport 统一
237
+ // 坍缩成 -32603),一律走 {ok:false,code,message} 信封。
238
+ // -----------------------------------------------------------------------
239
+
240
+ /** 待消费的创建期预设:sessionId → preset id(创建时消费并删除)。 */
241
+ pendingPresets = new Map();
242
+ /** minimal 下被门禁的 agent id(bridge 工具 + 问卷调用期拒绝,中英双语报错)。 */
243
+ minimalAgents = new Set();
244
+ /** 技能目录取数 agent(standard 预设,常驻但永不 prompt,零 token)。 */
245
+ inventorySessionId = "pi-webui-inventory";
246
+
247
+ /** roster 服务(无 preset-plane patch 时 undefined → 全员 legacy)。 */
248
+ presetRoster() {
249
+ try {
250
+ return this.ctx.get("agentPresets") ?? undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ /** 解析一个 preset id(非法/unknown → 默认;roster 缺失 → undefined)。 */
257
+ async resolvePresetId(requested) {
258
+ const roster = this.presetRoster();
259
+ if (!roster) return undefined;
260
+ try {
261
+ return (await roster.resolve(requested)).id;
262
+ } catch {
263
+ try {
264
+ return (await roster.resolve()).id;
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ }
270
+
271
+ /** preset/list RPC:roster 名录(display 字段以运行时树为准)。 */
272
+ async listPresets() {
273
+ const roster = this.presetRoster();
274
+ if (!roster) return { presets: [], defaultPreset: "standard", authorable: false, unavailable: true };
275
+ try {
276
+ // service.list() = discovery 数组;isDefault/authorable 走同名的 getter。
277
+ const items = await roster.list();
278
+ let defaultId = "standard";
279
+ try {
280
+ defaultId = roster.defaultId ?? defaultId;
281
+ } catch {
282
+ /* keep */
283
+ }
284
+ let authorable = false;
285
+ try {
286
+ authorable = !!roster.authorable;
287
+ } catch {
288
+ /* keep */
289
+ }
290
+ return {
291
+ presets: (Array.isArray(items) ? items : []).map((p) => ({
292
+ id: p.id,
293
+ trust: p.trust,
294
+ isDefault: p.id === defaultId,
295
+ ...(p.name ? { name: p.name } : {}),
296
+ ...(p.description ? { description: p.description } : {}),
297
+ ...(Number.isSafeInteger(p.order) ? { order: p.order } : {}),
298
+ ...(p.broken ? { broken: p.broken } : {}),
299
+ })),
300
+ defaultPreset: defaultId,
301
+ authorable,
302
+ };
303
+ } catch (err) {
304
+ return {
305
+ presets: [],
306
+ defaultPreset: "standard",
307
+ authorable: false,
308
+ unavailable: true,
309
+ error: err?.message ?? String(err),
310
+ };
311
+ }
312
+ }
313
+
314
+ /** preset/assign RPC:登记创建期预设(首个 prompt/goal 前调用,仅创建时消费)。 */
315
+ async assignPreset(params) {
316
+ if (typeof params?.sessionId !== "string") throw new TypeError("preset/assign requires sessionId");
317
+ if (typeof params?.preset !== "string" || !params.preset) throw new TypeError("preset/assign requires preset");
318
+ this.pendingPresets.set(params.sessionId, params.preset);
319
+ return { ok: true };
320
+ }
321
+
322
+ /** agent 存活检查(项目依赖的 0.1.1-rc.2 无基类 assertLiveAgent,内联同语义)。 */
323
+ liveAgent(rec, sessionId) {
324
+ if (!rec || this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
325
+ throw new Error(`session agent was disposed outside the server: ${sessionId}`);
326
+ }
327
+ return rec.handle.agent;
328
+ }
329
+
330
+ /** preset/select RPC:空白会话切换预设(首轮后官方抛 agent-preset/locked)。 */
331
+ async selectPreset(params) {
332
+ if (typeof params?.sessionId !== "string") throw new TypeError("preset/select requires sessionId");
333
+ if (typeof params?.preset !== "string" || !params.preset) throw new TypeError("preset/select requires preset");
334
+ const roster = this.presetRoster();
335
+ if (!roster)
336
+ return { ok: false, code: "agent-preset/unavailable", message: "agent presets unavailable (legacy runtime)" };
337
+ try {
338
+ const rec = await this.getOrCreateSession(params.sessionId);
339
+ const agent = this.liveAgent(rec, params.sessionId);
340
+ const id = await roster.select(agent, params.preset);
341
+ this.pendingPresets.delete(params.sessionId);
342
+ if (id === "minimal") this.minimalAgents.add(agent.id);
343
+ else this.minimalAgents.delete(agent.id);
344
+ return { ok: true, preset: id };
345
+ } catch (err) {
346
+ return { ok: false, code: err?.code ?? "agent-preset/error", message: err?.message ?? String(err) };
347
+ }
348
+ }
349
+
350
+ /** permission/get RPC:读一个会话的权限预设 projection(官方 /permission 弹窗同源)。 */
351
+ async getPermission(params) {
352
+ if (typeof params?.sessionId !== "string") throw new TypeError("permission/get requires sessionId");
353
+ const rec = await this.getOrCreateSession(params.sessionId);
354
+ const agent = this.liveAgent(rec, params.sessionId);
355
+ const permission = this.ctx.permissionPresets;
356
+ if (!permission) throw new Error("permission/get: permission service unavailable");
357
+ return permission.selectFor(permission.permissionState(agent.session));
358
+ }
359
+
360
+ /** permission/set RPC:按会话热切换权限预设(写会话日志,无需重启;
361
+ * 与官方 /permission 命令同语义:approval 走 live writer,模型会收到一条
362
+ * 策略变更的用户消息)。未知预设走 {ok:false} 信封,不抛 domain 错。 */
363
+ async setPermission(params) {
364
+ if (typeof params?.sessionId !== "string") throw new TypeError("permission/set requires sessionId");
365
+ if (typeof params?.preset !== "string" || !params.preset) throw new TypeError("permission/set requires preset");
366
+ const permission = this.ctx.permissionPresets;
367
+ if (!permission) throw new Error("permission/set: permission service unavailable");
368
+ if (!permission.names.includes(params.preset)) {
369
+ return {
370
+ ok: false,
371
+ code: "permission/unknown-preset",
372
+ message: `unknown preset "${params.preset}" (known: ${permission.names.join(", ")})`,
373
+ };
374
+ }
375
+ try {
376
+ const rec = await this.getOrCreateSession(params.sessionId);
377
+ const agent = this.liveAgent(rec, params.sessionId);
378
+ permission.apply(agent.session, params.preset, (policy) => this.ctx.approval.setPolicy(agent, policy));
379
+ return {
380
+ ok: true,
381
+ preset: params.preset,
382
+ ...permission.selectFor(permission.permissionState(agent.session)),
383
+ };
384
+ } catch (err) {
385
+ return { ok: false, code: "permission/error", message: err?.message ?? String(err) };
386
+ }
387
+ }
388
+
389
+ /** preset/tools RPC(仅 DEBUG):读一个会话 agent scope 的实际工具名(校验挂载用)。 */
390
+ async presetTools(params) {
391
+ if (process.env.PI_WEB_DSH_DEBUG !== "1") {
392
+ throw new Error("preset/tools 仅调试可用(服务端需设 PI_WEB_DSH_DEBUG=1)");
393
+ }
394
+ if (typeof params?.sessionId !== "string") throw new TypeError("preset/tools requires sessionId");
395
+ const rec = await this.getOrCreateSession(params.sessionId);
396
+ const agent = this.liveAgent(rec, params.sessionId);
397
+ // agent 视角(scope):preset 层 + host 层合并目录;缺省全局视角只看到 host 层。
398
+ const schemas = this.ctx.tools.schemas(agent);
399
+ return { tools: schemas.map((s) => s.name).sort() };
400
+ }
401
+
402
+ /** debug/assemble RPC(仅 DEBUG):读一个会话 agent 视角的系统提示词 section 名录
403
+ * (零 token 验证 persona 遮蔽/complete 压制/自定义 section 用;不返回正文)。 */
404
+ async debugAssemble(params) {
405
+ if (process.env.PI_WEB_DSH_DEBUG !== "1") {
406
+ throw new Error("debug/assemble 仅调试可用(服务端需设 PI_WEB_DSH_DEBUG=1)");
407
+ }
408
+ if (typeof params?.sessionId !== "string") throw new TypeError("debug/assemble requires sessionId");
409
+ const rec = await this.getOrCreateSession(params.sessionId);
410
+ const agent = this.liveAgent(rec, params.sessionId);
411
+ // 组装上下文同 agent-loop(assembleContextFor):{ agent, scope: agent }。
412
+ const contexts = [{ agent, scope: agent }, { scope: agent }, { scope: agent.ctx }];
413
+ let lastErr = null;
414
+ for (const context of contexts) {
415
+ try {
416
+ const asm = await this.ctx.systemPrompt.assemble(context);
417
+ if (asm && Array.isArray(asm.sections)) {
418
+ return { sections: asm.sections.map((s) => ({ name: s.name, chars: String(s.text ?? "").length })) };
419
+ }
420
+ } catch (err) {
421
+ lastErr = err;
422
+ }
423
+ }
424
+ throw new Error(`debug/assemble: assembly failed (${lastErr?.message ?? lastErr})`);
425
+ }
426
+
427
+ /** 会话创建(官方同形 + setup):mount 预设 + 装 per-agent 钩子。
428
+ * 磁盘上已有同 id(运行时重启/优雅关闭后重建)→ 转 agents.resume
429
+ * (官方恢复路径,自动缝合被中断的 turn;setup 照跑,预设照挂)。 */
430
+ async createSession(sessionId, preset) {
431
+ const requested = preset ?? this.pendingPresets.get(sessionId);
432
+ this.pendingPresets.delete(sessionId);
433
+ const resolved = await this.resolvePresetId(requested);
434
+ const agentOptions = {
435
+ provider: this.provider,
436
+ model: this.model,
437
+ ...(this.reasoningEffort === void 0 ? {} : { reasoningEffort: this.reasoningEffort }),
438
+ ...(this.maxTokens === void 0 ? {} : { maxTokens: this.maxTokens }),
439
+ };
440
+ const setup = async (agentCtx, agent) => this.agentSetup(agentCtx, agent, resolved);
441
+ let handle;
442
+ try {
443
+ handle = await this.ctx.agents.create({
444
+ sessionId,
445
+ meta: { cwd: this.cwd, ...(resolved ? { agentPreset: resolved } : {}) },
446
+ agentOptions,
447
+ setup,
448
+ });
449
+ } catch (err) {
450
+ if (typeof err?.message === "string" && err.message.includes(`session "${sessionId}" already exists`)) {
451
+ process.stderr.write(`[pi-web-ui-dsh] 会话 ${sessionId} 已在磁盘,resume 恢复\n`);
452
+ handle = await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup });
453
+ } else {
454
+ throw err;
455
+ }
456
+ }
457
+ const rec = { handle };
458
+ this.sessions.set(sessionId, rec);
459
+ return rec;
460
+ }
461
+
462
+ /** 官方 getOrCreateSession 同形(多出来的 preset 参数供创建时使用)。 */
463
+ async getOrCreateSession(sessionId, preset) {
464
+ if (this.shuttingDown) throw new Error("SDK server is shutting down");
465
+ const existing = this.sessions.get(sessionId);
466
+ if (existing) return existing;
467
+ const pending = this.sessionCreations.get(sessionId);
468
+ if (pending) return pending;
469
+ const creation = this.createSession(sessionId, preset);
470
+ this.sessionCreations.set(sessionId, creation);
471
+ creation.then(
472
+ () => {
473
+ this.sessionCreations.delete(sessionId);
474
+ },
475
+ () => {
476
+ this.sessionCreations.delete(sessionId);
477
+ },
478
+ );
479
+ return creation;
480
+ }
481
+
482
+ /** 每个 agent 出生时跑一次:mount 预设 + 问卷 waterfall + 技能目录过滤。
483
+ * 预设优先级:会话记录(header/selected 事件,重启恢复时是唯一真相)>
484
+ * 创建参数 > roster 默认。 */
485
+ async agentSetup(agentCtx, agent, presetId) {
486
+ const roster = this.presetRoster();
487
+ let effective = presetId;
488
+ if (roster) {
489
+ try {
490
+ const prior = this.ctx.sessionProjections.stateOf(agent.session, "agentPreset");
491
+ if (typeof prior === "string" && prior) effective = prior;
492
+ } catch {
493
+ /* 无 projection 能力 → 用创建参数 */
494
+ }
495
+ }
496
+ if (roster && effective) {
497
+ try {
498
+ await roster.mount(agentCtx, effective);
499
+ if (effective === "minimal") this.minimalAgents.add(agent.id);
500
+ } catch (err) {
501
+ process.stderr.write(
502
+ `[pi-web-ui-dsh] preset "${effective}" mount 失败,转 host 直连: ${err?.message ?? err}\n`,
503
+ );
504
+ }
505
+ }
506
+ // 问卷 waterfall(P0 修法):answerer 必须挂 agent scope —— host 层收不到
507
+ // agent-scoped 事件,且新版 UserQuestionService 已删 registerProvider。
508
+ // 与官方 ui-user-questions 同构:返回 {answers} 即 short-circuit。
509
+ agentCtx.on("user-questions/request", (request, _next) => this.answerScopedQuestion(agent, request));
510
+ // 技能目录过滤:与上面同理,host 版已死(收不到事件),改挂 agent scope。
511
+ // eslint-disable-next-line no-empty-pattern -- only next is needed
512
+ agentCtx.on("agent/pre-step", async ({}, next) => {
513
+ const decision = await next();
514
+ if (this.disabledSkills.size && decision?.kind === "enter" && Array.isArray(decision.messages)) {
515
+ if (decision.messages.some((m) => m?.source?.kind === "skill-catalog")) {
516
+ decision.messages = decision.messages.map((m) => filterSkillCatalogMessage(m, this.disabledSkills));
517
+ }
518
+ }
519
+ return decision;
520
+ });
521
+ }
522
+
523
+ /** agent scope 的问卷 answerer:minimal 门禁 → 浏览器桥(复用排队逻辑)。 */
524
+ async answerScopedQuestion(agent, request) {
525
+ if (this.minimalAgents.has(agent?.id)) {
526
+ throw new Error(
527
+ "ask_user_question is unavailable under the minimal preset; use the shell instead " +
528
+ "(极简模式下不可提问,请直接用 shell 命令推进)",
529
+ );
530
+ }
531
+ if (request?.signal?.aborted) throw new Error("ask_user_question aborted(提问已中止)");
532
+ return this.askUser(request ?? {});
533
+ }
534
+
535
+ /** 技能目录取数 agent(standard 常驻,永不 prompt;roster 缺失时不用)。 */
536
+ async ensureInventoryAgent() {
537
+ const rec = await this.getOrCreateSession(this.inventorySessionId);
538
+ return this.liveAgent(rec, this.inventorySessionId);
539
+ }
540
+
197
541
  /** 方法分发:goal/* 与 attachment/* 走上面/下面,其余交官方。 */
198
542
  async handleRequest(method, params) {
199
543
  switch (method) {
@@ -231,6 +575,20 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
231
575
  return this.registerSkill(params);
232
576
  case "skills/get":
233
577
  return this.getSkill(params);
578
+ case "preset/list":
579
+ return this.listPresets();
580
+ case "preset/assign":
581
+ return this.assignPreset(params);
582
+ case "preset/select":
583
+ return this.selectPreset(params);
584
+ case "permission/get":
585
+ return this.getPermission(params);
586
+ case "permission/set":
587
+ return this.setPermission(params);
588
+ case "preset/tools":
589
+ return this.presetTools(params);
590
+ case "debug/assemble":
591
+ return this.debugAssemble(params);
234
592
  default:
235
593
  return super.handleRequest(method, params);
236
594
  }
@@ -324,8 +682,17 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
324
682
  };
325
683
  }
326
684
 
327
- /** 桥接工具的 execute:把调用发给服务端并 await 结果。 */
685
+ /** 桥接工具的 execute:minimal 门禁后把调用发给服务端并 await 结果。 */
328
686
  invokeBridgedTool(exec, name, args) {
687
+ const agentId = exec?.agent?.id;
688
+ if (agentId !== undefined && this.minimalAgents.has(agentId)) {
689
+ return Promise.reject(
690
+ new Error(
691
+ `工具 ${name} unavailable under the minimal preset; use the shell instead ` +
692
+ `(极简模式下该插件工具不可用,请用 shell 命令推进)`,
693
+ ),
694
+ );
695
+ }
329
696
  const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
330
697
  const sessionId =
331
698
  exec?.agent?.session?.id && typeof exec.agent.session.id === "string" ? String(exec.agent.session.id) : undefined;
@@ -404,8 +771,30 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
404
771
  /** 禁用的技能名集合(skills/set-disabled 更新)。 */
405
772
  disabledSkills = new Set();
406
773
 
407
- /** 列出运行时当前可见技能(SkillRegistry.list,零 key 校验/前端显示用)。 */
774
+ /** 列出运行时当前可见技能:roster 下读常驻 inventory agent 的合并目录(技能只活在
775
+ * 各预设的 agent 层,host 的 skill-filesystem 已 disable,host 直读恒为空);
776
+ * legacy 下保持原来的 host 直读。零 key 校验/前端显示用。 */
408
777
  async listSkills() {
778
+ const roster = this.presetRoster();
779
+ if (roster) {
780
+ try {
781
+ const agent = await this.ensureInventoryAgent();
782
+ // scope 视图:host registry + agent 层合并;cwd 选工作区根。
783
+ const skills = await this.ctx.skills.list({ scope: agent, cwd: this.cwd });
784
+ if (Array.isArray(skills)) {
785
+ return {
786
+ skills: skills.map((s) => ({
787
+ name: s.name,
788
+ description: s.description ?? "",
789
+ ...(s.invocation ? { invocation: s.invocation } : {}),
790
+ })),
791
+ };
792
+ }
793
+ } catch (err) {
794
+ return { skills: [], error: err?.message ?? String(err) };
795
+ }
796
+ return { skills: [] };
797
+ }
409
798
  try {
410
799
  const skills = await this.ctx.skills.list();
411
800
  if (!Array.isArray(skills)) return { skills: [] };
@@ -593,25 +982,9 @@ function apply(ctx, config) {
593
982
  const server = new DshGoalJsonRpcServer(ctx, transport, {
594
983
  maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
595
984
  });
596
- // 注册用户提问 provider:模型 ask_user_question 浏览器对话框 → 答案回传。
597
- // 单个 context 只允许一个 provider;dispose 时随 ctx.effect 清理。
598
- const unregisterQuestions = ctx.userQuestions.registerProvider({
599
- ask: (request) => server.askUser(request),
600
- });
601
- ctx.effect(() => unregisterQuestions, "user-questions.bridge");
602
- // 技能启停(#18):晚 agent/pre-step 钩子,在 dsh-tool-skill 注入技能目录后
603
- // 按 server.disabledSkills 过滤 catalog 消息(剔除禁用技能条目)。
604
- // 注册在 base 之后 → 本钩子后跑,能拿到已渲染的 catalog。
605
- // eslint-disable-next-line no-empty-pattern -- only next is needed
606
- ctx.on("agent/pre-step", async ({}, next) => {
607
- const decision = await next();
608
- if (server.disabledSkills.size && decision?.kind === "enter" && Array.isArray(decision.messages)) {
609
- if (decision.messages.some((m) => m?.source?.kind === "skill-catalog")) {
610
- decision.messages = decision.messages.map((m) => filterSkillCatalogMessage(m, server.disabledSkills));
611
- }
612
- }
613
- return decision;
614
- });
985
+ // 问卷/技能目录钩子不再挂 host:agent-scoped 事件 host 收不到(且新版
986
+ // UserQuestionService 已删 registerProvider,旧写法直接导致 boot 失败)。
987
+ // 二者改由每次会话创建的 setup() 挂到 agent scope(见 agentSetup)。
615
988
  let exitTask;
616
989
  const disposeAndExit = () => {
617
990
  exitTask ??= (async () => {
@@ -640,6 +1013,17 @@ function apply(ctx, config) {
640
1013
  }
641
1014
 
642
1015
  const name = "sdk-jsonrpc-server";
643
- const inject = ["agents", "goals", "attachments", "userQuestions", "llm", "tools", "skills"];
1016
+ const inject = [
1017
+ "agents",
1018
+ "goals",
1019
+ "attachments",
1020
+ "userQuestions",
1021
+ "llm",
1022
+ "tools",
1023
+ "skills",
1024
+ "systemPrompt",
1025
+ "permissionPresets",
1026
+ "approval",
1027
+ ];
644
1028
 
645
1029
  export { Config, apply, inject, name };
@@ -60,6 +60,11 @@ if (userPatchDir) {
60
60
  }
61
61
  userPatchFiles.sort((a, b) => a.localeCompare(b));
62
62
 
63
+ // preset-plane patch 路径(服务端生成的 agent-plane 下沉层,见
64
+ // server/dsh/preset-clones.ts)。实际加载在 boot import 之后;缺失/不可读 =
65
+ // 回落 legacy 单组合(roster 不挂,会话走 host 直连),静默跳过。
66
+ const presetPlanePatch = process.env.PI_WEB_DSH_PRESET_PATCH ?? null;
67
+
63
68
  // ---------------------------------------------------------------------------
64
69
  // boot
65
70
  // ---------------------------------------------------------------------------
@@ -105,11 +110,23 @@ const userPatchLists = userPatchFiles.map((file) => {
105
110
  }
106
111
  });
107
112
  for (const list of userPatchLists) for (const patch of list) fixJsonrpcName(patch);
113
+ // preset-plane patch(服务端生成;override 之后、用户 patch 之前)。
114
+ let presetPlaneLists = [];
115
+ if (presetPlanePatch) {
116
+ try {
117
+ presetPlaneLists = loadOverlayPatches(BIN_NAME, presetPlanePatch);
118
+ } catch (err) {
119
+ process.stderr.write(`[${BIN_NAME}] 跳过 preset-plane patch ${presetPlanePatch}: ${err?.message ?? String(err)}\n`);
120
+ presetPlaneLists = [];
121
+ }
122
+ }
123
+ for (const patch of presetPlaneLists) fixJsonrpcName(patch);
108
124
  // patches 参数必须是扁平列表(boot → mountRootInclude → Include.applyPatches →
109
125
  // applyEntryPatches 逐个消费;数组嵌套会被当无 id 的 patch 跳过)。
110
126
  const patches = [
111
127
  ...loadOverlayPatches(BIN_NAME, baseBundlePatch),
112
128
  ...overrideList,
129
+ ...presetPlaneLists,
113
130
  // 用户 patch:同样按文件展开为扁平 patch entry 列表(一个文件可含多 entry)。
114
131
  ...userPatchLists.flat(),
115
132
  ];
@@ -18,10 +18,11 @@
18
18
  model: !!js process.env.DSH_DEFAULT_MODEL ?? 'deepseek-v4-flash'
19
19
 
20
20
  # 部署人设(默认空;pi-web-ui 设置面板可注入)。
21
+ # 键名必须是 personaPrefix(dsh-system-prompt schema;persona: 会被 zod 静默丢弃)。
21
22
  - id: system-prompt
22
23
  name: "@deepseek-ai/dsh-system-prompt"
23
24
  config:
24
- persona: !!js process.env.DSH_PERSONA ?? ''
25
+ personaPrefix: !!js process.env.DSH_PERSONA ?? ''
25
26
 
26
27
  # 沙箱工作区根(workspace-write 权限模式的护栏边界)。
27
28
  - id: sandbox-policy
@@ -59,6 +60,23 @@
59
60
 
60
61
  # ── 新行(plain insert)─────────────────────────────────────────────────────
61
62
  - insert:
63
+ # pi-web-ui 自定义系统提示词(设置面板):独立 host section,随 standard/ptc/
64
+ # cordis 下发;minimal(complete:true)按官方语义压住。文本走环境变量
65
+ # PI_WEB_DSH_CUSTOM_PROMPT(Node 侧维护,变化即重启运行时)。
66
+ - id: pi-webui-custom-prompt
67
+ name: ./custom-prompt.mjs
68
+ # PTC mode 的 TypeScript 运行时(preset 的 tool-presentation 行等它;
69
+ # 无此行时 ptc 预设能挂但不出 run_code,PTC 名存实亡)。
70
+ - id: code-runtime
71
+ name: "@deepseek-ai/dsh-code-runtime-worker-thread"
72
+ # 子代理模型选择开关(standard/ptc/cordis 的 tool-subagent 行
73
+ # modelSelectionSettings: true 要求它在 Host scope;缺失则整组 mount 失败)。
74
+ - id: subagent-model-selection-settings
75
+ name: "@deepseek-ai/dsh-tool-subagent/model-selection-settings"
76
+ # cordis 模式的宿主执行器(tool-cordis 等 dynamicCordisRunner/cordisInspect;
77
+ # 缺失则 cordis 预设 mount 失败)。与官方 dsh-web-app 的 cordis-host-runner 同位。
78
+ - id: cordis-host-runner
79
+ name: "@deepseek-ai/dsh-cordis-host-runner"
62
80
  # 模型提问工具:ask_user_question → ctx.userQuestions(base 已挂 user-questions
63
81
  # 服务)→ goal-rpc wrapper 注册的 provider 桥到浏览器对话框。
64
82
  - id: tool-ask-user