@sjhmars/happy-bridge 0.2.5 → 0.3.1
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 +6 -1
- package/lib/client.js +8 -8
- package/lib/index.js +307 -76
- package/lib/types/agent-state.d.ts +44 -0
- package/lib/types/bridge.d.ts +20 -1
- package/lib/types/inbound.d.ts +51 -1
- package/lib/types/session-socket.d.ts +3 -1
- package/lib/types/types.d.ts +75 -0
- package/package.json +16 -2
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。`permission` 是旧通道:题目选项仍可见可答,答完卡片不回显。两条通道都没有内联输入框——自由回答都靠每题末尾的「✏️ 自定义…」选项 + 输入框打字(`communications` 下,零选项的题目会弹 App 全屏表单,那里有真正的输入框)。
|
|
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
|
-
"
|
|
20
|
+
"name": "l9rBEq_name",
|
|
21
|
+
"body": "l9rBEq_body",
|
|
21
22
|
"qr": "l9rBEq_qr",
|
|
23
|
+
"description": "l9rBEq_description",
|
|
22
24
|
"select": "l9rBEq_select",
|
|
25
|
+
"card": "l9rBEq_card",
|
|
26
|
+
"headText": "l9rBEq_headText",
|
|
27
|
+
"status": "l9rBEq_status",
|
|
23
28
|
"row": "l9rBEq_row",
|
|
24
|
-
"name": "l9rBEq_name",
|
|
25
|
-
"body": "l9rBEq_body",
|
|
26
|
-
"label": "l9rBEq_label",
|
|
27
29
|
"header": "l9rBEq_header",
|
|
28
30
|
"chevron": "l9rBEq_chevron",
|
|
29
31
|
"checkbox": "l9rBEq_checkbox",
|
|
30
|
-
"card": "l9rBEq_card",
|
|
31
|
-
"headText": "l9rBEq_headText",
|
|
32
|
-
"button": "l9rBEq_button",
|
|
33
32
|
"error": "l9rBEq_error",
|
|
34
|
-
"
|
|
33
|
+
"button": "l9rBEq_button",
|
|
34
|
+
"label": "l9rBEq_label"
|
|
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
|
/**
|
|
@@ -1663,19 +1699,6 @@ function answersFromHappy(answers, questions) {
|
|
|
1663
1699
|
});
|
|
1664
1700
|
}
|
|
1665
1701
|
/**
|
|
1666
|
-
* Fill every still-open question with the typed chat line as `custom`.
|
|
1667
|
-
* @param questions - harness questions in order.
|
|
1668
|
-
* @param text - the App composer line.
|
|
1669
|
-
* @returns one answer row per question.
|
|
1670
|
-
*/
|
|
1671
|
-
function customAnswersFromText(questions, text) {
|
|
1672
|
-
return questions.map((question) => ({
|
|
1673
|
-
id: question.id,
|
|
1674
|
-
selected: [],
|
|
1675
|
-
custom: text
|
|
1676
|
-
}));
|
|
1677
|
-
}
|
|
1678
|
-
/**
|
|
1679
1702
|
* The option label that declines a plan-review question.
|
|
1680
1703
|
* @param question - first question of a plan-review `ask`.
|
|
1681
1704
|
* @returns the non-approve option label, or Keep planning.
|
|
@@ -1707,6 +1730,88 @@ function parsePermissionRpc(params) {
|
|
|
1707
1730
|
result.updatedInput = { answers: mapped };
|
|
1708
1731
|
return result;
|
|
1709
1732
|
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Synthetic last option the bridge appends to every question: the App's inline
|
|
1735
|
+
* form has no free-text input, so "custom answer" is a two-tap flow — pick
|
|
1736
|
+
* this option, then type the real answer in the composer.
|
|
1737
|
+
*/
|
|
1738
|
+
const CUSTOM_ANSWER_LABEL = "✏️ 自定义…";
|
|
1739
|
+
/**
|
|
1740
|
+
* Merge composer text with a deferred (marker-picked) submission.
|
|
1741
|
+
* - No deferred selections: the text becomes `custom` for every question
|
|
1742
|
+
* (the original "type while waiting" behavior).
|
|
1743
|
+
* - With deferred selections: real options are kept per question; questions
|
|
1744
|
+
* that picked the marker — and questions never answered — take the text
|
|
1745
|
+
* as `custom`.
|
|
1746
|
+
* @param questions - harness questions in order.
|
|
1747
|
+
* @param text - the composer line.
|
|
1748
|
+
* @param deferred - per-question submitted options, keyed by question id.
|
|
1749
|
+
* @returns one answer row per question.
|
|
1750
|
+
*/
|
|
1751
|
+
function mergeCustomAnswers(questions, text, deferred) {
|
|
1752
|
+
return questions.map((question) => {
|
|
1753
|
+
const selected = deferred?.[question.id];
|
|
1754
|
+
if (selected === void 0) return {
|
|
1755
|
+
id: question.id,
|
|
1756
|
+
selected: [],
|
|
1757
|
+
custom: text
|
|
1758
|
+
};
|
|
1759
|
+
const real = selected.filter((option) => option !== CUSTOM_ANSWER_LABEL);
|
|
1760
|
+
const marked = real.length !== selected.length;
|
|
1761
|
+
return {
|
|
1762
|
+
id: question.id,
|
|
1763
|
+
selected: real,
|
|
1764
|
+
...!marked && real.length > 0 ? {} : { custom: text }
|
|
1765
|
+
};
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Translate Happy communications answers (`{ [question id]: { options, custom } }`)
|
|
1770
|
+
* into harness answer rows. Options become `selected`; free text becomes `custom`.
|
|
1771
|
+
* @param answers - communication RPC answers keyed by question id.
|
|
1772
|
+
* @param questions - original harness questions in order.
|
|
1773
|
+
* @returns one answer row per question.
|
|
1774
|
+
*/
|
|
1775
|
+
function answersFromCommunication(answers, questions) {
|
|
1776
|
+
return questions.map((question) => {
|
|
1777
|
+
const answer = answers?.[question.id];
|
|
1778
|
+
if (answer === void 0) return {
|
|
1779
|
+
id: question.id,
|
|
1780
|
+
selected: []
|
|
1781
|
+
};
|
|
1782
|
+
return {
|
|
1783
|
+
id: question.id,
|
|
1784
|
+
selected: answer.options,
|
|
1785
|
+
...answer.custom === void 0 ? {} : { custom: answer.custom }
|
|
1786
|
+
};
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Decode a Happy `communication` RPC body (form answers or cancellation).
|
|
1791
|
+
* Unknown statuses degrade to `cancelled`, which re-asks instead of silently
|
|
1792
|
+
* submitting empty answers.
|
|
1793
|
+
* @param params - decrypted RPC params.
|
|
1794
|
+
* @returns id, form kind, answered/cancelled status, and per-question answers.
|
|
1795
|
+
*/
|
|
1796
|
+
function parseCommunicationRpc(params) {
|
|
1797
|
+
const record = asUnknownRecord(params);
|
|
1798
|
+
const result = {
|
|
1799
|
+
id: typeof record.id === "string" ? record.id : "",
|
|
1800
|
+
kind: typeof record.kind === "string" && record.kind !== "" ? record.kind : "form",
|
|
1801
|
+
status: record.status === "answered" ? "answered" : "cancelled"
|
|
1802
|
+
};
|
|
1803
|
+
if (!isUnknownRecord(record.answers)) return result;
|
|
1804
|
+
const answers = {};
|
|
1805
|
+
for (const [key, value] of Object.entries(record.answers)) {
|
|
1806
|
+
if (!isUnknownRecord(value)) continue;
|
|
1807
|
+
answers[key] = {
|
|
1808
|
+
options: Array.isArray(value.options) ? value.options.filter((option) => typeof option === "string") : [],
|
|
1809
|
+
...typeof value.custom === "string" && value.custom !== "" ? { custom: value.custom } : {}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
result.answers = answers;
|
|
1813
|
+
return result;
|
|
1814
|
+
}
|
|
1710
1815
|
function asUnknownRecord(value) {
|
|
1711
1816
|
return isUnknownRecord(value) ? value : {};
|
|
1712
1817
|
}
|
|
@@ -2328,6 +2433,11 @@ var HappySessionSocket = class {
|
|
|
2328
2433
|
return { ok: true };
|
|
2329
2434
|
}, this.handlers.log);
|
|
2330
2435
|
this.rpcMethods.push(`${this.happySessionId}:permission`);
|
|
2436
|
+
await registerRpc(socket, this.happySessionId, "communication", this.crypto, (params) => {
|
|
2437
|
+
this.handlers.onCommunication(parseCommunicationRpc(params));
|
|
2438
|
+
return { ok: true };
|
|
2439
|
+
}, this.handlers.log);
|
|
2440
|
+
this.rpcMethods.push(`${this.happySessionId}:communication`);
|
|
2331
2441
|
await registerRpc(socket, this.happySessionId, "abort", this.crypto, () => {
|
|
2332
2442
|
this.handlers.onAbort();
|
|
2333
2443
|
return { ok: true };
|
|
@@ -3306,6 +3416,7 @@ var HappyBridge = class {
|
|
|
3306
3416
|
const socket = new HappySessionSocket(sessionId, credentials.token, this.config.serverUrl, crypto, {
|
|
3307
3417
|
onInbound: (message) => this.queueInbound(agent.id, message),
|
|
3308
3418
|
onPermission: (rpc) => this.onPermission(agent.id, rpc),
|
|
3419
|
+
onCommunication: (rpc) => this.onCommunication(agent.id, rpc),
|
|
3309
3420
|
onAbort: () => this.onPhoneAbort(agent.id),
|
|
3310
3421
|
onArchived: () => this.onPhoneArchive(sessionId, agent.id),
|
|
3311
3422
|
onResumed: () => this.onPhoneRestore(agent.id),
|
|
@@ -3337,7 +3448,9 @@ var HappyBridge = class {
|
|
|
3337
3448
|
cwd,
|
|
3338
3449
|
events: [],
|
|
3339
3450
|
lastForwardedSeq: lastEventSeq(agent.session.events),
|
|
3340
|
-
parked: false
|
|
3451
|
+
parked: false,
|
|
3452
|
+
completedRequests: /* @__PURE__ */ new Map(),
|
|
3453
|
+
completedCommunications: /* @__PURE__ */ new Map()
|
|
3341
3454
|
};
|
|
3342
3455
|
this.links.set(agent.id, link);
|
|
3343
3456
|
this.happyToDsh.set(sessionId, agent.id);
|
|
@@ -3372,6 +3485,7 @@ var HappyBridge = class {
|
|
|
3372
3485
|
const socket = new HappySessionSocket(created.id, credentials.token, this.config.serverUrl, crypto, {
|
|
3373
3486
|
onInbound: (message) => this.queueInbound(dshId, message),
|
|
3374
3487
|
onPermission: (rpc) => this.onPermission(dshId, rpc),
|
|
3488
|
+
onCommunication: (rpc) => this.onCommunication(dshId, rpc),
|
|
3375
3489
|
onAbort: () => this.onPhoneAbort(dshId),
|
|
3376
3490
|
onArchived: () => this.onPhoneArchive(created.id, dshId),
|
|
3377
3491
|
onResumed: () => this.onPhoneRestore(dshId),
|
|
@@ -3403,6 +3517,8 @@ var HappyBridge = class {
|
|
|
3403
3517
|
events: stored.events,
|
|
3404
3518
|
lastForwardedSeq: -1,
|
|
3405
3519
|
parked: false,
|
|
3520
|
+
completedRequests: /* @__PURE__ */ new Map(),
|
|
3521
|
+
completedCommunications: /* @__PURE__ */ new Map(),
|
|
3406
3522
|
...stored.headerAgentPreset === void 0 ? {} : { headerAgentPreset: stored.headerAgentPreset }
|
|
3407
3523
|
};
|
|
3408
3524
|
this.links.set(dshId, link);
|
|
@@ -3620,16 +3736,21 @@ var HappyBridge = class {
|
|
|
3620
3736
|
const files = await this.drainPhoneFiles(link);
|
|
3621
3737
|
const pending = link.pendingHuman;
|
|
3622
3738
|
if (pending?.kind === "ask") {
|
|
3623
|
-
|
|
3739
|
+
const answers = mergeCustomAnswers(pending.questions, message.text, pending.deferred);
|
|
3740
|
+
pending.resolve({ answers });
|
|
3624
3741
|
delete link.pendingHuman;
|
|
3625
|
-
this.
|
|
3742
|
+
if (pending.communication !== void 0) this.completeCommunication(link, pending.id, pending.communication, "answered", Object.fromEntries(answers.map((row) => [row.id, {
|
|
3743
|
+
options: row.selected,
|
|
3744
|
+
...row.custom === void 0 ? {} : { custom: row.custom }
|
|
3745
|
+
}])));
|
|
3746
|
+
else this.clearRequest(link, pending.id, "canceled", pending.request);
|
|
3626
3747
|
link.socket.sendToolEnd(pending.id);
|
|
3627
3748
|
return;
|
|
3628
3749
|
}
|
|
3629
3750
|
if (pending?.kind === "plan-review") {
|
|
3630
3751
|
pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
|
|
3631
3752
|
delete link.pendingHuman;
|
|
3632
|
-
this.clearRequest(link, pending.id, "canceled");
|
|
3753
|
+
this.clearRequest(link, pending.id, "canceled", pending.request);
|
|
3633
3754
|
link.socket.sendToolEnd(pending.id);
|
|
3634
3755
|
}
|
|
3635
3756
|
if (!grantAtLeast(this.config.remoteGrant, "chat")) {
|
|
@@ -3981,11 +4102,14 @@ var HappyBridge = class {
|
|
|
3981
4102
|
kind: "approval",
|
|
3982
4103
|
id,
|
|
3983
4104
|
toolName: req.toolName,
|
|
3984
|
-
|
|
3985
|
-
|
|
4105
|
+
request: {
|
|
4106
|
+
tool: card.name,
|
|
4107
|
+
arguments: card.args,
|
|
4108
|
+
createdAt: Date.now()
|
|
4109
|
+
},
|
|
3986
4110
|
resolve: resolvePhone
|
|
3987
4111
|
};
|
|
3988
|
-
this.
|
|
4112
|
+
this.pushAgentState(link);
|
|
3989
4113
|
link.socket.keepAliveNow(true);
|
|
3990
4114
|
const web = next().then((outcome) => ({
|
|
3991
4115
|
src: "web",
|
|
@@ -4000,8 +4124,9 @@ var HappyBridge = class {
|
|
|
4000
4124
|
})), web]);
|
|
4001
4125
|
if (winner.src === "phone") controller.abort();
|
|
4002
4126
|
if (winner.src === "web" && link.pendingHuman?.kind === "approval" && link.pendingHuman.id === id) {
|
|
4127
|
+
const pending = link.pendingHuman;
|
|
4003
4128
|
delete link.pendingHuman;
|
|
4004
|
-
this.clearRequest(link, id, winner.outcome === "allowed-once" ? "approved" : "canceled");
|
|
4129
|
+
this.clearRequest(link, id, winner.outcome === "allowed-once" ? "approved" : "canceled", pending.request);
|
|
4005
4130
|
}
|
|
4006
4131
|
if (link.agent?.status === "running") link.socket.keepAliveNow(true);
|
|
4007
4132
|
return winner.outcome;
|
|
@@ -4021,6 +4146,11 @@ var HappyBridge = class {
|
|
|
4021
4146
|
link.pendingHuman = {
|
|
4022
4147
|
kind: "plan-review",
|
|
4023
4148
|
id,
|
|
4149
|
+
request: {
|
|
4150
|
+
tool: "exit_plan_mode",
|
|
4151
|
+
arguments: { plan },
|
|
4152
|
+
createdAt: Date.now()
|
|
4153
|
+
},
|
|
4024
4154
|
resolve,
|
|
4025
4155
|
reject,
|
|
4026
4156
|
approveLabel: first.intent.approve,
|
|
@@ -4028,30 +4158,74 @@ var HappyBridge = class {
|
|
|
4028
4158
|
questionId: first.id
|
|
4029
4159
|
};
|
|
4030
4160
|
link.socket.sendToolStart(id, "exit_plan_mode", { plan }, "审阅计划", "审阅计划");
|
|
4031
|
-
this.
|
|
4161
|
+
this.pushAgentState(link);
|
|
4032
4162
|
return;
|
|
4033
4163
|
}
|
|
4034
4164
|
const id = `ask-${createLocalId()}`;
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4165
|
+
const questions = request.questions.map((question) => ({
|
|
4166
|
+
id: question.id,
|
|
4167
|
+
question: question.question
|
|
4168
|
+
}));
|
|
4169
|
+
if (this.config.questionChannel === "communications") {
|
|
4170
|
+
const form = request.questions.map((question) => ({
|
|
4041
4171
|
id: question.id,
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4172
|
+
header: question.header ?? question.question.slice(0, 24),
|
|
4173
|
+
question: question.question,
|
|
4174
|
+
options: [...(question.options ?? []).map((option) => ({
|
|
4175
|
+
label: option.label,
|
|
4176
|
+
...option.description === void 0 ? {} : { description: option.description }
|
|
4177
|
+
})), ...(question.options ?? []).length > 0 ? [{ label: CUSTOM_ANSWER_LABEL }] : []],
|
|
4178
|
+
multiSelect: question.multiSelect === true,
|
|
4179
|
+
allowCustom: true,
|
|
4180
|
+
required: true
|
|
4181
|
+
}));
|
|
4182
|
+
const communication = {
|
|
4183
|
+
kind: "form",
|
|
4184
|
+
createdAt: Date.now(),
|
|
4185
|
+
toolUseId: id,
|
|
4186
|
+
title: "需要你回答",
|
|
4187
|
+
form: { questions: form }
|
|
4188
|
+
};
|
|
4189
|
+
link.pendingHuman = {
|
|
4190
|
+
kind: "ask",
|
|
4191
|
+
id,
|
|
4192
|
+
request: {
|
|
4193
|
+
tool: "request_user_input",
|
|
4194
|
+
arguments: { questions: form },
|
|
4195
|
+
createdAt: communication.createdAt
|
|
4196
|
+
},
|
|
4197
|
+
communication,
|
|
4198
|
+
resolve,
|
|
4199
|
+
reject,
|
|
4200
|
+
questions
|
|
4201
|
+
};
|
|
4202
|
+
link.socket.sendToolStart(id, "request_user_input", { questions: form }, "需要你回答", "需要你回答");
|
|
4203
|
+
this.pushAgentState(link);
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
const arguments_ = { questions: request.questions.map((question) => ({
|
|
4046
4207
|
question: question.question,
|
|
4047
4208
|
header: question.header ?? question.question.slice(0, 24),
|
|
4048
|
-
options: (question.options ?? []).map((option) => ({
|
|
4209
|
+
options: [...(question.options ?? []).map((option) => ({
|
|
4049
4210
|
label: option.label,
|
|
4050
4211
|
description: option.description ?? ""
|
|
4051
|
-
})),
|
|
4212
|
+
})), { label: CUSTOM_ANSWER_LABEL }],
|
|
4052
4213
|
multiSelect: question.multiSelect === true
|
|
4053
|
-
})) }
|
|
4054
|
-
|
|
4214
|
+
})) };
|
|
4215
|
+
link.pendingHuman = {
|
|
4216
|
+
kind: "ask",
|
|
4217
|
+
id,
|
|
4218
|
+
request: {
|
|
4219
|
+
tool: "AskUserQuestion",
|
|
4220
|
+
arguments: arguments_,
|
|
4221
|
+
createdAt: Date.now()
|
|
4222
|
+
},
|
|
4223
|
+
resolve,
|
|
4224
|
+
reject,
|
|
4225
|
+
questions
|
|
4226
|
+
};
|
|
4227
|
+
link.socket.sendToolStart(id, "AskUserQuestion", arguments_, "需要你回答", "需要你回答");
|
|
4228
|
+
this.pushAgentState(link);
|
|
4055
4229
|
});
|
|
4056
4230
|
const web = original.call(questions, {
|
|
4057
4231
|
...request,
|
|
@@ -4064,10 +4238,12 @@ var HappyBridge = class {
|
|
|
4064
4238
|
controller.abort();
|
|
4065
4239
|
throw error;
|
|
4066
4240
|
}), web.then((answer) => {
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
link.socket.sendToolEnd(link.pendingHuman.id);
|
|
4241
|
+
const pending = link.pendingHuman;
|
|
4242
|
+
if (pending !== void 0) {
|
|
4070
4243
|
delete link.pendingHuman;
|
|
4244
|
+
if (pending.kind === "ask" && pending.communication !== void 0) this.completeCommunication(link, pending.id, pending.communication, "cancelled");
|
|
4245
|
+
else this.clearRequest(link, pending.id, "canceled", pending.request);
|
|
4246
|
+
link.socket.sendToolEnd(pending.id);
|
|
4071
4247
|
}
|
|
4072
4248
|
return answer;
|
|
4073
4249
|
})]);
|
|
@@ -4091,12 +4267,13 @@ var HappyBridge = class {
|
|
|
4091
4267
|
if (rpc.approved) {
|
|
4092
4268
|
if (rpc.decision === "approved_for_session") link.alwaysAllow.add(pending.toolName);
|
|
4093
4269
|
pending.resolve("allowed-once");
|
|
4094
|
-
|
|
4270
|
+
delete link.pendingHuman;
|
|
4271
|
+
this.clearRequest(link, rpc.id, "approved", pending.request);
|
|
4095
4272
|
} else {
|
|
4096
4273
|
pending.resolve("rejected");
|
|
4097
|
-
|
|
4274
|
+
delete link.pendingHuman;
|
|
4275
|
+
this.clearRequest(link, rpc.id, "denied", pending.request);
|
|
4098
4276
|
}
|
|
4099
|
-
delete link.pendingHuman;
|
|
4100
4277
|
return;
|
|
4101
4278
|
}
|
|
4102
4279
|
if (pending.kind === "plan-review") {
|
|
@@ -4105,61 +4282,114 @@ var HappyBridge = class {
|
|
|
4105
4282
|
id: pending.questionId,
|
|
4106
4283
|
selected: [selected]
|
|
4107
4284
|
}] });
|
|
4108
|
-
this.clearRequest(link, rpc.id, rpc.approved ? "approved" : "denied");
|
|
4109
4285
|
delete link.pendingHuman;
|
|
4286
|
+
this.clearRequest(link, rpc.id, rpc.approved ? "approved" : "denied", pending.request);
|
|
4110
4287
|
link.socket.sendToolEnd(rpc.id);
|
|
4111
4288
|
return;
|
|
4112
4289
|
}
|
|
4113
4290
|
if (!rpc.approved) {
|
|
4114
4291
|
pending.reject(/* @__PURE__ */ new Error("ASK_ABORTED"));
|
|
4115
|
-
this.clearRequest(link, rpc.id, "denied");
|
|
4116
4292
|
delete link.pendingHuman;
|
|
4293
|
+
this.clearRequest(link, rpc.id, "denied", pending.request);
|
|
4117
4294
|
link.socket.sendToolEnd(rpc.id);
|
|
4118
4295
|
return;
|
|
4119
4296
|
}
|
|
4120
4297
|
const mapped = answersFromHappy(rpc.updatedInput?.answers, pending.questions);
|
|
4298
|
+
const deferred = {};
|
|
4299
|
+
let wantsCustom = false;
|
|
4300
|
+
for (const row of mapped) {
|
|
4301
|
+
deferred[row.id] = row.selected;
|
|
4302
|
+
if (row.selected.includes("✏️ 自定义…")) wantsCustom = true;
|
|
4303
|
+
}
|
|
4304
|
+
if (wantsCustom) {
|
|
4305
|
+
pending.deferred = deferred;
|
|
4306
|
+
link.socket.sendText("service", "已选自定义回答:请在输入框输入你的想法,直接发送即可。");
|
|
4307
|
+
return;
|
|
4308
|
+
}
|
|
4121
4309
|
pending.resolve({ answers: mapped.map((row) => ({
|
|
4122
4310
|
id: row.id,
|
|
4123
4311
|
selected: row.selected
|
|
4124
4312
|
})) });
|
|
4125
|
-
this.clearRequest(link, rpc.id, "approved");
|
|
4126
4313
|
delete link.pendingHuman;
|
|
4314
|
+
this.clearRequest(link, rpc.id, "approved", pending.request);
|
|
4127
4315
|
link.socket.sendToolEnd(rpc.id);
|
|
4128
4316
|
}
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
tool: "AskUserQuestion",
|
|
4145
|
-
arguments: {},
|
|
4146
|
-
createdAt: Date.now()
|
|
4147
|
-
};
|
|
4317
|
+
/**
|
|
4318
|
+
* Answered / cancelled a communications form. Swap the pending form for a
|
|
4319
|
+
* completed one so the App card keeps showing the user's choice verbatim.
|
|
4320
|
+
*/
|
|
4321
|
+
onCommunication(dshId, rpc) {
|
|
4322
|
+
const link = this.links.get(dshId);
|
|
4323
|
+
const pending = link?.pendingHuman;
|
|
4324
|
+
if (link === void 0 || pending === void 0 || pending.kind !== "ask") return;
|
|
4325
|
+
if (pending.id !== rpc.id || pending.communication === void 0) return;
|
|
4326
|
+
if (rpc.status !== "answered" || rpc.answers === void 0) {
|
|
4327
|
+
pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
|
|
4328
|
+
delete link.pendingHuman;
|
|
4329
|
+
this.completeCommunication(link, rpc.id, pending.communication, "cancelled");
|
|
4330
|
+
link.socket.sendToolEnd(rpc.id);
|
|
4331
|
+
return;
|
|
4148
4332
|
}
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4333
|
+
const deferred = {};
|
|
4334
|
+
let wantsCustom = false;
|
|
4335
|
+
for (const [questionId, answer] of Object.entries(rpc.answers)) {
|
|
4336
|
+
deferred[questionId] = answer.options;
|
|
4337
|
+
if (answer.options.includes("✏️ 自定义…")) wantsCustom = true;
|
|
4338
|
+
}
|
|
4339
|
+
if (wantsCustom) {
|
|
4340
|
+
pending.deferred = deferred;
|
|
4341
|
+
this.completeCommunication(link, rpc.id, pending.communication, "answered", rpc.answers);
|
|
4342
|
+
link.socket.sendText("service", "已选自定义回答:请在输入框输入你的想法,直接发送即可。");
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
pending.resolve({ answers: answersFromCommunication(rpc.answers, pending.questions) });
|
|
4346
|
+
delete link.pendingHuman;
|
|
4347
|
+
this.completeCommunication(link, rpc.id, pending.communication, "answered", rpc.answers);
|
|
4348
|
+
link.socket.sendToolEnd(rpc.id);
|
|
4153
4349
|
}
|
|
4154
|
-
|
|
4155
|
-
|
|
4350
|
+
/**
|
|
4351
|
+
* Publish the agentState snapshot: the pending approval / question plus
|
|
4352
|
+
* every completed entry. The App zod-validates the whole snapshot and
|
|
4353
|
+
* blanks it on any malformed entry, so completed entries must always carry
|
|
4354
|
+
* the full original request shape.
|
|
4355
|
+
*/
|
|
4356
|
+
pushAgentState(link) {
|
|
4357
|
+
const pending = link.pendingHuman;
|
|
4358
|
+
const communication = pending?.kind === "ask" ? pending.communication : void 0;
|
|
4359
|
+
const request = pending === void 0 || communication !== void 0 ? void 0 : pending.request;
|
|
4360
|
+
link.socket.updateState(agentStateSnapshot({
|
|
4156
4361
|
controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4362
|
+
...pending === void 0 ? {} : { pendingId: pending.id },
|
|
4363
|
+
...communication === void 0 ? {} : { pendingCommunication: communication },
|
|
4364
|
+
...request === void 0 ? {} : { pendingRequest: request },
|
|
4365
|
+
completedRequests: link.completedRequests,
|
|
4366
|
+
completedCommunications: link.completedCommunications
|
|
4367
|
+
}));
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Complete a permission request: keep the original entry fields (the App
|
|
4371
|
+
* schema rejects completed entries without `tool`/`arguments` — that used
|
|
4372
|
+
* to blank the whole snapshot and leave every card stuck `pending`), add
|
|
4373
|
+
* the outcome, then republish the snapshot.
|
|
4374
|
+
*/
|
|
4375
|
+
clearRequest(link, id, status, request, reason) {
|
|
4376
|
+
rememberCompleted(link.completedRequests, id, {
|
|
4377
|
+
...request,
|
|
4378
|
+
completedAt: Date.now(),
|
|
4379
|
+
status,
|
|
4380
|
+
...reason === void 0 ? {} : { reason }
|
|
4381
|
+
});
|
|
4382
|
+
this.pushAgentState(link);
|
|
4383
|
+
}
|
|
4384
|
+
/** Record a finished communications form and republish the snapshot. */
|
|
4385
|
+
completeCommunication(link, id, communication, status, answers) {
|
|
4386
|
+
rememberCompleted(link.completedCommunications, id, {
|
|
4387
|
+
...communication,
|
|
4388
|
+
completedAt: Date.now(),
|
|
4389
|
+
status,
|
|
4390
|
+
...answers === void 0 ? {} : { answers }
|
|
4162
4391
|
});
|
|
4392
|
+
this.pushAgentState(link);
|
|
4163
4393
|
}
|
|
4164
4394
|
async pushMetadata(link) {
|
|
4165
4395
|
if (this.credentials === void 0) return;
|
|
@@ -4521,7 +4751,8 @@ const Config = Schema.object({
|
|
|
4521
4751
|
Schema.const("chat"),
|
|
4522
4752
|
Schema.const("approve"),
|
|
4523
4753
|
Schema.const("full")
|
|
4524
|
-
]).default("approve")
|
|
4754
|
+
]).default("approve"),
|
|
4755
|
+
questionChannel: Schema.union([Schema.const("communications"), Schema.const("permission")]).default("communications")
|
|
4525
4756
|
});
|
|
4526
4757
|
/**
|
|
4527
4758
|
* 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
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -205,8 +205,27 @@ export declare class HappyBridge {
|
|
|
205
205
|
*/
|
|
206
206
|
private onPhoneAbort;
|
|
207
207
|
private onPermission;
|
|
208
|
-
|
|
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;
|
package/lib/types/inbound.d.ts
CHANGED
|
@@ -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,54 @@ 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
|
+
* Synthetic last option the bridge appends to every question: the App's inline
|
|
90
|
+
* form has no free-text input, so "custom answer" is a two-tap flow — pick
|
|
91
|
+
* this option, then type the real answer in the composer.
|
|
92
|
+
*/
|
|
93
|
+
export declare const CUSTOM_ANSWER_LABEL = "\u270F\uFE0F \u81EA\u5B9A\u4E49\u2026";
|
|
94
|
+
/**
|
|
95
|
+
* Merge composer text with a deferred (marker-picked) submission.
|
|
96
|
+
* - No deferred selections: the text becomes `custom` for every question
|
|
97
|
+
* (the original "type while waiting" behavior).
|
|
98
|
+
* - With deferred selections: real options are kept per question; questions
|
|
99
|
+
* that picked the marker — and questions never answered — take the text
|
|
100
|
+
* as `custom`.
|
|
101
|
+
* @param questions - harness questions in order.
|
|
102
|
+
* @param text - the composer line.
|
|
103
|
+
* @param deferred - per-question submitted options, keyed by question id.
|
|
104
|
+
* @returns one answer row per question.
|
|
105
|
+
*/
|
|
106
|
+
export declare function mergeCustomAnswers(questions: readonly {
|
|
107
|
+
id: string;
|
|
108
|
+
}[], text: string, deferred?: Record<string, string[]>): {
|
|
109
|
+
id: string;
|
|
110
|
+
selected: string[];
|
|
111
|
+
custom?: string;
|
|
112
|
+
}[];
|
|
113
|
+
/**
|
|
114
|
+
* Translate Happy communications answers (`{ [question id]: { options, custom } }`)
|
|
115
|
+
* into harness answer rows. Options become `selected`; free text becomes `custom`.
|
|
116
|
+
* @param answers - communication RPC answers keyed by question id.
|
|
117
|
+
* @param questions - original harness questions in order.
|
|
118
|
+
* @returns one answer row per question.
|
|
119
|
+
*/
|
|
120
|
+
export declare function answersFromCommunication(answers: Record<string, {
|
|
121
|
+
options: string[];
|
|
122
|
+
custom?: string;
|
|
123
|
+
}> | undefined, questions: readonly {
|
|
124
|
+
id: string;
|
|
125
|
+
}[]): {
|
|
126
|
+
id: string;
|
|
127
|
+
selected: string[];
|
|
128
|
+
custom?: string;
|
|
129
|
+
}[];
|
|
130
|
+
/**
|
|
131
|
+
* Decode a Happy `communication` RPC body (form answers or cancellation).
|
|
132
|
+
* Unknown statuses degrade to `cancelled`, which re-asks instead of silently
|
|
133
|
+
* submitting empty answers.
|
|
134
|
+
* @param params - decrypted RPC params.
|
|
135
|
+
* @returns id, form kind, answered/cancelled status, and per-question answers.
|
|
136
|
+
*/
|
|
137
|
+
export declare function parseCommunicationRpc(params: unknown): CommunicationRpc;
|
|
88
138
|
//# 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. */
|
package/lib/types/types.d.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|