@sjhmars/happy-bridge 0.3.8 → 0.3.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.
package/README.md CHANGED
@@ -12,12 +12,12 @@
12
12
 
13
13
  - 未配对:二维码 + 可复制的手机/网页链接。请在 **Happy App 里扫**,不要用系统相机。
14
14
  - 已配对:显示已连接,可断开或点 **重新配对** 再出一张新二维码。断开只关掉 Happy 映射,网页 UI 还在。
15
- - **手机能管多深**:只看 / 能聊 / 能批 / 完整。默认「能批」。改到 `danger-full-access`、以及让手机换模型和思考强度同步到电脑输入框,都需要「完整」。电脑上换模型和思考强度不需要这个档。两边都能切,会互相跟着变。网页加了新模型后,已经同步到手机的旧对话名单也会更新。改档位立刻生效,不必重新配对。
15
+ - **手机能管多深**:只看 / 能聊 / 能批 / 完整。默认「能批」。改到 `danger-full-access`、以及让手机换模型和思考强度同步到电脑输入框,都需要「完整」。电脑上换模型和思考强度不需要这个档。**真正发给模型的,永远是网页那一栏**;手机切档会先改这一栏,再发消息。两边都能切,会互相跟着变。网页自己发布到手机的回声(同一模型、同一档)不会反过来改网页。网页加了新模型后,已经同步到手机的旧对话名单也会更新。改档位立刻生效,不必重新配对。
16
16
 
17
17
  手机 Happy App:
18
18
 
19
19
  - 列表里看到本机普通会话(子 agent 不镜像)。侧栏里没点开的对话同样保持在线心跳。网页上的空白「新会话」占位(没发过第一条消息、网页未选中时会藏起来)不会出现在手机上;发出第一条之后才会镜像。在手机里给这样的对话发消息,会把它在电脑上唤醒(网页不必跟着切过去),并用这场对话上次用过的模型生成回复;没有记录就用网页默认模型。连着电脑时是在线;心跳断了会显示未在线。网页侧栏还在的对话会自动把心跳续上;手机点开这条未在线对话也会重新接通,不必先去网页点开。标题优先用网页会话名,没有就用第一条用户消息;空对话和网页一样叫「新会话」。列表按网页工作区分组,不会把 Windows 盘符路径堆成一坨。网页归档的对话会停心跳、进手机归档列表;从归档里打开或再发一条,网页侧栏会把这场对话显示回来。
20
- - 打开空的 Happy 会话时,会把网页里已有的用户/助手/工具记录补进去。之后网页新消息也会推到手机。从手机新建或唤醒一场对话时,首轮请求会补上明确的思考强度(网页正在用的档,否则模型默认档),并同时认消息里的 `effort` / `effortLevel`,避免第一段思考被写成正文。
20
+ - 打开空的 Happy 会话时,会把网页里已有的用户/助手/工具记录补进去。之后网页新消息也会推到手机。网页第一条、以及从手机新建或唤醒一场对话时,会把思考强度写进网页模型栏(网页正在用的档,否则模型默认档),请求只读这一栏,避免第一段思考被写成正文。手机消息里的 `effort` / `effortLevel` 若真是换档(不是网页刚发布的回声),会先落到网页栏再发出。
21
21
  - 新建:只能选已经在网页登记过的工作区。不会在磁盘上 mkdir。App 里「new worktree」会失败(故意不暴露 bash)。
22
22
  - 发文字:已注册 `/命令` 走命令通道;否则当聊天。`/skill名` 仍当普通消息,由 harness 注入。
23
23
  - 发图片:相册/拍照/粘贴后,会随下一条文字(也可以只发图)进同一场电脑对话,模型能看见图。`.java` / `.txt` 等普通文件会先下载到工作区 `happy-inbox/`,再走电脑原来的 `read` 工具读内容(和你在仓库里打开一个文件一样)。PDF、Word 这类不是纯文本的文件,电脑的 `read` 会拒绝,插件也不会自己拆文档。远程档至少「能聊」。输入框左边应有图片按钮;发出后电脑网页会出现图片,手机会转圈等回复。电脑网页贴的图也会按同一套加密上传推到手机。
@@ -72,7 +72,7 @@ pnpm dsh web --port 3080
72
72
  questionChannel: communications
73
73
  ```
74
74
 
75
- `remoteGrant`:`watch` | `chat` | `approve` | `full`。设置页下拉框会写入这份配置。会话里也可以 `/remote approve`(只改当前进程,不写设置)。档位「完整」时,手机换模型和思考强度走电脑同一条 `session.selectModel`,网页模型栏和推理等级会跟着变。电脑自己换模型和思考强度同样会写回手机。网页加了新模型后,已镜像的手机会话会重新收到完整名单。
75
+ `remoteGrant`:`watch` | `chat` | `approve` | `full`。设置页下拉框会写入这份配置。会话里也可以 `/remote approve`(只改当前进程,不写设置)。档位「完整」时,手机换模型和思考强度会先 `await` 电脑同一条 `session.selectModel`(网页模型栏和推理等级跟着变),再发下一条。电脑自己换模型和思考强度同样会写回手机。发给模型的请求只读网页栏,不会另走一套手机记忆。网页加了新模型后,已镜像的手机会话会重新收到完整名单。
76
76
 
77
77
  `questionChannel`:`communications`(默认)| `permission`。选择题走的 Happy 通道。`communications` 是 App 的表单通道:答完卡片常驻显示你选的答案,会话列表也会显示「等回答」;需要较新版 Happy App。`permission` 是旧通道:题目选项仍可见可答,答完卡片不回显。两条通道都没有内联输入框——自由回答都是直接在聊天输入框打字发送(表单首题末尾有提示行;`communications` 下零选项的题目会弹 App 全屏表单,那里有真正的输入框)。
78
78
 
package/lib/client.js CHANGED
@@ -17,21 +17,21 @@ window.__ModuleLoader__.load({
17
17
  document.head.appendChild(tag);
18
18
  }
19
19
  var HappyBridgeCard_module_css_default = {
20
+ "card": "l9rBEq_card",
20
21
  "description": "l9rBEq_description",
21
22
  "status": "l9rBEq_status",
22
- "label": "l9rBEq_label",
23
+ "headText": "l9rBEq_headText",
24
+ "name": "l9rBEq_name",
25
+ "body": "l9rBEq_body",
26
+ "checkbox": "l9rBEq_checkbox",
23
27
  "error": "l9rBEq_error",
24
28
  "row": "l9rBEq_row",
29
+ "label": "l9rBEq_label",
25
30
  "chevron": "l9rBEq_chevron",
26
- "button": "l9rBEq_button",
27
- "checkbox": "l9rBEq_checkbox",
28
- "card": "l9rBEq_card",
29
- "header": "l9rBEq_header",
30
- "headText": "l9rBEq_headText",
31
- "name": "l9rBEq_name",
32
- "qr": "l9rBEq_qr",
33
31
  "select": "l9rBEq_select",
34
- "body": "l9rBEq_body"
32
+ "qr": "l9rBEq_qr",
33
+ "header": "l9rBEq_header",
34
+ "button": "l9rBEq_button"
35
35
  };
36
36
  //#endregion
37
37
  //#region src/client/HappyBridgeCard.tsx
package/lib/index.js CHANGED
@@ -1613,9 +1613,10 @@ function wakeModelSelection(primary, ...fallbacks) {
1613
1613
  };
1614
1614
  }
1615
1615
  /**
1616
- * Effort the first phone-spawn / phone-wake request should send.
1616
+ * Effort the first web / phone-spawn / phone-wake request should send.
1617
1617
  * Current pick wins; otherwise a preferred (web) value or the model's
1618
1618
  * advertised default, but only when the model lists that id.
1619
+ * Written onto the Host picker via `selectModel`; requests then read that bar.
1619
1620
  * @param currentEffort - already chosen effort, if any.
1620
1621
  * @param preferred - web picker effort to reuse when the model accepts it.
1621
1622
  * @param modelDefault - `resolveModelInfo().reasoning.defaultEffort`.
@@ -2820,8 +2821,6 @@ var HappyBridge = class {
2820
2821
  lastPublished = /* @__PURE__ */ new Map();
2821
2822
  /** Count of in-flight phone-originated Host `selectModel` calls. */
2822
2823
  hostSelectFromPhone = 0;
2823
- /** Per-agent selection installed on phone wake / spawn, matching Host `selectionFor`. */
2824
- selections = /* @__PURE__ */ new WeakMap();
2825
2824
  /** In-flight phone wakes, so two inbound texts do not double-resume. */
2826
2825
  waking = /* @__PURE__ */ new Map();
2827
2826
  /** Phone stop-session / archive: do not recreate these Happy rows. */
@@ -2832,6 +2831,10 @@ var HappyBridge = class {
2832
2831
  lastArchivedIds = /* @__PURE__ */ new Set();
2833
2832
  /** Serialize phone text so two messages cannot split one attachment batch. */
2834
2833
  inboundTail = /* @__PURE__ */ new Map();
2834
+ /** One Host-picker pin per live Agent, awaited before the first prompt assembly. */
2835
+ hostPin = /* @__PURE__ */ new WeakMap();
2836
+ /** Agents that already prepend the pin onto `system-prompt/assemble`. */
2837
+ assemblePinBound = /* @__PURE__ */ new WeakSet();
2835
2838
  error;
2836
2839
  running = false;
2837
2840
  scanTimer;
@@ -3016,6 +3019,7 @@ var HappyBridge = class {
3016
3019
  this.wakePhone(existing, true);
3017
3020
  return;
3018
3021
  }
3022
+ this.watchFirstAssemble(agent);
3019
3023
  if (isBlankSession(agent.session.events) && !this.phoneSpawned.has(agent.id)) return;
3020
3024
  this.mirrorAgent(agent).then(() => {
3021
3025
  if (this.isHarnessArchived(agent.id)) this.parkPhoneSession(agent.id);
@@ -3093,7 +3097,7 @@ var HappyBridge = class {
3093
3097
  text: "当前没有已选模型"
3094
3098
  };
3095
3099
  if (id === "") {
3096
- this.rememberModel(invocation.agent, {
3100
+ await this.rememberModel(invocation.agent, {
3097
3101
  provider: current.provider,
3098
3102
  model: current.model
3099
3103
  });
@@ -3102,7 +3106,7 @@ var HappyBridge = class {
3102
3106
  text: "推理档已恢复为模型默认"
3103
3107
  };
3104
3108
  }
3105
- this.rememberModel(invocation.agent, {
3109
+ await this.rememberModel(invocation.agent, {
3106
3110
  provider: current.provider,
3107
3111
  model: current.model,
3108
3112
  reasoningEffort: ReasoningEffortId(id)
@@ -3257,6 +3261,7 @@ var HappyBridge = class {
3257
3261
  if (session.header.id !== link.dshId) return;
3258
3262
  this.onSessionEvent(link, event);
3259
3263
  }, { global: true });
3264
+ this.watchFirstAssemble(agent);
3260
3265
  }
3261
3266
  /** Copy log events newer than {@link Link.lastForwardedSeq} onto Happy. */
3262
3267
  drainNewEvents(link, agent) {
@@ -3312,14 +3317,15 @@ var HappyBridge = class {
3312
3317
  resumeSessionId: SessionId(link.dshId),
3313
3318
  setup
3314
3319
  });
3315
- await this.ensurePinnedEffort(handle.agent);
3320
+ await this.watchFirstAssemble(handle.agent);
3316
3321
  const current = this.links.get(link.dshId);
3317
3322
  if (current !== void 0) this.attachAgent(current, handle.agent);
3318
3323
  return handle.agent;
3319
3324
  }
3320
3325
  /**
3321
- * Resume/create composition matching Host `composeAgent`: install model
3322
- * selection, then mount the preset when a roster exists.
3326
+ * Resume/create composition matching Host `composeAgent`: mount the preset
3327
+ * when a roster exists. Model selection is the Host picker, landed by
3328
+ * `selectModel` in {@link watchFirstAssemble} / {@link rememberModel}.
3323
3329
  * @param presetHint - logged or requested preset id; omitted uses the roster default.
3324
3330
  */
3325
3331
  async composeAgentSetup(presetHint) {
@@ -3327,71 +3333,9 @@ var HappyBridge = class {
3327
3333
  let resolvedId;
3328
3334
  if (presets !== void 0) resolvedId = (await presets.resolve(presetHint)).id;
3329
3335
  return async (agentCtx) => {
3330
- this.installWakeSelection(agentCtx);
3331
3336
  if (presets !== void 0 && resolvedId !== void 0) await presets.mount(agentCtx, resolvedId);
3332
3337
  };
3333
3338
  }
3334
- /**
3335
- * Same lazy selection Host `selectionFor` installs: remembered pick, else
3336
- * the session's last `request/header`, else `agentDefaultModel`. A missing
3337
- * thinking level keeps the web picker's effort when it is the same model.
3338
- * Unlike Host `installModelSelection`, an absent effort does not clear
3339
- * inherited thinking.
3340
- */
3341
- installWakeSelection(agentCtx) {
3342
- const agent = agentCtx.agent;
3343
- if (agent === void 0) throw new Error("happy-bridge: agent setup has no scoped agent");
3344
- if (this.selections.has(agent)) return;
3345
- let picked;
3346
- const bridge = this;
3347
- const selection = {
3348
- get current() {
3349
- if (picked !== void 0) return picked;
3350
- return bridge.currentModel(agent);
3351
- },
3352
- set current(next) {
3353
- picked = next;
3354
- },
3355
- assembled: void 0
3356
- };
3357
- this.bindWakeSelection(agentCtx, selection);
3358
- this.selections.set(agent, selection);
3359
- const current = selection.current;
3360
- if (current === void 0) return;
3361
- const existing = this.models.get(agent.id);
3362
- if (existing === void 0 || existing.reasoningEffort === void 0 && current.reasoningEffort !== void 0) this.models.set(agent.id, current);
3363
- }
3364
- /**
3365
- * Pin provider/model for a phone-woken agent without wiping thinking when
3366
- * the selection names no effort.
3367
- */
3368
- bindWakeSelection(agentCtx, selection) {
3369
- agentCtx.on("system-prompt/assemble", async (_assembly, _context, next) => {
3370
- const selected = selection.current;
3371
- const assembled = await next();
3372
- selection.assembled = selected;
3373
- if (selected === void 0) return assembled;
3374
- return {
3375
- ...assembled,
3376
- variables: {
3377
- ...assembled.variables,
3378
- provider: selected.provider,
3379
- model: selected.model
3380
- }
3381
- };
3382
- });
3383
- agentCtx.on("agent/request", async (_payload, next) => {
3384
- const resolved = await next();
3385
- const selected = selection.assembled;
3386
- if (selected === void 0) return resolved;
3387
- return {
3388
- ...resolved,
3389
- provider: selected.provider,
3390
- model: selected.model,
3391
- ...selected.reasoningEffort === void 0 ? {} : { reasoningEffort: selected.reasoningEffort }
3392
- };
3393
- });
3394
- }
3395
3339
  /** Host default model, when the web profile mounted `agentDefaultModel`. */
3396
3340
  defaultModelSelection() {
3397
3341
  return this.ctx.get("agentDefaultModel")?.currentSelection();
@@ -3480,7 +3424,7 @@ var HappyBridge = class {
3480
3424
  onAbort: () => this.onPhoneAbort(agent.id),
3481
3425
  onArchived: () => this.onPhoneArchive(sessionId, agent.id),
3482
3426
  onResumed: () => this.onPhoneRestore(agent.id),
3483
- onCatalog: (meta) => this.applyPhoneCatalog(agent.id, meta),
3427
+ onCatalog: (meta) => this.queuePhoneCatalog(agent.id, meta),
3484
3428
  log: this.log
3485
3429
  });
3486
3430
  try {
@@ -3514,6 +3458,7 @@ var HappyBridge = class {
3514
3458
  };
3515
3459
  this.links.set(agent.id, link);
3516
3460
  this.happyToDsh.set(sessionId, agent.id);
3461
+ await this.watchFirstAssemble(agent);
3517
3462
  await this.pushMetadata(link);
3518
3463
  if (seq === 0) await this.replayHistory(link);
3519
3464
  this.log(`已镜像会话 ${agent.id} → Happy ${sessionId}(seq=${seq})`);
@@ -3549,7 +3494,7 @@ var HappyBridge = class {
3549
3494
  onAbort: () => this.onPhoneAbort(dshId),
3550
3495
  onArchived: () => this.onPhoneArchive(created.id, dshId),
3551
3496
  onResumed: () => this.onPhoneRestore(dshId),
3552
- onCatalog: (meta) => this.applyPhoneCatalog(dshId, meta),
3497
+ onCatalog: (meta) => this.queuePhoneCatalog(dshId, meta),
3553
3498
  log: this.log
3554
3499
  });
3555
3500
  try {
@@ -3754,8 +3699,8 @@ var HappyBridge = class {
3754
3699
  });
3755
3700
  await (this.ctx.get("workspaceRegistry")?.list().find((item) => item.path === real))?.attachSession(sessionId);
3756
3701
  this.phoneSpawned.add(handle.agent.id);
3757
- this.applySpawnMeta(handle.agent, options);
3758
- await this.ensurePinnedEffort(handle.agent);
3702
+ await this.applySpawnMeta(handle.agent, options);
3703
+ await this.watchFirstAssemble(handle.agent);
3759
3704
  await this.mirrorAgent(handle.agent, options.sessionId);
3760
3705
  return {
3761
3706
  type: "success",
@@ -3768,15 +3713,20 @@ var HappyBridge = class {
3768
3713
  };
3769
3714
  }
3770
3715
  }
3771
- applySpawnMeta(agent, options) {
3772
- if (options.modelMode !== void 0) this.applyModel(agent, options.modelMode, options.effortLevel);
3773
- else if (options.effortLevel !== void 0) this.applyEffort(agent, options.effortLevel);
3716
+ async applySpawnMeta(agent, options) {
3717
+ if (options.modelMode !== void 0) await this.applyModel(agent, options.modelMode, options.effortLevel);
3718
+ else if (options.effortLevel !== void 0) await this.applyEffort(agent, options.effortLevel);
3774
3719
  if (options.permissionMode !== void 0) this.applyPermission(agent, options.permissionMode, true);
3775
3720
  }
3776
3721
  queueInbound(dshId, message) {
3777
3722
  const next = (this.inboundTail.get(dshId) ?? Promise.resolve()).then(() => this.onInbound(dshId, message), () => this.onInbound(dshId, message));
3778
3723
  this.inboundTail.set(dshId, next);
3779
3724
  }
3725
+ /** Same queue as inbound text, so a catalog switch lands on the bar before the next send. */
3726
+ queuePhoneCatalog(dshId, meta) {
3727
+ const next = (this.inboundTail.get(dshId) ?? Promise.resolve()).then(() => this.applyPhoneCatalog(dshId, meta), () => this.applyPhoneCatalog(dshId, meta));
3728
+ this.inboundTail.set(dshId, next);
3729
+ }
3780
3730
  async onInbound(dshId, message) {
3781
3731
  const link = this.links.get(dshId);
3782
3732
  if (link === void 0 || link.replaying) return;
@@ -3826,7 +3776,6 @@ var HappyBridge = class {
3826
3776
  return;
3827
3777
  }
3828
3778
  const { agent } = ensured;
3829
- await this.ensurePinnedEffort(agent);
3830
3779
  const names = new Set((this.ctx.get("commands")?.list(agent) ?? []).map((command) => command.name));
3831
3780
  const kind = classifyInboundText(message.text, names);
3832
3781
  await this.applyMessageMeta(current, agent, message.meta, kind === "command");
@@ -3852,20 +3801,20 @@ var HappyBridge = class {
3852
3801
  const effortIsEcho = effort !== void 0 && published?.effort != null && effort === published.effort;
3853
3802
  if (model !== void 0 && !modelIsEcho) {
3854
3803
  if (!grantAtLeast(this.config.remoteGrant, "full")) link.socket.sendText("service", "改模型需要远程档「完整」,这条消息仍会发出。");
3855
- else this.applyModel(agent, model, typeof effort === "string" ? effort : effort === null ? null : void 0);
3804
+ else await this.applyModel(agent, model, typeof effort === "string" ? effort : effort === null ? null : void 0);
3856
3805
  } else if ((effort === null || typeof effort === "string") && !effortIsEcho) {
3857
3806
  if (!grantAtLeast(this.config.remoteGrant, "full")) link.socket.sendText("service", "改思考强度需要远程档「完整」,这条消息仍会发出。");
3858
3807
  else if (effort === null) {
3859
3808
  const current = this.currentModel(agent);
3860
- if (current !== void 0) this.rememberModel(agent, {
3809
+ if (current !== void 0) await this.rememberModel(agent, {
3861
3810
  provider: current.provider,
3862
3811
  model: current.model
3863
3812
  });
3864
- } else this.applyEffort(agent, effort);
3813
+ } else await this.applyEffort(agent, effort);
3865
3814
  }
3866
3815
  if (typeof permissionMode === "string" && permissionMode !== "") this.applyPermission(agent, permissionMode, isCommand);
3867
3816
  }
3868
- applyPhoneCatalog(dshId, meta) {
3817
+ async applyPhoneCatalog(dshId, meta) {
3869
3818
  if (!grantAtLeast(this.config.remoteGrant, "full")) return;
3870
3819
  const pick = catalogModelPick(meta);
3871
3820
  if (pick === void 0) return;
@@ -3876,20 +3825,20 @@ var HappyBridge = class {
3876
3825
  const agent = link === void 0 ? void 0 : this.requireAgent(link);
3877
3826
  if (agent === void 0) return;
3878
3827
  if (pick.model !== void 0) {
3879
- this.applyModel(agent, pick.model, pick.effort);
3828
+ await this.applyModel(agent, pick.model, pick.effort);
3880
3829
  return;
3881
3830
  }
3882
3831
  if (pick.effort === null) {
3883
3832
  const current = this.currentModel(agent);
3884
- if (current !== void 0) this.rememberModel(agent, {
3833
+ if (current !== void 0) await this.rememberModel(agent, {
3885
3834
  provider: current.provider,
3886
3835
  model: current.model
3887
3836
  });
3888
3837
  return;
3889
3838
  }
3890
- if (typeof pick.effort === "string") this.applyEffort(agent, pick.effort);
3839
+ if (typeof pick.effort === "string") await this.applyEffort(agent, pick.effort);
3891
3840
  }
3892
- applyModel(agent, code, effort) {
3841
+ async applyModel(agent, code, effort) {
3893
3842
  const split = splitModelCode(code);
3894
3843
  const current = this.currentModel(agent);
3895
3844
  const provider = split.provider === "" ? current?.provider ?? "" : split.provider;
@@ -3901,24 +3850,25 @@ var HappyBridge = class {
3901
3850
  model: split.model,
3902
3851
  ...reasoningEffort === void 0 ? {} : { reasoningEffort }
3903
3852
  };
3904
- this.rememberModel(agent, next);
3853
+ await this.rememberModel(agent, next);
3905
3854
  }
3906
- applyEffort(agent, effort) {
3855
+ async applyEffort(agent, effort) {
3907
3856
  const current = this.currentModel(agent);
3908
3857
  if (current === void 0) return;
3909
- this.rememberModel(agent, {
3858
+ await this.rememberModel(agent, {
3910
3859
  ...current,
3911
3860
  reasoningEffort: ReasoningEffortId(effort)
3912
3861
  });
3913
3862
  }
3914
- /** Keep the last Host pick so Happy metadata can echo the web composer. */
3915
- rememberModel(agent, next) {
3916
- const previous = this.models.get(agent.id);
3863
+ /**
3864
+ * Remember a phone-originated pick and land it on the Host picker before
3865
+ * the next request. `onHostModelSelected` is skipped while this runs, so
3866
+ * {@link models} is written here.
3867
+ */
3868
+ async rememberModel(agent, next) {
3869
+ if (sameModelOverride(this.models.get(agent.id), next)) return;
3917
3870
  this.models.set(agent.id, next);
3918
- const selection = this.selections.get(agent);
3919
- if (selection !== void 0) selection.current = next;
3920
- if (sameModelOverride(previous, next)) return;
3921
- this.syncHostSelection(agent, next);
3871
+ await this.syncHostSelection(agent, next);
3922
3872
  }
3923
3873
  /**
3924
3874
  * Write the web picker's Host selection (`sessionController.selectModel`) so
@@ -3957,16 +3907,36 @@ var HappyBridge = class {
3957
3907
  };
3958
3908
  this.models.set(sessionId, next);
3959
3909
  const link = this.links.get(sessionId);
3960
- const agent = link?.agent;
3961
- if (agent !== void 0) {
3962
- const current = this.selections.get(agent);
3963
- if (current !== void 0) current.current = next;
3964
- }
3965
3910
  if (link !== void 0 && !link.parked) this.pushMetadata(link);
3966
3911
  }
3967
3912
  /**
3968
- * Put a concrete reasoningEffort on a phone-spawned / phone-woken agent
3969
- * before the first LLM request, matching the effort Happy metadata advertises.
3913
+ * Land explicit effort on the Host picker and wait for that pin before this
3914
+ * Agent's first prompt assembly, so the request reads the web bar.
3915
+ * @param agent - live agent whose picker should carry explicit effort.
3916
+ */
3917
+ watchFirstAssemble(agent) {
3918
+ const pending = this.pinHostPicker(agent);
3919
+ if (this.assemblePinBound.has(agent)) return pending;
3920
+ this.assemblePinBound.add(agent);
3921
+ agent.ctx.on("system-prompt/assemble", async (_assembly, _context, next) => {
3922
+ await this.pinHostPicker(agent);
3923
+ return next();
3924
+ }, { prepend: true });
3925
+ return pending;
3926
+ }
3927
+ pinHostPicker(agent) {
3928
+ const existing = this.hostPin.get(agent);
3929
+ if (existing !== void 0) return existing;
3930
+ const pending = this.ensurePinnedEffort(agent);
3931
+ this.hostPin.set(agent, pending);
3932
+ return pending;
3933
+ }
3934
+ /**
3935
+ * Write a concrete reasoningEffort onto the Host picker so the next request
3936
+ * reads the web bar. Always lands via `selectModel`: phone resume/spawn have
3937
+ * no Host interceptor until then, and a live web agent with no conversation
3938
+ * effort would otherwise send the first call without thinking.
3939
+ * @param agent - live agent whose picker should carry explicit effort.
3970
3940
  */
3971
3941
  async ensurePinnedEffort(agent) {
3972
3942
  const current = this.currentModel(agent);
@@ -3980,14 +3950,13 @@ var HappyBridge = class {
3980
3950
  if (reasoning?.efforts !== void 0 && reasoning.efforts.length > 0) supported = reasoning.efforts.map((effort) => effort.id);
3981
3951
  } catch {}
3982
3952
  const effort = pinWakeEffort(current.reasoningEffort, preferred, modelDefault, supported);
3983
- if (effort === void 0 || effort === current.reasoningEffort) return;
3984
3953
  const next = {
3985
- ...current,
3986
- reasoningEffort: ReasoningEffortId(effort)
3954
+ provider: current.provider,
3955
+ model: current.model,
3956
+ ...effort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(effort) }
3987
3957
  };
3988
3958
  this.models.set(agent.id, next);
3989
- const selection = this.selections.get(agent);
3990
- if (selection !== void 0) selection.current = next;
3959
+ await this.syncHostSelection(agent, next);
3991
3960
  }
3992
3961
  applyPermission(agent, mode, fromCommand) {
3993
3962
  const presets = this.ctx.get("permissionPresets");
@@ -18,8 +18,6 @@ export declare class HappyBridge {
18
18
  private readonly lastPublished;
19
19
  /** Count of in-flight phone-originated Host `selectModel` calls. */
20
20
  private hostSelectFromPhone;
21
- /** Per-agent selection installed on phone wake / spawn, matching Host `selectionFor`. */
22
- private readonly selections;
23
21
  /** In-flight phone wakes, so two inbound texts do not double-resume. */
24
22
  private readonly waking;
25
23
  /** Phone stop-session / archive: do not recreate these Happy rows. */
@@ -30,6 +28,10 @@ export declare class HappyBridge {
30
28
  private lastArchivedIds;
31
29
  /** Serialize phone text so two messages cannot split one attachment batch. */
32
30
  private readonly inboundTail;
31
+ /** One Host-picker pin per live Agent, awaited before the first prompt assembly. */
32
+ private readonly hostPin;
33
+ /** Agents that already prepend the pin onto `system-prompt/assemble`. */
34
+ private readonly assemblePinBound;
33
35
  private error;
34
36
  private running;
35
37
  private scanTimer;
@@ -98,24 +100,12 @@ export declare class HappyBridge {
98
100
  private wakeAgent;
99
101
  private resumeSession;
100
102
  /**
101
- * Resume/create composition matching Host `composeAgent`: install model
102
- * selection, then mount the preset when a roster exists.
103
+ * Resume/create composition matching Host `composeAgent`: mount the preset
104
+ * when a roster exists. Model selection is the Host picker, landed by
105
+ * `selectModel` in {@link watchFirstAssemble} / {@link rememberModel}.
103
106
  * @param presetHint - logged or requested preset id; omitted uses the roster default.
104
107
  */
105
108
  private composeAgentSetup;
106
- /**
107
- * Same lazy selection Host `selectionFor` installs: remembered pick, else
108
- * the session's last `request/header`, else `agentDefaultModel`. A missing
109
- * thinking level keeps the web picker's effort when it is the same model.
110
- * Unlike Host `installModelSelection`, an absent effort does not clear
111
- * inherited thinking.
112
- */
113
- private installWakeSelection;
114
- /**
115
- * Pin provider/model for a phone-woken agent without wiping thinking when
116
- * the selection names no effort.
117
- */
118
- private bindWakeSelection;
119
109
  /** Host default model, when the web profile mounted `agentDefaultModel`. */
120
110
  private defaultModelSelection;
121
111
  /** Registered workspace for this session, or `undefined` when it is not in the sidebar. */
@@ -161,12 +151,18 @@ export declare class HappyBridge {
161
151
  private spawn;
162
152
  private applySpawnMeta;
163
153
  private queueInbound;
154
+ /** Same queue as inbound text, so a catalog switch lands on the bar before the next send. */
155
+ private queuePhoneCatalog;
164
156
  private onInbound;
165
157
  private applyMessageMeta;
166
158
  private applyPhoneCatalog;
167
159
  private applyModel;
168
160
  private applyEffort;
169
- /** Keep the last Host pick so Happy metadata can echo the web composer. */
161
+ /**
162
+ * Remember a phone-originated pick and land it on the Host picker before
163
+ * the next request. `onHostModelSelected` is skipped while this runs, so
164
+ * {@link models} is written here.
165
+ */
170
166
  private rememberModel;
171
167
  /**
172
168
  * Write the web picker's Host selection (`sessionController.selectModel`) so
@@ -179,8 +175,18 @@ export declare class HappyBridge {
179
175
  */
180
176
  private onHostModelSelected;
181
177
  /**
182
- * Put a concrete reasoningEffort on a phone-spawned / phone-woken agent
183
- * before the first LLM request, matching the effort Happy metadata advertises.
178
+ * Land explicit effort on the Host picker and wait for that pin before this
179
+ * Agent's first prompt assembly, so the request reads the web bar.
180
+ * @param agent - live agent whose picker should carry explicit effort.
181
+ */
182
+ private watchFirstAssemble;
183
+ private pinHostPicker;
184
+ /**
185
+ * Write a concrete reasoningEffort onto the Host picker so the next request
186
+ * reads the web bar. Always lands via `selectModel`: phone resume/spawn have
187
+ * no Host interceptor until then, and a live web agent with no conversation
188
+ * effort would otherwise send the first call without thinking.
189
+ * @param agent - live agent whose picker should carry explicit effort.
184
190
  */
185
191
  private ensurePinnedEffort;
186
192
  private applyPermission;
@@ -155,9 +155,10 @@ export interface WakeModelSelection {
155
155
  */
156
156
  export declare function wakeModelSelection(primary: WakeModelSelection | undefined, ...fallbacks: Array<WakeModelSelection | undefined>): WakeModelSelection | undefined;
157
157
  /**
158
- * Effort the first phone-spawn / phone-wake request should send.
158
+ * Effort the first web / phone-spawn / phone-wake request should send.
159
159
  * Current pick wins; otherwise a preferred (web) value or the model's
160
160
  * advertised default, but only when the model lists that id.
161
+ * Written onto the Host picker via `selectModel`; requests then read that bar.
161
162
  * @param currentEffort - already chosen effort, if any.
162
163
  * @param preferred - web picker effort to reuse when the model accepts it.
163
164
  * @param modelDefault - `resolveModelInfo().reasoning.defaultEffort`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjhmars/happy-bridge",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Remote-control bridge: pair a running dsh web/desktop client with the Happy mobile app by QR code, then remote-control the same harness sessions from the phone (send messages, read replies, approve tool calls, switch models)",
5
5
  "keywords": [
6
6
  "deepseek-harness",