@sjhmars/happy-bridge 0.1.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 +90 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +305 -0
- package/lib/index.js +4548 -0
- package/lib/invariant.js +15 -0
- package/lib/types/archive-sync.d.ts +38 -0
- package/lib/types/attachments.d.ts +19 -0
- package/lib/types/bridge.d.ts +227 -0
- package/lib/types/bytes.d.ts +16 -0
- package/lib/types/catalogs.d.ts +119 -0
- package/lib/types/client/HappyBridgeCard.d.ts +41 -0
- package/lib/types/client/index.d.ts +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/credentials.d.ts +59 -0
- package/lib/types/encryption.d.ts +104 -0
- package/lib/types/grant.d.ts +87 -0
- package/lib/types/happy-version.d.ts +11 -0
- package/lib/types/history.d.ts +184 -0
- package/lib/types/http.d.ts +108 -0
- package/lib/types/inbound.d.ts +88 -0
- package/lib/types/inbox.d.ts +39 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/invariant.d.ts +11 -0
- package/lib/types/machine.d.ts +40 -0
- package/lib/types/pairing.d.ts +23 -0
- package/lib/types/paths.d.ts +53 -0
- package/lib/types/remote.d.ts +34 -0
- package/lib/types/rpc.d.ts +22 -0
- package/lib/types/session-socket.d.ts +145 -0
- package/lib/types/types.d.ts +104 -0
- package/package.json +111 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4548 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { admitEncodedImages } from "@deepseek-ai/dsh-attachment";
|
|
4
|
+
import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
|
|
7
|
+
import { homedir, hostname } from "node:os";
|
|
8
|
+
import { basename, extname, isAbsolute, join, posix } from "node:path";
|
|
9
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
|
|
11
|
+
import nacl from "tweetnacl";
|
|
12
|
+
import { io } from "socket.io-client";
|
|
13
|
+
import QRCode from "qrcode";
|
|
14
|
+
import { createId } from "@paralleldrive/cuid2";
|
|
15
|
+
import { createEnvelope } from "@slopus/happy-wire";
|
|
16
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
17
|
+
//#region lib/types/archive-sync.js
|
|
18
|
+
/** Hide/show a harness session in the sidebar archive set without a Host unarchive RPC. */
|
|
19
|
+
/**
|
|
20
|
+
* Ids that entered or left the archive set.
|
|
21
|
+
* @param previous - last observed set.
|
|
22
|
+
* @param next - current `archivedSessionIds`.
|
|
23
|
+
*/
|
|
24
|
+
function archiveSetDiff(previous, next) {
|
|
25
|
+
return {
|
|
26
|
+
hidden: [...next].filter((id) => !previous.has(id)),
|
|
27
|
+
shown: [...previous].filter((id) => !next.has(id))
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Keep Happy online when the row is still in the unarchived web sidebar.
|
|
32
|
+
* Opening an offline phone row does not start a heartbeat by itself.
|
|
33
|
+
* @param parked - plugin park flag.
|
|
34
|
+
* @param liveOnWeb - id is in the unarchived web sidebar.
|
|
35
|
+
*/
|
|
36
|
+
function phoneParkAction(parked, liveOnWeb) {
|
|
37
|
+
if (liveOnWeb) return parked ? "unpark" : "keep";
|
|
38
|
+
return parked ? "keep" : "park";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Archive on the Host (public API). Idempotent when already archived.
|
|
42
|
+
* @param registry - `ctx.workspaceRegistry`, if the profile loaded it.
|
|
43
|
+
* @param sessionId - harness session id.
|
|
44
|
+
*/
|
|
45
|
+
async function hideOnHarness(registry, sessionId) {
|
|
46
|
+
if (registry === void 0) return;
|
|
47
|
+
const id = SessionId(sessionId);
|
|
48
|
+
if (registry.archivedSessionIds.includes(id)) return;
|
|
49
|
+
try {
|
|
50
|
+
await registry.archiveSession(id);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (registry.archivedSessionIds.includes(id)) return;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Take one id out of the Host archive set so grouping surfaces show it again
|
|
58
|
+
* in its kept `sessionIds` slot. Uses the registry write chain; does not add
|
|
59
|
+
* a Harness API.
|
|
60
|
+
* @param registry - `ctx.workspaceRegistry`, if the profile loaded it.
|
|
61
|
+
* @param sessionId - harness session id.
|
|
62
|
+
*/
|
|
63
|
+
async function revealOnHarness(registry, sessionId) {
|
|
64
|
+
if (registry === void 0) return;
|
|
65
|
+
const id = SessionId(sessionId);
|
|
66
|
+
if (!registry.archivedSessionIds.includes(id)) return;
|
|
67
|
+
const writer = registry;
|
|
68
|
+
await writer.enqueueOperation(async () => {
|
|
69
|
+
const state = writer.requireState();
|
|
70
|
+
if (!state.archivedSessionIds.includes(id)) return;
|
|
71
|
+
await writer.setState({
|
|
72
|
+
...state,
|
|
73
|
+
archivedSessionIds: state.archivedSessionIds.filter((item) => item !== id)
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region lib/types/happy-version.js
|
|
79
|
+
/**
|
|
80
|
+
* Version strings the Happy App compares against its minimum CLI.
|
|
81
|
+
*
|
|
82
|
+
* This is a reported compatibility tag matching npm `happy`, not this
|
|
83
|
+
* plugin's package version and not an install of the official CLI.
|
|
84
|
+
* Bump when Happy publishes a newer CLI that the App starts nagging for.
|
|
85
|
+
*/
|
|
86
|
+
const HAPPY_CLI_VERSION = "1.2.0";
|
|
87
|
+
/** Official `X-Happy-Client` / socket `happyClient` value. */
|
|
88
|
+
const HAPPY_CLIENT = `cli-coding-session/${HAPPY_CLI_VERSION}`;
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region lib/types/paths.js
|
|
91
|
+
/** Map Happy picker paths onto registered workspace directories. Do not mkdir. */
|
|
92
|
+
/** Virtual home the Happy directory picker starts in. */
|
|
93
|
+
const VIRTUAL_HOME = "/dsh-workspaces";
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a path for comparison: slashes, no trailing slash (except root), Windows drive case.
|
|
96
|
+
* @param value - real or virtual path.
|
|
97
|
+
* @returns comparable spelling.
|
|
98
|
+
*/
|
|
99
|
+
function normalizePath(value) {
|
|
100
|
+
const replaced = value.trim().replaceAll("\\", "/");
|
|
101
|
+
const withDrive = /^[A-Za-z]:/.test(replaced) ? replaced[0].toUpperCase() + replaced.slice(1) : replaced;
|
|
102
|
+
if (withDrive.length > 1 && withDrive.endsWith("/")) return withDrive.slice(0, -1);
|
|
103
|
+
return withDrive;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build virtual workspace entries from real registered roots.
|
|
107
|
+
* @param workspaces - `{ path, title, id }` from `workspaceRegistry.list()`.
|
|
108
|
+
* @returns virtual POSIX paths under {@link VIRTUAL_HOME}.
|
|
109
|
+
*/
|
|
110
|
+
function virtualWorkspaces(workspaces) {
|
|
111
|
+
const used = /* @__PURE__ */ new Set();
|
|
112
|
+
return workspaces.map((workspace) => {
|
|
113
|
+
const base = slug(workspace.title) || slug(workspace.id) || "workspace";
|
|
114
|
+
let name = base;
|
|
115
|
+
let n = 2;
|
|
116
|
+
while (used.has(name)) {
|
|
117
|
+
name = `${base}-${n}`;
|
|
118
|
+
n += 1;
|
|
119
|
+
}
|
|
120
|
+
used.add(name);
|
|
121
|
+
return {
|
|
122
|
+
virtualPath: `${VIRTUAL_HOME}/${name}`,
|
|
123
|
+
realPath: workspace.path,
|
|
124
|
+
title: workspace.title
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Find the registered workspace that owns a real session cwd.
|
|
130
|
+
* Nested roots pick the longest matching path.
|
|
131
|
+
* @param realCwd - session `header.cwd` or persisted meta.cwd.
|
|
132
|
+
* @param workspaces - virtual mapping.
|
|
133
|
+
*/
|
|
134
|
+
function matchVirtualWorkspace(realCwd, workspaces) {
|
|
135
|
+
const want = normalizePath(realCwd);
|
|
136
|
+
let best;
|
|
137
|
+
let bestLen = -1;
|
|
138
|
+
for (const workspace of workspaces) {
|
|
139
|
+
const root = normalizePath(workspace.realPath);
|
|
140
|
+
if (want !== root && !want.startsWith(`${root}/`)) continue;
|
|
141
|
+
if (root.length > bestLen) {
|
|
142
|
+
best = workspace;
|
|
143
|
+
bestLen = root.length;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return best;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a spawn `directory` to a registered workspace real path.
|
|
150
|
+
* @param directory - Happy spawn directory (virtual or real).
|
|
151
|
+
* @param workspaces - virtual mapping.
|
|
152
|
+
* @returns real path, or `undefined` when it is not a registered workspace.
|
|
153
|
+
*/
|
|
154
|
+
function resolveSpawnDirectory(directory, workspaces) {
|
|
155
|
+
const want = normalizePath(directory);
|
|
156
|
+
for (const workspace of workspaces) {
|
|
157
|
+
if (normalizePath(workspace.virtualPath) === want) return workspace.realPath;
|
|
158
|
+
if (normalizePath(workspace.realPath) === want) return workspace.realPath;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Slim `listDirectory`: only the virtual home and workspace roots.
|
|
163
|
+
* @param requestPath - path the App asked to list.
|
|
164
|
+
* @param workspaces - virtual mapping.
|
|
165
|
+
* @returns Happy listDirectory payload.
|
|
166
|
+
*/
|
|
167
|
+
function listVirtualDirectory(requestPath, workspaces) {
|
|
168
|
+
const want = normalizePath(requestPath === "" || requestPath === "~" ? VIRTUAL_HOME : requestPath);
|
|
169
|
+
if (want === "/" || want === "/dsh-workspaces") return {
|
|
170
|
+
success: true,
|
|
171
|
+
entries: workspaces.map((workspace) => ({
|
|
172
|
+
name: posix.basename(workspace.virtualPath),
|
|
173
|
+
type: "directory"
|
|
174
|
+
}))
|
|
175
|
+
};
|
|
176
|
+
if (workspaces.find((workspace) => normalizePath(workspace.virtualPath) === want) !== void 0) return {
|
|
177
|
+
success: true,
|
|
178
|
+
entries: []
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
error: "只列出已登记的工作区根目录"
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function slug(value) {
|
|
186
|
+
return (value.replaceAll("\\", "/").split("/").pop() ?? value).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region lib/types/catalogs.js
|
|
190
|
+
/** Build Happy session metadata catalogs from the live harness context. */
|
|
191
|
+
/**
|
|
192
|
+
* Snapshot catalogs for one session, live or still sitting in the sidebar.
|
|
193
|
+
* @param ctx - Host context.
|
|
194
|
+
* @param source - real cwd for skills, POSIX `happyPath` for the App list, log, and optional live agent.
|
|
195
|
+
* @param machineId - Happy machine id.
|
|
196
|
+
* @param title - session display name.
|
|
197
|
+
* @param selection - current provider/model/effort, when known.
|
|
198
|
+
* @param grant - unused in metadata; kept for future capability bits.
|
|
199
|
+
* @returns plaintext metadata.
|
|
200
|
+
*/
|
|
201
|
+
async function buildSessionMetadata(ctx, source, machineId, title, selection, _grant) {
|
|
202
|
+
const cwd = source.cwd;
|
|
203
|
+
const slashCommands = source.agent === void 0 ? [] : listCommands(ctx, source.agent).map((command) => command.name);
|
|
204
|
+
const skills = (await listSkills(ctx, cwd)).map((skill) => skill.name);
|
|
205
|
+
const models = await listModels(ctx);
|
|
206
|
+
const presets = ctx.get("permissionPresets");
|
|
207
|
+
const names = presets?.names ?? ["workspace-write", "danger-full-access"];
|
|
208
|
+
const currentPreset = presets === void 0 ? "workspace-write" : presets.current(source.events);
|
|
209
|
+
const operatingModes = names.filter((name) => name !== "custom" && name !== "read-only").map((name) => ({
|
|
210
|
+
code: name,
|
|
211
|
+
value: name,
|
|
212
|
+
description: name === "danger-full-access" ? "不限制文件,且不再询问批准" : "只能改当前工作区,危险工具要批准"
|
|
213
|
+
}));
|
|
214
|
+
const selected = resolveSelection(models, selection);
|
|
215
|
+
const thoughtLevels = selected?.row.effortOptions;
|
|
216
|
+
const thought = selected === void 0 ? void 0 : selected.effort ?? selected.row.defaultThinkingLevel;
|
|
217
|
+
return {
|
|
218
|
+
path: source.happyPath,
|
|
219
|
+
host: hostname(),
|
|
220
|
+
homeDir: VIRTUAL_HOME,
|
|
221
|
+
version: HAPPY_CLI_VERSION,
|
|
222
|
+
name: title,
|
|
223
|
+
summary: {
|
|
224
|
+
text: title,
|
|
225
|
+
updatedAt: Date.now()
|
|
226
|
+
},
|
|
227
|
+
os: process.platform,
|
|
228
|
+
machineId,
|
|
229
|
+
flavor: "acp",
|
|
230
|
+
startedBy: "terminal",
|
|
231
|
+
lifecycleState: "running",
|
|
232
|
+
lifecycleStateSince: Date.now(),
|
|
233
|
+
happyHomeDir: VIRTUAL_HOME,
|
|
234
|
+
happyLibDir: VIRTUAL_HOME,
|
|
235
|
+
happyToolsDir: VIRTUAL_HOME,
|
|
236
|
+
slashCommands,
|
|
237
|
+
skills,
|
|
238
|
+
models,
|
|
239
|
+
operatingModes,
|
|
240
|
+
client: {
|
|
241
|
+
id: "rig",
|
|
242
|
+
name: "DeepSeek Harness",
|
|
243
|
+
version: HAPPY_CLI_VERSION
|
|
244
|
+
},
|
|
245
|
+
rigMetadataVersion: 1,
|
|
246
|
+
capabilities: {
|
|
247
|
+
abort: true,
|
|
248
|
+
attachments: {
|
|
249
|
+
enabled: true,
|
|
250
|
+
maxBytes: 10485760,
|
|
251
|
+
mediaTypes: [
|
|
252
|
+
"image/png",
|
|
253
|
+
"image/jpeg",
|
|
254
|
+
"image/webp",
|
|
255
|
+
"image/gif"
|
|
256
|
+
]
|
|
257
|
+
},
|
|
258
|
+
files: {
|
|
259
|
+
browse: false,
|
|
260
|
+
read: false,
|
|
261
|
+
search: false,
|
|
262
|
+
write: false
|
|
263
|
+
},
|
|
264
|
+
modelSelection: true,
|
|
265
|
+
reasoningSelection: true,
|
|
266
|
+
permissionModeSelection: true,
|
|
267
|
+
resume: false,
|
|
268
|
+
rpcMethods: [
|
|
269
|
+
"permission",
|
|
270
|
+
"killSession",
|
|
271
|
+
"abort"
|
|
272
|
+
],
|
|
273
|
+
shell: false,
|
|
274
|
+
steering: false
|
|
275
|
+
},
|
|
276
|
+
currentOperatingModeCode: currentPreset === "custom" ? operatingModes[0]?.code ?? "workspace-write" : currentPreset,
|
|
277
|
+
...selected === void 0 ? {} : {
|
|
278
|
+
currentModelCode: selected.row.id,
|
|
279
|
+
currentModelProviderId: selected.row.providerId,
|
|
280
|
+
modelMode: `${selected.row.providerId}:${selected.row.id}`
|
|
281
|
+
},
|
|
282
|
+
...thoughtLevels === void 0 || thoughtLevels.length === 0 ? {} : {
|
|
283
|
+
thoughtLevels,
|
|
284
|
+
reasoning: {
|
|
285
|
+
current: thought ?? null,
|
|
286
|
+
levels: thoughtLevels.map((level) => level.code)
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
...thought === void 0 ? {} : {
|
|
290
|
+
currentThoughtLevelCode: thought,
|
|
291
|
+
effortLevel: thought
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function resolveSelection(models, selection) {
|
|
296
|
+
if (selection !== void 0) {
|
|
297
|
+
const row = models.find((model) => model.providerId === selection.provider && model.id === selection.model) ?? models.find((model) => model.code === `${selection.provider}/${selection.model}`);
|
|
298
|
+
if (row !== void 0) return {
|
|
299
|
+
row,
|
|
300
|
+
...selection.reasoningEffort === void 0 ? {} : { effort: selection.reasoningEffort }
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const first = models[0];
|
|
304
|
+
return first === void 0 ? void 0 : { row: first };
|
|
305
|
+
}
|
|
306
|
+
function listCommands(ctx, agent) {
|
|
307
|
+
const commands = ctx.get("commands");
|
|
308
|
+
if (commands === void 0) return [];
|
|
309
|
+
return commands.list(agent).map((command) => ({
|
|
310
|
+
name: command.name,
|
|
311
|
+
description: command.description
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
async function listSkills(ctx, cwd) {
|
|
315
|
+
const skills = ctx.get("skills");
|
|
316
|
+
if (skills === void 0) return [];
|
|
317
|
+
try {
|
|
318
|
+
return (await skills.list({ cwd })).filter((skill) => skill.invocation.userInvocable).map((skill) => ({
|
|
319
|
+
name: skill.name,
|
|
320
|
+
description: skill.description
|
|
321
|
+
}));
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function listModels(ctx) {
|
|
327
|
+
const llm = ctx.get("llm");
|
|
328
|
+
if (llm === void 0) return [];
|
|
329
|
+
const out = [];
|
|
330
|
+
for (const provider of llm.listProviders()) try {
|
|
331
|
+
const models = await llm.listModels(provider.id);
|
|
332
|
+
for (const model of models) {
|
|
333
|
+
const reasoning = await effortsFor(ctx, provider.id, model.id);
|
|
334
|
+
out.push({
|
|
335
|
+
code: `${provider.id}/${model.id}`,
|
|
336
|
+
value: model.name,
|
|
337
|
+
description: model.name,
|
|
338
|
+
id: model.id,
|
|
339
|
+
name: model.name,
|
|
340
|
+
providerId: provider.id,
|
|
341
|
+
providerKind: "custom",
|
|
342
|
+
providerName: provider.name,
|
|
343
|
+
thinkingLevels: reasoning?.options.map((option) => option.code) ?? [],
|
|
344
|
+
effortOptions: reasoning?.options ?? [],
|
|
345
|
+
...reasoning?.defaultEffort === void 0 ? {} : { defaultThinkingLevel: reasoning.defaultEffort }
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
} catch {}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
async function effortsFor(ctx, provider, model) {
|
|
352
|
+
const llm = ctx.get("llm");
|
|
353
|
+
if (llm === void 0) return void 0;
|
|
354
|
+
try {
|
|
355
|
+
const info = await llm.resolveModelInfo(provider, model);
|
|
356
|
+
const efforts = info.reasoning?.efforts;
|
|
357
|
+
if (efforts === void 0 || efforts.length === 0) return void 0;
|
|
358
|
+
return {
|
|
359
|
+
options: efforts.map((effort) => ({
|
|
360
|
+
code: effort.id,
|
|
361
|
+
value: effort.name
|
|
362
|
+
})),
|
|
363
|
+
...info.reasoning?.defaultEffort === void 0 ? {} : { defaultEffort: info.reasoning.defaultEffort }
|
|
364
|
+
};
|
|
365
|
+
} catch {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region lib/types/bytes.js
|
|
371
|
+
/** Base64 helpers matching Happy CLI `encodeBase64` / `decodeBase64`. */
|
|
372
|
+
/**
|
|
373
|
+
* Encode bytes as standard or URL-safe base64.
|
|
374
|
+
* @param buffer - bytes to encode.
|
|
375
|
+
* @param variant - `base64` (default) or `base64url` without padding.
|
|
376
|
+
* @returns encoded string.
|
|
377
|
+
*/
|
|
378
|
+
function encodeBase64(buffer, variant = "base64") {
|
|
379
|
+
const standard = Buffer.from(buffer).toString("base64");
|
|
380
|
+
if (variant === "base64") return standard;
|
|
381
|
+
return standard.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Decode a standard or URL-safe base64 string.
|
|
385
|
+
* @param value - encoded string.
|
|
386
|
+
* @param variant - encoding used by `value`.
|
|
387
|
+
* @returns decoded bytes.
|
|
388
|
+
*/
|
|
389
|
+
function decodeBase64(value, variant = "base64") {
|
|
390
|
+
if (variant === "base64url") {
|
|
391
|
+
const padded = value.replaceAll("-", "+").replaceAll("/", "_") + "=".repeat((4 - value.length % 4) % 4);
|
|
392
|
+
return new Uint8Array(Buffer.from(padded, "base64"));
|
|
393
|
+
}
|
|
394
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region lib/types/credentials.js
|
|
398
|
+
/** Load and store Happy credentials. Prefer `~/.happy/access.key`, else plugin dir. */
|
|
399
|
+
/**
|
|
400
|
+
* Resolve the plugin credential directory.
|
|
401
|
+
* @param configured - Config.credentialDir; empty means the default under home.
|
|
402
|
+
* @returns absolute directory path.
|
|
403
|
+
*/
|
|
404
|
+
function resolveCredentialDir(configured) {
|
|
405
|
+
if (configured.trim() !== "") return isAbsolute(configured) ? configured : join(homedir(), configured);
|
|
406
|
+
return join(homedir(), ".dsh", "happy-bridge");
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Load credentials: Happy CLI file first, then this plugin's file.
|
|
410
|
+
* @param credentialDir - plugin directory.
|
|
411
|
+
* @returns credentials, or `undefined` when nothing usable is on disk.
|
|
412
|
+
*/
|
|
413
|
+
async function loadCredentials(credentialDir) {
|
|
414
|
+
const state = await readState(credentialDir);
|
|
415
|
+
if (state.disconnected === true) return void 0;
|
|
416
|
+
const happyHome = await readAccessKey(join(homedir(), ".happy", "access.key"));
|
|
417
|
+
if (happyHome !== void 0) return {
|
|
418
|
+
...happyHome,
|
|
419
|
+
machineId: state.machineId ?? happyHome.machineId
|
|
420
|
+
};
|
|
421
|
+
return readAccessKey(join(credentialDir, "access.key"));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Persist credentials in the plugin directory. Does not overwrite `~/.happy/access.key`.
|
|
425
|
+
* @param credentialDir - plugin directory.
|
|
426
|
+
* @param credentials - token + encryption + machineId.
|
|
427
|
+
*/
|
|
428
|
+
async function saveCredentials(credentialDir, credentials) {
|
|
429
|
+
await mkdir(credentialDir, { recursive: true });
|
|
430
|
+
const body = {
|
|
431
|
+
token: credentials.token,
|
|
432
|
+
machineId: credentials.machineId
|
|
433
|
+
};
|
|
434
|
+
if (credentials.encryption.type === "legacy") body.secret = encodeBase64(credentials.encryption.secret);
|
|
435
|
+
else body.encryption = {
|
|
436
|
+
publicKey: encodeBase64(credentials.encryption.publicKey),
|
|
437
|
+
machineKey: encodeBase64(credentials.encryption.machineKey)
|
|
438
|
+
};
|
|
439
|
+
await writeFile(join(credentialDir, "access.key"), JSON.stringify(body, null, 2), "utf8");
|
|
440
|
+
await writeState(credentialDir, {
|
|
441
|
+
disconnected: false,
|
|
442
|
+
machineId: credentials.machineId
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Mark the plugin disconnected without deleting Happy CLI credentials.
|
|
447
|
+
* Clears phone-dismissed ids so a later pair remirrors those sessions.
|
|
448
|
+
* @param credentialDir - plugin directory.
|
|
449
|
+
* @param machineId - last known machine id to keep stable.
|
|
450
|
+
*/
|
|
451
|
+
async function markDisconnected(credentialDir, machineId) {
|
|
452
|
+
await mkdir(credentialDir, { recursive: true });
|
|
453
|
+
await writeState(credentialDir, {
|
|
454
|
+
disconnected: true,
|
|
455
|
+
...machineId === void 0 ? {} : { machineId },
|
|
456
|
+
dismissedDshIds: []
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Clear the local disconnected flag so existing credentials can be reused.
|
|
461
|
+
* @param credentialDir - plugin directory.
|
|
462
|
+
* @param machineId - machine id to keep.
|
|
463
|
+
*/
|
|
464
|
+
async function markConnected(credentialDir, machineId) {
|
|
465
|
+
await mkdir(credentialDir, { recursive: true });
|
|
466
|
+
await writeState(credentialDir, {
|
|
467
|
+
disconnected: false,
|
|
468
|
+
machineId
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Last stored machine id, even when locally disconnected.
|
|
473
|
+
* @param credentialDir - plugin directory.
|
|
474
|
+
* @returns machine id, or `undefined`.
|
|
475
|
+
*/
|
|
476
|
+
async function peekMachineId(credentialDir) {
|
|
477
|
+
return (await readState(credentialDir)).machineId;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Session ids the phone asked to stop mirroring. stop-session / archive
|
|
481
|
+
* persist here so a later scan does not recreate the Happy row.
|
|
482
|
+
* @param credentialDir - plugin directory.
|
|
483
|
+
* @returns harness session ids, possibly empty.
|
|
484
|
+
*/
|
|
485
|
+
async function loadDismissed(credentialDir) {
|
|
486
|
+
return (await readState(credentialDir)).dismissedDshIds;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Forget a previously dismissed harness session so it can remirror.
|
|
490
|
+
* @param credentialDir - plugin directory.
|
|
491
|
+
* @param dshId - harness session id.
|
|
492
|
+
*/
|
|
493
|
+
async function removeDismissed(credentialDir, dshId) {
|
|
494
|
+
const state = await readState(credentialDir);
|
|
495
|
+
if (!state.dismissedDshIds.includes(dshId)) return;
|
|
496
|
+
await writeState(credentialDir, {
|
|
497
|
+
disconnected: state.disconnected,
|
|
498
|
+
...state.machineId === void 0 ? {} : { machineId: state.machineId },
|
|
499
|
+
dismissedDshIds: state.dismissedDshIds.filter((id) => id !== dshId)
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Remember that the phone dismissed this harness session.
|
|
504
|
+
* @param credentialDir - plugin directory.
|
|
505
|
+
* @param dshId - harness session id.
|
|
506
|
+
*/
|
|
507
|
+
async function addDismissed(credentialDir, dshId) {
|
|
508
|
+
const state = await readState(credentialDir);
|
|
509
|
+
if (state.dismissedDshIds.includes(dshId)) return;
|
|
510
|
+
await writeState(credentialDir, {
|
|
511
|
+
disconnected: state.disconnected,
|
|
512
|
+
...state.machineId === void 0 ? {} : { machineId: state.machineId },
|
|
513
|
+
dismissedDshIds: [...state.dismissedDshIds, dshId]
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async function readAccessKey(path) {
|
|
517
|
+
let raw;
|
|
518
|
+
try {
|
|
519
|
+
raw = await readFile(path, "utf8");
|
|
520
|
+
} catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(raw);
|
|
526
|
+
} catch {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (typeof parsed.token !== "string" || parsed.token === "") return void 0;
|
|
530
|
+
const machineId = typeof parsed.machineId === "string" && parsed.machineId !== "" ? parsed.machineId : crypto.randomUUID();
|
|
531
|
+
if (typeof parsed.secret === "string" && parsed.secret !== "") return {
|
|
532
|
+
token: parsed.token,
|
|
533
|
+
encryption: {
|
|
534
|
+
type: "legacy",
|
|
535
|
+
secret: decodeBase64(parsed.secret)
|
|
536
|
+
},
|
|
537
|
+
machineId
|
|
538
|
+
};
|
|
539
|
+
const publicKey = parsed.encryption?.publicKey;
|
|
540
|
+
const machineKey = parsed.encryption?.machineKey;
|
|
541
|
+
if (typeof publicKey === "string" && typeof machineKey === "string") return {
|
|
542
|
+
token: parsed.token,
|
|
543
|
+
encryption: {
|
|
544
|
+
type: "dataKey",
|
|
545
|
+
publicKey: decodeBase64(publicKey),
|
|
546
|
+
machineKey: decodeBase64(machineKey)
|
|
547
|
+
},
|
|
548
|
+
machineId
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
async function readState(credentialDir) {
|
|
552
|
+
try {
|
|
553
|
+
const raw = await readFile(join(credentialDir, "state.json"), "utf8");
|
|
554
|
+
const parsed = JSON.parse(raw);
|
|
555
|
+
return {
|
|
556
|
+
disconnected: parsed.disconnected === true,
|
|
557
|
+
...typeof parsed.machineId === "string" ? { machineId: parsed.machineId } : {},
|
|
558
|
+
dismissedDshIds: stringList(parsed.dismissedDshIds)
|
|
559
|
+
};
|
|
560
|
+
} catch {
|
|
561
|
+
return {
|
|
562
|
+
disconnected: false,
|
|
563
|
+
dismissedDshIds: []
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function writeState(credentialDir, state) {
|
|
568
|
+
const previous = await readState(credentialDir);
|
|
569
|
+
const dismissed = state.dismissedDshIds ?? previous.dismissedDshIds;
|
|
570
|
+
await writeFile(join(credentialDir, "state.json"), JSON.stringify({
|
|
571
|
+
disconnected: state.disconnected,
|
|
572
|
+
...state.machineId === void 0 && previous.machineId === void 0 ? {} : { machineId: state.machineId ?? previous.machineId },
|
|
573
|
+
...dismissed.length === 0 ? {} : { dismissedDshIds: dismissed }
|
|
574
|
+
}, null, 2), "utf8");
|
|
575
|
+
}
|
|
576
|
+
function stringList(value) {
|
|
577
|
+
if (!Array.isArray(value)) return [];
|
|
578
|
+
return value.filter((item) => typeof item === "string" && item !== "");
|
|
579
|
+
}
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region lib/types/attachments.js
|
|
582
|
+
/** Phone file events: sniff image bytes and split them from other attachments. */
|
|
583
|
+
const IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
584
|
+
"image/png",
|
|
585
|
+
"image/jpeg",
|
|
586
|
+
"image/webp",
|
|
587
|
+
"image/gif"
|
|
588
|
+
]);
|
|
589
|
+
/**
|
|
590
|
+
* Detect a DSH image media type from magic bytes, then declared MIME, then filename.
|
|
591
|
+
* @param file - decrypted Happy attachment.
|
|
592
|
+
* @returns a version-one image media type, or `undefined` for other files.
|
|
593
|
+
*/
|
|
594
|
+
function sniffImageMime(file) {
|
|
595
|
+
const fromBytes = mimeFromMagic(file.bytes);
|
|
596
|
+
if (fromBytes !== void 0) return fromBytes;
|
|
597
|
+
if (IMAGE_TYPES.has(file.mimeType)) return file.mimeType;
|
|
598
|
+
const lower = file.name.toLowerCase();
|
|
599
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
600
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
601
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
602
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Split decrypted Happy files into DSH image uploads and leftover binaries.
|
|
606
|
+
* @param files - drained phone attachments in arrival order.
|
|
607
|
+
* @returns encoded images plus non-image files.
|
|
608
|
+
*/
|
|
609
|
+
function splitPendingFiles(files) {
|
|
610
|
+
const encoded = [];
|
|
611
|
+
const extras = [];
|
|
612
|
+
for (const file of files) {
|
|
613
|
+
const mime = sniffImageMime(file);
|
|
614
|
+
if (mime === void 0) {
|
|
615
|
+
extras.push(file);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
encoded.push({
|
|
619
|
+
mediaType: mime,
|
|
620
|
+
data: Buffer.from(file.bytes).toString("base64"),
|
|
621
|
+
name: file.name
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
encoded,
|
|
626
|
+
extras
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function mimeFromMagic(bytes) {
|
|
630
|
+
if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) return "image/png";
|
|
631
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
632
|
+
if (bytes.length >= 6 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56 && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97) return "image/gif";
|
|
633
|
+
if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region lib/types/inbox.js
|
|
637
|
+
/** Save phone non-image files into the session workspace so Harness `read` can open them. */
|
|
638
|
+
/** Directory under the session cwd that holds phone files for `read`. */
|
|
639
|
+
const HAPPY_INBOX_DIR = "happy-inbox";
|
|
640
|
+
const UNSAFE_NAME = /[<>:"/\\|?*\u0000-\u001f]/g;
|
|
641
|
+
/**
|
|
642
|
+
* Strip path separators and reserved characters so the file stays inside `happy-inbox/`.
|
|
643
|
+
* @param name - Happy's original filename.
|
|
644
|
+
* @returns a single path segment, or `file` when nothing usable remains.
|
|
645
|
+
*/
|
|
646
|
+
function sanitizeInboxName(name) {
|
|
647
|
+
const base = basename(name.replaceAll("\\", "/")).replace(UNSAFE_NAME, "_").replace(/^\.+$/u, "_");
|
|
648
|
+
return base.length > 0 ? base : "file";
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Pick a basename that is not already taken in this inbox (on disk or in this batch).
|
|
652
|
+
* @param taken - basenames already used.
|
|
653
|
+
* @param name - Happy's original filename.
|
|
654
|
+
* @returns a unique basename under `happy-inbox/`.
|
|
655
|
+
*/
|
|
656
|
+
function uniqueInboxName(taken, name) {
|
|
657
|
+
const safe = sanitizeInboxName(name);
|
|
658
|
+
if (!taken.has(safe)) return safe;
|
|
659
|
+
const ext = extname(safe);
|
|
660
|
+
const stem = ext.length > 0 ? safe.slice(0, -ext.length) : safe;
|
|
661
|
+
for (let i = 1; i < 1e4; i++) {
|
|
662
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
663
|
+
if (!taken.has(candidate)) return candidate;
|
|
664
|
+
}
|
|
665
|
+
return `${stem}-${Date.now()}${ext}`;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Workspace-relative path the `read` tool resolves against session cwd. Always `/`.
|
|
669
|
+
* @param filename - a sanitized inbox basename.
|
|
670
|
+
* @returns `happy-inbox/<filename>`.
|
|
671
|
+
*/
|
|
672
|
+
function inboxReadPath(filename) {
|
|
673
|
+
return `${HAPPY_INBOX_DIR}/${filename}`;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Append a `read`-tool instruction after the user's typed text.
|
|
677
|
+
* Harness web does not admit non-image composer uploads; ordinary files are workspace paths for `read`.
|
|
678
|
+
* @param text - what the phone typed, possibly empty.
|
|
679
|
+
* @param relativePaths - `happy-inbox/...` paths already written.
|
|
680
|
+
* @returns the followup text, or `text` when there are no files.
|
|
681
|
+
*/
|
|
682
|
+
function inboxReadPrompt(text, relativePaths) {
|
|
683
|
+
if (relativePaths.length === 0) return text;
|
|
684
|
+
const instruction = `请用 read 工具阅读这些工作区文件(不要用 cat):\n${relativePaths.map((path) => `- ${path}`).join("\n")}`;
|
|
685
|
+
return text.trim() === "" ? instruction : `${text.trim()}\n\n${instruction}`;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Write extras into `<cwd>/happy-inbox/` and return relative paths for `read`.
|
|
689
|
+
* @param cwd - session workspace root.
|
|
690
|
+
* @param files - decrypted non-image attachments.
|
|
691
|
+
* @returns posix-relative paths the model should pass to `read`.
|
|
692
|
+
*/
|
|
693
|
+
async function saveInboxFiles(cwd, files) {
|
|
694
|
+
if (files.length === 0) return [];
|
|
695
|
+
const inbox = join(cwd, HAPPY_INBOX_DIR);
|
|
696
|
+
await mkdir(inbox, { recursive: true });
|
|
697
|
+
const taken = new Set(await readdir(inbox));
|
|
698
|
+
const relative = [];
|
|
699
|
+
for (const file of files) {
|
|
700
|
+
const name = uniqueInboxName(taken, file.name);
|
|
701
|
+
taken.add(name);
|
|
702
|
+
await writeFile(join(inbox, name), file.bytes);
|
|
703
|
+
relative.push(inboxReadPath(name));
|
|
704
|
+
}
|
|
705
|
+
return relative;
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region lib/types/encryption.js
|
|
709
|
+
/** Happy content encryption: legacy NaCl secretbox or AES-256-GCM dataKey. */
|
|
710
|
+
/**
|
|
711
|
+
* Encrypt a JSON value the way Happy CLI `encrypt()` does.
|
|
712
|
+
* @param ctx - session or machine crypto.
|
|
713
|
+
* @param data - JSON-serializable plaintext.
|
|
714
|
+
* @returns nonce+ciphertext bytes.
|
|
715
|
+
*/
|
|
716
|
+
function encryptJson(ctx, data) {
|
|
717
|
+
if (ctx.variant === "legacy") return encryptLegacy(data, ctx.key);
|
|
718
|
+
return encryptWithDataKey(data, ctx.key);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Decrypt a Happy ciphertext into JSON.
|
|
722
|
+
* @param ctx - matching crypto.
|
|
723
|
+
* @param data - nonce+ciphertext bytes.
|
|
724
|
+
* @returns plaintext or `null` when the box does not open.
|
|
725
|
+
*/
|
|
726
|
+
function decryptJson(ctx, data) {
|
|
727
|
+
if (ctx.variant === "legacy") return decryptLegacy(data, ctx.key);
|
|
728
|
+
return decryptWithDataKey(data, ctx.key);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Encrypt JSON and return the on-wire base64 string.
|
|
732
|
+
* @param ctx - session or machine crypto.
|
|
733
|
+
* @param data - JSON-serializable plaintext.
|
|
734
|
+
* @returns base64 ciphertext.
|
|
735
|
+
*/
|
|
736
|
+
function encryptB64(ctx, data) {
|
|
737
|
+
return encodeBase64(encryptJson(ctx, data));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Decode base64 then decrypt JSON.
|
|
741
|
+
* @param ctx - matching crypto.
|
|
742
|
+
* @param value - base64 ciphertext.
|
|
743
|
+
* @returns plaintext or `null`.
|
|
744
|
+
*/
|
|
745
|
+
function decryptB64(ctx, value) {
|
|
746
|
+
return decryptJson(ctx, decodeBase64(value));
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Open the pairing `response` blob (ephemeral-box bundle).
|
|
750
|
+
* @param encryptedBundle - ephPublicKey + nonce + ciphertext.
|
|
751
|
+
* @param recipientSecretKey - our box secret key.
|
|
752
|
+
* @returns 32-byte shared secret, or versioned dataKey payload, or `null`.
|
|
753
|
+
*/
|
|
754
|
+
function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) {
|
|
755
|
+
const ephemeralPublicKey = encryptedBundle.slice(0, 32);
|
|
756
|
+
const nonce = encryptedBundle.slice(32, 32 + nacl.box.nonceLength);
|
|
757
|
+
const encrypted = encryptedBundle.slice(32 + nacl.box.nonceLength);
|
|
758
|
+
const decrypted = nacl.box.open(encrypted, nonce, ephemeralPublicKey, recipientSecretKey);
|
|
759
|
+
return decrypted ? decrypted : null;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Wrap a data-encryption key for the account public key.
|
|
763
|
+
* @param dataKey - 32-byte DEK.
|
|
764
|
+
* @param recipientPublicKey - account box public key.
|
|
765
|
+
* @returns versioned bundle Happy stores as `dataEncryptionKey`.
|
|
766
|
+
*/
|
|
767
|
+
function wrapDataEncryptionKey(dataKey, recipientPublicKey) {
|
|
768
|
+
const boxed = encryptForPublicKey(dataKey, recipientPublicKey);
|
|
769
|
+
const wrapped = new Uint8Array(boxed.length + 1);
|
|
770
|
+
wrapped.set([0], 0);
|
|
771
|
+
wrapped.set(boxed, 1);
|
|
772
|
+
return wrapped;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Content crypto for a newly created Happy session.
|
|
776
|
+
* @param credentials - account credentials.
|
|
777
|
+
* @returns session key plus optional wrapped DEK for POST /v1/sessions.
|
|
778
|
+
*/
|
|
779
|
+
function sessionCrypto(credentials) {
|
|
780
|
+
if (credentials.encryption.type === "legacy") return {
|
|
781
|
+
ctx: {
|
|
782
|
+
key: credentials.encryption.secret,
|
|
783
|
+
variant: "legacy"
|
|
784
|
+
},
|
|
785
|
+
dataEncryptionKey: void 0
|
|
786
|
+
};
|
|
787
|
+
const key = new Uint8Array(randomBytes(32));
|
|
788
|
+
return {
|
|
789
|
+
ctx: {
|
|
790
|
+
key,
|
|
791
|
+
variant: "dataKey"
|
|
792
|
+
},
|
|
793
|
+
dataEncryptionKey: wrapDataEncryptionKey(key, credentials.encryption.publicKey)
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Content crypto for the machine entity (uses `machineKey` on dataKey accounts).
|
|
798
|
+
* @param credentials - account credentials.
|
|
799
|
+
* @returns machine key plus optional wrapped DEK.
|
|
800
|
+
*/
|
|
801
|
+
function machineCrypto(credentials) {
|
|
802
|
+
if (credentials.encryption.type === "legacy") return {
|
|
803
|
+
ctx: {
|
|
804
|
+
key: credentials.encryption.secret,
|
|
805
|
+
variant: "legacy"
|
|
806
|
+
},
|
|
807
|
+
dataEncryptionKey: void 0
|
|
808
|
+
};
|
|
809
|
+
return {
|
|
810
|
+
ctx: {
|
|
811
|
+
key: credentials.encryption.machineKey,
|
|
812
|
+
variant: "dataKey"
|
|
813
|
+
},
|
|
814
|
+
dataEncryptionKey: wrapDataEncryptionKey(credentials.encryption.machineKey, credentials.encryption.publicKey)
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function encryptLegacy(data, secret) {
|
|
818
|
+
const nonce = randomBytes(nacl.secretbox.nonceLength);
|
|
819
|
+
const encrypted = nacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), standalone(nonce), standalone(secret));
|
|
820
|
+
const result = new Uint8Array(nonce.length + encrypted.length);
|
|
821
|
+
result.set(nonce);
|
|
822
|
+
result.set(encrypted, nonce.length);
|
|
823
|
+
return result;
|
|
824
|
+
}
|
|
825
|
+
function decryptLegacy(data, secret) {
|
|
826
|
+
const nonce = data.slice(0, nacl.secretbox.nonceLength);
|
|
827
|
+
const encrypted = data.slice(nacl.secretbox.nonceLength);
|
|
828
|
+
const decrypted = nacl.secretbox.open(encrypted, nonce, standalone(secret));
|
|
829
|
+
if (!decrypted) return null;
|
|
830
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
831
|
+
}
|
|
832
|
+
function encryptWithDataKey(data, dataKey) {
|
|
833
|
+
const nonce = randomBytes(12);
|
|
834
|
+
const cipher = createCipheriv("aes-256-gcm", dataKey, nonce);
|
|
835
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
836
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
837
|
+
const authTag = cipher.getAuthTag();
|
|
838
|
+
const bundle = new Uint8Array(13 + encrypted.length + 16);
|
|
839
|
+
bundle.set([0], 0);
|
|
840
|
+
bundle.set(nonce, 1);
|
|
841
|
+
bundle.set(encrypted, 13);
|
|
842
|
+
bundle.set(authTag, 13 + encrypted.length);
|
|
843
|
+
return bundle;
|
|
844
|
+
}
|
|
845
|
+
function decryptWithDataKey(bundle, dataKey) {
|
|
846
|
+
if (bundle.length < 29) return null;
|
|
847
|
+
if (bundle[0] !== 0) return null;
|
|
848
|
+
const nonce = bundle.slice(1, 13);
|
|
849
|
+
const authTag = bundle.slice(bundle.length - 16);
|
|
850
|
+
const ciphertext = bundle.slice(13, bundle.length - 16);
|
|
851
|
+
try {
|
|
852
|
+
const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce);
|
|
853
|
+
decipher.setAuthTag(authTag);
|
|
854
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
855
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function encryptForPublicKey(data, recipientPublicKey) {
|
|
861
|
+
const ephemeral = nacl.box.keyPair();
|
|
862
|
+
const nonce = randomBytes(nacl.box.nonceLength);
|
|
863
|
+
const encrypted = nacl.box(standalone(data), standalone(nonce), recipientPublicKey, ephemeral.secretKey);
|
|
864
|
+
const result = new Uint8Array(ephemeral.publicKey.length + nonce.length + encrypted.length);
|
|
865
|
+
result.set(ephemeral.publicKey, 0);
|
|
866
|
+
result.set(nonce, ephemeral.publicKey.length);
|
|
867
|
+
result.set(encrypted, ephemeral.publicKey.length + nonce.length);
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Encrypt a binary blob with NaCl crypto_secretbox (XSalsa20-Poly1305).
|
|
872
|
+
* Wire format: nonce (24 bytes) then ciphertext plus 16-byte auth tag.
|
|
873
|
+
* Matches Happy App/CLI `encryptBlob`.
|
|
874
|
+
* @param data - plaintext bytes.
|
|
875
|
+
* @param key - 32-byte blob key from {@link deriveBlobKey}.
|
|
876
|
+
* @returns nonce + ciphertext.
|
|
877
|
+
*/
|
|
878
|
+
function encryptBlob(data, key) {
|
|
879
|
+
const nonce = randomBytes(nacl.secretbox.nonceLength);
|
|
880
|
+
const encrypted = nacl.secretbox(standalone(data), standalone(nonce), standalone(key));
|
|
881
|
+
const result = new Uint8Array(nonce.length + encrypted.length);
|
|
882
|
+
result.set(nonce, 0);
|
|
883
|
+
result.set(encrypted, nonce.length);
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Decrypt a binary blob encrypted with NaCl crypto_secretbox.
|
|
888
|
+
* @param bundle - nonce + ciphertext from {@link encryptBlob}.
|
|
889
|
+
* @param key - matching 32-byte blob key.
|
|
890
|
+
* @returns plaintext bytes, or `null` when the box does not open.
|
|
891
|
+
*/
|
|
892
|
+
function decryptBlob(bundle, key) {
|
|
893
|
+
if (bundle.length < nacl.secretbox.nonceLength + 16) return null;
|
|
894
|
+
const nonce = bundle.slice(0, nacl.secretbox.nonceLength);
|
|
895
|
+
const ciphertext = bundle.slice(nacl.secretbox.nonceLength);
|
|
896
|
+
const decrypted = nacl.secretbox.open(ciphertext, standalone(nonce), standalone(key));
|
|
897
|
+
return decrypted ? new Uint8Array(decrypted) : null;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Session blob key for Happy file attachments.
|
|
901
|
+
* Legacy accounts: `deriveKey(secret, 'Happy Blobs', ['master'])`.
|
|
902
|
+
* DataKey accounts: `deriveKey(dataKey, 'Happy Blobs', ['session'])`.
|
|
903
|
+
* @param ctx - the same session crypto used for JSON envelopes.
|
|
904
|
+
* @returns 32-byte secretbox key.
|
|
905
|
+
*/
|
|
906
|
+
async function deriveBlobKey(ctx) {
|
|
907
|
+
const path = ctx.variant === "dataKey" ? ["session"] : ["master"];
|
|
908
|
+
return deriveKey(ctx.key, "Happy Blobs", path);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* HMAC-SHA512 hierarchical key tree used by Happy CLI `deriveKey`.
|
|
912
|
+
* @param master - root secret.
|
|
913
|
+
* @param usage - domain string such as `Happy Blobs`.
|
|
914
|
+
* @param path - child indexes such as `['session']` or `['master']`.
|
|
915
|
+
* @returns 32-byte derived key.
|
|
916
|
+
*/
|
|
917
|
+
async function deriveKey(master, usage, path) {
|
|
918
|
+
let state = hmacSha512(new TextEncoder().encode(`${usage} Master Seed`), master);
|
|
919
|
+
for (const index of path) {
|
|
920
|
+
const encoded = new TextEncoder().encode(index);
|
|
921
|
+
const data = new Uint8Array(1 + encoded.length);
|
|
922
|
+
data[0] = 0;
|
|
923
|
+
data.set(encoded, 1);
|
|
924
|
+
state = hmacSha512(state.subarray(32), data);
|
|
925
|
+
}
|
|
926
|
+
return state.subarray(0, 32);
|
|
927
|
+
}
|
|
928
|
+
/** Copy a view onto its own ArrayBuffer so tweetnacl accepts it. */
|
|
929
|
+
function standalone(data) {
|
|
930
|
+
if (data.byteOffset === 0 && data.buffer.byteLength === data.length) return data;
|
|
931
|
+
return data.slice();
|
|
932
|
+
}
|
|
933
|
+
function hmacSha512(key, data) {
|
|
934
|
+
return new Uint8Array(createHmac("sha512", key).update(data).digest());
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region lib/types/grant.js
|
|
938
|
+
/** Remote-grant checks: what the phone is allowed to do. */
|
|
939
|
+
const ORDER = [
|
|
940
|
+
"watch",
|
|
941
|
+
"chat",
|
|
942
|
+
"approve",
|
|
943
|
+
"full"
|
|
944
|
+
];
|
|
945
|
+
/**
|
|
946
|
+
* Whether `actual` is at least as deep as `needed`.
|
|
947
|
+
* @param actual - currently selected grant.
|
|
948
|
+
* @param needed - minimum required grant.
|
|
949
|
+
* @returns true when the phone may perform the action.
|
|
950
|
+
*/
|
|
951
|
+
function grantAtLeast(actual, needed) {
|
|
952
|
+
return ORDER.indexOf(actual) >= ORDER.indexOf(needed);
|
|
953
|
+
}
|
|
954
|
+
/** Claude-only permissionMode strings that must not be treated as dsh presets. */
|
|
955
|
+
const CLAUDE_PERMISSION_MODES = /* @__PURE__ */ new Set([
|
|
956
|
+
"default",
|
|
957
|
+
"acceptEdits",
|
|
958
|
+
"bypassPermissions",
|
|
959
|
+
"dontAsk",
|
|
960
|
+
"plan",
|
|
961
|
+
"read-only",
|
|
962
|
+
"safe-yolo",
|
|
963
|
+
"yolo"
|
|
964
|
+
]);
|
|
965
|
+
/**
|
|
966
|
+
* Decide how inbound `meta.permissionMode` maps onto a dsh preset.
|
|
967
|
+
* @param mode - Happy message meta.permissionMode.
|
|
968
|
+
* @param dshPresets - currently advertised preset names.
|
|
969
|
+
* @returns `apply` with the preset, `ignore` for Claude-only values, or `unknown`.
|
|
970
|
+
*/
|
|
971
|
+
function classifyPermissionMode(mode, dshPresets) {
|
|
972
|
+
if (dshPresets.includes(mode)) return {
|
|
973
|
+
kind: "apply",
|
|
974
|
+
preset: mode
|
|
975
|
+
};
|
|
976
|
+
if (CLAUDE_PERMISSION_MODES.has(mode)) return { kind: "ignore" };
|
|
977
|
+
return { kind: "unknown" };
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Split a Happy `meta.model` / spawn `modelMode` code into provider and model.
|
|
981
|
+
* @param code - `provider/model`, Rig `provider:model`, or a bare model id.
|
|
982
|
+
* @returns provider (empty when bare) and model.
|
|
983
|
+
*/
|
|
984
|
+
function splitModelCode(code) {
|
|
985
|
+
const slash = code.indexOf("/");
|
|
986
|
+
if (slash > 0) return {
|
|
987
|
+
provider: code.slice(0, slash),
|
|
988
|
+
model: code.slice(slash + 1)
|
|
989
|
+
};
|
|
990
|
+
const colon = code.indexOf(":");
|
|
991
|
+
if (colon > 0) return {
|
|
992
|
+
provider: code.slice(0, colon),
|
|
993
|
+
model: code.slice(colon + 1)
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
provider: "",
|
|
997
|
+
model: code
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Model code from a Happy inbound message (`meta.model` plus optional provider).
|
|
1002
|
+
* @param meta - Happy message meta object.
|
|
1003
|
+
* @returns `provider/model` when both are known, otherwise the raw model string.
|
|
1004
|
+
*/
|
|
1005
|
+
function messageModelCode(meta) {
|
|
1006
|
+
const model = meta.model;
|
|
1007
|
+
if (typeof model !== "string" || model === "") return void 0;
|
|
1008
|
+
const provider = typeof meta.modelProviderId === "string" ? meta.modelProviderId : "";
|
|
1009
|
+
if (provider !== "" && !model.includes("/") && !model.includes(":")) return `${provider}/${model}`;
|
|
1010
|
+
return model;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Effort from a Happy inbound message. Current App wire uses `effort`;
|
|
1014
|
+
* spawn and older clients send `effortLevel`.
|
|
1015
|
+
* @param meta - Happy message meta object.
|
|
1016
|
+
* @returns effort id, `null` when the field is explicitly cleared, or undefined when omitted.
|
|
1017
|
+
*/
|
|
1018
|
+
function messageEffort(meta) {
|
|
1019
|
+
if ("effort" in meta) {
|
|
1020
|
+
if (meta.effort === null) return null;
|
|
1021
|
+
if (typeof meta.effort === "string") return meta.effort;
|
|
1022
|
+
}
|
|
1023
|
+
if ("effortLevel" in meta) {
|
|
1024
|
+
if (meta.effortLevel === null) return null;
|
|
1025
|
+
if (typeof meta.effortLevel === "string") return meta.effortLevel;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Whether a settings write can land on the live bridge without disposing sockets.
|
|
1030
|
+
* Grant / pairOnStart changes take effect in place; URL or credential-dir
|
|
1031
|
+
* changes restart the relay.
|
|
1032
|
+
* @param previous - config the live bridge is using.
|
|
1033
|
+
* @param next - config just resolved from settings.
|
|
1034
|
+
*/
|
|
1035
|
+
function sameHappyRuntime(previous, next) {
|
|
1036
|
+
return previous.enabled === next.enabled && previous.serverUrl === next.serverUrl && previous.appUrl === next.appUrl && previous.credentialDir === next.credentialDir;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Whether two overrides name the same Host selection.
|
|
1040
|
+
* @param previous - last remembered override, if any.
|
|
1041
|
+
* @param next - candidate override.
|
|
1042
|
+
*/
|
|
1043
|
+
function sameModelOverride(previous, next) {
|
|
1044
|
+
return previous !== void 0 && previous.provider === next.provider && previous.model === next.model && previous.reasoningEffort === next.reasoningEffort;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Model and effort Happy stores on session metadata.
|
|
1048
|
+
* @param meta - decrypted Happy session metadata object.
|
|
1049
|
+
* @returns the current model and/or effort, or undefined when neither is set.
|
|
1050
|
+
*/
|
|
1051
|
+
function catalogModelPick(meta) {
|
|
1052
|
+
const model = catalogModelCode(meta);
|
|
1053
|
+
const effort = catalogEffort(meta);
|
|
1054
|
+
if (model === void 0 && effort === void 0) return void 0;
|
|
1055
|
+
return {
|
|
1056
|
+
...model === void 0 ? {} : { model },
|
|
1057
|
+
...effort === void 0 ? {} : { effort }
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether an inbound Happy catalog is the same pick we last published.
|
|
1062
|
+
* Slash and colon provider/model codes compare as one pair.
|
|
1063
|
+
* @param previous - last pick we wrote, if any.
|
|
1064
|
+
* @param next - pick decoded from inbound metadata.
|
|
1065
|
+
* @returns true when the inbound pick is our own echo.
|
|
1066
|
+
*/
|
|
1067
|
+
function sameCatalogPick(previous, next) {
|
|
1068
|
+
if (previous === void 0) return false;
|
|
1069
|
+
return catalogPickKey(previous) === catalogPickKey(next);
|
|
1070
|
+
}
|
|
1071
|
+
function catalogPickKey(pick) {
|
|
1072
|
+
const split = pick.model === void 0 ? void 0 : splitModelCode(pick.model);
|
|
1073
|
+
return `${split === void 0 ? "" : split.provider === "" ? split.model : `${split.provider}/${split.model}`}\0${pick.effort === void 0 ? "" : pick.effort === null ? "null" : pick.effort}`;
|
|
1074
|
+
}
|
|
1075
|
+
function catalogModelCode(meta) {
|
|
1076
|
+
const mode = meta.modelMode;
|
|
1077
|
+
if (typeof mode === "string" && mode !== "") return mode;
|
|
1078
|
+
const id = meta.currentModelCode;
|
|
1079
|
+
if (typeof id !== "string" || id === "") return void 0;
|
|
1080
|
+
const provider = typeof meta.currentModelProviderId === "string" ? meta.currentModelProviderId : "";
|
|
1081
|
+
if (provider !== "" && !id.includes("/") && !id.includes(":")) return `${provider}/${id}`;
|
|
1082
|
+
return id;
|
|
1083
|
+
}
|
|
1084
|
+
function catalogEffort(meta) {
|
|
1085
|
+
if (meta.effortLevel === null) return null;
|
|
1086
|
+
if (typeof meta.effortLevel === "string") return meta.effortLevel;
|
|
1087
|
+
if (meta.currentThoughtLevelCode === null) return null;
|
|
1088
|
+
if (typeof meta.currentThoughtLevelCode === "string") return meta.currentThoughtLevelCode;
|
|
1089
|
+
const reasoning = meta.reasoning;
|
|
1090
|
+
if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
1091
|
+
const current = reasoning.current;
|
|
1092
|
+
if (current === null) return null;
|
|
1093
|
+
if (typeof current === "string") return current;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region lib/types/history.js
|
|
1098
|
+
/** Fold a session label and replayable chat items from the harness log. */
|
|
1099
|
+
/**
|
|
1100
|
+
* Happy session-list title: logged title, else first human prompt, else the
|
|
1101
|
+
* same "新会话" the web sidebar uses. Never the folder name — that belongs
|
|
1102
|
+
* on Happy's project-group header via `metadata.path`.
|
|
1103
|
+
* @param events - session log.
|
|
1104
|
+
* @returns non-empty label.
|
|
1105
|
+
*/
|
|
1106
|
+
function sessionLabel(events) {
|
|
1107
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1108
|
+
const event = events[index];
|
|
1109
|
+
if (event === void 0 || event.type !== "session/title") continue;
|
|
1110
|
+
const title = asRecord$4(event.data).title;
|
|
1111
|
+
if (typeof title === "string" && title.trim() !== "") return title.trim();
|
|
1112
|
+
}
|
|
1113
|
+
for (const event of events) {
|
|
1114
|
+
if (event.type !== "user/message") continue;
|
|
1115
|
+
const text = visibleUserText(event);
|
|
1116
|
+
if (text === "") continue;
|
|
1117
|
+
const line = text.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1118
|
+
if (line === "") continue;
|
|
1119
|
+
return line.length <= 40 ? line : `${line.slice(0, 39)}…`;
|
|
1120
|
+
}
|
|
1121
|
+
return "新会话";
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Same blank rule as the web sidebar: no `turn/start` yet means a
|
|
1125
|
+
* provisional New Session placeholder, not a conversation.
|
|
1126
|
+
* @param events - session log.
|
|
1127
|
+
* @returns true when the sidebar would hide this row unless it is selected.
|
|
1128
|
+
*/
|
|
1129
|
+
function isBlankSession(events) {
|
|
1130
|
+
return !events.some((event) => event.type === "turn/start");
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Visible user/assistant/tool turns to copy onto an empty Happy session.
|
|
1134
|
+
* Plugin-injected user rows stay off the phone. Assistant chunks are skipped
|
|
1135
|
+
* in favor of the committed assistant/message. Reasoning blocks become a
|
|
1136
|
+
* collapsible Think card; tool-call blocks become Happy-known tool cards.
|
|
1137
|
+
* @param events - session log in seq order.
|
|
1138
|
+
* @returns replay items in log order.
|
|
1139
|
+
*/
|
|
1140
|
+
function historyItems(events) {
|
|
1141
|
+
const items = [];
|
|
1142
|
+
const started = /* @__PURE__ */ new Set();
|
|
1143
|
+
let reasoning = "";
|
|
1144
|
+
for (const event of events) {
|
|
1145
|
+
const time = typeof event.time === "number" ? event.time : 0;
|
|
1146
|
+
if (event.type === "turn/start") {
|
|
1147
|
+
items.push({
|
|
1148
|
+
kind: "turn-start",
|
|
1149
|
+
time
|
|
1150
|
+
});
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (event.type === "assistant/chunk") {
|
|
1154
|
+
reasoning = foldReasoning(reasoning, asRecord$4(asRecord$4(event.data).chunk));
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (event.type === "turn/end") {
|
|
1158
|
+
if (reasoning.trim() !== "") {
|
|
1159
|
+
pushThink(items, time, reasoning.trim());
|
|
1160
|
+
reasoning = "";
|
|
1161
|
+
}
|
|
1162
|
+
const kind = asRecord$4(asRecord$4(event.data).reason).kind;
|
|
1163
|
+
const status = kind === "error" ? "failed" : kind === "aborted" || kind === "interrupted" ? "cancelled" : "completed";
|
|
1164
|
+
items.push({
|
|
1165
|
+
kind: "turn-end",
|
|
1166
|
+
time,
|
|
1167
|
+
status
|
|
1168
|
+
});
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (event.type === "user/message") {
|
|
1172
|
+
const text = visibleUserText(event);
|
|
1173
|
+
const images = visibleUserImages(event);
|
|
1174
|
+
if (text === "" && images.length === 0) continue;
|
|
1175
|
+
items.push({
|
|
1176
|
+
kind: "user",
|
|
1177
|
+
time,
|
|
1178
|
+
text,
|
|
1179
|
+
images
|
|
1180
|
+
});
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (event.type === "assistant/message") {
|
|
1184
|
+
const parts = assistantParts(asRecord$4(asRecord$4(event.data).message).content);
|
|
1185
|
+
if (!parts.some((part) => part.kind === "thinking") && reasoning.trim() !== "") pushThink(items, time, reasoning.trim());
|
|
1186
|
+
reasoning = "";
|
|
1187
|
+
for (const part of parts) {
|
|
1188
|
+
if (part.kind === "thinking") {
|
|
1189
|
+
pushThink(items, time, part.text);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (part.kind === "text") {
|
|
1193
|
+
items.push({
|
|
1194
|
+
kind: "assistant",
|
|
1195
|
+
time,
|
|
1196
|
+
text: part.text
|
|
1197
|
+
});
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
if (started.has(part.call)) continue;
|
|
1201
|
+
started.add(part.call);
|
|
1202
|
+
items.push({
|
|
1203
|
+
kind: "tool-start",
|
|
1204
|
+
time,
|
|
1205
|
+
call: part.call,
|
|
1206
|
+
...happyTool(part.name, part.args)
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (event.type === "tool/call") {
|
|
1212
|
+
const data = asRecord$4(event.data);
|
|
1213
|
+
const call = typeof data.callId === "string" ? data.callId : "";
|
|
1214
|
+
const name = typeof data.name === "string" ? data.name : "tool";
|
|
1215
|
+
if (call === "" || started.has(call)) continue;
|
|
1216
|
+
started.add(call);
|
|
1217
|
+
const args = toolArgs(data.arguments);
|
|
1218
|
+
items.push({
|
|
1219
|
+
kind: "tool-start",
|
|
1220
|
+
time,
|
|
1221
|
+
call,
|
|
1222
|
+
...happyTool(name, args)
|
|
1223
|
+
});
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (event.type === "tool/result") {
|
|
1227
|
+
const call = asRecord$4(asRecord$4(asRecord$4(event.data).message).source).callId;
|
|
1228
|
+
if (typeof call === "string" && call !== "") items.push({
|
|
1229
|
+
kind: "tool-end",
|
|
1230
|
+
time,
|
|
1231
|
+
call
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return items;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Visible human prompt from a `user/message` log event.
|
|
1239
|
+
* Plugin injects and tool-result rows return empty.
|
|
1240
|
+
* @param event - one session log event.
|
|
1241
|
+
* @returns trimmed concatenated text blocks, or `''`.
|
|
1242
|
+
*/
|
|
1243
|
+
function visibleUserText(event) {
|
|
1244
|
+
if (event.type !== "user/message") return "";
|
|
1245
|
+
const data = asRecord$4(event.data);
|
|
1246
|
+
const kind = asRecord$4(data.source).kind;
|
|
1247
|
+
if (kind !== void 0 && kind !== "user") return "";
|
|
1248
|
+
return textBlocks(data.content);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Image blocks from a human `user/message`. Plugin injects stay off the phone.
|
|
1252
|
+
* @param event - one session log event.
|
|
1253
|
+
* @returns attachment refs in content order.
|
|
1254
|
+
*/
|
|
1255
|
+
function visibleUserImages(event) {
|
|
1256
|
+
if (event.type !== "user/message") return [];
|
|
1257
|
+
const data = asRecord$4(event.data);
|
|
1258
|
+
const kind = asRecord$4(data.source).kind;
|
|
1259
|
+
if (kind !== void 0 && kind !== "user") return [];
|
|
1260
|
+
if (!Array.isArray(data.content)) return [];
|
|
1261
|
+
const images = [];
|
|
1262
|
+
for (const block of data.content) {
|
|
1263
|
+
const row = asRecord$4(block);
|
|
1264
|
+
if (row.type !== "image") continue;
|
|
1265
|
+
const attachment = asRecord$4(row.attachment);
|
|
1266
|
+
if (typeof attachment.attachmentId !== "string" || attachment.attachmentId === "") continue;
|
|
1267
|
+
if (typeof attachment.mediaType !== "string" || typeof attachment.bytes !== "number") continue;
|
|
1268
|
+
if (typeof attachment.width !== "number" || typeof attachment.height !== "number") continue;
|
|
1269
|
+
images.push(attachment);
|
|
1270
|
+
}
|
|
1271
|
+
return images;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Walk committed assistant content in log order: reasoning, visible text, tool calls.
|
|
1275
|
+
* @param content - `assistant/message` content array.
|
|
1276
|
+
* @returns Happy-ready parts, skipping empty text.
|
|
1277
|
+
*/
|
|
1278
|
+
function assistantParts(content) {
|
|
1279
|
+
if (!Array.isArray(content)) return [];
|
|
1280
|
+
const parts = [];
|
|
1281
|
+
for (const block of content) {
|
|
1282
|
+
const row = asRecord$4(block);
|
|
1283
|
+
if (row.type === "reasoning" && typeof row.text === "string" && row.text.trim() !== "") {
|
|
1284
|
+
parts.push({
|
|
1285
|
+
kind: "thinking",
|
|
1286
|
+
text: row.text.trim()
|
|
1287
|
+
});
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (row.type === "text" && typeof row.text === "string" && row.text.trim() !== "") {
|
|
1291
|
+
parts.push({
|
|
1292
|
+
kind: "text",
|
|
1293
|
+
text: row.text.trim()
|
|
1294
|
+
});
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
if (row.type !== "tool-call") continue;
|
|
1298
|
+
const call = typeof row.id === "string" ? row.id : typeof row.callId === "string" ? row.callId : "";
|
|
1299
|
+
if (call === "") continue;
|
|
1300
|
+
const name = typeof row.name === "string" && row.name !== "" ? row.name : "tool";
|
|
1301
|
+
parts.push({
|
|
1302
|
+
kind: "tool",
|
|
1303
|
+
call,
|
|
1304
|
+
name,
|
|
1305
|
+
args: toolArgs(row.arguments)
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
return parts;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Happy App hides `thinking: true` text and tools named `think` /
|
|
1312
|
+
* `CodexReasoning` / `GeminiReasoning`. Names starting `mcp__` become a
|
|
1313
|
+
* one-line MCP row with no body. `Note` is unknown to that table, so it
|
|
1314
|
+
* stays a tappable card; full text rides in `args.text`.
|
|
1315
|
+
*/
|
|
1316
|
+
const THINK_TOOL_NAME = "Note";
|
|
1317
|
+
/**
|
|
1318
|
+
* Collapsed Think row label. Happy compact rows show `description`.
|
|
1319
|
+
* @param text - accumulated or committed reasoning.
|
|
1320
|
+
* @returns `Think` or `Think ·` plus the first line.
|
|
1321
|
+
*/
|
|
1322
|
+
function thinkLabel(text) {
|
|
1323
|
+
const first = clipLine(text.trim().split(/\r?\n/, 1)[0] ?? "");
|
|
1324
|
+
return first === "" ? "Think" : `Think · ${first}`;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Think card for a finished reasoning block. Full text rides in `args`
|
|
1328
|
+
* so a tap opens the detail page; the row itself stays one line.
|
|
1329
|
+
* @param text - committed reasoning.
|
|
1330
|
+
*/
|
|
1331
|
+
function thinkCard(text) {
|
|
1332
|
+
const trimmed = text.trim();
|
|
1333
|
+
return {
|
|
1334
|
+
name: THINK_TOOL_NAME,
|
|
1335
|
+
title: "Think",
|
|
1336
|
+
description: thinkLabel(trimmed),
|
|
1337
|
+
args: { text: trimmed }
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
/** dsh wire names Happy's knownTools table actually styles. */
|
|
1341
|
+
const HAPPY_TOOL_NAMES = {
|
|
1342
|
+
grep: "Grep",
|
|
1343
|
+
glob: "Glob",
|
|
1344
|
+
read: "Read",
|
|
1345
|
+
write: "Write",
|
|
1346
|
+
edit: "Edit",
|
|
1347
|
+
bash: "Bash",
|
|
1348
|
+
pwsh: "Bash",
|
|
1349
|
+
web_search: "WebSearch",
|
|
1350
|
+
web_fetch: "WebFetch",
|
|
1351
|
+
todo_write: "TodoWrite"
|
|
1352
|
+
};
|
|
1353
|
+
const FILE_PATH_TOOLS = /* @__PURE__ */ new Set([
|
|
1354
|
+
"read",
|
|
1355
|
+
"write",
|
|
1356
|
+
"edit"
|
|
1357
|
+
]);
|
|
1358
|
+
const TOOL_HEADINGS = {
|
|
1359
|
+
grep: "Grep",
|
|
1360
|
+
glob: "Glob",
|
|
1361
|
+
read: "Read",
|
|
1362
|
+
write: "Write",
|
|
1363
|
+
edit: "Edit",
|
|
1364
|
+
bash: "Bash",
|
|
1365
|
+
pwsh: "Pwsh",
|
|
1366
|
+
web_search: "Search",
|
|
1367
|
+
web_fetch: "Fetch",
|
|
1368
|
+
run_code: "Code"
|
|
1369
|
+
};
|
|
1370
|
+
const TOOL_SUMMARY_KEYS = {
|
|
1371
|
+
grep: ["pattern"],
|
|
1372
|
+
glob: ["pattern"],
|
|
1373
|
+
read: [
|
|
1374
|
+
"path",
|
|
1375
|
+
"file_path",
|
|
1376
|
+
"url"
|
|
1377
|
+
],
|
|
1378
|
+
write: ["path", "file_path"],
|
|
1379
|
+
edit: ["path", "file_path"],
|
|
1380
|
+
bash: ["description", "command"],
|
|
1381
|
+
pwsh: ["description", "command"],
|
|
1382
|
+
web_search: ["query"],
|
|
1383
|
+
web_fetch: ["url"]
|
|
1384
|
+
};
|
|
1385
|
+
/**
|
|
1386
|
+
* Map a dsh tool onto a Happy card. Compact rows only paint `description`,
|
|
1387
|
+
* so that field is `Grep · pattern` (tool name plus the web summary).
|
|
1388
|
+
* `name` stays PascalCase so Happy can still pick icons.
|
|
1389
|
+
* @param name - registered dsh tool name.
|
|
1390
|
+
* @param args - parsed tool arguments.
|
|
1391
|
+
*/
|
|
1392
|
+
function happyTool(name, args) {
|
|
1393
|
+
const heading = TOOL_HEADINGS[name] ?? headingFromName(name);
|
|
1394
|
+
return {
|
|
1395
|
+
name: HAPPY_TOOL_NAMES[name] ?? name,
|
|
1396
|
+
title: heading,
|
|
1397
|
+
description: toolTitle(name, args),
|
|
1398
|
+
args: happyToolArgs(name, args)
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Web-style one-line label used in tests: `Grep · pattern`.
|
|
1403
|
+
* @param name - registered tool name.
|
|
1404
|
+
* @param args - parsed tool arguments.
|
|
1405
|
+
*/
|
|
1406
|
+
function toolTitle(name, args) {
|
|
1407
|
+
const heading = TOOL_HEADINGS[name] ?? headingFromName(name);
|
|
1408
|
+
const summary = toolSummary(name, args);
|
|
1409
|
+
const label = summary === "" || summary === heading ? heading : `${heading} · ${summary}`;
|
|
1410
|
+
return label.length <= 80 ? label : `${label.slice(0, 79)}…`;
|
|
1411
|
+
}
|
|
1412
|
+
function pushThink(items, time, text) {
|
|
1413
|
+
const call = `think-${time}-${items.length}`;
|
|
1414
|
+
items.push({
|
|
1415
|
+
kind: "tool-start",
|
|
1416
|
+
time,
|
|
1417
|
+
call,
|
|
1418
|
+
...thinkCard(text)
|
|
1419
|
+
});
|
|
1420
|
+
items.push({
|
|
1421
|
+
kind: "tool-end",
|
|
1422
|
+
time,
|
|
1423
|
+
call
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
function happyToolArgs(name, args) {
|
|
1427
|
+
const mapped = { ...args };
|
|
1428
|
+
if (FILE_PATH_TOOLS.has(name) && typeof mapped.path === "string" && mapped.file_path === void 0) mapped.file_path = mapped.path;
|
|
1429
|
+
if (name === "web_search" && typeof mapped.query !== "string" && Array.isArray(mapped.queries)) {
|
|
1430
|
+
const query = mapped.queries.find((item) => typeof item === "string" && item.trim() !== "");
|
|
1431
|
+
if (query !== void 0) mapped.query = query;
|
|
1432
|
+
}
|
|
1433
|
+
return mapped;
|
|
1434
|
+
}
|
|
1435
|
+
function headingFromName(name) {
|
|
1436
|
+
const spaced = name.replaceAll("_", " ").trim();
|
|
1437
|
+
if (spaced === "") return "Tool";
|
|
1438
|
+
return spaced.replaceAll(/\b[a-z]/gu, (char) => char.toUpperCase());
|
|
1439
|
+
}
|
|
1440
|
+
function toolSummary(name, args) {
|
|
1441
|
+
if (name === "web_search" && Array.isArray(args.queries)) {
|
|
1442
|
+
const queries = args.queries.filter((query) => typeof query === "string" && query.trim() !== "");
|
|
1443
|
+
if (queries.length > 0) return clipLine(queries.join(", "));
|
|
1444
|
+
}
|
|
1445
|
+
const picked = firstString(args, TOOL_SUMMARY_KEYS[name] ?? [
|
|
1446
|
+
"path",
|
|
1447
|
+
"command",
|
|
1448
|
+
"cmd",
|
|
1449
|
+
"query",
|
|
1450
|
+
"url",
|
|
1451
|
+
"pattern",
|
|
1452
|
+
"file",
|
|
1453
|
+
"target"
|
|
1454
|
+
]);
|
|
1455
|
+
return picked === void 0 ? "" : clipLine(picked);
|
|
1456
|
+
}
|
|
1457
|
+
function clipLine(text) {
|
|
1458
|
+
const line = text.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1459
|
+
return line.length <= 80 ? line : `${line.slice(0, 79)}…`;
|
|
1460
|
+
}
|
|
1461
|
+
function foldReasoning(current, chunk) {
|
|
1462
|
+
if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") return current + chunk.text;
|
|
1463
|
+
if (chunk.type === "block-end") {
|
|
1464
|
+
const block = asRecord$4(chunk.block);
|
|
1465
|
+
if (block.type === "reasoning" && typeof block.text === "string") return block.text;
|
|
1466
|
+
}
|
|
1467
|
+
return current;
|
|
1468
|
+
}
|
|
1469
|
+
function firstString(args, keys) {
|
|
1470
|
+
for (const key of keys) {
|
|
1471
|
+
const value = args[key];
|
|
1472
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
function toolArgs(value) {
|
|
1476
|
+
if (typeof value === "string") try {
|
|
1477
|
+
return JSON.parse(value);
|
|
1478
|
+
} catch {
|
|
1479
|
+
return { raw: value };
|
|
1480
|
+
}
|
|
1481
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
1482
|
+
return {};
|
|
1483
|
+
}
|
|
1484
|
+
function textBlocks(content) {
|
|
1485
|
+
if (!Array.isArray(content)) return "";
|
|
1486
|
+
const parts = [];
|
|
1487
|
+
for (const block of content) {
|
|
1488
|
+
const row = asRecord$4(block);
|
|
1489
|
+
if (row.type === "text" && typeof row.text === "string") parts.push(row.text);
|
|
1490
|
+
}
|
|
1491
|
+
return parts.join("").trim();
|
|
1492
|
+
}
|
|
1493
|
+
function asRecord$4(value) {
|
|
1494
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
1495
|
+
return {};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Preset the session actually runs: last `agent-preset/selected`, else the
|
|
1499
|
+
* creation-header value. Phone wake must mount this same composition, not the
|
|
1500
|
+
* header alone — a blank session may have switched before its first turn.
|
|
1501
|
+
* @param events - session log, oldest first.
|
|
1502
|
+
* @param headerAgentPreset - `header.agentPreset` from inspect.
|
|
1503
|
+
*/
|
|
1504
|
+
function resolveSessionPreset(events, headerAgentPreset) {
|
|
1505
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1506
|
+
const event = events[index];
|
|
1507
|
+
if (event === void 0 || event.type !== "agent-preset/selected") continue;
|
|
1508
|
+
const id = asRecord$4(event.data).agentPreset;
|
|
1509
|
+
if (typeof id === "string" && id !== "") return id;
|
|
1510
|
+
}
|
|
1511
|
+
return headerAgentPreset;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Same precedence Host `selectionFor` uses: first usable provider/model
|
|
1515
|
+
* wins; a later candidate may only fill a missing thinking level when it is
|
|
1516
|
+
* that same model. Empty provider/model pairs are skipped.
|
|
1517
|
+
* @param primary - process pick, else `session.requestHeader()?.config`.
|
|
1518
|
+
* @param fallbacks - remaining sources, usually the log then `agentDefaultModel`.
|
|
1519
|
+
*/
|
|
1520
|
+
function wakeModelSelection(primary, ...fallbacks) {
|
|
1521
|
+
const candidates = [primary, ...fallbacks].filter((row) => row !== void 0 && row.provider !== "" && row.model !== "");
|
|
1522
|
+
const first = candidates[0];
|
|
1523
|
+
if (first === void 0) return void 0;
|
|
1524
|
+
const reasoningEffort = first.reasoningEffort ?? candidates.find((row) => row.provider === first.provider && row.model === first.model && row.reasoningEffort !== void 0)?.reasoningEffort;
|
|
1525
|
+
return {
|
|
1526
|
+
provider: first.provider,
|
|
1527
|
+
model: first.model,
|
|
1528
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Effort the first phone-spawn / phone-wake request should send.
|
|
1533
|
+
* Current pick wins; otherwise a preferred (web) value or the model's
|
|
1534
|
+
* advertised default, but only when the model lists that id.
|
|
1535
|
+
* @param currentEffort - already chosen effort, if any.
|
|
1536
|
+
* @param preferred - web picker effort to reuse when the model accepts it.
|
|
1537
|
+
* @param modelDefault - `resolveModelInfo().reasoning.defaultEffort`.
|
|
1538
|
+
* @param supported - advertised effort ids; empty/absent means any string is accepted.
|
|
1539
|
+
*/
|
|
1540
|
+
function pinWakeEffort(currentEffort, preferred, modelDefault, supported) {
|
|
1541
|
+
const allowed = supported === void 0 || supported.length === 0 ? void 0 : new Set(supported);
|
|
1542
|
+
const pick = (value) => {
|
|
1543
|
+
if (value === void 0 || value === "") return void 0;
|
|
1544
|
+
if (allowed !== void 0 && !allowed.has(value)) return void 0;
|
|
1545
|
+
return value;
|
|
1546
|
+
};
|
|
1547
|
+
return pick(currentEffort) ?? pick(preferred) ?? pick(modelDefault) ?? supported?.find((id) => id !== "off");
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Sidebar sessions that should stay linked on the phone: every workspace
|
|
1551
|
+
* membership except the registry-global archive set.
|
|
1552
|
+
* @param workspaces - `workspaceRegistry.list()` projections.
|
|
1553
|
+
* @param archived - `workspaceRegistry.archivedSessionIds`.
|
|
1554
|
+
*/
|
|
1555
|
+
function unarchivedSessionIds(workspaces, archived) {
|
|
1556
|
+
const hidden = new Set(archived);
|
|
1557
|
+
const out = [];
|
|
1558
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1559
|
+
for (const workspace of workspaces) for (const id of workspace.sessionIds) {
|
|
1560
|
+
if (hidden.has(id) || seen.has(id)) continue;
|
|
1561
|
+
seen.add(id);
|
|
1562
|
+
out.push(id);
|
|
1563
|
+
}
|
|
1564
|
+
return out;
|
|
1565
|
+
}
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region lib/types/inbound.js
|
|
1568
|
+
/** Classify inbound Happy user text as a registered slash command or ordinary chat. */
|
|
1569
|
+
/**
|
|
1570
|
+
* Read a user text or file event from a decrypted Happy payload.
|
|
1571
|
+
* The App wraps file events as `{ role: 'session', content: { type: 'session', data: { ev } } }`.
|
|
1572
|
+
* happy-wire envelopes put `ev` directly on `content`. Chat text from the phone
|
|
1573
|
+
* is still `{ role: 'user', content: { type: 'text' } }`.
|
|
1574
|
+
* @param plain - decrypted socket payload.
|
|
1575
|
+
* @returns inbound chat or file, or `undefined` when the payload is not user input.
|
|
1576
|
+
*/
|
|
1577
|
+
function parseHappyInbound(plain) {
|
|
1578
|
+
const record = asUnknownRecord(plain);
|
|
1579
|
+
const meta = asUnknownRecord(record.meta);
|
|
1580
|
+
const ev = userSessionEvent(record);
|
|
1581
|
+
if (ev !== void 0) {
|
|
1582
|
+
if (ev.t === "text" && typeof ev.text === "string") return {
|
|
1583
|
+
kind: "text",
|
|
1584
|
+
text: ev.text,
|
|
1585
|
+
meta
|
|
1586
|
+
};
|
|
1587
|
+
if (ev.t === "file" && typeof ev.ref === "string") return {
|
|
1588
|
+
kind: "file",
|
|
1589
|
+
ref: ev.ref,
|
|
1590
|
+
name: typeof ev.name === "string" ? ev.name : "file",
|
|
1591
|
+
meta,
|
|
1592
|
+
...typeof ev.mimeType === "string" ? { mimeType: ev.mimeType } : {}
|
|
1593
|
+
};
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (record.role === "user") {
|
|
1597
|
+
const content = asUnknownRecord(record.content);
|
|
1598
|
+
if (content.type === "text" && typeof content.text === "string") return {
|
|
1599
|
+
kind: "text",
|
|
1600
|
+
text: content.text,
|
|
1601
|
+
meta
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Unwrap a user-role session event from either Happy App raw records or happy-wire envelopes.
|
|
1607
|
+
* @param record - decrypted root object.
|
|
1608
|
+
* @returns the `ev` object, or `undefined`.
|
|
1609
|
+
*/
|
|
1610
|
+
function userSessionEvent(record) {
|
|
1611
|
+
if (record.role !== "session") return void 0;
|
|
1612
|
+
const content = asUnknownRecord(record.content);
|
|
1613
|
+
if (content.role === "user") return asUnknownRecord(content.ev);
|
|
1614
|
+
const data = asUnknownRecord(content.data);
|
|
1615
|
+
if (content.type === "session" && data.role === "user") return asUnknownRecord(data.ev);
|
|
1616
|
+
}
|
|
1617
|
+
const COMMAND_LINE = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u;
|
|
1618
|
+
/**
|
|
1619
|
+
* Parse a candidate slash line the same way `dsh-commands` `parseCommand` does.
|
|
1620
|
+
* @param line - complete user text.
|
|
1621
|
+
* @returns name + rawInput, or `undefined` when the line is not a command.
|
|
1622
|
+
*/
|
|
1623
|
+
function parseSlashLine(line) {
|
|
1624
|
+
const match = COMMAND_LINE.exec(line);
|
|
1625
|
+
if (match === null) return void 0;
|
|
1626
|
+
const name = match[1];
|
|
1627
|
+
if (name === void 0) return void 0;
|
|
1628
|
+
return {
|
|
1629
|
+
name,
|
|
1630
|
+
rawInput: line.slice(match[0].length)
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Decide whether inbound phone text should run as a command or as followup.
|
|
1635
|
+
* Unknown `/name` stays chat so user-invocable skills still inject.
|
|
1636
|
+
* @param line - complete user text.
|
|
1637
|
+
* @param commandNames - registered command names without the leading slash.
|
|
1638
|
+
* @returns `command` when the whole line is a registered command, otherwise `chat`.
|
|
1639
|
+
*/
|
|
1640
|
+
function classifyInboundText(line, commandNames) {
|
|
1641
|
+
const parsed = parseSlashLine(line.trim());
|
|
1642
|
+
if (parsed === void 0) return "chat";
|
|
1643
|
+
return commandNames.has(parsed.name) ? "command" : "chat";
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Translate Happy AskUserQuestion `answers` (`{ [question text]: "a, b" }`)
|
|
1647
|
+
* back into harness `{ id, selected }` rows.
|
|
1648
|
+
* @param answers - permission RPC `updatedInput.answers`.
|
|
1649
|
+
* @param questions - original harness questions in order.
|
|
1650
|
+
* @returns selected labels keyed by question id.
|
|
1651
|
+
*/
|
|
1652
|
+
function answersFromHappy(answers, questions) {
|
|
1653
|
+
if (answers === void 0) return questions.map((question) => ({
|
|
1654
|
+
id: question.id,
|
|
1655
|
+
selected: []
|
|
1656
|
+
}));
|
|
1657
|
+
return questions.map((question) => {
|
|
1658
|
+
const selected = (answers[question.question] ?? answers[question.id] ?? "").split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
1659
|
+
return {
|
|
1660
|
+
id: question.id,
|
|
1661
|
+
selected
|
|
1662
|
+
};
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
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
|
+
* The option label that declines a plan-review question.
|
|
1680
|
+
* @param question - first question of a plan-review `ask`.
|
|
1681
|
+
* @returns the non-approve option label, or Keep planning.
|
|
1682
|
+
*/
|
|
1683
|
+
function planReviewDeclineLabel(question) {
|
|
1684
|
+
const approve = question.intent?.kind === "plan-review" ? question.intent.approve : void 0;
|
|
1685
|
+
return question.options?.find((option) => option.label !== approve)?.label ?? "Keep planning";
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Decode a Happy `permission` RPC body.
|
|
1689
|
+
* @param params - decrypted RPC params.
|
|
1690
|
+
* @returns id, approved flag, optional Always-allow decision, and answers.
|
|
1691
|
+
*/
|
|
1692
|
+
function parsePermissionRpc(params) {
|
|
1693
|
+
const record = asUnknownRecord(params);
|
|
1694
|
+
const result = {
|
|
1695
|
+
id: typeof record.id === "string" ? record.id : "",
|
|
1696
|
+
approved: record.approved === true,
|
|
1697
|
+
...typeof record.decision === "string" && record.decision !== "" ? { decision: record.decision } : {}
|
|
1698
|
+
};
|
|
1699
|
+
if (!isUnknownRecord(record.updatedInput)) return result;
|
|
1700
|
+
const answers = record.updatedInput["answers"];
|
|
1701
|
+
if (!isUnknownRecord(answers)) {
|
|
1702
|
+
result.updatedInput = {};
|
|
1703
|
+
return result;
|
|
1704
|
+
}
|
|
1705
|
+
const mapped = {};
|
|
1706
|
+
for (const [key, value] of Object.entries(answers)) if (typeof value === "string") mapped[key] = value;
|
|
1707
|
+
result.updatedInput = { answers: mapped };
|
|
1708
|
+
return result;
|
|
1709
|
+
}
|
|
1710
|
+
function asUnknownRecord(value) {
|
|
1711
|
+
return isUnknownRecord(value) ? value : {};
|
|
1712
|
+
}
|
|
1713
|
+
function isUnknownRecord(value) {
|
|
1714
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1715
|
+
}
|
|
1716
|
+
//#endregion
|
|
1717
|
+
//#region lib/types/http.js
|
|
1718
|
+
/** Happy HTTP helpers: auth, sessions, machines, attachments. */
|
|
1719
|
+
/**
|
|
1720
|
+
* JSON POST/GET against the Happy API with the CLI client header.
|
|
1721
|
+
* @param serverUrl - API origin.
|
|
1722
|
+
* @param path - path beginning with `/`.
|
|
1723
|
+
* @param init - method, token, JSON body.
|
|
1724
|
+
* @returns parsed JSON, or throws with status text.
|
|
1725
|
+
*/
|
|
1726
|
+
async function happyFetch(serverUrl, path, init) {
|
|
1727
|
+
const headers = {
|
|
1728
|
+
"Content-Type": "application/json",
|
|
1729
|
+
"X-Happy-Client": HAPPY_CLIENT
|
|
1730
|
+
};
|
|
1731
|
+
if (init.token !== void 0) headers.Authorization = `Bearer ${init.token}`;
|
|
1732
|
+
const response = await fetch(`${trimSlash$1(serverUrl)}${path}`, {
|
|
1733
|
+
method: init.method ?? "GET",
|
|
1734
|
+
headers,
|
|
1735
|
+
...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
|
|
1736
|
+
});
|
|
1737
|
+
const text = await response.text();
|
|
1738
|
+
let json = void 0;
|
|
1739
|
+
if (text !== "") try {
|
|
1740
|
+
json = JSON.parse(text);
|
|
1741
|
+
} catch {
|
|
1742
|
+
json = { raw: text };
|
|
1743
|
+
}
|
|
1744
|
+
if (!response.ok) throw new Error(`Happy HTTP ${response.status} ${path}: ${text.slice(0, 300)}`);
|
|
1745
|
+
return json;
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Create or load a Happy session by tag.
|
|
1749
|
+
* @param serverUrl - API origin.
|
|
1750
|
+
* @param token - bearer token.
|
|
1751
|
+
* @param tag - stable tag such as `dsh:<sessionId>`.
|
|
1752
|
+
* @param crypto - content encryption for this session.
|
|
1753
|
+
* @param metadata - plaintext metadata.
|
|
1754
|
+
* @param agentState - plaintext agent state.
|
|
1755
|
+
* @param dataEncryptionKey - wrapped DEK bytes when using dataKey.
|
|
1756
|
+
* @returns Happy session id and versions.
|
|
1757
|
+
*/
|
|
1758
|
+
async function createOrLoadSession(input) {
|
|
1759
|
+
const session = asRecord$3(asRecord$3(await happyFetch(input.serverUrl, "/v1/sessions", {
|
|
1760
|
+
method: "POST",
|
|
1761
|
+
token: input.token,
|
|
1762
|
+
body: {
|
|
1763
|
+
tag: input.tag,
|
|
1764
|
+
metadata: encryptB64(input.crypto, input.metadata),
|
|
1765
|
+
agentState: input.agentState === null || input.agentState === void 0 ? null : encryptB64(input.crypto, input.agentState),
|
|
1766
|
+
dataEncryptionKey: input.dataEncryptionKey === void 0 ? null : encodeBase64(input.dataEncryptionKey)
|
|
1767
|
+
}
|
|
1768
|
+
})).session);
|
|
1769
|
+
const id = session.id;
|
|
1770
|
+
if (typeof id !== "string" || id === "") throw new Error("Happy 创建会话没有返回 id");
|
|
1771
|
+
return {
|
|
1772
|
+
id,
|
|
1773
|
+
seq: numberOr(session.seq, 0),
|
|
1774
|
+
metadataVersion: numberOr(session.metadataVersion, 0),
|
|
1775
|
+
agentStateVersion: numberOr(session.agentStateVersion, 0)
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Register or update the machine entity so the App can spawn onto this Host.
|
|
1780
|
+
* @param input - machine id, encrypted metadata, optional daemon state.
|
|
1781
|
+
* @returns versions from the server.
|
|
1782
|
+
*/
|
|
1783
|
+
async function createOrLoadMachine(input) {
|
|
1784
|
+
const machine = asRecord$3(asRecord$3(await happyFetch(input.serverUrl, "/v1/machines", {
|
|
1785
|
+
method: "POST",
|
|
1786
|
+
token: input.token,
|
|
1787
|
+
body: {
|
|
1788
|
+
id: input.machineId,
|
|
1789
|
+
metadata: encryptB64(input.crypto, input.metadata),
|
|
1790
|
+
daemonState: encryptB64(input.crypto, input.daemonState),
|
|
1791
|
+
...input.dataEncryptionKey === void 0 ? {} : { dataEncryptionKey: encodeBase64(input.dataEncryptionKey) }
|
|
1792
|
+
}
|
|
1793
|
+
})).machine);
|
|
1794
|
+
return {
|
|
1795
|
+
metadataVersion: numberOr(machine.metadataVersion, 0),
|
|
1796
|
+
daemonStateVersion: numberOr(machine.daemonStateVersion, 0)
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Upload an already-encrypted attachment the way Happy CLI `uploadLocalImageAttachmentEnvelope` does:
|
|
1801
|
+
* POST `/v1/sessions/:id/attachments/request-upload` `{ filename, size }` → `{ ref, uploadUrl, method }`
|
|
1802
|
+
* then PUT octet-stream or POST multipart to `uploadUrl`.
|
|
1803
|
+
* Presigned URLs must not receive extra headers; server-local URLs need Bearer.
|
|
1804
|
+
* @param serverUrl - API origin.
|
|
1805
|
+
* @param token - bearer token.
|
|
1806
|
+
* @param sessionId - Happy session id that owns the blob.
|
|
1807
|
+
* @param filename - display name sent to request-upload.
|
|
1808
|
+
* @param encrypted - nonce+ciphertext from `encryptBlob`.
|
|
1809
|
+
* @returns Happy `ref` to put on a user `file` event.
|
|
1810
|
+
*/
|
|
1811
|
+
async function uploadEncryptedAttachment(serverUrl, token, sessionId, filename, encrypted) {
|
|
1812
|
+
const origin = trimSlash$1(serverUrl);
|
|
1813
|
+
const upload = asRecord$3(await happyFetch(origin, `/v1/sessions/${encodeURIComponent(sessionId)}/attachments/request-upload`, {
|
|
1814
|
+
method: "POST",
|
|
1815
|
+
token,
|
|
1816
|
+
body: {
|
|
1817
|
+
filename,
|
|
1818
|
+
size: encrypted.length
|
|
1819
|
+
}
|
|
1820
|
+
}));
|
|
1821
|
+
const ref = upload.ref;
|
|
1822
|
+
const uploadUrl = upload.uploadUrl;
|
|
1823
|
+
if (typeof ref !== "string" || ref === "" || typeof uploadUrl !== "string" || uploadUrl === "") throw new Error("Happy 附件上传没有返回 uploadUrl");
|
|
1824
|
+
if (upload.method === "POST") {
|
|
1825
|
+
const { body, boundary } = buildMultipartUploadBody(stringRecord(upload.formFields), encrypted);
|
|
1826
|
+
const response = await fetch(uploadUrl, {
|
|
1827
|
+
method: "POST",
|
|
1828
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
1829
|
+
body: new Blob([copyBytes(body)])
|
|
1830
|
+
});
|
|
1831
|
+
if (!response.ok) throw new Error(`Happy 附件上传失败 ${String(response.status)}`);
|
|
1832
|
+
return ref;
|
|
1833
|
+
}
|
|
1834
|
+
const headers = { "Content-Type": "application/octet-stream" };
|
|
1835
|
+
if (uploadUrl.startsWith(origin)) headers.Authorization = `Bearer ${token}`;
|
|
1836
|
+
const response = await fetch(uploadUrl, {
|
|
1837
|
+
method: "PUT",
|
|
1838
|
+
headers,
|
|
1839
|
+
body: new Blob([copyBytes(encrypted)])
|
|
1840
|
+
});
|
|
1841
|
+
if (!response.ok) throw new Error(`Happy 附件上传失败 ${String(response.status)}`);
|
|
1842
|
+
return ref;
|
|
1843
|
+
}
|
|
1844
|
+
function stringRecord(value) {
|
|
1845
|
+
const record = asRecord$3(value);
|
|
1846
|
+
const out = {};
|
|
1847
|
+
for (const [key, field] of Object.entries(record)) if (typeof field === "string") out[key] = field;
|
|
1848
|
+
return out;
|
|
1849
|
+
}
|
|
1850
|
+
function escapeMultipartValue(value) {
|
|
1851
|
+
return value.replaceAll("\r", "").replaceAll("\n", "").replaceAll("\"", "%22");
|
|
1852
|
+
}
|
|
1853
|
+
function buildMultipartUploadBody(fields, data) {
|
|
1854
|
+
const boundary = `----happy-bridge-${crypto.randomUUID()}`;
|
|
1855
|
+
const chunks = [];
|
|
1856
|
+
for (const [key, value] of Object.entries(fields)) chunks.push(new TextEncoder().encode(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartValue(key)}"\r\n\r\n${value}\r\n`));
|
|
1857
|
+
chunks.push(new TextEncoder().encode(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="blob"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
|
1858
|
+
chunks.push(data);
|
|
1859
|
+
chunks.push(new TextEncoder().encode(`\r\n--${boundary}--\r\n`));
|
|
1860
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
1861
|
+
const body = new Uint8Array(total);
|
|
1862
|
+
let offset = 0;
|
|
1863
|
+
for (const chunk of chunks) {
|
|
1864
|
+
body.set(chunk, offset);
|
|
1865
|
+
offset += chunk.byteLength;
|
|
1866
|
+
}
|
|
1867
|
+
return {
|
|
1868
|
+
body,
|
|
1869
|
+
boundary
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Download an encrypted attachment blob the way Happy CLI does:
|
|
1874
|
+
* POST `/v1/sessions/:id/attachments/request-download` → `{ downloadUrl }` → GET bytes.
|
|
1875
|
+
* S3 presigned URLs must not receive extra headers; server-local URLs need Bearer.
|
|
1876
|
+
* @param serverUrl - API origin.
|
|
1877
|
+
* @param token - bearer token.
|
|
1878
|
+
* @param sessionId - Happy session id that owns the blob.
|
|
1879
|
+
* @param ref - file event `ref`.
|
|
1880
|
+
* @returns encrypted nonce+ciphertext bytes.
|
|
1881
|
+
*/
|
|
1882
|
+
async function downloadEncryptedAttachment(serverUrl, token, sessionId, ref) {
|
|
1883
|
+
const origin = trimSlash$1(serverUrl);
|
|
1884
|
+
const downloadUrl = asRecord$3(await happyFetch(origin, `/v1/sessions/${encodeURIComponent(sessionId)}/attachments/request-download`, {
|
|
1885
|
+
method: "POST",
|
|
1886
|
+
token,
|
|
1887
|
+
body: { ref }
|
|
1888
|
+
})).downloadUrl;
|
|
1889
|
+
if (typeof downloadUrl !== "string" || downloadUrl === "") throw new Error("Happy 附件下载没有返回 downloadUrl");
|
|
1890
|
+
const headers = {};
|
|
1891
|
+
if (downloadUrl.startsWith(origin)) headers.Authorization = `Bearer ${token}`;
|
|
1892
|
+
const response = await fetch(downloadUrl, { headers });
|
|
1893
|
+
if (!response.ok) throw new Error(`Happy 附件下载失败 ${String(response.status)}`);
|
|
1894
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Mark a Happy session inactive without deleting it. Accidental archive of a
|
|
1898
|
+
* real conversation can still receive a later phone send.
|
|
1899
|
+
* @param serverUrl - API origin.
|
|
1900
|
+
* @param token - bearer token.
|
|
1901
|
+
* @param sessionId - Happy session id.
|
|
1902
|
+
*/
|
|
1903
|
+
async function archiveHappySession(serverUrl, token, sessionId) {
|
|
1904
|
+
try {
|
|
1905
|
+
await happyFetch(serverUrl, `/v1/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
|
1906
|
+
method: "POST",
|
|
1907
|
+
token
|
|
1908
|
+
});
|
|
1909
|
+
} catch {}
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Remove a Happy cloud session so the App can drop it from the list.
|
|
1913
|
+
* Already-gone ids are ignored.
|
|
1914
|
+
* @param serverUrl - API origin.
|
|
1915
|
+
* @param token - bearer token.
|
|
1916
|
+
* @param sessionId - Happy session id.
|
|
1917
|
+
*/
|
|
1918
|
+
async function deleteHappySession(serverUrl, token, sessionId) {
|
|
1919
|
+
await archiveHappySession(serverUrl, token, sessionId);
|
|
1920
|
+
try {
|
|
1921
|
+
await happyFetch(serverUrl, `/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
1922
|
+
method: "DELETE",
|
|
1923
|
+
token
|
|
1924
|
+
});
|
|
1925
|
+
} catch {}
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Happy cloud sessions this token can see. Used to drop blank ghosts the
|
|
1929
|
+
* App can only archive, not delete.
|
|
1930
|
+
* @param serverUrl - API origin.
|
|
1931
|
+
* @param token - bearer token.
|
|
1932
|
+
* @returns id and optional tag; empty when the list endpoint is unavailable.
|
|
1933
|
+
*/
|
|
1934
|
+
async function listHappySessions(serverUrl, token) {
|
|
1935
|
+
try {
|
|
1936
|
+
return sessionRows(await happyFetch(serverUrl, "/v1/sessions", { token }));
|
|
1937
|
+
} catch {
|
|
1938
|
+
return [];
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
function sessionRows(json) {
|
|
1942
|
+
const root = asRecord$3(json);
|
|
1943
|
+
const list = Array.isArray(json) ? json : Array.isArray(root.sessions) ? root.sessions : Array.isArray(root.items) ? root.items : [];
|
|
1944
|
+
const out = [];
|
|
1945
|
+
for (const row of list) {
|
|
1946
|
+
const record = asRecord$3(row);
|
|
1947
|
+
const nested = asRecord$3(record.session);
|
|
1948
|
+
const id = typeof record.id === "string" && record.id !== "" ? record.id : typeof nested.id === "string" ? nested.id : "";
|
|
1949
|
+
if (id === "") continue;
|
|
1950
|
+
const tag = typeof record.tag === "string" ? record.tag : typeof nested.tag === "string" ? nested.tag : "";
|
|
1951
|
+
out.push({
|
|
1952
|
+
id,
|
|
1953
|
+
tag
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
return out;
|
|
1957
|
+
}
|
|
1958
|
+
function trimSlash$1(url) {
|
|
1959
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
1960
|
+
}
|
|
1961
|
+
function asRecord$3(value) {
|
|
1962
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
1963
|
+
return {};
|
|
1964
|
+
}
|
|
1965
|
+
function copyBytes(data) {
|
|
1966
|
+
const copy = new Uint8Array(data.byteLength);
|
|
1967
|
+
copy.set(data);
|
|
1968
|
+
return copy;
|
|
1969
|
+
}
|
|
1970
|
+
function numberOr(value, fallback) {
|
|
1971
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region lib/types/rpc.js
|
|
1975
|
+
/** Prefixed Happy RPC: encrypt params/results, wait for `rpc-registered`. */
|
|
1976
|
+
/**
|
|
1977
|
+
* Register `{prefix}:{method}` and wait for the server ack (with timeout).
|
|
1978
|
+
* @param socket - connected Socket.IO socket.
|
|
1979
|
+
* @param prefix - machineId or sessionId.
|
|
1980
|
+
* @param method - bare method name.
|
|
1981
|
+
* @param crypto - same variant as chat.
|
|
1982
|
+
* @param handler - decrypted params in, plaintext result out.
|
|
1983
|
+
* @param log - warning logger.
|
|
1984
|
+
*/
|
|
1985
|
+
async function registerRpc(socket, prefix, method, crypto, handler, log) {
|
|
1986
|
+
const prefixed = `${prefix}:${method}`;
|
|
1987
|
+
socket.on("rpc-request", async (data, callback) => {
|
|
1988
|
+
if (data.method !== prefixed) return;
|
|
1989
|
+
try {
|
|
1990
|
+
callback(encryptB64(crypto, await handler(typeof data.params === "string" ? decryptB64(crypto, data.params) : data.params)));
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1993
|
+
log(`RPC ${prefixed} 失败:${message}`);
|
|
1994
|
+
callback(encryptB64(crypto, { error: message }));
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
await waitRegistered(socket, prefixed, log);
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Re-emit `rpc-register` after a Socket.IO reconnect without adding another handler.
|
|
2001
|
+
* @param socket - connected socket.
|
|
2002
|
+
* @param method - already-prefixed `{id}:{name}` method.
|
|
2003
|
+
*/
|
|
2004
|
+
function requestRpcRegister(socket, method) {
|
|
2005
|
+
socket.emit("rpc-register", { method });
|
|
2006
|
+
}
|
|
2007
|
+
async function waitRegistered(socket, method, log) {
|
|
2008
|
+
await new Promise((resolve) => {
|
|
2009
|
+
const timer = setTimeout(() => {
|
|
2010
|
+
socket.off("rpc-registered", onRegistered);
|
|
2011
|
+
log(`等待 rpc-registered(${method})超时,继续运行`);
|
|
2012
|
+
resolve();
|
|
2013
|
+
}, 8e3);
|
|
2014
|
+
const onRegistered = (data) => {
|
|
2015
|
+
if (data.method !== method) return;
|
|
2016
|
+
clearTimeout(timer);
|
|
2017
|
+
socket.off("rpc-registered", onRegistered);
|
|
2018
|
+
resolve();
|
|
2019
|
+
};
|
|
2020
|
+
socket.on("rpc-registered", onRegistered);
|
|
2021
|
+
socket.emit("rpc-register", { method });
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
//#endregion
|
|
2025
|
+
//#region lib/types/machine.js
|
|
2026
|
+
/** Machine-scoped Happy socket: spawn, stop-session, slim listDirectory. */
|
|
2027
|
+
/**
|
|
2028
|
+
* Machine-scoped connection so the App New button reaches this Host.
|
|
2029
|
+
*/
|
|
2030
|
+
var HappyMachineSocket = class {
|
|
2031
|
+
machineId;
|
|
2032
|
+
token;
|
|
2033
|
+
serverUrl;
|
|
2034
|
+
crypto;
|
|
2035
|
+
handlers;
|
|
2036
|
+
socket;
|
|
2037
|
+
aliveTimer;
|
|
2038
|
+
rpcMethods = [];
|
|
2039
|
+
/**
|
|
2040
|
+
* @param machineId - stable machine id.
|
|
2041
|
+
* @param token - bearer token.
|
|
2042
|
+
* @param serverUrl - API origin.
|
|
2043
|
+
* @param crypto - machine encryption.
|
|
2044
|
+
* @param handlers - spawn / resume / list / stop.
|
|
2045
|
+
*/
|
|
2046
|
+
constructor(machineId, token, serverUrl, crypto, handlers) {
|
|
2047
|
+
this.machineId = machineId;
|
|
2048
|
+
this.token = token;
|
|
2049
|
+
this.serverUrl = serverUrl;
|
|
2050
|
+
this.crypto = crypto;
|
|
2051
|
+
this.handlers = handlers;
|
|
2052
|
+
}
|
|
2053
|
+
/** Connect and register prefixed RPCs. Does not register bash/writeFile. */
|
|
2054
|
+
async connect() {
|
|
2055
|
+
const socket = io(this.serverUrl, {
|
|
2056
|
+
auth: {
|
|
2057
|
+
token: this.token,
|
|
2058
|
+
clientType: "machine-scoped",
|
|
2059
|
+
machineId: this.machineId,
|
|
2060
|
+
happyClient: HAPPY_CLIENT
|
|
2061
|
+
},
|
|
2062
|
+
path: "/v1/updates",
|
|
2063
|
+
transports: ["websocket", "polling"],
|
|
2064
|
+
reconnection: true,
|
|
2065
|
+
reconnectionDelay: 1e3,
|
|
2066
|
+
reconnectionDelayMax: 5e3,
|
|
2067
|
+
timeout: 2e4,
|
|
2068
|
+
withCredentials: true
|
|
2069
|
+
});
|
|
2070
|
+
this.socket = socket;
|
|
2071
|
+
socket.on("connect_error", (error) => {
|
|
2072
|
+
this.handlers.log(`机器通道错误:${error.message}`);
|
|
2073
|
+
});
|
|
2074
|
+
socket.on("connect", () => {
|
|
2075
|
+
this.sendAlive();
|
|
2076
|
+
for (const method of this.rpcMethods) requestRpcRegister(socket, method);
|
|
2077
|
+
});
|
|
2078
|
+
try {
|
|
2079
|
+
await waitConnect$1(socket);
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
this.handlers.log(`${error instanceof Error ? error.message : String(error)},机器通道继续自动重连`);
|
|
2082
|
+
}
|
|
2083
|
+
await this.bindRpc(socket, "spawn-happy-session", async (params) => {
|
|
2084
|
+
const options = asSpawn(params);
|
|
2085
|
+
return this.handlers.spawn(options);
|
|
2086
|
+
});
|
|
2087
|
+
await this.bindRpc(socket, "resume-happy-session", async (params) => {
|
|
2088
|
+
const id = asRecord$2(params).sessionId;
|
|
2089
|
+
if (typeof id !== "string" || id === "") return {
|
|
2090
|
+
type: "error",
|
|
2091
|
+
errorMessage: "Session ID is required"
|
|
2092
|
+
};
|
|
2093
|
+
return this.handlers.resume(id);
|
|
2094
|
+
});
|
|
2095
|
+
await this.bindRpc(socket, "stop-session", (params) => {
|
|
2096
|
+
const id = asRecord$2(params).sessionId;
|
|
2097
|
+
if (typeof id !== "string" || id === "") throw new Error("Session ID is required");
|
|
2098
|
+
this.handlers.stopSession(id);
|
|
2099
|
+
return {
|
|
2100
|
+
success: true,
|
|
2101
|
+
message: "Session stopped"
|
|
2102
|
+
};
|
|
2103
|
+
});
|
|
2104
|
+
await this.bindRpc(socket, "listDirectory", (params) => {
|
|
2105
|
+
return listVirtualDirectory(typeof asRecord$2(params).path === "string" ? String(asRecord$2(params).path) : VIRTUAL_HOME, this.handlers.listWorkspaces());
|
|
2106
|
+
});
|
|
2107
|
+
this.sendAlive();
|
|
2108
|
+
this.aliveTimer = setInterval(() => {
|
|
2109
|
+
this.sendAlive();
|
|
2110
|
+
}, 2e4);
|
|
2111
|
+
this.handlers.log(`机器 ${this.machineId}(${hostname()})已连上 Happy`);
|
|
2112
|
+
}
|
|
2113
|
+
async bindRpc(socket, method, handler) {
|
|
2114
|
+
await registerRpc(socket, this.machineId, method, this.crypto, handler, this.handlers.log);
|
|
2115
|
+
this.rpcMethods.push(`${this.machineId}:${method}`);
|
|
2116
|
+
}
|
|
2117
|
+
sendAlive() {
|
|
2118
|
+
this.socket?.emit("machine-alive", {
|
|
2119
|
+
machineId: this.machineId,
|
|
2120
|
+
time: Date.now()
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
/** Close the machine socket. */
|
|
2124
|
+
dispose() {
|
|
2125
|
+
if (this.aliveTimer !== void 0) clearInterval(this.aliveTimer);
|
|
2126
|
+
this.socket?.removeAllListeners();
|
|
2127
|
+
this.socket?.disconnect();
|
|
2128
|
+
this.socket = void 0;
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
function waitConnect$1(socket) {
|
|
2132
|
+
if (socket.connected) return Promise.resolve();
|
|
2133
|
+
return new Promise((resolve, reject) => {
|
|
2134
|
+
const timer = setTimeout(() => {
|
|
2135
|
+
socket.off("connect", onConnect);
|
|
2136
|
+
reject(/* @__PURE__ */ new Error("连接 Happy 机器通道超时"));
|
|
2137
|
+
}, 6e4);
|
|
2138
|
+
const onConnect = () => {
|
|
2139
|
+
clearTimeout(timer);
|
|
2140
|
+
resolve();
|
|
2141
|
+
};
|
|
2142
|
+
socket.once("connect", onConnect);
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
function asSpawn(params) {
|
|
2146
|
+
const record = asRecord$2(params);
|
|
2147
|
+
if (typeof record.directory !== "string" || record.directory === "") throw new Error("Directory is required");
|
|
2148
|
+
return {
|
|
2149
|
+
directory: record.directory,
|
|
2150
|
+
...typeof record.sessionId === "string" ? { sessionId: record.sessionId } : {},
|
|
2151
|
+
...typeof record.permissionMode === "string" ? { permissionMode: record.permissionMode } : {},
|
|
2152
|
+
...typeof record.modelMode === "string" ? { modelMode: record.modelMode } : {},
|
|
2153
|
+
...typeof record.effortLevel === "string" ? { effortLevel: record.effortLevel } : {}
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
function asRecord$2(value) {
|
|
2157
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
2158
|
+
return {};
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region lib/types/pairing.js
|
|
2162
|
+
/** Happy terminal pairing: POST /v1/auth/request, QR URL, poll until authorized. */
|
|
2163
|
+
/**
|
|
2164
|
+
* Start one terminal auth request and poll until authorized.
|
|
2165
|
+
* @param serverUrl - Happy API origin.
|
|
2166
|
+
* @param appUrl - Happy App origin for the web URL.
|
|
2167
|
+
* @returns URLs plus a promise that settles on success or abort.
|
|
2168
|
+
*/
|
|
2169
|
+
async function startPairing(serverUrl, appUrl) {
|
|
2170
|
+
const secret = nacl.randomBytes(32);
|
|
2171
|
+
const keypair = nacl.box.keyPair.fromSecretKey(secret);
|
|
2172
|
+
const publicKeyB64 = encodeBase64(keypair.publicKey);
|
|
2173
|
+
await happyFetch(serverUrl, "/v1/auth/request", {
|
|
2174
|
+
method: "POST",
|
|
2175
|
+
body: {
|
|
2176
|
+
publicKey: publicKeyB64,
|
|
2177
|
+
supportsV2: false
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
const mobileUrl = `happy://terminal?${encodeBase64(keypair.publicKey, "base64url")}`;
|
|
2181
|
+
const webUrl = `${trimSlash(appUrl)}/terminal/connect#key=${encodeBase64(keypair.publicKey, "base64url")}`;
|
|
2182
|
+
const qrDataUrl = await QRCode.toDataURL(mobileUrl, {
|
|
2183
|
+
margin: 1,
|
|
2184
|
+
width: 240
|
|
2185
|
+
});
|
|
2186
|
+
const abort = new AbortController();
|
|
2187
|
+
return {
|
|
2188
|
+
mobileUrl,
|
|
2189
|
+
webUrl,
|
|
2190
|
+
qrDataUrl,
|
|
2191
|
+
abort: () => abort.abort(),
|
|
2192
|
+
done: pollAuthorized(serverUrl, publicKeyB64, keypair.secretKey, abort.signal)
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
async function pollAuthorized(serverUrl, publicKeyB64, secretKey, signal) {
|
|
2196
|
+
while (!signal.aborted) {
|
|
2197
|
+
const record = asRecord$1(await happyFetch(serverUrl, "/v1/auth/request", {
|
|
2198
|
+
method: "POST",
|
|
2199
|
+
body: {
|
|
2200
|
+
publicKey: publicKeyB64,
|
|
2201
|
+
supportsV2: false
|
|
2202
|
+
}
|
|
2203
|
+
}));
|
|
2204
|
+
if (record.state === "authorized" && typeof record.token === "string" && typeof record.response === "string") {
|
|
2205
|
+
const decrypted = decryptWithEphemeralKey(decodeBase64(record.response), secretKey);
|
|
2206
|
+
if (decrypted === null) throw new Error("无法解密配对响应");
|
|
2207
|
+
if (decrypted.length === 32) return {
|
|
2208
|
+
token: record.token,
|
|
2209
|
+
encryption: {
|
|
2210
|
+
type: "legacy",
|
|
2211
|
+
secret: decrypted
|
|
2212
|
+
}
|
|
2213
|
+
};
|
|
2214
|
+
if (decrypted[0] === 0 && decrypted.length >= 33) return {
|
|
2215
|
+
token: record.token,
|
|
2216
|
+
encryption: {
|
|
2217
|
+
type: "dataKey",
|
|
2218
|
+
publicKey: decrypted.slice(1, 33),
|
|
2219
|
+
machineKey: nacl.randomBytes(32)
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
throw new Error("配对响应格式无法识别");
|
|
2223
|
+
}
|
|
2224
|
+
await sleep(1e3, signal);
|
|
2225
|
+
}
|
|
2226
|
+
throw new Error("配对已取消");
|
|
2227
|
+
}
|
|
2228
|
+
function sleep(ms, signal) {
|
|
2229
|
+
return new Promise((resolve, reject) => {
|
|
2230
|
+
const timer = setTimeout(resolve, ms);
|
|
2231
|
+
const onAbort = () => {
|
|
2232
|
+
clearTimeout(timer);
|
|
2233
|
+
reject(/* @__PURE__ */ new Error("配对已取消"));
|
|
2234
|
+
};
|
|
2235
|
+
if (signal.aborted) {
|
|
2236
|
+
onAbort();
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
function trimSlash(url) {
|
|
2243
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
2244
|
+
}
|
|
2245
|
+
function asRecord$1(value) {
|
|
2246
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
2247
|
+
return {};
|
|
2248
|
+
}
|
|
2249
|
+
//#endregion
|
|
2250
|
+
//#region lib/types/session-socket.js
|
|
2251
|
+
/** One Happy session-scoped socket: encrypt chat, metadata, agentState, permission RPC. */
|
|
2252
|
+
/**
|
|
2253
|
+
* Session-scoped Happy client for one mirrored or spawned conversation.
|
|
2254
|
+
*/
|
|
2255
|
+
var HappySessionSocket = class {
|
|
2256
|
+
happySessionId;
|
|
2257
|
+
token;
|
|
2258
|
+
serverUrl;
|
|
2259
|
+
crypto;
|
|
2260
|
+
handlers;
|
|
2261
|
+
socket;
|
|
2262
|
+
metadataVersion = 0;
|
|
2263
|
+
agentStateVersion = 0;
|
|
2264
|
+
aliveTimer;
|
|
2265
|
+
turnId;
|
|
2266
|
+
thinking = false;
|
|
2267
|
+
rpcReady = false;
|
|
2268
|
+
rpcMethods = [];
|
|
2269
|
+
/**
|
|
2270
|
+
* @param happySessionId - Happy cloud session id.
|
|
2271
|
+
* @param token - bearer token.
|
|
2272
|
+
* @param serverUrl - API origin.
|
|
2273
|
+
* @param crypto - content encryption for this session.
|
|
2274
|
+
* @param handlers - inbound callbacks.
|
|
2275
|
+
*/
|
|
2276
|
+
constructor(happySessionId, token, serverUrl, crypto, handlers) {
|
|
2277
|
+
this.happySessionId = happySessionId;
|
|
2278
|
+
this.token = token;
|
|
2279
|
+
this.serverUrl = serverUrl;
|
|
2280
|
+
this.crypto = crypto;
|
|
2281
|
+
this.handlers = handlers;
|
|
2282
|
+
}
|
|
2283
|
+
/** Connect, register permission + abort + killSession, start keepalive. */
|
|
2284
|
+
async connect(initialMetadataVersion = 0, initialAgentStateVersion = 0) {
|
|
2285
|
+
this.metadataVersion = initialMetadataVersion;
|
|
2286
|
+
this.agentStateVersion = initialAgentStateVersion;
|
|
2287
|
+
const socket = io(this.serverUrl, {
|
|
2288
|
+
auth: {
|
|
2289
|
+
token: this.token,
|
|
2290
|
+
clientType: "session-scoped",
|
|
2291
|
+
sessionId: this.happySessionId,
|
|
2292
|
+
happyClient: HAPPY_CLIENT
|
|
2293
|
+
},
|
|
2294
|
+
path: "/v1/updates",
|
|
2295
|
+
transports: ["websocket", "polling"],
|
|
2296
|
+
reconnection: true,
|
|
2297
|
+
reconnectionDelay: 1e3,
|
|
2298
|
+
reconnectionDelayMax: 5e3,
|
|
2299
|
+
timeout: 2e4,
|
|
2300
|
+
withCredentials: true
|
|
2301
|
+
});
|
|
2302
|
+
this.socket = socket;
|
|
2303
|
+
socket.on("update", (data) => {
|
|
2304
|
+
if (data.body?.t === "update-session") {
|
|
2305
|
+
this.onMetadataUpdate(data.body.metadata);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if (data.body?.t !== "new-message") return;
|
|
2309
|
+
const content = data.body.message?.content;
|
|
2310
|
+
if (content?.t !== "encrypted" || typeof content.c !== "string") return;
|
|
2311
|
+
const plain = decryptB64(this.crypto, content.c);
|
|
2312
|
+
this.dispatchInbound(plain);
|
|
2313
|
+
});
|
|
2314
|
+
socket.on("connect_error", (error) => {
|
|
2315
|
+
this.handlers.log(`会话 ${this.happySessionId} 通道错误:${error.message}`);
|
|
2316
|
+
});
|
|
2317
|
+
socket.on("connect", () => {
|
|
2318
|
+
this.keepAlive(this.thinking);
|
|
2319
|
+
if (this.rpcReady) for (const method of this.rpcMethods) requestRpcRegister(socket, method);
|
|
2320
|
+
});
|
|
2321
|
+
try {
|
|
2322
|
+
await waitConnect(socket);
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
this.handlers.log(`${error instanceof Error ? error.message : String(error)},会话 ${this.happySessionId} 继续自动重连`);
|
|
2325
|
+
}
|
|
2326
|
+
await registerRpc(socket, this.happySessionId, "permission", this.crypto, (params) => {
|
|
2327
|
+
this.handlers.onPermission(asPermission(params));
|
|
2328
|
+
return { ok: true };
|
|
2329
|
+
}, this.handlers.log);
|
|
2330
|
+
this.rpcMethods.push(`${this.happySessionId}:permission`);
|
|
2331
|
+
await registerRpc(socket, this.happySessionId, "abort", this.crypto, () => {
|
|
2332
|
+
this.handlers.onAbort();
|
|
2333
|
+
return { ok: true };
|
|
2334
|
+
}, this.handlers.log);
|
|
2335
|
+
this.rpcMethods.push(`${this.happySessionId}:abort`);
|
|
2336
|
+
await registerRpc(socket, this.happySessionId, "killSession", this.crypto, () => {
|
|
2337
|
+
this.stopKeepAlive();
|
|
2338
|
+
this.handlers.onArchived();
|
|
2339
|
+
return {
|
|
2340
|
+
success: true,
|
|
2341
|
+
message: "Session archived"
|
|
2342
|
+
};
|
|
2343
|
+
}, this.handlers.log);
|
|
2344
|
+
this.rpcMethods.push(`${this.happySessionId}:killSession`);
|
|
2345
|
+
this.rpcReady = true;
|
|
2346
|
+
this.keepAlive(this.thinking);
|
|
2347
|
+
}
|
|
2348
|
+
/** Stop session-alive so an App archive can stick. */
|
|
2349
|
+
stopKeepAlive() {
|
|
2350
|
+
if (this.aliveTimer !== void 0) {
|
|
2351
|
+
clearInterval(this.aliveTimer);
|
|
2352
|
+
this.aliveTimer = void 0;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
/** Close the socket and keepalive. */
|
|
2356
|
+
dispose() {
|
|
2357
|
+
this.stopKeepAlive();
|
|
2358
|
+
this.socket?.removeAllListeners();
|
|
2359
|
+
this.socket?.disconnect();
|
|
2360
|
+
this.socket = void 0;
|
|
2361
|
+
}
|
|
2362
|
+
/** Whether the Happy session-scoped socket is connected. */
|
|
2363
|
+
isConnected() {
|
|
2364
|
+
return this.socket?.connected === true;
|
|
2365
|
+
}
|
|
2366
|
+
/** Current Happy turn id for agent envelopes. */
|
|
2367
|
+
currentTurn() {
|
|
2368
|
+
return this.turnId;
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Open a Happy turn (turn-start).
|
|
2372
|
+
* @param time - original log time when replaying history.
|
|
2373
|
+
* @returns the new turn id.
|
|
2374
|
+
*/
|
|
2375
|
+
startTurn(time) {
|
|
2376
|
+
this.turnId = createId();
|
|
2377
|
+
this.sendAgent({ t: "turn-start" }, time);
|
|
2378
|
+
return this.turnId;
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Close the Happy turn.
|
|
2382
|
+
* @param status - completed / failed / cancelled.
|
|
2383
|
+
* @param time - original log time when replaying history.
|
|
2384
|
+
*/
|
|
2385
|
+
endTurn(status, time) {
|
|
2386
|
+
this.sendAgent({
|
|
2387
|
+
t: "turn-end",
|
|
2388
|
+
status
|
|
2389
|
+
}, time);
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Send an agent text or service envelope.
|
|
2393
|
+
* @param kind - `text` or `service`.
|
|
2394
|
+
* @param text - markdown body.
|
|
2395
|
+
* @param time - original log time when replaying history.
|
|
2396
|
+
* @param thinking - `true` for a reasoning block the App can collapse.
|
|
2397
|
+
*/
|
|
2398
|
+
sendText(kind, text, time, thinking = false) {
|
|
2399
|
+
if (kind === "service") {
|
|
2400
|
+
this.sendAgent({
|
|
2401
|
+
t: "service",
|
|
2402
|
+
text
|
|
2403
|
+
}, time);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
this.sendAgent(thinking ? {
|
|
2407
|
+
t: "text",
|
|
2408
|
+
text,
|
|
2409
|
+
thinking: true
|
|
2410
|
+
} : {
|
|
2411
|
+
t: "text",
|
|
2412
|
+
text
|
|
2413
|
+
}, time);
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Send a user text envelope (history backfill or echo).
|
|
2417
|
+
* @param text - markdown body.
|
|
2418
|
+
* @param time - original log time when replaying history.
|
|
2419
|
+
*/
|
|
2420
|
+
sendUser(text, time) {
|
|
2421
|
+
const envelope = createEnvelope("user", {
|
|
2422
|
+
t: "text",
|
|
2423
|
+
text
|
|
2424
|
+
}, time === void 0 ? {} : { time });
|
|
2425
|
+
this.emitEnvelope(envelope);
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Send a user file envelope after the encrypted blob is already on Happy.
|
|
2429
|
+
* Matches CLI `uploadLocalImageAttachmentEnvelope`: `size` is plaintext bytes.
|
|
2430
|
+
* @param file - Happy `ref` plus display fields.
|
|
2431
|
+
* @param time - original log time when replaying history.
|
|
2432
|
+
*/
|
|
2433
|
+
sendFile(file, time) {
|
|
2434
|
+
const envelope = createEnvelope("user", {
|
|
2435
|
+
t: "file",
|
|
2436
|
+
ref: file.ref,
|
|
2437
|
+
name: file.name,
|
|
2438
|
+
size: file.size,
|
|
2439
|
+
...file.mimeType === void 0 ? {} : { mimeType: file.mimeType }
|
|
2440
|
+
}, time === void 0 ? {} : { time });
|
|
2441
|
+
this.emitEnvelope(envelope);
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Send a tool-call card.
|
|
2445
|
+
* @param call - matching id. A later start with the same id updates description.
|
|
2446
|
+
* @param name - Happy tool name (PascalCase knownTools, or a camouflage).
|
|
2447
|
+
* @param args - tool arguments. Happy merges by keeping already-seen keys.
|
|
2448
|
+
* @param title - short heading (schema-required; App often ignores it).
|
|
2449
|
+
* @param description - row subtitle the App actually shows.
|
|
2450
|
+
* @param time - original log time when replaying history.
|
|
2451
|
+
*/
|
|
2452
|
+
sendToolStart(call, name, args, title, description, time) {
|
|
2453
|
+
this.sendAgent({
|
|
2454
|
+
t: "tool-call-start",
|
|
2455
|
+
call,
|
|
2456
|
+
name,
|
|
2457
|
+
title,
|
|
2458
|
+
description,
|
|
2459
|
+
args
|
|
2460
|
+
}, time);
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Close a tool-call card.
|
|
2464
|
+
* @param call - matching id.
|
|
2465
|
+
* @param time - original log time when replaying history.
|
|
2466
|
+
*/
|
|
2467
|
+
sendToolEnd(call, time) {
|
|
2468
|
+
this.sendAgent({
|
|
2469
|
+
t: "tool-call-end",
|
|
2470
|
+
call
|
|
2471
|
+
}, time);
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Emit session-alive so the App shows the session as linked / online.
|
|
2475
|
+
* Happy CLI sends this immediately and every 2s; without it the list archives the row.
|
|
2476
|
+
* @param thinking - `agent/status === running`.
|
|
2477
|
+
*/
|
|
2478
|
+
keepAlive(thinking) {
|
|
2479
|
+
this.thinking = thinking;
|
|
2480
|
+
this.emitAlive();
|
|
2481
|
+
this.ensureAliveTimer();
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Restart session-alive if the timer was cleared. Happy lists the row as
|
|
2485
|
+
* offline once heartbeats stop; opening the chat on the phone does not
|
|
2486
|
+
* start them again.
|
|
2487
|
+
*/
|
|
2488
|
+
ensureKeepAlive() {
|
|
2489
|
+
if (this.aliveTimer !== void 0) return;
|
|
2490
|
+
this.keepAlive(this.thinking);
|
|
2491
|
+
}
|
|
2492
|
+
emitAlive() {
|
|
2493
|
+
this.socket?.volatile.emit("session-alive", {
|
|
2494
|
+
sid: this.happySessionId,
|
|
2495
|
+
time: Date.now(),
|
|
2496
|
+
thinking: this.thinking,
|
|
2497
|
+
mode: "remote"
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
ensureAliveTimer() {
|
|
2501
|
+
if (this.aliveTimer !== void 0 || this.socket === void 0) return;
|
|
2502
|
+
this.aliveTimer = setInterval(() => {
|
|
2503
|
+
this.emitAlive();
|
|
2504
|
+
}, 2e3);
|
|
2505
|
+
}
|
|
2506
|
+
/** Tell Happy this session process is gone so the App can archive or delete it. */
|
|
2507
|
+
endSession() {
|
|
2508
|
+
this.stopKeepAlive();
|
|
2509
|
+
this.socket?.emit("session-end", {
|
|
2510
|
+
sid: this.happySessionId,
|
|
2511
|
+
time: Date.now()
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Encrypt and push session metadata.
|
|
2516
|
+
* @param metadata - plaintext catalog object.
|
|
2517
|
+
*/
|
|
2518
|
+
updateMetadata(metadata) {
|
|
2519
|
+
const socket = this.socket;
|
|
2520
|
+
if (socket === void 0) return;
|
|
2521
|
+
this.emitMetadata(socket, metadata, this.metadataVersion, 0);
|
|
2522
|
+
}
|
|
2523
|
+
emitMetadata(socket, metadata, expected, attempt) {
|
|
2524
|
+
socket.emit("update-metadata", {
|
|
2525
|
+
sid: this.happySessionId,
|
|
2526
|
+
metadata: encryptB64(this.crypto, metadata),
|
|
2527
|
+
expectedVersion: expected
|
|
2528
|
+
}, (answer) => {
|
|
2529
|
+
if (typeof answer?.version === "number") this.metadataVersion = answer.version;
|
|
2530
|
+
if (answer?.result === "version-mismatch" && attempt < 3 && typeof answer.version === "number") this.emitMetadata(socket, metadata, answer.version, attempt + 1);
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Encrypt and push agentState (permission requests).
|
|
2535
|
+
* @param agentState - plaintext agentState.
|
|
2536
|
+
*/
|
|
2537
|
+
updateState(agentState) {
|
|
2538
|
+
const socket = this.socket;
|
|
2539
|
+
if (socket === void 0) return;
|
|
2540
|
+
socket.emit("update-state", {
|
|
2541
|
+
sid: this.happySessionId,
|
|
2542
|
+
agentState: encryptB64(this.crypto, agentState),
|
|
2543
|
+
expectedVersion: this.agentStateVersion
|
|
2544
|
+
}, (answer) => {
|
|
2545
|
+
if (typeof answer?.version === "number") this.agentStateVersion = answer.version;
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
sendAgent(ev, time) {
|
|
2549
|
+
if (this.turnId === void 0) this.turnId = createId();
|
|
2550
|
+
const envelope = createEnvelope("agent", ev, {
|
|
2551
|
+
turn: this.turnId,
|
|
2552
|
+
...time === void 0 ? {} : { time }
|
|
2553
|
+
});
|
|
2554
|
+
this.emitEnvelope(envelope);
|
|
2555
|
+
}
|
|
2556
|
+
emitEnvelope(envelope) {
|
|
2557
|
+
const content = {
|
|
2558
|
+
role: "session",
|
|
2559
|
+
content: envelope,
|
|
2560
|
+
meta: { sentFrom: "dsh" }
|
|
2561
|
+
};
|
|
2562
|
+
this.socket?.emit("message", {
|
|
2563
|
+
sid: this.happySessionId,
|
|
2564
|
+
message: encryptB64(this.crypto, content),
|
|
2565
|
+
localId: createId()
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
onMetadataUpdate(metadata) {
|
|
2569
|
+
if (typeof metadata?.value !== "string") return;
|
|
2570
|
+
if (typeof metadata.version === "number") this.metadataVersion = metadata.version;
|
|
2571
|
+
let record;
|
|
2572
|
+
try {
|
|
2573
|
+
record = asRecord(decryptB64(this.crypto, metadata.value));
|
|
2574
|
+
} catch {
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
const lifecycle = record.lifecycleState;
|
|
2578
|
+
if (lifecycle === "archiveRequested" || lifecycle === "archived") {
|
|
2579
|
+
this.handlers.onArchived();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
if (lifecycle === "running") this.handlers.onResumed();
|
|
2583
|
+
this.handlers.onCatalog(record);
|
|
2584
|
+
}
|
|
2585
|
+
dispatchInbound(plain) {
|
|
2586
|
+
const inbound = parseHappyInbound(plain);
|
|
2587
|
+
if (inbound !== void 0) this.handlers.onInbound(inbound);
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
function waitConnect(socket) {
|
|
2591
|
+
if (socket.connected) return Promise.resolve();
|
|
2592
|
+
return new Promise((resolve, reject) => {
|
|
2593
|
+
const timer = setTimeout(() => {
|
|
2594
|
+
socket.off("connect", onConnect);
|
|
2595
|
+
reject(/* @__PURE__ */ new Error("连接 Happy 会话超时"));
|
|
2596
|
+
}, 6e4);
|
|
2597
|
+
const onConnect = () => {
|
|
2598
|
+
clearTimeout(timer);
|
|
2599
|
+
resolve();
|
|
2600
|
+
};
|
|
2601
|
+
socket.once("connect", onConnect);
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
function asPermission(params) {
|
|
2605
|
+
return parsePermissionRpc(params);
|
|
2606
|
+
}
|
|
2607
|
+
function asRecord(value) {
|
|
2608
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
2609
|
+
return {};
|
|
2610
|
+
}
|
|
2611
|
+
//#endregion
|
|
2612
|
+
//#region lib/types/bridge.js
|
|
2613
|
+
/** Host orchestrator: pair, mirror sessions, map chat/approvals/questions onto Happy. */
|
|
2614
|
+
/**
|
|
2615
|
+
* Live Happy bridge for one Host process.
|
|
2616
|
+
*/
|
|
2617
|
+
var HappyBridge = class {
|
|
2618
|
+
ctx;
|
|
2619
|
+
config;
|
|
2620
|
+
log;
|
|
2621
|
+
credentials;
|
|
2622
|
+
pairing;
|
|
2623
|
+
machine;
|
|
2624
|
+
links = /* @__PURE__ */ new Map();
|
|
2625
|
+
happyToDsh = /* @__PURE__ */ new Map();
|
|
2626
|
+
models = /* @__PURE__ */ new Map();
|
|
2627
|
+
/** Last model/effort we wrote to Happy, so the metadata echo is not a phone pick. */
|
|
2628
|
+
lastPublished = /* @__PURE__ */ new Map();
|
|
2629
|
+
/** Count of in-flight phone-originated Host `selectModel` calls. */
|
|
2630
|
+
hostSelectFromPhone = 0;
|
|
2631
|
+
/** Per-agent selection installed on phone wake / spawn, matching Host `selectionFor`. */
|
|
2632
|
+
selections = /* @__PURE__ */ new WeakMap();
|
|
2633
|
+
/** In-flight phone wakes, so two inbound texts do not double-resume. */
|
|
2634
|
+
waking = /* @__PURE__ */ new Map();
|
|
2635
|
+
/** Phone stop-session / archive: do not recreate these Happy rows. */
|
|
2636
|
+
dismissed = /* @__PURE__ */ new Set();
|
|
2637
|
+
/** Phone New (spawn) blanks stay linked; web placeholders do not. */
|
|
2638
|
+
phoneSpawned = /* @__PURE__ */ new Set();
|
|
2639
|
+
/** Last Host archive set, so web archive/restore can park or unpark Happy. */
|
|
2640
|
+
lastArchivedIds = /* @__PURE__ */ new Set();
|
|
2641
|
+
/** Serialize phone text so two messages cannot split one attachment batch. */
|
|
2642
|
+
inboundTail = /* @__PURE__ */ new Map();
|
|
2643
|
+
error;
|
|
2644
|
+
running = false;
|
|
2645
|
+
scanTimer;
|
|
2646
|
+
/**
|
|
2647
|
+
* @param ctx - Host context.
|
|
2648
|
+
* @param config - resolved plugin config.
|
|
2649
|
+
* @param log - logger.
|
|
2650
|
+
*/
|
|
2651
|
+
constructor(ctx, config, log) {
|
|
2652
|
+
this.ctx = ctx;
|
|
2653
|
+
this.config = config;
|
|
2654
|
+
this.log = log;
|
|
2655
|
+
}
|
|
2656
|
+
/** Replace config after a settings write that keeps the same relay. */
|
|
2657
|
+
setConfig(config) {
|
|
2658
|
+
this.config = config;
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* Apply a settings write in place when the Happy relay identity is unchanged.
|
|
2662
|
+
* Grant changes take effect immediately; URL / credential-dir / enabled
|
|
2663
|
+
* changes must rebuild.
|
|
2664
|
+
* @param next - resolved settings section.
|
|
2665
|
+
* @returns true when the live bridge kept running.
|
|
2666
|
+
*/
|
|
2667
|
+
acceptSettings(next) {
|
|
2668
|
+
if (!sameHappyRuntime(this.config, next)) return false;
|
|
2669
|
+
this.config = next;
|
|
2670
|
+
return true;
|
|
2671
|
+
}
|
|
2672
|
+
/** Snapshot for the settings card. */
|
|
2673
|
+
status() {
|
|
2674
|
+
return {
|
|
2675
|
+
paired: this.credentials !== void 0,
|
|
2676
|
+
pairing: this.pairing !== void 0,
|
|
2677
|
+
serverUrl: this.config.serverUrl,
|
|
2678
|
+
...this.pairing === void 0 ? {} : {
|
|
2679
|
+
mobileUrl: this.pairing.mobileUrl,
|
|
2680
|
+
webUrl: this.pairing.webUrl,
|
|
2681
|
+
qrDataUrl: this.pairing.qrDataUrl
|
|
2682
|
+
},
|
|
2683
|
+
...this.error === void 0 ? {} : { error: this.error },
|
|
2684
|
+
...this.credentials === void 0 ? {} : { machineId: this.credentials.machineId },
|
|
2685
|
+
sessionCount: this.links.size,
|
|
2686
|
+
linkedCount: [...this.links.values()].filter((link) => link.socket.isConnected()).length
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
/** Load credentials, connect machine, mirror live root agents. */
|
|
2690
|
+
async start() {
|
|
2691
|
+
if (!this.config.enabled) return;
|
|
2692
|
+
this.running = true;
|
|
2693
|
+
this.installHooks();
|
|
2694
|
+
const dir = resolveCredentialDir(this.config.credentialDir);
|
|
2695
|
+
try {
|
|
2696
|
+
this.credentials = await loadCredentials(dir);
|
|
2697
|
+
if (this.credentials !== void 0) {
|
|
2698
|
+
for (const id of await loadDismissed(dir)) this.dismissed.add(id);
|
|
2699
|
+
await this.connectCloud();
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
2704
|
+
this.log(`读取凭据失败:${this.error}`);
|
|
2705
|
+
}
|
|
2706
|
+
if (this.config.pairOnStart) await this.beginPairing();
|
|
2707
|
+
}
|
|
2708
|
+
/** Tear down sockets. Web UI keeps running. */
|
|
2709
|
+
dispose() {
|
|
2710
|
+
this.running = false;
|
|
2711
|
+
if (this.scanTimer !== void 0) {
|
|
2712
|
+
clearInterval(this.scanTimer);
|
|
2713
|
+
this.scanTimer = void 0;
|
|
2714
|
+
}
|
|
2715
|
+
this.pairing?.abort();
|
|
2716
|
+
this.pairing = void 0;
|
|
2717
|
+
this.machine?.dispose();
|
|
2718
|
+
this.machine = void 0;
|
|
2719
|
+
for (const link of this.links.values()) link.socket.dispose();
|
|
2720
|
+
this.links.clear();
|
|
2721
|
+
this.happyToDsh.clear();
|
|
2722
|
+
this.waking.clear();
|
|
2723
|
+
this.lastPublished.clear();
|
|
2724
|
+
this.models.clear();
|
|
2725
|
+
}
|
|
2726
|
+
/** Start or resume pairing. */
|
|
2727
|
+
async beginPairing() {
|
|
2728
|
+
this.pairing?.abort();
|
|
2729
|
+
this.error = void 0;
|
|
2730
|
+
const dir = resolveCredentialDir(this.config.credentialDir);
|
|
2731
|
+
const existing = await loadCredentials(dir);
|
|
2732
|
+
if (existing !== void 0) {
|
|
2733
|
+
this.credentials = existing;
|
|
2734
|
+
await markConnected(dir, existing.machineId);
|
|
2735
|
+
await this.connectCloud();
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
const attempt = await startPairing(this.config.serverUrl, this.config.appUrl);
|
|
2739
|
+
this.pairing = attempt;
|
|
2740
|
+
const machineId = await peekMachineId(dir) ?? crypto.randomUUID();
|
|
2741
|
+
attempt.done.then(async (partial) => {
|
|
2742
|
+
const credentials = {
|
|
2743
|
+
...partial,
|
|
2744
|
+
machineId
|
|
2745
|
+
};
|
|
2746
|
+
this.credentials = credentials;
|
|
2747
|
+
this.pairing = void 0;
|
|
2748
|
+
await saveCredentials(dir, credentials);
|
|
2749
|
+
await this.connectCloud();
|
|
2750
|
+
}).catch((error) => {
|
|
2751
|
+
if (this.pairing === attempt) this.pairing = void 0;
|
|
2752
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
2753
|
+
this.log(`配对失败:${this.error}`);
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Drop the current Happy login and show a new QR. Keeps the same machine id
|
|
2758
|
+
* so already-mirrored sessions stay on this Host after the phone scans again.
|
|
2759
|
+
*/
|
|
2760
|
+
async rePair() {
|
|
2761
|
+
await this.disconnect();
|
|
2762
|
+
await this.beginPairing();
|
|
2763
|
+
}
|
|
2764
|
+
/** Disconnect Happy without killing dsh web. */
|
|
2765
|
+
async disconnect() {
|
|
2766
|
+
this.pairing?.abort();
|
|
2767
|
+
this.pairing = void 0;
|
|
2768
|
+
await markDisconnected(resolveCredentialDir(this.config.credentialDir), this.credentials?.machineId);
|
|
2769
|
+
this.credentials = void 0;
|
|
2770
|
+
this.machine?.dispose();
|
|
2771
|
+
this.machine = void 0;
|
|
2772
|
+
for (const link of this.links.values()) link.socket.dispose();
|
|
2773
|
+
this.links.clear();
|
|
2774
|
+
this.happyToDsh.clear();
|
|
2775
|
+
this.waking.clear();
|
|
2776
|
+
this.phoneSpawned.clear();
|
|
2777
|
+
this.dismissed.clear();
|
|
2778
|
+
}
|
|
2779
|
+
async connectCloud() {
|
|
2780
|
+
const credentials = this.credentials;
|
|
2781
|
+
if (credentials === void 0) return;
|
|
2782
|
+
const { ctx: crypto, dataEncryptionKey } = machineCrypto(credentials);
|
|
2783
|
+
await createOrLoadMachine({
|
|
2784
|
+
serverUrl: this.config.serverUrl,
|
|
2785
|
+
token: credentials.token,
|
|
2786
|
+
machineId: credentials.machineId,
|
|
2787
|
+
crypto,
|
|
2788
|
+
metadata: {
|
|
2789
|
+
host: "dsh",
|
|
2790
|
+
platform: process.platform,
|
|
2791
|
+
happyCliVersion: HAPPY_CLI_VERSION,
|
|
2792
|
+
homeDir: "/dsh-workspaces"
|
|
2793
|
+
},
|
|
2794
|
+
daemonState: {
|
|
2795
|
+
status: "running",
|
|
2796
|
+
pid: process.pid,
|
|
2797
|
+
startedAt: Date.now()
|
|
2798
|
+
},
|
|
2799
|
+
...dataEncryptionKey === void 0 ? {} : { dataEncryptionKey }
|
|
2800
|
+
});
|
|
2801
|
+
this.machine?.dispose();
|
|
2802
|
+
this.machine = new HappyMachineSocket(credentials.machineId, credentials.token, this.config.serverUrl, crypto, {
|
|
2803
|
+
spawn: (options) => this.spawn(options),
|
|
2804
|
+
resume: (happyId) => this.resumeHappySession(happyId),
|
|
2805
|
+
stopSession: (happyId) => this.unmapHappy(happyId),
|
|
2806
|
+
listWorkspaces: () => this.workspaces(),
|
|
2807
|
+
log: this.log
|
|
2808
|
+
});
|
|
2809
|
+
await this.machine.connect();
|
|
2810
|
+
this.seedArchiveSet();
|
|
2811
|
+
await this.syncMirrors();
|
|
2812
|
+
if (this.scanTimer === void 0) this.scanTimer = setInterval(() => {
|
|
2813
|
+
this.syncMirrors();
|
|
2814
|
+
}, 1e4);
|
|
2815
|
+
}
|
|
2816
|
+
installHooks() {
|
|
2817
|
+
this.ctx.on("agent/created", ({ agent }) => {
|
|
2818
|
+
if (!this.running || this.shouldSkip(agent)) return;
|
|
2819
|
+
if (this.dismissed.has(agent.id) && isBlankSession(agent.session.events)) return;
|
|
2820
|
+
if (this.dismissed.has(agent.id)) this.undismiss(agent.id);
|
|
2821
|
+
const existing = this.links.get(agent.id);
|
|
2822
|
+
if (existing !== void 0) {
|
|
2823
|
+
this.attachAgent(existing, agent);
|
|
2824
|
+
this.wakePhone(existing, true);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
if (isBlankSession(agent.session.events) && !this.phoneSpawned.has(agent.id)) return;
|
|
2828
|
+
this.mirrorAgent(agent).then(() => {
|
|
2829
|
+
if (this.isHarnessArchived(agent.id)) this.parkPhoneSession(agent.id);
|
|
2830
|
+
}).catch((error) => this.log(`镜像会话失败:${String(error)}`));
|
|
2831
|
+
});
|
|
2832
|
+
this.ctx.on("agent/disposed", ({ agent }) => {
|
|
2833
|
+
const link = this.links.get(agent.id);
|
|
2834
|
+
if (link === void 0) return;
|
|
2835
|
+
if (this.listedUnarchived().has(agent.id) || link.parked) {
|
|
2836
|
+
delete link.agent;
|
|
2837
|
+
link.sessionUnsub?.();
|
|
2838
|
+
delete link.sessionUnsub;
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
this.dropLink(agent.id);
|
|
2842
|
+
});
|
|
2843
|
+
this.ctx.on("agent/status", ({ agent, status }) => {
|
|
2844
|
+
const link = this.links.get(agent.id);
|
|
2845
|
+
if (link === void 0 || link.parked) return;
|
|
2846
|
+
link.socket.keepAlive(status === "running");
|
|
2847
|
+
if (status === "idle") this.drainNewEvents(link, agent);
|
|
2848
|
+
}, { global: true });
|
|
2849
|
+
this.ctx.on("session/event", (session, event) => {
|
|
2850
|
+
const link = this.links.get(session.header.id);
|
|
2851
|
+
if (link !== void 0) {
|
|
2852
|
+
this.onSessionEvent(link, event);
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
if (event.type !== "turn/start" || !this.running || this.credentials === void 0) return;
|
|
2856
|
+
if (this.dismissed.has(session.header.id)) this.undismiss(session.header.id);
|
|
2857
|
+
this.ensureMirror(session.header.id).catch((error) => this.log(`镜像会话失败:${String(error)}`));
|
|
2858
|
+
}, { global: true });
|
|
2859
|
+
this.ctx.on("approval/request", (req, next) => this.onApproval(req, next), { prepend: true });
|
|
2860
|
+
this.ctx.inject(["apiProxy"], (inner) => {
|
|
2861
|
+
const proxy = inner.get("apiProxy");
|
|
2862
|
+
if (proxy === void 0) return;
|
|
2863
|
+
const original = proxy.sessions.selectModel;
|
|
2864
|
+
proxy.sessions.selectModel = (request) => this.afterHostSelect(request, original.call(proxy.sessions, request));
|
|
2865
|
+
inner.effect(() => () => {
|
|
2866
|
+
proxy.sessions.selectModel = original;
|
|
2867
|
+
}, "happy-bridge: restore sessions.selectModel");
|
|
2868
|
+
});
|
|
2869
|
+
this.ctx.inject(["userQuestions"], (inner) => {
|
|
2870
|
+
const questions = inner.userQuestions;
|
|
2871
|
+
const original = questions.ask;
|
|
2872
|
+
questions.ask = (request) => this.onAsk(questions, original, request);
|
|
2873
|
+
inner.effect(() => () => {
|
|
2874
|
+
questions.ask = original;
|
|
2875
|
+
}, "happy-bridge: restore userQuestions.ask");
|
|
2876
|
+
});
|
|
2877
|
+
this.ctx.inject(["commands"], (inner) => {
|
|
2878
|
+
inner.commands.register({
|
|
2879
|
+
name: "remote",
|
|
2880
|
+
description: "查看或设置手机远程控制档(watch / chat / approve / full)",
|
|
2881
|
+
handler: (invocation) => {
|
|
2882
|
+
const arg = invocation.rawInput.trim();
|
|
2883
|
+
if (arg === "") return {
|
|
2884
|
+
kind: "success",
|
|
2885
|
+
text: `当前远程档 ${this.config.remoteGrant}`
|
|
2886
|
+
};
|
|
2887
|
+
if (arg !== "watch" && arg !== "chat" && arg !== "approve" && arg !== "full") return {
|
|
2888
|
+
kind: "error",
|
|
2889
|
+
text: `未知远程档 "${arg}"(watch / chat / approve / full)`
|
|
2890
|
+
};
|
|
2891
|
+
this.config.remoteGrant = arg;
|
|
2892
|
+
return {
|
|
2893
|
+
kind: "success",
|
|
2894
|
+
text: `远程档已设为 ${arg}`
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2898
|
+
inner.commands.register({
|
|
2899
|
+
name: "effort",
|
|
2900
|
+
description: "设置当前模型的推理档",
|
|
2901
|
+
handler: async (invocation) => {
|
|
2902
|
+
const id = invocation.rawInput.trim();
|
|
2903
|
+
const current = this.currentModel(invocation.agent);
|
|
2904
|
+
if (current === void 0) return {
|
|
2905
|
+
kind: "error",
|
|
2906
|
+
text: "当前没有已选模型"
|
|
2907
|
+
};
|
|
2908
|
+
if (id === "") {
|
|
2909
|
+
this.rememberModel(invocation.agent, {
|
|
2910
|
+
provider: current.provider,
|
|
2911
|
+
model: current.model
|
|
2912
|
+
});
|
|
2913
|
+
return {
|
|
2914
|
+
kind: "success",
|
|
2915
|
+
text: "推理档已恢复为模型默认"
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
this.rememberModel(invocation.agent, {
|
|
2919
|
+
provider: current.provider,
|
|
2920
|
+
model: current.model,
|
|
2921
|
+
reasoningEffort: ReasoningEffortId(id)
|
|
2922
|
+
});
|
|
2923
|
+
return {
|
|
2924
|
+
kind: "success",
|
|
2925
|
+
text: `推理档 ${id}`
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
});
|
|
2930
|
+
this.ctx.on("commands/change", () => {
|
|
2931
|
+
this.pushAllMetadata();
|
|
2932
|
+
});
|
|
2933
|
+
this.ctx.on("llm/adapters-updated", () => {
|
|
2934
|
+
this.pushAllMetadata();
|
|
2935
|
+
});
|
|
2936
|
+
this.ctx.on("domain/changed", (change) => {
|
|
2937
|
+
if (change.domain !== "workspace" || change.table !== "") return;
|
|
2938
|
+
this.reconcileArchiveSet();
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
isHarnessArchived(dshId) {
|
|
2942
|
+
return this.ctx.get("workspaceRegistry")?.archivedSessionIds.includes(SessionId(dshId)) === true;
|
|
2943
|
+
}
|
|
2944
|
+
seedArchiveSet() {
|
|
2945
|
+
const ids = this.ctx.get("workspaceRegistry")?.archivedSessionIds ?? [];
|
|
2946
|
+
this.lastArchivedIds = new Set(ids.map(String));
|
|
2947
|
+
}
|
|
2948
|
+
reconcileArchiveSet() {
|
|
2949
|
+
const next = new Set((this.ctx.get("workspaceRegistry")?.archivedSessionIds ?? []).map(String));
|
|
2950
|
+
const { hidden, shown } = archiveSetDiff(this.lastArchivedIds, next);
|
|
2951
|
+
this.lastArchivedIds = next;
|
|
2952
|
+
if (!this.running) return;
|
|
2953
|
+
for (const id of hidden) if (this.links.has(id)) this.parkPhoneSession(id);
|
|
2954
|
+
for (const id of shown) {
|
|
2955
|
+
const link = this.links.get(id);
|
|
2956
|
+
if (link !== void 0) this.unpark(link);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
listedUnarchived() {
|
|
2960
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
2961
|
+
return new Set(unarchivedSessionIds(registry?.list() ?? [], registry?.archivedSessionIds ?? []));
|
|
2962
|
+
}
|
|
2963
|
+
async syncMirrors() {
|
|
2964
|
+
if (!this.running || this.credentials === void 0) return;
|
|
2965
|
+
const wanted = /* @__PURE__ */ new Set([...this.listedUnarchived()]);
|
|
2966
|
+
for (const id of wanted) try {
|
|
2967
|
+
await this.ensureMirror(id);
|
|
2968
|
+
} catch (error) {
|
|
2969
|
+
this.log(`镜像会话失败:${String(error)}`);
|
|
2970
|
+
}
|
|
2971
|
+
for (const id of this.ctx.get("workspaceRegistry")?.archivedSessionIds ?? []) try {
|
|
2972
|
+
await this.ensureMirror(id);
|
|
2973
|
+
} catch (error) {
|
|
2974
|
+
this.log(`镜像会话失败:${String(error)}`);
|
|
2975
|
+
}
|
|
2976
|
+
for (const id of [...this.links.keys()]) {
|
|
2977
|
+
const link = this.links.get(id);
|
|
2978
|
+
if (link === void 0) continue;
|
|
2979
|
+
const action = phoneParkAction(link.parked, wanted.has(id));
|
|
2980
|
+
if (action === "unpark") {
|
|
2981
|
+
this.wakePhone(link);
|
|
2982
|
+
continue;
|
|
2983
|
+
}
|
|
2984
|
+
if (action === "park") {
|
|
2985
|
+
if (!this.phoneSpawned.has(id) && isBlankSession(this.linkEvents(link))) this.abandonBlankMirror(id);
|
|
2986
|
+
else if (this.isHarnessArchived(id)) this.parkPhoneSession(id);
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2989
|
+
if (link.parked) continue;
|
|
2990
|
+
if (wanted.has(id)) this.wakePhone(link);
|
|
2991
|
+
if (!this.phoneSpawned.has(id) && isBlankSession(this.linkEvents(link))) this.abandonBlankMirror(id);
|
|
2992
|
+
}
|
|
2993
|
+
await this.sweepHappyGhosts();
|
|
2994
|
+
}
|
|
2995
|
+
async ensureMirror(id) {
|
|
2996
|
+
if (this.links.has(id)) return;
|
|
2997
|
+
if (this.dismissed.has(id) && await this.sessionIsBlank(id) && !this.phoneSpawned.has(id)) return;
|
|
2998
|
+
if (this.dismissed.has(id)) this.undismiss(id);
|
|
2999
|
+
const agent = this.ctx.agents.get(SessionId(id));
|
|
3000
|
+
if (agent !== void 0) {
|
|
3001
|
+
if (this.shouldSkip(agent)) return;
|
|
3002
|
+
if (isBlankSession(agent.session.events) && !this.phoneSpawned.has(id)) return;
|
|
3003
|
+
await this.mirrorAgent(agent);
|
|
3004
|
+
if (this.isHarnessArchived(id)) this.parkPhoneSession(id);
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
await this.mirrorDormant(id);
|
|
3008
|
+
if (this.isHarnessArchived(id)) this.parkPhoneSession(id);
|
|
3009
|
+
}
|
|
3010
|
+
dropLink(dshId) {
|
|
3011
|
+
const link = this.links.get(dshId);
|
|
3012
|
+
if (link === void 0) return;
|
|
3013
|
+
link.sessionUnsub?.();
|
|
3014
|
+
this.happyToDsh.delete(link.socket.happySessionId);
|
|
3015
|
+
link.socket.dispose();
|
|
3016
|
+
this.links.delete(dshId);
|
|
3017
|
+
this.lastPublished.delete(dshId);
|
|
3018
|
+
this.models.delete(dshId);
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Remove a web New Session placeholder from Happy without remembering a
|
|
3022
|
+
* dismiss: the first real turn should remirror it.
|
|
3023
|
+
*/
|
|
3024
|
+
abandonBlankMirror(dshId) {
|
|
3025
|
+
const link = this.links.get(dshId);
|
|
3026
|
+
const happyId = link?.socket.happySessionId;
|
|
3027
|
+
link?.socket.endSession();
|
|
3028
|
+
this.dropLink(dshId);
|
|
3029
|
+
if (happyId !== void 0 && this.credentials !== void 0) deleteHappySession(this.config.serverUrl, this.credentials.token, happyId);
|
|
3030
|
+
this.log(`空白占位不出现在手机上 ${dshId}`);
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Drop Happy rows for dismissed or blank dsh tags. The App archive button
|
|
3034
|
+
* only sets inactive; without this sweep a keepalive ghost stays in the list.
|
|
3035
|
+
*/
|
|
3036
|
+
async sweepHappyGhosts() {
|
|
3037
|
+
if (this.credentials === void 0) return;
|
|
3038
|
+
const rows = await listHappySessions(this.config.serverUrl, this.credentials.token);
|
|
3039
|
+
for (const row of rows) {
|
|
3040
|
+
const dshId = row.tag.startsWith("dsh:") ? row.tag.slice(4) : this.happyToDsh.get(row.id);
|
|
3041
|
+
if (dshId === void 0 || this.phoneSpawned.has(dshId)) continue;
|
|
3042
|
+
if (await this.sessionIsBlank(dshId)) this.forgetPhoneSession(row.id, dshId);
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
async sessionIsBlank(dshId) {
|
|
3046
|
+
const agent = this.ctx.agents.get(SessionId(dshId));
|
|
3047
|
+
if (agent !== void 0) return isBlankSession(agent.session.events);
|
|
3048
|
+
const stored = await this.loadStored(dshId);
|
|
3049
|
+
if (stored === void 0) return !this.links.has(dshId);
|
|
3050
|
+
return isBlankSession(stored.events);
|
|
3051
|
+
}
|
|
3052
|
+
linkEvents(link) {
|
|
3053
|
+
return link.agent?.session.events ?? link.events;
|
|
3054
|
+
}
|
|
3055
|
+
requireAgent(link) {
|
|
3056
|
+
if (link.agent !== void 0) return link.agent;
|
|
3057
|
+
const live = this.ctx.agents.get(SessionId(link.dshId));
|
|
3058
|
+
if (live === void 0) return void 0;
|
|
3059
|
+
this.attachAgent(link, live);
|
|
3060
|
+
return live;
|
|
3061
|
+
}
|
|
3062
|
+
/** Bind a live agent onto an existing Happy socket without reminting it. */
|
|
3063
|
+
attachAgent(link, agent) {
|
|
3064
|
+
link.agent = agent;
|
|
3065
|
+
link.cwd = agent.session.header.cwd ?? link.cwd;
|
|
3066
|
+
link.events = [];
|
|
3067
|
+
if (link.lastForwardedSeq < 0) link.lastForwardedSeq = lastEventSeq(agent.session.events);
|
|
3068
|
+
link.sessionUnsub?.();
|
|
3069
|
+
link.sessionUnsub = agent.ctx.on("session/event", (session, event) => {
|
|
3070
|
+
if (session.header.id !== link.dshId) return;
|
|
3071
|
+
this.onSessionEvent(link, event);
|
|
3072
|
+
}, { global: true });
|
|
3073
|
+
}
|
|
3074
|
+
/** Copy log events newer than {@link Link.lastForwardedSeq} onto Happy. */
|
|
3075
|
+
drainNewEvents(link, agent) {
|
|
3076
|
+
if (link.replaying) return;
|
|
3077
|
+
for (const event of agent.session.events) this.onSessionEvent(link, event);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Live agent for this Happy socket, resuming the persisted session when the
|
|
3081
|
+
* web has never opened it. Same preset the Host would mount on a web open.
|
|
3082
|
+
*/
|
|
3083
|
+
async ensureAgent(link) {
|
|
3084
|
+
const live = this.requireAgent(link);
|
|
3085
|
+
if (live !== void 0) return {
|
|
3086
|
+
agent: live,
|
|
3087
|
+
woke: false
|
|
3088
|
+
};
|
|
3089
|
+
const inflight = this.waking.get(link.dshId);
|
|
3090
|
+
if (inflight !== void 0) {
|
|
3091
|
+
const agent = await inflight;
|
|
3092
|
+
return agent === void 0 ? void 0 : {
|
|
3093
|
+
agent,
|
|
3094
|
+
woke: true
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
link.socket.keepAlive(true);
|
|
3098
|
+
const waking = this.wakeAgent(link).finally(() => {
|
|
3099
|
+
if (this.waking.get(link.dshId) === waking) this.waking.delete(link.dshId);
|
|
3100
|
+
});
|
|
3101
|
+
this.waking.set(link.dshId, waking);
|
|
3102
|
+
const agent = await waking;
|
|
3103
|
+
return agent === void 0 ? void 0 : {
|
|
3104
|
+
agent,
|
|
3105
|
+
woke: true
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
async wakeAgent(link) {
|
|
3109
|
+
const already = this.requireAgent(link);
|
|
3110
|
+
if (already !== void 0) return already;
|
|
3111
|
+
try {
|
|
3112
|
+
const agent = await withTimeout(this.resumeSession(link), 6e4, "打开这场对话超时");
|
|
3113
|
+
this.log(`已从手机唤醒会话 ${link.dshId}`);
|
|
3114
|
+
return agent;
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
const recovered = this.requireAgent(link);
|
|
3117
|
+
if (recovered !== void 0) return recovered;
|
|
3118
|
+
this.log(`唤醒会话失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
async resumeSession(link) {
|
|
3123
|
+
const setup = await this.composeAgentSetup(resolveSessionPreset(this.linkEvents(link), link.headerAgentPreset));
|
|
3124
|
+
const handle = await this.ctx.agents.resume({
|
|
3125
|
+
resumeSessionId: SessionId(link.dshId),
|
|
3126
|
+
setup
|
|
3127
|
+
});
|
|
3128
|
+
await this.ensurePinnedEffort(handle.agent);
|
|
3129
|
+
const current = this.links.get(link.dshId);
|
|
3130
|
+
if (current !== void 0) this.attachAgent(current, handle.agent);
|
|
3131
|
+
return handle.agent;
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Resume/create composition matching Host `composeAgent`: install model
|
|
3135
|
+
* selection, then mount the preset when a roster exists.
|
|
3136
|
+
* @param presetHint - logged or requested preset id; omitted uses the roster default.
|
|
3137
|
+
*/
|
|
3138
|
+
async composeAgentSetup(presetHint) {
|
|
3139
|
+
const presets = this.ctx.get("agentPresets");
|
|
3140
|
+
let resolvedId;
|
|
3141
|
+
if (presets !== void 0) resolvedId = (await presets.resolve(presetHint)).id;
|
|
3142
|
+
return async (agentCtx) => {
|
|
3143
|
+
this.installWakeSelection(agentCtx);
|
|
3144
|
+
if (presets !== void 0 && resolvedId !== void 0) await presets.mount(agentCtx, resolvedId);
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Same lazy selection Host `selectionFor` installs: remembered pick, else
|
|
3149
|
+
* the session's last `request/header`, else `agentDefaultModel`. A missing
|
|
3150
|
+
* thinking level keeps the web picker's effort when it is the same model.
|
|
3151
|
+
* Unlike Host `installModelSelection`, an absent effort does not clear
|
|
3152
|
+
* inherited thinking.
|
|
3153
|
+
*/
|
|
3154
|
+
installWakeSelection(agentCtx) {
|
|
3155
|
+
const agent = agentCtx.agent;
|
|
3156
|
+
if (agent === void 0) throw new Error("happy-bridge: agent setup has no scoped agent");
|
|
3157
|
+
if (this.selections.has(agent)) return;
|
|
3158
|
+
let picked;
|
|
3159
|
+
const bridge = this;
|
|
3160
|
+
const selection = {
|
|
3161
|
+
get current() {
|
|
3162
|
+
if (picked !== void 0) return picked;
|
|
3163
|
+
return bridge.currentModel(agent);
|
|
3164
|
+
},
|
|
3165
|
+
set current(next) {
|
|
3166
|
+
picked = next;
|
|
3167
|
+
},
|
|
3168
|
+
assembled: void 0
|
|
3169
|
+
};
|
|
3170
|
+
this.bindWakeSelection(agentCtx, selection);
|
|
3171
|
+
this.selections.set(agent, selection);
|
|
3172
|
+
const current = selection.current;
|
|
3173
|
+
if (current === void 0) return;
|
|
3174
|
+
const existing = this.models.get(agent.id);
|
|
3175
|
+
if (existing === void 0 || existing.reasoningEffort === void 0 && current.reasoningEffort !== void 0) this.models.set(agent.id, current);
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Pin provider/model for a phone-woken agent without wiping thinking when
|
|
3179
|
+
* the selection names no effort.
|
|
3180
|
+
*/
|
|
3181
|
+
bindWakeSelection(agentCtx, selection) {
|
|
3182
|
+
agentCtx.on("system-prompt/assemble", async (_assembly, _context, next) => {
|
|
3183
|
+
const selected = selection.current;
|
|
3184
|
+
const assembled = await next();
|
|
3185
|
+
selection.assembled = selected;
|
|
3186
|
+
if (selected === void 0) return assembled;
|
|
3187
|
+
return {
|
|
3188
|
+
...assembled,
|
|
3189
|
+
variables: {
|
|
3190
|
+
...assembled.variables,
|
|
3191
|
+
provider: selected.provider,
|
|
3192
|
+
model: selected.model
|
|
3193
|
+
}
|
|
3194
|
+
};
|
|
3195
|
+
});
|
|
3196
|
+
agentCtx.on("agent/request", async (_payload, next) => {
|
|
3197
|
+
const resolved = await next();
|
|
3198
|
+
const selected = selection.assembled;
|
|
3199
|
+
if (selected === void 0) return resolved;
|
|
3200
|
+
return {
|
|
3201
|
+
...resolved,
|
|
3202
|
+
provider: selected.provider,
|
|
3203
|
+
model: selected.model,
|
|
3204
|
+
...selected.reasoningEffort === void 0 ? {} : { reasoningEffort: selected.reasoningEffort }
|
|
3205
|
+
};
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
/** Host default model, when the web profile mounted `agentDefaultModel`. */
|
|
3209
|
+
defaultModelSelection() {
|
|
3210
|
+
return this.ctx.get("agentDefaultModel")?.currentSelection();
|
|
3211
|
+
}
|
|
3212
|
+
/** Registered workspace for this session, or `undefined` when it is not in the sidebar. */
|
|
3213
|
+
workspaceFor(sessionId, cwd) {
|
|
3214
|
+
const mapped = this.workspaces();
|
|
3215
|
+
if (cwd !== void 0 && cwd !== "") {
|
|
3216
|
+
const hit = matchVirtualWorkspace(cwd, mapped);
|
|
3217
|
+
if (hit !== void 0) return hit;
|
|
3218
|
+
}
|
|
3219
|
+
const match = (this.ctx.get("workspaceRegistry")?.list() ?? []).find((workspace) => workspace.sessionIds.includes(SessionId(sessionId)));
|
|
3220
|
+
if (match === void 0) return void 0;
|
|
3221
|
+
return matchVirtualWorkspace(match.path, mapped);
|
|
3222
|
+
}
|
|
3223
|
+
async loadStored(id) {
|
|
3224
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
3225
|
+
if (persistence === void 0) return void 0;
|
|
3226
|
+
try {
|
|
3227
|
+
const stored = await persistence.inspect(SessionId(id));
|
|
3228
|
+
if (stored.meta.origin === "subagent") return void 0;
|
|
3229
|
+
const workspace = this.workspaceFor(id, stored.meta.cwd);
|
|
3230
|
+
if (workspace === void 0) return void 0;
|
|
3231
|
+
return {
|
|
3232
|
+
cwd: stored.meta.cwd ?? workspace.realPath,
|
|
3233
|
+
happyPath: workspace.virtualPath,
|
|
3234
|
+
events: stored.events,
|
|
3235
|
+
...stored.meta.agentPreset === void 0 ? {} : { headerAgentPreset: stored.meta.agentPreset }
|
|
3236
|
+
};
|
|
3237
|
+
} catch {
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
shouldSkip(agent) {
|
|
3242
|
+
return agent.session.header.origin === "subagent";
|
|
3243
|
+
}
|
|
3244
|
+
workspaces() {
|
|
3245
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
3246
|
+
if (registry === void 0) return [];
|
|
3247
|
+
return virtualWorkspaces(registry.list().map((workspace) => ({
|
|
3248
|
+
id: workspace.id,
|
|
3249
|
+
path: workspace.path,
|
|
3250
|
+
title: workspace.title
|
|
3251
|
+
})));
|
|
3252
|
+
}
|
|
3253
|
+
async mirrorAgent(agent, happySessionId) {
|
|
3254
|
+
if (this.links.has(agent.id) || this.credentials === void 0) return;
|
|
3255
|
+
if (isBlankSession(agent.session.events) && happySessionId === void 0 && !this.phoneSpawned.has(agent.id)) return;
|
|
3256
|
+
const workspace = this.workspaceFor(agent.id, agent.session.header.cwd);
|
|
3257
|
+
if (workspace === void 0) return;
|
|
3258
|
+
const cwd = agent.session.header.cwd ?? workspace.realPath;
|
|
3259
|
+
const credentials = this.credentials;
|
|
3260
|
+
const { ctx: crypto, dataEncryptionKey } = sessionCrypto(credentials);
|
|
3261
|
+
let sessionId = happySessionId;
|
|
3262
|
+
let metadataVersion = 0;
|
|
3263
|
+
let agentStateVersion = 0;
|
|
3264
|
+
let seq = happySessionId === void 0 ? 0 : 1;
|
|
3265
|
+
if (sessionId === void 0) {
|
|
3266
|
+
const created = await createOrLoadSession({
|
|
3267
|
+
serverUrl: this.config.serverUrl,
|
|
3268
|
+
token: credentials.token,
|
|
3269
|
+
tag: `dsh:${agent.id}`,
|
|
3270
|
+
crypto,
|
|
3271
|
+
metadata: await buildSessionMetadata(this.ctx, {
|
|
3272
|
+
cwd,
|
|
3273
|
+
happyPath: workspace.virtualPath,
|
|
3274
|
+
events: agent.session.events,
|
|
3275
|
+
agent
|
|
3276
|
+
}, credentials.machineId, sessionLabel(agent.session.events), this.currentModel(agent), this.config.remoteGrant),
|
|
3277
|
+
agentState: {
|
|
3278
|
+
controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
|
|
3279
|
+
requests: {}
|
|
3280
|
+
},
|
|
3281
|
+
...dataEncryptionKey === void 0 ? {} : { dataEncryptionKey }
|
|
3282
|
+
});
|
|
3283
|
+
sessionId = created.id;
|
|
3284
|
+
seq = created.seq;
|
|
3285
|
+
metadataVersion = created.metadataVersion;
|
|
3286
|
+
agentStateVersion = created.agentStateVersion;
|
|
3287
|
+
}
|
|
3288
|
+
if (sessionId === void 0) return;
|
|
3289
|
+
const socket = new HappySessionSocket(sessionId, credentials.token, this.config.serverUrl, crypto, {
|
|
3290
|
+
onInbound: (message) => this.queueInbound(agent.id, message),
|
|
3291
|
+
onPermission: (rpc) => this.onPermission(agent.id, rpc),
|
|
3292
|
+
onAbort: () => this.onPhoneAbort(agent.id),
|
|
3293
|
+
onArchived: () => this.onPhoneArchive(sessionId, agent.id),
|
|
3294
|
+
onResumed: () => this.onPhoneRestore(agent.id),
|
|
3295
|
+
onCatalog: (meta) => this.applyPhoneCatalog(agent.id, meta),
|
|
3296
|
+
log: this.log
|
|
3297
|
+
});
|
|
3298
|
+
try {
|
|
3299
|
+
await socket.connect(metadataVersion, agentStateVersion);
|
|
3300
|
+
} catch (error) {
|
|
3301
|
+
socket.dispose();
|
|
3302
|
+
throw error;
|
|
3303
|
+
}
|
|
3304
|
+
const link = {
|
|
3305
|
+
agent,
|
|
3306
|
+
socket,
|
|
3307
|
+
crypto,
|
|
3308
|
+
pendingDownloads: [],
|
|
3309
|
+
alwaysAllow: /* @__PURE__ */ new Set(),
|
|
3310
|
+
replaying: false,
|
|
3311
|
+
skipNextUser: 0,
|
|
3312
|
+
outboundTail: Promise.resolve(),
|
|
3313
|
+
lastPhoneText: "",
|
|
3314
|
+
lastPhoneAt: 0,
|
|
3315
|
+
startedCalls: /* @__PURE__ */ new Set(),
|
|
3316
|
+
reasoning: "",
|
|
3317
|
+
thinkBodySent: false,
|
|
3318
|
+
thinkLastEmit: 0,
|
|
3319
|
+
dshId: agent.id,
|
|
3320
|
+
cwd,
|
|
3321
|
+
events: [],
|
|
3322
|
+
lastForwardedSeq: lastEventSeq(agent.session.events),
|
|
3323
|
+
parked: false
|
|
3324
|
+
};
|
|
3325
|
+
this.links.set(agent.id, link);
|
|
3326
|
+
this.happyToDsh.set(sessionId, agent.id);
|
|
3327
|
+
await this.pushMetadata(link);
|
|
3328
|
+
if (seq === 0) await this.replayHistory(link);
|
|
3329
|
+
this.log(`已镜像会话 ${agent.id} → Happy ${sessionId}(seq=${seq})`);
|
|
3330
|
+
}
|
|
3331
|
+
async mirrorDormant(dshId) {
|
|
3332
|
+
if (this.links.has(dshId) || this.credentials === void 0) return;
|
|
3333
|
+
const stored = await this.loadStored(dshId);
|
|
3334
|
+
if (stored === void 0) return;
|
|
3335
|
+
if (isBlankSession(stored.events)) return;
|
|
3336
|
+
const credentials = this.credentials;
|
|
3337
|
+
const { ctx: crypto, dataEncryptionKey } = sessionCrypto(credentials);
|
|
3338
|
+
const title = sessionLabel(stored.events);
|
|
3339
|
+
const created = await createOrLoadSession({
|
|
3340
|
+
serverUrl: this.config.serverUrl,
|
|
3341
|
+
token: credentials.token,
|
|
3342
|
+
tag: `dsh:${dshId}`,
|
|
3343
|
+
crypto,
|
|
3344
|
+
metadata: await buildSessionMetadata(this.ctx, {
|
|
3345
|
+
cwd: stored.cwd,
|
|
3346
|
+
happyPath: stored.happyPath,
|
|
3347
|
+
events: stored.events
|
|
3348
|
+
}, credentials.machineId, title, void 0, this.config.remoteGrant),
|
|
3349
|
+
agentState: {
|
|
3350
|
+
controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
|
|
3351
|
+
requests: {}
|
|
3352
|
+
},
|
|
3353
|
+
...dataEncryptionKey === void 0 ? {} : { dataEncryptionKey }
|
|
3354
|
+
});
|
|
3355
|
+
const socket = new HappySessionSocket(created.id, credentials.token, this.config.serverUrl, crypto, {
|
|
3356
|
+
onInbound: (message) => this.queueInbound(dshId, message),
|
|
3357
|
+
onPermission: (rpc) => this.onPermission(dshId, rpc),
|
|
3358
|
+
onAbort: () => this.onPhoneAbort(dshId),
|
|
3359
|
+
onArchived: () => this.onPhoneArchive(created.id, dshId),
|
|
3360
|
+
onResumed: () => this.onPhoneRestore(dshId),
|
|
3361
|
+
onCatalog: (meta) => this.applyPhoneCatalog(dshId, meta),
|
|
3362
|
+
log: this.log
|
|
3363
|
+
});
|
|
3364
|
+
try {
|
|
3365
|
+
await socket.connect(created.metadataVersion, created.agentStateVersion);
|
|
3366
|
+
} catch (error) {
|
|
3367
|
+
socket.dispose();
|
|
3368
|
+
throw error;
|
|
3369
|
+
}
|
|
3370
|
+
const link = {
|
|
3371
|
+
socket,
|
|
3372
|
+
crypto,
|
|
3373
|
+
pendingDownloads: [],
|
|
3374
|
+
alwaysAllow: /* @__PURE__ */ new Set(),
|
|
3375
|
+
replaying: false,
|
|
3376
|
+
skipNextUser: 0,
|
|
3377
|
+
outboundTail: Promise.resolve(),
|
|
3378
|
+
lastPhoneText: "",
|
|
3379
|
+
lastPhoneAt: 0,
|
|
3380
|
+
startedCalls: /* @__PURE__ */ new Set(),
|
|
3381
|
+
reasoning: "",
|
|
3382
|
+
thinkBodySent: false,
|
|
3383
|
+
thinkLastEmit: 0,
|
|
3384
|
+
dshId,
|
|
3385
|
+
cwd: stored.cwd,
|
|
3386
|
+
events: stored.events,
|
|
3387
|
+
lastForwardedSeq: -1,
|
|
3388
|
+
parked: false,
|
|
3389
|
+
...stored.headerAgentPreset === void 0 ? {} : { headerAgentPreset: stored.headerAgentPreset }
|
|
3390
|
+
};
|
|
3391
|
+
this.links.set(dshId, link);
|
|
3392
|
+
this.happyToDsh.set(created.id, dshId);
|
|
3393
|
+
await this.pushMetadata(link);
|
|
3394
|
+
if (created.seq === 0) await this.replayHistory(link);
|
|
3395
|
+
this.log(`已镜像未打开会话 ${dshId} → Happy ${created.id}(seq=${created.seq})`);
|
|
3396
|
+
}
|
|
3397
|
+
unmapHappy(happySessionId) {
|
|
3398
|
+
this.onPhoneArchive(happySessionId, this.happyToDsh.get(happySessionId));
|
|
3399
|
+
}
|
|
3400
|
+
/**
|
|
3401
|
+
* Phone archive: park a real conversation so a later send resumes it.
|
|
3402
|
+
* Blank placeholders are forgotten and not remirrored.
|
|
3403
|
+
*/
|
|
3404
|
+
onPhoneArchive(happySessionId, dshId) {
|
|
3405
|
+
this.handlePhoneArchive(happySessionId, dshId);
|
|
3406
|
+
}
|
|
3407
|
+
async handlePhoneArchive(happySessionId, dshId) {
|
|
3408
|
+
if (dshId !== void 0 && !await this.sessionIsBlank(dshId)) {
|
|
3409
|
+
if (this.links.has(dshId)) this.parkPhoneSession(dshId, true);
|
|
3410
|
+
else this.undismiss(dshId);
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
this.forgetPhoneSession(happySessionId, dshId);
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* Park a real conversation on Happy. `hideHost` archives the same row on
|
|
3417
|
+
* the web (phone archive). Web-initiated archive only parks Happy.
|
|
3418
|
+
*/
|
|
3419
|
+
parkPhoneSession(dshId, hideHost = false) {
|
|
3420
|
+
const link = this.links.get(dshId);
|
|
3421
|
+
if (link === void 0 || link.parked) return;
|
|
3422
|
+
link.parked = true;
|
|
3423
|
+
link.socket.stopKeepAlive();
|
|
3424
|
+
if (this.credentials !== void 0) archiveHappySession(this.config.serverUrl, this.credentials.token, link.socket.happySessionId);
|
|
3425
|
+
if (hideHost) hideOnHarness(this.ctx.get("workspaceRegistry"), dshId).catch((error) => {
|
|
3426
|
+
this.log(`网页归档失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3427
|
+
});
|
|
3428
|
+
this.log(hideHost ? `已归档 ${dshId},手机归档列表里打开或再发一条,网页侧栏也会回来` : `网页归档了 ${dshId},手机也已归档,恢复后网页侧栏会回来`);
|
|
3429
|
+
}
|
|
3430
|
+
onPhoneRestore(dshId) {
|
|
3431
|
+
const link = this.links.get(dshId);
|
|
3432
|
+
if (link === void 0) {
|
|
3433
|
+
this.undismiss(dshId);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
this.unpark(link);
|
|
3437
|
+
}
|
|
3438
|
+
undismiss(dshId) {
|
|
3439
|
+
if (!this.dismissed.has(dshId)) return;
|
|
3440
|
+
this.dismissed.delete(dshId);
|
|
3441
|
+
removeDismissed(resolveCredentialDir(this.config.credentialDir), dshId);
|
|
3442
|
+
this.log(`已恢复镜像 ${dshId}`);
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* Put this Happy row back online. Opening an offline chat on the phone
|
|
3446
|
+
* does not start heartbeats; the web sidebar still showing the row, a
|
|
3447
|
+
* phone send, or Happy's resume RPC all come through here.
|
|
3448
|
+
* @param refreshCatalog - republish metadata (web open / phone resume).
|
|
3449
|
+
*/
|
|
3450
|
+
wakePhone(link, refreshCatalog = false) {
|
|
3451
|
+
if (link.parked) {
|
|
3452
|
+
this.unpark(link);
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
link.socket.ensureKeepAlive();
|
|
3456
|
+
if (refreshCatalog) this.pushMetadata(link);
|
|
3457
|
+
}
|
|
3458
|
+
unpark(link) {
|
|
3459
|
+
if (!link.parked) return;
|
|
3460
|
+
link.parked = false;
|
|
3461
|
+
if (this.dismissed.has(link.dshId)) this.undismiss(link.dshId);
|
|
3462
|
+
link.socket.keepAlive(false);
|
|
3463
|
+
this.pushMetadata(link);
|
|
3464
|
+
revealOnHarness(this.ctx.get("workspaceRegistry"), link.dshId).catch((error) => {
|
|
3465
|
+
this.log(`网页恢复失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3466
|
+
});
|
|
3467
|
+
this.log(`已恢复 ${link.dshId},网页侧栏会重新显示这场对话`);
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* Honor a phone archive/delete: stop keepalive, drop the Happy row, and do
|
|
3471
|
+
* not remirror this harness session until the plugin is unpaired.
|
|
3472
|
+
*/
|
|
3473
|
+
forgetPhoneSession(happySessionId, dshId) {
|
|
3474
|
+
if (dshId !== void 0 && this.dismissed.has(dshId) && !this.links.has(dshId)) return;
|
|
3475
|
+
if (dshId !== void 0) {
|
|
3476
|
+
this.dismissed.add(dshId);
|
|
3477
|
+
addDismissed(resolveCredentialDir(this.config.credentialDir), dshId);
|
|
3478
|
+
this.links.get(dshId)?.socket.stopKeepAlive();
|
|
3479
|
+
this.links.get(dshId)?.socket.endSession();
|
|
3480
|
+
this.dropLink(dshId);
|
|
3481
|
+
}
|
|
3482
|
+
if (happySessionId !== void 0 && this.credentials !== void 0) deleteHappySession(this.config.serverUrl, this.credentials.token, happySessionId);
|
|
3483
|
+
this.log(`已从手机去掉 ${happySessionId ?? dshId ?? ""}(网页会话仍在)`);
|
|
3484
|
+
}
|
|
3485
|
+
/**
|
|
3486
|
+
* Happy App continues an offline row with `resume-happy-session`.
|
|
3487
|
+
* Reuse the existing harness session; do not mint a new one.
|
|
3488
|
+
* @param happySessionId - Happy cloud session id from the App.
|
|
3489
|
+
*/
|
|
3490
|
+
async resumeHappySession(happySessionId) {
|
|
3491
|
+
const known = this.happyToDsh.get(happySessionId);
|
|
3492
|
+
if (known !== void 0) {
|
|
3493
|
+
const link = this.links.get(known);
|
|
3494
|
+
if (link !== void 0) {
|
|
3495
|
+
this.wakePhone(link, true);
|
|
3496
|
+
this.log(`已从手机重新接上 ${known}`);
|
|
3497
|
+
return {
|
|
3498
|
+
type: "success",
|
|
3499
|
+
sessionId: happySessionId
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
for (const link of this.links.values()) {
|
|
3504
|
+
if (link.socket.happySessionId !== happySessionId) continue;
|
|
3505
|
+
this.happyToDsh.set(happySessionId, link.dshId);
|
|
3506
|
+
this.wakePhone(link, true);
|
|
3507
|
+
this.log(`已从手机重新接上 ${link.dshId}`);
|
|
3508
|
+
return {
|
|
3509
|
+
type: "success",
|
|
3510
|
+
sessionId: happySessionId
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
if (this.credentials === void 0) return {
|
|
3514
|
+
type: "error",
|
|
3515
|
+
errorMessage: "还没连上 Happy"
|
|
3516
|
+
};
|
|
3517
|
+
try {
|
|
3518
|
+
const row = (await listHappySessions(this.config.serverUrl, this.credentials.token)).find((item) => item.id === happySessionId);
|
|
3519
|
+
const dshId = row?.tag.startsWith("dsh:") === true ? row.tag.slice(4) : void 0;
|
|
3520
|
+
if (dshId === void 0 || dshId === "") return {
|
|
3521
|
+
type: "error",
|
|
3522
|
+
errorMessage: "找不到这场对话,请在电脑网页里点开它"
|
|
3523
|
+
};
|
|
3524
|
+
this.undismiss(dshId);
|
|
3525
|
+
await this.ensureMirror(dshId);
|
|
3526
|
+
const link = this.links.get(dshId);
|
|
3527
|
+
if (link === void 0) return {
|
|
3528
|
+
type: "error",
|
|
3529
|
+
errorMessage: "找不到这场对话,请在电脑网页里点开它"
|
|
3530
|
+
};
|
|
3531
|
+
this.wakePhone(link, true);
|
|
3532
|
+
this.log(`已从手机重新接上 ${dshId}`);
|
|
3533
|
+
return {
|
|
3534
|
+
type: "success",
|
|
3535
|
+
sessionId: happySessionId
|
|
3536
|
+
};
|
|
3537
|
+
} catch (error) {
|
|
3538
|
+
return {
|
|
3539
|
+
type: "error",
|
|
3540
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
async spawn(options) {
|
|
3545
|
+
if (!grantAtLeast(this.config.remoteGrant, "chat")) return {
|
|
3546
|
+
type: "error",
|
|
3547
|
+
errorMessage: "当前远程档不能新建会话"
|
|
3548
|
+
};
|
|
3549
|
+
const real = resolveSpawnDirectory(options.directory, this.workspaces());
|
|
3550
|
+
if (real === void 0) return {
|
|
3551
|
+
type: "error",
|
|
3552
|
+
errorMessage: `目录不是已登记的工作区:${options.directory}`
|
|
3553
|
+
};
|
|
3554
|
+
try {
|
|
3555
|
+
const sessionId = SessionId(crypto.randomUUID());
|
|
3556
|
+
const setup = await this.composeAgentSetup();
|
|
3557
|
+
const handle = await this.ctx.agents.create({
|
|
3558
|
+
sessionId,
|
|
3559
|
+
meta: { cwd: real },
|
|
3560
|
+
setup
|
|
3561
|
+
});
|
|
3562
|
+
await (this.ctx.get("workspaceRegistry")?.list().find((item) => item.path === real))?.attachSession(sessionId);
|
|
3563
|
+
this.phoneSpawned.add(handle.agent.id);
|
|
3564
|
+
this.applySpawnMeta(handle.agent, options);
|
|
3565
|
+
await this.ensurePinnedEffort(handle.agent);
|
|
3566
|
+
await this.mirrorAgent(handle.agent, options.sessionId);
|
|
3567
|
+
return {
|
|
3568
|
+
type: "success",
|
|
3569
|
+
sessionId: this.links.get(handle.agent.id)?.socket.happySessionId ?? options.sessionId ?? handle.agent.id
|
|
3570
|
+
};
|
|
3571
|
+
} catch (error) {
|
|
3572
|
+
return {
|
|
3573
|
+
type: "error",
|
|
3574
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
applySpawnMeta(agent, options) {
|
|
3579
|
+
if (options.modelMode !== void 0) this.applyModel(agent, options.modelMode, options.effortLevel);
|
|
3580
|
+
else if (options.effortLevel !== void 0) this.applyEffort(agent, options.effortLevel);
|
|
3581
|
+
if (options.permissionMode !== void 0) this.applyPermission(agent, options.permissionMode, true);
|
|
3582
|
+
}
|
|
3583
|
+
queueInbound(dshId, message) {
|
|
3584
|
+
const next = (this.inboundTail.get(dshId) ?? Promise.resolve()).then(() => this.onInbound(dshId, message), () => this.onInbound(dshId, message));
|
|
3585
|
+
this.inboundTail.set(dshId, next);
|
|
3586
|
+
}
|
|
3587
|
+
async onInbound(dshId, message) {
|
|
3588
|
+
const link = this.links.get(dshId);
|
|
3589
|
+
if (link === void 0 || link.replaying) return;
|
|
3590
|
+
this.wakePhone(link);
|
|
3591
|
+
if (this.dismissed.has(dshId)) this.undismiss(dshId);
|
|
3592
|
+
if (message.meta.sentFrom === "dsh") return;
|
|
3593
|
+
if (message.kind === "text") {
|
|
3594
|
+
if (!(link.pendingDownloads.length > 0) && link.lastPhoneText === message.text && Date.now() - link.lastPhoneAt < 2500) return;
|
|
3595
|
+
link.lastPhoneText = message.text;
|
|
3596
|
+
link.lastPhoneAt = Date.now();
|
|
3597
|
+
}
|
|
3598
|
+
if (message.kind === "file") {
|
|
3599
|
+
if (!grantAtLeast(this.config.remoteGrant, "chat")) return;
|
|
3600
|
+
link.pendingDownloads.push(this.downloadPhoneFile(link, message));
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
const files = await this.drainPhoneFiles(link);
|
|
3604
|
+
const pending = link.pendingHuman;
|
|
3605
|
+
if (pending?.kind === "ask") {
|
|
3606
|
+
pending.resolve({ answers: customAnswersFromText(pending.questions, message.text) });
|
|
3607
|
+
delete link.pendingHuman;
|
|
3608
|
+
this.clearRequest(link, pending.id, "canceled");
|
|
3609
|
+
link.socket.sendToolEnd(pending.id);
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
if (pending?.kind === "plan-review") {
|
|
3613
|
+
pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
|
|
3614
|
+
delete link.pendingHuman;
|
|
3615
|
+
this.clearRequest(link, pending.id, "canceled");
|
|
3616
|
+
link.socket.sendToolEnd(pending.id);
|
|
3617
|
+
}
|
|
3618
|
+
if (!grantAtLeast(this.config.remoteGrant, "chat")) {
|
|
3619
|
+
link.socket.sendText("service", "当前是「只看」档,不能从手机发消息。");
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3622
|
+
if (message.text.trim() === "" && files.length === 0) return;
|
|
3623
|
+
const ensured = await this.ensureAgent(link);
|
|
3624
|
+
const current = this.links.get(dshId);
|
|
3625
|
+
if (ensured === void 0 || current === void 0) {
|
|
3626
|
+
link.socket.keepAlive(false);
|
|
3627
|
+
link.socket.sendText("service", "没法从手机打开这场对话。请先在网页点开它一次,然后再试。");
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
const { agent } = ensured;
|
|
3631
|
+
await this.ensurePinnedEffort(agent);
|
|
3632
|
+
const names = new Set((this.ctx.get("commands")?.list(agent) ?? []).map((command) => command.name));
|
|
3633
|
+
const kind = classifyInboundText(message.text, names);
|
|
3634
|
+
await this.applyMessageMeta(current, agent, message.meta, kind === "command");
|
|
3635
|
+
if (kind === "command") {
|
|
3636
|
+
if (!grantAtLeast(this.config.remoteGrant, "full")) {
|
|
3637
|
+
current.socket.sendText("service", "斜杠命令需要远程档「完整」。");
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
const { encoded } = splitPendingFiles(files);
|
|
3641
|
+
const result = await this.ctx.get("commands")?.execute(agent, message.text, encoded, new AbortController().signal);
|
|
3642
|
+
const text = result?.result.text ?? (result === void 0 ? "未知命令" : result.result.kind);
|
|
3643
|
+
current.socket.sendText("service", text);
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
await this.followup(current, agent, message.text, files);
|
|
3647
|
+
}
|
|
3648
|
+
async applyMessageMeta(link, agent, meta, isCommand) {
|
|
3649
|
+
const model = messageModelCode(meta);
|
|
3650
|
+
const effort = messageEffort(meta);
|
|
3651
|
+
const permissionMode = meta.permissionMode;
|
|
3652
|
+
if (model !== void 0) {
|
|
3653
|
+
if (!grantAtLeast(this.config.remoteGrant, "full")) link.socket.sendText("service", "改模型需要远程档「完整」,这条消息仍会发出。");
|
|
3654
|
+
else this.applyModel(agent, model, typeof effort === "string" ? effort : effort === null ? null : void 0);
|
|
3655
|
+
} else if (effort === null || typeof effort === "string") {
|
|
3656
|
+
if (!grantAtLeast(this.config.remoteGrant, "full")) link.socket.sendText("service", "改思考强度需要远程档「完整」,这条消息仍会发出。");
|
|
3657
|
+
else if (effort === null) {
|
|
3658
|
+
const current = this.currentModel(agent);
|
|
3659
|
+
if (current !== void 0) this.rememberModel(agent, {
|
|
3660
|
+
provider: current.provider,
|
|
3661
|
+
model: current.model
|
|
3662
|
+
});
|
|
3663
|
+
} else this.applyEffort(agent, effort);
|
|
3664
|
+
}
|
|
3665
|
+
if (typeof permissionMode === "string" && permissionMode !== "") this.applyPermission(agent, permissionMode, isCommand);
|
|
3666
|
+
}
|
|
3667
|
+
applyPhoneCatalog(dshId, meta) {
|
|
3668
|
+
if (!grantAtLeast(this.config.remoteGrant, "full")) return;
|
|
3669
|
+
const pick = catalogModelPick(meta);
|
|
3670
|
+
if (pick === void 0) return;
|
|
3671
|
+
const published = this.lastPublished.get(dshId);
|
|
3672
|
+
if (published === void 0 || sameCatalogPick(published, pick)) return;
|
|
3673
|
+
const link = this.links.get(dshId);
|
|
3674
|
+
const agent = link === void 0 ? void 0 : this.requireAgent(link);
|
|
3675
|
+
if (agent === void 0) return;
|
|
3676
|
+
if (pick.model !== void 0) {
|
|
3677
|
+
this.applyModel(agent, pick.model, pick.effort);
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
if (pick.effort === null) {
|
|
3681
|
+
const current = this.currentModel(agent);
|
|
3682
|
+
if (current !== void 0) this.rememberModel(agent, {
|
|
3683
|
+
provider: current.provider,
|
|
3684
|
+
model: current.model
|
|
3685
|
+
});
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
if (typeof pick.effort === "string") this.applyEffort(agent, pick.effort);
|
|
3689
|
+
}
|
|
3690
|
+
applyModel(agent, code, effort) {
|
|
3691
|
+
const split = splitModelCode(code);
|
|
3692
|
+
const current = this.currentModel(agent);
|
|
3693
|
+
const provider = split.provider === "" ? current?.provider ?? "" : split.provider;
|
|
3694
|
+
if (provider === "") return;
|
|
3695
|
+
const sameModel = current?.provider === provider && current.model === split.model;
|
|
3696
|
+
const reasoningEffort = effort === null ? void 0 : typeof effort === "string" ? ReasoningEffortId(effort) : sameModel ? current?.reasoningEffort : void 0;
|
|
3697
|
+
const next = {
|
|
3698
|
+
provider,
|
|
3699
|
+
model: split.model,
|
|
3700
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
3701
|
+
};
|
|
3702
|
+
this.rememberModel(agent, next);
|
|
3703
|
+
}
|
|
3704
|
+
applyEffort(agent, effort) {
|
|
3705
|
+
const current = this.currentModel(agent);
|
|
3706
|
+
if (current === void 0) return;
|
|
3707
|
+
this.rememberModel(agent, {
|
|
3708
|
+
...current,
|
|
3709
|
+
reasoningEffort: ReasoningEffortId(effort)
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
/** Keep the last Host pick so Happy metadata can echo the web composer. */
|
|
3713
|
+
rememberModel(agent, next) {
|
|
3714
|
+
const previous = this.models.get(agent.id);
|
|
3715
|
+
this.models.set(agent.id, next);
|
|
3716
|
+
const selection = this.selections.get(agent);
|
|
3717
|
+
if (selection !== void 0) selection.current = next;
|
|
3718
|
+
if (sameModelOverride(previous, next)) return;
|
|
3719
|
+
this.syncHostSelection(agent, next);
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Write the web picker's Host selection (`session.selectModel`) so the
|
|
3723
|
+
* composer model seat reloads without a click on the computer.
|
|
3724
|
+
*/
|
|
3725
|
+
async syncHostSelection(agent, next) {
|
|
3726
|
+
const proxy = this.ctx.get("apiProxy");
|
|
3727
|
+
if (proxy === void 0) return;
|
|
3728
|
+
this.hostSelectFromPhone += 1;
|
|
3729
|
+
try {
|
|
3730
|
+
const response = await proxy.sessions.selectModel({
|
|
3731
|
+
rpcId: `happy-model-${String(Date.now())}`,
|
|
3732
|
+
payload: {
|
|
3733
|
+
sessionId: agent.id,
|
|
3734
|
+
provider: next.provider,
|
|
3735
|
+
model: next.model,
|
|
3736
|
+
...next.reasoningEffort === void 0 ? {} : { reasoningEffort: next.reasoningEffort }
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
if (!response.result.ok) {
|
|
3740
|
+
this.log(`电脑模型栏未同步:${response.result.error.message}`);
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
const link = this.links.get(agent.id);
|
|
3744
|
+
if (link !== void 0 && !link.parked) this.pushMetadata(link);
|
|
3745
|
+
} catch (error) {
|
|
3746
|
+
this.log(`电脑模型栏未同步:${error instanceof Error ? error.message : String(error)}`);
|
|
3747
|
+
} finally {
|
|
3748
|
+
this.hostSelectFromPhone -= 1;
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* After the web picker (or any Host caller) lands a selection, publish it
|
|
3753
|
+
* to Happy. Phone-originated calls set {@link hostSelectFromPhone} and push themselves.
|
|
3754
|
+
*/
|
|
3755
|
+
async afterHostSelect(request, pending) {
|
|
3756
|
+
const response = await pending;
|
|
3757
|
+
if (this.hostSelectFromPhone) return response;
|
|
3758
|
+
if (!response.result.ok) return response;
|
|
3759
|
+
const { sessionId, provider, model, reasoningEffort } = request.payload;
|
|
3760
|
+
const id = String(sessionId);
|
|
3761
|
+
const next = {
|
|
3762
|
+
provider,
|
|
3763
|
+
model,
|
|
3764
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(reasoningEffort) }
|
|
3765
|
+
};
|
|
3766
|
+
this.models.set(id, next);
|
|
3767
|
+
const link = this.links.get(id);
|
|
3768
|
+
const agent = link?.agent;
|
|
3769
|
+
if (agent !== void 0) {
|
|
3770
|
+
const selection = this.selections.get(agent);
|
|
3771
|
+
if (selection !== void 0) selection.current = next;
|
|
3772
|
+
}
|
|
3773
|
+
if (link !== void 0 && !link.parked) this.pushMetadata(link);
|
|
3774
|
+
return response;
|
|
3775
|
+
}
|
|
3776
|
+
/**
|
|
3777
|
+
* Put a concrete reasoningEffort on a phone-spawned / phone-woken agent
|
|
3778
|
+
* before the first LLM request, matching the effort Happy metadata advertises.
|
|
3779
|
+
*/
|
|
3780
|
+
async ensurePinnedEffort(agent) {
|
|
3781
|
+
const current = this.currentModel(agent);
|
|
3782
|
+
if (current === void 0) return;
|
|
3783
|
+
const preferred = this.defaultModelSelection()?.reasoningEffort;
|
|
3784
|
+
let modelDefault;
|
|
3785
|
+
let supported;
|
|
3786
|
+
try {
|
|
3787
|
+
const reasoning = (await this.ctx.get("llm")?.resolveModelInfo(current.provider, current.model))?.reasoning;
|
|
3788
|
+
if (typeof reasoning?.defaultEffort === "string" && reasoning.defaultEffort !== "") modelDefault = reasoning.defaultEffort;
|
|
3789
|
+
if (reasoning?.efforts !== void 0 && reasoning.efforts.length > 0) supported = reasoning.efforts.map((effort) => effort.id);
|
|
3790
|
+
} catch {}
|
|
3791
|
+
const effort = pinWakeEffort(current.reasoningEffort, preferred, modelDefault, supported);
|
|
3792
|
+
if (effort === void 0 || effort === current.reasoningEffort) return;
|
|
3793
|
+
const next = {
|
|
3794
|
+
...current,
|
|
3795
|
+
reasoningEffort: ReasoningEffortId(effort)
|
|
3796
|
+
};
|
|
3797
|
+
this.models.set(agent.id, next);
|
|
3798
|
+
const selection = this.selections.get(agent);
|
|
3799
|
+
if (selection !== void 0) selection.current = next;
|
|
3800
|
+
}
|
|
3801
|
+
applyPermission(agent, mode, fromCommand) {
|
|
3802
|
+
const presets = this.ctx.get("permissionPresets");
|
|
3803
|
+
if (presets === void 0) return;
|
|
3804
|
+
const classified = classifyPermissionMode(mode, presets.names);
|
|
3805
|
+
if (classified.kind === "ignore" || classified.kind === "unknown") {
|
|
3806
|
+
this.links.get(agent.id)?.socket.sendText("service", `已忽略手机权限模式 "${mode}"(不是 dsh 预设)。消息仍会发出。`);
|
|
3807
|
+
return;
|
|
3808
|
+
}
|
|
3809
|
+
if (classified.preset === "danger-full-access" && !grantAtLeast(this.config.remoteGrant, "full")) {
|
|
3810
|
+
this.links.get(agent.id)?.socket.sendText("service", "改到 danger-full-access 需要远程档「完整」。");
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
if (!fromCommand && !grantAtLeast(this.config.remoteGrant, "full")) {
|
|
3814
|
+
this.links.get(agent.id)?.socket.sendText("service", "改权限预设需要远程档「完整」。");
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
try {
|
|
3818
|
+
presets.set(agent.session, classified.preset);
|
|
3819
|
+
} catch (error) {
|
|
3820
|
+
this.log(`切换权限预设失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3824
|
+
* Queue the phone text as a user followup. The App already shows the typed
|
|
3825
|
+
* bubble; do not send a second user envelope. Images ride as DSH
|
|
3826
|
+
* attachments; other files land under `happy-inbox` for Harness `read`.
|
|
3827
|
+
*/
|
|
3828
|
+
async followup(link, agent, text, files) {
|
|
3829
|
+
const blocks = [];
|
|
3830
|
+
const { encoded, extras } = splitPendingFiles(files);
|
|
3831
|
+
const store = this.ctx.get("attachments");
|
|
3832
|
+
if (encoded.length > 0 && store !== void 0) try {
|
|
3833
|
+
const refs = await admitEncodedImages(store, encoded);
|
|
3834
|
+
for (const ref of refs) blocks.push({
|
|
3835
|
+
type: "image",
|
|
3836
|
+
attachment: ref
|
|
3837
|
+
});
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
this.log(`图片准入失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3840
|
+
link.socket.sendText("service", `图片没能交给电脑:${error instanceof Error ? error.message : String(error)}`);
|
|
3841
|
+
}
|
|
3842
|
+
let prompt = text;
|
|
3843
|
+
if (extras.length > 0) prompt = inboxReadPrompt(text, await saveInboxFiles(agent.session.header.cwd ?? link.cwd, extras));
|
|
3844
|
+
if (prompt !== "") blocks.push({
|
|
3845
|
+
type: "text",
|
|
3846
|
+
text: prompt
|
|
3847
|
+
});
|
|
3848
|
+
if (blocks.length === 0) return;
|
|
3849
|
+
link.skipNextUser += 1;
|
|
3850
|
+
link.agent = agent;
|
|
3851
|
+
agent.followup(createUserMessage({
|
|
3852
|
+
content: blocks,
|
|
3853
|
+
source: { kind: "user" }
|
|
3854
|
+
}));
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* Claim every download started before this text, wait, keep the successes.
|
|
3858
|
+
* Swap-then-await so a later file event cannot join this batch.
|
|
3859
|
+
*/
|
|
3860
|
+
async drainPhoneFiles(link) {
|
|
3861
|
+
const downloads = link.pendingDownloads;
|
|
3862
|
+
link.pendingDownloads = [];
|
|
3863
|
+
if (downloads.length === 0) return [];
|
|
3864
|
+
return (await Promise.all(downloads)).filter((file) => file !== void 0);
|
|
3865
|
+
}
|
|
3866
|
+
async downloadPhoneFile(link, message) {
|
|
3867
|
+
if (this.credentials === void 0) return void 0;
|
|
3868
|
+
try {
|
|
3869
|
+
const encrypted = await downloadEncryptedAttachment(this.config.serverUrl, this.credentials.token, link.socket.happySessionId, message.ref);
|
|
3870
|
+
const key = link.blobKey ?? await deriveBlobKey(link.crypto);
|
|
3871
|
+
link.blobKey = key;
|
|
3872
|
+
const bytes = decryptBlob(encrypted, key);
|
|
3873
|
+
if (bytes === null) {
|
|
3874
|
+
link.socket.sendText("service", `无法解密附件 ${message.name}`);
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
return {
|
|
3878
|
+
name: message.name,
|
|
3879
|
+
bytes,
|
|
3880
|
+
mimeType: message.mimeType ?? ""
|
|
3881
|
+
};
|
|
3882
|
+
} catch (error) {
|
|
3883
|
+
this.log(`下载附件失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3884
|
+
link.socket.sendText("service", `下载附件失败:${message.name}`);
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
onSessionEvent(link, event) {
|
|
3889
|
+
if (!takeForwardedSeq(link, event.seq)) return;
|
|
3890
|
+
if (event.type === "session/title") {
|
|
3891
|
+
this.pushMetadata(link);
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
if (event.type === "user/message") {
|
|
3895
|
+
const text = visibleUserText(event);
|
|
3896
|
+
const images = visibleUserImages(event);
|
|
3897
|
+
if (text === "" && images.length === 0) return;
|
|
3898
|
+
if (link.skipNextUser > 0) {
|
|
3899
|
+
link.skipNextUser -= 1;
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
this.queueOutboundUser(link, text, images, event.time);
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
if (event.type === "turn/start") {
|
|
3906
|
+
link.socket.startTurn();
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
if (event.type === "turn/end") {
|
|
3910
|
+
this.flushReasoning(link, event.time);
|
|
3911
|
+
const reason = event.data.reason;
|
|
3912
|
+
if (reason.kind === "error") {
|
|
3913
|
+
const message = reason.error.message.trim();
|
|
3914
|
+
if (message !== "") link.socket.sendText("service", `电脑没生成回复:${message}`, event.time);
|
|
3915
|
+
}
|
|
3916
|
+
const status = reason.kind === "error" ? "failed" : reason.kind === "aborted" || reason.kind === "interrupted" ? "cancelled" : "completed";
|
|
3917
|
+
link.socket.endTurn(status);
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
if (event.type === "assistant/chunk") {
|
|
3921
|
+
const chunk = event.data.chunk;
|
|
3922
|
+
if (chunk.type === "reasoning-delta" && typeof chunk.text === "string" && chunk.text !== "") {
|
|
3923
|
+
link.reasoning += chunk.text;
|
|
3924
|
+
this.pulseThink(link);
|
|
3925
|
+
}
|
|
3926
|
+
if (chunk.type === "block-end" && chunk.block.type === "reasoning" && typeof chunk.block.text === "string") {
|
|
3927
|
+
link.reasoning = chunk.block.text;
|
|
3928
|
+
this.pulseThink(link);
|
|
3929
|
+
}
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3932
|
+
if (event.type === "assistant/message") {
|
|
3933
|
+
const parts = assistantParts(event.data.message.content);
|
|
3934
|
+
if (!parts.some((part) => part.kind === "thinking")) this.flushReasoning(link, event.time);
|
|
3935
|
+
else link.reasoning = "";
|
|
3936
|
+
for (const part of parts) {
|
|
3937
|
+
if (part.kind === "thinking") {
|
|
3938
|
+
this.finishThink(link, part.text, event.time);
|
|
3939
|
+
continue;
|
|
3940
|
+
}
|
|
3941
|
+
if (part.kind === "text") {
|
|
3942
|
+
link.socket.sendText("text", part.text, event.time);
|
|
3943
|
+
continue;
|
|
3944
|
+
}
|
|
3945
|
+
this.startTool(link, part.call, part.name, part.args);
|
|
3946
|
+
}
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
if (event.type === "tool/call") {
|
|
3950
|
+
let args = {};
|
|
3951
|
+
try {
|
|
3952
|
+
args = JSON.parse(event.data.arguments);
|
|
3953
|
+
} catch {
|
|
3954
|
+
args = { raw: event.data.arguments };
|
|
3955
|
+
}
|
|
3956
|
+
this.startTool(link, event.data.callId, event.data.name, args);
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
if (event.type === "tool/result") link.socket.sendToolEnd(event.data.message.source.callId);
|
|
3960
|
+
}
|
|
3961
|
+
async onApproval(req, next) {
|
|
3962
|
+
const link = this.links.get(req.agent.id);
|
|
3963
|
+
if (link === void 0 || req.callId === void 0) return next();
|
|
3964
|
+
if (link.alwaysAllow.has(req.toolName)) return "allowed-once";
|
|
3965
|
+
if (!grantAtLeast(this.config.remoteGrant, "approve")) return next();
|
|
3966
|
+
const id = req.callId;
|
|
3967
|
+
this.startTool(link, id, req.toolName, req.reason === void 0 ? {} : { reason: req.reason });
|
|
3968
|
+
const phone = new Promise((resolve) => {
|
|
3969
|
+
link.pendingHuman = {
|
|
3970
|
+
kind: "approval",
|
|
3971
|
+
id,
|
|
3972
|
+
toolName: req.toolName,
|
|
3973
|
+
resolve
|
|
3974
|
+
};
|
|
3975
|
+
});
|
|
3976
|
+
this.pushRequests(link);
|
|
3977
|
+
const winner = await Promise.race([phone.then((outcome) => ({
|
|
3978
|
+
src: "phone",
|
|
3979
|
+
outcome
|
|
3980
|
+
})), next().then((outcome) => ({
|
|
3981
|
+
src: "web",
|
|
3982
|
+
outcome
|
|
3983
|
+
}))]);
|
|
3984
|
+
if (winner.src === "web" && link.pendingHuman?.kind === "approval" && link.pendingHuman.id === id) {
|
|
3985
|
+
delete link.pendingHuman;
|
|
3986
|
+
this.clearRequest(link, id, winner.outcome === "allowed-once" ? "approved" : "canceled");
|
|
3987
|
+
}
|
|
3988
|
+
return winner.outcome;
|
|
3989
|
+
}
|
|
3990
|
+
onAsk(questions, original, request) {
|
|
3991
|
+
const agent = request.agent;
|
|
3992
|
+
const link = agent === void 0 ? void 0 : this.links.get(agent.id);
|
|
3993
|
+
if (link === void 0 || !grantAtLeast(this.config.remoteGrant, "approve")) return original.call(questions, request);
|
|
3994
|
+
const first = request.questions[0];
|
|
3995
|
+
const controller = new AbortController();
|
|
3996
|
+
const combined = request.signal === void 0 ? controller.signal : AbortSignal.any([request.signal, controller.signal]);
|
|
3997
|
+
const phone = new Promise((resolve, reject) => {
|
|
3998
|
+
if (first?.intent?.kind === "plan-review") {
|
|
3999
|
+
const id = `plan-${createLocalId()}`;
|
|
4000
|
+
const plan = first.detail ?? first.question;
|
|
4001
|
+
const decline = planReviewDeclineLabel(first);
|
|
4002
|
+
link.pendingHuman = {
|
|
4003
|
+
kind: "plan-review",
|
|
4004
|
+
id,
|
|
4005
|
+
resolve,
|
|
4006
|
+
reject,
|
|
4007
|
+
approveLabel: first.intent.approve,
|
|
4008
|
+
declineLabel: decline,
|
|
4009
|
+
questionId: first.id
|
|
4010
|
+
};
|
|
4011
|
+
link.socket.sendToolStart(id, "exit_plan_mode", { plan }, "审阅计划", "审阅计划");
|
|
4012
|
+
this.pushRequests(link);
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
const id = `ask-${createLocalId()}`;
|
|
4016
|
+
link.pendingHuman = {
|
|
4017
|
+
kind: "ask",
|
|
4018
|
+
id,
|
|
4019
|
+
resolve,
|
|
4020
|
+
reject,
|
|
4021
|
+
questions: request.questions.map((question) => ({
|
|
4022
|
+
id: question.id,
|
|
4023
|
+
question: question.question
|
|
4024
|
+
}))
|
|
4025
|
+
};
|
|
4026
|
+
link.socket.sendToolStart(id, "AskUserQuestion", { questions: request.questions.map((question) => ({
|
|
4027
|
+
question: question.question,
|
|
4028
|
+
header: question.header ?? question.question.slice(0, 24),
|
|
4029
|
+
options: (question.options ?? []).map((option) => ({
|
|
4030
|
+
label: option.label,
|
|
4031
|
+
description: option.description ?? ""
|
|
4032
|
+
})),
|
|
4033
|
+
multiSelect: question.multiSelect === true
|
|
4034
|
+
})) }, "需要你回答", "需要你回答");
|
|
4035
|
+
this.pushRequests(link);
|
|
4036
|
+
});
|
|
4037
|
+
const web = original.call(questions, {
|
|
4038
|
+
...request,
|
|
4039
|
+
signal: combined
|
|
4040
|
+
});
|
|
4041
|
+
return Promise.race([phone.then((answer) => {
|
|
4042
|
+
controller.abort();
|
|
4043
|
+
return answer;
|
|
4044
|
+
}, (error) => {
|
|
4045
|
+
controller.abort();
|
|
4046
|
+
throw error;
|
|
4047
|
+
}), web.then((answer) => {
|
|
4048
|
+
if (link.pendingHuman !== void 0) {
|
|
4049
|
+
this.clearRequest(link, link.pendingHuman.id, "canceled");
|
|
4050
|
+
link.socket.sendToolEnd(link.pendingHuman.id);
|
|
4051
|
+
delete link.pendingHuman;
|
|
4052
|
+
}
|
|
4053
|
+
return answer;
|
|
4054
|
+
})]);
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Happy App `sessionAbort` for Rig: empty params, RPC name `abort`.
|
|
4058
|
+
* Watch grant keeps the button from doing work; chat and above cancel the turn.
|
|
4059
|
+
*/
|
|
4060
|
+
onPhoneAbort(dshId) {
|
|
4061
|
+
if (!grantAtLeast(this.config.remoteGrant, "chat")) return;
|
|
4062
|
+
const link = this.links.get(dshId);
|
|
4063
|
+
const agent = link === void 0 ? void 0 : this.requireAgent(link);
|
|
4064
|
+
if (agent === void 0) return;
|
|
4065
|
+
agent.cancel({ kind: "user" });
|
|
4066
|
+
}
|
|
4067
|
+
onPermission(dshId, rpc) {
|
|
4068
|
+
const link = this.links.get(dshId);
|
|
4069
|
+
const pending = link?.pendingHuman;
|
|
4070
|
+
if (link === void 0 || pending === void 0 || pending.id !== rpc.id) return;
|
|
4071
|
+
if (pending.kind === "approval") {
|
|
4072
|
+
if (rpc.approved) {
|
|
4073
|
+
if (rpc.decision === "approved_for_session") link.alwaysAllow.add(pending.toolName);
|
|
4074
|
+
pending.resolve("allowed-once");
|
|
4075
|
+
this.clearRequest(link, rpc.id, "approved");
|
|
4076
|
+
} else {
|
|
4077
|
+
pending.resolve("rejected");
|
|
4078
|
+
this.clearRequest(link, rpc.id, "denied");
|
|
4079
|
+
}
|
|
4080
|
+
delete link.pendingHuman;
|
|
4081
|
+
return;
|
|
4082
|
+
}
|
|
4083
|
+
if (pending.kind === "plan-review") {
|
|
4084
|
+
const selected = rpc.approved ? pending.approveLabel : pending.declineLabel;
|
|
4085
|
+
pending.resolve({ answers: [{
|
|
4086
|
+
id: pending.questionId,
|
|
4087
|
+
selected: [selected]
|
|
4088
|
+
}] });
|
|
4089
|
+
this.clearRequest(link, rpc.id, rpc.approved ? "approved" : "denied");
|
|
4090
|
+
delete link.pendingHuman;
|
|
4091
|
+
link.socket.sendToolEnd(rpc.id);
|
|
4092
|
+
return;
|
|
4093
|
+
}
|
|
4094
|
+
if (!rpc.approved) {
|
|
4095
|
+
pending.reject(/* @__PURE__ */ new Error("ASK_ABORTED"));
|
|
4096
|
+
this.clearRequest(link, rpc.id, "denied");
|
|
4097
|
+
delete link.pendingHuman;
|
|
4098
|
+
link.socket.sendToolEnd(rpc.id);
|
|
4099
|
+
return;
|
|
4100
|
+
}
|
|
4101
|
+
const mapped = answersFromHappy(rpc.updatedInput?.answers, pending.questions);
|
|
4102
|
+
pending.resolve({ answers: mapped.map((row) => ({
|
|
4103
|
+
id: row.id,
|
|
4104
|
+
selected: row.selected
|
|
4105
|
+
})) });
|
|
4106
|
+
this.clearRequest(link, rpc.id, "approved");
|
|
4107
|
+
delete link.pendingHuman;
|
|
4108
|
+
link.socket.sendToolEnd(rpc.id);
|
|
4109
|
+
}
|
|
4110
|
+
pushRequests(link) {
|
|
4111
|
+
const pending = link.pendingHuman;
|
|
4112
|
+
const requests = {};
|
|
4113
|
+
if (pending !== void 0) {
|
|
4114
|
+
if (pending.kind === "approval") requests[pending.id] = {
|
|
4115
|
+
tool: pending.toolName,
|
|
4116
|
+
arguments: {},
|
|
4117
|
+
createdAt: Date.now()
|
|
4118
|
+
};
|
|
4119
|
+
else if (pending.kind === "plan-review") requests[pending.id] = {
|
|
4120
|
+
tool: "exit_plan_mode",
|
|
4121
|
+
arguments: {},
|
|
4122
|
+
createdAt: Date.now()
|
|
4123
|
+
};
|
|
4124
|
+
else requests[pending.id] = {
|
|
4125
|
+
tool: "AskUserQuestion",
|
|
4126
|
+
arguments: {},
|
|
4127
|
+
createdAt: Date.now()
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
link.socket.updateState({
|
|
4131
|
+
controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
|
|
4132
|
+
requests
|
|
4133
|
+
});
|
|
4134
|
+
}
|
|
4135
|
+
clearRequest(link, id, status) {
|
|
4136
|
+
link.socket.updateState({
|
|
4137
|
+
controlledByUser: grantAtLeast(this.config.remoteGrant, "chat"),
|
|
4138
|
+
requests: {},
|
|
4139
|
+
completedRequests: { [id]: {
|
|
4140
|
+
status,
|
|
4141
|
+
completedAt: Date.now()
|
|
4142
|
+
} }
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
async pushMetadata(link) {
|
|
4146
|
+
if (this.credentials === void 0) return;
|
|
4147
|
+
const workspace = this.workspaceFor(link.dshId, link.cwd);
|
|
4148
|
+
if (workspace === void 0) return;
|
|
4149
|
+
const events = this.linkEvents(link);
|
|
4150
|
+
const metadata = await buildSessionMetadata(this.ctx, {
|
|
4151
|
+
cwd: link.cwd,
|
|
4152
|
+
happyPath: workspace.virtualPath,
|
|
4153
|
+
events,
|
|
4154
|
+
...link.agent === void 0 ? {} : { agent: link.agent }
|
|
4155
|
+
}, this.credentials.machineId, sessionLabel(events), link.agent === void 0 ? void 0 : this.currentModel(link.agent), this.config.remoteGrant);
|
|
4156
|
+
link.socket.updateMetadata(metadata);
|
|
4157
|
+
const pick = catalogModelPick(metadata);
|
|
4158
|
+
if (pick === void 0) this.lastPublished.delete(link.dshId);
|
|
4159
|
+
else this.lastPublished.set(link.dshId, pick);
|
|
4160
|
+
}
|
|
4161
|
+
pushAllMetadata() {
|
|
4162
|
+
for (const link of this.links.values()) if (!link.parked) this.pushMetadata(link);
|
|
4163
|
+
}
|
|
4164
|
+
queueOutboundUser(link, text, images, time) {
|
|
4165
|
+
link.outboundTail = link.outboundTail.then(() => this.pushUserToPhone(link, text, images, time), () => this.pushUserToPhone(link, text, images, time));
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Upload web-side images with Happy CLI's encrypt-then-request-upload path,
|
|
4169
|
+
* then emit file events and any remaining user text.
|
|
4170
|
+
*/
|
|
4171
|
+
async pushUserToPhone(link, text, images, time) {
|
|
4172
|
+
for (const image of images) try {
|
|
4173
|
+
await this.uploadOutboundImage(link, image, time);
|
|
4174
|
+
} catch (error) {
|
|
4175
|
+
this.log(`电脑图片没能推到手机:${error instanceof Error ? error.message : String(error)}`);
|
|
4176
|
+
link.socket.sendText("service", `电脑这张图没能发到手机:${image.name ?? "image"}`, time);
|
|
4177
|
+
}
|
|
4178
|
+
if (text !== "") link.socket.sendUser(text, time);
|
|
4179
|
+
}
|
|
4180
|
+
async uploadOutboundImage(link, image, time) {
|
|
4181
|
+
const credentials = this.credentials;
|
|
4182
|
+
const store = this.ctx.get("attachments");
|
|
4183
|
+
if (credentials === void 0 || store === void 0) throw new Error("没有附件存储或 Happy 凭据");
|
|
4184
|
+
const stored = await store.readImage(image);
|
|
4185
|
+
const key = link.blobKey ?? await deriveBlobKey(link.crypto);
|
|
4186
|
+
link.blobKey = key;
|
|
4187
|
+
const encrypted = encryptBlob(stored.data, key);
|
|
4188
|
+
const name = image.name ?? `image.${extensionForMime(image.mediaType)}`;
|
|
4189
|
+
const ref = await uploadEncryptedAttachment(this.config.serverUrl, credentials.token, link.socket.happySessionId, name, encrypted);
|
|
4190
|
+
link.socket.sendFile({
|
|
4191
|
+
ref,
|
|
4192
|
+
name,
|
|
4193
|
+
size: stored.data.byteLength,
|
|
4194
|
+
mimeType: image.mediaType
|
|
4195
|
+
}, time);
|
|
4196
|
+
}
|
|
4197
|
+
async replayHistory(link) {
|
|
4198
|
+
const items = historyItems(this.linkEvents(link));
|
|
4199
|
+
if (items.length === 0) return;
|
|
4200
|
+
link.replaying = true;
|
|
4201
|
+
try {
|
|
4202
|
+
await this.emitHistory(link, items);
|
|
4203
|
+
} finally {
|
|
4204
|
+
link.replaying = false;
|
|
4205
|
+
}
|
|
4206
|
+
this.log(`已把 ${items.length} 条历史写入 Happy 会话 ${link.socket.happySessionId}`);
|
|
4207
|
+
}
|
|
4208
|
+
async emitHistory(link, items) {
|
|
4209
|
+
for (const item of items) {
|
|
4210
|
+
if (item.kind === "turn-start") {
|
|
4211
|
+
link.socket.startTurn(item.time);
|
|
4212
|
+
continue;
|
|
4213
|
+
}
|
|
4214
|
+
if (item.kind === "turn-end") {
|
|
4215
|
+
link.socket.endTurn(item.status, item.time);
|
|
4216
|
+
continue;
|
|
4217
|
+
}
|
|
4218
|
+
if (item.kind === "user") {
|
|
4219
|
+
await this.pushUserToPhone(link, item.text, item.images, item.time);
|
|
4220
|
+
continue;
|
|
4221
|
+
}
|
|
4222
|
+
if (item.kind === "assistant") {
|
|
4223
|
+
link.socket.sendText("text", item.text, item.time);
|
|
4224
|
+
continue;
|
|
4225
|
+
}
|
|
4226
|
+
if (item.kind === "tool-start") {
|
|
4227
|
+
link.socket.sendToolStart(item.call, item.name, item.args, item.title, item.description, item.time);
|
|
4228
|
+
continue;
|
|
4229
|
+
}
|
|
4230
|
+
link.socket.sendToolEnd(item.call, item.time);
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
pulseThink(link) {
|
|
4234
|
+
if (link.reasoning.trim() === "") return;
|
|
4235
|
+
const now = Date.now();
|
|
4236
|
+
const label = thinkLabel(link.reasoning);
|
|
4237
|
+
if (link.thinkCall === void 0) {
|
|
4238
|
+
link.thinkCall = createLocalId();
|
|
4239
|
+
link.thinkBodySent = false;
|
|
4240
|
+
link.thinkLastEmit = now;
|
|
4241
|
+
link.socket.sendToolStart(link.thinkCall, THINK_TOOL_NAME, { text: link.reasoning }, "Think", label);
|
|
4242
|
+
return;
|
|
4243
|
+
}
|
|
4244
|
+
if (now - link.thinkLastEmit < 200) return;
|
|
4245
|
+
link.thinkLastEmit = now;
|
|
4246
|
+
link.socket.sendToolStart(link.thinkCall, THINK_TOOL_NAME, { text: link.reasoning }, "Think", label);
|
|
4247
|
+
}
|
|
4248
|
+
flushReasoning(link, time) {
|
|
4249
|
+
const text = link.reasoning;
|
|
4250
|
+
link.reasoning = "";
|
|
4251
|
+
this.finishThink(link, text, time);
|
|
4252
|
+
}
|
|
4253
|
+
finishThink(link, text, time) {
|
|
4254
|
+
const body = text.trim();
|
|
4255
|
+
if (body === "") {
|
|
4256
|
+
if (link.thinkCall !== void 0) {
|
|
4257
|
+
link.socket.sendToolEnd(link.thinkCall, time);
|
|
4258
|
+
delete link.thinkCall;
|
|
4259
|
+
link.thinkBodySent = false;
|
|
4260
|
+
}
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
const call = link.thinkCall ?? createLocalId();
|
|
4264
|
+
const card = thinkCard(body);
|
|
4265
|
+
link.socket.sendToolStart(call, card.name, card.args, card.title, card.description, time);
|
|
4266
|
+
link.socket.sendToolEnd(call, time);
|
|
4267
|
+
delete link.thinkCall;
|
|
4268
|
+
link.thinkBodySent = false;
|
|
4269
|
+
link.thinkLastEmit = 0;
|
|
4270
|
+
}
|
|
4271
|
+
startTool(link, call, name, args) {
|
|
4272
|
+
if (call === "" || link.startedCalls.has(call)) return;
|
|
4273
|
+
link.startedCalls.add(call);
|
|
4274
|
+
const card = happyTool(name, args);
|
|
4275
|
+
link.socket.sendToolStart(call, card.name, card.args, card.title, card.description);
|
|
4276
|
+
}
|
|
4277
|
+
currentModel(agent) {
|
|
4278
|
+
const override = this.models.get(agent.id);
|
|
4279
|
+
const logged = agent.session.requestHeader()?.config;
|
|
4280
|
+
const resolved = wakeModelSelection(override, logged === void 0 ? void 0 : {
|
|
4281
|
+
provider: logged.provider,
|
|
4282
|
+
model: logged.model,
|
|
4283
|
+
...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
|
|
4284
|
+
}, this.defaultModelSelection());
|
|
4285
|
+
if (resolved === void 0) return void 0;
|
|
4286
|
+
return {
|
|
4287
|
+
provider: resolved.provider,
|
|
4288
|
+
model: resolved.model,
|
|
4289
|
+
...resolved.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(resolved.reasoningEffort) }
|
|
4290
|
+
};
|
|
4291
|
+
}
|
|
4292
|
+
};
|
|
4293
|
+
function lastEventSeq(events) {
|
|
4294
|
+
let max = -1;
|
|
4295
|
+
for (const event of events) if (typeof event.seq === "number" && event.seq > max) max = event.seq;
|
|
4296
|
+
return max;
|
|
4297
|
+
}
|
|
4298
|
+
function takeForwardedSeq(link, seq) {
|
|
4299
|
+
if (seq <= link.lastForwardedSeq) return false;
|
|
4300
|
+
link.lastForwardedSeq = seq;
|
|
4301
|
+
return true;
|
|
4302
|
+
}
|
|
4303
|
+
function withTimeout(promise, ms, message) {
|
|
4304
|
+
return new Promise((resolve, reject) => {
|
|
4305
|
+
const timer = setTimeout(() => {
|
|
4306
|
+
reject(new Error(message));
|
|
4307
|
+
}, ms);
|
|
4308
|
+
promise.then((value) => {
|
|
4309
|
+
clearTimeout(timer);
|
|
4310
|
+
resolve(value);
|
|
4311
|
+
}, (error) => {
|
|
4312
|
+
clearTimeout(timer);
|
|
4313
|
+
reject(error);
|
|
4314
|
+
});
|
|
4315
|
+
});
|
|
4316
|
+
}
|
|
4317
|
+
function createLocalId() {
|
|
4318
|
+
return crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
4319
|
+
}
|
|
4320
|
+
function extensionForMime(mediaType) {
|
|
4321
|
+
if (mediaType === "image/jpeg") return "jpg";
|
|
4322
|
+
if (mediaType === "image/gif") return "gif";
|
|
4323
|
+
if (mediaType === "image/webp") return "webp";
|
|
4324
|
+
return "png";
|
|
4325
|
+
}
|
|
4326
|
+
//#endregion
|
|
4327
|
+
//#region lib/types/remote.js
|
|
4328
|
+
/** Typert Remote for the settings card: pairing status, start, disconnect. */
|
|
4329
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
4330
|
+
var useValue = arguments.length > 2;
|
|
4331
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
4332
|
+
return useValue ? value : void 0;
|
|
4333
|
+
};
|
|
4334
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
4335
|
+
function accept(f) {
|
|
4336
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
4337
|
+
return f;
|
|
4338
|
+
}
|
|
4339
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
4340
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
4341
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
4342
|
+
var _, done = false;
|
|
4343
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
4344
|
+
var context = {};
|
|
4345
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
4346
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
4347
|
+
context.addInitializer = function(f) {
|
|
4348
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
4349
|
+
extraInitializers.push(accept(f || null));
|
|
4350
|
+
};
|
|
4351
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
4352
|
+
get: descriptor.get,
|
|
4353
|
+
set: descriptor.set
|
|
4354
|
+
} : descriptor[key], context);
|
|
4355
|
+
if (kind === "accessor") {
|
|
4356
|
+
if (result === void 0) continue;
|
|
4357
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
4358
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
4359
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
4360
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
4361
|
+
} else if (_ = accept(result)) {
|
|
4362
|
+
if (kind === "field") initializers.unshift(_);
|
|
4363
|
+
else descriptor[key] = _;
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
4367
|
+
done = true;
|
|
4368
|
+
};
|
|
4369
|
+
/**
|
|
4370
|
+
* Host RPC the browser settings card calls.
|
|
4371
|
+
*/
|
|
4372
|
+
let HappyBridgeService = (() => {
|
|
4373
|
+
let _classSuper = TypertRemoteService;
|
|
4374
|
+
let _instanceExtraInitializers = [];
|
|
4375
|
+
let _getStatus_decorators;
|
|
4376
|
+
let _startPairing_decorators;
|
|
4377
|
+
let _disconnect_decorators;
|
|
4378
|
+
let _rePair_decorators;
|
|
4379
|
+
return class HappyBridgeService extends _classSuper {
|
|
4380
|
+
static {
|
|
4381
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
4382
|
+
_getStatus_decorators = [Remote("getStatus")];
|
|
4383
|
+
_startPairing_decorators = [Remote("startPairing")];
|
|
4384
|
+
_disconnect_decorators = [Remote("disconnect")];
|
|
4385
|
+
_rePair_decorators = [Remote("rePair")];
|
|
4386
|
+
__esDecorate(this, null, _getStatus_decorators, {
|
|
4387
|
+
kind: "method",
|
|
4388
|
+
name: "getStatus",
|
|
4389
|
+
static: false,
|
|
4390
|
+
private: false,
|
|
4391
|
+
access: {
|
|
4392
|
+
has: (obj) => "getStatus" in obj,
|
|
4393
|
+
get: (obj) => obj.getStatus
|
|
4394
|
+
},
|
|
4395
|
+
metadata: _metadata
|
|
4396
|
+
}, null, _instanceExtraInitializers);
|
|
4397
|
+
__esDecorate(this, null, _startPairing_decorators, {
|
|
4398
|
+
kind: "method",
|
|
4399
|
+
name: "startPairing",
|
|
4400
|
+
static: false,
|
|
4401
|
+
private: false,
|
|
4402
|
+
access: {
|
|
4403
|
+
has: (obj) => "startPairing" in obj,
|
|
4404
|
+
get: (obj) => obj.startPairing
|
|
4405
|
+
},
|
|
4406
|
+
metadata: _metadata
|
|
4407
|
+
}, null, _instanceExtraInitializers);
|
|
4408
|
+
__esDecorate(this, null, _disconnect_decorators, {
|
|
4409
|
+
kind: "method",
|
|
4410
|
+
name: "disconnect",
|
|
4411
|
+
static: false,
|
|
4412
|
+
private: false,
|
|
4413
|
+
access: {
|
|
4414
|
+
has: (obj) => "disconnect" in obj,
|
|
4415
|
+
get: (obj) => obj.disconnect
|
|
4416
|
+
},
|
|
4417
|
+
metadata: _metadata
|
|
4418
|
+
}, null, _instanceExtraInitializers);
|
|
4419
|
+
__esDecorate(this, null, _rePair_decorators, {
|
|
4420
|
+
kind: "method",
|
|
4421
|
+
name: "rePair",
|
|
4422
|
+
static: false,
|
|
4423
|
+
private: false,
|
|
4424
|
+
access: {
|
|
4425
|
+
has: (obj) => "rePair" in obj,
|
|
4426
|
+
get: (obj) => obj.rePair
|
|
4427
|
+
},
|
|
4428
|
+
metadata: _metadata
|
|
4429
|
+
}, null, _instanceExtraInitializers);
|
|
4430
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
4431
|
+
enumerable: true,
|
|
4432
|
+
configurable: true,
|
|
4433
|
+
writable: true,
|
|
4434
|
+
value: _metadata
|
|
4435
|
+
});
|
|
4436
|
+
}
|
|
4437
|
+
/** Live bridge; swapped when settings rebuild. */
|
|
4438
|
+
live = __runInitializers(this, _instanceExtraInitializers);
|
|
4439
|
+
/**
|
|
4440
|
+
* @param ctx - Host context.
|
|
4441
|
+
*/
|
|
4442
|
+
constructor(ctx) {
|
|
4443
|
+
super(ctx, "happyBridge");
|
|
4444
|
+
}
|
|
4445
|
+
/**
|
|
4446
|
+
* Current pairing / connection snapshot, including a QR data URL while pairing.
|
|
4447
|
+
* @returns status for the settings card.
|
|
4448
|
+
*/
|
|
4449
|
+
async getStatus() {
|
|
4450
|
+
if (this.live === void 0) return {
|
|
4451
|
+
paired: false,
|
|
4452
|
+
pairing: false,
|
|
4453
|
+
serverUrl: ""
|
|
4454
|
+
};
|
|
4455
|
+
return this.live.status();
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* Start or resume pairing.
|
|
4459
|
+
*/
|
|
4460
|
+
async startPairing() {
|
|
4461
|
+
await this.live?.beginPairing();
|
|
4462
|
+
return this.getStatus();
|
|
4463
|
+
}
|
|
4464
|
+
/**
|
|
4465
|
+
* Disconnect Happy. The web UI keeps running.
|
|
4466
|
+
*/
|
|
4467
|
+
async disconnect() {
|
|
4468
|
+
await this.live?.disconnect();
|
|
4469
|
+
return this.getStatus();
|
|
4470
|
+
}
|
|
4471
|
+
/**
|
|
4472
|
+
* Drop the current login and start a fresh QR pairing.
|
|
4473
|
+
*/
|
|
4474
|
+
async rePair() {
|
|
4475
|
+
await this.live?.rePair();
|
|
4476
|
+
return this.getStatus();
|
|
4477
|
+
}
|
|
4478
|
+
};
|
|
4479
|
+
})();
|
|
4480
|
+
//#endregion
|
|
4481
|
+
//#region lib/types/index.js
|
|
4482
|
+
/**
|
|
4483
|
+
* Host half of Happy remote control: pair a running dsh web/desktop client
|
|
4484
|
+
* with Happy App so the phone drives the same harness sessions.
|
|
4485
|
+
* @module @sjhmars/happy-bridge
|
|
4486
|
+
*/
|
|
4487
|
+
/** Cordis plugin name. */
|
|
4488
|
+
const name = "happy-bridge";
|
|
4489
|
+
/** Settings namespace keyed by the Plugins tab card. */
|
|
4490
|
+
const HAPPY_BRIDGE_NS = settingsNamespace("happy-bridge");
|
|
4491
|
+
/** Wait for the agent registry before mirroring sessions. */
|
|
4492
|
+
const inject = ["agents"];
|
|
4493
|
+
/** Validated plugin config. Illegal values fail at load. */
|
|
4494
|
+
const Config = Schema.object({
|
|
4495
|
+
enabled: Schema.boolean().default(true),
|
|
4496
|
+
serverUrl: Schema.string().default("https://api.cluster-fluster.com"),
|
|
4497
|
+
appUrl: Schema.string().default("https://app.happy.engineering"),
|
|
4498
|
+
credentialDir: Schema.string().default(""),
|
|
4499
|
+
pairOnStart: Schema.boolean().default(true),
|
|
4500
|
+
remoteGrant: Schema.union([
|
|
4501
|
+
Schema.const("watch"),
|
|
4502
|
+
Schema.const("chat"),
|
|
4503
|
+
Schema.const("approve"),
|
|
4504
|
+
Schema.const("full")
|
|
4505
|
+
]).default("approve")
|
|
4506
|
+
});
|
|
4507
|
+
/**
|
|
4508
|
+
* Mount the Host half: settings namespace, Typert Remote, Happy relay.
|
|
4509
|
+
* @param ctx - Host context.
|
|
4510
|
+
* @param config - composition entry config.
|
|
4511
|
+
*/
|
|
4512
|
+
function apply(ctx, config) {
|
|
4513
|
+
const service = new HappyBridgeService(ctx);
|
|
4514
|
+
let source = () => config;
|
|
4515
|
+
const rebuild = () => {
|
|
4516
|
+
service.live?.dispose();
|
|
4517
|
+
const current = source();
|
|
4518
|
+
if (!current.enabled) {
|
|
4519
|
+
service.live = void 0;
|
|
4520
|
+
ctx.logger.info("happy-bridge: 已关闭");
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
const bridge = new HappyBridge(ctx, current, (message) => ctx.logger.info(`happy-bridge: ${message}`));
|
|
4524
|
+
service.live = bridge;
|
|
4525
|
+
bridge.start().catch((error) => {
|
|
4526
|
+
ctx.logger.warn(`happy-bridge: 启动失败 ${error instanceof Error ? error.message : String(error)}`);
|
|
4527
|
+
});
|
|
4528
|
+
};
|
|
4529
|
+
installSettingsSection(ctx, HAPPY_BRIDGE_NS, Config, config, {
|
|
4530
|
+
setSource: (current) => {
|
|
4531
|
+
source = current;
|
|
4532
|
+
},
|
|
4533
|
+
onChange: () => {
|
|
4534
|
+
const current = source();
|
|
4535
|
+
if (service.live?.acceptSettings(current) === true) return;
|
|
4536
|
+
rebuild();
|
|
4537
|
+
}
|
|
4538
|
+
});
|
|
4539
|
+
ctx.effect(() => {
|
|
4540
|
+
rebuild();
|
|
4541
|
+
return () => {
|
|
4542
|
+
service.live?.dispose();
|
|
4543
|
+
service.live = void 0;
|
|
4544
|
+
};
|
|
4545
|
+
}, "happy-bridge: runtime");
|
|
4546
|
+
}
|
|
4547
|
+
//#endregion
|
|
4548
|
+
export { Config, HAPPY_BRIDGE_NS, apply, inject, name };
|