@sjhmars/happy-bridge 0.2.4 → 0.3.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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @sjhmars/happy-bridge
2
2
 
3
+ > **Remote-control bridge for the Happy mobile app.** Pair an already-running `dsh web` / DeepSeek Harness desktop client with [Happy App](https://github.com/slopus/happy) by scanning a QR code, then remote-control the **same harness sessions** from your phone over the Happy encrypted relay: send messages and images, read replies, approve tool calls / plan reviews / questions, stop a running turn, and switch models. Four grant levels: `watch` / `chat` / `approve` / `full`. "Bridge" here means the remote-control link between desktop harness and phone — not a card game.
4
+
3
5
  电脑照常跑 `dsh web` / 桌面客户端。装上这个插件并扫码后,手机 [Happy App](https://github.com/slopus/happy) 遥控的是**同一套对话**(发消息、看回复、点批准),不是另开一个 agent。
4
6
 
5
7
  不安装 Happy CLI。不要用 `happy acp -- dsh acp`。本插件不改 DeepSeek Harness 源码,也不改 Happy 官方仓库。
@@ -19,7 +21,7 @@
19
21
  - 新建:只能选已经在网页登记过的工作区。不会在磁盘上 mkdir。App 里「new worktree」会失败(故意不暴露 bash)。
20
22
  - 发文字:已注册 `/命令` 走命令通道;否则当聊天。`/skill名` 仍当普通消息,由 harness 注入。
21
23
  - 发图片:相册/拍照/粘贴后,会随下一条文字(也可以只发图)进同一场电脑对话,模型能看见图。`.java` / `.txt` 等普通文件会先下载到工作区 `happy-inbox/`,再走电脑原来的 `read` 工具读内容(和你在仓库里打开一个文件一样)。PDF、Word 这类不是纯文本的文件,电脑的 `read` 会拒绝,插件也不会自己拆文档。远程档至少「能聊」。输入框左边应有图片按钮;发出后电脑网页会出现图片,手机会转圈等回复。电脑网页贴的图也会按同一套加密上传推到手机。
22
- - 工具批准、计划审阅、选择题:和网页黄框/提问框赛跑,谁先点谁算。手机点「始终允许」只在这次网页进程里记住。提问还在等的时候,用输入框打字等于自己填;审阅计划时打字会关掉审阅,这句话当普通聊天发出去。远程档至少「能批」才能从手机点。
24
+ - 工具批准、计划审阅、选择题:和网页黄框/提问框赛跑,谁先点谁算。手机点「始终允许」只在这次网页进程里记住。选择题走 Happy 表单通道:选项照列,每题下面还有自由输入框,提交后卡片上会一直显示你选的答案(旧版 App 没有这条通道,见 `questionChannel`)。提问还在等的时候,用输入框打字等于自己填;审阅计划时打字会关掉审阅,这句话当普通聊天发出去。批完/拒完的卡片会停在「已批准/已拒绝」状态,按钮失效。远程档至少「能批」才能从手机点。
23
25
  - 电脑正在生成时,手机可以点停止(Happy App 的 Stop)。远程档至少「能聊」。只看档点了不会停。
24
26
  - 助手回复按整段推到手机(生成中会转圈)。思考收成一行 **Think · 摘要** 的 Note 卡片,点进去看全文。工具用 Happy 认识的名字(Grep / Read / Bash),描述跟网页那一行摘要一样。Happy 没有网页同款图标。
25
27
 
@@ -67,10 +69,13 @@ pnpm dsh web --port 3080
67
69
  appUrl: https://app.happy.engineering
68
70
  pairOnStart: true
69
71
  remoteGrant: approve
72
+ questionChannel: communications
70
73
  ```
71
74
 
72
75
  `remoteGrant`:`watch` | `chat` | `approve` | `full`。设置页下拉框会写入这份配置。会话里也可以 `/remote approve`(只改当前进程,不写设置)。档位「完整」时,手机换模型和思考强度走电脑同一条 `session.selectModel`,网页模型栏和推理等级会跟着变。电脑自己换模型和思考强度同样会写回手机。网页加了新模型后,已镜像的手机会话会重新收到完整名单。
73
76
 
77
+ `questionChannel`:`communications`(默认)| `permission`。选择题走的 Happy 通道。`communications` 是 App 的表单通道:每道题带自由输入框,提交后卡片常驻显示你选的答案,会话列表也会显示「等回答」;需要较新版 Happy App。旧版 App 没有这条通道时配成 `permission`:题目选项仍可见可答,但没有自由输入框(此时可在输入框打字作答),答完卡片也不回显。
78
+
74
79
  ## 已知限制
75
80
 
76
81
  - 第 1 期 `listDirectory` 只列出已登记工作区根,不能在手机里浏览仓库内部文件。
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
- "error": "l9rBEq_error",
21
- "chevron": "l9rBEq_chevron",
22
- "button": "l9rBEq_button",
23
- "headText": "l9rBEq_headText",
24
- "description": "l9rBEq_description",
25
20
  "body": "l9rBEq_body",
21
+ "row": "l9rBEq_row",
22
+ "description": "l9rBEq_description",
26
23
  "name": "l9rBEq_name",
24
+ "headText": "l9rBEq_headText",
25
+ "chevron": "l9rBEq_chevron",
26
+ "qr": "l9rBEq_qr",
27
27
  "card": "l9rBEq_card",
28
+ "error": "l9rBEq_error",
29
+ "button": "l9rBEq_button",
30
+ "status": "l9rBEq_status",
31
+ "checkbox": "l9rBEq_checkbox",
28
32
  "label": "l9rBEq_label",
29
- "header": "l9rBEq_header",
30
33
  "select": "l9rBEq_select",
31
- "checkbox": "l9rBEq_checkbox",
32
- "qr": "l9rBEq_qr",
33
- "status": "l9rBEq_status",
34
- "row": "l9rBEq_row"
34
+ "header": "l9rBEq_header"
35
35
  };
36
36
  //#endregion
37
37
  //#region src/client/HappyBridgeCard.tsx
package/lib/index.js CHANGED
@@ -74,6 +74,42 @@ async function revealOnHarness(registry, sessionId) {
74
74
  });
75
75
  });
76
76
  }
77
+ /**
78
+ * Insert or refresh a completed entry with FIFO eviction. Re-answering an id
79
+ * moves it back to newest; the oldest entry falls off the cap.
80
+ * @param map - per-link completed map.
81
+ * @param id - Happy request / communication id.
82
+ * @param entry - schema-valid completed entry.
83
+ */
84
+ function rememberCompleted(map, id, entry) {
85
+ map.delete(id);
86
+ map.set(id, entry);
87
+ while (map.size > 50) {
88
+ const oldest = map.keys().next().value;
89
+ if (oldest === void 0) break;
90
+ map.delete(oldest);
91
+ }
92
+ }
93
+ /** Plain-object view of a completed map for the encrypted snapshot. */
94
+ function snapshotMap(map) {
95
+ return Object.fromEntries(map);
96
+ }
97
+ /**
98
+ * Build the full agentState snapshot pushed with `update-state`. The pending
99
+ * approval or question sits in `requests` / `communications`; every completed
100
+ * entry rides along so App-side replays keep the answered card states.
101
+ * @param input - pending id + entries, the completed maps, and the grant flag.
102
+ * @returns the plaintext agentState object.
103
+ */
104
+ function agentStateSnapshot(input) {
105
+ return {
106
+ controlledByUser: input.controlledByUser,
107
+ requests: input.pendingId === void 0 || input.pendingRequest === void 0 ? {} : { [input.pendingId]: input.pendingRequest },
108
+ communications: input.pendingId === void 0 || input.pendingCommunication === void 0 ? {} : { [input.pendingId]: input.pendingCommunication },
109
+ completedRequests: snapshotMap(input.completedRequests ?? /* @__PURE__ */ new Map()),
110
+ completedCommunications: snapshotMap(input.completedCommunications ?? /* @__PURE__ */ new Map())
111
+ };
112
+ }
77
113
  //#endregion
78
114
  //#region lib/types/happy-version.js
79
115
  /**
@@ -1707,6 +1743,53 @@ function parsePermissionRpc(params) {
1707
1743
  result.updatedInput = { answers: mapped };
1708
1744
  return result;
1709
1745
  }
1746
+ /**
1747
+ * Translate Happy communications answers (`{ [question id]: { options, custom } }`)
1748
+ * into harness answer rows. Options become `selected`; free text becomes `custom`.
1749
+ * @param answers - communication RPC answers keyed by question id.
1750
+ * @param questions - original harness questions in order.
1751
+ * @returns one answer row per question.
1752
+ */
1753
+ function answersFromCommunication(answers, questions) {
1754
+ return questions.map((question) => {
1755
+ const answer = answers?.[question.id];
1756
+ if (answer === void 0) return {
1757
+ id: question.id,
1758
+ selected: []
1759
+ };
1760
+ return {
1761
+ id: question.id,
1762
+ selected: answer.options,
1763
+ ...answer.custom === void 0 ? {} : { custom: answer.custom }
1764
+ };
1765
+ });
1766
+ }
1767
+ /**
1768
+ * Decode a Happy `communication` RPC body (form answers or cancellation).
1769
+ * Unknown statuses degrade to `cancelled`, which re-asks instead of silently
1770
+ * submitting empty answers.
1771
+ * @param params - decrypted RPC params.
1772
+ * @returns id, form kind, answered/cancelled status, and per-question answers.
1773
+ */
1774
+ function parseCommunicationRpc(params) {
1775
+ const record = asUnknownRecord(params);
1776
+ const result = {
1777
+ id: typeof record.id === "string" ? record.id : "",
1778
+ kind: typeof record.kind === "string" && record.kind !== "" ? record.kind : "form",
1779
+ status: record.status === "answered" ? "answered" : "cancelled"
1780
+ };
1781
+ if (!isUnknownRecord(record.answers)) return result;
1782
+ const answers = {};
1783
+ for (const [key, value] of Object.entries(record.answers)) {
1784
+ if (!isUnknownRecord(value)) continue;
1785
+ answers[key] = {
1786
+ options: Array.isArray(value.options) ? value.options.filter((option) => typeof option === "string") : [],
1787
+ ...typeof value.custom === "string" && value.custom !== "" ? { custom: value.custom } : {}
1788
+ };
1789
+ }
1790
+ result.answers = answers;
1791
+ return result;
1792
+ }
1710
1793
  function asUnknownRecord(value) {
1711
1794
  return isUnknownRecord(value) ? value : {};
1712
1795
  }
@@ -2328,6 +2411,11 @@ var HappySessionSocket = class {
2328
2411
  return { ok: true };
2329
2412
  }, this.handlers.log);
2330
2413
  this.rpcMethods.push(`${this.happySessionId}:permission`);
2414
+ await registerRpc(socket, this.happySessionId, "communication", this.crypto, (params) => {
2415
+ this.handlers.onCommunication(parseCommunicationRpc(params));
2416
+ return { ok: true };
2417
+ }, this.handlers.log);
2418
+ this.rpcMethods.push(`${this.happySessionId}:communication`);
2331
2419
  await registerRpc(socket, this.happySessionId, "abort", this.crypto, () => {
2332
2420
  this.handlers.onAbort();
2333
2421
  return { ok: true };
@@ -2548,17 +2636,23 @@ var HappySessionSocket = class {
2548
2636
  }
2549
2637
  /**
2550
2638
  * Encrypt and push agentState (permission requests).
2639
+ * Retries on version-mismatch the same way metadata does; a dropped bump
2640
+ * leaves the App with empty `requests` and no Yes/No card.
2551
2641
  * @param agentState - plaintext agentState.
2552
2642
  */
2553
2643
  updateState(agentState) {
2554
2644
  const socket = this.socket;
2555
2645
  if (socket === void 0) return;
2646
+ this.emitState(socket, agentState, this.agentStateVersion, 0);
2647
+ }
2648
+ emitState(socket, agentState, expected, attempt) {
2556
2649
  socket.emit("update-state", {
2557
2650
  sid: this.happySessionId,
2558
2651
  agentState: encryptB64(this.crypto, agentState),
2559
- expectedVersion: this.agentStateVersion
2652
+ expectedVersion: expected
2560
2653
  }, (answer) => {
2561
2654
  if (typeof answer?.version === "number") this.agentStateVersion = answer.version;
2655
+ if (answer?.result === "version-mismatch" && attempt < 3 && typeof answer.version === "number") this.emitState(socket, agentState, answer.version, attempt + 1);
2562
2656
  });
2563
2657
  }
2564
2658
  sendAgent(ev, time) {
@@ -3300,6 +3394,7 @@ var HappyBridge = class {
3300
3394
  const socket = new HappySessionSocket(sessionId, credentials.token, this.config.serverUrl, crypto, {
3301
3395
  onInbound: (message) => this.queueInbound(agent.id, message),
3302
3396
  onPermission: (rpc) => this.onPermission(agent.id, rpc),
3397
+ onCommunication: (rpc) => this.onCommunication(agent.id, rpc),
3303
3398
  onAbort: () => this.onPhoneAbort(agent.id),
3304
3399
  onArchived: () => this.onPhoneArchive(sessionId, agent.id),
3305
3400
  onResumed: () => this.onPhoneRestore(agent.id),
@@ -3331,7 +3426,9 @@ var HappyBridge = class {
3331
3426
  cwd,
3332
3427
  events: [],
3333
3428
  lastForwardedSeq: lastEventSeq(agent.session.events),
3334
- parked: false
3429
+ parked: false,
3430
+ completedRequests: /* @__PURE__ */ new Map(),
3431
+ completedCommunications: /* @__PURE__ */ new Map()
3335
3432
  };
3336
3433
  this.links.set(agent.id, link);
3337
3434
  this.happyToDsh.set(sessionId, agent.id);
@@ -3366,6 +3463,7 @@ var HappyBridge = class {
3366
3463
  const socket = new HappySessionSocket(created.id, credentials.token, this.config.serverUrl, crypto, {
3367
3464
  onInbound: (message) => this.queueInbound(dshId, message),
3368
3465
  onPermission: (rpc) => this.onPermission(dshId, rpc),
3466
+ onCommunication: (rpc) => this.onCommunication(dshId, rpc),
3369
3467
  onAbort: () => this.onPhoneAbort(dshId),
3370
3468
  onArchived: () => this.onPhoneArchive(created.id, dshId),
3371
3469
  onResumed: () => this.onPhoneRestore(dshId),
@@ -3397,6 +3495,8 @@ var HappyBridge = class {
3397
3495
  events: stored.events,
3398
3496
  lastForwardedSeq: -1,
3399
3497
  parked: false,
3498
+ completedRequests: /* @__PURE__ */ new Map(),
3499
+ completedCommunications: /* @__PURE__ */ new Map(),
3400
3500
  ...stored.headerAgentPreset === void 0 ? {} : { headerAgentPreset: stored.headerAgentPreset }
3401
3501
  };
3402
3502
  this.links.set(dshId, link);
@@ -3614,16 +3714,21 @@ var HappyBridge = class {
3614
3714
  const files = await this.drainPhoneFiles(link);
3615
3715
  const pending = link.pendingHuman;
3616
3716
  if (pending?.kind === "ask") {
3617
- pending.resolve({ answers: customAnswersFromText(pending.questions, message.text) });
3717
+ const answers = customAnswersFromText(pending.questions, message.text);
3718
+ pending.resolve({ answers });
3618
3719
  delete link.pendingHuman;
3619
- this.clearRequest(link, pending.id, "canceled");
3720
+ if (pending.communication !== void 0) this.completeCommunication(link, pending.id, pending.communication, "answered", Object.fromEntries(answers.map((row) => [row.id, {
3721
+ options: [],
3722
+ custom: row.custom
3723
+ }])));
3724
+ else this.clearRequest(link, pending.id, "canceled", pending.request);
3620
3725
  link.socket.sendToolEnd(pending.id);
3621
3726
  return;
3622
3727
  }
3623
3728
  if (pending?.kind === "plan-review") {
3624
3729
  pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
3625
3730
  delete link.pendingHuman;
3626
- this.clearRequest(link, pending.id, "canceled");
3731
+ this.clearRequest(link, pending.id, "canceled", pending.request);
3627
3732
  link.socket.sendToolEnd(pending.id);
3628
3733
  }
3629
3734
  if (!grantAtLeast(this.config.remoteGrant, "chat")) {
@@ -3965,16 +4070,25 @@ var HappyBridge = class {
3965
4070
  const id = req.callId;
3966
4071
  const controller = new AbortController();
3967
4072
  req.signal = req.signal === void 0 ? controller.signal : AbortSignal.any([req.signal, controller.signal]);
3968
- this.startTool(link, id, req.toolName, req.reason === void 0 ? {} : { reason: req.reason });
3969
- this.pushRequests(link);
4073
+ const args = req.reason === void 0 ? {} : { reason: req.reason };
4074
+ const card = happyTool(req.toolName, args);
4075
+ let resolvePhone = () => {};
3970
4076
  const phone = new Promise((resolve) => {
3971
- link.pendingHuman = {
3972
- kind: "approval",
3973
- id,
3974
- toolName: req.toolName,
3975
- resolve
3976
- };
4077
+ resolvePhone = resolve;
3977
4078
  });
4079
+ link.pendingHuman = {
4080
+ kind: "approval",
4081
+ id,
4082
+ toolName: req.toolName,
4083
+ request: {
4084
+ tool: card.name,
4085
+ arguments: card.args,
4086
+ createdAt: Date.now()
4087
+ },
4088
+ resolve: resolvePhone
4089
+ };
4090
+ this.pushAgentState(link);
4091
+ link.socket.keepAliveNow(true);
3978
4092
  const web = next().then((outcome) => ({
3979
4093
  src: "web",
3980
4094
  outcome
@@ -3988,10 +4102,11 @@ var HappyBridge = class {
3988
4102
  })), web]);
3989
4103
  if (winner.src === "phone") controller.abort();
3990
4104
  if (winner.src === "web" && link.pendingHuman?.kind === "approval" && link.pendingHuman.id === id) {
4105
+ const pending = link.pendingHuman;
3991
4106
  delete link.pendingHuman;
3992
- this.clearRequest(link, id, winner.outcome === "allowed-once" ? "approved" : "canceled");
4107
+ this.clearRequest(link, id, winner.outcome === "allowed-once" ? "approved" : "canceled", pending.request);
3993
4108
  }
3994
- if (this.running) link.socket.keepAliveNow(true);
4109
+ if (link.agent?.status === "running") link.socket.keepAliveNow(true);
3995
4110
  return winner.outcome;
3996
4111
  }
3997
4112
  onAsk(questions, original, request) {
@@ -4009,6 +4124,11 @@ var HappyBridge = class {
4009
4124
  link.pendingHuman = {
4010
4125
  kind: "plan-review",
4011
4126
  id,
4127
+ request: {
4128
+ tool: "exit_plan_mode",
4129
+ arguments: { plan },
4130
+ createdAt: Date.now()
4131
+ },
4012
4132
  resolve,
4013
4133
  reject,
4014
4134
  approveLabel: first.intent.approve,
@@ -4016,21 +4136,52 @@ var HappyBridge = class {
4016
4136
  questionId: first.id
4017
4137
  };
4018
4138
  link.socket.sendToolStart(id, "exit_plan_mode", { plan }, "审阅计划", "审阅计划");
4019
- this.pushRequests(link);
4139
+ this.pushAgentState(link);
4020
4140
  return;
4021
4141
  }
4022
4142
  const id = `ask-${createLocalId()}`;
4023
- link.pendingHuman = {
4024
- kind: "ask",
4025
- id,
4026
- resolve,
4027
- reject,
4028
- questions: request.questions.map((question) => ({
4143
+ const questions = request.questions.map((question) => ({
4144
+ id: question.id,
4145
+ question: question.question
4146
+ }));
4147
+ if (this.config.questionChannel === "communications") {
4148
+ const form = request.questions.map((question) => ({
4029
4149
  id: question.id,
4030
- question: question.question
4031
- }))
4032
- };
4033
- link.socket.sendToolStart(id, "AskUserQuestion", { questions: request.questions.map((question) => ({
4150
+ header: question.header ?? question.question.slice(0, 24),
4151
+ question: question.question,
4152
+ options: (question.options ?? []).map((option) => ({
4153
+ label: option.label,
4154
+ ...option.description === void 0 ? {} : { description: option.description }
4155
+ })),
4156
+ multiSelect: question.multiSelect === true,
4157
+ allowCustom: true,
4158
+ required: true
4159
+ }));
4160
+ const communication = {
4161
+ kind: "form",
4162
+ createdAt: Date.now(),
4163
+ toolUseId: id,
4164
+ title: "需要你回答",
4165
+ form: { questions: form }
4166
+ };
4167
+ link.pendingHuman = {
4168
+ kind: "ask",
4169
+ id,
4170
+ request: {
4171
+ tool: "request_user_input",
4172
+ arguments: { questions: form },
4173
+ createdAt: communication.createdAt
4174
+ },
4175
+ communication,
4176
+ resolve,
4177
+ reject,
4178
+ questions
4179
+ };
4180
+ link.socket.sendToolStart(id, "request_user_input", { questions: form }, "需要你回答", "需要你回答");
4181
+ this.pushAgentState(link);
4182
+ return;
4183
+ }
4184
+ const arguments_ = { questions: request.questions.map((question) => ({
4034
4185
  question: question.question,
4035
4186
  header: question.header ?? question.question.slice(0, 24),
4036
4187
  options: (question.options ?? []).map((option) => ({
@@ -4038,8 +4189,21 @@ var HappyBridge = class {
4038
4189
  description: option.description ?? ""
4039
4190
  })),
4040
4191
  multiSelect: question.multiSelect === true
4041
- })) }, "需要你回答", "需要你回答");
4042
- this.pushRequests(link);
4192
+ })) };
4193
+ link.pendingHuman = {
4194
+ kind: "ask",
4195
+ id,
4196
+ request: {
4197
+ tool: "AskUserQuestion",
4198
+ arguments: arguments_,
4199
+ createdAt: Date.now()
4200
+ },
4201
+ resolve,
4202
+ reject,
4203
+ questions
4204
+ };
4205
+ link.socket.sendToolStart(id, "AskUserQuestion", arguments_, "需要你回答", "需要你回答");
4206
+ this.pushAgentState(link);
4043
4207
  });
4044
4208
  const web = original.call(questions, {
4045
4209
  ...request,
@@ -4052,10 +4216,12 @@ var HappyBridge = class {
4052
4216
  controller.abort();
4053
4217
  throw error;
4054
4218
  }), web.then((answer) => {
4055
- if (link.pendingHuman !== void 0) {
4056
- this.clearRequest(link, link.pendingHuman.id, "canceled");
4057
- link.socket.sendToolEnd(link.pendingHuman.id);
4219
+ const pending = link.pendingHuman;
4220
+ if (pending !== void 0) {
4058
4221
  delete link.pendingHuman;
4222
+ if (pending.kind === "ask" && pending.communication !== void 0) this.completeCommunication(link, pending.id, pending.communication, "cancelled");
4223
+ else this.clearRequest(link, pending.id, "canceled", pending.request);
4224
+ link.socket.sendToolEnd(pending.id);
4059
4225
  }
4060
4226
  return answer;
4061
4227
  })]);
@@ -4079,12 +4245,13 @@ var HappyBridge = class {
4079
4245
  if (rpc.approved) {
4080
4246
  if (rpc.decision === "approved_for_session") link.alwaysAllow.add(pending.toolName);
4081
4247
  pending.resolve("allowed-once");
4082
- this.clearRequest(link, rpc.id, "approved");
4248
+ delete link.pendingHuman;
4249
+ this.clearRequest(link, rpc.id, "approved", pending.request);
4083
4250
  } else {
4084
4251
  pending.resolve("rejected");
4085
- this.clearRequest(link, rpc.id, "denied");
4252
+ delete link.pendingHuman;
4253
+ this.clearRequest(link, rpc.id, "denied", pending.request);
4086
4254
  }
4087
- delete link.pendingHuman;
4088
4255
  return;
4089
4256
  }
4090
4257
  if (pending.kind === "plan-review") {
@@ -4093,15 +4260,15 @@ var HappyBridge = class {
4093
4260
  id: pending.questionId,
4094
4261
  selected: [selected]
4095
4262
  }] });
4096
- this.clearRequest(link, rpc.id, rpc.approved ? "approved" : "denied");
4097
4263
  delete link.pendingHuman;
4264
+ this.clearRequest(link, rpc.id, rpc.approved ? "approved" : "denied", pending.request);
4098
4265
  link.socket.sendToolEnd(rpc.id);
4099
4266
  return;
4100
4267
  }
4101
4268
  if (!rpc.approved) {
4102
4269
  pending.reject(/* @__PURE__ */ new Error("ASK_ABORTED"));
4103
- this.clearRequest(link, rpc.id, "denied");
4104
4270
  delete link.pendingHuman;
4271
+ this.clearRequest(link, rpc.id, "denied", pending.request);
4105
4272
  link.socket.sendToolEnd(rpc.id);
4106
4273
  return;
4107
4274
  }
@@ -4110,44 +4277,74 @@ var HappyBridge = class {
4110
4277
  id: row.id,
4111
4278
  selected: row.selected
4112
4279
  })) });
4113
- this.clearRequest(link, rpc.id, "approved");
4114
4280
  delete link.pendingHuman;
4281
+ this.clearRequest(link, rpc.id, "approved", pending.request);
4115
4282
  link.socket.sendToolEnd(rpc.id);
4116
4283
  }
4117
- pushRequests(link) {
4118
- const pending = link.pendingHuman;
4119
- const requests = {};
4120
- if (pending !== void 0) {
4121
- if (pending.kind === "approval") requests[pending.id] = {
4122
- tool: pending.toolName,
4123
- arguments: {},
4124
- createdAt: Date.now()
4125
- };
4126
- else if (pending.kind === "plan-review") requests[pending.id] = {
4127
- tool: "exit_plan_mode",
4128
- arguments: {},
4129
- createdAt: Date.now()
4130
- };
4131
- else requests[pending.id] = {
4132
- tool: "AskUserQuestion",
4133
- arguments: {},
4134
- createdAt: Date.now()
4135
- };
4284
+ /**
4285
+ * Answered / cancelled a communications form. Swap the pending form for a
4286
+ * completed one so the App card keeps showing the user's choice verbatim.
4287
+ */
4288
+ onCommunication(dshId, rpc) {
4289
+ const link = this.links.get(dshId);
4290
+ const pending = link?.pendingHuman;
4291
+ if (link === void 0 || pending === void 0 || pending.kind !== "ask") return;
4292
+ if (pending.id !== rpc.id || pending.communication === void 0) return;
4293
+ if (rpc.status !== "answered" || rpc.answers === void 0) {
4294
+ pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
4295
+ delete link.pendingHuman;
4296
+ this.completeCommunication(link, rpc.id, pending.communication, "cancelled");
4297
+ link.socket.sendToolEnd(rpc.id);
4298
+ return;
4136
4299
  }
4137
- link.socket.updateState({
4138
- controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
4139
- requests
4140
- });
4300
+ pending.resolve({ answers: answersFromCommunication(rpc.answers, pending.questions) });
4301
+ delete link.pendingHuman;
4302
+ this.completeCommunication(link, rpc.id, pending.communication, "answered", rpc.answers);
4303
+ link.socket.sendToolEnd(rpc.id);
4141
4304
  }
4142
- clearRequest(link, id, status) {
4143
- link.socket.updateState({
4305
+ /**
4306
+ * Publish the agentState snapshot: the pending approval / question plus
4307
+ * every completed entry. The App zod-validates the whole snapshot and
4308
+ * blanks it on any malformed entry, so completed entries must always carry
4309
+ * the full original request shape.
4310
+ */
4311
+ pushAgentState(link) {
4312
+ const pending = link.pendingHuman;
4313
+ const communication = pending?.kind === "ask" ? pending.communication : void 0;
4314
+ const request = pending === void 0 || communication !== void 0 ? void 0 : pending.request;
4315
+ link.socket.updateState(agentStateSnapshot({
4144
4316
  controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
4145
- requests: {},
4146
- completedRequests: { [id]: {
4147
- status,
4148
- completedAt: Date.now()
4149
- } }
4317
+ ...pending === void 0 ? {} : { pendingId: pending.id },
4318
+ ...communication === void 0 ? {} : { pendingCommunication: communication },
4319
+ ...request === void 0 ? {} : { pendingRequest: request },
4320
+ completedRequests: link.completedRequests,
4321
+ completedCommunications: link.completedCommunications
4322
+ }));
4323
+ }
4324
+ /**
4325
+ * Complete a permission request: keep the original entry fields (the App
4326
+ * schema rejects completed entries without `tool`/`arguments` — that used
4327
+ * to blank the whole snapshot and leave every card stuck `pending`), add
4328
+ * the outcome, then republish the snapshot.
4329
+ */
4330
+ clearRequest(link, id, status, request, reason) {
4331
+ rememberCompleted(link.completedRequests, id, {
4332
+ ...request,
4333
+ completedAt: Date.now(),
4334
+ status,
4335
+ ...reason === void 0 ? {} : { reason }
4336
+ });
4337
+ this.pushAgentState(link);
4338
+ }
4339
+ /** Record a finished communications form and republish the snapshot. */
4340
+ completeCommunication(link, id, communication, status, answers) {
4341
+ rememberCompleted(link.completedCommunications, id, {
4342
+ ...communication,
4343
+ completedAt: Date.now(),
4344
+ status,
4345
+ ...answers === void 0 ? {} : { answers }
4150
4346
  });
4347
+ this.pushAgentState(link);
4151
4348
  }
4152
4349
  async pushMetadata(link) {
4153
4350
  if (this.credentials === void 0) return;
@@ -4509,7 +4706,8 @@ const Config = Schema.object({
4509
4706
  Schema.const("chat"),
4510
4707
  Schema.const("approve"),
4511
4708
  Schema.const("full")
4512
- ]).default("approve")
4709
+ ]).default("approve"),
4710
+ questionChannel: Schema.union([Schema.const("communications"), Schema.const("permission")]).default("communications")
4513
4711
  });
4514
4712
  /**
4515
4713
  * Mount the Host half: settings namespace, Typert Remote, Happy relay.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure helpers for the Happy agentState snapshot. The App zod-validates the
3
+ * whole decrypted snapshot and blanks it on any malformed entry, so every
4
+ * completed entry must carry the full original request shape.
5
+ * @module agent-state
6
+ */
7
+ import type { HappyCommunicationEntry, HappyCompletedCommunication, HappyCompletedRequest, HappyRequestEntry } from './types.ts';
8
+ /** Completed entries kept per session, so snapshots cannot grow without bound. */
9
+ export declare const COMPLETED_CAP = 50;
10
+ /**
11
+ * Insert or refresh a completed entry with FIFO eviction. Re-answering an id
12
+ * moves it back to newest; the oldest entry falls off the cap.
13
+ * @param map - per-link completed map.
14
+ * @param id - Happy request / communication id.
15
+ * @param entry - schema-valid completed entry.
16
+ */
17
+ export declare function rememberCompleted<T>(map: Map<string, T>, id: string, entry: T): void;
18
+ /** Plain-object view of a completed map for the encrypted snapshot. */
19
+ export declare function snapshotMap<T>(map: ReadonlyMap<string, T>): Record<string, T>;
20
+ /**
21
+ * Build the full agentState snapshot pushed with `update-state`. The pending
22
+ * approval or question sits in `requests` / `communications`; every completed
23
+ * entry rides along so App-side replays keep the answered card states.
24
+ * @param input - pending id + entries, the completed maps, and the grant flag.
25
+ * @returns the plaintext agentState object.
26
+ */
27
+ export declare function agentStateSnapshot(input: {
28
+ controlledByUser: boolean;
29
+ /** Local pending id; also the key in both maps and the tool-call id. */
30
+ pendingId?: string;
31
+ /** Pending entry for the permission channel (approvals, plan review, legacy questions). */
32
+ pendingRequest?: HappyRequestEntry;
33
+ /** Pending entry for the communications channel (questions). */
34
+ pendingCommunication?: HappyCommunicationEntry;
35
+ completedRequests?: ReadonlyMap<string, HappyCompletedRequest>;
36
+ completedCommunications?: ReadonlyMap<string, HappyCompletedCommunication>;
37
+ }): {
38
+ controlledByUser: boolean;
39
+ requests: Record<string, HappyRequestEntry>;
40
+ communications: Record<string, HappyCommunicationEntry>;
41
+ completedRequests: Record<string, HappyCompletedRequest>;
42
+ completedCommunications: Record<string, HappyCompletedCommunication>;
43
+ };
44
+ //# sourceMappingURL=agent-state.d.ts.map
@@ -205,8 +205,27 @@ export declare class HappyBridge {
205
205
  */
206
206
  private onPhoneAbort;
207
207
  private onPermission;
208
- private pushRequests;
208
+ /**
209
+ * Answered / cancelled a communications form. Swap the pending form for a
210
+ * completed one so the App card keeps showing the user's choice verbatim.
211
+ */
212
+ private onCommunication;
213
+ /**
214
+ * Publish the agentState snapshot: the pending approval / question plus
215
+ * every completed entry. The App zod-validates the whole snapshot and
216
+ * blanks it on any malformed entry, so completed entries must always carry
217
+ * the full original request shape.
218
+ */
219
+ private pushAgentState;
220
+ /**
221
+ * Complete a permission request: keep the original entry fields (the App
222
+ * schema rejects completed entries without `tool`/`arguments` — that used
223
+ * to blank the whole snapshot and leave every card stuck `pending`), add
224
+ * the outcome, then republish the snapshot.
225
+ */
209
226
  private clearRequest;
227
+ /** Record a finished communications form and republish the snapshot. */
228
+ private completeCommunication;
210
229
  private pushMetadata;
211
230
  private pushAllMetadata;
212
231
  private queueOutboundUser;
@@ -1,5 +1,5 @@
1
1
  /** Classify inbound Happy user text as a registered slash command or ordinary chat. */
2
- import type { PermissionRpc } from './types.ts';
2
+ import type { CommunicationRpc, PermissionRpc } from './types.ts';
3
3
  /** Normalized inbound payload from a decrypted Happy message. */
4
4
  export type HappyInbound = {
5
5
  kind: 'text';
@@ -85,4 +85,29 @@ export declare function planReviewDeclineLabel(question: {
85
85
  * @returns id, approved flag, optional Always-allow decision, and answers.
86
86
  */
87
87
  export declare function parsePermissionRpc(params: unknown): PermissionRpc;
88
+ /**
89
+ * Translate Happy communications answers (`{ [question id]: { options, custom } }`)
90
+ * into harness answer rows. Options become `selected`; free text becomes `custom`.
91
+ * @param answers - communication RPC answers keyed by question id.
92
+ * @param questions - original harness questions in order.
93
+ * @returns one answer row per question.
94
+ */
95
+ export declare function answersFromCommunication(answers: Record<string, {
96
+ options: string[];
97
+ custom?: string;
98
+ }> | undefined, questions: readonly {
99
+ id: string;
100
+ }[]): {
101
+ id: string;
102
+ selected: string[];
103
+ custom?: string;
104
+ }[];
105
+ /**
106
+ * Decode a Happy `communication` RPC body (form answers or cancellation).
107
+ * Unknown statuses degrade to `cancelled`, which re-asks instead of silently
108
+ * submitting empty answers.
109
+ * @param params - decrypted RPC params.
110
+ * @returns id, form kind, answered/cancelled status, and per-question answers.
111
+ */
112
+ export declare function parseCommunicationRpc(params: unknown): CommunicationRpc;
88
113
  //# sourceMappingURL=inbound.d.ts.map
@@ -1,13 +1,15 @@
1
1
  /** One Happy session-scoped socket: encrypt chat, metadata, agentState, permission RPC. */
2
2
  import { type CryptoContext } from './encryption.ts';
3
3
  import { type HappyInbound } from './inbound.ts';
4
- import type { PermissionRpc } from './types.ts';
4
+ import type { CommunicationRpc, PermissionRpc } from './types.ts';
5
5
  /** Callbacks the bridge installs on one Happy session. */
6
6
  export interface SessionHandlers {
7
7
  /** Decrypted inbound chat or file. */
8
8
  onInbound: (message: InboundMessage) => void;
9
9
  /** Phone answered a permission / fake-tool request. */
10
10
  onPermission: (rpc: PermissionRpc) => void;
11
+ /** Phone answered or cancelled a communications-channel form. */
12
+ onCommunication: (rpc: CommunicationRpc) => void;
11
13
  /** Phone tapped Stop. Happy App `sessionAbort` for Rig sends `{}`. */
12
14
  onAbort: () => void;
13
15
  /** App archived or deleted this Happy session. */
@@ -141,9 +143,12 @@ export declare class HappySessionSocket {
141
143
  private emitMetadata;
142
144
  /**
143
145
  * Encrypt and push agentState (permission requests).
146
+ * Retries on version-mismatch the same way metadata does; a dropped bump
147
+ * leaves the App with empty `requests` and no Yes/No card.
144
148
  * @param agentState - plaintext agentState.
145
149
  */
146
150
  updateState(agentState: unknown): void;
151
+ private emitState;
147
152
  private sendAgent;
148
153
  private emitEnvelope;
149
154
  private onMetadataUpdate;
@@ -1,6 +1,8 @@
1
1
  /** Shared plugin types: config, credentials, remote grant, and Happy wire extras. */
2
2
  /** Remote-control depth the phone is allowed on this Host. */
3
3
  export type RemoteGrant = 'watch' | 'chat' | 'approve' | 'full';
4
+ /** Transport for harness `ask_user_question` on the phone. */
5
+ export type QuestionChannel = 'communications' | 'permission';
4
6
  /** Validated plugin config. */
5
7
  export interface Config {
6
8
  /** Master switch. */
@@ -15,6 +17,13 @@ export interface Config {
15
17
  pairOnStart: boolean;
16
18
  /** How deeply the phone may control this Host. */
17
19
  remoteGrant: RemoteGrant;
20
+ /**
21
+ * Which Happy channel carries questions. `communications` is the App's form
22
+ * channel (options plus a free-text answer, with the choice echoed back on
23
+ * the card); `permission` is the legacy AskUserQuestion fallback for older
24
+ * App builds.
25
+ */
26
+ questionChannel: QuestionChannel;
18
27
  }
19
28
  /** Encryption variant stored with the account credentials. */
20
29
  export type EncryptionVariant = 'legacy' | 'dataKey';
@@ -101,4 +110,70 @@ export interface PermissionRpc {
101
110
  answers?: Record<string, string>;
102
111
  };
103
112
  }
113
+ /** One question published on the Happy communications channel. */
114
+ export interface HappyFormQuestion {
115
+ /** Harness question id; the App echoes it back as the answer key. */
116
+ id: string;
117
+ header: string;
118
+ question: string;
119
+ options: {
120
+ label: string;
121
+ description?: string;
122
+ }[];
123
+ multiSelect: boolean;
124
+ /** Lets the user write an answer the options did not offer. */
125
+ allowCustom: boolean;
126
+ required: boolean;
127
+ }
128
+ /** Pending communications entry published through agentState.communications. */
129
+ export interface HappyCommunicationEntry {
130
+ kind: 'form';
131
+ createdAt: number;
132
+ /** Equals the `request_user_input` tool-call id so the App joins the form to its card. */
133
+ toolUseId: string;
134
+ title: string;
135
+ form: {
136
+ questions: HappyFormQuestion[];
137
+ };
138
+ }
139
+ /**
140
+ * Completed communications entry. The App renders `answers` verbatim on the
141
+ * card ("header: chosen labels / custom text"), which is the answer echo.
142
+ */
143
+ export interface HappyCompletedCommunication extends HappyCommunicationEntry {
144
+ completedAt: number;
145
+ status: 'answered' | 'cancelled';
146
+ answers?: Record<string, {
147
+ options: string[];
148
+ custom?: string;
149
+ }>;
150
+ }
151
+ /**
152
+ * Pending permission request entry, shaped like the official CLI's
153
+ * `requests[id] = { tool, arguments, createdAt }`.
154
+ */
155
+ export interface HappyRequestEntry {
156
+ tool: string;
157
+ arguments: Record<string, unknown>;
158
+ createdAt: number;
159
+ }
160
+ /**
161
+ * Completed permission request entry. The App's zod schema requires `tool`
162
+ * (and reads `arguments`); an entry without them blanks the whole agentState.
163
+ */
164
+ export interface HappyCompletedRequest extends HappyRequestEntry {
165
+ completedAt: number;
166
+ status: 'approved' | 'denied' | 'canceled';
167
+ reason?: string;
168
+ }
169
+ /** Happy `communication` RPC body from the App. */
170
+ export interface CommunicationRpc {
171
+ id: string;
172
+ kind: string;
173
+ status: 'answered' | 'cancelled';
174
+ answers?: Record<string, {
175
+ options: string[];
176
+ custom?: string;
177
+ }>;
178
+ }
104
179
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "@sjhmars/happy-bridge",
3
- "version": "0.2.4",
4
- "description": "Pair an already-running dsh web/desktop client with Happy App so the phone remote-controls the same harness sessions",
3
+ "version": "0.3.0",
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
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh-plugin",
8
+ "dsh",
9
+ "happy",
10
+ "happy-app",
11
+ "remote-control",
12
+ "remote",
13
+ "mobile",
14
+ "companion",
15
+ "pairing",
16
+ "qr-code",
17
+ "bridge"
18
+ ],
5
19
  "type": "module",
6
20
  "main": "lib/index.js",
7
21
  "types": "lib/types/index.d.ts",